Skip to content

Advance NanoVDB pin to openvdb #2319 and adopt single-space cuda::Buffer where it now fits (#770 steps 1-2) - #773

Merged
harrism merged 7 commits into
openvdb:mainfrom
harrism:mjh/nanovdb-memres-step1-2
Sep 15, 2026
Merged

harrism merged 7 commits into
openvdb:mainfrom
harrism:mjh/nanovdb-memres-step1-2

Conversation

@harrism

@harrism harrism commented Sep 11, 2026

Copy link
Copy Markdown
Contributor

Supersedes #752 (carrying its commit, with @swahtz as author). Steps 1–2 of #770, plus a latent-bug fix the new pin's validation exposed.

Summary

Advances the NanoVDB pin to 31d1e596 (merge of openvdb #2319) — past the whole "introduce + deprecate" arc of openvdb #2232 that has landed so far — and uses the single-space nanovdb::cuda::Buffer directly at the three device-only scratch/output sites where #2301 removed the reasons for an adapter or a dual-space type.

Grid storage is unchanged. GridBatchData's GridHandle<TorchDeviceBuffer> stays as it is; that flip is #770 steps 3–5 and deliberately not in this PR.

Commit 1 — normalize grid headers before constructing GridHandles (new)

A pre-existing bug, surfaced by the pin: NanoVDB #2292/#2301 make GridHandle validate a host buffer's grid chain at construction — every header must be valid, carry index i of count N, and have a size that fits. Two fvdb sites copied a header out of a multi-grid batch into a single-grid buffer and built a handle around it without resetting those fields:

  • SaveNanoVDB.cu indexToGridHost()*dstGrid->data() = *srcGrid->data() kept the source's mGridIndex, mGridCount and (index-grid) mGridSize. The CUDA tools::cuda::indexToGrid it ports resets index/count (IndexToGrid.cuh:167-168); the port didn't.
  • LoadNanovdb.cpp copyIndexGridToHandle() — set mGridCount = 1 but left mGridIndex at the batch position.

copyIndexGridToHandle also copies the grid only up to its first blind-metadata record yet left mBlindMetadataCount/Offset claiming it, and every one of these header writes left the file's checksum stale; both are now normalized too (checksum marked disabled, as MakeContiguous/ConcatenateGrids do after their fixups).

Harmless before (the fields were overwritten later or never read); at the new pin, saving N>1 grids to a standard type fails with "invalid host buffer (grid 1 of N)" and loading grid i>0 of a tensor-grid file fails with "inconsistent grid index/count (grid 0 of 1)"275 of 622 test_io.py cases. Nine lines, correct at the old pin too, so it leads the series and every commit passes.

Commit 2 — pin + -Werror fallout (swahtz's #752, rebased)

Commit 3 — single-space buffers where NanoVDB now allows them

site before after
ReinitializeSdf.cu VBM metadata local TorchVbmBuffer adapter (−79 lines) VoxelBlockManagerHandle<cuda::Buffer<std::byte, BuilderResource>> directly
BuildPrunedGrid.cu per-leaf mask scratch TorchDeviceBuffer + two reinterpret_cast; PruneGrid on legacy stream 0 cuda::Buffer<Mask<3>, BuilderResource>, typed data(); PruneGrid on the fill stream
SaveNanoVDB.cu indexToGrid output GridHandle<TorchDeviceBuffer> via a TorchDeviceBuffer(0, device) guide GridHandle<cuda::Buffer<std::byte, BuilderResource>> via a default-constructed pool buffer

Why each is safe now, verified against the pinned headers rather than assumed:

  • The adapter existed because VoxelBlockManagerHandle's accessors were gated on hasDeviceDual and buildVoxelBlockManager allocated through BufferT::create. #2301 adds BufferHasDeviceSingle accessors and routes allocation through createDeviceStorage(bytes, proto, …); BufferTraits<cuda::Buffer<T,R>>::hasDeviceSingle is !is_host_accessible_resource<R>, true for BuilderResource, which is default-constructible and stream-ordered — so the nullptr prototype yields Buffer(stream, bytes, noInit) on the reinit stream. reset() releases via destroy().
  • noInit for the mask scratch is behaviour-preserving: TorchDeviceBuffer never zeroed the allocation, and InjectPredicateToMaskFunctor writes every word (zero, then setOnAtomic) before PruneGrid reads it.
  • PruneGrid takes a cudaStream_t defaulting to 0 and fvdb never passed one, so its mask reads and its two internal cudaStreamSynchronizes were not ordered after the fill kernel whenever torch's current stream is a non-blocking one. Pre-existing; closed here because the new comment on that block would otherwise be false.
  • IndexToGrid::getHandle allocates through createDeviceStorage<BufferT>(size, &pool, …) at this pin, so a default-constructed pool supplies the resource and no device guide is needed.

With this, no TorchDeviceBuffer is allocated in SaveNanoVDB.cu; the remaining mentions read the grid batch's own handle and belong to the storage flip.

Not in scope

openvdb #2319's UnifiedBuffer deprecation (fvdb never uses it); openvdb #2322 (open) which, after #757, touches only PadGrid.cuh — the one file this PR already reworks; everything under #770 steps 3–6.

Verification

On an RTX PRO 6000 Blackwell (sm_120), torch 2.13.0 / CUDA 13.0 env built from env/build_environment.yml + the test_environment.yml delta:

  • ./build.sh install gtests-Werror=all-warnings clean, zero diagnostics.
  • gtests via ctest: 47/47 passed.
  • pytest unit/test_sdf.py unit/test_io.py unit/test_basic_ops.py unit/test_basic_ops_single.py unit/test_prune_single_voxel.py unit/test_sliced_batch.py unit/test_inject.py unit/test_batched_topology_builder.py: 1270 passed, 1 skipped, 18 subtests passed.
  • Without commit 1, the same run at this pin is 995 passed / 275 failed, all in test_io.py — that is the regression test for the header fix.

Each commit builds on its own; commit 1 is also correct at the old pin. Note for anyone reproducing: ./build.sh ctest currently picks up the CPM blosc-subbuild's CMakeCache.txt (find … -print -quit) and reports no tests; run ctest in build/<tag>/src directly. Pre-existing, not touched here.

clang-format 18 / black 24 check: clean.

Review

Codex-reviewed (read-only, full diff + tree) before this revision. It confirmed the PruneGrid stream omission and the blind-metadata/checksum staleness above — all folded in. Two further pre-existing items it raised are not fixed here and are noted for follow-up: patchGridWithBlindShape (and the CUDA export path) rewrite checksum-covered GridData fields after the checksum is computed, so exported standard-type files carry a stale checksum; and TorchDeviceBuffer::create treats a null stream as "unspecified", which makes the legacy default stream 0 indistinguishable from "use torch's current stream" — this is the design documented in the header comment (commit 2), flagged for @swahtz rather than changed.

🤖 Generated with Claude Code

harrism and others added 3 commits September 11, 2026 23:07
…ndle

Two places copy a NanoVDB GridData header out of a multi-grid batch into a
single-grid buffer and construct a GridHandle around it without resetting
the fields that describe the buffer the header now lives in:

- SaveNanoVDB.cu indexToGridHost(): `*dstGrid->data() = *srcGrid->data()`
  carries the source's mGridIndex (its batch position), mGridCount (the
  batch size) and mGridSize (the index grid's size, not the typed grid's)
  into the destination. tools::cuda::indexToGrid, which this function is a
  host port of, resets index/count to 0/1 (IndexToGrid.cuh:167-168); the
  port omitted that and never set mGridSize.

- LoadNanovdb.cpp copyIndexGridToHandle(): sets mGridCount = 1 and
  mGridSize, but leaves mGridIndex at the source's batch position. It also
  copies the grid only up to its first blind-metadata record, yet leaves
  mBlindMetadataCount/Offset claiming the record it dropped; those are
  cleared too, so the header describes the buffer it is in.
  Every one of these writes also invalidates the checksum the file carried,
  which was already left stale; it is now marked disabled, the convention
  the other header-fixup sites (MakeContiguous, ConcatenateGrids) follow.

This has been harmless so far: GridHandle took the header at its word, and
the callers either overwrote the fields later (patchGridWithBlindShape) or
never read them. NanoVDB #2292/#2301 make GridHandle validate a host
buffer's grid chain at construction -- every header must be valid, carry
index i of count N, and have a size that fits the buffer. Once the pin
advances past them, saving a batch of N > 1 grids to a standard NanoVDB
type fails with "GridHandle was constructed with an invalid host buffer
(grid 1 of N)" (the stale mGridSize points the chain walk into node data),
and loading grid i > 0 of a tensor-grid file fails with "inconsistent grid
index/count (grid 0 of 1)". 275 of the 622 cases in tests/unit/test_io.py.

Set index 0 / count 1 / the destination's own size before constructing the
handle in both places. This is correct at the current pin as well, so it
lands ahead of the bump that enforces it.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Signed-off-by: Mark Harris <mharris@nvidia.com>
…coverage

Bump the NanoVDB pin to 31d1e596, the merge commit of openvdb #2319. This
carries the whole "introduce + deprecate" arc of openvdb #2232 that has
landed so far: #2288 (single-space GridHandle<cuda::Buffer<T,R>>), #2292
(cuda::copyTo), #2293 (reset() via destroy()), #2301 (DeviceBuffer
deprecated in favour of DualDeviceBuffer; single-space VBM accessors;
HandleStorage.h), and #2319 (per-device DistributedPointsToGrid
resources; UnifiedBuffer deprecated). The hasDeviceSingle/hasHostSingle
traits are detected with false defaults, so TorchDeviceBuffer's
dual-trait specialization is unaffected.

#2301 also changes what PadGrid.cuh, our local TopologyBuilder-based padding
op, can rely on, and this repo builds with -Werror=all-warnings:

- nanovdb::cuda::DeviceBuffer becomes a [[deprecated]] alias of the renamed
  DualDeviceBuffer. PadGrid's getHandle() default template argument spelled
  the old name; it now names DualDeviceBuffer (the same class). No fvdb caller
  uses the default -- they pass TorchDeviceBuffer explicitly.

- TopologyBuilder::mProcessedRoot is removed outright, replaced by a pinned
  host staging buffer plus device scratch behind allocateProcessedRoot() /
  uploadProcessedRoot() / deviceProcessedRoot(). PadGrid::padRoot() built the
  padded root into a DeviceBuffer it assigned to mProcessedRoot and then
  deviceUpload()ed; it now fills the builder's staging area and uploads on
  mStream, mirroring upstream DilateGrid::dilateRoot. The upload is now
  asynchronous (pinned source) and the device copy allocates through the
  builder's ResourceT (BuilderResource) instead of DeviceBuffer's pool. The
  kernels already consumed deviceProcessedRoot(), so they are unchanged.

On top of the bump, three allocator-coverage improvements:

- ReinitializeSdf: the VoxelBlockManager's firstLeafID/jumpMap buffers
  now allocate through BuilderResource (torch's active CUDA allocator)
  via a local TorchVbmBuffer adapter, instead of DeviceBuffer's separate
  pool. (The adapter is removed in the next commit: #2301 gives the VBM
  handle the single-space accessors and prototype-buffer allocation the
  adapter was working around.)

- SaveNanoVDB: the device staging buffers (the defensive host-grid
  upload and the per-batch (N+1)-element value buffer) become
  nanovdb::cuda::Buffer over BuilderResource, stream-ordered on the save
  stream. The indexToGrid output handle stays TorchDeviceBuffer for now.

- TorchDeviceBuffer: CUDA allocations can now be associated with an
  explicit stream (raw_alloc_with_stream), and create() forwards the
  stream nanovdb builders pass instead of discarding it. Previously the
  allocation was silently associated with the device's current torch
  stream, which is only correct when that coincides with the builder's
  stream.

Rebased onto main past openvdb#754 (pin 9df9ec06) and openvdb#757; the pin conflict was
resolved forward to 31d1e596 inside this commit so that it builds, since
the original e679862f is behind main's pin.

Originally verified by the author at pin e679862f: full rebuild (112
targets, sm_120, -Werror=all-warnings clean); tests/unit/test_sdf.py
10/10, test_io.py 622/622, test_basic_ops.py 276/276 (+1 skip) on an RTX
PRO 6000 Blackwell. Re-verification at 31d1e596 is recorded on the PR.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Signed-off-by: Jonathan Swartz <jonathan@jswartz.info>
(cherry picked from commit 2d0eb61)
Signed-off-by: Mark Harris <mharris@nvidia.com>
openvdb #2301 (in the pin advanced by the previous commit) removes the
reasons three device-only scratch/output buffers were still routed through
dual-space types. Each now uses nanovdb::cuda::Buffer<T, BuilderResource>
directly, so it allocates from torch's active CUDA allocator, stream-ordered
on the stream that uses it, with no adapter in between.

- ReinitializeSdf: delete the TorchVbmBuffer adapter. It existed because
  VoxelBlockManagerHandle's device accessors were gated on hasDeviceDual and
  buildVoxelBlockManager allocated through the legacy BufferT::create. #2301
  adds hasDeviceSingle accessors to the handle and routes its allocation
  through createDeviceStorage with an optional prototype buffer, so
  VoxelBlockManagerHandle<cuda::Buffer<std::byte, BuilderResource>> works
  as-is: BuilderResource is default-constructible and stream-ordered, so the
  nullptr prototype yields Buffer(stream, bytes, noInit) on the reinit
  stream, and reset() releases through destroy(). This also drops the
  adapter's host-side data() returning nullptr and the const_cast in its
  deviceData(), neither of which had a legitimate caller.

- BuildPrunedGrid: the per-leaf keep-mask scratch becomes
  cuda::Buffer<Mask<3>, BuilderResource> on the fill kernel's stream, with a
  typed data() replacing two reinterpret_casts. noInit is behaviour-
  preserving: TorchDeviceBuffer never zeroed the allocation either, and
  InjectPredicateToMaskFunctor writes every mask word (zero then
  setOnAtomic) before PruneGrid reads it.
  PruneGrid is now also constructed on that stream: it defaults to the legacy
  stream 0, so its reads of the mask (and its two internal synchronizations)
  were not ordered after the fill kernel when torch's current stream is a
  non-blocking one -- a pre-existing race, closed here since the comment
  above would otherwise be false.

- SaveNanoVDB: the indexToGrid output handle becomes
  GridHandle<cuda::Buffer<std::byte, BuilderResource>>. IndexToGrid's
  getHandle allocates through createDeviceStorage at this pin, so the
  default-constructed pool buffer supplies the resource and the previous
  TorchDeviceBuffer(0, device) guide is no longer needed. The D2H staging
  copy reads buffer().data() instead of deviceData(). With this, no
  TorchDeviceBuffer is allocated in SaveNanoVDB; the remaining mentions read
  the grid batch's own handle and belong to the storage flip in openvdb#770.

Grid storage itself (GridBatchData's GridHandle<TorchDeviceBuffer>) is
unchanged here; that is the subject of openvdb#770 steps 3-5.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Signed-off-by: Mark Harris <mharris@nvidia.com>
@harrism
harrism force-pushed the mjh/nanovdb-memres-step1-2 branch from 05f5037 to d6e9300 Compare September 11, 2026 13:14
@harrism

harrism commented Sep 11, 2026

Copy link
Copy Markdown
Contributor Author

Analysis by Claude Code, run by @harrism.

Revised since the first push, after a Codex review of the full diff (which should have preceded opening this — it didn't; corrected now). Two of its confirmed findings were real and are folded in:

  • BuildPrunedGrid.cuPruneGrid was never given a stream. Its constructor defaults to legacy stream 0, so its mask reads and its two internal cudaStreamSynchronizes were not ordered after the fill kernel whenever torch's current stream is non-blocking. Pre-existing, but the new comment on that block asserted same-stream ordering, so it is fixed rather than reworded: pruneOp(grid, leafMask, stream.stream()).
  • LoadNanovdb.cppcopyIndexGridToHandle header now fully describes its buffer. Beyond mGridIndex, it copies the grid only up to the first blind-metadata record yet left mBlindMetadataCount/Offset claiming it, and every header write left the file's checksum stale. Both cleared; checksum marked disabled per the MakeContiguous/ConcatenateGrids convention.

Two further pre-existing items from the review are not fixed here, deliberately, and are in the body under Review: stale checksums in exported standard-type files (patchGridWithBlindShape and the CUDA export path both rewrite checksum-covered fields after computing it), and TorchDeviceBuffer::create's null-stream-means-current-stream ambiguity, which is the design documented in commit 2 — @swahtz, flagging that one to you rather than changing it.

Re-verified at the new head: -Werror clean, 47/47 gtests, 1270 passed / 1 skipped across the eight suites, CI-equivalent clang-format clean on all 344 src/ files.

@swahtz swahtz left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review of steps 1–2 against the pinned headers at 31d1e596 and the local c10 source. One regression that should be fixed before merge (inline on TorchDeviceBuffer.cpp), one comment that mis-states an upstream invariant, and cleanups. Plan-level items that belong to later steps are on #770 rather than here.

Two notes on lines outside the diff hunks:

  • BuildPrunedGrid.cu:103nanovdb::cuda::mergeGridHandles(handles, &guide) still runs on legacy stream 0 while this PR is making the rest of the file stream-correct (the new comment on the PruneGrid call describes exactly this read-before-write class). Suggest passing stream.stream() here since the file is already open; the other eight mergeGridHandles sites and three voxelsToGrid sites are step 5's.
  • SaveNanoVDB.cu:11,16<nanovdb/cuda/DeviceBuffer.h> and <c10/cuda/CUDACachingAllocator.h> have no remaining uses in the file; the diff added <nanovdb/cuda/Buffer.h> next to DeviceBuffer.h rather than replacing it.

Not raised: the unreachable H2D fallback in fvdbToNanovdbGridWithValues (rewritten in step 4) and the raw_alloc ternary (class is deprecated in step 6).

🤖 Generated with Claude Code

Comment thread src/fvdb/TorchDeviceBuffer.cpp
Comment thread src/fvdb/detail/io/SaveNanoVDB.cu Outdated
Comment thread src/fvdb/BuilderResource.h Outdated
Comment thread src/fvdb/TorchDeviceBuffer.h Outdated
@harrism

harrism commented Sep 12, 2026

Copy link
Copy Markdown
Contributor Author

Analysis by Claude Code, run by @harrism.

CI is 41/41 on d6e9300. For the record, the pip CUDA 13.2 workflow shows three attempts: attempt 1 failed only test_sample.py::test_trilinear_dense_vs_pytorch_05_cuda (fp16 × 16ch, Max grad error 0.1875) — a pre-existing flake unrelated to this PR (its backward accumulates with gpuAtomicAddNoReturn, so seeded inputs still see order-dependent fp16 rounding; it passed 8/8 locally on this exact code and on the previous head's identical job). Attempt 2 was a "re-run failed jobs" that can never start on our ephemeral runners (noted on #761); attempt 3 is the full rerun, all green.

@swahtz

swahtz commented Sep 12, 2026

Copy link
Copy Markdown
Contributor

Analysis by Claude Code, run by @harrism.

CI is 41/41 on d6e9300. For the record, the pip CUDA 13.2 workflow shows three attempts: attempt 1 failed only test_sample.py::test_trilinear_dense_vs_pytorch_05_cuda (fp16 × 16ch, Max grad error 0.1875) — a pre-existing flake unrelated to this PR (its backward accumulates with gpuAtomicAddNoReturn, so seeded inputs still see order-dependent fp16 rounding; it passed 8/8 locally on this exact code and on the previous head's identical job). Attempt 2 was a "re-run failed jobs" that can never start on our ephemeral runners (noted on #761); attempt 3 is the full rerun, all green.

I also noted that flaky test. I have a fix for it in the #753 PR because I hit it there too.

harrism and others added 2 commits September 15, 2026 13:01
Review of openvdb#773 (swahtz) found that forwarding a nanovdb builder's stream
into raw_alloc_with_stream keys the block to that stream in torch's
native caching allocator. There the allocation stream is a partition
key, not an ordering point: only later allocations on the same stream
can reuse the block, and freeing does no synchronization. At pin
31d1e596 DistributedPointsToGrid creates its output storage on its
DeviceMesh stream, which the PrivateUse1 dispatches of BuildGridFromIjk
and BuildDenseGrid create per batch item and destroy with the mesh. A
grid block keyed to it would never be reused (reserved memory grows one
segment per build until an OOM release), expandable segments would
synchronize on the destroyed handle when releasing it, and the
cudaMallocAsync backend would cudaFreeAsync on it. The path is not
reachable from fvdb-core today (nothing registers the PrivateUse1
device), but the storage resource planned in openvdb#770 step 3 would inherit
the policy.

Fix it in the resource, once. TorchResource::allocate_async always
allocates on the current device's current torch stream; when the
caller's write stream differs, it waits on an event recorded right after
the allocation, so the caller's writes are ordered behind the block's
previous tenant, whose work may still be queued on the torch stream.
TorchDeviceBuffer's CUDA paths allocate and free through TorchResource
instead of calling c10 directly, so it carries no allocation policy of
its own. The stream parameter on both is now documented as the stream
the caller writes on, not the allocation stream.

Verified: -Werror clean, 47/47 gtests, 1605 passed / 1 skipped across
nine pytest suites, clang-format clean.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Signed-off-by: Mark Harris <mharris@nvidia.com>
…ment

Three items from swahtz's review of openvdb#773.

- BuilderResource.h gains `template <class T> using BuilderBuffer =
  nanovdb::cuda::Buffer<T, BuilderResource>;` and the six spelled-out
  Buffer<..., BuilderResource> sites (SaveNanoVDB, ReinitializeSdf,
  BuildPrunedGrid) use it, so retargeting the resource retargets the
  ops' own staging and scratch buffers too. The header comment now
  describes the category of allocation the alias covers instead of
  listing files, which had already fallen out of date.

- TorchDeviceBuffer no longer treats a null stream as "unspecified".
  Null is the legacy default stream, and TorchResource orders it after
  the allocation like any other write stream (no-op when torch's current
  stream is also the default). This removes the documented ambiguity
  between an omitted stream and explicit stream zero, and it makes the
  synchronous cudaMemcpy paths in to() correctly ordered behind the
  allocation even when a non-blocking torch stream is current.

- SaveNanoVDB indexToGridHost: the comment now states what the code
  guarantees (one grid, header index/count/size match this buffer) and
  drops the claim that the CUDA indexToGrid does the same; at this pin
  it does not reset mGridSize. The empty-grid divergence between the two
  paths is tracked on openvdb#770 for step 4.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Signed-off-by: Mark Harris <mharris@nvidia.com>

@swahtz swahtz left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nice, thanks for addressing my notes. Looks good to go.

Codex review of d4d4cbf found a race it introduced. TorchResource had
been changed to allocate every block on torch's current stream and
event-order the caller's stream after it. That is the right policy for
storage but wrong for scratch: deallocate_async does not order the free,
so the block must be keyed to the stream its work runs on. The pinned
PointsToGrid destroys its per-tile counts immediately after enqueuing
the scan that reads them, and the 13 builders that bind BuilderResource
run on legacy stream 0. With the block keyed to a non-blocking torch
stream instead, torch could reuse it (native) or cudaFreeAsync it
(cudaMallocAsync backend) ahead of that scan.

- TorchResource::allocate_async keys the block to the caller's stream
  again, as before d4d4cbf. The comment now says why that is load-bearing
  and that the stream must outlive the block.

- TorchStorageResource (same header) carries the storage policy: allocate
  on the current torch stream through TorchResource, and if the caller's
  write stream differs make it wait on an event recorded after the
  allocation. If any event call fails the block is freed before the error
  is raised. Nothing orders the free: as for a tensor, a caller whose
  writer stream differs must synchronize it before releasing the storage.
  The builders fvdb hands TorchDeviceBuffer to (PointsToGrid,
  DistributedPointsToGrid, the topology builders, PadGrid) do so before
  returning their handle. This is the body openvdb#770's TorchDeviceResource
  should adopt.

- TorchDeviceBuffer's CUDA paths use TorchStorageResource. to()'s CUDA
  copies move off the legacy default stream onto the writing device's
  current torch stream (the block's stream), are synchronized before
  returning and error-checked; a cudaMemcpy on stream 0 was neither
  ordered after the source's writers on a non-blocking torch stream nor
  guaranteed complete before the block could be reused (pre-existing).
  Device-to-device first synchronizes the source device's stream and
  uses cudaMemcpyPeerAsync, which is correct without peer access enabled
  between the two pools (a plain device-to-device memcpy is not, and c10
  itself selects the peer copy for this case). The destination is owned
  by a scope guard until the copy has succeeded, so a failed copy no
  longer leaks it or leaves the buffer half-moved.
  The guard's destructor swallows a secondary CUDA error so a failing
  copy propagates instead of terminating the process.

- BuilderResource.h no longer claims to cover every op-lifetime device
  allocation; ops that still take CUB scratch from c10 directly are
  outside it until they migrate (openvdb#770 step 5).

Correction to d4d4cbf's message: BuildDenseGrid's PrivateUse1 dispatch
builds one DeviceMesh for the first batch item and copies that grid for
the rest; BuildGridFromIjk's builds one per item. The destroyed-stream
rationale is unchanged.

Reviewed with codex across five rounds before push; each round's
findings are folded in above.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Signed-off-by: Mark Harris <mharris@nvidia.com>
@harrism

harrism commented Sep 15, 2026

Copy link
Copy Markdown
Contributor Author

Follow-up 6f605b9, after a codex review of d4d4cbf and 6e8420c that should have preceded pushing them. Its main finding was a race d4d4cbf introduced: keying scratch to torch's current stream is wrong, because TorchResource::deallocate_async does not order the free and the pinned PointsToGrid destroys its per-tile counts right behind an in-flight scan on legacy stream 0. The block has to be keyed to the stream its work runs on. So the two policies are now two resources:

Also in the commit, all surfaced by the same review: TorchDeviceBuffer::to()'s CUDA copies move off the legacy default stream onto the writing device's torch stream, are synchronized and error-checked, use cudaMemcpyPeerAsync across devices, and hold the destination in a scope guard so a failed copy neither leaks nor half-moves the buffer (all pre-existing). BuilderResource.h no longer claims to cover ops that still take CUB scratch from c10 directly. And a correction to d4d4cbf's message: BuildDenseGrid's PrivateUse1 dispatch builds one mesh for the first item and copies for the rest; only BuildGridFromIjk builds one per item.

Five codex rounds to clean. Re-verified at 6f605b9: -Werror clean, 47/47 gtests, 1605 passed / 1 skipped across nine suites, clang-format clean.

@swahtz

swahtz commented Sep 15, 2026

Copy link
Copy Markdown
Contributor

[P3] Preserve zero-byte buffer transfers to CUDA — src/fvdb/TorchDeviceBuffer.cpp:177

The new allocation path changes the behavior of an empty C++ buffer:

fvdb::TorchDeviceBuffer buf;
buf.to(torch::Device(torch::kCUDA, 0));

Previously this succeeded: the native allocator returns a null pointer for a zero-byte allocation, and the zero-byte H2D cudaMemcpy succeeds. Now deviceAlloc(0) reaches TorchResource::allocate_async, whose null-pointer check throws fvdb: TorchResource::allocate_async failed. The new CUDA-to-CUDA allocation path has the same issue for zero-sized buffers.

Please handle zero-sized transfers without allocating, while preserving the requested destination device, and add a focused buffer-level regression test. This concerns zero-byte TorchDeviceBuffer objects, not empty voxel grids that still have an allocated NanoVDB header.

Validation: confirmed the native allocator's null result and the old zero-byte H2D copy's success with a local CUDA probe; the new exception follows from the allocation call chain at 6f605b9. I did not rebuild the updated PR.

swahtz: after 6f605b9 an empty buffer could no longer be moved to CUDA.
to() reached TorchStorageResource, whose null-pointer check treats the
allocator's null result for a zero-byte request as failure, where the
old raw_alloc(0) plus zero-byte cudaMemcpy succeeded. An empty buffer
owns no allocation on either device, so to() now just records the new
device for size 0, for every device combination.

The size-0 return also precedes the same-device check, which used to
reject an empty buffer moved to the device it was already on.

New TorchDeviceBufferTest, written so each test fails deterministically
without the behaviour it guards: the empty moves (CPU to CUDA and back,
same device); a 1 MiB move to CUDA under a non-default current torch
stream held open by a spin kernel, read back on the legacy stream, so an
upload that was only enqueued would read stale bytes; and construction
with a foreign write stream after giving the block a previous tenant
still being filled on the current stream, so a writer not ordered after
the allocation would have its memset overwritten. The last test skips
when the allocator does not hand the same block back or the tenant has
already finished. Verified by mutation: with both guarded behaviours
removed the two tests fail 3/3; restored, all four pass 3/3.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Signed-off-by: Mark Harris <mharris@nvidia.com>
@harrism

harrism commented Sep 15, 2026

Copy link
Copy Markdown
Contributor Author

Confirmed, thank you: to() reached the storage resource's null check for a zero-byte request. Fixed in 8a77387: to() returns early for size 0, recording the requested device and nothing else, and that now precedes the same-device check, which also used to reject an empty buffer moved to the device it was already on.

New TorchDeviceBufferTest (four gtests): the empty moves, plus two that were worth writing while here. Each of those two was rebuilt until a mutation run proved it earns its keep: with the CPU-to-CUDA synchronization removed from to() and the allocation-to-writer event removed from TorchStorageResource, the two tests fail on 3 of 3 runs; with the source restored all four pass on 3 of 3, 48/48 gtests, 1605 pytest. The one residual is a host thread descheduled for longer than the 0.25 s spin at exactly the wrong moment, which turns a would-be failure into a pass, never the reverse; it is documented on the constant.

@harrism
harrism merged commit 46d10f4 into openvdb:main Sep 15, 2026
41 checks passed
@harrism
harrism deleted the mjh/nanovdb-memres-step1-2 branch September 15, 2026 07:37
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.

2 participants