Skip to content

Commit c3fce2c

Browse files
committed
test(cuda.core): cover graph attachment ownership boundaries
Exercise subgraph deletion, in-flight launches, failed transactions, and independent executable references so attachment lifetime guarantees remain regression-tested.
1 parent 9377a31 commit c3fce2c

8 files changed

Lines changed: 364 additions & 32 deletions

File tree

cuda_core/cuda/core/graph/_graph_builder.pyi

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -490,4 +490,7 @@ class Graph:
490490
__all__ = ['Graph', 'GraphBuilder', 'GraphCompleteOptions', 'GraphDebugPrintOptions']
491491

492492
def _instantiate_graph(h_graph, options: GraphCompleteOptions | None=None) -> Graph:
493-
...
493+
...
494+
495+
def _capture_callback_with_tail_failure_for_testing(gb: GraphBuilder, fn, *, user_data=None):
496+
"""Exercise anonymous attachment retention after capture commits a node."""

cuda_core/cuda/core/graph/_graph_builder.pyx

Lines changed: 43 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -852,39 +852,54 @@ cdef class GraphBuilder:
852852
pointer (caller manages lifetime). If bytes-like, the data is
853853
copied and its lifetime is tied to the graph.
854854
"""
855-
GB_check_open(self)
856-
cdef Stream stream = self._stream
857-
cdef cydriver.CUstream c_stream = as_cu(stream._h_stream)
858-
cdef cydriver.CUstreamCaptureStatus capture_status
855+
GB_callback(self, fn, user_data, False)
859856
860-
with nogil:
861-
_get_capture_info(c_stream, &capture_status, NULL)
862857
863-
if capture_status != cydriver.CU_STREAM_CAPTURE_STATUS_ACTIVE:
864-
raise RuntimeError("Cannot add callback when graph is not being built")
858+
cdef inline void GB_callback(
859+
GraphBuilder gb, object fn, object user_data,
860+
bint fail_tail_discovery_for_testing) except *:
861+
GB_check_open(gb)
862+
cdef Stream stream = gb._stream
863+
cdef cydriver.CUstream c_stream = as_cu(stream._h_stream)
864+
cdef cydriver.CUstreamCaptureStatus capture_status
865865
866-
cdef cydriver.CUhostFn c_fn
867-
cdef void* c_user_data = NULL
868-
cdef OpaqueHandle fn_owner, data_owner
869-
cdef GraphAttachmentsHandle attachments
870-
_resolve_host_callback(fn, user_data, &c_fn, &c_user_data, &fn_owner, &data_owner)
871-
attachments = _prepare_host_callback_owners(
872-
self._h_graph, fn_owner, data_owner)
866+
with nogil:
867+
_get_capture_info(c_stream, &capture_status, NULL)
873868
874-
with nogil:
875-
HANDLE_RETURN(cydriver.cuLaunchHostFunc(c_stream, c_fn, c_user_data))
869+
if capture_status != cydriver.CU_STREAM_CAPTURE_STATUS_ACTIVE:
870+
raise RuntimeError("Cannot add callback when graph is not being built")
876871
877-
# Capturing the host function added a node to the graph; it is now the
878-
# stream's sole capture dependency. If tail discovery unexpectedly
879-
# fails, keep the owners anonymously graph-retained: rolling them back
880-
# would leave the already-captured node with dangling pointers.
881-
cdef cydriver.CUgraphNode host_node
882-
try:
883-
host_node = _capture_tail_node(c_stream)
884-
except:
885-
graph_abandon_attachments(attachments)
886-
raise
887-
HANDLE_RETURN(graph_commit_attachments(attachments, host_node))
872+
cdef cydriver.CUhostFn c_fn
873+
cdef void* c_user_data = NULL
874+
cdef OpaqueHandle fn_owner, data_owner
875+
cdef GraphAttachmentsHandle attachments
876+
_resolve_host_callback(
877+
fn, user_data, &c_fn, &c_user_data, &fn_owner, &data_owner)
878+
attachments = _prepare_host_callback_owners(
879+
gb._h_graph, fn_owner, data_owner)
880+
881+
with nogil:
882+
HANDLE_RETURN(cydriver.cuLaunchHostFunc(c_stream, c_fn, c_user_data))
883+
884+
# Capturing the host function added a node to the graph; it is now the
885+
# stream's sole capture dependency. If tail discovery unexpectedly fails,
886+
# keep the owners anonymously graph-retained: rolling them back would
887+
# leave the already-captured node with dangling pointers.
888+
cdef cydriver.CUgraphNode host_node
889+
try:
890+
if fail_tail_discovery_for_testing:
891+
raise RuntimeError("forced capture tail discovery failure")
892+
host_node = _capture_tail_node(c_stream)
893+
except:
894+
graph_abandon_attachments(attachments)
895+
raise
896+
HANDLE_RETURN(graph_commit_attachments(attachments, host_node))
897+
898+
899+
def _capture_callback_with_tail_failure_for_testing(
900+
GraphBuilder gb, fn, *, user_data=None):
901+
"""Exercise anonymous attachment retention after capture commits a node."""
902+
GB_callback(gb, fn, user_data, True)
888903
889904
890905
cdef inline int GB_check_open(GraphBuilder gb) except -1:

cuda_core/cuda/core/graph/_graph_node.pyi

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -405,4 +405,7 @@ class GraphNode:
405405
A new SwitchNode with branches accessible via ``.branches``.
406406
"""
407407
__all__ = ['GraphNode']
408-
_node_registry: weakref.WeakValueDictionary[int, GraphNode] = weakref.WeakValueDictionary()
408+
_node_registry: weakref.WeakValueDictionary[int, GraphNode] = weakref.WeakValueDictionary()
409+
410+
def _fail_callback_node_creation_after_prepare_for_testing(graph_def: GraphDefinition, fn):
411+
"""Exercise prepared-attachment rollback without relying on driver errors."""

cuda_core/cuda/core/graph/_graph_node.pyx

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -485,7 +485,7 @@ cdef class GraphNode:
485485
HostCallbackNode
486486
A new HostCallbackNode representing the callback.
487487
"""
488-
return GN_callback(self, fn, user_data)
488+
return GN_callback(self, fn, user_data, False)
489489
490490
def if_then(self, condition: GraphCondition) -> IfNode:
491491
"""Add an if-conditional node depending on this node.
@@ -1084,7 +1084,9 @@ cdef inline EventWaitNode GN_wait_event(GraphNode self, Event ev):
10841084
create_graph_node_handle(new_node, h_graph), ev._h_event))
10851085

10861086

1087-
cdef inline HostCallbackNode GN_callback(GraphNode self, object fn, object user_data):
1087+
cdef inline HostCallbackNode GN_callback(
1088+
GraphNode self, object fn, object user_data,
1089+
bint fail_after_prepare_for_testing):
10881090
cdef cydriver.CUDA_HOST_NODE_PARAMS node_params
10891091
cdef cydriver.CUgraphNode new_node = NULL
10901092
cdef GraphHandle h_graph = graph_node_get_graph(self._h_node)
@@ -1104,6 +1106,9 @@ cdef inline HostCallbackNode GN_callback(GraphNode self, object fn, object user_
11041106
attachments = _prepare_host_callback_owners(
11051107
h_graph, fn_owner, data_owner)
11061108

1109+
if fail_after_prepare_for_testing:
1110+
raise RuntimeError("forced failure after attachment preparation")
1111+
11071112
with nogil:
11081113
HANDLE_RETURN(cydriver.cuGraphAddHostNode(
11091114
&new_node, as_cu(h_graph), deps, num_deps, &node_params))
@@ -1113,3 +1118,12 @@ cdef inline HostCallbackNode GN_callback(GraphNode self, object fn, object user_
11131118
return _registered(HostCallbackNode._create_with_params(
11141119
create_graph_node_handle(new_node, h_graph), callable_obj,
11151120
node_params.fn, node_params.userData))
1121+
1122+
1123+
def _fail_callback_node_creation_after_prepare_for_testing(
1124+
GraphDefinition graph_def, fn):
1125+
"""Exercise prepared-attachment rollback without relying on driver errors."""
1126+
cdef GraphNode entry = GraphNode.__new__(GraphNode)
1127+
entry._h_node = create_graph_node_handle(
1128+
<cydriver.CUgraphNode>NULL, graph_def._h_graph)
1129+
return GN_callback(entry, fn, None, True)

cuda_core/tests/graph/test_graph_builder.py

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,8 @@
44
"""GraphBuilder stream capture tests."""
55

66
import gc
7+
import time
8+
import weakref
79

810
import numpy as np
911
import pytest
@@ -13,6 +15,18 @@
1315

1416
from cuda.core import Device, LaunchConfig, LegacyPinnedMemoryResource, launch
1517
from cuda.core.graph import GraphBuilder, GraphDefinition
18+
from cuda.core.graph._graph_builder import (
19+
_capture_callback_with_tail_failure_for_testing,
20+
)
21+
22+
23+
def _wait_until(predicate, timeout=5.0):
24+
deadline = time.monotonic() + timeout
25+
while not predicate():
26+
if time.monotonic() >= deadline:
27+
raise AssertionError(f"condition not satisfied within {timeout}s")
28+
gc.collect()
29+
time.sleep(0.02)
1630

1731

1832
def test_graph_is_building(init_cuda):
@@ -341,6 +355,34 @@ def read_byte(data):
341355
assert result[0] == 0xAB
342356

343357

358+
@pytest.mark.agent_authored(model="gpt-5.6")
359+
def test_capture_tail_discovery_failure_preserves_callback_attachment(init_cuda):
360+
"""A committed captured node keeps its owner when discovery fails."""
361+
called = [False]
362+
363+
def callback():
364+
called[0] = True
365+
366+
callback_weak = weakref.ref(callback)
367+
stream = Device().create_stream()
368+
gb = stream.create_graph_builder().begin_building()
369+
370+
with pytest.raises(RuntimeError, match="forced capture tail discovery failure"):
371+
_capture_callback_with_tail_failure_for_testing(gb, callback)
372+
373+
del callback
374+
gc.collect()
375+
assert callback_weak() is not None
376+
377+
graph = gb.end_building().complete()
378+
graph.launch(stream)
379+
stream.sync()
380+
assert called[0]
381+
382+
del graph, gb
383+
_wait_until(lambda: callback_weak() is None)
384+
385+
344386
@pytest.mark.skipif(tuple(int(i) for i in np.__version__.split(".")[:2]) < (2, 1), reason="need numpy 2.1.0+")
345387
def test_graph_child_graph(init_cuda):
346388
mod = compile_common_kernels()

cuda_core/tests/graph/test_graph_definition_errors.py

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,9 @@
44
"""Tests for GraphDefinition input validation, error handling, and edge cases."""
55

66
import ctypes
7+
import gc
8+
import time
9+
import weakref
710

811
import pytest
912
from helpers.graph_kernels import compile_common_kernels
@@ -17,6 +20,9 @@
1720
GraphCondition,
1821
GraphDefinition,
1922
)
23+
from cuda.core.graph._graph_node import (
24+
_fail_callback_node_creation_after_prepare_for_testing,
25+
)
2026

2127
SIZEOF_INT = ctypes.sizeof(ctypes.c_int)
2228

@@ -26,6 +32,15 @@ def _skip_if_no_mempool():
2632
pytest.skip("Device does not support mempool operations")
2733

2834

35+
def _wait_until(predicate, timeout=5.0):
36+
deadline = time.monotonic() + timeout
37+
while not predicate():
38+
if time.monotonic() >= deadline:
39+
raise AssertionError(f"condition not satisfied within {timeout}s")
40+
gc.collect()
41+
time.sleep(0.02)
42+
43+
2944
# =============================================================================
3045
# Type validation — wrong types for conditional node methods
3146
# =============================================================================
@@ -98,6 +113,24 @@ def test_condition_from_different_graph(init_cuda):
98113
g2.if_then(condition)
99114

100115

116+
@pytest.mark.agent_authored(model="gpt-5.6")
117+
def test_failed_node_creation_releases_prepared_attachments(init_cuda):
118+
"""Failure after preparation rolls back ownership without adding a node."""
119+
120+
def callback():
121+
pass
122+
123+
callback_weak = weakref.ref(callback)
124+
graph_def = GraphDefinition()
125+
126+
with pytest.raises(RuntimeError, match="forced failure after attachment preparation"):
127+
_fail_callback_node_creation_after_prepare_for_testing(graph_def, callback)
128+
129+
del callback
130+
_wait_until(lambda: callback_weak() is None)
131+
assert graph_def.nodes() == set()
132+
133+
101134
# =============================================================================
102135
# Edge cases — valid but unusual usage patterns
103136
# =============================================================================

0 commit comments

Comments
 (0)