Skip to content

Commit eef14b0

Browse files
committed
cuda.core: allow updating Buffer deallocation streams
1 parent 47f74ba commit eef14b0

5 files changed

Lines changed: 175 additions & 6 deletions

File tree

cuda_core/cuda/core/_memory/_buffer.pyi

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -127,6 +127,41 @@ class Buffer:
127127
stream : :obj:`~_stream.Stream` | :obj:`~graph.GraphBuilder`, optional
128128
The stream object to use for asynchronous deallocation. If None,
129129
the deallocation stream stored in the handle is used.
130+
131+
See Also
132+
--------
133+
set_deallocation_stream
134+
Change the deallocation stream without closing the buffer.
135+
"""
136+
137+
def set_deallocation_stream(self, stream: Stream | GraphBuilder) -> None:
138+
"""Change the stream that orders this buffer's eventual deallocation.
139+
140+
The buffer remains open and usable. A later :meth:`close` without a
141+
stream, garbage collection, or release of the final retained device
142+
pointer handle uses the replacement stream.
143+
144+
This method does not synchronize streams or establish dependencies.
145+
The caller must ensure that allocation and all accesses are ordered
146+
before the deallocation on ``stream``.
147+
148+
Parameters
149+
----------
150+
stream : :obj:`~_stream.Stream` | :obj:`~graph.GraphBuilder`
151+
The stream to use for eventual asynchronous deallocation.
152+
153+
Raises
154+
------
155+
RuntimeError
156+
If the buffer is already closed, or if a default-stream token
157+
cannot be bound because no CUDA context is current.
158+
TypeError
159+
If ``stream`` is ``None`` or is not an accepted stream object.
160+
161+
Notes
162+
-----
163+
Synchronizing concurrent mutation and destruction of the same buffer
164+
is the caller's responsibility.
130165
"""
131166

132167
def __enter__(self):

cuda_core/cuda/core/_memory/_buffer.pyx

Lines changed: 51 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -213,7 +213,9 @@ cdef class Buffer:
213213
cdef uintptr_t c_ptr = <uintptr_t>(int(ptr))
214214
cdef Stream s
215215
if mr is not None:
216-
s = Stream_accept(default_stream() if stream is None else stream)
216+
# Validate before taking ownership so a bad stream does not cause
217+
# construction failure to deallocate the caller's pointer.
218+
s = default_stream() if stream is None else Stream_accept(stream)
217219
_require_deallocation_stream_context(s)
218220
self._h_ptr = deviceptr_create_with_mr(c_ptr, size, mr)
219221
HANDLE_RETURN(set_deallocation_stream(self._h_ptr, s._h_stream))
@@ -238,7 +240,7 @@ cdef class Buffer:
238240
# The parent process's stream is not portable across processes, so the
239241
# pickle path cannot thread an explicit stream through. Seed the
240242
# imported buffer's deallocation with the current context's default
241-
# stream; the receiver can override via buffer.close(stream).
243+
# stream; the receiver can override it before or during close.
242244
return Buffer.from_ipc_descriptor(mr, ipc_descriptor, stream=default_stream())
243245

244246
def __reduce__(self) -> tuple[object, ...]:
@@ -333,9 +335,45 @@ cdef class Buffer:
333335
stream : :obj:`~_stream.Stream` | :obj:`~graph.GraphBuilder`, optional
334336
The stream object to use for asynchronous deallocation. If None,
335337
the deallocation stream stored in the handle is used.
338+
339+
See Also
340+
--------
341+
set_deallocation_stream
342+
Change the deallocation stream without closing the buffer.
336343
"""
337344
Buffer_close(self, stream)
338345

346+
def set_deallocation_stream(self, stream: Stream | GraphBuilder) -> None:
347+
"""Change the stream that orders this buffer's eventual deallocation.
348+
349+
The buffer remains open and usable. A later :meth:`close` without a
350+
stream, garbage collection, or release of the final retained device
351+
pointer handle uses the replacement stream.
352+
353+
This method does not synchronize streams or establish dependencies.
354+
The caller must ensure that allocation and all accesses are ordered
355+
before the deallocation on ``stream``.
356+
357+
Parameters
358+
----------
359+
stream : :obj:`~_stream.Stream` | :obj:`~graph.GraphBuilder`
360+
The stream to use for eventual asynchronous deallocation.
361+
362+
Raises
363+
------
364+
RuntimeError
365+
If the buffer is already closed, or if a default-stream token
366+
cannot be bound because no CUDA context is current.
367+
TypeError
368+
If ``stream`` is ``None`` or is not an accepted stream object.
369+
370+
Notes
371+
-----
372+
Synchronizing concurrent mutation and destruction of the same buffer
373+
is the caller's responsibility.
374+
"""
375+
Buffer_set_deallocation_stream(self, stream)
376+
339377
def __enter__(self):
340378
return self
341379

@@ -667,16 +705,23 @@ cdef Buffer Buffer_from_deviceptr_handle(
667705
return buf
668706

669707

708+
cdef inline void Buffer_set_deallocation_stream(Buffer self, object stream):
709+
"""Validate and replace a live buffer's deallocation recipe."""
710+
cdef Stream s
711+
if not self._h_ptr:
712+
raise RuntimeError("Cannot set the deallocation stream on a closed Buffer")
713+
s = Stream_accept(stream)
714+
_require_deallocation_stream_context(s)
715+
HANDLE_RETURN(set_deallocation_stream(self._h_ptr, s._h_stream))
716+
717+
670718
cdef inline void Buffer_close(Buffer self, object stream):
671719
"""Close a buffer, freeing its memory."""
672-
cdef Stream s
673720
if not self._h_ptr:
674721
return
675722
# Update deallocation stream if provided
676723
if stream is not None:
677-
s = Stream_accept(stream)
678-
_require_deallocation_stream_context(s)
679-
HANDLE_RETURN(set_deallocation_stream(self._h_ptr, s._h_stream))
724+
Buffer_set_deallocation_stream(self, stream)
680725
# Reset handle - RAII deleter will free the memory (and release owner ref in C++)
681726
self._h_ptr.reset()
682727
self._size = 0

cuda_core/docs/source/api.rst

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,12 @@ Memory management
7777
ManagedMemoryResourceOptions
7878
VirtualMemoryResourceOptions
7979

80+
A :class:`Buffer` records the stream that will order its eventual deallocation.
81+
Use :meth:`Buffer.set_deallocation_stream` to replace that stream without
82+
closing the buffer. Changing the recorded stream does not synchronize streams;
83+
the caller must order allocation and every access before the deallocation,
84+
using events or other CUDA synchronization mechanisms as needed.
85+
8086

8187
CUDA compilation toolchain
8288
--------------------------

cuda_core/docs/source/release/1.2.0-notes.rst

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,10 @@ Fixes and enhancements
2323
free recipe can pin that context.
2424
(`#2497 <https://github.com/NVIDIA/cuda-python/issues/2497>`__)
2525

26+
- Added :meth:`Buffer.set_deallocation_stream` to change the stream that orders
27+
a buffer's eventual deallocation without closing the buffer.
28+
(`#2600 <https://github.com/NVIDIA/cuda-python/issues/2600>`__)
29+
2630
- Explicit calls to ``deallocate()`` on pool-backed memory resources and
2731
:class:`GraphMemoryResource` now propagate errors from the underlying CUDA
2832
free operation. Previously, these errors could be suppressed. Automatic

cuda_core/tests/test_memory.py

Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -514,6 +514,85 @@ def deallocate(self, ptr, size, *, stream=None):
514514
assert received["stream"].handle == stream.handle
515515

516516

517+
class _StreamCaptureMemoryResource(MemoryResource):
518+
def __init__(self, device):
519+
self.device = device
520+
self.deallocation_streams = []
521+
522+
@property
523+
def is_device_accessible(self):
524+
return True
525+
526+
@property
527+
def is_host_accessible(self):
528+
return False
529+
530+
@property
531+
def device_id(self):
532+
return self.device.device_id
533+
534+
def allocate(self, size, *, stream):
535+
raise NotImplementedError
536+
537+
def deallocate(self, ptr, size, *, stream):
538+
self.deallocation_streams.append(stream)
539+
540+
541+
@pytest.mark.agent_authored(model="gpt-5.6")
542+
@pytest.mark.parametrize(
543+
("configuration", "destruction"),
544+
[
545+
("initialization", "close"),
546+
("initialization", "gc"),
547+
("setter", "close"),
548+
("setter", "gc"),
549+
("close", "close"),
550+
],
551+
)
552+
def test_buffer_deallocation_stream_configuration_paths(configuration, destruction):
553+
"""Creation, mutation, and close overrides use the requested stream."""
554+
import gc
555+
556+
device = Device()
557+
device.set_current()
558+
initial_stream = device.create_stream()
559+
target_stream = device.create_stream()
560+
mr = _StreamCaptureMemoryResource(device)
561+
562+
stream = target_stream if configuration == "initialization" else initial_stream
563+
buf = Buffer.from_handle(1, 1024, mr=mr, stream=stream)
564+
if configuration == "setter":
565+
handle = buf.handle
566+
buf.set_deallocation_stream(target_stream)
567+
assert buf.handle == handle
568+
assert buf.size == 1024
569+
570+
if destruction == "close":
571+
buf.close(stream=target_stream if configuration == "close" else None)
572+
else:
573+
del buf
574+
gc.collect()
575+
576+
assert len(mr.deallocation_streams) == 1
577+
assert mr.deallocation_streams[0].handle == target_stream.handle
578+
579+
580+
@pytest.mark.agent_authored(model="gpt-5.6")
581+
def test_set_deallocation_stream_rejects_none_and_closed_buffer():
582+
device = Device()
583+
device.set_current()
584+
stream = device.create_stream()
585+
mr = _StreamCaptureMemoryResource(device)
586+
buf = Buffer.from_handle(1, 1024, mr=mr, stream=stream)
587+
588+
with pytest.raises(TypeError, match="stream is required"):
589+
buf.set_deallocation_stream(None)
590+
591+
buf.close()
592+
with pytest.raises(RuntimeError, match="closed Buffer"):
593+
buf.set_deallocation_stream(stream)
594+
595+
517596
def test_from_handle_mr_records_default_stream():
518597
"""When a Buffer is minted via :meth:`Buffer.from_handle` with ``mr`` but
519598
without an explicit ``stream=``, the deallocation stream is recorded at

0 commit comments

Comments
 (0)