Build dual/padded grids via leaf-mask morphology to cut memory - #710
Conversation
dual_grid materialized an 8x-expanded candidate coordinate list before deduplicating it, costing ~420-450 bytes of transient memory per input voxel (and overflowing an int32 cast in NanoVDB's segmented radix sort above ~2^31 candidates). A ~500M-voxel grid OOMed on an 80GB GPU. Build the padded topology directly from the 512-bit leaf activity masks via nanovdb::tools::cuda::TopologyBuilder instead, so scratch is O(node count) rather than O(voxel count). - New src/fvdb/detail/utils/nanovdb/PadGrid.cuh: a one-sided (octant) analogue of nanovdb's DilateGrid. The internal-node functor reuses DilateInternalNodesFunctor's scatter verbatim with only the neighbor stencil swapped for a one-sided one; the leaf and erosion functors are the one-sided shift-OR / shift-AND derivations. - BuildPaddedGrid.cu CUDA path composes bmax positive + -bmin negative unit passes (pad, or erode + PruneGrid for exclude_border), preserving the primal/dual transform swap. dual_grid is one positive pass. - Guard the empty-erosion case (PruneGrid cannot build an empty grid); use GridHandle::copy for the bmin==bmax==0 identity. - Reject exclude_border on PrivateUse1 explicitly (was a latent crash) and require bmin <= 0 <= bmax. - Add a low-level build_padded_grid binding for the generic box, plus tests covering CUDA/CPU parity, leaf/root boundary crossings, empty batch items, erode-to-empty, and peak memory. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Signed-off-by: Jonathan Swartz <jonathan@jswartz.info>
Signed-off-by: Jonathan Swartz <jonathan@jswartz.info>
There was a problem hiding this comment.
Pull request overview
This PR reworks the CUDA implementation of dual_grid / padded-grid construction to avoid materializing an 8× expanded coordinate list by instead building padded topology directly from NanoVDB leaf activity masks via TopologyBuilder, significantly reducing transient memory usage and avoiding large-candidate overflows.
Changes:
- Adds a new device-side one-sided morphology implementation (
PadGrid.cuh) to build padded/dual topologies from leaf masks. - Updates the CUDA padded-grid path (
BuildPaddedGrid.cu) to use mask-morphology passes (and erosion+prune forexclude_border), with explicit guards for identity and empty-erosion cases. - Exposes a low-level Python binding
build_padded_grid(...)and adds CUDA/CPU parity + boundary + memory tests.
Reviewed changes
Copilot reviewed 5 out of 5 changed files in this pull request and generated 3 comments.
Show a summary per file
| File | Description |
|---|---|
| tests/unit/test_dual.py | Adds CUDA/CPU parity tests for dual_grid and generic padded boxes, plus boundary/empty/memory coverage. |
| src/python/GridBatchOps.cpp | Exposes build_padded_grid binding and routes dual_grid through buildPaddedGrid. |
| src/fvdb/detail/utils/nanovdb/PadGrid.cuh | New one-sided (octant) mask-morphology topology builder for padded/dual grids on CUDA. |
| src/fvdb/detail/ops/BuildPaddedGrid.cu | Switches CUDA implementation to TopologyBuilder-based mask morphology; adds erosion+prune path and guards. |
| fvdb/_fvdb_cpp.pyi | Adds the build_padded_grid type stub. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Signed-off-by: Jonathan Swartz <jonathan@jswartz.info>
Signed-off-by: Jonathan Swartz <jonathan@jswartz.info>
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 5 out of 5 changed files in this pull request and generated no new comments.
Suppressed comments (1)
src/python/GridBatchOps.cpp:675
build_padded_gridis exposed as a general padding primitive, but it delegates toops::buildPaddedGrid, which always swaps primal/dual transforms (seeBuildPaddedGrid.cuwheremDualTransform/mPrimalTransformare swapped). For callers,build_padded_grid(bmin=0,bmax=0)is therefore not a pure identity (it still swaps transforms), which is surprising given the name/signature. Please either document this behavior on the binding or rename the binding to make the dual-grid semantics explicit.
[](const GBI &grid, int bmin, int bmax, bool excludeBorder) {
if (grid.batchSize() == 0) {
return fvdb::detail::makeEmptyGridBatchData(grid.device());
}
return ops::buildPaddedGrid(grid, bmin, bmax, excludeBorder);
buildPaddedGrid unconditionally swapped the source's primal/dual transforms in its metadata tail. That is correct for dual_grid, whose result voxels sit on the corner (dual) lattice, but wrong for the general build_padded_grid primitive exposed in this PR: a plain padded grid stays on the *same* lattice as its source, so build_padded_grid(0, 0) was not an identity (it shifted the origin by half a voxel) and every box pad silently moved to the dual lattice. Thread a dualTransform flag through ops::buildPaddedGrid: dual_grid passes true (keep the swap), build_padded_grid passes false (carry the source transforms verbatim). populateGridMetadata already recomputes the correct primal/dual transforms from the source's (voxelSize, origin), so the non-dual path just copies them over unchanged. Adds test_dual_grid_transform_is_dual_lattice (origin shifts by -0.5 voxel) and test_build_padded_grid_preserves_transform (origin/voxel size unchanged for every box), both on CPU and CUDA. Addresses the review on openvdb#710. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Signed-off-by: Jonathan Swartz <jonathan@jswartz.info>
|
Re: the suppressed Copilot note on Good catch, and it's a real correctness bug rather than just a naming/doc nit. The swap is only correct for Fixed:
Added regression tests on CPU + CUDA: |
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 6 out of 6 changed files in this pull request and generated no new comments.
Suppressed comments (1)
src/fvdb/detail/utils/nanovdb/PadGrid.cuh:179
valueMaskis declared but never used inPadInternalNodesFunctor. This will trigger an unused-variable warning (and can fail builds if warnings are treated as errors). Remove it or explicitly mark it unused.
const auto &srcTree = srcGrid->tree();
const auto &lower = srcTree.template getFirstNode<1>()[lowerID];
auto &valueMask = const_cast<LowerMaskT &>(lower.valueMask());
The valueMask reference bound in PadInternalNodesFunctor was never read -- the padding stencil reads each child leaf's valueMask() directly -- so it only produced an unused-variable warning. The const_cast reference binding had no side effect, so drop it. Addresses the Copilot review note on openvdb#710. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Signed-off-by: Jonathan Swartz <jonathan@jswartz.info>
|
Re: the suppressed Copilot note on Fixed — removed it. The stencil reads each child leaf's mask directly ( |
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 6 out of 6 changed files in this pull request and generated 1 comment.
Suppressed comments (1)
src/fvdb/detail/ops/BuildPaddedGrid.cu:337
- This retrieves the per-item grid via
mGridHdl->deviceGrid(i), which assumes batch itemicorresponds to the i-th grid in the underlying handle. That is not true for non-contiguous/slicedGridBatchData(which uses per-itemmCumBytesoffsets; seeGridBatchData::Accessor::grid). Use the batch metadata’s byte offsets to compute the grid pointer so CUDA padding works correctly on indexed batches too.
nanovdb::OnIndexGrid *grid = baseBatchHdl.mGridHdl->deviceGrid<nanovdb::ValueOnIndex>(i);
TORCH_CHECK(grid, "Grid is null");
A sliced or indexed GridBatchData is a view: it shares the underlying NanoVDB handle (which keeps every grid) and only shrinks batchSize() and the metadata array, so batchSize() < nanoGridHandle().gridCount() and each item's grid is located by its mCumBytes byte offset, not by physical index. The CUDA path indexed the handle physically (mGridHdl->deviceGrid(i)) and the identity fast-path copied the whole handle, so on a sliced batch dual_grid returned the wrong grids and build_padded_grid pulled in the excluded siblings (with an out-of-bounds metadata read in the transform fix-up). The CPU path looped gridCount() with the same effect, corrupting the heap. main's CUDA path was correct here (it went through the Accessor); this restores that. - CUDA padding loop: resolve each source grid as bufBase + cumBytesAt(i) (the mapping Accessor::grid() uses), iterating batchSize(). No input copy. - CUDA identity (bmin==bmax==0): keep the one-shot whole-handle copy for a contiguous batch; for a sliced view rebuild from Accessor-mapped coordinates (only build_padded_grid(0,0) reaches this; dual_grid, being (0,1), never does). - CPU paths: iterate batchSize() and resolve grids by byte offset. Adds test_sliced_batch_padding_matches_contiguous (CPU+CUDA) over tail, gap, reversed, and single-item selections. Addresses the review on openvdb#710. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Signed-off-by: Jonathan Swartz <jonathan@jswartz.info>
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 6 out of 6 changed files in this pull request and generated 1 comment.
Suppressed comments (1)
src/fvdb/detail/utils/nanovdb/PadGrid.cuh:534
- Same UB pattern as in
PadLeafNodesFunctor:originalWordsis defined viareinterpret_cast<WordStencilT>(originalWordsShifted[1][1][1])and later indexed with negative indices (originalWords[-1][…],originalWords[i][…][-1], etc.). Because the reference does not denote an actual[10][3][3]array object, this relies on undefined behavior and could be miscompiled.
Recommend switching to direct indexing into originalWordsShifted with explicit +1 offsets (or an accessor helper) so all indices stay within the declared array object.
uint64_t originalWordsShifted[10][3][3] = {};
using WordStencilT = uint64_t(&)[10][3][3];
auto &originalWords = reinterpret_cast<WordStencilT>(originalWordsShifted[1][1][1]);
…dexing PadLeafNodesFunctor and ErodeKeepMaskFunctor built a uint64_t(&)[10][3][3] reference by reinterpret_cast'ing the address of the middle element originalWordsShifted[1][1][1], then indexed it with negative and out-of-range subscripts (originalWords[-1][..], originalWords[8][..], originalWords[i-1][..][-1]). The notional [10][3][3] array does not exist at that address (it also extends past the real buffer), so the negative/out-of-range subscripting is undefined behavior an optimizer may miscompile. Replace the reference with a small centered-accessor lambda that indexes the real array with an explicit +1 offset -- originalWords(i, dBj, dBk) maps to originalWordsShifted[i + 1][dBj + 1][dBk + 1] -- so every access is in bounds. The computed addresses (and generated code) are identical; the CUDA<->CPU parity tests are unchanged. Addresses the review on openvdb#710. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Signed-off-by: Jonathan Swartz <jonathan@jswartz.info>
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 6 out of 6 changed files in this pull request and generated no new comments.
Suppressed comments (2)
src/fvdb/detail/ops/BuildPaddedGrid.cu:376
- When
excludeBorder==true, a grid withnumVoxelsAt(i) > 0butleafCount == 0(e.g. a grid represented by value tiles, or otherwise malformed metadata) will hitbreakbeforehandleis assigned. The code then pushes a default-constructedGridHandle, which can lead to crashes later.
Even if this situation “shouldn’t happen”, it’s safer to make the leafCount==0 branch produce an explicit empty handle (or throw) so behavior is well-defined.
.mNodeCount[0]
: baseBatchHdl.numLeavesAt(i);
if (leafCount == 0) {
break; // already eroded to empty; further erosion is a no-op
}
src/fvdb/detail/ops/BuildPaddedGrid.cu:277
keepTensoris allocated as auint8tensor and then reinterpreted asnanovdb::Mask<3>*. This relies on the returned pointer being suitably aligned forMask<3>(likely 8-byte alignment); if it isn't, dereferencing in the CUDA kernels is undefined behavior and can cause misaligned global memory accesses.
Allocate the sidecar as a torch::kUInt64 tensor (or otherwise ensure alignment) before casting to Mask<3>*.
This issue also appears on line 372 of the same file.
const int64_t maskBytes = static_cast<int64_t>(sizeof(nanovdb::Mask<3>)) * leafCount;
torch::Tensor keepTensor =
torch::empty({maskBytes}, torch::TensorOptions().dtype(torch::kUInt8).device(device));
auto *keepMasks = reinterpret_cast<nanovdb::Mask<3> *>(keepTensor.data_ptr());
…grids Two defensive fixes from the openvdb#710 review, both on the exclude_border erosion path: - Allocate the per-leaf keep-mask sidecar as uint64 rather than uint8 so its data pointer is guaranteed 8-byte aligned for Mask<3> (8 uint64_t words), which the kernels dereference directly. torch's allocators over-align in practice, but the tensor API doesn't guarantee it. The emptiness check runs on a zero-copy uint8 view, since torch's any() (an 'or' reduction) isn't implemented for uint64. - In the erosion pass loop, if the first pass sees leafCount == 0 (a grid with voxels but no leaf nodes, e.g. a tile-only grid), push an explicit empty grid handle instead of the default-constructed one, which would crash downstream. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Signed-off-by: Jonathan Swartz <jonathan@jswartz.info>
|
Addressed the two suppressed Copilot notes on
|
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 6 out of 6 changed files in this pull request and generated no new comments.
Suppressed comments (2)
src/fvdb/detail/ops/BuildPaddedGrid.cu:334
numNegativeis computed as-bminusingintarithmetic. If a caller passesbmin == INT_MIN, this negation overflows (undefined behavior) before any range checks can catch it, andtotalPassesbecomes incorrect. Compute counts in a wider signed type and validate that the pass count is non-negative and fits in anintloop bound.
const int numPositive = bmax;
const int numNegative = -bmin;
const int totalPasses = numPositive + numNegative;
src/fvdb/detail/utils/nanovdb/PadGrid.cuh:720
cudaMemcpyAsync(..., cudaMemcpyDeviceToHost, mStream)is followed by immediate dereference ofsrcRootAndUpperBufferon the host without synchronizing the stream. This is only safe ifHostBuffer::create()always returns pageable memory (making the copy implicitly synchronous); if it ever returns pinned memory, this becomes a real race and can read uninitialized data. Prefer making the copy explicitly synchronous (eithercudaMemcpyorcudaStreamSynchronize(mStream)after the async copy) to keep correctness independent of the host-buffer allocation strategy.
auto srcRootAndUpperBuffer = nanovdb::HostBuffer::create(rootAndUpperSize);
cudaCheck(cudaMemcpyAsync(srcRootAndUpperBuffer.data(),
deviceSrcRoot,
rootAndUpperSize,
cudaMemcpyDeviceToHost,
mStream));
auto srcRootAndUpper = static_cast<RootT *>(srcRootAndUpperBuffer.data());
for (uint32_t t = 0; t < srcRootAndUpper->tileCount(); t++) {
The root-speculation step copies the source root+upper nodes to a host buffer with cudaMemcpyAsync(..., cudaMemcpyDeviceToHost, mStream) and then dereferences it on the host to enumerate tiles. nanovdb::HostBuffer::create() returns pageable memory today, which makes that D2H copy implicitly host-synchronous, so it works -- but a pinned host buffer would leave the copy in flight and the host read would race on uninitialized data. Add an explicit cudaStreamSynchronize(mStream) before the dereference so correctness no longer depends on the host-buffer allocation strategy. Cost is negligible: one small copy per grid. Addresses the review on openvdb#710. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Signed-off-by: Jonathan Swartz <jonathan@jswartz.info>
A sliced/indexed GridBatch (grid[idx], grid[a:b]) is a view: it shares the underlying handle (which keeps every grid) and only shrinks batchSize() and the per-grid metadata, so batchSize() < nanoGridHandle().gridCount() and each item's grid is located by its mCumBytes byte offset, not by physical index. The mask-morphology fast paths resolved grids with mGridHdl->deviceGrid(i) (physical) and returned whole-handle copies, so on an indexed batch they silently produced the wrong grids or a voxelSizes/gridCount mismatch -- the same bug class fixed in buildPaddedGrid (openvdb#710), across the rest of the openvdb#711 ops and the pre-existing dilate/prune/clone helpers they use. - GridBatchData gains view-aware accessors deviceGridPtrAt(i)/hostGridPtrAt(i): the i-th *logical* grid resolved by byte offset (like Accessor::grid(i)). Every physical-index site (conv, conv_transpose, coarsen, subdivide, dilate, prune -- CUDA fast paths and CPU proxy loops) now uses them. - MakeContiguous exposes contiguousGridHandle() (compact the selected grids: a per-grid byte copy + mGridIndex/mGridCount header fixup, no radix sort) and cloneGridHandleAt(); makeContiguous and cloneGrid build on them. cloneGrid had copied *every physical grid* of a shared handle -- wrong for a sliced batch. - Zero-copy identity: coarsened_grid(1) / refined_grid(1) / dilated_grid(0) now return the input grid unchanged in Python, preserving its view and metadata with no copy (the C++ ops keep a compacting-copy fallback for direct callers). Adds test_sliced_batch.py: every op on tail/gap/reversed/single-item views (CPU+CUDA) matches an independently-built contiguous reference. Full unit suite green (2775 passed). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Signed-off-by: Jonathan Swartz <jonathan@jswartz.info>
The erode keep-mask emptiness check reduced over a uint8 view of the uint64 sidecar because any() (an 'or' reduction) is not implemented for uint64 on CUDA. (mask != 0).any() is equivalent and clearer -- the != yields a bool tensor whose any() reduction is implemented, so no dtype-reinterpreting view is needed. (.view(dtype) did compile and work; this just avoids the byte-reinterpretation.) Addresses the review on openvdb#712. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Signed-off-by: Jonathan Swartz <jonathan@jswartz.info>
A sliced/indexed GridBatch (grid[idx], grid[a:b]) is a view: it shares the underlying handle (which keeps every grid) and only shrinks batchSize() and the per-grid metadata, so batchSize() < nanoGridHandle().gridCount() and each item's grid is located by its mCumBytes byte offset, not by physical index. The mask-morphology fast paths resolved grids with mGridHdl->deviceGrid(i) (physical) and returned whole-handle copies, so on an indexed batch they silently produced the wrong grids or a voxelSizes/gridCount mismatch -- the same bug class fixed in buildPaddedGrid (openvdb#710), across the rest of the openvdb#711 ops and the pre-existing dilate/prune/clone helpers they use. - GridBatchData gains view-aware accessors deviceGridPtrAt(i)/hostGridPtrAt(i): the i-th *logical* grid resolved by byte offset (like Accessor::grid(i)). Every physical-index site (conv, conv_transpose, coarsen, subdivide, dilate, prune -- CUDA fast paths and CPU proxy loops) now uses them. - MakeContiguous exposes contiguousGridHandle() (compact the selected grids: a per-grid byte copy + mGridIndex/mGridCount header fixup, no radix sort) and cloneGridHandleAt(); makeContiguous and cloneGrid build on them. cloneGrid had copied *every physical grid* of a shared handle -- wrong for a sliced batch. - Zero-copy identity: coarsened_grid(1) / refined_grid(1) / dilated_grid(0) now return the input grid unchanged in Python, preserving its view and metadata with no copy (the C++ ops keep a compacting-copy fallback for direct callers). Adds test_sliced_batch.py: every op on tail/gap/reversed/single-item views (CPU+CUDA) matches an independently-built contiguous reference. Full unit suite green (2775 passed). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Signed-off-by: Jonathan Swartz <jonathan@jswartz.info>
Signed-off-by: Jonathan Swartz <jonathan@jswartz.info>
A sliced/indexed GridBatch (grid[idx], grid[a:b]) is a view: it shares the underlying handle (which keeps every grid) and only shrinks batchSize() and the per-grid metadata, so batchSize() < nanoGridHandle().gridCount() and each item's grid is located by its mCumBytes byte offset, not by physical index. The mask-morphology fast paths resolved grids with mGridHdl->deviceGrid(i) (physical) and returned whole-handle copies, so on an indexed batch they silently produced the wrong grids or a voxelSizes/gridCount mismatch -- the same bug class fixed in buildPaddedGrid (openvdb#710), across the rest of the openvdb#711 ops and the pre-existing dilate/prune/clone helpers they use. - GridBatchData gains view-aware accessors deviceGridPtrAt(i)/hostGridPtrAt(i): the i-th *logical* grid resolved by byte offset (like Accessor::grid(i)). Every physical-index site (conv, conv_transpose, coarsen, subdivide, dilate, prune -- CUDA fast paths and CPU proxy loops) now uses them. - MakeContiguous exposes contiguousGridHandle() (compact the selected grids: a per-grid byte copy + mGridIndex/mGridCount header fixup, no radix sort) and cloneGridHandleAt(); makeContiguous and cloneGrid build on them. cloneGrid had copied *every physical grid* of a shared handle -- wrong for a sliced batch. - Zero-copy identity: coarsened_grid(1) / refined_grid(1) / dilated_grid(0) now return the input grid unchanged in Python, preserving its view and metadata with no copy (the C++ ops keep a compacting-copy fallback for direct callers). Adds test_sliced_batch.py: every op on tail/gap/reversed/single-item views (CPU+CUDA) matches an independently-built contiguous reference. Full unit suite green (2775 passed). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Signed-off-by: Jonathan Swartz <jonathan@jswartz.info>
dual_gridmaterialized an 8x-expanded candidate coordinate list before deduplicating it, costing ~420-450 bytes of transient memory per input voxel (and overflowing an int32 cast in NanoVDB's segmented radix sort above ~2^31 candidates). A ~500M-voxel grid OOMed on an 80GB GPU.Build the padded topology directly from the 512-bit leaf activity masks via
nanovdb::tools::cuda::TopologyBuilderinstead, so scratch is O(node count) rather than O(voxel count).src/fvdb/detail/utils/nanovdb/PadGrid.cuh: a one-sided (octant) analogue of nanovdb's DilateGrid. The internal-node functor reuses DilateInternalNodesFunctor's scatter verbatim with only the neighbor stencil swapped for a one-sided one; the leaf and erosion functors are the one-sided shift-OR / shift-AND derivations.BuildPaddedGrid.cuCUDA path composes bmax positive + -bmin negative unit passes (pad, or erode + PruneGrid for exclude_border), preserving the primal/dual transform swap. dual_grid is one positive pass.GridHandle::copyfor the bmin==bmax==0 identity.exclude_borderon PrivateUse1 explicitly (was a latent crash) and require bmin <= 0 <= bmax.build_padded_gridpython binding for the generic box, plus tests covering CUDA/CPU parity, leaf/root boundary crossings, empty batch items, erode-to-empty, and peak memory.