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

@rparolin rparolin 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.

Check Status Tool Details Artifact
Secrets SKIP Pulse Secret Scanner Image pull denied — not authenticated to gitlab-master.nvidia.com:5005 (needs a PAT with read_registry) .plc/security/pulse-secret-scan.json (skip-stub)
SAST WARN Semgrep 1.157.0 Findings 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
Dependencies WARN osv-scanner / pip-audit Source 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
License SKIP pip-licenses No installed-distribution metadata in scope; no dependencies added. BlackDuck + OSRB remain authoritative .plc/security/pip-licenses.json (skip-stub)
Container N/A No container files in this change
Security Review PASS inline Reviewed 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 added inline in PR diff

rparolin and 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>
@rparolin rparolin added this to the cuda.core 1.2.0 milestone Jul 31, 2026
@rparolin rparolin added CI/CD CI/CD infrastructure packaging Anything related to wheels or Conda packages labels Jul 31, 2026
@copy-pr-bot

copy-pr-bot Bot commented Jul 31, 2026

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-actions github-actions Bot 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 kkraus14 July 31, 2026 18:00
@rparolin rparolin self-assigned this Jul 31, 2026
rparolin and 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

Comment thread cuda_core/build_hooks.py

# 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).

Comment thread cuda_core/build_hooks.py
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
if previous is not None and previous != cuda_major:
if previous != cuda_major:

Comment thread cuda_core/build_hooks.py
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 free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

CI/CD CI/CD infrastructure cuda.bindings Everything related to the cuda.bindings module cuda.core Everything related to the cuda.core module packaging Anything related to wheels or Conda packages

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants