Skip to content

NanoVDB: migrate off the dual-space buffers and deprecate the DeviceBuffer name (CUDA) - #2301

Open
harrism wants to merge 15 commits into
AcademySoftwareFoundation:masterfrom
harrism:nanovdb-dual-buffer-deprecation
Open

NanoVDB: migrate off the dual-space buffers and deprecate the DeviceBuffer name (CUDA)#2301
harrism wants to merge 15 commits into
AcademySoftwareFoundation:masterfrom
harrism:nanovdb-dual-buffer-deprecation

Conversation

@harrism

@harrism harrism commented Aug 27, 2026

Copy link
Copy Markdown
Contributor

What this PR is, and why

This is the migrate-and-deprecate step of the memory-management work under #2232. Grid storage is moving from the dual-space DeviceBuffer (one object holding a host copy and a device copy, moved with deviceUpload/deviceDownload) to the single-space cuda::Buffer with explicit transfers (cuda::copyTo). This PR migrates everything inside NanoVDB and then deprecates the old name — designed so that nobody is broken and nobody gets a warning they cannot act on:

  • cuda::DeviceBuffer becomes a deprecated alias for the renamed implementation class. The GPU tools' default buffer types name the implementation directly, so code that relies on defaults — most user code — compiles without any warning until the defaults change at removal, one or more releases from now. Only code that spells DeviceBuffer sees the warning, and the message carries the migration recipe.
  • cuda::copyTo adopts the source handle's metadata — validated once, when that handle was constructed from raw bytes — so transfers run no kernel and are callable from host-only translation units (cuda/HandleStorage.h). The validation kernel keeps guarding the real trust boundary: constructing a handle from raw bytes.
  • Cross-stream ordering, which DeviceBuffer handled with internal events, is the documented four-line CUDA-event pattern in cuda/Buffer.h — matching cuda::buffer (CCCL) and RMM.
  • UnifiedBuffer intentionally keeps its name here: its replacement (ManagedResource, added in this PR, plus the multi-GPU tool that defaults to it) completes in the multi-GPU follow-up in the same release, with the identical alias treatment.

Commits, in review order

  1. Tool entry points accept single-space buffers — the tools allocated results through the duck-typed create() that cuda::Buffer doesn't have; a small bridge (cuda/HandleStorage.h) dispatches to create() or the buffer's memory resource. Plus the friendly copy() diagnostic from the NanoVDB: cuda::copyTo cross-space transfers and pinned-buffer GridHandles (CUDA) #2292 review.
  2. ManagedResource — cudaMallocManaged-backed; a handle over it exposes both the host and device accessors over one allocation (the UnifiedBuffer migration target). Also restores four buffer headers missing from the install list.
  3. TopologyBuilder's two dual members split into pinned host staging + resource-allocated device buffers, with the upload mechanics in one place instead of six tools.
  4. MeshToGrid's 15 internal buffers onto resource-backed cuda::Buffer (it predated the earlier scratch conversions); fixes its sidecar being hard-coded to DeviceBuffer, which meant the SidecarBufferT parameter never compiled with any other type.
  5. SignedFloodFill's root pass onto ManagedResource (the out-of-bounds read documented in NanoVDB: cuda::SignedFloodFill's root-tile pass reads out of bounds on the device (confirmed with compute-sanitizer) #2300 is pre-existing and unchanged here; its fix belongs to that issue).
  6. All 15 CUDA examples migrated — including three pre-existing example bugs found on the way (a grid-index example whose "GPU" output was computed on the CPU, a deprecated shim use, a dead #if 0).
  7. The deprecation itself — rename + deprecated alias, with the tests keeping their coverage of the still-shipping dual paths under file-local warning suppression.
  8. CUDA headers out of example host files — the topology drivers are plain .cpp; transfers route through their .cu companions (plus an old signature mismatch fixed).
  9. copyTo adopts metadata and becomes host-callable — deletes the transfer-helper pattern the previous commit needed, removes a scratch allocation and two synchronizing round trips per device transfer (net −59 lines).
  10. Review-round polish — comment style, the rename rationale documented at the class, one over-deletion restored.

Testing

Full CPU/CUDA/buffer/memory-resource/mgpu suites green at every step (155+63+46+19+11 on RTX 6000 Ada); example programs run-verified where buildable; python module builds; a two-translation-unit link check confirms the rename preserves the cross-TU constructor instantiation; a host-only probe TU calling copyTo compiles under g++ and clang.

🤖 Generated with Claude Code

@harrism
harrism requested a review from kmuseth as a code owner August 27, 2026 02:46
@harrism
harrism marked this pull request as draft August 27, 2026 03:23
@harrism
harrism marked this pull request as ready for review August 27, 2026 04:57
harrism and others added 10 commits August 27, 2026 05:18
…age (CUDA)

The GPU tools build their result handle through the duck-typed static
BufferT::create(size, pool, device, stream), so a single-space buffer type
(cuda::Buffer over a memory resource) did not compile at any entry point.
The new cuda/HandleStorage.h provides the bridge: createDeviceStorage
allocates through create() for buffers that provide it and through the
pool buffer's resource otherwise, and deviceStorageData returns the device
address either way (data() for single-space, deviceData() for dual). The
PointsToGrid family, TopologyBuilder (serving the topology ops and
MeshToGrid), IndexToGrid, addBlindData and VoxelBlockManager entry points
now allocate through the bridge; addBlindData also drops its
dual-space-only static_assert and orders its allocation on the stream it
already runs on.

The no-argument GridHandle::copy() now names its default-construction
requirement in a static_assert instead of failing inside the pool
construction, backed by a BufferIsDefaultConstructible detector beside the
other trait companions.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Signed-off-by: Mark Harris <mharris@nvidia.com>
ManagedResource is a synchronous memory resource over cudaMallocManaged,
the migration target for UnifiedBuffer users: its allocations are valid on
the host and the device, so a handle over cuda::Buffer<std::byte,
ManagedResource> parses its metadata on the host and exposes both accessor
families over the same allocation. The classification comes from a new
DEVICE_ACCESSIBLE resource marker (detected by
is_device_accessible_resource and forwarded by ResourceRef and
AsyncFromSync, like HOST_ACCESSIBLE): a buffer whose resource is
host-accessible AND device-accessible sets both hasHostSingle and
hasDeviceSingle, and the handle gates now distinguish device-only
single-space buffers (device metadata parse, host accessors are
compile-time errors) from both-space ones (host parse, everything
available). GridHandle's write/read and NodeManagerHandle follow the same
rule.

Also adds the four buffer/resource headers missing from
NANOVDB_INCLUDE_CUDA_FILES, so installed trees ship cuda/Buffer.h and
cuda/PinnedResource.h alongside the new ManagedResource.h and
HandleStorage.h.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Signed-off-by: Mark Harris <mharris@nvidia.com>
…t and device buffers (CUDA)

The builder's two remaining dual-space members carried a host side and a
device side inside one DeviceBuffer: mProcessedRoot (the consumer tools
build the new root topology on the host, then upload) and mData (the
builder parameters, written on the host and uploaded once). They are now
explicit: pinned host staging (so the uploads stay asynchronous, matching
the DeviceBuffer behavior) plus a device buffer allocated through the
builder's injected resource, with allocateProcessedRoot /
uploadProcessedRoot / uploadData carrying the mechanics once instead of
per tool. The six consumers (the five topology ops and MeshToGrid) each
lose their DeviceBuffer::create + deviceUpload pair, and the early release
in the empty-grid path frees both sides through the new members.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Signed-off-by: Mark Harris <mharris@nvidia.com>
…s (CUDA)

MeshToGrid predated the builder scratch conversions, so its three device
members and twelve local scratch buffers still allocated through the
dual-space DeviceBuffer, paying for host mirrors nothing read and
bypassing the injected resource. They are now
cuda::Buffer<std::byte, ResourceRef<ResourceT>> like the other builders,
allocated through the resource the class already takes (TopologyBuilder
gains a public ref() accessor for consumers sharing its instance), with
early releases through destroy() on the retained stream.

The UDF sidecar buffer was hard-coded to DeviceBuffer, so the
SidecarBufferT template parameter never compiled with any other type; it
now allocates through the entry-point bridge, honoring the caller's
sidecar prototype buffer.

Two ride-alongs from review: PointsToGrid's storage null-check message now
names the reachable failure (a buffer type producing no device memory)
instead of allocation failure, and IndexToGrid's commented-out
synchronization line is deleted.

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

The root-tile scanline pass interleaves host access with device reads of
the same bytes, which is what UnifiedBuffer provided; it now uses
cuda::Buffer over ManagedResource. One behavioral difference: the old
buffer reserved virtual address space and grew in place, so pointers
survived resize; the new buffer reallocates on growth past the 64 reserved
tiles, and the tree pointer is re-derived after the resize.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Signed-off-by: Mark Harris <mharris@nvidia.com>
…nsfers (CUDA)

The examples taught the dual-space workflow: build or read into a
DeviceBuffer handle, deviceUpload, use both sides, deviceDownload. All 15
CUDA examples now hold the grid in a host handle and move it with
cuda::copyTo -- the migration story external code will follow. Device-side
image and particle scratch becomes cuda::Buffer with explicit copies for
results, and voxels_to_grid demonstrates the full single-space flow: the
builder allocates its result directly in a cuda::Buffer, kernels use the
device handle, and copyTo brings the grid back for validation. Streams are
scoped to outlive the device handles whose buffers free on them.

Three pre-existing example defects fixed along the way: index_grid passed
its host grid where its kernel launcher expects the device grid, so the
example's GPU output was never produced on the GPU; nodemanager used the
deprecated cudaCreateNodeManager shim through an output parameter, now the
resource-taking overload; and a dead #if 0 alternative is deleted.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Signed-off-by: Mark Harris <mharris@nvidia.com>
The class is renamed to DualDeviceBuffer -- a transitional implementation
name -- and the public DeviceBuffer spelling becomes a deprecated alias
whose message carries the migration recipe. The GPU tools' buffer-type
defaults name the implementation directly, so code that relies on the
defaults compiles without any warning until the defaults change at
removal; only code that names DeviceBuffer is warned, and it is the code
that has an action available. The legacy CudaDeviceBuffer alias retargets
to the implementation so a use fires one warning, not two. The python
bindings keep their surface by naming the implementation internally, and
the NanoVDB tests keep exercising the dual-space paths -- which still
ship and need the coverage -- under translation-unit-local suppression
with a comment stating the policy.

cuda::Buffer's stream contract now documents the four-line event recipe
for handing buffer contents to another stream, which replaces the
event tracking DeviceBuffer carried. Also migrates the last device-only
DeviceBuffer scratch in the dilate example onto cuda::Buffer, and fixes a
pre-existing signature mismatch in the raytrace_level_set OpenVDB
comparison path that only an OpenVDB-enabled build would have hit.

UnifiedBuffer intentionally keeps its name for now: its replacement story
(ManagedResource plus the DistributedPointsToGrid re-plumb) lands in the
multi-GPU follow-up in the same release, and the alias treatment applies
there identically.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Signed-off-by: Mark Harris <mharris@nvidia.com>
The topology example drivers are plain .cpp translation units, and
including cuda/GridHandle.cuh there fails on every host compiler; they
route transfers through helpers in their _kernels.cu companions and hold
the returned handles through host-safe headers. Also fixes a pre-existing
signature mismatch in raytrace_level_set's OpenVDB comparison path, owns
the voxel example's input arrays as cuda::Buffer, and moves the multi-GPU
example onto the implementation name it migrates away from with
UnifiedBuffer.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Signed-off-by: Mark Harris <mharris@nvidia.com>
… host-callable (CUDA)

A handle-to-handle copy re-validated bytes it had just copied from an
already-validated handle: copyTo ended by constructing the destination
handle, whose single-space constructor runs the chain-validation kernel.
The metadata was validated once, when the source handle was constructed
from raw bytes -- so copyTo now adopts it, through a HandleFactory that is
the one gateway to building a handle from trusted metadata. With no kernel
in the transfer path, copyTo (and the storage helpers) move to the
host-includable cuda/HandleStorage.h: a plain C++ translation unit linked
against the CUDA runtime can transfer grid handles directly.

That deletes the transfer-helper pattern from every example -- the eight
host-main programs call copyTo themselves and their .cu companions lose
their uploadGrid shims -- and removes the metadata scratch allocation and
two synchronizing round trips from every device-destination transfer. The
validation kernel keeps guarding the real trust boundary: constructing a
handle from raw bytes.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Signed-off-by: Mark Harris <mharris@nvidia.com>
From the pre-publication review: trailing comments this work added get a
space before the slashes (closing-brace labels keep the surrounding
files' form); a comment narrating the language rather than the library is
deleted; the DualDeviceBuffer class doc states why the type is renamed
while its header is not; and the openvdb-to-nanovdb example's host-side
print function, deleted one line too eagerly by the helper-removal pass,
is restored.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Signed-off-by: Mark Harris <mharris@nvidia.com>
@harrism
harrism force-pushed the nanovdb-dual-buffer-deprecation branch from 0b6521d to 06d0d74 Compare August 27, 2026 05:19
@swahtz swahtz added the nanovdb label Aug 28, 2026
The \file tag named DualDeviceBuffer.h, a file that does not exist:
the header deliberately keeps its long-standing name and include path,
as its own class note explains. A \file tag that does not match the
actual file name detaches doxygen's file page from the header.

Signed-off-by: Mark Harris <mharris@nvidia.com>
Comment thread nanovdb/nanovdb/GridHandle.h Outdated
/// single-space device buffer, which has no host-readable bytes.
/// @warning Note that the return pointer can be NULL if the GridHandle was not initialized
template<typename U = BufferT, typename util::disable_if<BufferHasDeviceSingle<U>::value, int>::type = 0>
template<typename U = BufferT, typename util::disable_if<BufferHasDeviceSingle<U>::value && !BufferHasHostSingle<U>::value, int>::type = 0>

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.

💅 polish: ‏We use this BufferHasDeviceSingle<U>::value && !BufferHasHostSingle<U>::value expression 16 times here and 3 times in NodeManager.cuh, we could push this predicate into something named to DRY this up/improve robustness.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Good call. Named it BufferIsDeviceOnly beside the other detectors in HostBuffer.h and replaced all 19 spellings across the handle headers. Fixed in bfc450d.

#include <nanovdb/cuda/Buffer.h>
#include <nanovdb/GridHandle.h>
#include <nanovdb/cuda/UnifiedBuffer.h>
#include <nanovdb/cuda/Buffer.h>

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.

⌨️ typo:Buffer.h is already included 2 lines above

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Fixed in bfc450d.

Comment thread nanovdb/nanovdb/cuda/DeviceBuffer.h Outdated
/// build or read into a host handle and move it with
/// cuda::copyTo (see cuda/GridHandle.cuh), or allocate the
/// result of a GPU tool directly in a cuda::Buffer. Transfers
/// construct the device handle with a validation kernel, so

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.

❓ question: ‏Isn't this description inaccurate? Isn't copyTo kernel-free and host-callable?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Correct on both counts: the metadata-adoption commit made copyTo kernel-free and host-callable, and this doc predated it. Rewrote the @deprecated block and the alias message; they now point at cuda/HandleStorage.h, where copyTo lives. Fixed in bfc450d (a sibling message in GridHandle::copy caught in e8224fe).

Comment thread nanovdb/nanovdb/cuda/HandleStorage.h Outdated
else srcPtr = src.data();
constexpr cudaMemcpyKind kind = srcDev ? (dstDev ? cudaMemcpyDeviceToDevice : cudaMemcpyDeviceToHost)
: cudaMemcpyHostToDevice;
cudaCheck(cudaMemcpyAsync(dst.data(), srcPtr, bytes, kind, stream));

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.

🚩 issue: ‏(From Claude) cuda::copyTo returns with the H2D cudaMemcpyAsync still in flight for a stream-less (sync-resource) source such as a pinned-resource Buffer, leaving a use-after-free window when the source handle is destroyed promptly.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Real, but resolved in the contract rather than with a hidden synchronization: cross-stream ordering stays the caller's, and for a source with a retained stream it is automatic (destruction frees on the same stream as the copy). The gap was the documentation, which claimed the retained-stream overload has "no such requirement" without qualifying stream-less sources: a synchronous-resource (pinned) source frees immediately on destruction, unordered against the still-asynchronous copy. The @warning now states that responsibility explicitly — synchronize the stream before destroying such a source (pageable sources are exempt; their copies degrade to synchronous). Fixed in ec26d38.

Comment thread nanovdb/nanovdb/cuda/HandleStorage.h Outdated
constexpr cudaMemcpyKind kind = srcDev ? (dstDev ? cudaMemcpyDeviceToDevice : cudaMemcpyDeviceToHost)
: cudaMemcpyHostToDevice;
cudaCheck(cudaMemcpyAsync(dst.data(), srcPtr, bytes, kind, stream));
if constexpr (!dstDev)

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.

🚩 issue: ‏(From Claude) cuda::copyTo skips the documented pre-return synchronization for a ManagedResource destination: it is host-readable (hasHostSingle==true) yet classified dstDev==true, so host reads through the returned handle race the pending async copy.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Confirmed, and this one is a code fix: the documented postcondition is "a host-readable destination synchronizes before returning", and a ManagedResource destination is host-readable (its handle exposes grid() immediately) but was classified by hasDeviceSingle alone. The sync condition is now !dstDev || BufferHasHostSingle<DstBufferT> — pinned and managed destinations synchronize, device-only destinations stay stream-ordered. Added TestBuffer.GridHandleCopyToManagedSynchronizes. Fixed in ec26d38.

template <typename OtherBufferT>
inline GridHandle<OtherBufferT> GridHandle<BufferT>::copy(const OtherBufferT& other) const
{
static_assert(!(BufferHasDeviceSingle<BufferT>::value || BufferHasDeviceSingle<OtherBufferT>::value),

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.

🚩 issue: ‏(From Claude) GridHandle::copy() has no compiling route to a host buffer for ManagedResource handles, and its two error messages point at each other in a circle.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Half-confirmed: there is a compiling route — cuda::copyTo<HostBuffer>(managedHandle) works (managed handles satisfy the device-single side of its static_assert) — so the messages terminate rather than circle. But copy(pool)'s message did bounce you through copy()'s error to find that out; both messages now name cuda::copyTo and its header directly. Fixed in bfc450d and e8224fe.

/// @param stream cuda stream
/// @return Handle that contains a device NodeManager
template <typename BuildT, typename BufferT = DeviceBuffer>
template <typename BuildT, typename BufferT = DualDeviceBuffer>

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.

❓ question: ‏Why does NodeManager have to use DualDeviceBuffer, is it not possible to use the single-space cuda::Buffer?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

It doesn't have to be — the single-space route is the createNodeManager overload taking a memory resource (directly below this one): it returns a handle over cuda::Buffer<std::byte, ResourceRef<ResourceT>>, and GridStats uses it internally. The DualDeviceBuffer default on this overload is the legacy pool-buffer entry point, kept warning-free until the defaults flip at the removal step — the same policy as the tools' signature defaults. No change here; happy to add a cross-reference in the doc comment if that would help.

Comment thread nanovdb/nanovdb/cuda/HandleStorage.h Outdated
…UDA)

cuda::copyTo documents that a host-readable destination synchronizes
before returning, but the sync condition tested hasDeviceSingle alone,
so a ManagedResource destination - host-readable and device-accessible
from the same allocation - returned with the copy still in flight and
host reads through the handle raced it. The condition now keys on
host readability, so pinned and managed destinations synchronize and
device-only destinations stay stream-ordered.

The copy direction is now cudaMemcpyDefault: every pointer involved is
a UVA address, so the runtime infers the direction, as the distributed
builder's cross-device copies already rely on.

The stream-safety warning now states the source-lifetime contract for
sources without a retained stream: a synchronous-resource (pinned)
source frees immediately on destruction, unordered against the still-
asynchronous copy, so the caller synchronizes the stream before
destroying it.

Signed-off-by: Mark Harris <mharris@nvidia.com>
BufferIsDeviceOnly names the hasDeviceSingle-and-not-hasHostSingle
predicate that gates the handles' host accessors, replacing its 19
hand-spelled repetitions across the handle headers.

The DeviceBuffer deprecation text predated the metadata-adopting
copyTo: it claimed transfers run a validation kernel and belong in CUDA
translation units. copyTo is kernel-free and host-callable, and lives
in cuda/HandleStorage.h - the doc, the alias message, and
GridHandle::copy(pool)'s error message now say so (the latter pointed
at copy(), whose error then pointed at copyTo; it now names the
destination directly). Also drops a duplicated include.

Signed-off-by: Mark Harris <mharris@nvidia.com>
Signed-off-by: Mark Harris <mharris@nvidia.com>
Signed-off-by: Mark Harris <mharris@nvidia.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants