Skip to content

Commit 41c8db9

Browse files
committed
build: add opt-in Cython generated-source cache
Add an opt-in Cython cache behind CUDA_PYTHON_CYTHON_CACHE_DIR for cuda.bindings and cuda.core builds. When unset, cythonize() is called without cache= and builds are unchanged. POSIX only; Windows returns None with a warning. _cython_cache_path namespaces the cache by package, Python version, and a SHA-256 of output-affecting config (compiler_directives, compile_time_env, language_level, cplus, debug, cuda_major). This works around cython/cython#7532 (Cython omits compiler_directives from its native fingerprint); the helper and its tests can be removed once that issue is fixed in a released Cython version covered by cuda-python's minimum. _stable_cython_alias creates package-local directory symlinks (.cython-stdlib, and .cython-bindings for cuda.core) before cythonize() and removes them in finally, giving Cython stable relative include paths across PEP 517 builds that install deps under randomized temp prefixes. The two helpers are vendored in both build_hooks.py files (PEP 517 isolation forbids a shared import) and kept in sync by the existing pre-commit hook toolshed/check_build_hooks_sync.py, now covering one merged "shared build helpers" block instead of the toolchain-only block. No runtime drift test. Tests live in cuda_python_test_helpers/cython_cache.py (shared mixins + miss/hit smoke test + cross-isolated-env cache-hit regression) and are exercised by both packages' tests/test_build_hooks.py. POSIX-only tests skip on Windows; a Windows-only test asserts the set-env warn+None path.
1 parent f0a1b78 commit 41c8db9

7 files changed

Lines changed: 583 additions & 52 deletions

File tree

‎.gitignore‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,8 @@ __pycache__/
88

99
# CUDA Python specific
1010
.cache/
11+
.cython-stdlib
12+
.cython-bindings
1113
.lycheecache
1214
.pytest_cache/
1315
.benchmarks/

‎.pre-commit-config.yaml‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -73,7 +73,7 @@ repos:
7373
files: '^cuda_core/tests/.*\.py$'
7474

7575
- id: check-build-hooks-sync
76-
name: Check shared toolchain block is in sync across build_hooks.py files
76+
name: Check shared build_hooks.py blocks are in sync
7777
entry: python ./toolshed/check_build_hooks_sync.py
7878
language: python
7979
files: '^(cuda_bindings|cuda_core)/build_hooks\.py$'

‎cuda_bindings/build_hooks.py‎

Lines changed: 167 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -11,11 +11,13 @@
1111
import contextlib
1212
import functools
1313
import glob
14+
import hashlib
1415
import os
1516
import shutil
1617
import sys
1718
import sysconfig
1819
import tempfile
20+
import uuid
1921
from pathlib import Path
2022
from warnings import warn
2123

@@ -31,7 +33,8 @@
3133
get_requires_for_build_editable = _build_meta.get_requires_for_build_editable
3234

3335
# Note: There is no support guarantee for environment variables like
34-
# CUDA_PYTHON_TOOLCHAIN, etc. They may be removed or changed in the future.
36+
# CUDA_PYTHON_TOOLCHAIN, CUDA_PYTHON_CYTHON_CACHE_DIR, etc. They may be
37+
# removed or changed in the future.
3538

3639
# Populated by _build_cuda_bindings(); consumed by setup.py.
3740
_extensions = None
@@ -86,13 +89,14 @@ def _get_cuda_path() -> str:
8689
# -----------------------------------------------------------------------
8790
# Toolchain selection
8891
#
89-
# The helpers below (down to the end-of-shared-block marker) are duplicated
90-
# verbatim in cuda_core/build_hooks.py. Keep them in sync. Only the
91-
# per-package _resolve_toolchain() flag assembly that follows is package-
92-
# specific (it differs because the two packages use different C++ standards
93-
# and opt levels).
94-
95-
# --- begin shared toolchain helpers (keep in sync) ---
92+
# There is one shared helper block below, duplicated verbatim in
93+
# cuda_core/build_hooks.py (keep it in sync; enforced by
94+
# toolshed/check_build_hooks_sync.py). It contains the toolchain helpers and
95+
# the Cython cache helpers. Only the per-package _resolve_toolchain() flag
96+
# assembly that follows the shared block is package-specific (it differs
97+
# because the two packages use different C++ standards and opt levels).
98+
99+
# --- begin shared build helpers (keep in sync) ---
96100
_TOOLCHAINS_LINUX = ("gnu", "llvm")
97101
_TOOLCHAINS_WINDOWS = ("msvc",)
98102
_TOOLCHAIN_COMPILERS = {
@@ -157,7 +161,132 @@ def _check_toolchain_available(name):
157161
)
158162

159163

160-
# --- end shared toolchain helpers ---
164+
# === Cython generated-source cache (opt-in via CUDA_PYTHON_CYTHON_CACHE_DIR) ===
165+
# Workaround for Cython issue #7532: Cython's native cache fingerprint omits
166+
# `compiler_directives`, so builds with different directives (e.g. linetrace
167+
# for coverage) could reuse stale generated C/C++ output. This helper
168+
# namespaces the Cython cache by package and a digest of output-affecting
169+
# build configuration so distinct configurations get distinct caches.
170+
#
171+
# Removal: once cython/cython#7532 is resolved in a released Cython version
172+
# and cuda-python's minimum Cython version includes the fix, this helper
173+
# and its workaround-specific tests can be deleted; cythonize() can then be
174+
# called with `cache=<root>` (or `cache=True`) without per-config namespacing.
175+
# See https://github.com/cython/cython/issues/7532
176+
def _cython_cache_path(
177+
package,
178+
*,
179+
compiler_directives=None,
180+
compile_time_env=None,
181+
language_level=None,
182+
cplus=None,
183+
debug=False,
184+
cuda_major=None,
185+
):
186+
"""Return a per-configuration Cython cache directory, or None to disable caching.
187+
188+
Returns None when CUDA_PYTHON_CYTHON_CACHE_DIR is unset, so cythonize()
189+
is called without ``cache=`` and existing workflows are unchanged.
190+
"""
191+
cache_root = os.environ.get("CUDA_PYTHON_CYTHON_CACHE_DIR")
192+
if not cache_root:
193+
return None
194+
if sys.platform == "win32":
195+
warn(
196+
"CUDA_PYTHON_CYTHON_CACHE_DIR is set but Cython caching via symlinks "
197+
"is not supported on Windows; caching will be disabled.",
198+
stacklevel=2,
199+
)
200+
return None
201+
202+
h = hashlib.sha256()
203+
h.update(package.encode("utf-8"))
204+
# The Python version running cythonize affects generated C code
205+
# (e.g. CYTHON_COMPRESS_STRINGS: zstd on 3.14, zlib on 3.12/3.13).
206+
h.update(f"python={sys.version_info.major}.{sys.version_info.minor}".encode())
207+
208+
def _update(name, value):
209+
h.update(name.encode("utf-8"))
210+
h.update(repr(value).encode("utf-8"))
211+
212+
# compiler_directives are not in Cython's native fingerprint (#7532).
213+
if compiler_directives:
214+
for key in sorted(compiler_directives):
215+
_update(f"directive:{key}", compiler_directives[key])
216+
# compile_time_env, language_level, and cplus are already in Cython's
217+
# fingerprint, but we include them so the namespace stays correct even
218+
# if Cython's fingerprint logic changes.
219+
if compile_time_env:
220+
for key in sorted(compile_time_env):
221+
_update(f"compile_time_env:{key}", compile_time_env[key])
222+
if language_level is not None:
223+
_update("language_level", language_level)
224+
if cplus is not None:
225+
_update("cplus", cplus)
226+
# debug toggles gdb_debug in cythonize(), which affects generated code.
227+
_update("debug", debug)
228+
if cuda_major is not None:
229+
_update("cuda_major", cuda_major)
230+
231+
return os.path.join(cache_root, f"{package}-{h.hexdigest()[:16]}")
232+
233+
234+
@contextlib.contextmanager
235+
def _stable_cython_alias(target: Path, alias: Path):
236+
"""Atomically create a stable directory symlink alias for a Cython include tree.
237+
238+
Cython's cache fingerprint includes the absolute path of each resolved
239+
.pxd dependency (via ``file_hash()``). PEP 517 build environments install
240+
dependencies under randomized temporary prefixes, making those paths
241+
unstable across runs. This context manager creates a fixed, worktree-
242+
relative symlink so Cython sees a stable lexical path.
243+
244+
The symlink is created in the *package directory* (the directory containing
245+
this build_hooks.py), not in the cwd, to keep aliases package-local and
246+
avoid cross-package races.
247+
248+
alias must not already exist as a real file or directory; if it is a
249+
symlink (including a dangling one) it is atomically replaced.
250+
251+
On exit the alias is removed only if it still points at ``target`` (a
252+
racing replacement will not be deleted).
253+
254+
POSIX only: directory symlinks require no elevated privileges on Linux.
255+
"""
256+
# Resolve the *parent* directory (must exist), then append the name.
257+
# We deliberately do not follow a symlink that may already sit at alias.
258+
if not alias.is_absolute():
259+
alias = Path(__file__).parent / alias
260+
alias = alias.parent.resolve() / alias.name
261+
target = target.resolve()
262+
263+
if alias.exists() and not alias.is_symlink():
264+
raise RuntimeError(
265+
f"Cannot create Cython include alias at {alias}: a real file or directory already exists there."
266+
)
267+
268+
tmp_alias = alias.with_name(f".{alias.name}.{uuid.uuid4().hex[:8]}.tmp")
269+
try:
270+
os.symlink(target, tmp_alias, target_is_directory=True)
271+
try:
272+
os.replace(tmp_alias, alias)
273+
except BaseException:
274+
tmp_alias.unlink(missing_ok=True)
275+
raise
276+
rel = os.path.relpath(alias, start=Path.cwd())
277+
yield rel
278+
finally:
279+
tmp_alias.unlink(missing_ok=True)
280+
# Only remove the alias we created; leave it alone if something else
281+
# has already replaced it (readlink will differ).
282+
try:
283+
if alias.is_symlink() and Path(os.readlink(alias)).resolve() == target:
284+
alias.unlink()
285+
except OSError:
286+
pass
287+
288+
289+
# --- end shared build helpers ---
161290

162291

163292
def _resolve_toolchain(debug=False, compile_for_coverage=False):
@@ -313,6 +442,7 @@ def _build_cuda_bindings(debug=False):
313442
All CUDA-dependent logic (cythonization) is deferred to this function so
314443
that metadata queries do not require a CUDA toolkit installation.
315444
"""
445+
import Cython
316446
from Cython.Build import cythonize
317447
from Cython.Compiler import Options as _CythonOptions
318448

@@ -403,14 +533,37 @@ def get_static_libraries(f):
403533
# build, so a stale .so from a previous toolchain is never packaged.
404534
_check_build_toolchain(toolchain)
405535

406-
_extensions = cythonize(
407-
extensions,
408-
nthreads=nthreads,
409-
build_dir="." if compile_for_coverage else "build/cython",
536+
cache_path = _cython_cache_path(
537+
"cuda-bindings",
410538
compiler_directives=cython_directives,
411-
**extra_cythonize_kwargs,
539+
language_level=3,
540+
cplus=True,
541+
debug=debug,
412542
)
413543

544+
def _do_cythonize(cython_include_path):
545+
global _extensions
546+
_extensions = cythonize(
547+
extensions,
548+
nthreads=nthreads,
549+
build_dir="." if compile_for_coverage else "build/cython",
550+
compiler_directives=cython_directives,
551+
include_path=cython_include_path,
552+
cache=cache_path,
553+
**extra_cythonize_kwargs,
554+
)
555+
556+
if cache_path is not None:
557+
# Alias Cython's bundled .pxd declarations under a stable worktree-relative
558+
# path so Cython's cache fingerprint sees the same path on every run
559+
# despite PEP 517 build environments landing under randomized temp prefixes.
560+
stdlib_target = Path(Cython.__file__).parent / "Includes"
561+
stdlib_alias = Path(__file__).parent / ".cython-stdlib"
562+
with _stable_cython_alias(stdlib_target, stdlib_alias) as rel_stdlib:
563+
_do_cythonize([".", rel_stdlib])
564+
else:
565+
_do_cythonize(["."])
566+
414567

415568
# -----------------------------------------------------------------------
416569
# PEP 517 build hooks

‎cuda_bindings/tests/test_build_hooks.py‎

Lines changed: 75 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,7 @@ def _load_build_hooks():
4141

4242
@pytest.fixture(autouse=True)
4343
def _isolate_toolchain_env():
44-
names = ("CUDA_PYTHON_TOOLCHAIN", "CC", "CXX", "LDSHARED")
44+
names = ("CUDA_PYTHON_TOOLCHAIN", "CC", "CXX", "LDSHARED", "CUDA_PYTHON_CYTHON_CACHE_DIR")
4545
original = {name: os.environ[name] for name in names if name in os.environ}
4646
for name in names:
4747
os.environ.pop(name, None)
@@ -221,3 +221,77 @@ def test_record_writes_stamp(self, stamp):
221221
build_hooks.record_build_toolchain()
222222
expected = "msvc" if sys.platform == "win32" else "gnu"
223223
assert stamp.read_text().strip() == expected
224+
225+
226+
# ---------------------------------------------------------------------------
227+
# Cython cache path helper (workaround for cython/cython#7532)
228+
#
229+
# These tests cover the configuration-digest workaround in build_hooks.py.
230+
# They can be deleted together with the `_cython_cache_path` helper once
231+
# cython/cython#7532 is resolved in a released Cython version and
232+
# cuda-python's minimum Cython version includes the fix.
233+
# See https://github.com/cython/cython/issues/7532
234+
235+
236+
_test_helpers_root = Path(__file__).parents[2] / "cuda_python_test_helpers"
237+
if _test_helpers_root.is_dir() and str(_test_helpers_root) not in sys.path:
238+
sys.path.insert(0, str(_test_helpers_root))
239+
240+
from cuda_python_test_helpers.cython_cache import POSIX_ONLY_CACHE, CythonAliasMixin, CythonCachePathMixin
241+
242+
243+
class TestCythonCachePath(CythonCachePathMixin):
244+
"""`_cython_cache_path` tests specific to cuda.bindings.
245+
246+
Inherits the common tests from CythonCachePathMixin; the mixin
247+
covers the package-agnostic behavior. cuda.bindings does not pass
248+
``compile_time_env`` or ``cuda_major``, so the debug-only partition is
249+
tested here.
250+
"""
251+
252+
build_hooks = build_hooks
253+
package = "cuda-bindings"
254+
255+
@POSIX_ONLY_CACHE
256+
@pytest.mark.agent_authored(model="grok-4.6")
257+
def test_changed_debug_changes_namespace(self, monkeypatch, tmp_path):
258+
"""``debug`` partitions the namespace."""
259+
self._set_env(monkeypatch, str(tmp_path))
260+
p1 = build_hooks._cython_cache_path("cuda-bindings", debug=False)
261+
p2 = build_hooks._cython_cache_path("cuda-bindings", debug=True)
262+
assert p1 != p2
263+
264+
265+
class TestCythonCacheSmokeTest:
266+
"""Real Cython cache miss/hit through `_cython_cache_path`.
267+
268+
The actual cythonize exercise lives in
269+
``cuda_python_test_helpers.cython_cache`` so it is shared with
270+
``cuda_core/tests/test_build_hooks.py``.
271+
"""
272+
273+
@POSIX_ONLY_CACHE
274+
@pytest.mark.agent_authored(model="grok-4.6")
275+
def test_cache_miss_then_hit(self, monkeypatch, tmp_path, capsys):
276+
from cuda_python_test_helpers.cython_cache import (
277+
cython_cache_miss_then_hit,
278+
)
279+
280+
cache_root = tmp_path / "cython-cache"
281+
cache_root.mkdir()
282+
monkeypatch.setenv("CUDA_PYTHON_CYTHON_CACHE_DIR", str(cache_root))
283+
284+
cache_path = build_hooks._cython_cache_path(
285+
"cuda-bindings",
286+
compiler_directives={"language_level": 3},
287+
language_level=3,
288+
cplus=True,
289+
)
290+
assert cache_path is not None
291+
cython_cache_miss_then_hit(cache_path, tmp_path, capsys)
292+
293+
294+
class TestCythonAlias(CythonAliasMixin):
295+
"""`_stable_cython_alias` tests for cuda.bindings."""
296+
297+
build_hooks = build_hooks

0 commit comments

Comments
 (0)