Skip to content

cuda.core: fix pool setup and builder teardown under stream capture - #2838

Open
Andy-Jost wants to merge 10 commits into
NVIDIA:mainfrom
Andy-Jost:ajost/capture-fixes-2834
Open

Andy-Jost wants to merge 10 commits into
NVIDIA:mainfrom
Andy-Jost:ajost/capture-fixes-2834

Conversation

@Andy-Jost

@Andy-Jost Andy-Jost commented Sep 11, 2026

Copy link
Copy Markdown
Contributor

Summary

Fixes #2834, both parts, and #2776. DeviceMemoryResource(device) can be constructed for the device's default pool, and Device.memory_resource first touched, while the calling thread is inside a global or thread_local stream capture, without failing or invalidating the capture. Ending, closing, or collecting a top-level GraphBuilder whose capture was invalidated, or left unjoined by a join() that failed partway, no longer segfaults.

Both minimal reproducers from #2834 were run against this branch on a local GPU host: every scenario of the first now leaves the capture active, and the second survives 200 invalidate-and-collect rounds in each capture mode.

Changes

  • _memory_pool.pyx: MP_raise_release_threshold makes its cuMemPoolGetAttribute/cuMemPoolSetAttribute calls in relaxed capture mode via cuThreadExchangeStreamCaptureMode and restores the thread's previous mode. Capture mode is per thread, so this needs no stream and has no effect when the thread is not capturing. A failed restore is attached to the propagating error as a note, per the error handling policy. This mirrors the libcu++ fix in [libcu++] Make resolving a default memory pool legal under stream capture cccl#11360.
  • _cpp/rt/graph.cpp, api.hpp, _rt.pxd, _rt.pyx: new invalidate_root_graph_state(h_root) retires a hierarchy whose root graph CUDA destroyed itself, so the owning handle's deleter no longer calls cuGraphDestroy on it.
  • _graph_builder.pyx: GB_end_capture replaces GB_end_capture_if_needed and settles graph ownership from the cuStreamEndCapture result. A NULL graph means the driver discarded the capture graph; the builder drops its handle and enters the new CAPTURE_INVALIDATED state. end_building() ends an invalidated capture and raises the driver error, close() closes the builder and then raises, and __dealloc__ reports as before. complete(), debug_dot_print(), graph_definition, embed() and Graph.update() reject a graph-less builder with a clear message. end_building() on a forked builder raises RuntimeError instead of ending capture on the wrong stream, which invalidated the whole capture.
  • _graph_builder.pyx: join() closes every fork the loop did not reach before the error propagates, so no fork is left capturing on its private stream. The capture then ends with the driver's unjoined-work error from end_building(), which the invalidated-capture handling above turns into a clean error instead of a second cuGraphDestroy (cuda.core: GraphBuilder.join leaves forked builders in a state that segfaults at garbage collection if it raises midway #2776, folded in from cuda.core: close unjoined forks when GraphBuilder.join fails #2881).
  • Tests: construction and lazy first touch under each capture mode keep the capture valid; the thread's capture mode is restored afterwards; an invalidated capture can be ended, closed, and collected in every mode, and collection reports a CUDAWarning; forked end_building() is rejected; a join() that fails partway closes the unjoined forks and end_building() reports the unjoined work.
  • Release notes for 1.3.0.

Related Work

🤖 Generated with Claude Code

@Andy-Jost Andy-Jost added this to the cuda.core 1.3.0 milestone Sep 11, 2026
@Andy-Jost Andy-Jost added bug Something isn't working P0 High priority - Must do! cuda.core Everything related to the cuda.core module labels Sep 11, 2026
@Andy-Jost Andy-Jost self-assigned this Sep 11, 2026
@copy-pr-bot

copy-pr-bot Bot commented Sep 11, 2026

Copy link
Copy Markdown
Contributor

Auto-sync is disabled for draft pull requests in this repository. Workflows must be run manually.

Contributors can view more details about this message here.

@github-actions github-actions Bot added the CI/CD CI/CD infrastructure label Sep 11, 2026
@Andy-Jost

Copy link
Copy Markdown
Contributor Author

/ok to test

@github-actions

Copy link
Copy Markdown
Contributor

@Andy-Jost
Andy-Jost force-pushed the ajost/capture-fixes-2834 branch 2 times, most recently from 5b5b623 to c3a72aa Compare September 15, 2026 19:07
@Andy-Jost

Copy link
Copy Markdown
Contributor Author

/ok to test

1 similar comment
@Andy-Jost

Copy link
Copy Markdown
Contributor Author

/ok to test

@Andy-Jost
Andy-Jost requested a review from leofang September 15, 2026 23:00
@Andy-Jost
Andy-Jost force-pushed the ajost/capture-fixes-2834 branch from af42fb1 to 7ce9893 Compare September 15, 2026 23:04
@Andy-Jost
Andy-Jost marked this pull request as ready for review September 15, 2026 23:04
@Andy-Jost Andy-Jost removed the CI/CD CI/CD infrastructure label Sep 15, 2026
DeviceMemoryResource(device) with no options raises the release threshold
of the driver's pool with cuMemPoolGetAttribute and cuMemPoolSetAttribute.
The driver refuses both as potentially unsafe calls while the calling
thread is inside a global or thread-local capture, and it invalidates the
capture. Device.memory_resource constructs the resource lazily, so a first
allocation could invalidate a capture in progress. Make the two calls in
relaxed capture mode and restore the thread's previous mode afterwards. A
failure to restore the mode is attached to the propagating error as a note.

Ending an invalidated capture made the builder destroy a graph the driver
had already destroyed. cuStreamEndCapture returns a NULL graph for an
invalidated (or unjoined) capture and releases the capture graph itself,
but the builder kept the owning handle it took from cuStreamGetCaptureInfo
and its deleter called cuGraphDestroy again: a use-after-free that
segfaulted at close() or garbage collection. Add invalidate_root_graph_state
to retire the hierarchy when the driver discards the root graph, and route
end_building(), close() and __dealloc__ through one GB_end_capture helper
that settles graph ownership from the end-capture result. end_building()
now ends an invalidated capture and raises the driver error; the builder
then holds no graph (new CAPTURE_INVALIDATED state), and complete(),
debug_dot_print(), graph_definition, embed() and Graph.update() say so.
close() closes the builder before raising. end_building() on a forked
builder is rejected with RuntimeError instead of invalidating the capture.

Fixes NVIDIA#2834.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Andy-Jost and others added 4 commits September 17, 2026 08:58
The driver also discards the body graph of a conditional node when the
body capture ends invalidated, and the parent graph keeps referring to
it, which cuda.core cannot repair. Say so in end_building(), the release
note, and the GB_end_capture comment. Scope the DeviceMemoryResource
note to the default-pool constructor, since cuMemPoolCreate is still
refused under capture. Drop the end_building() call on a forked builder
from the skip path of test_graph_conditional_on_forked_builder.

Issue NVIDIA#2834

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
join() waits on each forked builder's stream and then closes it. When a
wait raised partway through, the forks the loop had not reached stayed
open with capturing streams, and destroying them later during garbage
collection crashed the interpreter.

Close every fork the loop did not reach before the error propagates.
The capture cannot complete without the work captured on those forks,
so end_building() then raises the driver's unjoined-capture error and
the builder closes cleanly, which NVIDIA#2834 made possible.

Issue NVIDIA#2776

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
PR NVIDIA#2881 is folded into this PR: its join() cleanup relies on the
invalidated-capture handling here, and the NVIDIA#2776 crash is the same
double destroy of a graph the driver already discarded. Merge the two
release-note entries and correct the test docstring, which attributed
the crash to the fork rather than to the builder's teardown.

Issue NVIDIA#2834, NVIDIA#2776

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
An invalidated capture of a conditional body leaves the parent graph
invalid, and ending the parent capture afterwards crashes inside the
driver (NVIDIA#2918, a CUDA driver bug reproduced with the
driver API alone). Say so in the end_building docstring and the release
note instead of explaining the mechanism.

Issue NVIDIA#2834

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
@Andy-Jost Andy-Jost added the PR review get-together Mark PRs you'd like the team to review at the weekly PR review get-together. label Sep 21, 2026
gb = Device().create_graph_builder().begin_building()
gb, bad, left, right = gb.split(4)
launch(left, LaunchConfig(grid=1, block=1), empty_kernel)
bad.stream.close()

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hmm I think we have to pass a primary whose end capture is actually refused to fully exercize things here. Provoking the RuntimeError from something we created via split makes it so that GB_end_capture simply short circuits.

import gc
from cuda.core import Device
from cuda.core.graph import GraphBuilder
dev = Device()
dev.set_current()
def bad_primary():
    """A primary whose close() fails: it has an unjoined fork, so the driver
    refuses cuStreamEndCapture."""
    p = dev.create_graph_builder().begin_building()
    p, _unjoined = p.split(2)
    return p
root = dev.create_graph_builder().begin_building()
root, fork = root.split(2)
bad1, bad2 = bad_primary(), bad_primary()
try:
    GraphBuilder.join(root, bad1, bad2, fork)
except Exception as e:
    print(f"join raised: {type(e).__name__}: {str(e).split(':')[0]}")
# join() documents that on failure "the builders that were not joined are
# closed before the error propagates, so none is left capturing".
left_open = [n for n, b in (("bad1", bad1), ("bad2", bad2), ("fork", fork)) if not b.is_closed]
print(f"left open: {left_open}")
del bad1, bad2, fork
gc.collect()

this gives me, on this branch,

join raised: CUDAError: CUDA_ERROR_STREAM_CAPTURE_INVALIDATED
left open: ['bad2', 'fork']

So the first truly failing close stranded the ones later on in the close list.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for catching that. I will upload a fix.

// CUDA destroyed the root graph and, with it, every child. Retire every box
// so that no registry entry resolves to the dead graphs and the hierarchy's
// deleter finds no root to destroy.
void invalidate_root_graph_state(const GraphHandle& h_root) noexcept {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This looks like parts of it can be factored out from here and invalidate_child_graph_state below, would be a bit more DRY

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

retire_graph(hierarchy, graph) ?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Will do

error, after which the builder holds no graph. A failed ``join`` also closes
the forked builders it did not join. This does not extend to the body
builder of a conditional node: an invalidated body capture leaves the parent
graph invalid as well, and ending it may crash the process

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This doesn't cut across

``cuda.core`` never terminates the process. A CUDA error is raised, or reported
right?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The crash is a SEGV due to a driver bug with no known workaround. Issue #2918 has more details.

@brandon-b-miller brandon-b-miller left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Couple Q's otherwise LGTM.

@Andy-Jost Andy-Jost removed the PR review get-together Mark PRs you'd like the team to review at the weekly PR review get-together. label Sep 21, 2026
Andy-Jost and others added 4 commits September 21, 2026 14:14
join() closes the builders it did not join before its error propagates.
That sweep ran in a finally block and called close() on each builder. A
builder from another capture makes the root's wait fail, and the driver
invalidates that builder's capture as well, so its close() raised too.
The raise stopped the sweep, stranded the later builders with capturing
streams, and replaced the merge error with the close error.

Run the sweep in an except block, close each builder through GB_close,
which returns the driver status instead of raising, and attach a failed
close to the propagating error as a note (error handling policy). The
original error is re-raised unchanged.

Review feedback on NVIDIA#2838.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…anup

The existing test provokes the join failure with a forked builder, whose
close() cannot raise, so the sweep's own failure path went untested. Join
two separate primaries into a capture: the driver refuses the
cross-capture wait and invalidates the other capture, so closing that
builder fails. The test checks that every builder is closed, that the
merge error propagates, and that the failed close is attached as a note
(or reported as a CUDAWarning on Python 3.10).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
invalidate_child_graph_state and invalidate_root_graph_state repeated the
per-box retirement: detach node handles, drop the registry entry and
attachments, move the box to the graveyard. Both now call retire_graph
and differ only in which boxes they select. No behavior change.

Review feedback on NVIDIA#2838.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
@Andy-Jost

Copy link
Copy Markdown
Contributor Author

64b5925 includes fixes for the things @brandon-b-miller pointed out

This branch has not been deployed

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

Labels

bug Something isn't working cuda.core Everything related to the cuda.core module P0 High priority - Must do!

Projects

None yet

Development

Successfully merging this pull request may close these issues.

cuda.core: DeviceMemoryResource setup is refused under non-relaxed stream capture, and tearing down the invalidated GraphBuilder segfaults

2 participants