Skip to content

build: make Cython/extension artifacts CUDA-major aware - #2472

Open
rparolin wants to merge 5 commits into
NVIDIA:mainfrom
rparolin:build/cuda-major-aware-artifacts
Open

build: make Cython/extension artifacts CUDA-major aware#2472
rparolin wants to merge 5 commits into
NVIDIA:mainfrom
rparolin:build/cuda-major-aware-artifacts

Conversation

@rparolin

@rparolinrparolin commented Jul 31, 2026

Copy link
Copy Markdown
Collaborator

What

Moving a checkout between the cu12 and cu13 pixi environments fails at compile time, pointing at code that is perfectly fine. Reproduced on main at 755cf61cu13 build, then cu12:

build/cython/cuda/core/_device_resources.cpp:4143:76:
error: 'CUdevWorkqueueConfigScope' was not declared in this scope

Nothing tells you the real cause: leftover generated C++ from the previous CUDA major.

This is the configuration-aware approach @kkraus14 asked for in #2443, in place of a destructive cleaner.

Why it happens

Two caches, neither of which notices the CUDA major changed.

Cython does not hash compile_time_env.cuda.core feeds CUDA_CORE_BUILD_MAJOR through it, so a cu13 -> cu12 switch silently reuses the cu13 generated C++. That is the error above.

setuptools' build_ext compares source mtimes against the output .so. In an editable install that .so lives in the source tree under a name keyed by the Python ABI tag alone — there is nowhere to record the CUDA major. On a cu12 -> cu13 -> cu12 round trip the final build finds an older generated source next to a newer .so and skips the rebuild entirely.

The change

Two things, both in cuda_core:

  • Generated sources go to build/cython/cu<major> instead of a single shared build/cython, so each major keeps its own cache.
  • build/.build-cuda-major records the last completed build (written after build_ext succeeds, so a failed build does not claim outputs it never produced). When it differs, setup.py sets build_ext.force.

The second is what handles the return leg, where directory keying can't help.

CUDA_PYTHON_COVERAGE is untouched, as requested — it still generates in-tree (build_dir=".") so it can package the generated sources.

Migration is self-healing. An existing unkeyed build/cython is orphaned and ignored; the first build after this change regenerates into a keyed directory and overwrites the in-tree extensions. No cleanup step required.

Verification

cu13 -> cu12 -> cu13 with pixi on linux-64. Before this PR the cu12 leg fails to compile. After, all three legs build, build/cython/cu12 and build/cython/cu13 both exist, and the extension from the second cu13 leg is byte-identical to the first (md5 1c53ab83a7201d54576175c912d03e93).

That last point is the load-bearing assertion. Leg 1's generated .cpp is older than leg 2's .so, so without the forced rebuild newer_group() would have skipped and left the cu12 artifact in place — and the build would still have "succeeded". Only the byte-identical result distinguishes a real rebuild from a wrong skip.

New build-identity-roundtrip job in ci-pixi-source-test.yml runs that sequence on every relevant PR. 18 unit tests cover the stamp logic; pre-commit clean.

Scope

Deliberately narrow — the CUDA major is the only axis keyed, and only cuda_core is touched.

  • cuda_bindings is not changed. It has no compile_time_env, so its generated C++ is CUDA-major independent, and it cannot be source-built against CUDA 12 at all today (its sources reference CUatomicOperation, nvrtcBundledHeadersInfo, CUstreamCigCaptureParams). That is a separate pre-existing problem, worth its own issue; I can file it.
  • debug and coverage are not keyed. Both change the compiled output, and toggling either has a similar staleness problem — but it is a different defect from the one this PR is titled for. Worth noting for anyone tempted to add them: gdb_debug=True does not change the generated C++ (I tested it — the .c is byte-identical; Cython only writes a side cython_debug/ directory), so keying a directory by it would only duplicate identical sources. linetrace genuinely does change the generated C++.
  • The cu12 leg resolves published cuda-bindings 12.x, not the local checkout — cuda_core's cu12 environment omits local-deps by design.

Relationship to #2443

#2443 proposed pixi run clean as the workflow. Per review feedback, this PR makes the artifacts configuration-aware so cleanup is not the correctness boundary. #2443 can stand down to narrowed recovery tooling.


PLC Local Security Evidence (Advisory)

Local advisory checks only. Authoritative release gates remain Pulse, SonarQube, Coverity, BlackDuck, nSpect, ScanSpect, OSRB, and Anchore — none of the rows below are release-gate results.

CheckStatusToolDetailsArtifact
SecretsSKIPPulse Secret ScannerImage pull denied — not authenticated to gitlab-master.nvidia.com:5005 (needs a PAT with read_registry).plc/security/pulse-secret-scan.json (skip-stub)
SASTWARNSemgrep 1.157.0Findings are pre-existing and untouched by this diff: .github/workflows/ci-pixi-source-test.yml run-shell-injection (the existing nightly full-test job), plus tempfile-without-flush / dangerous-subprocess-use-audit in cuda_bindings, which this PR no longer modifies.plc/security/semgrep.json
DependenciesWARNosv-scanner / pip-auditSource in a dependency-bearing ecosystem changed but no manifest or lockfile is in scope. The change adds no dependencies — the new code imports only the standard library — so there is nothing to resolve; recorded rather than suppressed.plc/security/dependency-*.json
LicenseSKIPpip-licensesNo installed-distribution metadata in scope; no dependencies added. BlackDuck + OSRB remain authoritative.plc/security/pip-licenses.json (skip-stub)
ContainerN/ANo container files in this change
Security ReviewPASSinlineReviewed the file-writing path: the stamp is written to a fixed relative path under build/, contents are a CUDA major string with no user-controlled input, and nothing is deleted. No shell invocation addedinline in PR diff

rparolinand others added 2 commits July 31, 2026 08:57
Moving a checkout between the cu12 and cu13 pixi environments failed at
compile time, pointing at code that is perfectly fine. Two independent
caches are to blame, and neither tool notices the configuration changed:
- Cython's up-to-date check hashes the .pyx and its cimport dependencies,
but not compile_time_env. cuda.core feeds CUDA_CORE_BUILD_MAJOR through
compile_time_env, so a cu13 -> cu12 switch silently reuses the cu13
generated C++. Reproduced on main: the cu12 build dies on
'CUdevWorkqueueConfigScope was not declared' in a build/cython/*.cpp
generated under CUDA 13.
- setuptools' build_ext compares source mtimes against the output .so. In
an editable install that .so lives in the source tree under a name keyed
by the Python ABI tag alone -- there is nowhere to record the CUDA major.
So on a cu12 -> cu13 -> cu12 round trip the final build finds an older
generated source next to a newer .so and skips the rebuild entirely.
Both build backends now compute a build identity (CUDA major, plus the
debug and coverage flags, which likewise change the generated C++ that
neither tool tracks). Generated sources go to build/cython/<identity>, and
build/.build-identity records the last completed build so setup.py can
force build_ext when the configuration changes. Python version and platform
stay out of the identity: setuptools already encodes them in its own
build/lib.* and build/temp.* names.
CUDA_PYTHON_COVERAGE keeps generating in-tree (build_dir=".") so it can
still package the generated sources; it only contributes to the identity.
Migration is self-healing. An existing unkeyed build/cython is orphaned and
ignored; the first build after this change regenerates into a keyed
directory and overwrites the in-tree extensions.
Verified end to end with pixi on linux-64: cu13 -> cu12 -> cu13 for
cuda_core. Before, the cu12 leg failed to compile; after, all three legs
succeed, each major keeps its own build/cython/cu1X-debug directory, and
the second cu13 extension is byte-identical to the first (md5
ae4090a4f66ab9cee67b5b2f64b43781), proving it was recompiled rather than
left as the cu12 artifact.
The new CI job covers cuda_core only. cuda_bindings cannot be source-built
in its cu12 environment at all -- the 13.x sources reference CUDA 13-only
symbols (CUatomicOperation, nvrtcBundledHeadersInfo,
CUstreamCigCaptureParams), so even freshly generated cu12 sources fail
against CUDA 12 headers. That is a pre-existing problem, unrelated to
artifact staleness; the identical identity logic in cuda_bindings is
covered by unit tests instead.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The two _determine_cuda_major_version implementations are annotated "keep in
sync"; a missing cuda.h surfaced as a bare FileNotFoundError in cuda_bindings
instead of the RuntimeError naming CUDA_PATH/CUDA_HOME.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@rparolinrparolin added this to the cuda.core 1.2.0 milestone Jul 31, 2026
@rparolinrparolin added CI/CD CI/CD infrastructure packaging Anything related to wheels or Conda packages labels Jul 31, 2026
@copy-pr-bot

Copy link
Copy Markdown
Contributor

Auto-sync is disabled for draft pull requests in this repository. Workflows must be run manually.

Contributors can view more details about this message here.

@github-actionsgithub-actionsBot added cuda.bindings Everything related to the cuda.bindings module cuda.core Everything related to the cuda.core module labels Jul 31, 2026
@rparolin
rparolin requested a review from kkraus14July 31, 2026 18:00
@rparolinrparolin self-assigned this Jul 31, 2026
rparolinand others added 2 commits July 31, 2026 11:36
Cuts the change to the two mechanisms that are actually load-bearing for the
cu12/cu13 problem, and drops everything that was speculative.
Removed the debug and coverage identity axes. The stated justification for
debug -- that gdb_debug changes the cythonize output -- is wrong: with
gdb_debug=True the generated .c is byte-identical, and Cython only writes a
side cython_debug/ directory. Keying by it produced a duplicate directory of
identical sources and re-ran cythonize on every editable/wheel switch for no
benefit. Coverage does change the generated C (linetrace), but toggling it is
a separate defect from the one this PR is about.
Removed the cuda_bindings half entirely. It has no compile_time_env, so its
generated C is CUDA-major independent, and it cannot be source-built against
CUDA 12 at all today -- the scenario the code guarded against is unreachable.
That also drops a second _determine_cuda_major_version, a duplicated 50-line
block, and a test file.
What remains: cuda_core generates into build/cython/cu<major>, and
build/.build-cuda-major records the last completed build so setup.py forces
build_ext when the major changes. The stamp is a bare major rather than a
composite identity string, so the "no build ran" guard and its test are gone
too.
Re-verified cu13 -> cu12 -> cu13 on linux-64: all three legs build, both
build/cython/cu12 and build/cython/cu13 exist, and the second cu13 extension
is byte-identical to the first (md5 1c53ab83a7201d54576175c912d03e93).
18 unit tests pass; pre-commit clean.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Author's call. The convention stays in CLAUDE.md and on the ~30 tests that
already carry it; only the four added by this PR are affected.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@rparolin
rparolin marked this pull request as ready for review July 31, 2026 19:20
@github-actions

Copy link
Copy Markdown


# Records the CUDA major of the last completed build, so setup.py can force
# build_ext when it changes. Written by record_build_major().
_BUILD_MAJOR_STAMP = os.path.join("build", ".build-cuda-major")

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This will be relative to pwd, and you can build a project from anywhere. It should instead be something like:

Path(__file__).parent / "build" / ".build-cuda-major"

(and we should use pathlib.Path for all new code, not the soft-deprecated os.path APIs).

except FileNotFoundError:
previous = None

if previous is not None and previous != cuda_major:

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

IIUC, if previous is None, we failed to determine whether we need a full rebuild, so we should do a full rebuild.

Suggested change
ifpreviousisnotNoneandprevious!=cuda_major:
ifprevious!=cuda_major:

setup.py calls this after build_ext succeeds, so that a build which failed
partway through does not claim outputs it never produced.
"""
os.makedirs(os.path.dirname(_BUILD_MAJOR_STAMP), exist_ok=True)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ditto for pathlib.

Comment on lines +126 to +168
build-identity-roundtrip:
name: "cu13 -> cu12 -> cu13 round trip (linux-64, CPU)"
if: ${{ github.event_name == 'pull_request' || github.event_name == 'workflow_dispatch' }}
runs-on: ubuntu-latest
timeout-minutes: 60
steps:
- name: Checkout ${{ github.event.repository.name }}
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
# Full history + tags so setuptools-scm derives the real (13.x)
# package version; a shallow checkout yields 0.1.dev1, which trips
# cuda.core's "cuda.bindings 12.x or 13.x must be installed" guard.
fetch-depth: 0

- name: Setup pixi
# Pinned to a commit SHA; install logic lives in the action and is
# auditable/pinned (vs. a curl|bash of an unverified installer).
uses: prefix-dev/setup-pixi@5185adfbffb4bd703da3010310260805d89ebb11 # v0.9.6
with:
pixi-version: ${{ env.PIXI_VERSION }}
run-install: false

- name: Build cu13, then cu12, then cu13 again in one checkout
run: |
for cuda_env in cu13 cu12 cu13; do
echo "::group::${cuda_env}"
pixi run --manifest-path cuda_core -e "${cuda_env}" \
python -c "import cuda.core; print('core import OK')"
echo "::endgroup::"
done
# The last build was cu13, and each major must have kept its own
# generated sources rather than overwriting the other's.
stamp=$(cat cuda_core/build/.build-cuda-major)
if [ "${stamp}" != "13" ]; then
echo "::error::build stamp is '${stamp}', expected 13"
exit 1
fi
for major in cu12 cu13; do
if [ ! -d "cuda_core/build/cython/${major}" ]; then
echo "::error::no ${major} generated-source directory"
exit 1
fi
done

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Maybe to address later -- but I worry about the time of this test for sort of a niche problem. Is there a way we could "simulate" a build doing the wrong thing rather than doing a full build?

Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

CI/CDCI/CD infrastructurecuda.bindingsEverything related to the cuda.bindings modulecuda.coreEverything related to the cuda.core modulepackagingAnything related to wheels or Conda packages

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants

@rparolin@mdboom