NanoVDB: correctness and determinism fixes - #2249
Conversation
Five correctness/determinism fixes to the NanoVDB CUDA tools: * GridChecksum: evalChecksum copied sizeof(GridData)+sizeof(TreeData) (736 B) from a device buffer holding a single uint32 CRC into the 4-byte head/tail of a stack Checksum -- an out-of-bounds device read plus host stack corruption (compute-sanitizer visible; a hard CUDA "invalid argument" on some configs). Copy sizeof(uint32_t) at all four sites, matching validateChecksum. * Stream propagation: the NN_FACE_EDGE_VERTEX leaf-dilation kernel and SignedFloodFill's root pass ran on the default stream instead of the caller's stream, silently corrupting output for callers using a non-blocking stream. Launch and synchronize on the operation's stream. * Bounded grid-name copy: the grid-name copy read a fixed MaxNameSize bytes from a possibly shorter std::string (whose .data() is never null, making the memset branch dead), over-reading up to 255 bytes of host heap into the grid and any written .nvdb file. Zero the field, then copy only min(name.size()+1, MaxNameSize) bytes. Applied to PointsToGrid, DistributedPointsToGrid, MeshToGrid. * Deterministic output initialization: PointsToGrid and IndexToGrid left alignment padding and stats fields carrying recycled pool bytes, making output nondeterministic and leaking host heap into files. Zero the whole grid buffer once after allocation. For PointsToGrid this makes the three dense background-fill passes (upper/lower value tables, and the inactive-leaf-values pass for all but the Point build type) redundant, so they are dropped. * DeviceBuffer stream-ordered lifetime: the destructor, move-assignment and clear() freed device allocations on the default stream regardless of the stream the buffer was last used on -- a latent use-after-free for non-blocking-stream callers, masked today by legacy-stream semantics. Track the allocation stream and free on it, matching the stream-ordered free TempPool now performs. Adds gtest coverage in unittest/TestNanoVDB.cu: cross-stream dilation determinism, grid-name round-trip (empty/short/max-length), PointsToGrid output determinism, and DeviceBuffer non-blocking-stream lifetime. No performance claims. Signed-off-by: Jonathan Swartz <jonathan@jswartz.info> 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 fixes several CUDA correctness and determinism issues in NanoVDB tooling (stream propagation, checksum safety, deterministic initialization, and safer grid-name copying) and adds targeted CUDA/gtest regressions to prevent recurrence.
Changes:
- Fix CUDA stream propagation so kernels and synchronizations honor the caller-provided stream (avoid default-stream leakage).
- Fix checksum and buffer-lifetime hazards (correct-sized checksum copies; stream-ordered frees for
DeviceBuffer). - Improve determinism and hygiene (zero-initialize grid buffers; safer grid-name copies) and add regression tests covering these scenarios.
Reviewed changes
Copilot reviewed 9 out of 9 changed files in this pull request and generated 4 comments.
Show a summary per file
| File | Description |
|---|---|
nanovdb/nanovdb/unittest/TestNanoVDB.cu |
Adds CUDA regression tests for cross-stream correctness, grid-name round-trip safety, determinism, and DeviceBuffer lifetime behavior. |
nanovdb/nanovdb/tools/cuda/SignedFloodFill.cuh |
Ensures root processing honors the operation stream and synchronizes appropriately for host reads. |
nanovdb/nanovdb/tools/cuda/PointsToGrid.cuh |
Zero-initializes the output buffer for determinism; fixes grid-name copy to avoid host over-read; removes redundant background fill passes. |
nanovdb/nanovdb/tools/cuda/MeshToGrid.cuh |
Updates grid-name copy to avoid over-read by copying only the actual string (after clearing destination). |
nanovdb/nanovdb/tools/cuda/IndexToGrid.cuh |
Zero-initializes the output buffer to prevent nondeterminism/leakage from recycled allocator bytes. |
nanovdb/nanovdb/tools/cuda/GridChecksum.cuh |
Fixes incorrect copy size when reading back CRC values (avoid OOB device read / host corruption). |
nanovdb/nanovdb/tools/cuda/DistributedPointsToGrid.cuh |
Updates distributed grid-name copy to avoid fixed-size over-reads and propagate deterministic name contents. |
nanovdb/nanovdb/tools/cuda/DilateGrid.cuh |
Fixes NN_FACE_EDGE_VERTEX dilation kernel launch to use mStream rather than the default stream. |
nanovdb/nanovdb/cuda/DeviceBuffer.h |
Tracks an associated stream and orders frees on it to avoid latent UAF for non-blocking-stream callers. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
…eeds
The Tranche-1 determinism fix zero-initialized the *entire* IndexToGrid output
buffer so that bytes the build kernels never write - stats fields absent from
the source grid, and struct alignment padding - are deterministic instead of
carrying recycled pool bytes (which otherwise leak into written .nvdb files).
Measured against master this cost 10-22% on indextogrid across dragon/emu/
crawler/wdas_cloud: the leaf array dominates the buffer and was memset to zero
and then immediately overwritten value-by-value - a redundant multi-GB pass.
Scope the initialization to the bytes that are genuinely left unwritten:
* getBuffer() memsets only the non-leaf region [0, node[0]) - grid, tree,
root, root tiles and the internal nodes - a small fraction of the buffer.
* processLeafsKernel zeroes each leaf's own gap [&mMinimum, mValues): the
stats fields (skipped when the source has no stats) and any padding before
the 32-aligned mValues array. Every mValues[i] is written anyway, so this
reproduces the former full zero-init byte-for-byte.
Output is bit-identical to the full zero-init - all indextogrid goldens pass on
dragon/emu/crawler/wdas_cloud, compute-sanitizer memcheck is clean and initcheck
shows no new reports - and the regression vs master is gone (NEUTRAL on all four
datasets, RTX PRO 6000 / sm_120).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Signed-off-by: Jonathan Swartz <jonathan@jswartz.info>
…ce in test Addresses PR review: - The grid-name copy could leave GridData::mGridName without a null terminator when the name length >= MaxNameSize: min(size+1, MaxNameSize) == MaxNameSize overwrote the entire zeroed field, and gridName() (a C-string accessor) would then read past it. Copy at most MaxNameSize-1 bytes and rely on the preceding memset for termination, truncating over-long names instead. Fixed in PointsToGrid, MeshToGrid and DistributedPointsToGrid. - GridName_CudaPointsToGrid gains an over-long (>= MaxNameSize) case asserting the result is truncated to MaxNameSize-1 chars and stays null-terminated. - NonBlockingStreamDilate_ValueOnIndex queried device 0 for the clock rate; query the current device via cudaGetDevice() instead. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Signed-off-by: Jonathan Swartz <jonathan@jswartz.info>
…ream The Tranche-1 stream-correctness fix added an unconditional cudaStreamSynchronize(stream) before processRoot's synchronous default-stream cudaMemcpy of the tree. That sync is only needed when the caller's node passes ran on a non-default stream; on the default stream (0) the blocking copy already orders after prior stream-0 work, so the extra sync there was pure overhead - a measured ~4-11% regression on floodfill/dragon vs master. Guard it with `if (stream != 0)`. Correctness for non-blocking-stream callers is unchanged. floodfill is now NEUTRAL vs master on dragon/emu/crawler (0 regressions), and byte-exact validation is unaffected. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Signed-off-by: Jonathan Swartz <jonathan@jswartz.info>
Addresses PR review: the Tranche-1 stream-ordered-free change stored a single mStream, overwritten by every DeviceBuffer::deviceUpload. A DeviceBuffer can hold an allocation on each device (mGpuData is per-device), and the multi-GPU example (ex_make_mgpu_nanovdb) uploads one handle to every device with that device's own stream. The destructor/move-assign then freed *every* device's allocation on the last device's stream - a cross-device cudaFreeAsync(ptr@devA, stream@devB), which is invalid (the free stream must belong to the allocation's device). Pre-Tranche-1 code freed on stream 0 uniformly, so this was a regression for the multi-GPU path. Track the allocation stream per device in a cudaStream_t array parallel to mGpuData, set in init()/deviceUpload() at mStreams[device], and free each mGpuData[i] on its own mStreams[i] in clear()/move-assignment. The single-GPU path is unchanged (one device, one stream); the multi-GPU path now frees each device's allocation on its own device-bound stream. Single-GPU verified (DeviceBuffer lifetime gtest + byte-exact validation); the multi-GPU free path is correct by construction but not runtime-tested here (single-GPU environment). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Signed-off-by: Jonathan Swartz <jonathan@jswartz.info>
…-wait (test) Addresses PR review: the test launches streamBusyWaitKernel on the default stream to occupy it during the candidate dilation, but only synchronized the non-blocking stream afterward. The busy-wait could remain queued on stream 0 and leak into subsequent tests, causing timing-dependent flakes. Drain it with a cudaDeviceSynchronize() after the candidate run. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Signed-off-by: Jonathan Swartz <jonathan@jswartz.info>
Addresses PR review: mStreams[device] was set only when the device allocation was first created. If the buffer is later re-uploaded (or downloaded) on a different stream, the destructor/move-assign/clear would still free on the old stream, which does not necessarily order after the most recent work on the new stream (notably for cudaStreamNonBlocking). Update mStreams[device] to the current stream on every host<->device copy in deviceUpload/deviceDownload, not just on first allocation. Guarded on mStreams (null for externally-managed buffers, which are never freed here). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Signed-off-by: Jonathan Swartz <jonathan@jswartz.info>
…am comments Addresses PR review: - PointsToGrid/MeshToGrid/DistributedPointsToGrid use std::min in the grid-name copy but did not include <algorithm> (it compiled only via transitive includes). Add the include so each header is self-sufficient. - DeviceBuffer comments still described mStreams as the "allocation stream", but it is now updated on every deviceUpload/deviceDownload; reword them to "last-used stream" to match the actual behavior. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Signed-off-by: Jonathan Swartz <jonathan@jswartz.info>
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>
kmuseth
left a comment
There was a problem hiding this comment.
it looks good but I would like Matthew Cong to take a look at it as well
| uint64_t mSize; // total number of bytes managed by this buffer (assumed to be identical for host and device) | ||
| void *mCpuData, **mGpuData; // raw pointers to the host and device buffers | ||
| int mDeviceCount, mManaged;// if mManaged is non-zero this class is responsible for allocating and freeing memory buffers. Otherwise this is assumed to be handled externally | ||
| cudaStream_t *mStreams = nullptr;// per-device stream each managed device buffer was last used on - set at allocation, then updated by every deviceUpload/deviceDownload (parallel to mGpuData, length mDeviceCount). Frees (destructor, move-assign, clear) are ordered on the owning device's stream instead of the default stream 0, otherwise a buffer last used on a non-blocking stream could be freed while that stream's work is still in flight (use-after-free). One stream per device is required because a single DeviceBuffer can hold allocations on several devices (multi-GPU), each on its own device-bound stream. The caller must keep these streams alive until the buffer is destroyed. |
There was a problem hiding this comment.
Would this have the potential to introduce bugs in existing code with blocking streams? For example, if you download the same buffer to multiple blocking streams, the clear operation would now only synchronize on the last blocking stream as opposed to all of the blocking streams (via the default stream) leading to early destruction.
There was a problem hiding this comment.
Good catch — you're right, and it reproduces. I wrote a repro and ran it against three states of the code:
| scenario | master | this PR (as reviewed) | with fix |
|---|---|---|---|
| A) device-only buffer, user kernel on a second blocking stream | pass | FAIL — 64 MB clobbered | pass |
| B) host buffer, upload on a non-blocking stream | pass | pass | pass |
Scenario A is exactly the mechanism you described: the free is ordered on the last-used stream only, the block is recycled, and the other stream's still-queued work lands in the next allocation. Two things were needed to observe it at all — a warm-up pass (on a cold context the first launches serialize and hide the race) and a spin loop the compiler can't elide.
A second finding while chasing this, which I think reframes the change. clear() calls cudaFreeHost(mCpuData) before the device frees, and cudaFreeHost implicitly synchronizes — I verified directly that a busy stream goes to done across it, while cudaFreeAsync does not. So for any buffer that owns pinned host memory that call already masks the hazard, including the non-blocking-stream case this hunk was written to fix, which is why scenario B passes on master too. The regression only escapes because device-only buffers (create(size, nullptr, device, stream), e.g. in buildVoxelBlockManager) leave mCpuData null, so cudaFreeHost(nullptr) is a no-op.
So as written, the hunk fixed a hazard that is currently masked and introduced one that isn't. Rather than revert it, I've made the ordering explicit instead of leaving correctness resting on cudaFreeHost's implicit synchronization, which isn't in CUDA's documented implicit-synchronization list.
The fix replaces the per-device stream with a per-device event: each use waits on the event before issuing work and re-records it afterwards, so a single event transitively covers every stream the buffer has been used on, and the frees wait on it. That is correct for any mix and number of blocking/non-blocking streams, and it also drops the "caller must keep these streams alive until the buffer is destroyed" requirement the old comment conceded, since the event is owned by the buffer.
Both scenarios now pass, and the VBM/topology/maintenance goldens are unchanged (46/46 on dragon and emu). Pushed, along with a corrected pendingchanges entry — the old wording described the superseded "free on the last-used stream" mechanism.
Happy to add the repro as a regression test if you'd like it, though I left it out deliberately: it needs a ~350 ms spin, and if the allocator declines to recycle the block it would pass vacuously, so it risks being a slow test that can silently stop testing anything.
There was a problem hiding this comment.
Follow-up: I went ahead and added the regression test after all — DeviceBufferMultiStreamFreeOrdering in TestNanoVDB.cu.
The concern about it passing vacuously turned out to be fixable. My first attempt gated the assertion on having staged the race (work still pending and the block recycled), which was backwards: with a correctly ordered free the allocator cannot hand the block out again until the other stream drains, so both of those observations are the fix working rather than a reason to skip. They are now reported in the failure message instead of gating the check.
Verified in both directions — it fails against the pre-fix header (64 MB clobbered) and passes with the fix. It uses a device-only buffer deliberately, so clear()'s cudaFreeHost cannot mask the problem, and it needs the warm-up pass because on a cold context the first launches serialize and the race never forms.
Also relabelled the pre-existing DeviceBufferNonBlockingStreamLifetime as a smoke test: it only asserts cudaSuccess, so it passes with or without a correctly ordered free, and its comment still described the superseded "free on the stream it was allocated on" mechanism.
|
Looks good to me for the most part, minus the change to the DeviceBuffer which I raised a question about above. |
harrism
left a comment
There was a problem hiding this comment.
All five are real bugs, and the fixes are the right shape. Three brief inline notes, none blocking.
On the buffer stream fixes specifically:
- The
freeAsync(ptr, 0)free is a genuine use-after-free for non-blocking-stream callers — the legacy default stream does not order against them — so this is a correctness fix, not defensive extra synchronization. - Tracking the allocation/last-use stream and freeing on it is the same retained-stream contract
cuda::Buffer(#2251) uses and the roadmap (#2232) records. The legacy buffer converging on it independently is good validation of the rule, and the per-device stream array is the right extension for a buffer that can hold allocations on several devices. - I swept
tools/cuda/on this head for kernel launches without an explicit stream argument: none remain — the two fixed here (DilateGrid 26-neighbour pass, SignedFloodFill root pass) were the last.
The IndexToGrid scoped zero-init — measured against the naive whole-buffer version and engineered to be byte-identical without the 10–22% cost — is exactly how a change like that should be justified.
Sequencing note: this conflicts textually with #2269/#2270 in MeshToGrid.cuh and PointsToGrid.cuh. This PR should land first; I'll rebase that stack over it.
… the buffer Tracking only the stream a device buffer was LAST used on is not sufficient: two streams can both have work in flight, and freeing on the later one leaves the earlier one's work running against memory the allocator has already recycled. Reported in review by matthewdcong. Reproduced: a device-only DeviceBuffer plus a user kernel on a second blocking stream, with a warm-up pass so the launches actually overlap. The freed block is recycled and all 64 MB of the next allocation are overwritten by the late kernel. Freeing on the default stream (the previous behaviour) passes, because the legacy stream is implicitly ordered after every blocking stream; freeing on the last-used stream fails. Replace the per-device stream with a per-device event. Each use waits on the event before issuing work and re-records it afterwards, so one event transitively covers every stream the buffer has been used on, and the frees wait on it. This is correct for any mix of blocking and non-blocking streams and for any number of them, and it removes the requirement that callers keep their streams alive until the buffer is destroyed, since the event is owned by the buffer. Verified: both the multi-blocking-stream case and the non-blocking case pass, and the VBM, topology and maintenance goldens are unchanged (46/46 on dragon and emu). Signed-off-by: Jonathan Swartz <jonathan@jswartz.info>
The entry described the mechanism as freeing on the stream the buffer was last used on, which was superseded: frees are now ordered after every stream the buffer was used on via a per-device event. Signed-off-by: Jonathan Swartz <jonathan@jswartz.info>
…ering The existing DeviceBufferNonBlockingStreamLifetime test only asserts that repeated allocate/use/free returns cudaSuccess, so it passes with or without a correctly ordered free. Its comment also described the superseded 'free on the stream it was allocated on' mechanism. Reword it as the smoke test it is, and add a test that actually discriminates. DeviceBufferMultiStreamFreeOrdering uses a device-only buffer on purpose - it owns no pinned host memory, so clear()'s cudaFreeHost (which implicitly synchronizes) cannot mask the problem. A second stream is parked behind a busy-wait with a write to the buffer queued behind it, the buffer is destroyed, and the next allocation is checked for corruption. Verified to fail against the pre-fix DeviceBuffer (64 MB clobbered) and pass with the fix. A warm-up pass is required: on a cold context the first launches serialize and the race never forms. The recycled/still-pending state is reported in the failure message but deliberately not used as a preconditon - with a correctly ordered free the allocator cannot hand the block out again until the other stream drains, so those observations are the fix working rather than a reason to skip. Signed-off-by: Jonathan Swartz <jonathan@jswartz.info>
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 10 out of 10 changed files in this pull request and generated 1 comment.
Suppressed comments (3)
nanovdb/nanovdb/cuda/DeviceBuffer.h:80
- This reuses one
streamfor every device allocation. A CUDA stream belongs to one device, butDeviceBuffercan own allocations on several devices (for example,ex_make_mgpu_nanovdb/main.cu:197-200uploads one handle to each device). Waiting and callingcudaFreeAsyncfor another device's pointer on this stream can fail or free with the wrong ordering. Select a stream on each allocation's device (and switch the current device as needed) rather than applying the caller/current device's stream to the entire array.
for (int i = 0; i < mDeviceCount; ++i) {
this->orderAfterPriorUses(i, stream);
cudaCheck(util::cuda::freeAsync(mGpuData[i], stream));
nanovdb/nanovdb/tools/cuda/SignedFloodFill.cuh:217
- The SignedFloodFill stream-propagation fix has no non-blocking-stream regression coverage. The existing CUDA SignedFloodFill test invokes only the default stream (
TestNanoVDB.cu:1219), so it would not detect this code reverting to a default-stream launch. Add a test analogous to the dilation regression that occupies the default stream and compares results produced on a non-blocking stream.
kernels::processRoot<BuildT>(d_tree, mStream);
nanovdb/nanovdb/unittest/TestNanoVDB.cu:3771
- Because
useris a blocking stream, a free enqueued on the legacy default stream is implicitly ordered after this stream's pending kernel. Consequently, the original default-stream implementation can pass this supposed regression test—the adjacent comment even identifies this synchronization. Make the stream carrying the late write non-blocking so the test actually removes default-stream ordering and exercises the lifetime fix.
cudaCheck(cudaStreamCreate(&user));// blocking streams, i.e. the ones the legacy
cudaCheck(cudaStreamCreate(&other));// default stream implicitly synchronizes with
| void orderAfterPriorUses(int device, cudaStream_t stream) const | ||
| { | ||
| if (mEvents && mEvents[device]) cudaCheck(cudaStreamWaitEvent(stream, mEvents[device], 0)); | ||
| } |
There was a problem hiding this comment.
All four points from this review (the comment above plus the three suppressed ones) evaluated and addressed in 457b671. For the humans following along:
Raw-pointer uses are untracked (this comment) — correct, and the most important of the four. The event only covers deviceUpload/deviceDownload; kernels launched against deviceData()'s raw pointer are invisible to it. Two clarifications on scope: work on blocking streams is still covered (the free is issued on the default stream, which is implicitly ordered after them — the same coverage master provided), and the uncovered case — raw-pointer work on a non-blocking stream — was equally uncovered on master, where no API could even express the dependency. Fixed as suggested by making the contract explicit: recordUse(device, stream) is now public, and deviceData() documents that raw-pointer work on non-blocking streams must either be registered through it or synchronized before the buffer is cleared/destroyed.
Test's user stream is blocking (suppressed, TestNanoVDB.cu) — deliberate, but the observation exposed a gap. The blocking scenario is what discriminates the last-used-stream revision this thread started with; simply flipping it to non-blocking would create a test no implementation can pass, because the late write never goes through the buffer's API. With recordUse public, the constructive version exists, so the test is now two scenarios, verified per revision:
| revision | blocking + unregistered write | non-blocking + recordUse |
|---|---|---|
| this PR (event fix) | pass | pass |
| earlier revision (free on last-used stream) | FAIL | FAIL |
| master (free on stream 0, no event) | pass | FAIL |
One stream for every device's free (suppressed, DeviceBuffer.h) — agreed in principle. A stream belongs to one device, and nothing in the SOMA documentation blesses freeing one device's pool allocation on another device's stream ("deallocation can be performed in any stream" is stated in a same-device context). freeDeviceBuffers now frees each allocation on its own device — the caller's stream for the current device, the owning device's default stream otherwise, switching devices as needed. Honesty note: master's loop had the same single-stream shape, and this machine has one GPU, so the multi-device path is verified by inspection and compilation only.
SignedFloodFill test coverage (suppressed, SignedFloodFill.cuh) — agreed, added as NonBlockingStreamSignedFloodFill, mirroring the dilation regression (occupied default stream, non-blocking run, readback on the producing stream, identical output required). One scope caveat, verified rather than assumed: it locks the cross-stream contract and would catch node passes escaping to the default stream, but it cannot discriminate processRoot's internal ordering on constructible inputs — I swapped the pre-fix header in and it passes (3/3), because that path only does work when interior root-level tiles exist (a level set thousands of voxels across) and the pre-fix code was accidentally host-synchronous otherwise via its blocking legacy-stream memcpy. The test comment says so.
Goldens unchanged (46/46), full test file compiles, self-move check still passes.
…rgument Two non-blocking review points from harrism. Self-move assignment freed the buffers and then read its own (now dangling) members. Verified: without the guard 'buf = std::move(buf)' aborts on the deviceData assertion because mDeviceCount has been zeroed; with it the buffer is left intact. Note UnifiedBuffer::operator=(&&) has the same flaw and is left for a separate change. Also document clear()'s stream argument. The review noted it had become dead, which was true of the revision reviewed - the per-device stream overrode it. With frees now ordered by the tracking event the argument is honoured again and simply selects where the free is enqueued, so any stream is safe to pass regardless of where the buffer was used. Signed-off-by: Jonathan Swartz <jonathan@jswartz.info>
…er stream tests Review follow-ups from the Copilot pass on this PR. The lifetime event only covers uses issued through deviceUpload/deviceDownload; work enqueued against the raw pointer from deviceData() is invisible to it, so on a non-blocking stream such work could still outlive the buffer (as on master, where no API could express the dependency). Make recordUse public so raw-pointer callers can register their stream, and document the contract on deviceData(). freeDeviceBuffers issued every device's free on one stream, but a stream belongs to a single device; free each allocation on its own device instead (caller's stream for the current device, the owning device's default stream otherwise, switching devices as needed). Same pre-existing shape as master's loop; correctness on multi-GPU is by inspection - this machine has one GPU. Split the free-ordering regression test into the two scenarios that discriminate the two historical bugs: a blocking stream with an unregistered write (fails if frees move off the default stream: the reviewed revision) and a non-blocking stream with a recordUse-registered write (fails without the event: master). Verified per revision: current passes both, the reviewed revision fails both, master fails the non-blocking one. Add NonBlockingStreamSignedFloodFill, mirroring the dilation regression: identical output on the default stream and on a non-blocking stream while the default stream is occupied, with the readback on the producing stream. This locks the cross-stream contract; it cannot discriminate processRoot's internal ordering on constructible inputs (verified: the pre-fix header passes it) because that path only does work when interior root-level tiles exist and pre-fix code was accidentally host-synchronous otherwise - noted in the test. Signed-off-by: Jonathan Swartz <jonathan@jswartz.info>
Summary
Five correctness/determinism fixes to the NanoVDB CUDA tools, plus regression tests. Bug fixes only — no API changes, no behavior change for already-correct callers.
Fixes
1. GridChecksum — out-of-bounds device read + host stack corruption
evalChecksumcopiedsizeof(GridData)+sizeof(TreeData)(736 B) out of a device buffer that only holds a single 4-byte CRC, into the 4-byte head/tail of a stackChecksum. That's both an out-of-bounds device read and a host stack smash (compute-sanitizer flags it; some configs hard-fail with a CUDA "invalid argument"). Now copiessizeof(uint32_t)at all four sites, matchingvalidateChecksum.2. Stream propagation — work silently escaping to the default stream
The 26-neighbour (
NN_FACE_EDGE_VERTEX) leaf-dilation kernel and SignedFloodFill's root pass launched and synchronized on the default stream instead of the caller's stream, silently corrupting output for callers on a non-blocking stream. Both now use the operation's stream.3. Grid-name copy — up to 255 bytes of host heap over-read into the output
The name copy read a fixed
MaxNameSize(256) bytes from a possibly shorterstd::string— whose.data()is never null, so the old memset fallback was dead code — over-reading host heap into the grid and into any written.nvdb. Now zeroes the field and copies onlymin(name.size()+1, MaxNameSize). Applied to PointsToGrid, DistributedPointsToGrid, and MeshToGrid.4. Deterministic output — recycled pool bytes leaking into grids/files
PointsToGrid and IndexToGrid left alignment padding and stats fields (absent when the source has no stats) holding whatever the recycled device pool last contained, making output nondeterministic and leaking host heap into files.
PointsToGrid: the grid buffer is zeroed once after allocation, which also makes the three dense background-fill passes (upper/lower value tables, and inactive-leaf-values for every build type except Point) redundant, so they're dropped — net-neutral on runtime, measured against master (same harness, 30 iters / 10 warmup, RTX PRO 6000 sm_120, median):
p2g_onindex_n23347893p2g_onindex_n93391572_dup4p2g_onindex_n373566288_dup16p2g_onindex_n96956688(marginally favourable rather than exactly neutral, by about the width of run-to-run noise; the master side needs
--checksum=skipto benchmark at all, since it trips the crash fixed in 1 above)IndexToGrid: the initialization is scoped to only the bytes actually left unwritten — a memset of the non-leaf region
[0, node[0])plus a per-leaf zeroing of the stats/padding gap inprocessLeafsKernel(everymValues[i]is written anyway). This is byte-identical to a full zero-init (allindextogridgoldens pass; compute-sanitizer memcheck clean) while avoiding the redundant multi-GB memset a whole-buffer zero-init would incur — which measures as a 10–22% regression vs. master on this op, eliminated here.5. DeviceBuffer — stream-ordered free (latent use-after-free)
The destructor, move-assignment, and
clear()freed device memory on the default stream regardless of what work was still outstanding on the buffer — a latent use-after-free for non-blocking-stream callers, since the default stream is only implicitly ordered after blocking streams.An earlier revision of this PR fixed that by freeing on the stream the buffer was last used on. @matthewdcong pointed out that this is insufficient when a buffer is used on more than one stream: the free is then ordered after the last one only. That is reproducible — a device-only buffer plus a user kernel on a second blocking stream has the freed block recycled and fully overwritten by the late kernel, where freeing on the default stream passes.
The buffer now tracks its uses with a per-device event: each use waits on the event before issuing work and re-records it afterwards, so one event transitively covers every stream the buffer has been used on, and the frees wait on it. This is correct for any mix and number of blocking and non-blocking streams, and it removes any requirement that callers keep their streams alive until the buffer is destroyed, since the event is owned by the buffer.
Worth noting for reviewers:
clear()callscudaFreeHost(mCpuData)before the device frees, andcudaFreeHostimplicitly synchronizes. That masks this whole class of hazard for buffers that own pinned host memory — it is device-only buffers (create(size, nullptr, device, stream), e.g. inbuildVoxelBlockManager) wheremCpuDatais null that are actually exposed. The fix here makes the ordering explicit rather than leaving correctness resting on that implicit synchronization, which is not in CUDA's documented list.Tests
New gtest coverage in
unittest/TestNanoVDB.cu: