Skip to content

Commit 1214a4e

Browse files
committed
nanovdb python: bind DeviceBuffer/DeviceGridHandle.recordUse for external streams
The bindings' interop surface hands raw device pointers to CuPy / PyTorch / Numba via device_ptr(), __cuda_array_interface__ and __dlpack__, and those frameworks launch kernels on their own non-blocking streams. Such work is invisible to the buffer's automatic upload/download use tracking, so the cudaFreeAsync issued when the buffer is cleared or destroyed could race it — the C++ side grew DeviceBuffer::recordUse(device, stream) for exactly this caller, but Python had no way to reach it. Bind recordUse(stream, device=-1) on DeviceBuffer and on DeviceGridHandle (which owns its buffer internally and is where every real workflow's owning buffer lives). Both forward to a shared recordUseChecked helper that validates the device id — the C++ method indexes per-device tracking state unchecked — raising IndexError on an out-of-range id, with -1 selecting the current CUDA device. The device_ptr and __cuda_array_interface__ docstrings now point callers at recordUse. Non-owning (from_external) buffers accept the call as a no-op, matching the C++ behavior. Add TestRecordUse to TestGpuInterop.py: a CuPy reduction enqueued on a non-blocking stream against a zero-copy CAI view, recorded, and validated after the handle is destroyed while the work may still be in flight; plus default-stream/explicit-device acceptance, IndexError on a bad device id, and the non-owning no-op. Also normalize the cuda/ sources' includes of shared binding headers: add the python source dir to the target's private include paths so they use plain names ("PyGridHandle.h", "BuildTypes.def") instead of "../"-relative paths, which the rest of the codebase does not use. Signed-off-by: Jonathan Swartz <jonathan@jswartz.info>
1 parent 5fccbab commit 1214a4e

7 files changed

Lines changed: 127 additions & 12 deletions

File tree

nanovdb/nanovdb/python/CMakeLists.txt

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -58,7 +58,10 @@ nanobind_add_module(nanovdb_python NB_STATIC
5858
cuda/PyDeviceGridChecksum.cu
5959
)
6060

61-
target_include_directories(nanovdb_python PRIVATE ${CUDA_INCLUDE_DIRECTORY})
61+
# CMAKE_CURRENT_SOURCE_DIR lets sources in cuda/ include the shared binding
62+
# headers by their plain names ("PyGridHandle.h", "BuildTypes.def") instead of
63+
# "../"-relative paths, matching the include style used across the codebase.
64+
target_include_directories(nanovdb_python PRIVATE ${CMAKE_CURRENT_SOURCE_DIR} ${CUDA_INCLUDE_DIRECTORY})
6265
target_link_libraries(nanovdb_python PRIVATE nanovdb ${CUDA_LIBRARIES} ${NANOVDB_BLOSC} ${NANOVDB_ZLIB} ${NANOVDB_OPENVDB} ${NANOVDB_TBB})
6366
target_compile_definitions(nanovdb_python PRIVATE ${NANOVDB_USE_CUDA_FLAG} ${NANOVDB_USE_BLOSC_FLAG} ${NANOVDB_USE_ZLIB_FLAG} ${NANOVDB_USE_OPENVDB_FLAG} ${NANOVDB_USE_TBB_FLAG})
6467
set_target_properties(nanovdb_python PROPERTIES OUTPUT_NAME "nanovdb")

nanovdb/nanovdb/python/cuda/PyDeviceBuffer.cc

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -46,7 +46,9 @@ void defineDeviceBuffer(nb::module_& m)
4646
"does NOT take ownership: it will never free either pointer, so "
4747
"the caller must keep both allocations alive for the buffer's "
4848
"lifetime. The device pointer is associated with the current CUDA "
49-
"device.");
49+
"device.")
50+
.def("recordUse", &recordUseChecked, "stream"_a, "device"_a = -1,
51+
kRecordUseDoc);
5052
}
5153

5254
} // namespace pynanovdb

nanovdb/nanovdb/python/cuda/PyDeviceBuffer.h

Lines changed: 41 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,8 +9,11 @@
99
#include <nanobind/ndarray.h>
1010

1111
#include <cstdint>
12+
#include <string>
1213

1314
#include <cuda_runtime.h>
15+
16+
#include <nanovdb/cuda/DeviceBuffer.h>
1417
#endif
1518

1619
namespace nb = nanobind;
@@ -19,6 +22,39 @@ namespace pynanovdb {
1922

2023
#ifdef NANOVDB_USE_CUDA
2124

25+
/// @brief Bounds-checked forwarder for DeviceBuffer::recordUse, shared by the
26+
/// DeviceBuffer and DeviceGridHandle bindings. device == -1 selects the
27+
/// current CUDA device. recordUse itself indexes per-device tracking
28+
/// state unchecked, so validate here where we can raise a Python
29+
/// exception instead.
30+
inline void recordUseChecked(nanovdb::cuda::DeviceBuffer& buf, uintptr_t stream, int device)
31+
{
32+
int count = 0;
33+
cudaCheck(cudaGetDeviceCount(&count));
34+
if (device < 0) cudaCheck(cudaGetDevice(&device));
35+
if (device >= count) {
36+
const std::string msg = "recordUse: device id " + std::to_string(device) +
37+
" out of range [0, " + std::to_string(count) + ").";
38+
throw nb::index_error(msg.c_str());
39+
}
40+
nb::gil_scoped_release release;
41+
buf.recordUse(device, reinterpret_cast<cudaStream_t>(stream));
42+
}
43+
44+
/// @brief Docstring shared by the DeviceBuffer and DeviceGridHandle recordUse
45+
/// bindings (the two forward to the same underlying buffer method).
46+
inline constexpr char kRecordUseDoc[] =
47+
"Record that this buffer's device data was just used on `stream` (a raw "
48+
"cudaStream_t as a Python int, e.g. cupy.cuda.Stream.ptr), so the device "
49+
"free issued when the buffer is cleared or destroyed is ordered after "
50+
"that work. Uploads/downloads record themselves automatically; call this "
51+
"after enqueuing your own kernels or copies against device_ptr() / "
52+
"__cuda_array_interface__ / __dlpack__ on a non-blocking stream — "
53+
"without it such work is only safe if you synchronize before dropping "
54+
"the buffer. device selects which device's buffer was used (-1 = the "
55+
"current CUDA device). No-op on non-owning (from_external) buffers, "
56+
"which never free their pointers.";
57+
2258
/// @brief Bind the device-interop surface (CUDA Array Interface / DLPack, raw
2359
/// device/host pointers, streams) onto a device-buffer-like class.
2460
///
@@ -40,7 +76,11 @@ void addDeviceInterop(nb::class_<BufferT>& cls)
4076
return reinterpret_cast<uintptr_t>(buf.deviceData());
4177
},
4278
"Raw device pointer to the current device's buffer as a Python int "
43-
"(0 if no device allocation exists yet).");
79+
"(0 if no device allocation exists yet). Work you enqueue against "
80+
"this pointer on a non-blocking stream is invisible to the buffer's "
81+
"lifetime tracking: record it afterwards where the buffer supports "
82+
"it (DeviceBuffer.recordUse / DeviceGridHandle.recordUse), or "
83+
"synchronize before the buffer is destroyed.");
4484

4585
cls.def(
4686
"host_ptr",

nanovdb/nanovdb/python/cuda/PyDeviceGridHandle.cu

Lines changed: 18 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,8 @@
22
// SPDX-License-Identifier: Apache-2.0
33
#ifdef NANOVDB_USE_CUDA
44

5-
#include "../PyGridHandle.h"
5+
#include "PyGridHandle.h"
6+
#include "PyDeviceBuffer.h" // for recordUseChecked / kRecordUseDoc
67
#include <nanobind/ndarray.h>
78

89
#include <cstdint>
@@ -54,7 +55,7 @@ static nb::object pyDeviceGrid(nb::handle py_handle, uint32_t n)
5455
return grid ? nb::cast(grid, nb::rv_policy::reference, py_handle) \
5556
: nb::none(); \
5657
}
57-
#include "../BuildTypes.def"
58+
#include "BuildTypes.def"
5859
default:
5960
return nb::none();
6061
}
@@ -114,7 +115,17 @@ void defineDeviceGridHandle(nb::module_& m)
114115
},
115116
"Raw device pointer to the base of the whole device buffer as a "
116117
"Python int (0 if the handle has not been uploaded to the device "
117-
"yet).")
118+
"yet). Work you enqueue against this pointer on a non-blocking "
119+
"stream is invisible to the buffer's lifetime tracking: call "
120+
"recordUse(stream) afterwards, or synchronize before the handle "
121+
"is destroyed.")
122+
.def(
123+
"recordUse",
124+
[](GridHandle<BufferT>& handle, uintptr_t stream, int device) {
125+
recordUseChecked(handle.buffer(), stream, device);
126+
},
127+
"stream"_a, "device"_a = -1,
128+
kRecordUseDoc)
118129
.def_prop_ro(
119130
"__cuda_array_interface__",
120131
[](GridHandle<BufferT>& handle) {
@@ -133,7 +144,10 @@ void defineDeviceGridHandle(nb::module_& m)
133144
},
134145
"CUDA Array Interface (v3) view of the whole device buffer as 1-D "
135146
"uint8 — lets CuPy / Numba / PyTorch consume the serialized grid "
136-
"bytes zero-copy. Returns a null data pointer until deviceUpload.")
147+
"bytes zero-copy. Returns a null data pointer until deviceUpload. "
148+
"After enqueuing work on this view from a non-blocking stream, "
149+
"call recordUse(stream) so the buffer's device free is ordered "
150+
"after it.")
137151
.def(
138152
"__dlpack_device__",
139153
[](GridHandle<BufferT>&) {

nanovdb/nanovdb/python/cuda/PyDeviceNodeManager.cu

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
// SPDX-License-Identifier: Apache-2.0
33
#ifdef NANOVDB_USE_CUDA
44

5-
#include "../PyTree.h"
5+
#include "PyTree.h"
66

77
#include <cstdint>
88

@@ -56,7 +56,7 @@ static nb::object pyDeviceNodeMgr(nb::handle py_self)
5656
if (auto* m = handle.template deviceMgr<T>()) { \
5757
return nb::cast(m, nb::rv_policy::reference, py_self); \
5858
}
59-
#include "../BuildTypes.def"
59+
#include "BuildTypes.def"
6060
return nb::none();
6161
}
6262

@@ -141,7 +141,7 @@ static void defineCreateDeviceNodeManager(nb::module_& m)
141141
if (auto obj = tryCreateDeviceNodeManager<T>(py_grid, s); obj.is_valid()) { \
142142
return obj; \
143143
}
144-
#include "../BuildTypes.def"
144+
#include "BuildTypes.def"
145145
throw nb::type_error(
146146
"createDeviceNodeManager: argument is not a NanoVDB device "
147147
"grid of any bound BuildT. Pass a device grid obtained from "

nanovdb/nanovdb/python/cuda/PyUnifiedGridHandle.cu

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
#ifdef NANOVDB_USE_CUDA
44

55
#include "PyUnifiedGridHandle.h"
6-
#include "../PyGridHandle.h"
6+
#include "PyGridHandle.h"
77

88
#include <cstdint>
99

@@ -51,7 +51,7 @@ static nb::object pyUnifiedDeviceGrid(nb::handle py_handle, uint32_t n)
5151
return grid ? nb::cast(grid, nb::rv_policy::reference, py_handle) \
5252
: nb::none(); \
5353
}
54-
#include "../BuildTypes.def"
54+
#include "BuildTypes.def"
5555
default:
5656
return nb::none();
5757
}

nanovdb/nanovdb/python/test/TestGpuInterop.py

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -295,6 +295,62 @@ def test_from_buffer_rejects_garbage(self):
295295
cp.cuda.set_allocator(prev)
296296

297297

298+
@unittest.skipIf(
299+
not nanovdb.isCudaAvailable(), "nanovdb module was compiled without CUDA support"
300+
)
301+
@unittest.skipIf(
302+
not nanovdb.isGpuAvailable(), "No CUDA-capable GPU available"
303+
)
304+
class TestRecordUse(unittest.TestCase):
305+
"""DeviceBuffer/DeviceGridHandle.recordUse: order the buffer's device free
306+
after work enqueued on external non-blocking streams (the CAI/DLPack
307+
interop pattern, where kernels launched against device_ptr() are invisible
308+
to the buffer's automatic upload/download tracking)."""
309+
310+
def test_free_ordered_after_external_stream_work(self):
311+
cp = _require_cupy(self)
312+
dh, _ = _build_device_onindex_grid(20.0)
313+
# Baseline: reduce the serialized grid bytes while the handle is alive.
314+
view = cp.asarray(dh) # zero-copy CAI view of the device buffer
315+
expected = int(view.sum())
316+
# Enqueue the same reduction on a NON-BLOCKING stream, record the use,
317+
# and drop the handle while the reduction may still be in flight. The
318+
# buffer's cudaFreeAsync must be ordered after the recorded event; if
319+
# it were not, the reduction would race the free and read freed memory.
320+
s = cp.cuda.Stream(non_blocking=True)
321+
with s:
322+
pending = view.sum() # device scalar; no host sync yet
323+
dh.recordUse(s.ptr)
324+
del dh, view
325+
s.synchronize()
326+
self.assertEqual(int(pending), expected)
327+
328+
def test_default_stream_and_explicit_device(self):
329+
dh, _ = _build_device_onindex_grid(10.0)
330+
# Default stream (0) and the current device, both explicit and implied.
331+
dh.recordUse(0)
332+
dh.recordUse(0, device=0)
333+
334+
def test_rejects_out_of_range_device(self):
335+
dh, _ = _build_device_onindex_grid(10.0)
336+
with self.assertRaises(IndexError):
337+
dh.recordUse(0, device=1_000_000)
338+
339+
def test_noop_on_non_owning_buffer(self):
340+
cp = _require_cupy(self)
341+
prev = cp.cuda.get_allocator()
342+
cp.cuda.set_allocator(cp.cuda.malloc_managed)
343+
try:
344+
mbuf = cp.zeros(256, dtype=cp.uint8)
345+
ptr = int(mbuf.data.ptr)
346+
ext = nanovdb.cuda.DeviceBuffer.from_external(256, ptr, ptr)
347+
# Non-owning buffers never free their pointers, so recordUse has
348+
# nothing to order — it must be accepted and be a no-op.
349+
ext.recordUse(0)
350+
finally:
351+
cp.cuda.set_allocator(prev)
352+
353+
298354
@unittest.skipIf(
299355
not nanovdb.isCudaAvailable(), "nanovdb module was compiled without CUDA support"
300356
)

0 commit comments

Comments
 (0)