Skip to content

[BUG]: Timing dependent concurrency flake via test_destroying_child_node_invalidates_embedded_handles #3156

Open

Description

@jpascucci-nv

Is this a duplicate?

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:
    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:
    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:
    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:

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:

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:

  1. One test copy calls GraphNode.destroy().
  2. GraphNode.destroy() calls cuGraphDestroyNode() first.
  3. CUDA implicitly destroys the node's embedded child graphs.
  4. The raw child CUgraph handle becomes eligible for reuse.
  5. Before invalidate_child_graph_state() removes the old registry entry, another worker creates a child graph with the same raw handle value.
  6. create_child_graph_handle() finds the old entry and returns its stale GraphHandle.
  7. The first worker then retires/nulls that handle, or the second worker passes mismatched metadata to graph_clone_attachments().
  8. The second worker observes an invalid GraphDefinition or gets CUDA_ERROR_INVALID_VALUE.

Relevant ordering in GraphNode.destroy():

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:

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:

// 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:

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 CUgraph value
  • GraphBox address
  • Parent hierarchy and owner node
  • Thread ID
  • Whether graph_clone_attachments() failed its local precondition or propagated an error from cuGraphNodeFindInClone()

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_handles

Possible 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

  1. added
    cuda.coreEverything related to the cuda.core module
    P1Medium priority - Should do
    on Oct 22, 2026
  2. added this to the cuda.core 1.3.0 milestone on Oct 22, 2026
  3. Andy-Jost commented on Oct 27, 2026

    @Andy-Jost
    Contributor

    Another test of this family flaked today: test_destroying_conditional_node_invalidates_branch_handles[switch] raised RuntimeError: GraphDefinition is no longer valid on the win-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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Labels

P1Medium priority - Should dobugSomething isn't workingcuda.coreEverything related to the cuda.core module

Type

No type

Projects

No projects

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions