Skip to content

cuda.core: defer graph user-object payload cleanup#2371

Merged
Andy-Jost merged 8 commits into
NVIDIA:mainfrom
Andy-Jost:graph-deferred-reclamation
Jul 15, 2026
Merged

cuda.core: defer graph user-object payload cleanup#2371
Andy-Jost merged 8 commits into
NVIDIA:mainfrom
Andy-Jost:graph-deferred-reclamation

Conversation

@Andy-Jost

@Andy-Jost Andy-Jost commented Jul 14, 2026

Copy link
Copy Markdown
Contributor

Summary

Move graph attachment payload destruction off CUDA's internal user-object callback thread. The callback now performs only an intrusive queue handoff and one coalesced Py_AddPendingCall; resource handles and Python owners are released later from Python's main thread, where their deleters may safely call CUDA.

Changes

  • Add a process-lifetime lock-free deferred-cleanup queue with one pending-call wakeup regardless of payload count.
  • Preserve queued payloads when pending-call scheduling fails and retry from later safe graph-attachment entries.
  • Stop cleanup during interpreter finalization and intentionally leak intact payloads when safe cleanup is no longer possible.
  • Initialize shutdown state explicitly during module import and document the callback/threading contract.
  • Add stress coverage for more than 32 callbacks, pending-call queue saturation, main-thread finalization, and interpreter shutdown.

Related Work

Move graph attachment destruction off CUDA's internal callback thread through a coalesced Py_AddPendingCall drain. Queue payloads lock-free, retry scheduling failures from later safe entries, and leak intact payloads once Python finalization begins.
Verify more than 32 CUDA callbacks coalesce onto Python's main thread, recover after Py_AddPendingCall queue saturation, and remain safe during interpreter shutdown.
@Andy-Jost Andy-Jost added this to the cuda.core next milestone Jul 14, 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 Jul 14, 2026
@Andy-Jost Andy-Jost self-assigned this Jul 14, 2026
@copy-pr-bot

copy-pr-bot Bot commented Jul 14, 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.

@Andy-Jost

Andy-Jost commented Jul 14, 2026

Copy link
Copy Markdown
Contributor Author

PR #2371 separates “CUDA says this payload is no longer needed” from actually cleaning it up.

Parts

1. DeferredCleanupItem

A small queue item allocated before CUDA sees the user object. It contains:

  • The payload, currently GraphSlotTable*
  • The function that cleans up that payload
  • A next pointer for the intrusive queue

The process-wide cleanup queue pointer is published atomically once during module initialization and loaded by the CUDA callback; it is not repeated in each item. Because the item is preallocated, CUDA’s callback does not allocate memory.

2. DeferredCleanupQueue

A process-lifetime object containing:

  • head_: atomic head of an intrusive multiple-producer, single-consumer queue
  • scheduled_: whether a Python pending call is already scheduled
  • accepting_: disabled during Python finalization

It is intentionally never freed, so a late CUDA callback can never access destroyed queue state.

3. CUDA callback

CUDA invokes enqueue_cleanup() when the user-object reference count reaches zero.

The callback only:

  1. Atomically enqueues its DeferredCleanupItem.
  2. Attempts to schedule one pending call.

It does not release a handle, call CUDA, allocate, or wait for cleanup.

4. Pending-call scheduling

schedule() atomically changes scheduled_ from false to true. Therefore, many CUDA callbacks still produce at most one outstanding Py_AddPendingCall.

If CPython’s pending-call queue is full:

  • Scheduling returns -1.
  • scheduled_ returns to false.
  • The payload remains intact in cuda-core’s cleanup queue.
  • A later enqueue or safe graph-attachment entry retries scheduling.

Retrying schedules cleanup; it does not enqueue the item again.

5. Main-thread drain

The pending callback runs on Python’s main thread. It atomically exchanges head_ with null, taking exclusive ownership of the entire queued list.

It then invokes each item’s cleanup function and deletes the item. Any resulting Python finalizer or CUDA-backed handle deleter runs from this permitted host context.

Items arriving during a drain are either picked up by its next loop iteration or cause another pending call to be scheduled after scheduled_ is cleared.

6. Shutdown

A Py_AtExit callback sets accepting_ false. The drain also checks whether Python is finalizing.

After that point:

  • CUDA callbacks may still enqueue safely because the cleanup queue lives for the process lifetime.
  • No new Python callback is scheduled.
  • Queued payloads are intentionally leaked rather than risking Python/CUDA use during shutdown.

Why cleanup happens once

Each payload follows one of two mutually exclusive paths:

CUDA user-object creation fails:
    item allocated → cleanup executed directly

CUDA user-object creation succeeds:
    CUDA-owned → callback → enqueued → atomically detached → cleanup executed

After successful cuUserObjectCreate, cuda-core never executes the item’s cleanup directly. CUDA invokes its destructor callback once, when the user object’s total reference count reaches zero.

Within the cleanup queue:

  • The CUDA callback enqueues that item once.
  • Scheduling retries never enqueue it again.
  • head_.exchange(nullptr) transfers each queued item to the single consumer exactly once.
  • The cleanup function is noexcept, so processing cannot abandon an item halfway through.
  • The item is deleted immediately after its payload is cleaned up.

Thus safety relies on CUDA’s user-object contract providing one final destructor invocation. The cleanup queue then preserves that exactly-once transition through atomic ownership transfer; it does not need a separate “already cleaned” flag.

Add concise descriptions for each reclaimer operation and state field, and explain the typed synchronous helper versus CUDA's void-pointer callback ABI.
@Andy-Jost Andy-Jost force-pushed the graph-deferred-reclamation branch from 70d601a to f454e13 Compare July 14, 2026 22:47
Use the process-wide reclaimer directly instead of storing the same pointer in every envelope, publish the singleton atomically, and make the intrusive next link non-atomic under the queue's release/acquire handoff.
Use payload, cleanup item, cleanup queue, enqueue, drain, and cleanup consistently across implementation, documentation, and tests.
@Andy-Jost Andy-Jost changed the title cuda.core: defer graph user-object payload reclamation cuda.core: defer graph user-object payload cleanup Jul 14, 2026
@Andy-Jost Andy-Jost requested review from leofang and mdboom July 14, 2026 23:26
@Andy-Jost Andy-Jost marked this pull request as ready for review July 14, 2026 23:26
Run the interpreter-shutdown probe outside the source tree so CI imports the installed package with generated modules.
@github-actions

This comment has been minimized.

Validate queue-saturation safety without assuming when CUDA invokes its asynchronous user-object destructor.

@mdboom mdboom 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.

My initial reaction was that this might be more complex than it needs to be. Py_AddPendingCall already functions as a queue (you can call it multiple times and it will run all of the calls in order the next chance it calls any of them), so it might not be necessary to also have a queue on our side.

/However/ Py_AddPendingCall is a FIFO queue, with a maximum of 300 elements (and it appears to silently evict elements if it's more than 300). All that is "probably fine", but it's an implementation detail that could change.

So I think what's here is preferable -- we can be in charge of our own destiny on the queueing. (Say, for example, we discover that the ordering needs to change, this gives us a place to do so).

My other comments are just around improving comments and docs.

Comment on lines +338 to +342
auto* queue = new DeferredCleanupQueue();
deferred_cleanup_queue.store(queue, std::memory_order_release);
if (Py_AtExit(stop_deferred_cleanup) != 0) {
queue->stop();
}

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 is thread-safe only as long as the GIL is held -- it is in practice (also it's run once at import under normal conditions) but maybe just add a comment.

Comment on lines +234 to +236
void retry_schedule() noexcept {
schedule();
}

@mdboom mdboom Jul 15, 2026

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.

Maybe add a little context about why retrying would be necessary? I don't think it's necessarily unsound, just could use a little motivating statement.

@Andy-Jost

Copy link
Copy Markdown
Contributor Author

Thanks for the review, @mdboom. I'll wrap the touch-ups into the next PR (#2357).

As for the complexity, I largely agree. My initial idea was very similar to yours: simply make a call to Py_AddPendingCall from CUDA's destructor thread for each user object. That ran into a few snags:

  • Py_AddPendingCall only accepts 32 queued callbacks. I had my agent follow up after you mentioned 300 entries and it seems to stand by that number (details below).
  • When Py_AddPendingCall fails for any reason on the CUDA thread, we're totally stuck without any recourse other than to leak that object. Introducing a queue we manage where insertion never fails fixes that.

So I agree, the complexity is higher than anticipated, but I don't think it can be reduced.

Queue limit details

My “32” was substantively right for Py_AddPendingCall; Mike found the newer 300-entry backing array but not the effective limit of this public API. Exact answer:

• Python 3.10–3.12: array size 32, but the ring buffer reserves one slot, so only 31 callbacks fit.
• Python 3.13–3.15, including free-threaded builds: backing array size is 300, but Py_AddPendingCall targets pending_mainthread, whose configured maximum remains 32.
• Full queues do not evict callbacks. Py_AddPendingCall returns -1.

Source findings
• Python 3.10 defines NPENDINGCALLS 32 (https://github.com/python/cpython/blob/3.10/Include/internal/pycore_interp.h) and its push function returns -1 when advancing would meet first (https://github.com/python/cpython/blob/3.10/Python/ceval.c#L584-L598). That leaves 31 usable entries.
• Python 3.13 introduces PENDINGCALLSARRAYSIZE 300, but also explicitly defines MAXPENDINGCALLS_MAIN 32 (https://github.com/python/cpython/blob/3.13/Include/internal/pycore_ceval_state.h).
• The runtime initializes pending_mainthread.max from that 32-entry limit in pycore_runtime_init.h (https://github.com/python/cpython/blob/3.13/Include/internal/pycore_runtime_init.h).
• Py_AddPendingCall (https://github.com/python/cpython/blob/3.13/Python/ceval_gil.c#L805-L819) explicitly selects _Py_PENDING_MAINTHREADONLY; when that queue is full, it returns -1.
• Python 3.15 retains MAXPENDINGCALLS_MAIN 32 (https://github.com/python/cpython/blob/3.15/Include/internal/pycore_ceval_state.h).
• The public documentation (https://docs.python.org/3.14/c-api/threads.html#c.Py_AddPendingCall) promises -1 on failure but intentionally does not document a capacity.

@Andy-Jost Andy-Jost merged commit e2e816a into NVIDIA:main Jul 15, 2026
103 checks passed
@Andy-Jost Andy-Jost deleted the graph-deferred-reclamation branch July 15, 2026 16:56
@github-actions

Copy link
Copy Markdown
Doc Preview CI
Preview removed because the pull request was closed or merged.

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.

2 participants