Skip to content

Make the renderer ~1.3-1.5x faster: measure first, then remove work nothing consumes - #121

Merged
doubleailes merged 5 commits into
mainfrom
optimization_part_3
Jul 29, 2026
Merged

Make the renderer ~1.3-1.5x faster: measure first, then remove work nothing consumes#121
doubleailes merged 5 commits into
mainfrom
optimization_part_3

Conversation

@doubleailes

Copy link
Copy Markdown
Owner

The renderer was slow in a way the existing profiler could see but not explain: samples/cornellbox.usda1 582 triangles — managed 39.5 Mray/s across 48 threads, about 2 600 cycles per ray query, on a scene whose BVH should traverse in a few hundred.

An instruction-level profile (callgrind) said the time was not going where anyone had assumed. Four of the five largest costs were work that produced nothing.

Result

Interleaved A/B against the pristine baseline:

scene before after Δ throughput
veach_mis 4.060 s 2.770 s -31.8% 56.9 → 89.3 Mray/s
cornellbox 1.382 s 1.021 s -26.1% 39.5 → 55.5 Mray/s
Kitchen_set 3.624 s 2.847 s -21.4%
instancing 0.341 s 0.286 s -16.1%
nested_instancing 0.347 s 0.293 s -15.6%
openpbr_showcase 0.682 s 0.579 s -15.1%
Kitchen_set_instanced 3.468 s 3.199 s -7.8%

Instructions on cornellbox -s 2: 4.050 G → 2.906 G (-28.2%).

What was actually costing

  • Every UsdGeomMesh was wrapped in an instance, shared or not, so a 5-mesh Cornell box did 3.85 instance descents per camera ray — each a ray transform, a fresh slab/shear setup and a cold descent into a second tree — and the top-level BVH never saw a triangle, only boxes-of-boxes that spatial splits cannot tighten. Now 0.13/ray. This is the same pathology the previous commit found on the Moana island, and it confirms that commit's conclusion that the fix belonged in the importer rather than the kernel.
  • The 736-byte traversal stack was zeroed on every query, including every instance descent: 180 M of 187 M total memset instructions, written before read.
  • A full 4-D Sobol block was drawn per camera ray for a shutter time nothing reads on a static scene (4.2% of the render). ray.time is consumed only by a moving instance.
  • One malloc/free pair per camera sample for the path's vertex buffer (4.4%).
  • Coat and sheen lobes evaluated at zero weight, including the only unconditional powf in shading. eval_all -32%.

Correctness

219 tests pass (8 new), SIMD matrix passes under all four codegens, no new clippy warnings, each commit compiles standalone.

Commits 1-3 are bit-identical on all 14 sample scenes, and so is commit 4 under CRUST_MESH_BAKE=0 — which is what separates a deferral bug from a baking difference.

Baking does change output, because a different top-level BVH orders exact ties differently. That it is noise and not bias is checked, not asserted — on veach_mis the mean absolute difference falls 2.96e-4 → 4.83e-5 → 2.61e-5 at 16 / 1024 / 4096 spp, i.e. 1/√N.

The mirrored-transform case (det < 0) needs a winding swap or front_face inverts and mirrored prims render inside-out, flipping which side of a refractive interface a ray thinks it is on. No sample scene has a mirror, so it is pinned by unit tests comparing baked against instanced hits directly; both fail if the swap is removed.

Trade-off you should know about

Resident memory is neutral (Kitchen_set kernel memory 134.59 → 134.44 MiB), but only after fixing a second bug this surfaced: commit() grew the primitive array by repeated doubling, costing +28 MiB of genuinely committed capacity once meshes were baked into it.

What is not fixed is the transient build peak: 374 → 579 MiB on Kitchen_set, because one SBVH build over ~600 K references holds a few hundred MiB where 1 788 small builds held almost nothing (PrimRef and the binary Node are 48 bytes each, and merge concatenates child arrays while both children live). Pre-existing builder behaviour this change exercises rather than introduces. Documented in CLAUDE.md; the lever, if that peak ever outweighs the ~20% render win, is a triangle-count cap on baking.

Also here

  • A measurement harness, because the naive method is actively wrong here. Sequential comparison reported a 12% regression for a change that interleaved A/B showed to be a 4-5% improvement — background load, not code. scripts/bench_ab.sh alternates two binaries within the same seconds; check_images.sh gates correctness at 16 spp (above it, adaptive sampling turns a 1-ulp difference into a different sample budget and a bit-identical change looks structural). CLAUDE.md gains a "Measuring a change" section.
  • Codegen: lto = "fat" (-1.5% instructions, bit-identical), panic = "abort" (binary 5.26 → 4.36 MiB), and a commented-out x86-64-v3 opt-in rather than a non-distributable target-cpu=native.
  • A latent variance bug recorded, not fixed: LobePmf::from_params floors absent lobe weights at 1e-6, so a material with coat_weight == 0 still selects the coat lobe one sample in a million and inflates the mixture density every specular sample divides by (default coat_roughness of 0 floors its α at 1e-4, so pdf_coat reaches ~3e7 near the mirror direction). Unbiased, but it wastes samples and darkens near-mirror specular. Fixing it changes the image, so it wants a converged A/B of its own.

Deliberately deferred

Two planned changes were dropped on evidence: hoisting OpenPBR's per-material derived values predicted ~1% for a refactor across ~10 functions and ~20 test sites, and the light-list indices measure ~0 on these scenes (cornellbox has no light-list lights at all). Both remain worth doing — just not ahead of the traversal work.

docs/simd.md now notes that BVH8/node compression should be re-measured before starting: the counters previously pointed at node visits, but most of those visits should never have happened.

🤖 Generated with Claude Code

doubleailes and others added 4 commits July 29, 2026 10:11
Everything that follows is a performance change worth 1-20%, and this
repository had no way to tell such a change from machine noise. Two
properties of the setup make the obvious method wrong:

  - Sequential comparison lies. Timing a change before and after, minutes
    apart, reported a 12% *regression* for a change that bench_ab.sh then
    showed to be a 4-5% improvement. The difference was background load.
    bench_ab.sh alternates the two binaries within the same seconds so load
    lands on both, and reports min and mean.

  - The run-to-run spread reaches 15%, so a 1-5% step cannot be resolved by
    timing at all. Those get counted in instructions via callgrind, which is
    deterministic; the recipe is in bench_scenes.sh's header.

check_images.sh is the correctness gate: golden EXRs for every sample scene,
re-rendered and diffed. It renders at 16 spp deliberately.
min_samples_per_pixel defaults to 32 and the adaptive early-stop needs
taken >= min_spp, so at 16 every pixel takes exactly 16 samples; above it a
single-ulp difference changes a pixel's sample budget and cascades, which
would make a bit-identical change look structural.

Codegen, measured with the above:

  - lto = "fat" over "thin": 3.989G instructions against 4.050G on cornellbox
    -s 2 (-1.5%) for link time only, and the image stays bit-identical. Rust
    does not enable fast-math and LLVM will not reassociate float arithmetic
    without it, so whole-program inlining moves no bits.
  - panic = "abort": no catch_unwind and no should_panic anywhere in the
    workspace, and crust-core's "return Error, never exit" contract is about
    recoverable errors, which are Result values. Removes the landing pads
    around the bounce loop's expect()s; the binary drops 5.26 -> 4.36 MiB,
    which callgrind cannot see because landing pads are not executed.
  - No debug info. Callgrind names functions from the symbol table, which is
    how every measurement here was taken; line tables would quadruple the
    binary for detail nothing used. Ask per-invocation instead.
  - No active target-cpu. .cargo/config.toml ships x86-64-v3 commented out:
    reproducible across machines in a way `native` is not, and exactly the
    AVX2+FMA level docs/simd.md already measured at 2-4%.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Bvh::hit and Bvh::hit_any each declared `[0u32; 3 * MAX_DEPTH + 4]` as their
traversal stack. The bound is genuinely worst-case — a wide node can advance
the binary depth by one level and leaves 3 deferred lanes behind — but the
array is 736 bytes and Rust zeroes it on entry, on every query, including
every instance descent, which re-enters hit recursively.

Callgrind attributed 180M of cornellbox's 187M memset instructions to exactly
those two declarations: 4.8% of the render, and 5.4% on veach_mis, all of it
clearing memory that is written before it is read.

Replaced by a small inline array plus a Vec that stays unallocated unless a
tree is deep enough to overflow it. The spill is the point: it makes
correctness unconditional, so STACK_INLINE becomes a tuning knob and the
"prove 3*MAX_DEPTH+4 is always enough" argument goes away rather than being
replaced by a smaller, shakier one. Threading a scratch buffer through the
query API instead was rejected: the leaf call makes traversal re-entrant, so
it would have to pass &mut through Prim::hit, every PrimNode arm, the public
Scene::intersect/occluded and every crust-core call site — and a thread-local
RefCell is not an escape, because the borrow is live across the leaf call and
the nested traversal would panic re-borrowing it.

STACK_INLINE = 32 is sized from measurement, not from the old bound. The
traversal-stats build now reports the deepest stack any query reached, and it
grows logarithmically as a good tree should:

    primitives in one tree    deepest stack
    1 024                          6
    16 384                         8
    262 144                       11
    1 048 576                     12

Instanced and nested layouts stay at 9-10, each level being its own tree with
its own stack. So 32 is ~2.7x the deepest figure seen on a million-primitive
tree. Deliberately not tightened to hug that: overflowing costs a heap
allocation per traversal, which would be worse than the memset this removes.
Shrinking to 16 measured ~0.3% — not worth moving that cliff closer.

memset falls from 186.8M instructions to 6.8M (the remainder is EXR/PNG output
and USD parsing, which was always there); total falls 2.7%, and wall clock
3-6% on cornellbox, veach_mis and instancing. Output is bit-identical on all
14 sample scenes and the SIMD matrix is unchanged. The stack type is unit
tested directly — push/pop across the spill boundary, and interleaved — rather
than by trying to build a tree deep enough to reach it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
eval_all evaluated diffuse, specular, coat and fuzz unconditionally and applied
each lobe's weight only as a final multiply. With the default material —
coat_weight 0, fuzz_weight 0, base_metalness 0 — that meant computing, per
call, a full anisotropic GGX D + Smith G2 with two sqrts for a coat that
contributes nothing, the entire F82 metal Fresnel chain for a surface that is
not metal, and sheen_charlie, which holds the only unconditional powf on the
default shading path.

Guards added in eval_all (coat, fuzz), eval_specular (the two ends of the
metalness blend) and eval_diffuse (the presence weights).

Bit-identical, and that is a proof rather than a hope: every skipped lobe ends
in a multiply by its weight, and every one is finite for all inputs, because
every GGX denominator is floored (roughness_to_alpha_aniso clamps alpha >= 1e-4,
the cosines at 1e-4, so D <= ~3e23) and sheen_charlie is bounded because
sheen_charlie_d clamps alpha >= 0.05, keeping its powf argument in [0,1].
fresnel_f82_tint clamps to [0,1]. A finite value times 0.0 is exactly +0.0, and
+0.0 + x == x. The one way it could have differed is a lobe overflowing to inf,
where inf * 0.0 = NaN today and 0.0 after — unreachable given the clamps, and
stated in the code rather than left implied.

eval_all falls from 367M instructions to 250M (-32%), the shading powf
disappears (libm 85.1M -> 40.3M, the remainder being tone mapping in main), and
total falls 4.6% — 6-8% wall clock on cornellbox, veach_mis and
openpbr_showcase. Verified against openpbr_showcase in particular, which is the
only sample scene authoring non-zero coat, fuzz, thin film, metalness and
dispersion, i.e. the only one that exercises the guards' taken branches.

Two notes recorded in passing, neither acted on here:

  - pdf_all's coat term is *not* skippable the way eval_coat is.
    LobePmf::from_params floors an absent lobe's selection weight at 1e-6, so
    p_coat is nonzero and the term genuinely belongs in the density sampling
    divides by. Deduplicating pdf_diffuse and pdf_fuzz, which are the same
    expression, measured exactly nothing — LLVM had already done it.

  - Those floors are a latent variance bug, documented at from_params. At
    coat_weight == 0 the coat lobe still holds ~1e-6 of the selection mass, and
    because the default coat_roughness of 0 floors its alpha at 1e-4, pdf_coat
    reaches ~3e7 near the mirror direction. So a zero-energy lobe is picked one
    sample in a million and inflates the mixture density every specular sample
    divides by. Unbiased, but it wastes samples and slightly darkens
    near-mirror specular. Fixing it changes the image, so it wants a converged
    A/B of its own rather than being folded in here.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Three changes to stop the integrator and importer doing work nothing consumes.
They share one file each way, so they land together.

1. Bake single-placement meshes flat, instead of instancing everything.

emit_mesh wrapped every UsdGeomMesh in a Geometry::Instance around its own
single-mesh scene, whether or not that geometry was shared; flat world-space
triangles were only the fallback for a non-invertible transform. So a five-mesh
Cornell box performed 3.85 instance descents per camera ray, each one a ray
transform, a fresh slab and shear setup, and a cold descent into a second tree
— and the top-level BVH never saw a triangle, only loose boxes-of-boxes that
spatial splits cannot tighten, since InstancePrim does not override
clipped_aabb. Instancing something placed once buys no sharing to amortise any
of that against.

The decision is deferred, because a placement count is only final once the
whole stage is walked: emit_mesh interns the triangles by content and reserves
a geom_id, and flush_meshes fills the slots in afterwards. Reserving matters —
geom_ids are handed out in traversal order and index the material table, and
lights take theirs from the same counter, so deciding late must not renumber
them. Hence SceneBuilder::set_geometry and WorldBuilder::reserve_slot.

Deciding globally rather than per chunk is load-bearing twice over: cornellbox
streams, so per-chunk every mesh would look like a singleton and results would
differ under CRUST_STREAM_IMPORT=0; and geometry referenced from two elements
of a production stage would get one resident copy per element, a regression in
exactly the case sharing exists for. Genuine instancing is untouched —
collect_proto_parts commits a prototype's scene as before, which is also what
marks that mesh ineligible for baking, so a mesh used both directly and as a
prototype is not stored twice.

Baking a mirrored (det < 0) placement swaps two indices. The instanced path
derives its normal inside the prototype and maps it out through the inverse
transpose; baking derives it from world-space vertices, and those disagree in
sign, so without the swap front_face inverts and every mirrored prim renders
inside-out — flipping which side of a refractive interface a ray thinks it is
on. No sample scene has a mirror, so this is pinned by unit tests that compare
baked against instanced hits directly; both fail if the swap is removed.

2. Do not sample the shutter when nothing moves.

draw_sample_f32::<N> computes a whole 4-dimensional Owen-scrambled Sobol block
whatever N is, and the integrator drew one per camera ray for a single float —
4.2% of the render on a scene with no motion blur. ray.time is read by exactly
one thing, InstancePrim::transforms_at, which ignores it when the instance is
static, so on such a scene every value produces the same image. Scene now
carries a has_motion flag folded in at commit (propagating out of nested
instances, which is the one way to get this wrong and silently drop blur — hence
a test for it), and the draw is skipped when it is false. Not calling
new_domain cannot perturb the other dimensions: it is a pure function of the
parent state taken by &self.

3. Reuse the path's vertex buffer.

trace_path allocated a Vec<VertexRec> per camera sample: one malloc/free pair
per sample, 4.4% of the render. Now borrowed from a PathScratch owned per work
unit — the same ownership story as RayStats, private to one thread by
construction. The allocator disappears from the profile entirely.

Also fixed while measuring this: SceneBuilder::commit grew the primitive array
by repeated doubling, which was invisible until baking pushed hundreds of
thousands of triangles through it and left +28 MiB of genuinely committed
capacity (MemoryFootprint counts capacity, not length, which is why it showed
up). Sized exactly once from the geometries now.

Measured, pristine baseline to here, interleaved:

    veach_mis          4.060s -> 2.770s   -31.8%   56.9 -> 89.3 Mray/s
    cornellbox         1.382s -> 1.021s   -26.1%   39.5 -> 55.5 Mray/s
    Kitchen_set        3.624s -> 2.847s   -21.4%
    instancing         0.341s -> 0.286s   -16.1%
    nested_instancing  0.347s -> 0.293s   -15.6%
    openpbr_showcase   0.682s -> 0.579s   -15.1%

Instructions on cornellbox -s 2: 4.050G -> 2.906G, -28.2%. Instance descents
per camera ray: 3.85 -> 0.13. Total node visits actually rise slightly
(6.63 -> 7.59, one deeper tree) — what was costing was the descents, not the
nodes.

Items 2 and 3 are bit-identical on all 14 sample scenes, and so is item 1 under
CRUST_MESH_BAKE=0, which is what separates a deferral bug from a baking
difference. Baking itself does change output, because a different top-level BVH
orders exact ties differently. That it is noise and not bias is checked by
convergence, not asserted: on veach_mis the mean absolute difference falls
2.96e-4 -> 4.83e-5 -> 2.61e-5 at 16, 1024 and 4096 spp, i.e. 1/sqrt(N).

Known trade-off, documented in CLAUDE.md: resident memory is neutral
(Kitchen_set kernel memory 134.59 -> 134.44 MiB) but the *transient* build peak
is higher, 374 -> 579 MiB, because one SBVH build over ~600K references holds a
few hundred MiB where 1788 small builds held almost nothing. PrimRef and the
binary Node are 48 bytes each and merge concatenates child arrays while both
children live. That is pre-existing builder behaviour this change exercises
rather than introduces; the lever, if that peak ever outweighs the ~20% render
win, is a triangle-count cap on baking.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@qodo-code-review

Copy link
Copy Markdown
Contributor

Qodo reviews are paused for this user.

Troubleshooting steps vary by plan Learn more →

On a Teams plan?
Reviews resume once this user has a paid seat and their Git account is linked in Qodo.
Link Git account →

Using GitHub Enterprise Server, GitLab Self-Managed, or Bitbucket Data Center?
These require an Enterprise plan - Contact us
Contact us →

@doubleailes
doubleailes merged commit 2565225 into main Jul 29, 2026
2 checks passed
@doubleailes
doubleailes deleted the optimization_part_3 branch July 29, 2026 13:22
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant