Skip to content

Commit 621ade8

Browse files
committed
cuda.core: retain device allocations in graph memcpy/memset slots
Store DevicePtrHandle in slot table instead of Buffer wrappers so reset/close cannot release memory while a graph still references it. Add test-only weak_handle() for deterministic allocation lifetime checks and extend graph lifetime tests accordingly.
1 parent 10268ff commit 621ade8

9 files changed

Lines changed: 487 additions & 101 deletions

File tree

cuda_core/cuda/core/_resource_handles.pyi

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,4 +24,5 @@ FileDescriptorHandle = shared_ptr
2424
OpaqueArrayHandle = shared_ptr
2525
MipmappedArrayHandle = shared_ptr
2626
TexObjectHandle = shared_ptr
27-
SurfObjectHandle = shared_ptr
27+
SurfObjectHandle = shared_ptr
28+
OpaqueHandle = shared_ptr
Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
# This file was generated by stubgen-pyx v0.2.6 from cuda_core/cuda/core/_utils/_weak_handles.pyx
2+
3+
"""Test-only weak handles for resource-handle lifetime checks.
4+
5+
This module is **not** part of the public ``cuda.core`` API. It is built into
6+
the package (like other private ``_utils`` modules) purely so the test suite can
7+
observe, deterministically, when the strong references that keep a CUDA resource
8+
alive have all been released -- without relying on driver- or hardware-specific
9+
side effects (for example, whether freed device memory happens to remain
10+
readable).
11+
12+
Every resource handle is owned by a C++ ``std::shared_ptr``. A **weak handle**
13+
is a non-owning ``std::weak_ptr`` observer of that control block: truthy while
14+
some strong owner remains, falsy once the last one is gone. Use :func:`weak_handle`
15+
to obtain a weak handle from a supported front-end object.
16+
17+
To support another type, add a ``cdef _weak_from_<type>`` that reads its ``cdef``
18+
handle field (see ``*.pxd``), assigns to :ctype:`OpaqueHandle`, and extend the
19+
``isinstance`` chain in :func:`weak_handle`. Types whose slots hold arbitrary
20+
Python owners via ``make_opaque_py`` are not covered here -- use
21+
:class:`weakref.ref` on a weak-referenceable owner object in tests instead.
22+
"""
23+
from __future__ import annotations
24+
25+
26+
class WeakHandle:
27+
"""Non-owning weak handle for a resource's shared control block.
28+
29+
Truthy while some strong owner of the underlying resource handle remains,
30+
falsy once the last strong reference is released. Obtain instances via
31+
:func:`weak_handle` rather than constructing directly.
32+
"""
33+
34+
def __bool__(self):
35+
...
36+
37+
def expired(self):
38+
"""Return ``True`` once every strong owner of the handle is gone."""
39+
40+
def use_count(self):
41+
"""Number of strong owners currently sharing the handle."""
42+
43+
def weak_handle(obj):
44+
"""Return a :class:`WeakHandle` observing the resource behind ``obj``.
45+
46+
Currently supports :class:`~cuda.core.Buffer` (device allocation handle).
47+
See the module docstring for how to add more types.
48+
49+
Raises
50+
------
51+
ValueError
52+
If ``obj`` is a :class:`~cuda.core.Buffer` with no active allocation.
53+
TypeError
54+
If ``obj`` is not a supported type.
55+
"""
Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,106 @@
1+
# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
2+
# SPDX-License-Identifier: Apache-2.0
3+
4+
"""Test-only weak handles for resource-handle lifetime checks.
5+
6+
This module is **not** part of the public ``cuda.core`` API. It is built into
7+
the package (like other private ``_utils`` modules) purely so the test suite can
8+
observe, deterministically, when the strong references that keep a CUDA resource
9+
alive have all been released -- without relying on driver- or hardware-specific
10+
side effects (for example, whether freed device memory happens to remain
11+
readable).
12+
13+
Every resource handle is owned by a C++ ``std::shared_ptr``. A **weak handle**
14+
is a non-owning ``std::weak_ptr`` observer of that control block: truthy while
15+
some strong owner remains, falsy once the last one is gone. Use :func:`weak_handle`
16+
to obtain a weak handle from a supported front-end object.
17+
18+
To support another type, add a ``cdef _weak_from_<type>`` that reads its ``cdef``
19+
handle field (see ``*.pxd``), assigns to :ctype:`OpaqueHandle`, and extend the
20+
``isinstance`` chain in :func:`weak_handle`. Types whose slots hold arbitrary
21+
Python owners via ``make_opaque_py`` are not covered here -- use
22+
:class:`weakref.ref` on a weak-referenceable owner object in tests instead.
23+
"""
24+
25+
from cuda.core._memory._buffer cimport Buffer
26+
from cuda.core._resource_handles cimport OpaqueHandle
27+
28+
29+
# Cython cannot spell ``weak_ptr[const void]`` inline (the ``const void``
30+
# template argument fails to parse), so the weak type and its one constructor
31+
# are provided by a small inline C++ shim local to this test-only module. This
32+
# keeps the production resource_handles translation units untouched.
33+
cdef extern from *:
34+
"""
35+
#include <memory>
36+
namespace cuda_core_test {
37+
using OpaqueWeakHandle = std::weak_ptr<const void>;
38+
static inline OpaqueWeakHandle make_weak(const std::shared_ptr<const void>& h) {
39+
return OpaqueWeakHandle(h);
40+
}
41+
} // namespace cuda_core_test
42+
"""
43+
cppclass OpaqueWeakHandle "cuda_core_test::OpaqueWeakHandle":
44+
OpaqueWeakHandle()
45+
bint expired()
46+
long use_count()
47+
OpaqueWeakHandle make_weak "cuda_core_test::make_weak" (const OpaqueHandle& h)
48+
49+
50+
cdef class WeakHandle:
51+
"""Non-owning weak handle for a resource's shared control block.
52+
53+
Truthy while some strong owner of the underlying resource handle remains,
54+
falsy once the last strong reference is released. Obtain instances via
55+
:func:`weak_handle` rather than constructing directly.
56+
"""
57+
58+
cdef OpaqueWeakHandle _w
59+
60+
def __bool__(self):
61+
return not self._w.expired()
62+
63+
def expired(self):
64+
"""Return ``True`` once every strong owner of the handle is gone."""
65+
return self._w.expired()
66+
67+
def use_count(self):
68+
"""Number of strong owners currently sharing the handle."""
69+
return self._w.use_count()
70+
71+
72+
cdef WeakHandle _weak_from_opaque(OpaqueHandle h):
73+
# Build the weak handle from a (temporary) strong handle. The strong copy
74+
# lives only for the duration of this call, so it does not perturb the
75+
# reference count the weak handle later reports.
76+
cdef WeakHandle wh = WeakHandle.__new__(WeakHandle)
77+
wh._w = make_weak(h)
78+
return wh
79+
80+
81+
cdef WeakHandle _weak_from_buffer(Buffer buf):
82+
cdef OpaqueHandle h = buf._h_ptr
83+
if not h:
84+
raise ValueError("Buffer has no active allocation")
85+
return _weak_from_opaque(h)
86+
87+
88+
def weak_handle(obj):
89+
"""Return a :class:`WeakHandle` observing the resource behind ``obj``.
90+
91+
Currently supports :class:`~cuda.core.Buffer` (device allocation handle).
92+
See the module docstring for how to add more types.
93+
94+
Raises
95+
------
96+
ValueError
97+
If ``obj`` is a :class:`~cuda.core.Buffer` with no active allocation.
98+
TypeError
99+
If ``obj`` is not a supported type.
100+
"""
101+
if isinstance(obj, Buffer):
102+
return _weak_from_buffer(obj)
103+
raise TypeError(
104+
f"weak_handle() does not support {type(obj).__name__!r}; "
105+
"supported types: Buffer"
106+
)

cuda_core/cuda/core/graph/_graph_builder.pyi

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -106,6 +106,18 @@ class GraphBuilder:
106106
to ambiguity. New graph builders should instead be created through a
107107
:obj:`~_device.Device`, or a :obj:`~_stream.stream` object.
108108
109+
.. note::
110+
111+
Operations recorded during capture reference your memory but do not
112+
take ownership of it. As with ordinary stream work, you must keep the
113+
operands alive for as long as the completed graph may execute -- for
114+
example, the :obj:`~_memory.Buffer` objects passed to :func:`~launch`
115+
or :meth:`~_memory.Buffer.copy_to`. Host callbacks added with
116+
:meth:`callback` are the exception: the callable (and any copied
117+
``user_data``) are retained for the graph's lifetime. This differs from
118+
building a graph explicitly with :class:`~graph.GraphDefinition`, which
119+
retains the operands it is given.
120+
109121
"""
110122

111123
def __init__(self):

cuda_core/cuda/core/graph/_graph_definition.pyi

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ from __future__ import annotations
66
from cuda.core._device import Device
77
from cuda.core._event import Event
88
from cuda.core._launch_config import LaunchConfig
9+
from cuda.core._memory._buffer import Buffer
910
from cuda.core._module import Kernel
1011
from cuda.core._utils.cuda_utils import driver
1112
from cuda.core.graph._graph_builder import (Graph, GraphCompleteOptions,
@@ -85,7 +86,7 @@ class GraphDefinition:
8586
See :meth:`GraphNode.deallocate` for full documentation.
8687
"""
8788

88-
def memset(self, dst: int, value, width: int, height: int=1, pitch: int=0) -> MemsetNode:
89+
def memset(self, dst: Buffer | int, value, width: int, *, height: int=1, pitch: int=0, dst_owner=None) -> MemsetNode:
8990
"""Add an entry-point memset node (no dependencies).
9091
9192
See :meth:`GraphNode.memset` for full documentation.
@@ -120,7 +121,7 @@ class GraphDefinition:
120121
A new EmptyNode that depends on all input nodes.
121122
"""
122123

123-
def memcpy(self, dst: int, src: int, size: int) -> MemcpyNode:
124+
def memcpy(self, dst: Buffer | int, src: Buffer | int, size: int, *, dst_owner=None, src_owner=None) -> MemcpyNode:
124125
"""Add an entry-point memcpy node (no dependencies).
125126
126127
See :meth:`GraphNode.memcpy` for full documentation.

cuda_core/cuda/core/graph/_graph_node.pyi

Lines changed: 39 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ from collections.abc import Iterable
99
from cuda.core._device import Device
1010
from cuda.core._event import Event
1111
from cuda.core._launch_config import LaunchConfig
12+
from cuda.core._memory._buffer import Buffer
1213
from cuda.core._module import Kernel
1314
from cuda.core._utils.cuda_utils import driver
1415
from cuda.core.graph._adjacency_set_proxy import AdjacencySetProxy
@@ -178,13 +179,16 @@ class GraphNode:
178179
A new FreeNode representing the free operation.
179180
"""
180181

181-
def memset(self, dst: int, value, width: int, height: int=1, pitch: int=0) -> MemsetNode:
182+
def memset(self, dst: Buffer | int, value, width: int, *, height: int=1, pitch: int=0, dst_owner=None) -> MemsetNode:
182183
"""Add a memset node depending on this node.
183184
184185
Parameters
185186
----------
186-
dst : int
187-
Destination device pointer.
187+
dst : Buffer or int
188+
Destination. When ``dst`` is a :class:`Buffer`, the underlying
189+
allocation is retained for the graph's lifetime. A raw pointer
190+
(``int``) is used as-is; the caller must keep the underlying memory
191+
alive, or supply ``dst_owner`` to have the graph retain it.
188192
value : int or buffer-protocol object
189193
Fill value. int for 1-byte fill (range [0, 256)),
190194
or buffer-protocol object of 1, 2, or 4 bytes.
@@ -194,14 +198,23 @@ class GraphNode:
194198
Number of rows (default 1).
195199
pitch : int, optional
196200
Pitch of destination in bytes (default 0, unused if height is 1).
201+
dst_owner : object, optional
202+
Object retained for the graph's lifetime when ``dst`` is a raw
203+
pointer. A :class:`Buffer` owner retains its underlying allocation,
204+
not the wrapper. Must not be passed when ``dst`` is a :class:`Buffer`.
197205
198206
Returns
199207
-------
200208
MemsetNode
201209
A new MemsetNode representing the memset operation.
210+
211+
Raises
212+
------
213+
ValueError
214+
If ``dst_owner`` is given together with a :class:`Buffer` ``dst``.
202215
"""
203216

204-
def memcpy(self, dst: int, src: int, size: int) -> MemcpyNode:
217+
def memcpy(self, dst: Buffer | int, src: Buffer | int, size: int, *, dst_owner=None, src_owner=None) -> MemcpyNode:
205218
"""Add a memcpy node depending on this node.
206219
207220
Copies ``size`` bytes from ``src`` to ``dst``. Memory types are
@@ -210,17 +223,35 @@ class GraphNode:
210223
211224
Parameters
212225
----------
213-
dst : int
214-
Destination pointer (device or pinned host).
215-
src : int
216-
Source pointer (device or pinned host).
226+
dst : Buffer or int
227+
Destination (device or pinned host). When a :class:`Buffer` is given,
228+
the underlying allocation is retained for the graph's lifetime. A raw
229+
pointer (``int``) is used as-is; the caller must keep the underlying
230+
memory alive, or supply ``dst_owner`` to have the graph retain it.
231+
src : Buffer or int
232+
Source (device or pinned host). Same retention rules as ``dst``;
233+
use ``src_owner`` for a raw pointer.
217234
size : int
218235
Number of bytes to copy.
236+
dst_owner : object, optional
237+
Object retained for the graph's lifetime when ``dst`` is a raw
238+
pointer. A :class:`Buffer` owner retains its underlying allocation.
239+
Must not be passed when ``dst`` is a :class:`Buffer`.
240+
src_owner : object, optional
241+
Object retained for the graph's lifetime when ``src`` is a raw
242+
pointer. A :class:`Buffer` owner retains its underlying allocation.
243+
Must not be passed when ``src`` is a :class:`Buffer`.
219244
220245
Returns
221246
-------
222247
MemcpyNode
223248
A new MemcpyNode representing the copy operation.
249+
250+
Raises
251+
------
252+
ValueError
253+
If ``dst_owner`` or ``src_owner`` is given together with a
254+
:class:`Buffer` ``dst`` or ``src`` respectively.
224255
"""
225256

226257
def embed(self, child: GraphDefinition) -> ChildGraphNode:

0 commit comments

Comments
 (0)