Skip to content

Commit 6aaeb66

Browse files
Andy-Jostclaude
andauthored
cuda.core: define the error handling policy and report failures that cannot be raised (#2759)
* cuda.core: define the error handling policy and report failures that cannot be raised Write down how cuda.core handles CUDA failures (docs/source/error_handling.rst for users, a "Failure handling" section in AGENTS.md and _cpp/DESIGN.md for contributors) and bring the code into line with it: - Add cuda.core.CUDAWarning, emitted for CUDA errors that cannot be raised (destructors, CUDA callbacks, cleanup after an earlier failure). The C++ handle layer reports through one helper that uses the Python warnings machinery when the interpreter is usable, delivers an escalated warning as an unraisable exception, and falls back to stderr otherwise. CUDA_ERROR_DEINITIALIZED is not reported. - Wrap every destroy call made from a deleter (pw_*) so its failure is reported instead of discarded, including memory pools, green contexts, graphs, graph execs, graphics resources, the linker, user objects, the NVRTC/NVVM/nvJitLink handles and file descriptors; release the GIL around the compiler-handle destroys like the CUDA ones. - When the caller's context cannot be restored after a successful operation, undo the creation and raise a CUDAError that says which context is current; report the same failure as a warning in deleters; report a skipped context-sensitive undo instead of leaking silently. - Add context_get_device and graph_node_set_params so Stream_get_ctx_device and _set_definition_node_params stop hand-rolling cuCtxPush/Pop/SetCurrent. The node update now publishes its attachment before raising a restoration failure, closing a window that left the node referencing released owners. - Device.set_current(ctx) switches with a single cuCtxSetCurrent, so a failure leaves the previous context current and the call works without one. - Report failed cuStreamEndCapture in GraphBuilder.__dealloc__ and failed child-graph rollbacks; warn from _mr_dealloc_callback instead of printing. - Add a test hook that makes the next context restoration fail, tests for the policy, and release notes for 1.3.0. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * cuda.core: keep the texture autosummary contiguous in api.rst The "Errors and warnings" section was inserted between the texture classes and the texture option dataclasses, which moved OpaqueArrayOptions, MipmappedArrayOptions and TextureObjectOptions under cuda.core in the docs index and failed test_api_docs_consistency on every CI platform. Place the section after the texture section instead. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * cuda.core: attach secondary failures to the propagating exception as notes Review follow-ups on the error-handling policy: - A failure that happens while an exception is being raised is no longer reported out of band. When both an operation and the restoration of the caller's context fail, the operation's CUDAError is raised with the restoration failure attached; when only the restoration fails, its error is raised with the context explanation attached. The attachment is a PEP 678 note on Python 3.11+ and is appended to the message on 3.10. The thread-local detail is keyed to the status it was recorded for, so it cannot attach to an unrelated error if that status is never raised. - A failed rollback inside a Cython `except` block is attached to the exception being handled through note_or_report_cuda_error(), which falls back to a CUDAWarning when nothing is being handled or notes are unavailable. - Reporting stays reserved for destructors and CUDA callbacks; CUDAWarning's docstring and the docs say so. - DESIGN.md explains the two status conventions of the C++ layer (handle factories use thread-local err, everything else returns CUresult) and the abort-helper guidance in AGENTS.md asks for a faulthandler-style traceback. - Drop the release-relative "in this release" wording from the stable docs. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * cuda.core: follow the review of #2750 and flush the stderr fallback Rebased onto the reviewed head of #2750. Adjustments the rebase needed: - The review's warning for an undo skipped after a failed context restoration is routed through report_cuda_error(), so it carries the CUDA status and becomes a CUDAWarning like every other non-raising report. - invoke_in_context and invoke_in_context_or_undo now reject empty handles themselves, so context_get_device drops its own guard like the other helpers did; enter_context's no-op for empty handles is documented as used only by graph_node_set_params. - _SynchronousMemoryResource moved to its own module; the error-handling test imports it from there. The review's two teardown tests asserted that stderr stayed empty; under the policy a teardown failure is a CUDAWarning, so they assert that no CUDAWarning is issued instead (and are marked thread_unsafe because warning capture is process-global). - report_message() flushes stderr after its last-resort fprintf, so the text is not lost if the process dies right after (review comment). Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * cuda.core tests: check host-only buffer teardown for CUDAWarning, not stderr The host-only Buffer tests from #2773 asserted that nothing containing "Warning" reached stderr. Under the error handling policy a teardown failure is a CUDAWarning, not stderr text, so that assertion no longer checks anything. Use assert_no_cuda_warning() around allocate/close instead (marked thread_unsafe, as warning capture is process-global). The spawned-process variant checks inside the child, since warnings do not cross processes; a failure surfaces as the non-zero exit code the parent already asserts on. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * cuda.core docs: cuda.core never terminates the process The policy text reserved std::abort for an internal invariant violation and specified how such a helper would have to behave. The decision is that cuda.core never terminates the process: an internal invariant violation is raised as a RuntimeError where an exception can propagate, reported as a CUDAWarning where it cannot, and the affected resource is leaked. Users who want fail-fast behavior escalate the warning category themselves. An implicit abort (an exception escaping noexcept code) remains a bug, not a policy choice. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * cuda.core: report cleanup failures after restoring the caller's context cleanup_in_context() reported an activation or operation failure before it switched back to the caller's context. A CUDAWarning runs user code (warning filters, showwarning), so that code observed the cleanup context instead of the caller's. Emit both reports after the restoration attempt; the report order and the return value are unchanged. Review follow-up on #2759. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * cuda.core: name the resource in cleanup reports, export CUDAError, note the detail limitation Review follow-ups on #2759: - Cleanup reports name the resource handle ("cuMemFreeAsync(0x...) failed ..."). Python's warning registry collapses repeated warnings with identical text from one call site, so two independent resources failing the same call from the same line produced a single CUDAWarning. The pw_* wrappers name their first argument, cleanup_in_context() takes the handle explicitly, and the Buffer deallocation callback names the pointer. A test releases two buffers under an injected restoration failure and expects two reports. - CUDAError and NVRTCError are importable from cuda.core; the error handling page told users to catch CUDAError but it lived in a private module. Both classes gained docstrings. - DESIGN.md and the docs no longer claim that keying the thread-local detail to its status prevents misattribution; a caller that drops the status leaves it behind for a later error with the same code. #2760 removes the thread-local state. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * cuda.core: never acquire the GIL while holding a C++ lock deviceptr_import_ipc() took ipc_import_mutex and only then released the GIL, so a thread blocked on the mutex while holding the GIL deadlocked with the holder waiting to reacquire it at scope exit (#2840). It also called the pw_ wrapper for the discard path under the mutex, and the wrapper acquires the GIL to emit a CUDAWarning. Release the GIL before taking the mutex, keep lookup, import and registration under the mutex so a descriptor is never imported twice, discard with the raw driver call, and report a failed discard only after the lock is released. DESIGN.md states the rule: the GIL is the outermost lock; nothing that holds a C++ lock may acquire or reacquire it. The guard reorder is the same fix as #2848, which also adds the regression test for the deadlock; this change keeps that reorder and adds the deferred report, so whichever lands second resolves the overlapping hunk in its favor. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * cuda.core docs: which reporting channel to use, and what pw_ really does Review follow-ups on #2759: - DESIGN.md gains a table that picks the reporting channel by situation (a path that can raise, an except block whose rollback failed, a deleter, a Python destructor path, a CUDA callback thread), with the thread-local mechanisms marked transitional pending #2760, and a section on p_ versus pw_: a pw_ wrapper acquires the GIL on failure and runs user Python, so it is never used while a C++ lock is held. Python exceptions raised by that code never become C++ exceptions; nothing on the report path may allocate or throw. CUDA callback threads do nothing that needs the GIL; Py_AddPendingCall is how work leaves them. - AGENTS.md gets the same two rules in short form. The header comments on the reporting functions and on WarnOnFailure say what pw_ runs. - note_or_report_cuda_error is renamed attach_rollback_failure: callers are in one situation (a rollback failed while an exception is in flight) and should not have to know the note-or-warning mechanism. The test hook follows. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * cuda.core: hold ipc_import_mutex while freeing an imported IPC pointer Brandon's concurrent-import test (#2848) crashes on CUDA 12.9 once the GIL reorder lets the importers run: the IPC pointer cache's deleter did not take ipc_import_mutex, so a concurrent importer that found the entry expired while the deleter was still freeing re-imported the allocation, got a duplicate pointer to the same mapping (nvbug 5570902), and the first cuMemFreeAsync unmapped it for both. The deleter now releases the GIL, then holds the mutex across unregister and free. The cleanup report emits a CUDAWarning, which acquires the GIL and runs user code, so it must not run under the mutex: cleanup_in_context() gains an overload with an after_cleanup hook, called unconditionally once the cleanup and the context restoration are done and before anything that may run user code, and the deleter passes one that unlocks its std::unique_lock. The deallocation context is resolved before the lock for the same reason. Companion to the main-side fix pushed to #2848. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
1 parent 0ad1583 commit 6aaeb66

23 files changed

Lines changed: 1577 additions & 209 deletions

cuda_core/AGENTS.md

Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -101,6 +101,81 @@ and agents should flag violations.
101101
(kernel arguments, memcpy/memset operands, `dst_owner`/`src_owner`, and
102102
host-callback closures) inherit this contract.
103103

104+
## Failure handling
105+
106+
The user-facing contract lives in `docs/source/error_handling.rst`; the rules
107+
below are for contributors. Reviewers and agents should flag violations.
108+
109+
- **Raise by default**: any failure on a path where an exception can propagate
110+
raises. Driver statuses go through `HANDLE_RETURN` (Cython) or are returned as
111+
`CUresult` from the C++ handle layer and then `HANDLE_RETURN`ed; never
112+
replace a `CUresult` with a generic `RuntimeError`, and drain
113+
`get_last_error()` immediately after a handle constructor returns empty so a
114+
stale status cannot be misattributed later.
115+
- **Guarantees**: a call that creates a resource must create nothing when it
116+
raises (undo the creation if a later step fails). Every call except
117+
`Device.set_current` must leave the calling thread's current context as it
118+
found it. Do not hand-roll `cuCtxPush/Pop/SetCurrent` sequences in Cython; use
119+
the handle layer's scoped-context helpers (`invoke_in_context`,
120+
`invoke_in_context_or_undo`, `cleanup_in_context`, `context_get_device`,
121+
`graph_node_set_params`) so the failure handling exists in one place.
122+
- **Publish before you raise**: when a driver mutation has succeeded and a later
123+
step can still fail, commit whatever keeps that mutation memory-safe (for
124+
example the graph attachment that retains a node's new owners) before raising
125+
the later error. Rolling back the retention of a live mutation creates a
126+
dangling reference. When ownership cannot be established, retain the
127+
resources anyway (leak) rather than release them; a leak is always preferred
128+
to a use-after-free.
129+
- **Non-propagating paths never raise and never discard a status**: shared_ptr
130+
deleters, `__dealloc__` and CUDA callbacks report through one channel, `report_cuda_error()` / `report_message()` in C++ (the
131+
`pw_*` wrappers) or `warnings.warn(..., CUDAWarning)` in Cython and Python,
132+
which emits `cuda.core.CUDAWarning`. No `print(file=sys.stderr)` and no
133+
`fprintf` outside that helper. `CUDA_ERROR_DEINITIALIZED` is filtered by the
134+
helper because it means the driver is shutting down.
135+
- **Pick the channel by where you are**: a path that can raise uses
136+
`HANDLE_RETURN`; an `except` block whose rollback failed uses
137+
`attach_rollback_failure()`; a deleter or cleanup path uses a `pw_*`
138+
wrapper or `report_cuda_error()`; the same situation in Cython or Python
139+
uses `warnings.warn(..., CUDAWarning)`; a CUDA callback thread does nothing
140+
that needs the GIL and hands its work to the deferred-cleanup queue
141+
(`Py_AddPendingCall` is GIL-free and allowed there). The table in
142+
`_cpp/DESIGN.md` ("Which channel to use") spells this out.
143+
- **`pw_*` runs user Python**: a `p_` pointer only calls the driver; its `pw_`
144+
twin also acquires the GIL on failure and runs the warning filters,
145+
`showwarning`, or `sys.unraisablehook`, any of which may call back into
146+
cuda.core. Never call a `pw_*` wrapper or `report_*` while holding a C++
147+
lock. Take the GIL as the outermost lock, release it before taking a C++
148+
lock, and when a lock must stay held call `p_`, keep the status, and report
149+
after the lock is released (`deviceptr_import_ipc` is the model).
150+
- **Rollback failure**: the original exception propagates; the failed rollback
151+
is attached to it with `attach_rollback_failure()` (a PEP 678 note on
152+
Python 3.11+, reported out-of-band on 3.10), or chained with
153+
`raise ... from` when a second exception must be raised. Catching everything
154+
(bare `except:` or `except BaseException:`) is acceptable only for
155+
rollback-then-`raise` blocks, where the rollback must also run for
156+
`KeyboardInterrupt`.
157+
- **Finalization**: once `py_is_finalizing()` is true, do no Python work from
158+
destructors or callbacks and accept the leak (see
159+
`_cpp/resource_handles.hpp` and `_cpp/GRAPH_ATTACHMENTS.md`).
160+
- **Never terminate the process**: no `std::abort`, `std::terminate`, `exit`,
161+
`Py_FatalError`, or `assert` that survives into a release build, anywhere in
162+
`cuda.core`. A failed CUDA call, including a failed context restoration, is
163+
raised or reported. An internal invariant violation is handled the same way:
164+
raise a `RuntimeError` that says "internal cuda.core error, please report"
165+
where an exception can propagate, report through the channel above where it
166+
cannot, and leak the affected resource rather than touch state that may be
167+
inconsistent. Users who want fail-fast behavior get it with
168+
`warnings.filterwarnings("error", category=CUDAWarning)` and
169+
`PYTHONFAULTHANDLER`; the library does not make that choice for them. An
170+
*implicit* abort (an exception escaping a `noexcept` function or a deleter,
171+
including `std::bad_alloc` from an allocation inside `noexcept` code) is a
172+
bug (#1489, #2417), not a policy choice: `noexcept` helpers must not
173+
allocate, or must catch what they call.
174+
- **Testing**: inject restoration failures with
175+
`cuda.core._resource_handles._set_context_restore_fault_for_testing`; assert
176+
reports with `pytest.warns(CUDAWarning)` or `warnings.catch_warnings`, never
177+
by matching stderr text.
178+
104179
## API design guidelines
105180

106181
These are some API design guidelines we try to follow when adding new APIs to

cuda_core/cuda/core/__init__.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -102,8 +102,12 @@ class _PatchedProperty(metaclass=_PatchedPropMeta):
102102
from cuda.core._stream import __all__ as _stream_all
103103
from cuda.core._tensor_map import *
104104
from cuda.core._tensor_map import __all__ as _tensor_map_all
105+
from cuda.core._utils.cuda_utils import CUDAError, CUDAWarning, NVRTCError
105106

106107
__all__ = [
108+
"CUDAError",
109+
"CUDAWarning",
110+
"NVRTCError",
107111
*_context_all,
108112
*_device_all,
109113
*_device_resources_all,

cuda_core/cuda/core/_cpp/DESIGN.md

Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -226,6 +226,20 @@ Handle destructors may run from any thread. The implementation includes RAII gua
226226
The handle API functions are safe to call with or without the GIL held. They
227227
will release the GIL (if necessary) before calling CUDA driver API functions.
228228
229+
**The GIL is the outermost lock.** Code that holds a C++ lock (a registry's
230+
mutex, `ipc_import_mutex`, any `std::mutex`) must not acquire or reacquire the
231+
GIL while the lock is held: no `report_*` or `pw_*` calls, no
232+
`GILAcquireGuard`, and no `GILReleaseGuard` whose destructor runs inside the
233+
locked region. Code that needs a C++ lock and may run with the GIL held
234+
releases the GIL first (`GILReleaseGuard` before `lock_guard`). Otherwise a
235+
thread blocked on the lock while holding the GIL deadlocks with the lock holder
236+
waiting for the GIL (#2840). Collect statuses under the lock and report after it
237+
is released, as `deviceptr_import_ipc` does: `cleanup_in_context` takes an
238+
`after_cleanup` hook that runs once the cleanup is done and before anything that
239+
may run user code, and the deleter passes one that unlocks its
240+
`std::unique_lock`. The registries store `weak_ptr`s,
241+
so erasing an entry under a registry lock never runs a deleter.
242+
229243
### Static Initialization and Deadlock Hazards
230244
231245
When writing C++ code that interacts with Python, a subtle deadlock can occur
@@ -275,6 +289,95 @@ Related functions:
275289
- `peek_last_error()`: Returns the error without clearing it
276290
- `clear_last_error()`: Clears the error state
277291

292+
The C++ layer never raises Python exceptions: it runs `nogil` and `noexcept`,
293+
and is called from deleters, CUDA callbacks and GIL-released code where raising
294+
is impossible. Status is turned into `CUDAError` in one place, `HANDLE_RETURN`
295+
in the Cython layer. Which status convention a function uses is decided by its
296+
return value. Factories return the handle, so their status goes to thread-local
297+
`err` and is read with `get_last_error()`. Functions that do not produce a
298+
handle (`context_synchronize`, `context_get_device`, `graph_node_set_params`,
299+
the `graph_*_attachment` family, `deviceptr_alloc_raw`) return the `CUresult`
300+
directly and deliver results through out-parameters, mirroring the driver API;
301+
their callers `HANDLE_RETURN` the value. The two conventions never mix.
302+
303+
### Context-scoped operations
304+
305+
Operations that must run in a specific context use `invoke_in_context` /
306+
`invoke_in_context_or_undo` (propagating paths) and `cleanup_in_context`
307+
(deleters). They switch the current context, run the operation, and restore the
308+
caller's context. `cleanup_in_context` emits its reports only after that
309+
restoration, so the user code a `CUDAWarning` runs (filters, `showwarning`)
310+
observes the caller's context. When restoration fails after the operation
311+
succeeded, the creation is undone and the restoration status is returned. When both fail, the
312+
operation status is returned. Either way the helper records a thread-local
313+
detail keyed to the returned status (`take_last_error_detail(status)`) that
314+
`_check_driver_error` attaches to the raised `CUDAError` as a PEP 678 note
315+
(appended to the message on Python 3.10), so the user learns that the caller's
316+
context was not restored, which context is current and, for a double failure,
317+
why restoration failed. Keying the detail to its status narrows, but does not
318+
remove, misattribution: a caller that drops the status (an empty handle raised
319+
as a generic error) leaves the detail behind, and a later error on the same
320+
thread with the same status code picks it up. `enter_context` clears stale
321+
detail at the next context-scoped operation. Issue #2760 removes this
322+
thread-local state in favor of explicit status returns. Tests inject restoration failures with
323+
`set_context_restore_fault_for_testing()`.
324+
325+
### Reporting from non-propagating paths
326+
327+
Deleters and CUDA callbacks cannot raise. They report through
328+
`report_cuda_error()` / `report_message()` (the `pw_*` wrappers decorate
329+
destroy calls with it and name the resource handle in the message, so Python's
330+
warning registry does not collapse independent failures of one call), which emit a `cuda.core.CUDAWarning` through
331+
the Python warnings machinery when the interpreter is usable, deliver an
332+
escalated warning as an unraisable exception, and fall back to stderr when the
333+
GIL cannot be taken (for example during finalization). `CUDA_ERROR_DEINITIALIZED`
334+
is never reported because it means the driver is shutting down. No status is
335+
discarded silently anywhere in this layer, and nothing in this layer may
336+
terminate the process; see `docs/source/error_handling.rst` and the "Failure handling"
337+
section of `AGENTS.md` for the policy.
338+
339+
A rollback that fails inside a Cython `except` block is not a non-propagating
340+
path: `attach_rollback_failure()` attaches it as a note to the exception being
341+
handled (`PyErr_GetHandledException`, Python 3.11+) and falls back to a report
342+
only when there is no such exception or notes are unavailable.
343+
344+
### Which channel to use
345+
346+
Pick the channel by where the failure happens. Every failure goes through
347+
exactly one of these; none is ever dropped.
348+
349+
| Where you are | Use | Result |
350+
|---|---|---|
351+
| Cython, on a path that can raise | `HANDLE_RETURN(status)` | Raises `CUDAError`. A restoration detail recorded by the C++ helper becomes a note on the exception. |
352+
| Cython, after a handle constructor returned an empty handle | `HANDLE_RETURN(get_last_error())`, immediately | Same. Transitional: #2760 makes constructors return the status instead. |
353+
| C++, a helper that runs an operation in another context | Return the `CUresult`; `exit_context` records the restoration detail | Cython raises it. Transitional: #2760 returns the restoration status as a second out-parameter. |
354+
| Cython, inside an `except` block whose rollback failed | `attach_rollback_failure(op, status, detail)` | Adds a note to the exception being handled. Reports instead if nothing is being handled or notes do not exist (Python 3.10). |
355+
| C++, a deleter or deferred cleanup | A `pw_*` wrapper, or `report_cuda_error()` / `report_message()` | Emits `CUDAWarning`. Never raises. |
356+
| Cython or Python, a `__dealloc__` or destructor-path callback | `warnings.warn(msg, CUDAWarning, stacklevel=2)` | Same. |
357+
| A CUDA callback thread | Nothing that needs the GIL. Hand the work to the deferred-cleanup queue with `Py_AddPendingCall` | CUDA forbids driver calls there, and acquiring the GIL there can deadlock with a GIL holder blocked in a driver call. GIL-free C API that only schedules work is fine. |
358+
359+
### `p_` versus `pw_`
360+
361+
A `p_` function pointer calls the driver and nothing else. Its `pw_` twin calls
362+
the driver and, if the call fails, acquires the GIL and runs Python: the warning
363+
filters, `showwarning`, or `sys.unraisablehook`. Any of those can be user code,
364+
and user code can call back into cuda.core. This is the one place where the
365+
handle layer runs code it does not control, and it is the entry point through
366+
which a thread holding a C++ lock can deadlock (see "GIL Management").
367+
368+
Python exceptions raised by that code never become C++ exceptions: the C API
369+
reports them as return codes, and `report_message` hands them to
370+
`sys.unraisablehook`. Nothing on the report path may allocate or throw, since a
371+
deleter is `noexcept`.
372+
373+
So: use `pw_` only in deleters and cleanup paths that hold no C++ lock and have
374+
finished updating the layer's own state. Where a lock must stay held, call
375+
`p_`, keep the status, and report after the lock is released, as
376+
`deviceptr_import_ipc` does. CUDA callback threads need no extra rule for
377+
`pw_`: the driver call is forbidden there, so the wrapper is too. The general
378+
rule for those threads is no GIL and no Python objects; GIL-free scheduling
379+
calls such as `Py_AddPendingCall` are how work leaves them.
380+
278381
## Usage from Cython
279382

280383
```cython

0 commit comments

Comments
 (0)