Skip to content

BVH/mesh AABB queries: packed-leaf candidate-bound loads leave performance on the table #1840

Description

@AnkaChan

Summary

In the BVH and mesh AABB query traversal, when a packed leaf (leaf_size > 1) is enumerated, each candidate primitive's bounds are fetched lazily inside the short-circuiting intersect_aabb_aabb test. That compiles to a per-component load / test / branch dependency chain. The primitive-level reads (primitive_indices, item_lowers/item_uppers, mesh face bounds) also bypass the CUDA read-only data path (__ldg) that node loads already use.

Opportunity

Load each candidate's AABB into locals up front so the overlap test issues as a single predicate chain (the dominant effect), and route the primitive reads through the read-only path. Gated to the AABB query kind — applying the eager load to the heavier sphere/capsule narrow-phase instantiations regresses them via register pressure.

Measured up to ~21% faster on packed-leaf (leaf_size = 8) BVH AABB queries and ~5-9% on mesh AABB, on an L40 (122k-triangle scene, lbvh/cubql/sah constructors). Singleton-leaf trees (leaf_size = 1, the wp.Bvh default) and non-AABB query kinds are unaffected. Query results are bitwise-identical.

Notes

First of a short series of BVH/mesh query hot-path performance improvements.

Activity

  1. added this to the 1.18.0 milestone on Aug 24, 2026
  2. shi-eric commented on Aug 25, 2026

    @shi-eric
    Contributor

    This is addressed by fcc86dd.

    The change routes primitive indices and AABB candidate bounds through CUDA read-only loads and eagerly materializes packed-leaf candidate bounds before overlap testing. The eager-load optimization is compile-time-gated to AABB queries, so sphere and capsule paths avoid the reported register-pressure regression.

    Public API behavior

    Python scope

    • No signatures change. Existing wp.Bvh(..., leaf_size=...) and wp.Mesh(..., bvh_leaf_size=...) objects benefit without caller changes.

    Kernel scope

    • Existing wp.bvh_query_aabb() and wp.mesh_query_aabb() queries are faster for packed leaves while preserving their returned hit sets.

    Test coverage

    • No test files were changed in this commit. Existing warp/tests/geometry/test_bvh.py::test_bvh_query_aabb and warp/tests/geometry/test_mesh_query_aabb.py::test_mesh_query_aabb_count_overlap_with_checksum cover packed-leaf correctness, and asv/benchmarks/spatial_query.py::BvhAABBQuery covers leaf-size 8 performance for LBVH and cuBQL.

    Behavioral verification

    A CUDA probe with leaf_size=8 compared complete BVH and mesh hit matrices against brute-force AABB overlap for LBVH, SAH, and cuBQL; all six cases matched exactly.

    Follow-up to consider

    • Non-blocking: Extend BvhAABBQuery’s GPU constructor matrix to include SAH so the existing ASV benchmark covers all three constructors named in this issue.
  3. shi-eric commented on Sep 20, 2026

    @shi-eric
    Contributor

    Additional testing of fcc86dd on Jetson AGX Thor found an architecture-dependent mesh-ray regression in the read-only load conversion. The packed-leaf AABB improvement described in this issue still reproduces, so a full revert of the commit is not recommended.

    The isolated AABB results on Thor vary with leaf size:

    Query Parent Commit Change
    Raw BVH AABB, radius .008, leaf 8, CuBQL 3.94 ms 3.46 ms -12.2%
    Raw BVH AABB, radius .008, leaf 0, CuBQL 2.42 ms 2.54 ms +5.0%

    Packed leaves benefit from the eager and read-only loads because the fixed work is spread across several primitives. Some single-primitive leaves pay that cost without enough reuse.

    The larger regression is in the primary closest-hit mesh_query_ray() path:

    Query Parent Commit Change
    Mesh ray, 1080, leaf 8, LBVH 35.4 ms 40.9 ms +15.5%
    Mesh ray, 1080, leaf 8, CuBQL 27.3 ms 31.6 ms +15.8%
    Raw BVH ray, 1080, leaf 8, LBVH 90.9 ms 90.9 ms approximately 0%

    The four read-only loads in the mesh leaf loop are one primitive-index load followed by three triangle vertex-index loads. On sm_110, those changes increase the exact mesh-ray kernel from 62 to 69 registers per thread while leaving the stack frame at 320 bytes. With 256 threads per block, residency falls from four blocks per SM to three, or from 1,024 to 768 active threads per SM.

    The same source compiled for L40 (sm_89) moves from 64 to 63 registers, which is consistent with the 4% to 6% mesh-ray improvement originally measured there. CUDA 13.0 cross-compilation shows how the result varies across targets:

    Target Parent registers Commit registers Register-limited 256-thread blocks per SM
    sm_89 64 63 4 to 4
    sm_100 62 69 4 to 3
    sm_103 62 69 4 to 3
    sm_110 62 69 4 to 3
    sm_120 63 61 4 to 4
    sm_121 63 61 4 to 4

    Thor is the only Blackwell target physically measured in this follow-up. The other Blackwell rows are compiler-resource results. They indicate that B200 and B300 have the same register cliff, while sm_120 and sm_121 do not.

    The Thor clocks were not locked, so the absolute timings should be treated accordingly. The regression ratios repeated, and the register and occupancy changes are deterministic.

    Test coverage

    • No test files changed in this commit. Existing BVH and mesh query tests cover functional correctness, while asv/benchmarks/spatial_query.py covers the relevant performance families.
    • The available performance coverage did not catch a compiler-resource cliff outside the L40 target. Resource summaries for each architecture would make this type of regression visible before runtime benchmarking.

    Behavioral verification

    A controlled test changed only the four loads in the primary mesh_query_ray() leaf loop back to ordinary array loads:

    Variant Thor time Registers
    Unmodified commit, block size 256 40.99 ms 69
    Unmodified commit, block size 128 41.16 ms 69
    Four ordinary loads, block size 256 35.60 ms 62

    A fresh-cache rerun of the unmodified commit measured 40.96 ms. The four-load change therefore recovered 13.2% and returned the kernel to the parent’s 62-register, four-block residency regime. Reducing the block size did not help.

    Follow-up to consider

    For 1.18.0rc2, change only these four loads in the primary mesh_query_ray() leaf loop back to ordinary loads:

    • mesh.bvh.primitive_indices[pc]
    • mesh.indices[primitive_index * 3 + 0]
    • mesh.indices[primitive_index * 3 + 1]
    • mesh.indices[primitive_index * 3 + 2]

    Keep the rest of fcc86dd, including the AABB-specific eager and read-only loads. A path-level exception is preferable to an sm_110 condition because sm_100 and sm_103 cross the same register boundary.

    The any-hit, ordered, count-intersections, and closest-sign ray variants should be measured separately before changing their load policy. The RC2 validation matrix should include Thor, L40, B200 or B300, and one sm_120 or sm_121 system.

  4. shi-eric commented on Sep 20, 2026

    @shi-eric
    Contributor

    Progress update: follow-up testing of fcc86dd now covers the remaining mesh-ray variants on Jetson AGX Thor.

    The comparison used the exact parent and commit wheels with the ASV replicated-bunny scene, 1080 by 1080 camera rays, LBVH and CuBQL, and leaf sizes 0 and 8. Each configuration was run twice with 15 samples per run. Positive values below are regressions.

    Variant LBVH default LBVH leaf 8 CuBQL default CuBQL leaf 8
    Closest-hit +9.23% +15.13% -0.20% +16.11%
    Any-hit +5.89% +11.86% -2.58% +12.60%
    Ordered +6.05% +9.94% +0.07% +10.64%
    Count-intersections -0.53% +4.25% -9.91% +6.17%
    Closest-sign -0.18% -0.23% -0.08% -0.04%

    The compiler results explain part of the split:

    Variant Registers, parent to commit Stack/thread Register-limited blocks/SM
    Closest-hit 62 to 69 320 B unchanged 4 to 3
    Any-hit 56 to 63 320 B unchanged 4 to 4
    Ordered 56 to 63 320 B unchanged 4 to 4
    Count-intersections 64 to 63 192 B unchanged 4 to 4
    Closest-sign 48 to 48 128 B unchanged 5 to 5

    Closest-hit still has the clear occupancy cliff. Any-hit and ordered stay in the same four-block residency regime, but the extra seven registers and the explicit LDG.E.CONSTANT load path cost 6% to 12% for LBVH and packed leaves. The penalty grows at leaf size 8, where each visited leaf executes more primitive and triangle-index loads.

    Count-intersections is a real tradeoff. Read-only loads improve CuBQL with default leaves by 9.91%, while packed leaves regress by 4.25% on LBVH and 6.17% on CuBQL. Closest-sign remains neutral across the matrix.

    Thor clocks were not locked, but the parent repeat medians differed by at most 0.61% and the commit repeats by at most 0.84%. All output checksums matched.

    Test coverage

    • The existing ASV ray benchmark measures only closest-hit mesh_query_ray(). It has no direct performance cases for any-hit, ordered, or count-intersections. Closest-sign is exercised indirectly by the signed closest-point benchmark, where its cost is mixed with the surrounding point query.
    • No test files changed in the original commit. The targeted probe covers the missing performance paths, but it is not yet checked into ASV.

    Behavioral verification

    A causal build changed only the four primitive and triangle-index loads in each ray path back to ordinary loads. Every kernel returned to its parent register and stack counts, and all 20 timing cells returned to within 0.74% of the parent. This isolates the load policy as the cause of both the gains and regressions.

    Follow-up to consider

    For 1.18.0rc2, the load policy should be adjusted by path:

    • Use ordinary loads for closest-hit on the affected Blackwell targets. Thor physically regresses, and the earlier sm_100 and sm_103 compiler results show the same 62-to-69-register residency cliff.
    • Use ordinary loads for any-hit and ordered on sm_110. Other targets should be measured before extending that exception.
    • Use ordinary loads for count-intersections on sm_110 if the RC priority is to avoid regressions relative to the parent. This removes the packed-leaf losses but gives up the 9.91% CuBQL/default-leaf gain.
    • Keep the read-only loads for closest-sign.

    The AABB-specific eager and read-only loads should remain in place because their packed-leaf improvement still reproduces on Thor. A split-load experiment for count-intersections can follow after 1.18 to see whether the CuBQL/default-leaf gain can be kept without the packed-leaf loss.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Labels

spatial accelerationBVHs, hash grids, and the spatial queries they accelerate

Projects

No projects

    Milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions