Skip to content

Alternative stencil resolvers for VoxelBlockManager - #2263

Draft
swahtz wants to merge 8 commits into
AcademySoftwareFoundation:masterfrom
swahtz:nanovdb-vbm-stencil-resolvers
Draft

Alternative stencil resolvers for VoxelBlockManager#2263
swahtz wants to merge 8 commits into
AcademySoftwareFoundation:masterfrom
swahtz:nanovdb-vbm-stencil-resolvers

Conversation

@swahtz

@swahtz swahtz commented Jul 29, 2026

Copy link
Copy Markdown
Contributor

Summary

This PR focuses on two independent costs that are part of resolving a 3×3×3 stencil through the VoxelBlockManager: 27 root-down tree traversals (one per tap), and a 27-element per-thread index array to hold the result. This PR adds new resolver functions (alongside the existing computeBoxStencil) that address each cost, and one that addresses both:

Each stencil shape now comes in a plain and a Cached form, with a single convention stated on the class:

  • <name> — resolves each tap by a root-down tree traversal. No shared memory, no barrier, safe from divergent threads.
  • <name>Cached — the block cooperatively stages a per-leaf neighbour table in shared memory, so each tap is a direct in-leaf lookup. Substantially faster, but must be called by all threads (uses __syncthreads) and costs shared memory.

Independently, compute<Name> materializes taps into a 27-slot array while forEach<Name> streams (tap, index) to a device-inlined callback, avoiding the per-thread array entirely.

method shape resolution output
computeBoxStencil (existing, unchanged) 27-tap tree walk materialized
forEachBoxStencil 27-tap tree walk streamed
computeBoxStencilCached 27-tap cached table materialized
forEachBoxStencilCached 27-tap cached table streamed
computeCrossStencil 7-tap tree walk materialized
computeCrossStencilCached 7-tap cached table materialized

The cached resolvers exploit a structural property of the VBM: a block's voxels span few distinct, consecutive leaves, so the block cooperatively stages a per-leaf neighbour table in shared memory (built by all threads, not a leader) and each tap becomes a direct in-leaf lookup. A 3×3×3 stencil touches at most 7 neighbouring leaves, not 27 independent lookups. Blocks spanning more than MaxCachedLeaves fall back to per-tap traversal.

Measurements

All six resolvers measured in one run, on one grid, against one baseline, with the same consumer: resolve the taps, then accumulate C sidecar channels per tap (box variants accumulate their 27, cross variants their 7). Sparse sphere shell: 1.29M active voxels across 9140 leaves, mean leaf occupancy 141/512 (~72% sparse). Output is byte-exact within each stencil shape.

One sidecar channel

method shape resolution divergent-safe? smem output w128 ms w128 × w512 ms w512 ×
computeBoxStencil (existing) 27-tap box tree walk yes none materialized 0.1868 1.00× 0.2713 1.00×
forEachBoxStencil 27-tap box tree walk yes none streamed 0.1408 1.33× 0.1425 1.90×
computeBoxStencilCached 27-tap box cached table no 3456 B materialized 0.1469 1.27× 0.1771 1.53×
forEachBoxStencilCached 27-tap box cached table no 3456 B streamed 0.1014 1.84× 0.1044 2.60×
computeCrossStencil 7-pt cross tree walk yes none materialized 0.0534 3.50× 0.0545 4.98×
computeCrossStencilCached 7-pt cross cached table no 896 B materialized 0.0414 4.51× 0.0422 6.43×

Speedups share the computeBoxStencil baseline — noting that the cross resolvers deliberately resolve 7 taps rather than 27, which is the point of matching the resolver to the stencil shape.

Throughout the rest of this description, a pair like 1.26× / 1.56× means BlockWidth 128 / BlockWidth 512.

Shared memory is the resolver's table only: MaxCachedLeaves(16) x entries x 8 B. Confirmed against ptxas -v — a width-128 kernel reports 4228 B total for the cached box (512 B leaf-index array + 256 B voxel-offset array + 3456 B table + 4 B span counter) and 1668 B for the cross.

What each technique contributes

  • Streaming alone removes the 27-element index array; caching alone removes the tree walks. The costs are independent, so fusing them beats either — 1.84x / 2.60x versus 1.33x / 1.53x.
  • Matching the resolver to the stencil shape is the largest single lever: the cross resolvers are 3.5-6.4x.

Feature width erodes every margin

At 8 sidecar channels the taps x C payload gather (identical for all resolvers) starts to dominate: forEachBoxStencilCached 1.41x / 2.14x, computeBoxStencilCached 1.22x / 1.26x, computeCrossStencilCached 3.92x / 4.56x. The ordering is unchanged.

Register cost of materialization

For a 27-tap consumer (ptxas -v, sm_120) the uint64_t st[27] array costs 26 registers and a 216-byte stack frame (70 -> 44 registers, 216 B -> 0; the 216 B is exactly 27 x sizeof(uint64)). On sm_120 that is 28 -> 42 resident warps, 58.3% -> 87.5% occupancy — the relief the streaming variants capture.

Validation

  • Byte-exact within each stencil shape: the box resolvers all agree with computeBoxStencil, and the cross resolvers all agree with each other, at block widths 64/128/256/512, on sparse and dense topology and at every feature width measured. The resolvers change how a tap index is found, never which index. (A cross resolver writes only the 7 cross slots, so it is compared against a cross-shaped consumer rather than the full box.)
  • The VoxelBlockManager_ValueOnIndex unit-test path passes: 250,047 (voxel, tap) comparisons against getAccessor().getValue(), zero mismatches.

Notes

  • No existing behaviour changes. computeBoxStencil keeps its documented contract (no shared memory, no barrier, callable from divergent threads); the cached forms are additive. Callers choose between them on two axes that are genuinely theirs to weigh: whether the call site is uniform, and whether the block can spare the shared memory for the table (16 x 27 leaf pointers for the box, 16 x 7 for the cross).
  • MaxCachedLeaves is 16; the box table costs 16 × 27 leaf pointers of shared memory per block (the cross table 16 × 7), which is the main resource trade-off against the naive path.
  • Implementing forEachCrossStencil was not measured to win any performance gains over computeCrossStencil, no additional register pressure was created instantiating the 7-integer table vs. the full 27-integer table for the cross stencil. I made the decision to drop this implementation.
  • This PR subsumes NanoVDB VBM: Streaming box-stencil #2262, I leave that open as the first step along this path for comparison and until we finish discussing the design.

swahtz added 8 commits July 27, 2026 11:17
Add VoxelBlockManager::forEachBoxStencil, which visits the 27 taps of the 3x3x3 box
stencil and streams (tap, index) to a device-inlined callback in the same deterministic
tap order as computeBoxStencil, without materializing the 27-element per-thread index
array. Consumers that reduce or accumulate taps (looking each index up in the sidecar)
avoid the stack frame and register pressure of the materialized form; callers needing
random access to all 27 taps keep computeBoxStencil.

Byte-exact with computeBoxStencil (identical output). Eliminating the st[27] index array
saves 26 registers + a 216-byte per-thread stack frame (70->44 regs, 216B->0; ptxas -v,
sm_120). On a real value-accumulating box filter the streaming path is 1.3-1.9x at 1 sidecar
channel, tapering with feature width as the per-tap gather dominates (~1.07x at 16 channels,
width 128; ~1.29x at 16 channels, width 512).

Signed-off-by: Jonathan Swartz <jonathan@jswartz.info>
…lBlockManager

Add computeCrossStencil (7-point cross) and computeBoxStencilCached (full 3x3x3 box).
A block's voxels span few distinct, consecutive leaves, so the thread block cooperatively
stages a per-spanned-leaf neighbor table in shared memory (built by ALL threads) and each
stencil tap becomes a direct in-leaf lookup instead of a root-down tree traversal. Blocks
spanning more than MaxCachedLeaves leaves fall back to per-tap traversal.

Byte-exact with computeBoxStencil at block widths 64/128/256/512. Measured on Blackwell with
value-accumulating filters over an Index-Grid sidecar (1-16 channels), on sparse and dense
topology: the 7-tap cross filter is 4.1-6.8x at one channel (1.5-2.0x at 16), and the 27-tap
cached box is 1.1-1.6x; both widen at larger block widths and narrow as the per-tap payload
gather grows. Partial-tap consumers keep the naive computeBoxStencil, whose tap-level dead-code
elimination already skips unread taps.

Signed-off-by: Jonathan Swartz <jonathan@jswartz.info>
…StencilCached)

Combine the cached leaf table of computeBoxStencilCached with the streaming output of
forEachBoxStencil: taps are resolved through the staged per-leaf neighbor table AND handed
to a device-inlined callback, so a 27-tap consumer pays neither the 27 root-down traversals
nor the 27-element per-thread array. The two costs are independent, so fusing them beats
either technique alone.

Byte-exact with computeBoxStencil at block widths 64/128/256/512. On a real value-
accumulating filter over an Index-Grid sidecar: 1.85x (width 128) and 2.59x (width 512) over
the naive resolution at one channel, versus 1.28x/1.54x for the cached table alone and
1.33x/1.91x for streaming alone. Requires all threads in the block (cooperative table build),
so plain forEachBoxStencil remains the option for divergent call sites.

Signed-off-by: Jonathan Swartz <jonathan@jswartz.info>
…mputeCrossStencilCached

Give the 7-point cross the same two forms as the box stencil, and state the naming
convention on the class: <name> resolves each tap by a root-down traversal, uses no shared
memory and is safe from divergent threads; <name>Cached stages a cooperative per-leaf table
in shared memory, is substantially faster, and must be called by all threads.

The naive cross is not merely an intent/symmetry API: resolving the 7 taps directly measured
1.57x (width 128) and 1.75x (width 512) faster than calling computeBoxStencil and reading
only the cross slots, i.e. the compiler does not fully eliminate the 20 unread taps. The
cached form remains fastest at 2.13x/2.26x on the same consumer.

Byte-exact with the naive box stencil for the cross slots at widths 64/128/256/512. No
streaming (forEach) cross variant is provided: a 7-element stencil is cheap enough to
materialize that removing the array measured as a wash.

Signed-off-by: Jonathan Swartz <jonathan@jswartz.info>
Signed-off-by: Jonathan Swartz <jonathan@jswartz.info>
…vers

The cached resolvers shared two pieces of copied code: the span-detection preamble
(appearing three times) and, between computeBoxStencilCached and forEachBoxStencilCached,
an entire table-staging and tap-resolution body that differed only in whether each tap was
stored or handed to a callback.

Extract the preamble as cachedLeafSpan(), and express computeBoxStencilCached as
forEachBoxStencilCached with a storing callback. No behaviour change: registers, stack and
shared memory are identical (74 regs / 216 B stack / 4228 B smem for a materialized cached
box kernel; 38 / 16 / 1668 for the cross), timings are within +-0.3% run-to-run noise, and
all VBM goldens still pass byte-exact.

Signed-off-by: Jonathan Swartz <jonathan@jswartz.info>
…d resolvers

Both cached resolvers staged their per-spanned-leaf table with the same strided loop:
decompose the flat entry index into (slot, entry), special-case the entry holding the leaf
itself, probe for the rest, and barrier. Extract that as stageLeafTable(), parameterised on
the table width, the self entry, and the shift from a leaf origin to its n-th neighbor's
origin - so each resolver keeps the entry ordering its lookup expects and its own cheap
offset arithmetic.

No behaviour change and no codegen cost: the cross kernel is unchanged at 38 registers /
16 B stack / 1668 B smem and the materialized cached box improves slightly to 73 registers
(from 74) at the same stack and shared memory; timings match the pre-refactor baseline and
all VBM goldens still pass byte-exact.

An earlier attempt that unified the offset arithmetic too - mapping each entry onto a 3x3x3
spoke id and decomposing that back into per-axis offsets - measured ~0.9% slower on the cross
resolver, because it replaced a single-axis add with an encode/decode round trip.

Signed-off-by: Jonathan Swartz <jonathan@jswartz.info>
…resolvers

Rename the terse identifiers introduced with the cached resolvers to read as plain English,
matching the naming used elsewhere in this file: c/slot/n -> entry/leafSlot/neighborID,
nSpanned -> spannedLeafCount, firstSpanned -> firstLeaf, nl -> neighborLeaf,
cached -> isCached, vi/vj/vk -> voxelX/Y/Z, li/lj/lk -> leafX/Y/Z, and sSpannedCount ->
sSpannedLeafCount. Also note why the cross lookup tests (voxelOnAxis + dir) & ~7, i.e.
whether the tap steps off the leaf on that axis.

Comments and identifiers only. Registers, stack and shared memory are unchanged (38/16/1668
for the cross kernel, 73/216/4228 for the materialized cached box) and all VBM goldens still
pass byte-exact.

Signed-off-by: Jonathan Swartz <jonathan@jswartz.info>
@swahtz swahtz self-assigned this Jul 29, 2026
@swahtz swahtz added the nanovdb label Jul 29, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant