Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
81b3125
cuda.core: define the error handling policy and report failures that …
Andy-Jost Sep 3, 2026
963e0b6
cuda.core: keep the texture autosummary contiguous in api.rst
Andy-Jost Sep 3, 2026
7e2ac8c
cuda.core: attach secondary failures to the propagating exception as …
Andy-Jost Sep 3, 2026
c5399ce
cuda.core: follow the review of #2750 and flush the stderr fallback
Andy-Jost Sep 4, 2026
07e2f6a
Merge remote-tracking branch 'origin/main' into ajost/error-handling-…
Andy-Jost Sep 8, 2026
e30c01f
cuda.core tests: check host-only buffer teardown for CUDAWarning, not…
Andy-Jost Sep 8, 2026
cb5784e
cuda.core: directory-aware C++ build rule and drop the dead top-level…
Andy-Jost Sep 10, 2026
28b73d2
cuda.core: keep the merged-wheel layout check in the merge script; us…
Andy-Jost Sep 11, 2026
f37e416
Merge PR #2759 (error-handling policy) into the _rt refactor base
Andy-Jost Sep 11, 2026
260089d
Merge PR #2799 (directory-aware C++ build rule, merged-wheel fix) int…
Andy-Jost Sep 11, 2026
bf92d4f
cuda.core: rename _resource_handles to _rt, no code motion
Andy-Jost Sep 11, 2026
41dd419
cuda.core: remove the dead py_object_user_object_destroy
Andy-Jost Sep 11, 2026
0f3931a
cuda.core build: depend on module-directory headers; compile an exten…
Andy-Jost Sep 11, 2026
69ac578
cuda.core: split rt.hpp into the _cpp/rt/ headers
Andy-Jost Sep 11, 2026
c1f90e8
cuda.core: split rt.cpp into the _cpp/rt/ sources
Andy-Jost Sep 11, 2026
35ecf47
cuda.core: update the design notes and agent guide for _rt
Andy-Jost Sep 11, 2026
2161c01
cuda.core build: parallelize only compilers that use CCompiler.compile
Andy-Jost Sep 11, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ __pycache__/
!*_impl.cpp
!cuda_bindings/cuda/bindings/_lib/param_packer.cpp
!cuda_bindings/cuda/bindings/_bindings/loader.cpp
!cuda_core/cuda/core/_cpp/**/*.cpp
cache_driver
cache_runtime
cache_nvrtc
Expand Down
25 changes: 12 additions & 13 deletions ci/tools/merge_cuda_core_wheels.py
Original file line number Diff line number Diff line change
Expand Up @@ -133,9 +133,11 @@ def merge_wheels(wheels: list[Path], output_dir: Path, show_wheel_contents: bool
# Copy version-specific directories from each wheel into versioned subdirectories
base_dir = Path("cuda") / "core"

versioned_dirs = set()
for i, wheel_dir in enumerate(extracted_wheels):
cuda_version = wheels[i].name.split(".cu")[1].split(".")[0]
versioned_dir = base_wheel / base_dir / f"cu{cuda_version}"
versioned_dirs.add(versioned_dir.name)

# Copy entire directory tree from source wheel to versioned directory
print(f" Copying {wheel_dir / base_dir} to {versioned_dir}", file=sys.stderr)
Expand All @@ -145,25 +147,17 @@ def merge_wheels(wheels: list[Path], output_dir: Path, show_wheel_contents: bool
os.truncate(versioned_dir / "__init__.py", 0)

print("\n=== Removing files from cuda/core/ directory ===", file=sys.stderr)
items_to_keep = (
"__init__.py",
"_version.py",
"_include",
"_cpp", # Headers for Cython development
"cu12",
"cu13",
)
# _resource_handles is shared (not CUDA-version-specific) and must stay
# at top level. It's imported early in __init__.py before versioned code.
items_to_keep_prefix = ("_resource_handles",)
# Only what cuda/core/__init__.py uses before it rewrites __path__ to the
# versioned subpackage stays at top level: it imports _version, then
# redirects every later import into the versioned tree. Anything else
# left at top level is a dead copy that nothing imports.
items_to_keep = {"__init__.py", "_version.py", *versioned_dirs}
all_items = os.scandir(base_wheel / base_dir)
removed_count = 0
for f in all_items:
f_abspath = f.path
if f.name in items_to_keep:
continue
if any(f.name.startswith(prefix) for prefix in items_to_keep_prefix):
continue
if f.is_dir():
print(f" Removing directory: {f.name}", file=sys.stderr)
shutil.rmtree(f_abspath)
Expand All @@ -172,6 +166,11 @@ def merge_wheels(wheels: list[Path], output_dir: Path, show_wheel_contents: bool
os.remove(f_abspath)
removed_count += 1
print(f"Removed {removed_count} items from cuda/core/ directory", file=sys.stderr)
remaining = {entry.name for entry in os.scandir(base_wheel / base_dir)}
if remaining != items_to_keep:
raise RuntimeError(
f"unexpected top level under cuda/core/: {sorted(remaining)} (expected {sorted(items_to_keep)})"
)

# Repack the merged wheel
output_dir.mkdir(parents=True, exist_ok=True)
Expand Down
2 changes: 1 addition & 1 deletion ci/tools/tests/test_compute_ci_plan.py
Original file line number Diff line number Diff line change
Expand Up @@ -113,7 +113,7 @@ def test_ignored_paths_select_no_work(self) -> None:
"cuda_core/tests/fixtures/pixi.toml",
"benchmarks/cuda_bindings/pixi.toml",
"benchmarks/cuda_bindings/AGENTS.md",
"cuda_core/cuda/core/_cpp/DESIGN.md",
"cuda_core/cuda/core/_cpp/rt/DESIGN.md",
"cuda_bindings/README.md",
"cuda_core/README.md",
"new-area/pixi.toml",
Expand Down
67 changes: 64 additions & 3 deletions cuda_core/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,8 +19,9 @@ This file describes `cuda_core`, the high-level Pythonic CUDA subpackage in the
- system-level APIs: `cuda/core/system/`
- compile/link path: `_program.pyx`, `_linker.pyx`, `_module.pyx`
- execution path: `_launcher.pyx`, `_launch_config.pyx`, `_stream.pyx`
- **C++ helpers**: module-specific C++ implementations live under
`cuda/core/_cpp/`.
- **C++ helpers**: module-specific C++ lives under `cuda/core/_cpp/`, either as
one `_cpp/<name>.cpp` or as a directory `_cpp/<name>/` whose sources all
compile into the `_<name>` extension (`_cpp/rt/` for `_rt`).
- **Build backend**: `build_hooks.py` handles Cython extension setup and build
dependency wiring.

Expand Down Expand Up @@ -90,7 +91,7 @@ and agents should flag violations.
objects that are not meant to be shared (e.g., the thread-local `Device`) do not
need such guards (see #2321). Reference-count integrity is guaranteed; cache
value-identity/idempotency is not.
- **Entry points assume the GIL is held**: the helpers in `_cpp/resource_handles.*`
- **Entry points assume the GIL is held**: the helpers in `_cpp/rt/`
are called from Cython with the GIL held and do not re-acquire it. Driver and
destructor callbacks run at arbitrary times, so they take the GIL (`with gil`)
and probe for interpreter shutdown before touching Python objects.
Expand All @@ -101,6 +102,66 @@ and agents should flag violations.
(kernel arguments, memcpy/memset operands, `dst_owner`/`src_owner`, and
host-callback closures) inherit this contract.

## Failure handling

The user-facing contract lives in `docs/source/error_handling.rst`; the rules
below are for contributors. Reviewers and agents should flag violations.

- **Raise by default**: any failure on a path where an exception can propagate
raises. Driver statuses go through `HANDLE_RETURN` (Cython) or are returned as
`CUresult` from the C++ handle layer and then `HANDLE_RETURN`ed; never
replace a `CUresult` with a generic `RuntimeError`, and drain
`get_last_error()` immediately after a handle constructor returns empty so a
stale status cannot be misattributed later.
- **Guarantees**: a call that creates a resource must create nothing when it
raises (undo the creation if a later step fails). Every call except
`Device.set_current` must leave the calling thread's current context as it
found it. Do not hand-roll `cuCtxPush/Pop/SetCurrent` sequences in Cython; use
the handle layer's scoped-context helpers (`invoke_in_context`,
`invoke_in_context_or_undo`, `cleanup_in_context`, `context_get_device`,
`graph_node_set_params`) so the failure handling exists in one place.
- **Publish before you raise**: when a driver mutation has succeeded and a later
step can still fail, commit whatever keeps that mutation memory-safe (for
example the graph attachment that retains a node's new owners) before raising
the later error. Rolling back the retention of a live mutation creates a
dangling reference. When ownership cannot be established, retain the
resources anyway (leak) rather than release them; a leak is always preferred
to a use-after-free.
- **Non-propagating paths never raise and never discard a status**: shared_ptr
deleters, `__dealloc__` and CUDA callbacks report through one channel, `report_cuda_error()` / `report_message()` in C++ (the
`pw_*` wrappers) or `warnings.warn(..., CUDAWarning)` in Cython and Python,
which emits `cuda.core.CUDAWarning`. No `print(file=sys.stderr)` and no
`fprintf` outside that helper. `CUDA_ERROR_DEINITIALIZED` is filtered by the
helper because it means the driver is shutting down.
- **Rollback failure**: the original exception propagates; the failed rollback
is attached to it with `note_or_report_cuda_error()` (a PEP 678 note on
Python 3.11+, reported out-of-band on 3.10), or chained with
`raise ... from` when a second exception must be raised. Catching everything
(bare `except:` or `except BaseException:`) is acceptable only for
rollback-then-`raise` blocks, where the rollback must also run for
`KeyboardInterrupt`.
- **Finalization**: once `py_is_finalizing()` is true, do no Python work from
destructors or callbacks and accept the leak (see
`_cpp/rt/py.hpp` and `_cpp/rt/GRAPH_ATTACHMENTS.md`).
- **Aborting**: `std::abort` (or any process termination) is reserved for an
internal invariant violation where continuing could corrupt memory or produce
silently wrong results *and* no leak-based fallback exists. A failed CUDA
call, including a failed context restoration, never qualifies: raise or
report instead. There is currently no such path; if one is ever needed it
must go through a single helper that writes a diagnostic (call, CUDA error,
invariant, "please report") and a Python traceback of all threads to stderr
before aborting (as `faulthandler` does, via the GIL-free
`_Py_DumpTracebackThreads`; no Python-object work), must never trigger
during interpreter finalization or for driver-shutdown errors, and must be
called out in the docs and release notes. An *implicit* abort (an exception
escaping a `noexcept` function or a deleter, including `std::bad_alloc` from
an allocation inside `noexcept` code) is a bug (#1489, #2417), not a policy
choice: `noexcept` helpers must not allocate, or must catch what they call.
- **Testing**: inject restoration failures with
`cuda.core._rt._set_context_restore_fault_for_testing`; assert
reports with `pytest.warns(CUDAWarning)` or `warnings.catch_warnings`, never
by matching stderr text.

## API design guidelines

These are some API design guidelines we try to follow when adding new APIs to
Expand Down
59 changes: 46 additions & 13 deletions cuda_core/build_hooks.py
Original file line number Diff line number Diff line change
Expand Up @@ -183,6 +183,45 @@ def _relativize_extension_sources(extensions) -> None:
]


def _extension_sources(mod_name):
"""The module's .pyx plus its C++, if any: every .cpp under
cuda/core/_cpp/<stem>/, or the single legacy file cuda/core/_cpp/<stem>.cpp.
Example: _tensor_map.pyx compiles _cpp/tensor_map.cpp."""
sources = [f"cuda/core/{mod_name}.pyx"]
cpp_stem = Path("cuda", "core", "_cpp", mod_name.lstrip("_"))
if cpp_stem.is_dir():
cpp_sources = sorted(str(path) for path in cpp_stem.rglob("*.cpp"))
if not cpp_sources:
raise RuntimeError(f"{cpp_stem}/ exists but contains no .cpp files")
sources.extend(cpp_sources)
elif cpp_stem.with_suffix(".cpp").is_file():
sources.append(str(cpp_stem.with_suffix(".cpp")))
return sources


def _extension_depends():
"""Headers whose edits must rebuild an extension: every header under a
directory-form module's cuda/core/_cpp/<stem>/ (a single-file module has
none).

The same list serves every extension. A module that cimports a
directory-form module compiles against the header its .pxd names, and
cythonize copies each `depends` entry into its build directory before
compiling, so the copied header finds its sibling includes beside it
(quoted includes resolve next to the copy, not in the source tree).
Listing the whole directory keeps the rule free of include parsing; the
cost is that every extension rebuilds when any of these headers changes,
exactly as editing the one monolithic header did before the split."""
cpp = Path("cuda", "core", "_cpp")
return sorted(
str(path)
for module_dir in cpp.iterdir()
if module_dir.is_dir()
for path in module_dir.rglob("*")
if path.suffix in (".h", ".hpp")
)


def _build_cuda_core(debug=False):
# Customizing the build hooks is needed because we must defer cythonization until cuda-bindings,
# now a required build-time dependency that's dynamically installed via the other hook below,
Expand Down Expand Up @@ -227,18 +266,6 @@ def module_names():
continue
yield mod

def get_sources(mod_name):
"""Get source files for a module, including any .cpp files."""
sources = [f"cuda/core/{mod_name}.pyx"]

# Add module-specific .cpp file from _cpp/ directory if it exists
# Example: _resource_handles.pyx finds _cpp/resource_handles.cpp.
cpp_file = f"cuda/core/_cpp/{mod_name.lstrip('_')}.cpp"
if os.path.exists(cpp_file):
sources.append(cpp_file)

return sources

all_include_dirs = [os.path.join(cuda_path, "include")]
extra_compile_args = []
extra_link_args = []
Expand All @@ -261,10 +288,12 @@ def get_sources(mod_name):
# related to free-threading builds.
extra_compile_args += ["-DCYTHON_TRACE_NOGIL=1", "-DCYTHON_USE_SYS_MONITORING=0"]

depends = _extension_depends()
ext_modules = tuple(
Extension(
f"cuda.core.{mod.replace(os.path.sep, '.')}",
sources=get_sources(mod),
sources=_extension_sources(mod),
depends=depends,
include_dirs=[
"cuda/core/_include",
"cuda/core/_cpp",
Expand Down Expand Up @@ -295,6 +324,10 @@ def get_sources(mod_name):
# CUDA_PYTHON_COVERAGE deliberately generates in-tree so the sources can
# be packaged; every other build gets its own per-configuration cache,
# anchored alongside the stamp so both resolve the same from any cwd.
# Cython also copies each extension's extern headers and `depends` under
# this directory and compiles against the copies. Copies are refreshed by
# mtime and never deleted, so remove build/ after renaming or deleting a
# header under _cpp/.
build_dir="." if COMPILE_FOR_COVERAGE else str(_BUILD_DIR / "cython" / f"cu{cuda_major}"),
nthreads=nthreads,
compiler_directives=compiler_directives,
Expand Down
2 changes: 2 additions & 0 deletions cuda_core/cuda/core/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -102,8 +102,10 @@ class _PatchedProperty(metaclass=_PatchedPropMeta):
from cuda.core._stream import __all__ as _stream_all
from cuda.core._tensor_map import *
from cuda.core._tensor_map import __all__ as _tensor_map_all
from cuda.core._utils.cuda_utils import CUDAWarning

__all__ = [
"CUDAWarning",
*_context_all,
*_device_all,
*_device_resources_all,
Expand Down
2 changes: 1 addition & 1 deletion cuda_core/cuda/core/_context.pxd
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
#
# SPDX-License-Identifier: Apache-2.0

from cuda.core._resource_handles cimport ContextHandle, GreenCtxHandle
from cuda.core._rt cimport ContextHandle, GreenCtxHandle

cdef class Context:
"""Cython declaration for Context class.
Expand Down
2 changes: 1 addition & 1 deletion cuda_core/cuda/core/_context.pyx
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ import cython
from cuda.bindings cimport cydriver
from cuda.core._device_resources cimport DeviceResources, SMResource, WorkqueueResource
from cuda.core._device_resources import SMResource, WorkqueueResource
from cuda.core._resource_handles cimport (
from cuda.core._rt cimport (
ContextHandle,
GreenCtxHandle,
as_cu,
Expand Down
Loading
Loading