Skip to content

Commit 7d72ca5

Browse files
Andy-Jostcursoragentleofang
authored
cuda.core: graph slot table for node attachment lifetimes (#2280)
* cuda.core: add GraphBuilder.graph_definition property Completes step 3 of #1330 by exposing the captured graph as an explicit `GraphDefinition` view that shares ownership of the underlying `CUgraph`. The handle-layer plumbing landed in PR #2008; this commit wires up the user-facing surface and locks in the state-guard rules. State semantics: - PRIMARY builder: only valid after `end_building()`. Before `begin_building()` no graph exists; during capture the driver is the sole writer, so explicit access is unsafe. - CONDITIONAL_BODY builder: valid both before `begin_building()` (the body graph is allocated at conditional-node creation time) and after `end_building()`. This enables a hybrid flow where a conditional body is populated entirely via the explicit API, with no capture at all. - FORKED builder: never valid. Forked builders share the primary's graph; access through the primary instead. Tests cover the happy path, both hybrid flows on conditional bodies (populate-via-explicit-API and capture-then-augment), the three error states (forked, capturing, primary pre-capture), and the shared-ownership guarantee (the `GraphDefinition` survives the builder's `close()`). Co-authored-by: Cursor <cursoragent@cursor.com> * cuda.core: add graph slot table infrastructure (phase 1) Introduce OpaqueHandle and a per-graph slot table retained on the CUgraph as a user object, preparing to replace ad-hoc per-resource user objects when wiring graph node attachments in a follow-up change. * cuda.core: wire graph node attachments to the slot table (phase 2) Replace the per-resource CUDA user objects attached at each graph node with the per-graph slot table from phase 1. Kernel, event-record, event-wait, and host-callback nodes now store their owning handles in node slots via graph_set_slot. Stream-captured callbacks map the just-captured host node from cuStreamGetCaptureInfo and use the same path; forked builders share the primary's graph handle so their attachments reach the same table. Refine the phase 1 surface to support this: the slot table is created lazily on first attachment, so conditional-branch bodies (ref handles) get one too, and graph_set_slot returns CUresult for HANDLE_RETURN-style error checking. Removes _attach_user_object and the per-type heap-copy deleters. * cuda.core: rename graph host-callback module and retain kernel args Rename graph/_utils to graph/_host_callback now that it holds only host-callback machinery (the trampoline, _is_py_host_trampoline, and _resolve_host_callback), matching the concept-named files around it, and update the three cimport sites. Add _attach_host_callback_owners to share the "callback -> slot 0, user_data -> slot 1" attachment between the eager (GN_callback) and capture (add_callback) paths. Guard a zero-length user_data copy against malloc(0) and hoist the per-call ctypes import. Attach the kernel-argument tuple to the kernel node's slot 1 so the Python objects backing the arguments -- notably device Buffers -- outlive the graph. The driver copies argument values into the node at add time but does not keep the referenced device memory alive, so without this a kernel node could be left with a stale device pointer. This is the slot-table port of the user-object fix from #2041 (currently only on main). * cuda.core: accept Buffer in graph memcpy/memset and retain operands GraphNode.memcpy/memset (and the GraphDefinition pass-throughs) now accept a Buffer or a raw int for each address. A new _resolve_ptr helper reads the device pointer from a Buffer and returns it as an owner; a raw int casts through with no owner. GN_memcpy attaches a Buffer dst to slot 0 and src to slot 1, and GN_memset attaches dst to slot 0, so buffers passed by value outlive the graph. Raw ints behave exactly as before (caller owns the lifetime), so this is backward compatible. Document the stream-capture lifetime contract on GraphBuilder: operations recorded during capture reference caller-owned memory and are not retained, unlike explicit GraphDefinition construction. Host callbacks are the one exception, retained on both the capture and explicit paths. * cuda.core: add slot-table lifetime tests for Buffer memcpy/memset and capture callbacks Cover GraphDefinition memset/memcpy with Buffer operands (including clone), and GraphBuilder capture host callbacks retained after dropping Python refs. * cuda.core: add explicit dst/src_owner for graph memcpy/memset Keyword-only *_owner args retain arbitrary objects for raw pointer operands; Buffer+owner combinations are rejected. Strengthen owner tests with weakref retention checks and add src_owner rejection test. * 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. * cuda.core: keep memset height/pitch positional; mark new graph tests Address PR #2280 review feedback: - Move the keyword-only "*" marker in GraphNode.memset and GraphDefinition.memset to after height/pitch, so pre-existing positional calls memset(dst, value, width, height, pitch) keep working. The new dst_owner argument remains keyword-only. This avoids a public API break across 1.x. memcpy is unchanged (its dst_owner/src_owner args are new, so the existing "*" placement is non-breaking). - Add @pytest.mark.agent_authored markers to the new graph tests in test_graph_builder.py and test_graph_definition_lifetime.py. * cuda.core: roll back graph node when owner-slot attachment fails A node added via cuGraphAdd*Node is committed to the graph before its owner slots are attached. If graph_set_slot fails (e.g. the driver lacks cuUserObjectCreate, or a transient error), the node would remain in the graph referencing Python-owned memory with nothing keeping it alive, risking a later launch dereferencing freed memory. Guard the slot-attachment at each explicit-add site (kernel, memset, memcpy, event record/wait, host callback) with a try/except that destroys the node (best effort) and re-raises. The capture-path callback in _graph_builder is intentionally left alone: its node is created by cuLaunchHostFunc during active capture, where destroying a capture dependency would corrupt capture state. * cuda.core: use if/elif chain in graph_definition guard Convert the sequential guard checks in GraphBuilder.graph_definition to an if/elif chain (splitting the final compound condition into a nested if). Behavior is unchanged since each leading branch raises; the chain lets Cython generate tighter branch code. Addresses a review nit on PR #2280. * cuda.core: make graph_set_slot a no-op for null owners Centralize null-owner handling in graph_set_slot: a null OpaqueHandle now returns CUDA_SUCCESS without forcing slot-table (and user-object) creation. This resolves the reviewer question about the asymmetric per-call-site NULL checks -- optional owners are uniformly safe at the source, so callers no longer need to guard them. Update the header doc accordingly. --------- Co-authored-by: Cursor <cursoragent@cursor.com> Co-authored-by: Leo Fang <leof@nvidia.com>
1 parent 35144d0 commit 7d72ca5

21 files changed

Lines changed: 1194 additions & 200 deletions

cuda_core/cuda/core/_cpp/resource_handles.cpp

Lines changed: 103 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,8 +6,11 @@
66

77
#include "resource_handles.hpp"
88
#include <cuda.h>
9+
#include <array>
910
#include <cstdint>
11+
#include <cstdlib>
1012
#include <cstring>
13+
#include <map>
1114
#include <mutex>
1215
#include <stdexcept>
1316
#include <unordered_map>
@@ -70,6 +73,9 @@ decltype(&cuLibraryGetKernel) p_cuLibraryGetKernel = nullptr;
7073
// Graph
7174
decltype(&cuGraphDestroy) p_cuGraphDestroy = nullptr;
7275
decltype(&cuGraphExecDestroy) p_cuGraphExecDestroy = nullptr;
76+
decltype(&cuUserObjectCreate) p_cuUserObjectCreate = nullptr;
77+
decltype(&cuUserObjectRelease) p_cuUserObjectRelease = nullptr;
78+
decltype(&cuGraphRetainUserObject) p_cuGraphRetainUserObject = nullptr;
7379

7480
// Linker
7581
decltype(&cuLinkDestroy) p_cuLinkDestroy = nullptr;
@@ -1114,12 +1120,92 @@ LibraryHandle get_kernel_library(const KernelHandle& h) noexcept {
11141120
// ============================================================================
11151121

11161122
namespace {
1123+
1124+
// Slot table layout (internal). Each graph maps CUgraphNode -> a fixed-size
1125+
// array of type-erased owners. The width is the most any single node needs: a
1126+
// kernel node holds its kernel and its packed arguments; a host node holds its
1127+
// callback and the userData. The table is heap-allocated and retained on the
1128+
// graph as a user object, so the driver frees it -- and every owner in it --
1129+
// when the graph is destroyed.
1130+
constexpr std::size_t SLOTS_PER_NODE = 2;
1131+
using NodeSlots = std::array<OpaqueHandle, SLOTS_PER_NODE>;
1132+
using GraphSlotTable = std::map<CUgraphNode, NodeSlots>;
1133+
1134+
// shared_ptr deleters for the payloads that need one. Typed handles convert to
1135+
// OpaqueHandle by assignment and reuse their own control block, so they need no
1136+
// deleter here. The Python deleter follows the owner-release pattern used by
1137+
// the stream/deviceptr handles above.
1138+
void py_deleter(const void* p) noexcept {
1139+
GILAcquireGuard gil;
1140+
if (gil.acquired()) {
1141+
Py_DECREF(const_cast<PyObject*>(static_cast<const PyObject*>(p)));
1142+
}
1143+
}
1144+
1145+
void free_deleter(const void* p) noexcept {
1146+
std::free(const_cast<void*>(p));
1147+
}
1148+
1149+
void destroy_graph_slot_table(void* table) noexcept {
1150+
delete static_cast<GraphSlotTable*>(table);
1151+
}
1152+
11171153
struct GraphBox {
11181154
CUgraph resource;
1119-
GraphHandle h_parent; // Keeps parent alive for child/branch graphs
1155+
GraphHandle h_parent; // Keeps parent alive for child/branch graphs
1156+
mutable GraphSlotTable* slot_table = nullptr; // Lazily created; owned by the graph's user object
11201157
};
1158+
1159+
const GraphBox* get_box(const GraphHandle& h) {
1160+
const CUgraph* p = h.get();
1161+
return reinterpret_cast<const GraphBox*>(
1162+
reinterpret_cast<const char*>(p) - offsetof(GraphBox, resource)
1163+
);
1164+
}
1165+
1166+
// Return box's slot table, creating it on first use. The table is retained on
1167+
// the graph as a user object (MOVE transfers our only reference into the
1168+
// graph), so it -- and every owner in it -- is freed when the graph is
1169+
// destroyed. Returns nullptr if the driver lacks user-object support or a
1170+
// driver call fails; the cached pointer is non-owning.
1171+
GraphSlotTable* ensure_slot_table(const GraphBox* box) {
1172+
if (box->slot_table) {
1173+
return box->slot_table;
1174+
}
1175+
if (!p_cuUserObjectCreate || !p_cuGraphRetainUserObject || !p_cuUserObjectRelease) {
1176+
return nullptr;
1177+
}
1178+
auto* table = new GraphSlotTable();
1179+
CUuserObject user_obj = nullptr;
1180+
{
1181+
GILReleaseGuard gil;
1182+
if (p_cuUserObjectCreate(&user_obj, table,
1183+
reinterpret_cast<CUhostFn>(destroy_graph_slot_table),
1184+
1, CU_USER_OBJECT_NO_DESTRUCTOR_SYNC) != CUDA_SUCCESS) {
1185+
delete table; // no user object created; nothing else owns the table
1186+
return nullptr;
1187+
}
1188+
if (p_cuGraphRetainUserObject(box->resource, user_obj, 1,
1189+
CU_GRAPH_USER_OBJECT_MOVE) != CUDA_SUCCESS) {
1190+
p_cuUserObjectRelease(user_obj, 1); // drops refcount to 0 -> frees table
1191+
return nullptr;
1192+
}
1193+
}
1194+
box->slot_table = table; // non-owning cache; the user object owns it
1195+
return table;
1196+
}
1197+
11211198
} // namespace
11221199

1200+
OpaqueHandle make_opaque_py(PyObject* obj) {
1201+
Py_INCREF(obj);
1202+
return OpaqueHandle(static_cast<const void*>(obj), py_deleter);
1203+
}
1204+
1205+
OpaqueHandle make_opaque_malloc(void* buf) {
1206+
return OpaqueHandle(static_cast<const void*>(buf), free_deleter);
1207+
}
1208+
11231209
GraphHandle create_graph_handle(CUgraph graph) {
11241210
auto box = std::shared_ptr<const GraphBox>(
11251211
new GraphBox{graph, {}},
@@ -1137,6 +1223,22 @@ GraphHandle create_graph_handle_ref(CUgraph graph, const GraphHandle& h_parent)
11371223
return GraphHandle(box, &box->resource);
11381224
}
11391225

1226+
CUresult graph_set_slot(const GraphHandle& h_graph, CUgraphNode node,
1227+
unsigned int slot, OpaqueHandle owner) {
1228+
if (!h_graph || slot >= SLOTS_PER_NODE) {
1229+
return CUDA_ERROR_INVALID_VALUE;
1230+
}
1231+
if (!owner) {
1232+
return CUDA_SUCCESS; // nothing to retain; don't force table creation
1233+
}
1234+
GraphSlotTable* table = ensure_slot_table(get_box(h_graph));
1235+
if (!table) {
1236+
return CUDA_ERROR_NOT_SUPPORTED;
1237+
}
1238+
(*table)[node][slot] = std::move(owner);
1239+
return CUDA_SUCCESS;
1240+
}
1241+
11401242
// ============================================================================
11411243
// Graph Exec Handles
11421244
// ============================================================================

cuda_core/cuda/core/_cpp/resource_handles.hpp

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -109,6 +109,9 @@ extern decltype(&cuLibraryGetKernel) p_cuLibraryGetKernel;
109109
// Graph
110110
extern decltype(&cuGraphDestroy) p_cuGraphDestroy;
111111
extern decltype(&cuGraphExecDestroy) p_cuGraphExecDestroy;
112+
extern decltype(&cuUserObjectCreate) p_cuUserObjectCreate;
113+
extern decltype(&cuUserObjectRelease) p_cuUserObjectRelease;
114+
extern decltype(&cuGraphRetainUserObject) p_cuGraphRetainUserObject;
112115

113116
// Linker
114117
extern decltype(&cuLinkDestroy) p_cuLinkDestroy;
@@ -466,6 +469,39 @@ GraphHandle create_graph_handle(CUgraph graph);
466469
// but h_parent will be prevented from destruction while this handle exists.
467470
GraphHandle create_graph_handle_ref(CUgraph graph, const GraphHandle& h_parent);
468471

472+
// ============================================================================
473+
// Graph slot attachments
474+
//
475+
// A graph carries a side table that keeps resources used by its nodes (kernel
476+
// arguments, host callbacks, events, ...) alive for as long as the graph can
477+
// execute. The table is created on first use and retained on the CUgraph as a
478+
// user object, so the driver releases it -- and everything attached through it
479+
// -- when the graph is destroyed. The table layout is an internal detail;
480+
// callers use the abstract API below.
481+
// ============================================================================
482+
483+
// Type-erased shared owner of an attached resource. Typed handles such as
484+
// EventHandle and KernelHandle convert to OpaqueHandle by assignment, reusing
485+
// their existing control block; the helpers below build OpaqueHandles for the
486+
// two cases that need a custom deleter.
487+
using OpaqueHandle = std::shared_ptr<const void>;
488+
489+
// Build an OpaqueHandle from a Python object: increments its refcount now and
490+
// decrements it (under the GIL) on release. The caller must hold the GIL.
491+
OpaqueHandle make_opaque_py(PyObject* obj);
492+
493+
// Build an OpaqueHandle from a malloc'd buffer: std::free on release.
494+
OpaqueHandle make_opaque_malloc(void* buf);
495+
496+
// Attach owner to one of node's fixed slots on h_graph, replacing whatever was
497+
// there. The graph's slot table is created on first use. A null owner is a
498+
// no-op (returns CUDA_SUCCESS without creating the table), so callers need not
499+
// guard optional owners. Returns CUDA_SUCCESS, or an error if slot is out of
500+
// range or the graph cannot hold a table (e.g. the driver lacks user-object
501+
// support).
502+
CUresult graph_set_slot(const GraphHandle& h_graph, CUgraphNode node,
503+
unsigned int slot, OpaqueHandle owner);
504+
469505
// ============================================================================
470506
// Graph exec handle functions
471507
// ============================================================================

cuda_core/cuda/core/_resource_handles.pxd

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,12 @@ cdef extern from "_cpp/resource_handles.hpp" namespace "cuda_core":
5757
ctypedef shared_ptr[const TexObjectValue] TexObjectHandle
5858
ctypedef shared_ptr[const SurfObjectValue] SurfObjectHandle
5959

60+
# Type-erased shared owner for resources attached to graph node slots.
61+
# Typed handles above assign directly to an OpaqueHandle (shared control
62+
# block); make_opaque_py / make_opaque_malloc cover the two cases needing a
63+
# custom deleter.
64+
ctypedef shared_ptr[const void] OpaqueHandle
65+
6066
# as_cu() - extract the raw CUDA handle (inline C++)
6167
cydriver.CUcontext as_cu(ContextHandle h) noexcept nogil
6268
cydriver.CUgreenCtx as_cu(GreenCtxHandle h) noexcept nogil
@@ -223,6 +229,13 @@ cdef LibraryHandle get_kernel_library(const KernelHandle& h) noexcept nogil
223229
cdef GraphHandle create_graph_handle(cydriver.CUgraph graph) except+ nogil
224230
cdef GraphHandle create_graph_handle_ref(cydriver.CUgraph graph, const GraphHandle& h_parent) except+ nogil
225231

232+
# Graph slot attachments
233+
cdef OpaqueHandle make_opaque_py(object obj) except+
234+
cdef OpaqueHandle make_opaque_malloc(void* buf) except+
235+
cdef cydriver.CUresult graph_set_slot(
236+
const GraphHandle& h_graph, cydriver.CUgraphNode node,
237+
unsigned int slot, OpaqueHandle owner) except+
238+
226239
# Graph exec handles
227240
cdef GraphExecHandle create_graph_exec_handle(cydriver.CUgraphExec graph_exec) except+ nogil
228241

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

cuda_core/cuda/core/_resource_handles.pyx

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -151,6 +151,13 @@ cdef extern from "_cpp/resource_handles.hpp" namespace "cuda_core":
151151
GraphHandle create_graph_handle_ref "cuda_core::create_graph_handle_ref" (
152152
cydriver.CUgraph graph, const GraphHandle& h_parent) except+ nogil
153153

154+
# Graph slot attachments
155+
OpaqueHandle make_opaque_py "cuda_core::make_opaque_py" (object obj) except+
156+
OpaqueHandle make_opaque_malloc "cuda_core::make_opaque_malloc" (void* buf) except+
157+
cydriver.CUresult graph_set_slot "cuda_core::graph_set_slot" (
158+
const GraphHandle& h_graph, cydriver.CUgraphNode node,
159+
unsigned int slot, OpaqueHandle owner) except+
160+
154161
# Graph exec handles
155162
GraphExecHandle create_graph_exec_handle "cuda_core::create_graph_exec_handle" (
156163
cydriver.CUgraphExec graph_exec) except+ nogil
@@ -304,6 +311,9 @@ cdef extern from "_cpp/resource_handles.hpp" namespace "cuda_core":
304311
# Graph
305312
void* p_cuGraphDestroy "reinterpret_cast<void*&>(cuda_core::p_cuGraphDestroy)"
306313
void* p_cuGraphExecDestroy "reinterpret_cast<void*&>(cuda_core::p_cuGraphExecDestroy)"
314+
void* p_cuUserObjectCreate "reinterpret_cast<void*&>(cuda_core::p_cuUserObjectCreate)"
315+
void* p_cuUserObjectRelease "reinterpret_cast<void*&>(cuda_core::p_cuUserObjectRelease)"
316+
void* p_cuGraphRetainUserObject "reinterpret_cast<void*&>(cuda_core::p_cuGraphRetainUserObject)"
307317

308318
# Linker
309319
void* p_cuLinkDestroy "reinterpret_cast<void*&>(cuda_core::p_cuLinkDestroy)"
@@ -364,6 +374,7 @@ cdef void _init_driver_fn_pointers() noexcept:
364374
global p_cuMemPoolImportPointer
365375
global p_cuLibraryLoadFromFile, p_cuLibraryLoadData, p_cuLibraryUnload, p_cuLibraryGetKernel
366376
global p_cuGraphDestroy, p_cuGraphExecDestroy
377+
global p_cuUserObjectCreate, p_cuUserObjectRelease, p_cuGraphRetainUserObject
367378
global p_cuLinkDestroy
368379
global p_cuGraphicsUnmapResources, p_cuGraphicsUnregisterResource
369380
global p_cuDevSmResourceSplit
@@ -424,6 +435,9 @@ cdef void _init_driver_fn_pointers() noexcept:
424435
# Graph
425436
p_cuGraphDestroy = _get_driver_fn("cuGraphDestroy")
426437
p_cuGraphExecDestroy = _get_driver_fn("cuGraphExecDestroy")
438+
p_cuUserObjectCreate = _get_driver_fn("cuUserObjectCreate")
439+
p_cuUserObjectRelease = _get_driver_fn("cuUserObjectRelease")
440+
p_cuGraphRetainUserObject = _get_driver_fn("cuGraphRetainUserObject")
427441

428442
# Linker
429443
p_cuLinkDestroy = _get_driver_fn("cuLinkDestroy")
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+
"""

0 commit comments

Comments
 (0)