Skip to content

Add GridStorage, TorchDeviceResource and device-handle utilities (#770 step 3) - #786

Merged
harrism merged 2 commits into
openvdb:mainfrom
harrism:mjh/nanovdb-memres-step3
Sep 16, 2026
Merged

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

Conversation

@harrism

@harrism harrism commented Sep 15, 2026

Copy link
Copy Markdown
Contributor

Summary

Step 3 of #770: the primitives the grid-storage flip is built on. Nothing uses them yet. GridBatchData still holds GridHandle<TorchDeviceBuffer>; steps 4 and 5 move storage and the builders onto these. Reviewing this on its own keeps the flip PR to mechanical call-site changes.

What is added

piece role
TorchDeviceResource.h Storage resource bound to one torch device (CUDA:i or PrivateUse1). NanoVDB picks host vs. device storage per buffer type, so the device travels in the resource: a prototype buffer's resource() is what createDeviceStorage and cuda::copyTo allocate from. The block is keyed to the buffer's retained stream (the TorchResource policy): a cuda::Buffer orders its writes and its free on that stream, so torch's reuse rule and NanoVDB's ordering model agree with no events. This is deliberately not TorchStorageResource's current-stream policy from #773, which is right for the dual-space TorchDeviceBuffer (no retained stream, unordered free) and wrong here; see the header comment. Obligations: the retained stream must outlive the block and must not be changed after allocation (set_stream/resize to another stream); GridStorage copies instead. DeviceGridBuffer = cuda::Buffer<std::byte, TorchDeviceResource>.
GridStorage.{h,cu} variant<GridHandle<HostBuffer>, GridHandle<DeviceGridBuffer>> + torch::Device. Count, sizes, offsets, types and typed grid pointers come from adopted metadata; no device reads. to(device, stream) is cuda::copyTo with explicit ordering (below). empty(device), deviceProto(device, stream) (replaces the zero-size TorchDeviceBuffer guide). Host-includable.
GridHeaderUtils.h normalizeStandaloneGridHeader: the one spelling of the header writes every hand-wrapped grid needs now that GridHandle validates the chain (index 0 of 1, size, no blind data with offset = size, checksum disabled). The six existing sites migrate in step 4.
DeviceGridHandleUtils.cuh updateGridCountAndZeroChecksum hoisted from MakeContiguous/ConcatenateGrids; makeDeviceHandleFromLayout; mergeDeviceGridHandles, the single-space replacement for nanovdb::cuda::mergeGridHandles: one allocation, one memcpy + one header kernel per grid, host-assembled metadata, no per-grid synchronization.
GridStorageTest.cu Twelve gtests, below.

Stream contract of GridStorage::to

  • The copy runs on the caller's stream. If the source is device storage, stream first waits on the source's retained stream, so the source's writers are complete.
  • For a device destination the source's retained stream then waits on the copy. Destroying the source right after to() returns is therefore safe even when torch's caching allocator hands its block straight to the next allocation on that stream. This is the failure the Finish adopting NanoVDB's injectable CUDA memory resources (openvdb #2232): move grid storage off the dual-space TorchDeviceBuffer #770 design review reproduced with a probe; SourceMayBeDestroyedRightAfterAsyncCopy reproduces it deterministically (the copy stream is held open by a spin kernel, the source is destroyed, its block is re-taken and overwritten on the source stream) and passes.
  • A host destination is complete on return (copyTo synchronizes), so no post-copy ordering is needed there.
  • Across CUDA devices the copy is cudaMemcpyPeerAsync (torch's expandable-segment and cudaMallocAsync backends do not support a plain memcpy between devices without peer access). Events are recorded under their stream's device.
  • Empty storage moves without allocating and lands on the requested device. copyTo alone returns a default-constructed handle, whose resource is the current device (design review item 2).
  • An explicit legacy stream 0 is honoured as the retained stream; an omitted stream means the destination device's current torch stream (design review item 3).

Tests

HostAccessorsAnswerFromMetadata, EmptyStorageKeepsItsDevice, DeviceProtoCarriesDeviceAndStream, RoundTripThroughCudaPreservesBytesAndMetadata, ExplicitStreamZeroWhileNonDefaultStreamIsCurrent, SourceMayBeDestroyedRightAfterAsyncCopy, MergeDeviceGridHandlesMatchesHostMerge (byte-for-byte against nanovdb::mergeGrids, including an empty source in the middle, then re-parsed from raw bytes), MakeDeviceHandleFromLayoutChecksExtent, NormalizeStandaloneGridHeaderMakesAGridWrappable (the un-normalized copy is rejected by GridHandle; the normalized one wraps), StorageBlockReturnsToItsRetainedStream, SynchronousResourceApiWorksUnderAnyCurrentDevice. The lifetime test uses a source retained on a non-current stream, the case that distinguishes the two keying policies.

Verification

./build.sh install gtests with -Werror=all-warnings: clean. ctest 49/49 (the new suite 12/12 on three consecutive runs). The nine pytest suites touched by the epic: unchanged. clang-format: clean. Codex-reviewed before opening; three passes found twelve real defects, all folded in and listed in the commit message.

For #770

Step 5's DistributedPointsToGrid path creates storage on a DeviceMesh stream that dies with the mesh. Under retained-stream keying that is an orphaned block; it has to be solved at that builder (a long-lived mesh, or re-homing the storage after the builder's own synchronization), not in the resource.

🤖 Generated with Claude Code

…nvdb#770 step 3)

The primitives the grid-storage flip (openvdb#770 steps 4-5) is built on. Nothing
uses them yet; GridBatchData still holds GridHandle<TorchDeviceBuffer>.

- TorchDeviceResource: a storage resource bound to one torch device
  (CUDA:i or PrivateUse1). NanoVDB picks host vs. device storage per
  buffer *type*, so the device has to travel in the resource: a prototype
  buffer's resource() is what createDeviceStorage and cuda::copyTo
  allocate from, which makes every allocation device-correct without a
  caller-side guard. The block is keyed to the buffer's retained stream
  (the TorchResource policy), not to torch's current stream as openvdb#773's
  TorchStorageResource does for the legacy dual-space buffer: a
  cuda::Buffer orders its writes and its free on the retained stream, so
  keying the block there makes torch's reuse rule and NanoVDB's ordering
  model agree with no events. Codex found the alternative wrong: a buffer
  retained on B but keyed to the current stream A is freed to A, which
  nothing that ordered B was protecting. The obligation this leaves is
  that the retained stream outlive the block (torch pool streams and the
  legacy stream do); DistributedPointsToGrid's mesh streams do not, which
  step 5 must handle at that builder. And the retained stream must not
  change after allocation (no set_stream/resize to another stream), since
  torch frees to the keyed stream whatever stream the free names;
  GridStorage copies instead. The synchronous allocate/deallocate
  are spelled out so their synchronization runs under the bound device.
  PrivateUse1 allocates through its registered allocator. DeviceGridBuffer
  = cuda::Buffer<std::byte, TorchDeviceResource>.

- GridStorage: variant<GridHandle<HostBuffer>, GridHandle<DeviceGridBuffer>>
  plus a torch::Device. Everything about the grids (count, sizes, offsets,
  types, typed pointers) is answered from adopted metadata without
  touching device memory. to(device, stream) is cuda::copyTo with the
  ordering the design review asked for: the copy stream waits on the
  source's retained stream, and for a device destination the source's
  stream waits on the copy, so the source may be destroyed as soon as
  to() returns even with allocator caching handing its block straight to
  the next tenant. Across CUDA devices the copy is a peer copy (torch's
  expandable-segment and cudaMallocAsync backends do not support a plain
  memcpy between devices without peer access), and every event is
  recorded under its stream's device. Empty storage moves without
  allocating and keeps the
  requested device, in its buffer's resource as well as in device(),
  where copyTo alone would default-construct a handle on the current
  device. deviceProto(device, stream) is the zero-byte
  prototype that replaces the zero-size TorchDeviceBuffer "guide". The
  header is host-includable.

- GridHeaderUtils.h: normalizeStandaloneGridHeader, the one spelling of
  the header writes every hand-wrapped grid needs now that GridHandle
  validates the chain at construction (index 0 of 1, size, no blind data
  with offset = size per GridData::init, checksum disabled). The six
  existing sites migrate in step 4.

- DeviceGridHandleUtils.cuh: the updateGridCountAndZeroChecksum kernel
  hoisted out of MakeContiguous/ConcatenateGrids; makeDeviceHandleFromLayout
  for callers that know their layout; mergeDeviceGridHandles, the
  single-space replacement for nanovdb::cuda::mergeGridHandles with one
  allocation, one memcpy and one header kernel per grid, host-assembled
  metadata and no per-grid synchronization, and per-source stream ordering
  before and after the copy.

- GridStorageTest: twelve gtests, including the three contract tests from
  the openvdb#770 design review (source destroyed right after an asynchronous
  device copy, with caching, held deterministic by a spin kernel; empty
  transfer preserving the destination device; explicit stream zero while
  a non-default torch stream is current), a byte-for-byte comparison of
  mergeDeviceGridHandles against nanovdb::mergeGrids, and a check that a
  freed block is handed back on its retained stream and not on torch's
  current one. The lifetime test uses a source retained on a stream that
  is not the current one, the case the first draft got wrong.

Reviewed with codex before opening; its first pass found the keying
error above, an event recorded under the wrong device for cross-device
copies, the missing peer copy, empty handles carrying the current
device's resource, an overflowable layout check, and the inherited
synchronous allocate synchronizing the wrong device. Its second pass
found the cross-device wait enqueued under the signaler's device (a null
waiter then named the wrong GPU's default stream), the PrivateUse1
synchronize fallback with the same null-stream problem, empty transfers
dropping the requested stream, and the set_stream/resize constraint
above. Its third pass found the PrivateUse1 fallback skipped by the
same-stream shortcut and the merge's copies and kernels running without
the prototype's device current. All folded in.

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.

Reviewed at f28cfb3 against the #770 plan and its comment thread. Two items look like they should land before step 4 builds on these primitives (the PrivateUse1 host-source sync and the 2-arg constructor). The rest are nits or optional cleanups, marked as such. Points that belong to later steps rather than this PR (checksum policy for the merge helper, cross-stream readers, PrivateUse1 ordering cost, the long-name flag in the normalizer) are on #770 instead.

Comment thread src/fvdb/GridStorage.cu Outdated
Comment thread src/fvdb/GridStorage.cu Outdated
Comment thread src/fvdb/detail/utils/nanovdb/DeviceGridHandleUtils.cuh Outdated
Comment thread src/fvdb/detail/utils/nanovdb/DeviceGridHandleUtils.cuh Outdated
Comment thread src/tests/GridStorageTest.cu
Comment thread src/fvdb/detail/utils/cuda/StreamOrdering.h
Comment thread src/fvdb/GridStorage.h Outdated
…ng, strict layout

swahtz's seven threads, all taken.

- GridStorage::to, host to PrivateUse1: cuda::copyTo does not synchronize
  for a device destination and PrivateUse1 storage hands its unified
  pointer to the host immediately, so hostGridAt() could read pre-copy
  bytes. The copy stream is synchronized for that destination; the
  comment is narrowed to the CUDA case.

- GridStorage(DeviceHandle&&, device): an empty handle carries the
  resource it was default-constructed with (the current device, e.g.
  cuda::copyTo's result for a zero-byte source). It is now re-homed onto
  the named device so the buffer agrees with device() for anyone who
  re-wraps it or uses it as a prototype, the same rule empty() and to()
  already applied.

- makeDeviceHandleFromLayout requires what the rest of the stack can
  consume: grids packed end to end from offset 0 at NANOVDB_DATA_ALIGNMENT,
  and no metadata iff the buffer is empty. A gap, a misaligned offset or
  metadata-less bytes now throw instead of yielding a handle the chain
  parse rejects or the header kernel faults on. Tests cover the gap and
  the empty-metadata cases.

- mergeDeviceGridHandles's comment now says exactly who does what with
  checksums: the CUDA MakeContiguous/ConcatenateGrids kernel disables
  them; nanovdb::mergeGrids, the CPU sides of those ops and the replaced
  nanovdb::cuda::mergeGridHandles update an existing one. It no longer
  claims parity with the replaced path; the step-5 choice is on openvdb#770.

- The block-reuse test skips unless the native caching allocator is
  active, since only it keys free blocks by stream; under
  backend:cudaMallocAsync opportunistic reuse could hand the block to
  another stream and fail the test for a correct resource.

- orderStreamAfter was tried on c10::cuda::CUDAEvent as suggested and
  reverted: c10's stream conversion rejects the cudaStreamLegacy and
  cudaStreamPerThread sentinels, which raw CUDA accepts. The raw version
  keeps the same three guards (record under the signaler's device, wait
  under the waiter's, destroy under the signaler's).

- hostGridAt/deviceGridAt delegate to the handles' own grid<T>() /
  deviceGrid<T>() per variant arm, and one metaAt(i) replaces the three
  duplicated range checks.

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.

Looks good, thanks for addressing my comments

@swahtz swahtz added the core library Core fVDB library. i.e. anything in the _Cpp module (C++) or fvdb python module label Sep 15, 2026
@harrism
harrism merged commit 7d9b790 into openvdb:main Sep 16, 2026
41 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

core library Core fVDB library. i.e. anything in the _Cpp module (C++) or fvdb python module

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants