Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@ __pycache__/

# CUDA Python specific
.cache/
.cython-stdlib
.cython-bindings
.lycheecache
.pytest_cache/
.benchmarks/
Expand Down
2 changes: 1 addition & 1 deletion .pre-commit-config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -73,7 +73,7 @@ repos:
files: '^cuda_core/tests/.*\.py$'

- id: check-build-hooks-sync
name: Check shared toolchain block is in sync across build_hooks.py files
name: Check shared build_hooks.py blocks are in sync
entry: python ./toolshed/check_build_hooks_sync.py
language: python
files: '^(cuda_bindings|cuda_core)/build_hooks\.py$'
Expand Down
181 changes: 167 additions & 14 deletions cuda_bindings/build_hooks.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,11 +11,13 @@
import contextlib
import functools
import glob
import hashlib
import os
import shutil
import sys
import sysconfig
import tempfile
import uuid
from pathlib import Path
from warnings import warn

Expand All @@ -31,7 +33,8 @@
get_requires_for_build_editable = _build_meta.get_requires_for_build_editable

# Note: There is no support guarantee for environment variables like
# CUDA_PYTHON_TOOLCHAIN, etc. They may be removed or changed in the future.
# CUDA_PYTHON_TOOLCHAIN, CUDA_PYTHON_CYTHON_CACHE_DIR, etc. They may be
# removed or changed in the future.

# Populated by _build_cuda_bindings(); consumed by setup.py.
_extensions = None
Expand Down Expand Up @@ -86,13 +89,14 @@ def _get_cuda_path() -> str:
# -----------------------------------------------------------------------
# Toolchain selection
#
# The helpers below (down to the end-of-shared-block marker) are duplicated
# verbatim in cuda_core/build_hooks.py. Keep them in sync. Only the
# per-package _resolve_toolchain() flag assembly that follows is package-
# specific (it differs because the two packages use different C++ standards
# and opt levels).

# --- begin shared toolchain helpers (keep in sync) ---
# There is one shared helper block below, duplicated verbatim in
# cuda_core/build_hooks.py (keep it in sync; enforced by
# toolshed/check_build_hooks_sync.py). It contains the toolchain helpers and
# the Cython cache helpers. Only the per-package _resolve_toolchain() flag
# assembly that follows the shared block is package-specific (it differs
# because the two packages use different C++ standards and opt levels).

# --- begin shared build helpers (keep in sync) ---
_TOOLCHAINS_LINUX = ("gnu", "llvm")
_TOOLCHAINS_WINDOWS = ("msvc",)
_TOOLCHAIN_COMPILERS = {
Expand Down Expand Up @@ -157,7 +161,132 @@ def _check_toolchain_available(name):
)


# --- end shared toolchain helpers ---
# === Cython generated-source cache (opt-in via CUDA_PYTHON_CYTHON_CACHE_DIR) ===
# Workaround for Cython issue #7532: Cython's native cache fingerprint omits
# `compiler_directives`, so builds with different directives (e.g. linetrace
# for coverage) could reuse stale generated C/C++ output. This helper
# namespaces the Cython cache by package and a digest of output-affecting
# build configuration so distinct configurations get distinct caches.
#
# Removal: once cython/cython#7532 is resolved in a released Cython version
# and cuda-python's minimum Cython version includes the fix, this helper
# and its workaround-specific tests can be deleted; cythonize() can then be
# called with `cache=<root>` (or `cache=True`) without per-config namespacing.
# See https://github.com/cython/cython/issues/7532
def _cython_cache_path(
package,
*,
compiler_directives=None,
compile_time_env=None,
language_level=None,
cplus=None,
debug=False,
cuda_major=None,
):
"""Return a per-configuration Cython cache directory, or None to disable caching.

Returns None when CUDA_PYTHON_CYTHON_CACHE_DIR is unset, so cythonize()
is called without ``cache=`` and existing workflows are unchanged.
"""
cache_root = os.environ.get("CUDA_PYTHON_CYTHON_CACHE_DIR")
if not cache_root:
return None
if sys.platform == "win32":
warn(
"CUDA_PYTHON_CYTHON_CACHE_DIR is set but Cython caching via symlinks "
"is not supported on Windows; caching will be disabled.",
stacklevel=2,
)
return None

