Skip to content

Commit e2e816a

Browse files
authored
cuda.core: defer graph user-object payload cleanup (NVIDIA#2371)
* cuda.core: defer CUDA user-object payload reclamation 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. * test(cuda.core): stress deferred graph reclamation Verify more than 32 CUDA callbacks coalesce onto Python's main thread, recover after Py_AddPendingCall queue saturation, and remain safe during interpreter shutdown. * docs(cuda.core): clarify deferred reclaimer roles Add concise descriptions for each reclaimer operation and state field, and explain the typed synchronous helper versus CUDA's void-pointer callback ABI. * refactor(cuda.core): simplify deferred queue envelopes 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. * refactor(cuda.core): unify deferred cleanup terminology Use payload, cleanup item, cleanup queue, enqueue, drain, and cleanup consistently across implementation, documentation, and tests. * test(cuda.core): isolate shutdown subprocess Run the interpreter-shutdown probe outside the source tree so CI imports the installed package with generated modules. * test(cuda.core): remove pending-call timing race Validate queue-saturation safety without assuming when CUDA invokes its asynchronous user-object destructor.
1 parent 4ab061f commit e2e816a

5 files changed

Lines changed: 304 additions & 12 deletions

File tree

cuda_core/cuda/core/_cpp/DESIGN.md

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -217,6 +217,21 @@ Handle destructors may run from any thread. The implementation includes RAII gua
217217
The handle API functions are safe to call with or without the GIL held. They
218218
will release the GIL (if necessary) before calling CUDA driver API functions.
219219
220+
### CUDA User-Object Cleanup
221+
222+
CUDA user-object destructors may run on an internal driver thread where CUDA
223+
API calls are forbidden. Graph attachment payloads can release handles whose
224+
deleters call CUDA or run Python finalizers, so their CUDA callback must not
225+
delete the payload directly.
226+
227+
The callback enqueues a preallocated `DeferredCleanupItem` on a
228+
process-lifetime lock-free `DeferredCleanupQueue` and schedules one coalesced
229+
`Py_AddPendingCall`. The pending callback drains all queued payloads from
230+
Python's main thread. If scheduling fails, payloads remain intact for a later
231+
retry; they are intentionally leaked once interpreter finalization begins. A
232+
single pending call is shared by all queued payloads because CPython's
233+
main-thread pending-call queue is bounded.
234+
220235
### Static Initialization and Deadlock Hazards
221236
222237
When writing C++ code that interacts with Python, a subtle deadlock can occur

cuda_core/cuda/core/_cpp/resource_handles.cpp

Lines changed: 168 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66

77
#include "resource_handles.hpp"
88
#include <cuda.h>
9+
#include <atomic>
910
#include <array>
1011
#include <cstdint>
1112
#include <cstdlib>
@@ -185,6 +186,162 @@ class GILAcquireGuard {
185186

186187
} // namespace
187188

189+
// ============================================================================
190+
// CUDA user-object deferred cleanup
191+
//
192+
// CUDA may invoke a user-object destructor on an internal thread where CUDA
193+
// calls are forbidden. Payload cleanup can release resource handles whose
194+
// deleters call CUDA, so the callback only transfers a preallocated intrusive
195+
// node to this process-lifetime queue. One coalesced pending call drains all
196+
// queued payloads from Python's main thread.
197+
// ============================================================================
198+
199+
namespace {
200+
201+
class DeferredCleanupQueue;
202+
203+
using CleanupFn = void (*)(void*) noexcept;
204+
205+
// Preallocated queue item that transfers one payload out of CUDA's callback.
206+
struct DeferredCleanupItem {
207+
DeferredCleanupItem* next = nullptr;
208+
void* payload;
209+
CleanupFn cleanup;
210+
211+
DeferredCleanupItem(void* payload_, CleanupFn cleanup_) noexcept
212+
: payload(payload_), cleanup(cleanup_) {}
213+
};
214+
215+
// Process-lifetime MPSC queue that drains payloads from Python's main thread.
216+
class DeferredCleanupQueue {
217+
public:
218+
// Transfer one preallocated cleanup item from a producer to the queue.
219+
void enqueue(DeferredCleanupItem* item) noexcept {
220+
DeferredCleanupItem* head = head_.load(std::memory_order_relaxed);
221+
do {
222+
item->next = head;
223+
} while (!head_.compare_exchange_weak(
224+
head, item, std::memory_order_release, std::memory_order_relaxed));
225+
schedule();
226+
}
227+
228+
// Permanently disable pending-call scheduling during interpreter shutdown.
229+
void stop() noexcept {
230+
accepting_.store(false, std::memory_order_release);
231+
}
232+
233+
// Reattempt scheduling for payloads left queued after an earlier failure.
234+
void retry_schedule() noexcept {
235+
schedule();
236+
}
237+
238+
private:
239+
// Adapt queue draining to CPython's int (*)(void*) callback ABI.
240+
static int pending_call(void* arg) noexcept {
241+
static_cast<DeferredCleanupQueue*>(arg)->drain();
242+
return 0;
243+
}
244+
245+
// Coalesce all queued work behind at most one CPython pending call.
246+
void schedule() noexcept {
247+
if (!accepting_.load(std::memory_order_acquire) ||
248+
!head_.load(std::memory_order_acquire)) {
249+
return;
250+
}
251+
bool expected = false;
252+
if (!scheduled_.compare_exchange_strong(
253+
expected, true, std::memory_order_acq_rel,
254+
std::memory_order_relaxed)) {
255+
return;
256+
}
257+
if (Py_AddPendingCall(&DeferredCleanupQueue::pending_call, this) != 0) {
258+
// Keep every payload queued. A later enqueue or safe cuda-core
259+
// entry can retry without blocking CUDA's callback thread.
260+
scheduled_.store(false, std::memory_order_release);
261+
}
262+
}
263+
264+
// Detach and destroy all queued payloads from Python's main thread.
265+
void drain() noexcept {
266+
if (!Py_IsInitialized() || py_is_finalizing()) {
267+
stop();
268+
scheduled_.store(false, std::memory_order_release);
269+
return; // Intentionally leak intact payloads during shutdown.
270+
}
271+
272+
while (DeferredCleanupItem* list =
273+
head_.exchange(nullptr, std::memory_order_acquire)) {
274+
while (list) {
275+
DeferredCleanupItem* next = list->next;
276+
list->cleanup(list->payload);
277+
delete list;
278+
list = next;
279+
}
280+
}
281+
282+
scheduled_.store(false, std::memory_order_release);
283+
if (head_.load(std::memory_order_acquire)) {
284+
schedule();
285+
}
286+
}
287+
288+
// Head of the intrusive multi-producer, single-consumer payload stack.
289+
std::atomic<DeferredCleanupItem*> head_{nullptr};
290+
// True while one cuda-core drain callback is pending or executing.
291+
std::atomic<bool> scheduled_{false};
292+
// False once shutdown begins, causing later payloads to be leaked safely.
293+
std::atomic<bool> accepting_{true};
294+
};
295+
296+
// Published once at module initialization and intentionally never freed.
297+
std::atomic<DeferredCleanupQueue*> deferred_cleanup_queue{nullptr};
298+
299+
void stop_deferred_cleanup() {
300+
if (DeferredCleanupQueue* queue =
301+
deferred_cleanup_queue.load(std::memory_order_acquire)) {
302+
queue->stop();
303+
}
304+
}
305+
306+
DeferredCleanupItem* make_cleanup_item(
307+
void* payload, CleanupFn cleanup) {
308+
DeferredCleanupQueue* queue =
309+
deferred_cleanup_queue.load(std::memory_order_acquire);
310+
if (!queue) {
311+
throw std::runtime_error("deferred cleanup is not initialized");
312+
}
313+
queue->retry_schedule();
314+
return new DeferredCleanupItem(payload, cleanup);
315+
}
316+
317+
// Execute an item's cleanup synchronously before CUDA has acquired ownership.
318+
void execute_cleanup_now(DeferredCleanupItem* item) noexcept {
319+
item->cleanup(item->payload);
320+
delete item;
321+
}
322+
323+
// CUDA's CUhostFn ABI is void (*)(void*); recover and enqueue the cleanup item.
324+
void enqueue_cleanup(void* item) noexcept {
325+
auto* cleanup = static_cast<DeferredCleanupItem*>(item);
326+
if (DeferredCleanupQueue* queue =
327+
deferred_cleanup_queue.load(std::memory_order_acquire)) {
328+
queue->enqueue(cleanup);
329+
}
330+
}
331+
332+
} // namespace
333+
334+
void initialize_deferred_cleanup() {
335+
if (deferred_cleanup_queue.load(std::memory_order_acquire)) {
336+
return;
337+
}
338+
auto* queue = new DeferredCleanupQueue();
339+
deferred_cleanup_queue.store(queue, std::memory_order_release);
340+
if (Py_AtExit(stop_deferred_cleanup) != 0) {
341+
queue->stop();
342+
}
343+
}
344+
188345
// ============================================================================
189346
// Handle reverse-lookup registry
190347
//
@@ -1146,7 +1303,7 @@ void free_deleter(const void* p) noexcept {
11461303
std::free(const_cast<void*>(p));
11471304
}
11481305

1149-
void destroy_graph_slot_table(void* table) noexcept {
1306+
void cleanup_graph_slot_table(void* table) noexcept {
11501307
delete static_cast<GraphSlotTable*>(table);
11511308
}
11521309

@@ -1176,13 +1333,20 @@ GraphSlotTable* ensure_slot_table(const GraphBox* box) {
11761333
return nullptr;
11771334
}
11781335
auto* table = new GraphSlotTable();
1336+
DeferredCleanupItem* cleanup_item = nullptr;
1337+
try {
1338+
cleanup_item = make_cleanup_item(table, cleanup_graph_slot_table);
1339+
} catch (...) {
1340+
delete table;
1341+
throw;
1342+
}
11791343
CUuserObject user_obj = nullptr;
11801344
{
11811345
GILReleaseGuard gil;
1182-
if (p_cuUserObjectCreate(&user_obj, table,
1183-
reinterpret_cast<CUhostFn>(destroy_graph_slot_table),
1346+
if (p_cuUserObjectCreate(&user_obj, cleanup_item,
1347+
reinterpret_cast<CUhostFn>(enqueue_cleanup),
11841348
1, CU_USER_OBJECT_NO_DESTRUCTOR_SYNC) != CUDA_SUCCESS) {
1185-
delete table; // no user object created; nothing else owns the table
1349+
execute_cleanup_now(cleanup_item);
11861350
return nullptr;
11871351
}
11881352
if (p_cuGraphRetainUserObject(box->resource, user_obj, 1,

cuda_core/cuda/core/_cpp/resource_handles.hpp

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -256,6 +256,10 @@ StreamHandle create_stream_handle_with_owner(CUstream stream, PyObject* owner);
256256
// If Python is finalized or finalizing, the object is intentionally leaked.
257257
void py_object_user_object_destroy(void* py_object) noexcept;
258258

259+
// Initialize the process-lifetime CUDA user-object cleanup queue. Called once
260+
// from module initialization while Python is fully initialized.
261+
void initialize_deferred_cleanup();
262+
259263
// Return the context dependency associated with a stream handle, if any.
260264
ContextHandle get_stream_context(const StreamHandle& h) noexcept;
261265

cuda_core/cuda/core/_resource_handles.pyx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,7 @@ cdef extern from "_cpp/resource_handles.hpp" namespace "cuda_core":
6161
cydriver.CUstream stream, object owner) except+ nogil
6262
void py_object_user_object_destroy "cuda_core::py_object_user_object_destroy" (
6363
void* py_object) noexcept nogil
64+
void initialize_deferred_cleanup "cuda_core::initialize_deferred_cleanup" () except+
6465
ContextHandle get_stream_context "cuda_core::get_stream_context" (
6566
const StreamHandle& h) noexcept nogil
6667
StreamHandle get_legacy_stream "cuda_core::get_legacy_stream" () except+ nogil
@@ -461,6 +462,7 @@ cdef void _init_driver_fn_pointers() noexcept:
461462
p_cuDevSmResourceSplit = _get_optional_driver_fn("cuDevSmResourceSplit")
462463

463464
_init_driver_fn_pointers()
465+
initialize_deferred_cleanup()
464466

465467
# =============================================================================
466468
# NVRTC function pointer initialization

0 commit comments

Comments
 (0)