Skip to content

Commit c278ecb

Browse files
authored
cuda.core: retain graph attachments with per-node user objects (#2357)
* test(cuda.core): define graph attachment ownership requirements Preserve the validated lifetime and failure scenarios as executable specifications for the metadata redesign, alongside independent cleanup clarifications. * refactor(cuda.core): define graph hierarchy storage Establish canonical per-graph boxes, stable hierarchy ownership, and Level 1 graph identity before rebuilding attachment operations. * refactor(cuda.core): canonicalize graph hierarchy handles Give root and child graph handles stable per-graph boxes while sharing one hierarchy control block and registry identity. * refactor(cuda.core): retain immutable node attachments Replace the mutable graph-wide slot table with complete per-node user objects and intrusive deferred cleanup. * refactor(cuda.core): import cloned graph attachments Copy and rekey source attachment metadata into embedded graph hierarchies before publishing their canonical boxes. * refactor(cuda.core): invalidate destroyed child graph state Unregister and tombstone child graph boxes after CUDA destroys their owner while preserving stable storage for existing handles. * fix(cuda.core): retain unidentified captured callbacks Allow graph-level anonymous attachments so a captured host callback stays safe when CUDA commits it but its node cannot be recovered. * test(cuda.core): cover graph hierarchy invalidation Verify anonymous capture retention and proactive invalidation of child and conditional graph views. * docs(cuda.core): explain graph attachment ownership Orient contributors to versioned node ownership, graph hierarchy metadata, clone propagation, invalidation, and deferred cleanup. * fix(cuda.core): harden graph attachment lifecycle Make node identity atomic, preserve tombstoned graph identity, retry deferred cleanup safely, and cover final-reference launch ownership. * docs(cuda.core): add 1.2.0 graph lifetime note Document corrected graph attachment lifetime and callback-safe resource release. * refactor(cuda.core): prepare attachments before graph mutation Retain attachment owners and preallocate metadata before CUDA graph mutations so failures cannot leave nodes with unretained resources. * fix(cuda.core): make attachment rollback portable Carry rollback through the prepared attachment deleter so consumer extension modules do not depend on an out-of-line C++ symbol. * docs(cuda.core): clarify graph handle semantics Preserve the general no-invalidation guarantee while documenting borrowed child graphs as a narrow driver-owned exception, and tighten implementation comments for reviewers. * docs(cuda.core): clarify graph attachment ownership Reframe the design around parameter versions and driver-managed lifetimes, and keep mutation ordering guidance beside the implementation. * docs(cuda.core): add graph ownership diagrams Replace the ASCII sketch with Mermaid and illustrate the GraphHandle alias and control-block relationships for reviewers. * docs(cuda.core): warn about graph attachment cycles Document that driver-held Python references are opaque to cyclic GC and identify affected graph APIs.
1 parent 76bac4e commit c278ecb

22 files changed

Lines changed: 1817 additions & 353 deletions

cuda_core/cuda/core/_cpp/DESIGN.md

Lines changed: 9 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,12 @@ Resource handles provide **referentially transparent** wrappers around CUDA reso
4141
This eliminates global lifetime analysis. Correctness is enforced structurally—if you
4242
have a handle, you have a valid resource.
4343

44+
Handles to child graphs and their nodes are a narrow exception. These handles
45+
are borrowed references to CUDA resources owned by a mutable parent graph.
46+
Removing or replacing the owning graph node destroys those resources, forcing
47+
cuda-core to invalidate any outstanding handles. See
48+
[Graph Node Attachments](GRAPH_ATTACHMENTS.md).
49+
4450
## Handle Types
4551

4652
All handles are `std::shared_ptr` aliases that expose only the raw CUDA resource:
@@ -205,6 +211,9 @@ struct StreamBox {
205211
The shared pointer's custom deleter captures any additional state needed for
206212
destruction. This ensures resources are always destroyed in the correct order.
207213
214+
Graph node parameters require a specialized ownership model built on
215+
`OpaqueHandle`; see [Graph Node Attachments](GRAPH_ATTACHMENTS.md).
216+
208217
### GIL Management
209218
210219
Handle destructors may run from any thread. The implementation includes RAII guards
@@ -217,21 +226,6 @@ Handle destructors may run from any thread. The implementation includes RAII gua
217226
The handle API functions are safe to call with or without the GIL held. They
218227
will release the GIL (if necessary) before calling CUDA driver API functions.
219228
220-
### CUDA User-Object Cleanup
221-
222-
CUDA user-object destructors may run on an internal driver thread where CUDA
223-
API calls are forbidden. Graph attachment payloads can release handles whose
224-
deleters call CUDA or run Python finalizers, so their CUDA callback must not
225-
delete the payload directly.
226-
227-
The callback enqueues a preallocated `DeferredCleanupItem` on a
228-
process-lifetime lock-free `DeferredCleanupQueue` and schedules one coalesced
229-
`Py_AddPendingCall`. The pending callback drains all queued payloads from
230-
Python's main thread. If scheduling fails, payloads remain intact for a later
231-
retry; they are intentionally leaked once interpreter finalization begins. A
232-
single pending call is shared by all queued payloads because CPython's
233-
main-thread pending-call queue is bounded.
234-
235229
### Static Initialization and Deadlock Hazards
236230
237231
When writing C++ code that interacts with Python, a subtle deadlock can occur
Lines changed: 189 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,189 @@
1+
# Graph Node Attachments
2+
3+
## Why attachments are needed
4+
5+
CUDA graph work has three stages: definition, instantiation, and execution. An
6+
application builds a mutable `CUgraph`, instantiates a snapshot as a
7+
`CUgraphExec`, then launches that executable one or more times. The `CUgraph`
8+
is composed of `CUgraphNode` objects. Each node has a complete parameter set,
9+
and those parameters can be replaced after the node is created.
10+
11+
A `CUgraph` may also be cloned or embedded as a child graph. Cloning,
12+
embedding, and instantiation each copy the source graph's current nodes and
13+
parameters. The copy then has an independent lifetime: later changes to the
14+
source do not affect it. An embedded copy is owned by its parent node and is
15+
destroyed with that node.
16+
17+
Node parameters may refer to kernels, memory, events, callbacks, or other
18+
resources that CUDA does not own. Their lifetimes cannot simply follow the
19+
source node because copied or previously launched work may continue to use old
20+
parameters after the source changes.
21+
22+
For example, suppose a kernel node refers to allocation A when its graph is
23+
instantiated. Updating the source node to refer to allocation B leaves the
24+
executable graph using A. If that executable is launched and then successfully
25+
updated from the source graph, future launches use B while the earlier launch
26+
continues to use A. Releasing A at either update would leave the executable or
27+
launch with a dangling pointer.
28+
29+
At any instant, one logical node may therefore have several complete parameter
30+
sets in use. cuda.core knows which parameters are current in a graph
31+
definition, but not which clones, executable graphs, or asynchronous launches
32+
still use older sets. Only CUDA can track the lifetimes of all those consumers;
33+
CUDA user objects provide the mechanism for tying resource lifetime to those
34+
consumers.
35+
36+
cuda.core uses this mechanism by treating each complete parameter set as a
37+
separate lifetime unit, called a **parameter version**. Adding or updating a
38+
node creates a new version, and multiple versions may be live at once. The
39+
resources referenced by each version must remain alive until all of its CUDA
40+
consumers have finished.
41+
42+
To enforce this rule, cuda.core packages each version's resource owners in one
43+
`NodeAttachment`. Once published, its owner bundle is immutable. Replacing the
44+
complete bundle as a unit prevents torn ownership state. A graph-retained CUDA
45+
user object owns the attachment, allowing CUDA to preserve it across clones,
46+
executable graphs, and in-flight launches and release it after the last
47+
consumer finishes.
48+
49+
## Ownership model
50+
51+
For each resource-bearing parameter version, cuda.core:
52+
53+
1. Collects its resources in a `NodeAttachment` whose owner bundle is immutable
54+
after publication (although the resources it keeps alive may themselves be
55+
mutable).
56+
2. Creates a CUDA user object that owns the attachment.
57+
3. Retains one user-object reference on the `CUgraph`.
58+
59+
CUDA copies graph-owned user-object references when it clones or instantiates a
60+
graph. It also keeps the references needed by in-flight launches. Replacing or
61+
deleting a source node therefore releases only the source graph's reference.
62+
Clones, executable graphs, and launches retain the old attachment for as long
63+
as they need it.
64+
65+
```mermaid
66+
flowchart LR
67+
Definition["CUgraph definition"] -->|retains| UserObject["CUuserObject"]
68+
Clone["CUgraph clone"] -->|retains| UserObject
69+
Executable["CUgraphExec"] -->|retains| UserObject
70+
Launch["in-flight launch"] -->|retains| UserObject
71+
UserObject -->|owns| Attachment["NodeAttachment"]
72+
Attachment -->|owns| Resources["resources"]
73+
Metadata["GraphAttachmentMap"] -. non-owning current lookup .-> Attachment
74+
```
75+
76+
The CUDA user-object reference count controls the attachment lifetime.
77+
`GraphAttachmentMap` only lets cuda.core find the attachment currently
78+
associated with a node.
79+
80+
Each `NodeAttachment` contains two type-erased `OpaqueHandle` owners:
81+
82+
- kernel: kernel and argument storage
83+
- host callback: callback and copied user data
84+
- memcpy: destination and source
85+
- memset or event: destination or event in the first owner
86+
87+
`OpaqueHandle` is `shared_ptr<const void>`. Existing cuda.core handles reuse
88+
their shared ownership when converted to it. Python objects and copied callback
89+
data use custom deleters.
90+
91+
This immutability is structural: cuda.core never modifies either owner in a
92+
published attachment. The resources those owners keep alive, including Python
93+
objects, may remain mutable, but they must not be modified in a way that
94+
releases resources still referenced by an installed parameter version.
95+
96+
## Deferred cleanup
97+
98+
CUDA invokes a user-object destructor on an internal thread where CUDA API
99+
calls are forbidden. Destroying an attachment there could release handles whose
100+
deleters call CUDA or run Python finalizers.
101+
102+
`NodeAttachment` therefore inherits from `DeferredCleanupItem`. The CUDA
103+
destructor callback only adds the attachment to the process-lifetime
104+
`DeferredCleanupQueue` and requests a `Py_AddPendingCall`.
105+
106+
One pending call drains all queued attachments from Python's main thread. The
107+
queue coalesces work because CPython's pending-call queue is bounded. If
108+
scheduling fails, attachments stay queued and a later enqueue or safe cuda.core
109+
entry retries. Graph and executable-graph destruction and explicit close paths
110+
provide additional retry points. During Python finalization, scheduling stops
111+
and unreclaimable attachments are intentionally leaked rather than destroyed
112+
in an unsafe context.
113+
114+
## Graph hierarchy state
115+
116+
One `GraphHierarchy` represents a root graph and every CUDA-owned child or
117+
conditional-body graph below it. Every graph in the hierarchy has one
118+
canonical `GraphBox`.
119+
120+
A `GraphHandle` (type `std::shared_ptr<const CUgraph>`) points to a
121+
`GraphBox::resource` while sharing ownership of the whole `GraphHierarchy`.
122+
Holding any root or child handle therefore keeps the entire hierarchy
123+
alive. Only the root graph is destroyed with `cuGraphDestroy`; CUDA itself
124+
destroys embedded child graphs.
125+
126+
```mermaid
127+
flowchart LR
128+
subgraph Hierarchy ["**GraphHierarchy**"]
129+
Boxes["**GraphBox**
130+
resource: CUgraph
131+
hierarchy: GraphHierarchy*"]
132+
end
133+
134+
Hierarchy ~~~ Handle["**GraphHandle**"]
135+
Handle -. derefs to CUgraph .-> Boxes
136+
Handle -->|owns via control block| Hierarchy
137+
```
138+
139+
`GraphHierarchy::graphs` is a list of live `GraphBox` objects containing
140+
metadata for the root graph and subgraphs. Each `GraphBox` stores:
141+
142+
- the `CUgraph`
143+
- its parent box and owning node, when it is a child graph
144+
- a `GraphAttachmentMap` from nodes to node attachments (`NodeAttachment*`)
145+
- a per-graph weak registry to canonicalize node handles
146+
147+
When CUDA destroys a child graph, the associated `GraphBox` is cleared and
148+
moved to `GraphHierarchy::graveyard`. That invalidates existing handles and
149+
preserves the storage until the hierarchy's last shared owner is released.
150+
Removing the registry entry also prevents corruption if a future CUDA graph
151+
reuses the raw handle value.
152+
153+
The process-wide graph registry maps a live `CUgraph` to its canonical
154+
`GraphHandle`. Node handles are scoped to their `GraphBox`, where they can all
155+
be invalidated when CUDA destroys that graph. They use separate
156+
`GraphNodeBox::resource` fields and are nulled before the graph box is retired.
157+
158+
## Invariants
159+
160+
1. The owner bundle of a published `NodeAttachment` is never modified in place.
161+
2. CUDA user-object references, not metadata pointers, own attachments.
162+
3. Metadata is removed or replaced before its graph reference is released.
163+
4. Fallible attachment setup and metadata allocation happen before the CUDA
164+
graph mutation they support.
165+
5. Every live cuda.core `CUgraph` has one canonical `GraphBox` and registry
166+
entry.
167+
6. Graph boxes remain in parent-before-child order.
168+
7. Destroyed child boxes remain at stable addresses in the graveyard.
169+
8. A raw graph handle is unregistered before its box becomes a tombstone.
170+
9. CUDA callbacks only enqueue attachments; they never release owners or call
171+
CUDA.
172+
10. Graph mutations and their metadata updates require the same external
173+
synchronization as the underlying CUDA graph.
174+
175+
## Scope
176+
177+
- Attachment metadata tracks graph mutations performed through cuda.core.
178+
- Raw driver clones receive the CUDA user-object references needed for safe
179+
execution, but cuda.core cannot reconstruct their node-to-attachment map.
180+
- Executable graphs rely on CUDA's inherited user-object references; they do
181+
not use `GraphAttachmentMap`.
182+
- Direct executable-node updates require separate executable ownership and are
183+
not tracked by definition attachment metadata.
184+
- Stream capture explicitly retains host callbacks. Other captured operations
185+
keep their documented caller-owned lifetime contract.
186+
- CPython's cyclic garbage collector cannot follow the ownership path from a
187+
CUDA graph through a driver-held CUDA user object to an attached Python
188+
parameter. Reference cycles involving attached Python parameters therefore
189+
cannot be collected and are resource leaks.

cuda_core/cuda/core/_cpp/REGISTRY_DESIGN.md

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -20,17 +20,17 @@ expires.
2020
## Level 1: Driver Handle -> Resource Handle (C++)
2121

2222
`HandleRegistry` in `resource_handles.cpp` maps a raw CUDA handle
23-
(e.g., `CUevent`, `CUkernel`, `CUgraphNode`) to the `weak_ptr` that
24-
owns it. When a `_ref` constructor receives a raw handle, it
25-
checks the registry first. If found, it returns the existing
23+
(e.g., `CUevent`, `CUkernel`, `CUgraph`) to a `weak_ptr`
24+
for its owning resource handle. When a `_ref` constructor receives a raw
25+
handle, it checks the registry first. If found, it returns the existing
2626
`shared_ptr`, preserving the Box and its metadata (e.g., `EventBox`
2727
carries timing/IPC flags, `KernelBox` carries the library dependency).
2828

2929
Without this level, a round-tripped handle would produce a new Box
3030
with default metadata, losing information that was set at creation.
3131

3232
Instances: `context_registry`, `stream_registry`, `event_registry`,
33-
`kernel_registry`, `graph_node_registry`.
33+
`kernel_registry`, `graph_registry`, and one node registry per `GraphBox`.
3434

3535
## Level 2: Resource Handle -> Python Object (Cython)
3636

0 commit comments

Comments
 (0)