h = hashlib.sha256()
h.update(package.encode("utf-8"))
# The Python version running cythonize affects generated C code
# (e.g. CYTHON_COMPRESS_STRINGS: zstd on 3.14, zlib on 3.12/3.13).
h.update(f"python={sys.version_info.major}.{sys.version_info.minor}".encode())

def _update(name, value):
h.update(name.encode("utf-8"))
h.update(repr(value).encode("utf-8"))

# compiler_directives are not in Cython's native fingerprint (#7532).
if compiler_directives:
for key in sorted(compiler_directives):
_update(f"directive:{key}", compiler_directives[key])
# compile_time_env, language_level, and cplus are already in Cython's
# fingerprint, but we include them so the namespace stays correct even
# if Cython's fingerprint logic changes.
if compile_time_env:
for key in sorted(compile_time_env):
_update(f"compile_time_env:{key}", compile_time_env[key])
if language_level is not None:
_update("language_level", language_level)
if cplus is not None:
_update("cplus", cplus)
# debug toggles gdb_debug in cythonize(), which affects generated code.
_update("debug", debug)
if cuda_major is not None:
_update("cuda_major", cuda_major)

return os.path.join(cache_root, f"{package}-{h.hexdigest()[:16]}")


@contextlib.contextmanager
def _stable_cython_alias(target: Path, alias: Path):
"""Atomically create a stable directory symlink alias for a Cython include tree.

Cython's cache fingerprint includes the absolute path of each resolved
.pxd dependency (via ``file_hash()``). PEP 517 build environments install
dependencies under randomized temporary prefixes, making those paths
unstable across runs. This context manager creates a fixed, worktree-
relative symlink so Cython sees a stable lexical path.

The symlink is created in the *package directory* (the directory containing
this build_hooks.py), not in the cwd, to keep aliases package-local and
avoid cross-package races.

alias must not already exist as a real file or directory; if it is a
symlink (including a dangling one) it is atomically replaced.

On exit the alias is removed only if it still points at ``target`` (a
racing replacement will not be deleted).

POSIX only: directory symlinks require no elevated privileges on Linux.
"""
# Resolve the *parent* directory (must exist), then append the name.
# We deliberately do not follow a symlink that may already sit at alias.
if not alias.is_absolute():
alias = Path(__file__).parent / alias
alias = alias.parent.resolve() / alias.name
target = target.resolve()

if alias.exists() and not alias.is_symlink():
raise RuntimeError(
f"Cannot create Cython include alias at {alias}: a real file or directory already exists there."
)

tmp_alias = alias.with_name(f".{alias.name}.{uuid.uuid4().hex[:8]}.tmp")
try:
os.symlink(target, tmp_alias, target_is_directory=True)
try:
os.replace(tmp_alias, alias)
except BaseException:
tmp_alias.unlink(missing_ok=True)
raise
rel = os.path.relpath(alias, start=Path.cwd())
yield rel
finally:
tmp_alias.unlink(missing_ok=True)
# Only remove the alias we created; leave it alone if something else
# has already replaced it (readlink will differ).
try:
if alias.is_symlink() and Path(os.readlink(alias)).resolve() == target:
alias.unlink()
except OSError:
pass


# --- end shared build helpers ---


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

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

_extensions = cythonize(
extensions,
nthreads=nthreads,
build_dir="." if compile_for_coverage else "build/cython",
cache_path = _cython_cache_path(
"cuda-bindings",
compiler_directives=cython_directives,
**extra_cythonize_kwargs,
language_level=3,
cplus=True,
debug=debug,
)

def _do_cythonize(cython_include_path):
global _extensions
_extensions = cythonize(
extensions,
nthreads=nthreads,
build_dir="." if compile_for_coverage else "build/cython",
compiler_directives=cython_directives,
include_path=cython_include_path,
cache=cache_path,
**extra_cythonize_kwargs,
)

if cache_path is not None:
# Alias Cython's bundled .pxd declarations under a stable worktree-relative
# path so Cython's cache fingerprint sees the same path on every run
# despite PEP 517 build environments landing under randomized temp prefixes.
stdlib_target = Path(Cython.__file__).parent / "Includes"
stdlib_alias = Path(__file__).parent / ".cython-stdlib"
with _stable_cython_alias(stdlib_target, stdlib_alias) as rel_stdlib:
_do_cythonize([".", rel_stdlib])
else:
_do_cythonize(["."])


# -----------------------------------------------------------------------
# PEP 517 build hooks
Expand Down
76 changes: 75 additions & 1 deletion cuda_bindings/tests/test_build_hooks.py
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@ def _load_build_hooks():

@pytest.fixture(autouse=True)
def _isolate_toolchain_env():
names = ("CUDA_PYTHON_TOOLCHAIN", "CC", "CXX", "LDSHARED")
names = ("CUDA_PYTHON_TOOLCHAIN", "CC", "CXX", "LDSHARED", "CUDA_PYTHON_CYTHON_CACHE_DIR")
original = {name: os.environ[name] for name in names if name in os.environ}
for name in names:
os.environ.pop(name, None)
Expand Down Expand Up @@ -221,3 +221,77 @@ def test_record_writes_stamp(self, stamp):
build_hooks.record_build_toolchain()
expected = "msvc" if sys.platform == "win32" else "gnu"
assert stamp.read_text().strip() == expected


# ---------------------------------------------------------------------------
# Cython cache path helper (workaround for cython/cython#7532)
#
# These tests cover the configuration-digest workaround in build_hooks.py.
# They can be deleted together with the `_cython_cache_path` helper once
# cython/cython#7532 is resolved in a released Cython version and
# cuda-python's minimum Cython version includes the fix.
# See https://github.com/cython/cython/issues/7532


_test_helpers_root = Path(__file__).parents[2] / "cuda_python_test_helpers"
if _test_helpers_root.is_dir() and str(_test_helpers_root) not in sys.path:
sys.path.insert(0, str(_test_helpers_root))

from cuda_python_test_helpers.cython_cache import POSIX_ONLY_CACHE, CythonAliasMixin, CythonCachePathMixin


class TestCythonCachePath(CythonCachePathMixin):
"""`_cython_cache_path` tests specific to cuda.bindings.

Inherits the common tests from CythonCachePathMixin; the mixin
covers the package-agnostic behavior. cuda.bindings does not pass
``compile_time_env`` or ``cuda_major``, so the debug-only partition is
tested here.
"""

build_hooks = build_hooks
package = "cuda-bindings"

@POSIX_ONLY_CACHE
@pytest.mark.agent_authored(model="grok-4.6")
def test_changed_debug_changes_namespace(self, monkeypatch, tmp_path):
"""``debug`` partitions the namespace."""
self._set_env(monkeypatch, str(tmp_path))
p1 = build_hooks._cython_cache_path("cuda-bindings", debug=False)
p2 = build_hooks._cython_cache_path("cuda-bindings", debug=True)
assert p1 != p2


class TestCythonCacheSmokeTest:
"""Real Cython cache miss/hit through `_cython_cache_path`.

The actual cythonize exercise lives in
``cuda_python_test_helpers.cython_cache`` so it is shared with
``cuda_core/tests/test_build_hooks.py``.
"""

@POSIX_ONLY_CACHE
@pytest.mark.agent_authored(model="grok-4.6")
def test_cache_miss_then_hit(self, monkeypatch, tmp_path, capsys):
from cuda_python_test_helpers.cython_cache import (
cython_cache_miss_then_hit,
)

cache_root = tmp_path / "cython-cache"
cache_root.mkdir()
monkeypatch.setenv("CUDA_PYTHON_CYTHON_CACHE_DIR", str(cache_root))

cache_path = build_hooks._cython_cache_path(
"cuda-bindings",
compiler_directives={"language_level": 3},
language_level=3,
cplus=True,
)
assert cache_path is not None
cython_cache_miss_then_hit(cache_path, tmp_path, capsys)


class TestCythonAlias(CythonAliasMixin):
"""`_stable_cython_alias` tests for cuda.bindings."""

build_hooks = build_hooks
Loading
Loading