Repository navigation
[BUG]: Timing dependent concurrency flake via test_destroying_child_node_invalidates_embedded_handles #3156
Description
Is this a duplicate?
- I confirmed there appear to be no duplicate issues for this bug and that I agree to the Code of Conduct
Type of Bug
Runtime Error
Component
cuda.core
Describe the bug
Summary
Two related CUDA graph lifetime tests failed intermittently on Windows free-threaded Python when pytest-run-parallel ran four copies of each test concurrently. Both failures disappeared when the jobs were rerun.
The evidence points to a possible raw-CUgraph handle reuse/ABA race in the process-wide graph registry. A child graph can be destroyed by CUDA before its registry entry is retired, allowing another thread to resolve a reused raw handle to stale metadata.
Failing run
Run: https://github.com/NVIDIA/cuda-python/actions/runs/39296344205
Commit: 9d903cdcaee82731618dd56e8c76bcd528918592
Failure 1
Job: https://github.com/NVIDIA/cuda-python/actions/runs/39296344205/job/118215572432
Environment:
- Windows
- Python 3.14.8 free-threaded
- CUDA toolkit 13.4.3
- A100 40 GB, MCDM
- Driver 596.36
pytest-run-parallel==0.10.0--parallel-threads=4
Failing test:
tests/graph/test_graph_definition_lifetime.py::
test_destroying_child_node_invalidates_embedded_handles
The test failed during its first middle.embed(inner), before reaching its destruction or invalidation assertions:
GraphDefinition.embed
GraphNode.embed
GN_embed
HANDLE_RETURN(graph_clone_attachments(h_embedded, child_def._h_graph))
CUDAError: CUDA_ERROR_INVALID_VALUE
Source:
- Test:
cuda-python/cuda_core/tests/graph/test_graph_definition_lifetime.py
Lines 445 to 465 in 9d903cd
def test_destroying_child_node_invalidates_embedded_handles(init_cuda): """Destroying a child owner invalidates all embedded graph and node views.""" inner = GraphDefinition() inner.callback(lambda: None) middle = GraphDefinition() middle.embed(inner) outer = GraphDefinition() outer_child = outer.embed(middle) embedded_middle = outer_child.child_graph embedded_child = next(node for node in embedded_middle.nodes() if isinstance(node, ChildGraphNode)) embedded_inner = embedded_child.child_graph embedded_callback = next(node for node in embedded_inner.nodes() if isinstance(node, HostCallbackNode)) outer_child.destroy() assert not outer_child.is_valid assert int(embedded_middle.handle) == 0 assert int(embedded_inner.handle) == 0 assert not embedded_child.is_valid assert not embedded_callback.is_valid - Embed implementation:
cuda-python/cuda_core/cuda/core/graph/_graph_node.pyx
Lines 1098 to 1117 in 9d903cd
with nogil: HANDLE_RETURN(cydriver.cuGraphAddChildGraphNode( &new_node, as_cu(h_graph), deps, num_deps, as_cu(child_def._h_graph))) cdef cydriver.CUgraph embedded_graph = NULL cdef GraphHandle h_embedded try: with nogil: HANDLE_RETURN(cydriver.cuGraphChildGraphNodeGetGraph( new_node, &embedded_graph)) h_embedded = create_child_graph_handle( embedded_graph, h_graph, new_node) HANDLE_RETURN(graph_clone_attachments( h_embedded, child_def._h_graph)) except: with nogil: rollback_status = cydriver.cuGraphDestroyNode(new_node) if rollback_status == cydriver.CUDA_SUCCESS: invalidate_child_graph_state(h_graph, new_node) - Attachment cloning:
cuda-python/cuda_core/cuda/core/_cpp/rt/graph.cpp
Lines 601 to 646 in 9d903cd
CUresult graph_clone_attachments( const GraphHandle& h_clone, const GraphHandle& h_source) { if (!h_clone || !h_source) { return CUDA_ERROR_INVALID_VALUE; } GraphBox* clone = get_box(h_clone); GraphBox* source = get_box(h_source); if (!clone->resource || !source->resource || !clone->attachments.empty()) { return CUDA_ERROR_INVALID_VALUE; } // Build and rekey the clone metadata off-hierarchy so a CUDA mapping error // cannot partially publish it. GraphAttachmentMap attachments; std::list<GraphBox> subgraphs; StagedGraphMetadataList staged; stage_graph_metadata( *source, *clone, attachments, subgraphs, staged); std::vector<GraphHandle> handles; handles.reserve(subgraphs.size()); for (GraphBox& graph : subgraphs) { handles.emplace_back(h_clone, &graph.resource); } CUresult status; { GILReleaseGuard gil; status = rekey_graph_metadata(staged); } if (status != CUDA_SUCCESS) { return status; } clone->attachments.swap(attachments); if (subgraphs.empty()) { return CUDA_SUCCESS; } clone->hierarchy->graphs.splice( clone->hierarchy->graphs.end(), subgraphs); graph_registry.register_handles(handles); return CUDA_SUCCESS;
The identical matrix entry was rerun successfully:
https://github.com/NVIDIA/cuda-python/actions/runs/39296344205/job/118232342949
The rerun used the same source SHA, artifact digests, dependencies, GPU model, driver, and four-thread configuration.
Failure 2
A second free-threaded job failed a related test during the same run and attempt:
https://github.com/NVIDIA/cuda-python/actions/runs/39296344205/job/118215572511
Environment:
- Windows
- Python 3.15t
- CUDA 12.9.1
- V100, TCC
--parallel-threads=4
Failing test:
test_destroying_conditional_node_invalidates_branch_handles[switch]
It failed before that invocation called owner.destroy():
RuntimeError: GraphDefinition is no longer valid
The traceback locals showed that two freshly returned branch objects were already invalid:
branches = [
<GraphDefinition handle=0x0>,
<GraphDefinition handle=0x0>,
<GraphDefinition handle=0x212b886b810>,
<GraphDefinition handle=0x212b886b3c0>,
]
Conditional branches use the same create_child_graph_handle() registry path as embedded child graphs:
cuda-python/cuda_core/cuda/core/graph/_graph_node.pyx
Lines 674 to 681 in 9d903cd
| cdef list branch_list = [] | |
| cdef unsigned int i | |
| cdef cydriver.CUgraph bg | |
| cdef GraphHandle h_branch | |
| for i in range(size): | |
| bg = params.conditional.phGraph_out[i] | |
| h_branch = create_child_graph_handle(bg, h_graph, new_node) | |
| branch_list.append(GraphDefinition._from_handle(h_branch)) |
Seeing newly created branch handles invalidated before their own owner is destroyed suggests cross-thread registry aliasing or invalidation.
Why this appears timing-dependent
ci/tools/run-tests enables four parallel threads on free-threaded Python:
cuda-python/ci/tools/run-tests
Lines 40 to 45 in 9d903cd
| TEST_FT_GROUP=() | |
| PYTEST_PARALLEL_ARGS=() | |
| if python -c 'import sys; assert not sys._is_gil_enabled()' 2> /dev/null; then | |
| TEST_FT_GROUP=(--group test-ft) | |
| PYTEST_PARALLEL_ARGS=(--parallel-threads=4) | |
| fi |
pytest-run-parallel runs four concurrent copies of each parallel-safe test in the same process.
The failing and passing jobs used different pytest seeds, but run-tests passes --randomly-dont-reorganize, and these tests do not use randomness. The neighboring test order was identical.
The successful runner was also consistently slower, which likely changed the thread scheduling enough to avoid the race.
Suspected race
The likely interleaving is:
- One test copy calls
GraphNode.destroy(). GraphNode.destroy()callscuGraphDestroyNode()first.- CUDA implicitly destroys the node's embedded child graphs.
- The raw child
CUgraphhandle becomes eligible for reuse. - Before
invalidate_child_graph_state()removes the old registry entry, another worker creates a child graph with the same raw handle value. create_child_graph_handle()finds the old entry and returns its staleGraphHandle.- The first worker then retires/nulls that handle, or the second worker passes mismatched metadata to
graph_clone_attachments(). - The second worker observes an invalid
GraphDefinitionor getsCUDA_ERROR_INVALID_VALUE.
Relevant ordering in GraphNode.destroy():
cuda-python/cuda_core/cuda/core/graph/_graph_node.pyx
Lines 181 to 193 in 9d903cd
| cdef GraphHandle h_graph = graph_node_get_graph(self._h_node) | |
| # Allocate the cleanup transaction before asking CUDA to destroy the | |
| # node. A failed CUDA call leaves metadata and wrappers unchanged. | |
| HANDLE_RETURN(graph_prepare_attachment( | |
| h_graph, OpaqueHandle(), OpaqueHandle(), &prepared)) | |
| with nogil: | |
| HANDLE_RETURN(cydriver.cuGraphDestroyNode(node)) | |
| # Publish attachment removal before invalidating graph and node aliases. | |
| cdef cydriver.CUresult cleanup_status = graph_commit_attachment(prepared, node) | |
| invalidate_child_graph_state(h_graph, node) | |
| _node_registry.pop(<uintptr_t>self._h_node.get(), None) | |
| invalidate_graph_node(self._h_node) |
Registry lookup and registration:
cuda-python/cuda_core/cuda/core/_cpp/rt/graph.cpp
Lines 329 to 346 in 9d903cd
| GraphHandle create_child_graph_handle( | |
| CUgraph child_graph, const GraphHandle& h_parent, | |
| CUgraphNode owner_node) { | |
| if (!child_graph || !h_parent || !owner_node) { | |
| return {}; | |
| } | |
| if (GraphHandle h_graph = graph_registry.lookup(child_graph)) { | |
| return h_graph; | |
| } | |
| GraphBox* parent = get_box(h_parent); | |
| GraphHierarchy* hierarchy = parent->hierarchy; | |
| GraphBox& child = hierarchy->graphs.emplace_back( | |
| child_graph, hierarchy, parent, owner_node); | |
| GraphHandle h_child(h_parent, &child.resource); | |
| graph_registry.register_handle(child_graph, h_child); | |
| return h_child; |
Registry retirement:
cuda-python/cuda_core/cuda/core/_cpp/rt/graph.cpp
Lines 663 to 706 in 9d903cd
| // Retire one box of a graph that CUDA has destroyed: detach its node handles, | |
| // drop its registry entry and attachments, and move it to the graveyard. | |
| // `graph` must be an iterator into hierarchy.graphs; the splice invalidates it. | |
| static void retire_graph( | |
| GraphHierarchy& hierarchy, | |
| std::list<GraphBox>::iterator graph) noexcept { | |
| for (auto& entry : graph->node_handles.drain()) { | |
| if (GraphNodeHandle h_node = entry.second.lock()) { | |
| get_box(h_node)->resource = nullptr; | |
| } | |
| } | |
| if (graph->resource) { | |
| graph_registry.unregister_handle(graph->resource); | |
| graph->resource = nullptr; | |
| } | |
| graph->attachments.clear(); | |
| hierarchy.graveyard.splice( | |
| hierarchy.graveyard.end(), hierarchy.graphs, graph); | |
| } | |
| // graphs is ordered parent-before-child. Nulling a selected box marks its | |
| // later descendants, whose parent pointers remain valid after list splicing. | |
| // This permits one allocation-free sweep of the hierarchy. | |
| void invalidate_child_graph_state( | |
| const GraphHandle& h_parent, | |
| CUgraphNode owner_node) noexcept { | |
| if (!h_parent || !owner_node) { | |
| return; | |
| } | |
| GraphBox* parent = get_box(h_parent); | |
| if (!parent->resource) { | |
| return; | |
| } | |
| GraphHierarchy& hierarchy = *parent->hierarchy; | |
| for (auto it = hierarchy.graphs.begin(); | |
| it != hierarchy.graphs.end();) { | |
| auto graph = it++; | |
| bool is_owned_root = graph->parent == parent && | |
| graph->owner_node == owner_node; | |
| bool is_descendant = graph->parent && | |
| !graph->parent->resource; | |
| if (is_owned_root || is_descendant) { | |
| retire_graph(hierarchy, graph); |
The registry has a mutex, but it protects individual lookup/register/unregister operations. It does not make the driver destruction → registry retirement sequence atomic.
The graph attachment design document explicitly notes that registry removal is needed to prevent corruption when CUDA reuses a raw handle:
cuda-python/cuda_core/cuda/core/_cpp/rt/GRAPH_ATTACHMENTS.md
Lines 172 to 180 in 9d903cd
| When CUDA destroys a child graph, the associated `GraphBox` is cleared and | |
| moved to `GraphHierarchy::graveyard`. That invalidates existing handles and | |
| preserves the storage until the hierarchy's last shared owner is released. | |
| Removing the registry entry also prevents corruption if a future CUDA graph | |
| reuses the raw handle value. | |
| The process-wide graph registry maps a live `CUgraph` to its canonical | |
| `GraphHandle`. Node handles are scoped to their `GraphBox`, where they can all | |
| be invalidated when CUDA destroys that graph. They use separate |
Each parallel test invocation creates distinct graph objects, so this does not appear to be unsupported concurrent mutation of the same user-visible object.
Suggested investigation
Add temporary instrumentation around:
create_child_graph_handle()retire_graph()invalidate_child_graph_state()graph_clone_attachments()
Log the following:
- Raw
CUgraphvalue GraphBoxaddress- Parent hierarchy and owner node
- Thread ID
- Whether
graph_clone_attachments()failed its local precondition or propagated an error fromcuGraphNodeFindInClone()
A possible stress command on a matching Windows free-threaded runner is:
cd cuda_core
pytest -v --parallel-threads=4 --count=100 \
tests/graph/test_graph_definition_lifetime.py::test_destroying_child_node_invalidates_embedded_handles \
tests/graph/test_graph_definition_lifetime.py::test_destroying_conditional_node_invalidates_branch_handlesPossible fixes
The underlying fix likely needs one of:
- Serialize child-graph destruction/retirement against child-handle creation.
- Remove or reserve registry entries before CUDA can reuse the raw handle, with rollback if destruction fails.
- Make registry entries and removals generation-aware so an old owner cannot resolve or erase a newer graph using the same raw value.
As a short-term CI mitigation, the two invalidation tests could be marked thread_unsafe. However, that would hide what appears to be a real distinct-object concurrency defect rather than fix it.
How to Reproduce
Not reproducible. You could try the stress reproduction suggestions above.
Expected behavior
Under most circumstances, this test succeeds.
Operating System
No response
nvidia-smi output
No response
Activity
Andy-Jost commentedon Oct 27, 2026
Another test of this family flaked today:
test_destroying_conditional_node_invalidates_branch_handles[switch]raisedRuntimeError: GraphDefinition is no longer validon thewin-64 / Python 3.14t, CUDA 12.9.1 (local), GPU l4 (TCC)job of PR #3230 (https://github.com/NVIDIA/cuda-python/actions/runs/40564325631, attempt 1). The change under test touches neither graphs nor this file.