Skip to content

Commit 45a4ced

Browse files
Andy-Jostclaude
andauthored
cuda.core: require a cuda-bindings floor and call the driver through its resolved pointers (#2783) (#2920)
* cuda.core: require a per-major cuda-bindings floor at build and run time cuda.core accepted any cuda-bindings of the right major at build time and at run time, and built against whatever cuda.h was on the include path. Builds succeeded for configurations we never test, and an older cuda-bindings at run time surfaced as an ImportError for a missing C function, a silently disabled feature, or a null-pointer crash (#2783). Each supported CUDA major now has a floor, the newest cuda-bindings release its CI source root can build: 12.9.8 for CUDA 12 and 13.4.1 for CUDA 13, in the import-free module cuda/core/_bindings_floor.py. Build time: the pip build requirement becomes `cuda-bindings>=<floor>,==<major>.*`, and because conda-forge, pixi and --no-build-isolation installs bypass it, the backend itself checks the imported cuda-bindings against the floor and requires the cuda.h it compiles against to have the same major.minor as that cuda-bindings (the header cuda-bindings was generated from), even when CUDA_CORE_BUILD_MAJOR is set. It records the header version and the floor in the generated, gitignored cuda/core/_build_info.py, shipped like _version.py. Run time: cuda/core/__init__.py reads that record from the selected build (the cu12/cu13 subpackage of the merged wheel, or the top level of a plain build) and requires the installed cuda-bindings to be of the build's major and at least the floor, or at least the header's minor when that is newer. The error names the version found, the version required, and the pip command that fixes it. Pre-release and dev builds of an accepted version pass. Packaging: the cu12/cu13 extras pin the floor; a test keeps them and the ci/versions.yml toolkit pins in step with the module. CI: the BINDINGS_SOURCE=published rows, which paired a new wheel with cuda-bindings 13.0 (no longer supported), become BINDINGS_SOURCE=floor: they install the floor bindings, read from the wheel under test by ci/tools/cuda_core_bindings_floor.py, and keep their older CTK libraries. The prior-major rows whose CTK minor differs from prev_build do the same. Docs: support policy section, install guide, 1.3.0 release note. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * cuda.core: fence C++ on the CUDA major only, never on CUDA_VERSION The two `#if CUDA_VERSION >= 130x0` fences in _cpp/rt/driver_api.* compiled cuDevSmResourceSplit and cuMemcpyWithAttributesAsync out of a source build against an older 13.x header, while the run-time gates, which looked at the bindings and the driver, still reported the features as available; the calls then failed with CUDA_ERROR_NOT_SUPPORTED (#2783, "guards disable working features"). Both features also went through void*-typed C++ shims and has_*() presence probes whose only purpose was to avoid an eager cimport of a cydriver function that cuda-bindings 13.0 did not export. With the cuda-bindings floor in place (13.4.1 for CUDA 13), the cimport is safe, so the shims, the presence probes and the two fenced pointers go away: _device_resources.pyx and _buffer.pyx call cydriver.cuDevSmResourceSplit and cydriver.cuMemcpyWithAttributesAsync directly under the existing `IF CUDA_CORE_BUILD_MAJOR >= 13`, gated on the driver version alone. build_hooks.py now defines CUDA_CORE_BUILD_MAJOR (until now a Cython-only compile-time constant) and CUDA_CORE_MIN_CUDA_VERSION (the floor's major.minor) for the C++ compiler. The new _cpp/rt/versions.hpp, the first include of the tree via types.hpp, re-checks cuda.h against both with #error, so a build that bypasses the backend still cannot compile against an unsupported header. tests/test_rt_layout.py enforces that versions.hpp is the only file under _cpp/ that names CUDA_VERSION. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * cuda.core: call the driver through cuda-bindings' resolved pointers (#2783) The C++ under _cpp/rt/ used to call the driver through cuda-bindings' Cython wrappers, extracted from cydriver.__pyx_capi__ at import. A wrapper for a function the driver lacks raises a Python exception the C++ never sees, and a wrapper the installed cuda-bindings lacks made the pointer optional and probed for null at every use. driver_api.hpp now lists every driver function the C++ calls, with the CUDA version cuda-bindings requests it at. The table is filled on first use from cuda.bindings._internal.driver._inspect_function_pointers(), which holds the driver's own entry points, so `import cuda.core` never touches the driver. Calls go through DRIVER_CALL(name, args...), which fills the table if needed and, when a pointer is still null after the fill (a missing feature gate or a failed fill), reports once and returns an error status from a trampoline instead of dereferencing null. The fill rejects a driver older than the CUDA major series. pw_ deleter wrappers use the same path, so the C++ never null-checks a pointer; functions the driver may lack are gated on the driver version in Cython. NVRTC, NVVM and nvJitLink each have their own one-entry table, filled when a program or linker handle is created. Consequences in this commit: the null-check fallbacks to CUDA_ERROR_NOT_SUPPORTED are gone; green-context stream creation is gated in _stream.pyx on driver 12.5; deviceptr_import_ipc resolves the table before taking ipc_import_mutex and keeps raw calls under the lock (marked `// raw:`); _rt.pyx no longer imports cydriver/cynvrtc/cynvvm/ cynvjitlink or reads __pyx_capi__. test_rt_layout.py checks the table against cuda-bindings' loader and forbids raw p_ calls elsewhere. DESIGN.md and AGENTS.md describe the mechanism and settle the GIL contract for entry points. * cuda.core: gate features on the driver alone now that cuda-bindings has a floor (#2783) Every cuda-bindings cuda.core accepts (12.9.8+, 13.4.1+) has the full API surface of its CUDA major, so a run-time check of the cuda-bindings version says nothing a build-major fence does not. Double checks (driver and bindings) become driver-only checks; checks of the bindings minor become `IF CUDA_CORE_BUILD_MAJOR` fences in Cython or a comparison with the new `cuda.core._utils.version.BUILD_CUDA_MAJOR` in Python; checks that every accepted cuda-bindings satisfies are deleted, along with the fallbacks they guarded. - Kernel argument info, green contexts, workqueues, graph node updates, conditional graph nodes: driver-only gates. - cuGraphNodeGetParams (13.2): a driver gate on the CUDA 13 build, and the call goes through cydriver instead of the Python driver layer. - Managed memory NUMA locations, virtual memory MANAGED type: build fence (plus the 13.0 driver where a driver entry point is involved). - Copy attribute enums (CUDA 12.8): unconditional. - NVVM, nvJitLink, NVRTC PCH bindings: always present; only the library load is probed. - Checkpoint: the CUDA 13 build only (CUcheckpointGpuPair is a CUDA 13 type; the CUDA 12 bindings never had it), stated plainly. - cuda.core.system: NVML through cuda.bindings unconditionally; the driver/runtime fallbacks are gone and CUDA_BINDINGS_NVML_IS_COMPATIBLE is a deprecated constant True. system.typing exports DeviceArch and FieldId unconditionally. - Error-enum explanations come from cuda-bindings' enum docstrings; the frozen 13.1.1 tables and their loader are removed. - cuda.bindings.utils.warn_if_cuda_major_version_mismatch (13.3+) is called on the CUDA 13 build only, instead of try/except ImportError. cy_binding_version() has no callers left and is removed; binding_version() stays. Tests follow the same rule set. * cuda.core tests: gate the checkpoint helper tests on the CUDA 13 build (#2783) test_checkpoint.py probed cuda.core.checkpoint._REQUIRED_BINDING_ATTRS, which the gate collapse removed, so the whole session failed at collection. The helpers build CUcheckpointGpuPair, a CUDA 13 type; skip them on the CUDA 12 build instead. * cuda.core: regenerate stubs and drop exec from the floor tool and tests (#2783) stubgen-pyx regenerated version.pyi (BUILD_CUDA_MAJOR), system/_system.pyi (the constant is now True) and system/_device.pyi (NVLink 6.0 mapping is unconditional). ci/tools/cuda_core_bindings_floor.py reads the CUDA_BINDINGS_FLOOR literal with ast instead of executing the module; its tests and test_build_hooks.py load modules with importlib instead of exec, and the stubbed configuration check takes any arguments. * cuda.core: import cuda.bindings for the build check through the #1824 namespace repair (#2783) The build-configuration check imported cuda.bindings directly. In an isolated PEP 517 build with an in-tree backend (the wheel-from-sdist CI job), the project's own cuda/ directory is the whole `cuda` namespace, so the cuda-bindings pip installed into the build environment is not importable (#1824) and the check failed with "requires cuda-bindings to build". Import it the way _import_get_cuda_path_or_home() imports cuda.pathfinder: add the site-packages cuda/ directory to the namespace path when the plain import fails. The pxd-path block uses the same helper. Package metadata is not a substitute: pip's in-process hook runner forwards find_distributions without the requested name, so it returns this project's own metadata for `cuda-bindings`. test_managed_ops.py asserted the old wording of the NUMA-host message ("cuda-bindings 13.0+"), which now names the CUDA 13 build; the four regexes follow. * cuda.core: read linked LTOIR through cynvjitlink instead of probing the Python bindings (#2783) #2867 fetched linked LTOIR through the Python-level cuda.bindings.nvjitlink module, probing it with hasattr for get_linked_ltoir_size/get_linked_ltoir because the cuda-bindings in use might predate them, and left a TODO(#2783) to switch once a floor existed. Both floors (12.9.8, 13.4.1) have the cynvjitlink getters, so Linker.link("ltoir") now calls nvJitLinkGetLinkedLTOIRSize/nvJitLinkGetLinkedLTOIR directly, like the cubin and ptx branches. The nvJitLink library-version gate (13.3) stays: that is the library, not the bindings. The cached module object is gone; only the cached library version remains. Tests that faked the module probe or asserted the cached module follow. * cuda.core: declare the cuda-bindings floor once, in the pyproject extras (#2783) The floor of each CUDA major was typed in three places: a table in cuda/core/_bindings_floor.py, the cu12/cu13 extras in pyproject.toml, and the support-policy table in the docs, kept in step by tests that only ran in the GPU test jobs. The extras are now the single declaration, in the form `cuda-bindings[all]>=<floor>,==<major>.*`, and everything derives from them: - _bindings_floor.py keeps the logic only: floors_from_extras() parses the [project.optional-dependencies] table strictly (a malformed extra fails instead of shifting the floor), plus the version helpers and the import-time check, which now takes the build's recorded floor. - build_hooks.py reads pyproject.toml (tomllib; tomli on 3.10, added to build-system.requires) for the build-time check, the dynamic build requirement and -DCUDA_CORE_MIN_CUDA_VERSION, and records the floor in the generated _build_info.py, which cuda/core/__init__.py already reads. __init__.py no longer hard-codes the supported majors. - ci/tools/cuda_core_bindings_floor.py reads the floor out of the wheel's _build_info.py (per major in the merged wheel) instead of the table. - docs/source/conf.py defines |cuda-bindings-floor-cu12| and |cuda-bindings-floor-cu13| from pyproject.toml; support.rst uses them and labels the row with |release|. The stale cuda-bindings minimums in api_nvml.rst are gone: cuda.core.system has no requirement beyond the floor. - toolshed/check_cuda_core_bindings_floor.py, a new pre-commit hook, checks the two constraints that cannot be derived: ci/versions.yml must build each major against a toolkit of the floor's major.minor, and no documentation page (release notes excepted) may spell a floor out by hand. tests/test_bindings_floor.py runs the same checks. - AGENTS.md documents the coupling and a "Bumping the cuda-bindings floor" checklist. * cuda.core: compare headers, not version strings, in the cuda-bindings header rule (#2783) The build required cuda.h to match the installed cuda-bindings' version string by major.minor, and the import-time check demanded a cuda-bindings version at least as new as the build's header. Both fail on a cuda-bindings built from main right after a toolkit minor bump: its version string is the previous release's (13.4.3.devN) while it was generated from the new header. Compare headers instead: cuda.bindings.driver.CUDA_VERSION, present at both floors, is the CUDA_VERSION macro of the header cuda-bindings was generated from. The build check and check_installed_bindings() use it; the floor stays a version-string comparison; required_minimum() is gone. Isolated builds now request `cuda-bindings>=<floor>,==<major>.*,<<major>.<header minor + 1>` when cuda.h is readable, so pip cannot pick a newer minor than the toolkit, while the development build above (old version string, new header) still resolves and reaches the header rule. The pre-commit hook lets the CI toolkit run ahead of the floor's minor (the toolkit-bump window) and rejects only a toolkit below it. AGENTS.md documents the bump order and the accepted red window for the rows that install the literal floor. __init__.py reads cuda.bindings.driver by module object: the `cuda` namespace package carries no `bindings` attribute when the submodule comes from sys.modules. Found by the new child-interpreter test that imports cuda.core against fake cuda-bindings and asserts each ImportError, which issue #2783 asked for. The wrong-major message no longer promises a build for the installed major, and the pip commands use double quotes so they work in cmd.exe. * cuda.core: latch a failed driver-table fill, keep pending exceptions, attach the reason (#2783) A fill of the driver function table that failed was retried by every later DRIVER_CALL, re-importing cuda-bindings and re-warning each time, and its Python calls ran with whatever exception the caller had pending: a deleter making the first driver call during unwinding clobbered the user's exception. The failure is now latched per table (transient causes such as KeyboardInterrupt, MemoryError and RecursionError excepted), a PendingExceptionGuard saves and restores the pending exception around the fill, and fn_table_error() copies its text under the mutex. The CUDAError raised for the trampoline's CUDA_ERROR_NOT_INITIALIZED used to explain that cuInit() was not called. report_unavailable_fn() now records the fill's reason as the error detail (note_driver_table_failure), which the Cython error path attaches to the exception as a note, on every affected call; the fill itself warns once, and a null entry in a filled table (a gate bug) warns once per table. format_cuda_error() no longer null-checks the pointers, deviceptr_import_ipc() returns an error when the fill failed, and the graphics entry's requested version is annotated. The raw-pointer lint in test_rt_layout.py now catches null checks too. tests/test_driver_table.py runs a child interpreter whose _inspect_function_pointers() returns a table with a null baseline entry or a missing entry, and asserts the note, the single warning and the latch. * cuda.core: keep the NVML constant's stub annotation-only; finish the text sweep (#2783) The API check gates "Check job status", so the stub of CUDA_BINDINGS_NVML_IS_COMPATIBLE must keep the bare `bool` annotation it had: the constant is declared with the annotation and assigned in a runtime-only block, which the stub generator does not record. It is always True and no longer called deprecated. DeviceArch is a plain enum.IntEnum instead of subclassing cuda-bindings' private FastEnum. Remaining text that named cuda-bindings versions as feature requirements now names the driver and the CUDA 13 build: the copy-options messages and docstrings in _buffer.pyx and _copy_ops.pyx, the graph node update docstrings, api.rst, the checkpoint section and module docstring, install.rst on conda-forge, DESIGN.md's summary, AGENTS.md's path to it, and the test helpers. The hasattr/getattr probes for NVML members present at both floors are gone, as is the probe-derived allow-list in test_enum_coverage.py. * cuda-bindings header check at build; final floor messages; bounds spelled <next major> (#2783) - cuda_bindings/build_hooks.py: before cythonize, compare the toolkit's cuda.h CUDA_VERSION with the one the sources were generated from (cydriver.pxd). A mismatch fails with a message that names the resolved cuda.h, both versions, the run-time compatibility, and the two remedies. Tests in cuda_bindings/tests/test_build_hooks.py; install page and 13.5.0 release note. - cuda.core build-time and import-time messages: name the resolved cuda.h, state what is needed and what was found, say that the rule does not constrain the CUDA driver or toolkit at run time, and link the support policy. - Review follow-ups: the cu12/cu13 extras read `>=<floor>,<<next major>`; the floor reader accepts `<N+1` or `==N.*` and checks that the bounds confine cuda-bindings to major N; bindings_requirement() takes an upper bound; the pip commands in the messages use the same form. Two wording fixes in support.rst. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * Copyedit the docs, comments, docstrings and messages this PR adds (#2783) A plain-language pass over the prose the PR introduces: one idea per sentence, active voice, no semicolons or parenthetical asides, one term per concept (cuda-bindings for the package, cuda.bindings for the module). Error messages keep what they state and change only how they say it; the tests that match them follow. The regenerated stubs follow their docstrings. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * cuda-bindings header check: review wording for the run-time note (#2783) Per review: "supports any CUDA 13.x toolkit" overstates the claim, and "this cuda-bindings build" names what the note is about. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * Compile the build record; verify the compiler's cuda.h against the Python read; review follow-ups (#2783) - cuda/core/_build_info.pyx replaces the generated _build_info.py. It exposes CUDA_VERSION from the cuda.h the compiler resolved and the major and floor from the Cython compile-time environment, so the record cannot disagree with the binaries. cuda/core/__init__.py reads the extension. - build_hooks.py passes the cuda-bindings header version as a third macro; versions.hpp fails the compile unless the resolved cuda.h has the same major.minor. The Python read of cuda.h stays for the decisions that precede any compile, and can no longer silently differ from the compiler's. - ci/tools/cuda_core_bindings_floor.py reads the floor from the wheel's METADATA (the cu<major> extra) instead of parsing a generated module. - __init__.py imports _bindings_floor directly; the merge tool keeps it at the top level. A missing _build_info now distinguishes "no build for this CUDA major" from "not built". - ci/tools/env-vars reads ci/versions.yml with yq. - cuda_python/setup.py no longer pins cuda-core. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * ci: install yq in the Linux GPU test containers ci/tools/env-vars reads ci/versions.yml with yq, and the ubuntu:24.04 test container has none, so every Linux test job stopped at the environment step. A composite action installs the pinned, checksummed mikefarah/yq release for amd64 or arm64; the Windows GPU runners already provide yq. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * Address review: restore the cuda-core pin; re-deliver KeyboardInterrupt from the table fill; yq via apt; nits (#2783) - cuda_python/setup.py pins cuda-core~=1.2.0 again. The bump at each cuda-core release, followed by a cuda-python release, is documented in .github/RELEASE-core.md, cuda_python/AGENTS.md and cuda_core/AGENTS.md. - A KeyboardInterrupt raised inside the function-table fill is no longer swallowed. The fill re-arms it with PyErr_SetInterrupt() after its warning, and report_message() does the same when an interrupt fires inside the warnings machinery, so the user sees the interrupt at the next bytecode boundary and the warning survives. Empty exception text falls back to the type name. Test: cuda_core/tests/test_driver_table.py, through the NVRTC table, CPU-only. - The Linux GPU test containers get yq from apt (the Python jq wrapper, which accepts the same command); the composite action is removed. - DeviceArch is a FastEnum again (present at both floors). - support.rst: the driver bullet states what the floor does not change and what an older driver means. Small comment and release-note fixes. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
1 parent 2e976bf commit 45a4ced

116 files changed

Lines changed: 3676 additions & 2887 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

‎.github/RELEASE-core.md‎

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -62,9 +62,14 @@ platforms as appropriate for each release.
6262
## Check (or update if needed) the dependency requirements
6363

6464
Review `cuda_core/pyproject.toml` and verify that all dependency
65-
requirements are current.
66-
67-
Update the cuda_core dependency in `cuda_python/setup.py`.
65+
requirements are current. The `cu12`/`cu13` extras declare the
66+
`cuda-bindings` floors; see "Bumping the cuda-bindings floor" in
67+
`cuda_core/AGENTS.md`.
68+
69+
Update the `cuda-core` pin in `cuda_python/setup.py` to the new minor
70+
series (`cuda-core~=X.Y.0`), and plan a `cuda-python` release right after
71+
this one. Until that release, `pip install cuda-python` resolves to the
72+
previous `cuda-core`.
6873

6974
---
7075

‎.github/workflows/coverage.yml‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -80,8 +80,8 @@ jobs:
8080
uses: ./.github/actions/install_unix_deps
8181
continue-on-error: false
8282
with:
83-
dependencies: "tree rsync libsqlite3-0 g++ jq wget libgl1 libegl1"
84-
dependent_exes: "tree rsync libsqlite3-0 g++ jq wget libgl1 libegl1"
83+
dependencies: "tree rsync libsqlite3-0 g++ jq wget libgl1 libegl1 yq"
84+
dependent_exes: "tree rsync libsqlite3-0 g++ jq wget libgl1 libegl1 yq"
8585

8686
- name: Setup proxy cache
8787
uses: nv-gha-runners/setup-proxy-cache@main

‎.github/workflows/test-wheel-linux.yml‎

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -144,8 +144,9 @@ jobs:
144144
# for artifact fetching, graphics libs, g++ required for cffi in
145145
# example; util-linux for `nsenter` (custom-DRIVER rows re-exec
146146
# install_gpu_driver.sh onto the host through nsenter)
147-
dependencies: "jq wget libgl1 libegl1 g++ util-linux"
148-
dependent_exes: "jq wget"
147+
# yq: ci/tools/env-vars reads ci/versions.yml with it
148+
dependencies: "jq wget libgl1 libegl1 g++ util-linux yq"
149+
dependent_exes: "jq wget yq"
149150

150151
- name: Install GPU driver
151152
if: ${{ matrix.DRIVER != 'latest' && matrix.DRIVER != 'earliest' }}
@@ -252,14 +253,14 @@ jobs:
252253
fi
253254
254255
- name: Display structure of downloaded cuda-python artifacts
255-
if: ${{ env.TEST_PYTHON == 'true' && env.BINDINGS_SOURCE != 'published' }}
256+
if: ${{ env.TEST_PYTHON == 'true' && env.BINDINGS_SOURCE != 'floor' }}
256257
run: |
257258
pwd
258259
ls -lah cuda_python*.whl cuda_pathfinder/
259260
260261
- name: Display structure of downloaded cuda.bindings artifacts
261262
if: ${{ (env.TEST_BINDINGS == 'true' || env.TEST_CORE == 'true' || env.TEST_PYTHON == 'true') &&
262-
env.BINDINGS_SOURCE != 'published' }}
263+
env.BINDINGS_SOURCE != 'floor' }}
263264
run: |
264265
pwd
265266
ls -lahR $CUDA_BINDINGS_ARTIFACTS_DIR

‎.github/workflows/test-wheel-windows.yml‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -232,14 +232,14 @@ jobs:
232232
fi
233233
234234
- name: Display structure of downloaded cuda-python artifacts
235-
if: ${{ env.TEST_PYTHON == 'true' && env.BINDINGS_SOURCE != 'published' }}
235+
if: ${{ env.TEST_PYTHON == 'true' && env.BINDINGS_SOURCE != 'floor' }}
236236
run: |
237237
Get-Location
238238
Get-ChildItem cuda_python*.whl | Select-Object Mode, LastWriteTime, Length, FullName
239239
240240
- name: Display structure of downloaded cuda.bindings artifacts
241241
if: ${{ (env.TEST_BINDINGS == 'true' || env.TEST_CORE == 'true' || env.TEST_PYTHON == 'true') &&
242-
env.BINDINGS_SOURCE != 'published' }}
242+
env.BINDINGS_SOURCE != 'floor' }}
243243
run: |
244244
Get-Location
245245
Get-ChildItem -Recurse -Force $env:CUDA_BINDINGS_ARTIFACTS_DIR | Select-Object Mode, LastWriteTime, Length, FullName

‎.pre-commit-config.yaml‎

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,15 @@ repos:
4242
pass_filenames: false
4343
verbose: true
4444

45+
- id: check-cuda-core-bindings-floor
46+
name: Check the cuda-bindings floor against ci/versions.yml and the docs
47+
entry: python ./toolshed/check_cuda_core_bindings_floor.py
48+
language: python
49+
files: ^(cuda_core/pyproject\.toml|ci/versions\.yml|cuda_core/cuda/core/_bindings_floor\.py|cuda_core/docs/source/.*\.rst|toolshed/check_cuda_core_bindings_floor\.py)$
50+
pass_filenames: false
51+
additional_dependencies:
52+
- "tomli>=1.1.0; python_version < '3.11'"
53+
4554
- id: check-spdx
4655
name: Check SPDX Headers
4756
entry: python ./toolshed/check_spdx.py
Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
#!/usr/bin/env python3
2+
# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
3+
#
4+
# SPDX-License-Identifier: Apache-2.0
5+
6+
"""Print the cuda-bindings floor of a cuda-core wheel for one CUDA major.
7+
8+
cuda_core_bindings_floor.py --wheel dist/cuda_core-*.whl --major 13
9+
-> 13.4.1
10+
11+
CI installs `cuda-bindings==<floor>` next to a freshly built cuda-core wheel to
12+
test the oldest cuda-bindings that wheel supports (BINDINGS_SOURCE=floor in
13+
ci/tools/env-vars). This script reads the floor from the wheel under test, not
14+
from the checkout, so a nightly job that tests a wheel built from another
15+
commit reads that wheel's floor.
16+
17+
The floors are declared once, by the `cu12`/`cu13` extras in
18+
cuda_core/pyproject.toml, and the wheel's METADATA carries them as
19+
`Requires-Dist: cuda-bindings[all]<14,>=13.4.1; extra == "cu13"`. This script
20+
reads that line.
21+
"""
22+
23+
from __future__ import annotations
24+
25+
import argparse
26+
import re
27+
import sys
28+
import zipfile
29+
from pathlib import Path
30+
31+
# The cuda-bindings requirement of one `cu<major>` extra in METADATA. The name
32+
# is followed by `[`, an operator or whitespace so that other names do not match.
33+
_REQUIRES_DIST_RE = re.compile(
34+
r"^Requires-Dist:\s*cuda-bindings(?=[\[<>=!~\s])(?P<spec>[^;]*);\s*extra\s*==\s*['\"]cu(?P<major>\d+)['\"]"
35+
)
36+
_FLOOR_RE = re.compile(r">=\s*(\d+)\.(\d+)\.(\d+)")
37+
38+
39+
def floor_from_metadata(metadata: str, major: int) -> str:
40+
"""The floor that the `cu<major>` extra of a wheel's METADATA pins cuda-bindings to."""
41+
for line in metadata.splitlines():
42+
m = _REQUIRES_DIST_RE.match(line)
43+
if m is None or int(m.group("major")) != major:
44+
continue
45+
floor = _FLOOR_RE.search(m.group("spec"))
46+
if floor is None:
47+
raise SystemExit(f"METADATA pins cuda-bindings without a floor for the cu{major} extra: {line.strip()!r}")
48+
if int(floor.group(1)) != major:
49+
raise SystemExit(
50+
f"METADATA pins a cuda-bindings floor of another major for the cu{major} extra: {line.strip()!r}"
51+
)
52+
return ".".join(floor.groups())
53+
raise SystemExit(f"METADATA declares no cuda-bindings requirement for the cu{major} extra")
54+
55+
56+
def floor_from_wheel(wheel: Path, major: int) -> str:
57+
with zipfile.ZipFile(wheel) as zf:
58+
metadata = [name for name in zf.namelist() if name.endswith(".dist-info/METADATA")]
59+
if len(metadata) != 1:
60+
raise SystemExit(f"{wheel.name} contains {len(metadata)} METADATA files, expected 1. Is it a wheel?")
61+
return floor_from_metadata(zf.read(metadata[0]).decode("utf-8"), major)
62+
63+
64+
def main(argv: list[str] | None = None) -> int:
65+
parser = argparse.ArgumentParser(description=__doc__.splitlines()[0])
66+
parser.add_argument("--wheel", type=Path, required=True, help="the cuda-core wheel under test")
67+
parser.add_argument("--major", type=int, required=True, help="CUDA major series (12 or 13)")
68+
args = parser.parse_args(argv)
69+
print(floor_from_wheel(args.wheel, args.major))
70+
return 0
71+
72+
73+
if __name__ == "__main__":
74+
sys.exit(main())

‎ci/tools/env-vars‎

Lines changed: 28 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -64,23 +64,45 @@ elif [[ "${1}" == "test" ]]; then
6464
# BINDINGS_SOURCE controls which cuda-bindings to install at test time:
6565
# main — use the just-built bindings wheel from this CI run
6666
# backport — fetch bindings from the prior (N-1) branch
67-
# published — install from PyPI (cuda-bindings==${TEST_CUDA_MAJOR}.${TEST_CUDA_MINOR}.*)
67+
# floor — install from PyPI the oldest cuda-bindings that the cuda-core
68+
# wheel under test supports, its per-major floor (see
69+
# cuda_core/cuda/core/_bindings_floor.py and ci/tools/run-tests).
70+
# Selected when the test CTK minor differs from the minor the
71+
# wheel was built against. Those rows exercise a new cuda-core
72+
# with the floor cuda-bindings and older CTK libraries, the skew
73+
# that cuda-core supports. cuda-bindings older than the floor is
74+
# unsupported and fails at import
75+
# (https://github.com/NVIDIA/cuda-python/issues/2783).
6876
#
6977
# SKIP_CUDA_BINDINGS_TEST / SKIP_CYTHON_TEST control which *tests* to run
7078
# (they do NOT affect installation — that's BINDINGS_SOURCE's job).
7179

7280
BUILD_CUDA_MINOR="$(cut -d '.' -f 2 <<< ${BUILD_CUDA_VER})"
7381
TEST_CUDA_MINOR="$(cut -d '.' -f 2 <<< ${CUDA_VER})"
82+
# CI builds the prior-major half of the cuda-core wheel against the prev_build
83+
# toolkit in ci/versions.yml and the backport branch's bindings.
84+
if ! command -v yq >/dev/null 2>&1; then
85+
echo "Error: yq is required to read ci/versions.yml" >&2
86+
exit 1
87+
fi
88+
BUILD_PREV_CUDA_VER="$(yq -r '.cuda.prev_build.version' ci/versions.yml)"
89+
BUILD_PREV_CUDA_MINOR="$(cut -d '.' -f 2 <<< ${BUILD_PREV_CUDA_VER})"
7490

7591
if [[ ${BUILD_CUDA_MAJOR} != ${TEST_CUDA_MAJOR} ]]; then
76-
# Major mismatch (e.g. build=13.x, test=12.x): use the backport branch.
77-
BINDINGS_SOURCE=backport
7892
SKIP_CUDA_BINDINGS_TEST=1
7993
SKIP_CYTHON_TEST=1
94+
if [[ ${BUILD_PREV_CUDA_MINOR} != ${TEST_CUDA_MINOR} ]]; then
95+
# Prior major, minor mismatch (e.g. built against 12.9, test=12.6): floor
96+
# bindings from PyPI with the older CTK libraries.
97+
BINDINGS_SOURCE=floor
98+
else
99+
# Prior major, same minor (e.g. build=13.x, test=12.9): the backport branch.
100+
BINDINGS_SOURCE=backport
101+
fi
80102
elif [[ ${BUILD_CUDA_MINOR} != ${TEST_CUDA_MINOR} ]]; then
81-
# Same major, minor mismatch (e.g. build=13.2, test=13.0): use published
82-
# bindings from PyPI to test the real-world backward-compat scenario.
83-
BINDINGS_SOURCE=published
103+
# Same major, minor mismatch (e.g. build=13.4, test=13.0): floor bindings
104+
# from PyPI with the older CTK libraries.
105+
BINDINGS_SOURCE=floor
84106
SKIP_CUDA_BINDINGS_TEST=1
85107
SKIP_CYTHON_TEST=1
86108
else

‎ci/tools/merge_cuda_core_wheels.py‎

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -148,10 +148,11 @@ def merge_wheels(wheels: list[Path], output_dir: Path, show_wheel_contents: bool
148148

149149
print("\n=== Removing files from cuda/core/ directory ===", file=sys.stderr)
150150
# Only what cuda/core/__init__.py uses before it rewrites __path__ to the
151-
# versioned subpackage stays at top level: it imports _version, then
152-
# redirects every later import into the versioned tree. Anything else
153-
# left at top level is a dead copy that nothing imports.
154-
items_to_keep = {"__init__.py", "_version.py", *versioned_dirs}
151+
# versioned subpackage stays at top level: it imports _version and the
152+
# build-independent floor logic in _bindings_floor, then redirects every
153+
# later import into the versioned tree. Anything else left at top level
154+
# is a dead copy that nothing imports.
155+
items_to_keep = {"__init__.py", "_version.py", "_bindings_floor.py", *versioned_dirs}
155156
all_items = os.scandir(base_wheel / base_dir)
156157
removed_count = 0
157158
for f in all_items:

‎ci/tools/run-tests‎

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -74,10 +74,14 @@ elif [[ "${test_module}" == "core" || "${test_module}" == nightly-* ]]; then
7474

7575
# Resolve bindings based on BINDINGS_SOURCE (set by env-vars):
7676
# main/backport → local wheel from artifacts dir
77-
# published → install from PyPI by version
77+
# floor → the oldest cuda-bindings that the cuda-core wheel under test
78+
# supports, read from that wheel and installed from PyPI
7879
BINDINGS_ARGS=()
79-
if [[ "${BINDINGS_SOURCE}" == "published" ]]; then
80-
BINDINGS_ARGS+=("cuda-bindings==${TEST_CUDA_MAJOR}.${TEST_CUDA_MINOR}.*")
80+
if [[ "${BINDINGS_SOURCE}" == "floor" ]]; then
81+
CORE_WHL_FOR_FLOOR=("${CUDA_CORE_ARTIFACTS_DIR}"/*.whl)
82+
BINDINGS_FLOOR="$(python ci/tools/cuda_core_bindings_floor.py --wheel "${CORE_WHL_FOR_FLOOR[0]}" --major "${TEST_CUDA_MAJOR}")"
83+
echo "cuda-bindings floor of ${CORE_WHL_FOR_FLOOR[0]##*/} for CUDA ${TEST_CUDA_MAJOR}: ${BINDINGS_FLOOR}"
84+
BINDINGS_ARGS+=("cuda-bindings==${BINDINGS_FLOOR}")
8185
else
8286
BINDINGS_ARGS=("${CUDA_BINDINGS_ARTIFACTS_DIR}"/*.whl)
8387
if [[ "${LOCAL_CTK}" != 1 ]]; then
Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,99 @@
1+
# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
2+
#
3+
# SPDX-License-Identifier: Apache-2.0
4+
5+
import importlib.util
6+
import zipfile
7+
from pathlib import Path
8+
9+
import pytest
10+
11+
TOOLS = Path(__file__).resolve().parent.parent
12+
13+
14+
def _load(name, path):
15+
spec = importlib.util.spec_from_file_location(name, path)
16+
module = importlib.util.module_from_spec(spec)
17+
spec.loader.exec_module(module)
18+
return module
19+
20+
21+
tool = _load("cuda_core_bindings_floor", TOOLS / "cuda_core_bindings_floor.py")
22+
23+
# METADATA as setuptools writes it: pip's normalized specifier order, extras in quotes.
24+
METADATA = """\
25+
Metadata-Version: 2.4
26+
Name: cuda-core
27+
Version: 1.3.0
28+
Requires-Dist: cuda-pathfinder>=1.4.2
29+
Requires-Dist: numpy
30+
Provides-Extra: cu12
31+
Requires-Dist: cuda-bindings[all]<13,>=12.9.8; extra == "cu12"
32+
Requires-Dist: cuda-toolkit==12.*; extra == "cu12"
33+
Provides-Extra: cu13
34+
Requires-Dist: cuda-bindings[all]<14,>=13.4.1; extra == "cu13"
35+
Requires-Dist: cuda-toolkit==13.*; extra == "cu13"
36+
"""
37+
38+
39+
def _wheel(tmp_path, metadata=METADATA):
40+
path = tmp_path / "cuda_core-1.3.0-cp312-cp312-linux_x86_64.whl"
41+
with zipfile.ZipFile(path, "w") as zf:
42+
zf.writestr("cuda/core/__init__.py", "")
43+
if metadata is not None:
44+
zf.writestr("cuda_core-1.3.0.dist-info/METADATA", metadata)
45+
return path
46+
47+
48+
@pytest.mark.agent_authored(model="claude-fable-5-1")
49+
@pytest.mark.parametrize(("major", "floor"), [(12, "12.9.8"), (13, "13.4.1")])
50+
def test_reads_the_floor_of_each_extra(tmp_path, major, floor):
51+
assert tool.floor_from_wheel(_wheel(tmp_path), major) == floor
52+
53+
54+
@pytest.mark.agent_authored(model="claude-fable-5-1")
55+
def test_accepts_other_spellings_of_the_requirement():
56+
metadata = 'Requires-Dist: cuda-bindings>=13.4.1,==13.*; extra == "cu13"\n'
57+
assert tool.floor_from_metadata(metadata, 13) == "13.4.1"
58+
metadata = "Requires-Dist: cuda-bindings [all] >= 13.4.1, < 14 ; extra == 'cu13'\n"
59+
assert tool.floor_from_metadata(metadata, 13) == "13.4.1"
60+
61+
62+
@pytest.mark.agent_authored(model="claude-fable-5-1")
63+
def test_ignores_lookalike_names_and_other_extras():
64+
metadata = (
65+
'Requires-Dist: cuda-bindings-extra>=1.0; extra == "cu13"\n'
66+
'Requires-Dist: cuda-bindings[all]<13,>=12.9.8; extra == "cu12"\n'
67+
'Requires-Dist: cuda-bindings[all]<14,>=13.4.1; extra == "cu13"\n'
68+
)
69+
assert tool.floor_from_metadata(metadata, 13) == "13.4.1"
70+
71+
72+
@pytest.mark.agent_authored(model="claude-fable-5-1")
73+
def test_rejects_a_missing_extra(tmp_path):
74+
with pytest.raises(SystemExit, match="no cuda-bindings requirement for the cu11 extra"):
75+
tool.floor_from_wheel(_wheel(tmp_path), 11)
76+
77+
78+
@pytest.mark.agent_authored(model="claude-fable-5-1")
79+
def test_rejects_a_requirement_without_a_floor():
80+
with pytest.raises(SystemExit, match="without a floor for the cu13 extra"):
81+
tool.floor_from_metadata('Requires-Dist: cuda-bindings==13.*; extra == "cu13"\n', 13)
82+
83+
84+
@pytest.mark.agent_authored(model="claude-fable-5-1")
85+
def test_rejects_a_floor_of_another_major():
86+
with pytest.raises(SystemExit, match="floor of another major for the cu13 extra"):
87+
tool.floor_from_metadata('Requires-Dist: cuda-bindings>=12.9.8,<14; extra == "cu13"\n', 13)
88+
89+
90+
@pytest.mark.agent_authored(model="claude-fable-5-1")
91+
def test_rejects_a_zip_that_is_not_a_wheel(tmp_path):
92+
with pytest.raises(SystemExit, match="contains 0 METADATA files"):
93+
tool.floor_from_wheel(_wheel(tmp_path, metadata=None), 13)
94+
95+
96+
@pytest.mark.agent_authored(model="claude-fable-5-1")
97+
def test_cli_prints_the_floor(tmp_path, capsys):
98+
assert tool.main(["--wheel", str(_wheel(tmp_path)), "--major", "13"]) == 0
99+
assert capsys.readouterr().out.strip() == "13.4.1"

0 commit comments

Comments
 (0)