Skip to content

Commit 2f44c6b

Browse files
authored
build: add CUDA_PYTHON_TOOLCHAIN override for compiler/linker selection (#2903)
Switch compiler and linker as a unit (gnu/llvm on Linux, msvc on Windows). Defaults preserve current builds; stamps force rebuilds when config changes.
1 parent 36e4d40 commit 2f44c6b

8 files changed

Lines changed: 967 additions & 126 deletions

File tree

‎.pre-commit-config.yaml‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,13 @@ repos:
7272
language: python
7373
files: '^cuda_core/tests/.*\.py$'
7474

75+
- id: check-build-hooks-sync
76+
name: Check shared toolchain block is in sync across build_hooks.py files
77+
entry: python ./toolshed/check_build_hooks_sync.py
78+
language: python
79+
files: '^(cuda_bindings|cuda_core)/build_hooks\.py$'
80+
pass_filenames: false
81+
7582
- id: no-markdown-in-docs-source
7683
name: Prevent markdown files in docs/source directories
7784
entry: bash -c

‎cuda_bindings/build_hooks.py‎

Lines changed: 192 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,9 @@
3030
get_requires_for_build_wheel = _build_meta.get_requires_for_build_wheel
3131
get_requires_for_build_editable = _build_meta.get_requires_for_build_editable
3232

33+
# Note: There is no support guarantee for environment variables like
34+
# CUDA_PYTHON_TOOLCHAIN, etc. They may be removed or changed in the future.
35+
3336
# Populated by _build_cuda_bindings(); consumed by setup.py.
3437
_extensions = None
3538

@@ -80,6 +83,180 @@ def _get_cuda_path() -> str:
8083
return cuda_path
8184

8285

86+
# -----------------------------------------------------------------------
87+
# Toolchain selection
88+
#
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) ---
96+
_TOOLCHAINS_LINUX = ("gnu", "llvm")
97+
_TOOLCHAINS_WINDOWS = ("msvc",)
98+
_TOOLCHAIN_COMPILERS = {
99+
"gnu": ("gcc", "g++"),
100+
"llvm": ("clang", "clang++"),
101+
"msvc": (None, None),
102+
}
103+
104+
105+
def _resolve_toolchain_name():
106+
"""Read CUDA_PYTHON_TOOLCHAIN, validate it, return (name, allowed, cc, cxx).
107+
108+
The default toolchain (gnu on Linux, msvc on Windows) is the first entry
109+
of the platform's allowed tuple. cc/cxx are the compiler binaries for the
110+
toolchain (None for msvc, which distutils discovers via the MSVC env).
111+
"""
112+
if sys.platform == "win32":
113+
platform_key, allowed = "win32", _TOOLCHAINS_WINDOWS
114+
else:
115+
platform_key, allowed = "linux", _TOOLCHAINS_LINUX
116+
name = os.environ.get("CUDA_PYTHON_TOOLCHAIN", allowed[0]).strip().lower()
117+
if name not in allowed:
118+
raise RuntimeError(
119+
f"CUDA_PYTHON_TOOLCHAIN={name!r} is not supported on {platform_key}. Valid values: {', '.join(allowed)}."
120+
)
121+
cc, cxx = _TOOLCHAIN_COMPILERS[name]
122+
explicit = bool(os.environ.get("CUDA_PYTHON_TOOLCHAIN", "").strip())
123+
return name, allowed, cc, cxx, explicit
124+
125+
126+
def _apply_toolchain_env(cc, cxx, explicit):
127+
"""Set CC/CXX/LDSHARED for an explicitly-chosen toolchain.
128+
129+
The default path (CUDA_PYTHON_TOOLCHAIN unset) intentionally
130+
does not touch the env, so an externally-set compiler (e.g.
131+
CC="sccache cc" in CI) keeps working. An explicit CUDA_PYTHON_TOOLCHAIN
132+
override (incl. =gnu) governs the compiler and overrides CC/CXX/LDSHARED.
133+
"""
134+
if explicit and cc is not None:
135+
os.environ["CC"] = cc
136+
os.environ["CXX"] = cxx
137+
os.environ["LDSHARED"] = f"{cxx} -shared"
138+
139+
140+
def _check_toolchain_available(name):
141+
"""Preflight: verify the selected toolchain's tools are on PATH.
142+
143+
No-op for the platform default (distutils discovers those). For llvm,
144+
probes clang, clang++, and ld.lld so a missing toolchain fails fast with a
145+
helpful message instead of a cryptic compile error.
146+
"""
147+
if name != "llvm":
148+
return
149+
tools = ("clang", "clang++", "ld.lld")
150+
missing = [t for t in tools if shutil.which(t) is None]
151+
if missing:
152+
raise RuntimeError(
153+
f"CUDA_PYTHON_TOOLCHAIN=llvm but required tool(s) not found on PATH: "
154+
f"{', '.join(missing)}. Install clang and lld "
155+
f"(e.g. `apt install clang lld` or `dnf install clang lld`) "
156+
f"or set CUDA_PYTHON_TOOLCHAIN=gnu."
157+
)
158+
159+
160+
# --- end shared toolchain helpers ---
161+
162+
163+
def _resolve_toolchain(debug=False, compile_for_coverage=False):
164+
"""Resolve the C/C++ toolchain from CUDA_PYTHON_TOOLCHAIN.
165+
166+
Returns (name, cc, cxx, extra_compile_args, extra_link_args). The default
167+
toolchain (gnu on Linux, msvc on Windows) reproduces the previous build
168+
behavior and does not touch CC/CXX/LDSHARED, so an externally-set compiler
169+
(e.g. CC="sccache cc") keeps working. A non-default toolchain (llvm on
170+
Linux) selects clang/clang++ and lld and sets CC/CXX/LDSHARED so distutils'
171+
customize_compiler picks them up.
172+
"""
173+
name, _allowed, cc, cxx, explicit = _resolve_toolchain_name()
174+
175+
extra_compile_args = []
176+
extra_link_args = []
177+
178+
if name == "msvc":
179+
if debug:
180+
raise RuntimeError("Debuggable builds are not supported on Windows.")
181+
else:
182+
# Common Linux compile flags.
183+
extra_compile_args += ["-std=c++14", "-Wno-deprecated-declarations"]
184+
# Compiler-specific flags.
185+
if name == "gnu":
186+
extra_compile_args += ["-fpermissive", "-fno-var-tracking-assignments"]
187+
elif name == "llvm":
188+
extra_link_args += ["-fuse-ld=lld"]
189+
# Common Linux debug/opt flags.
190+
if debug:
191+
extra_compile_args += ["-g", "-O0", "-D _GLIBCXX_ASSERTIONS"]
192+
else:
193+
extra_compile_args += ["-g0", "-O3"]
194+
extra_link_args += ["-Wl,--strip-all"]
195+
196+
if compile_for_coverage:
197+
# CYTHON_TRACE_NOGIL indicates to trace nogil functions. It is not
198+
# related to free-threading builds.
199+
extra_compile_args += ["-DCYTHON_TRACE_NOGIL=1", "-DCYTHON_USE_SYS_MONITORING=0"]
200+
201+
_apply_toolchain_env(cc, cxx, explicit)
202+
203+
return name, cc, cxx, extra_compile_args, extra_link_args
204+
205+
206+
# -----------------------------------------------------------------------
207+
# Toolchain stamp
208+
209+
_BUILD_DIR = Path(__file__).parent / "build"
210+
211+
212+
def _abi_stamp_path(stem):
213+
"""Return a stamp path scoped to this interpreter's extension ABI."""
214+
extension_suffix = sysconfig.get_config_var("EXT_SUFFIX")
215+
if not extension_suffix:
216+
raise RuntimeError("Python's EXT_SUFFIX build configuration is unavailable")
217+
return _BUILD_DIR / f"{stem}{extension_suffix}"
218+
219+
220+
# Records the toolchain of the last completed build for this extension ABI,
221+
# so setup.py can force build_ext when it changes. Written by
222+
# record_build_toolchain().
223+
_BUILD_TOOLCHAIN_STAMP = _abi_stamp_path(".build-toolchain")
224+
225+
force_build_ext = False
226+
227+
228+
def _check_build_toolchain(toolchain):
229+
"""Set force_build_ext when the toolchain changed since the last build.
230+
231+
Setuptools' freshness check does not include the extension flags, so a
232+
stale .so compiled by a previous toolchain would otherwise be packaged.
233+
"""
234+
global force_build_ext
235+
236+
try:
237+
previous = _BUILD_TOOLCHAIN_STAMP.read_text(encoding="utf-8").strip()
238+
except FileNotFoundError:
239+
previous = None
240+
241+
# A missing stamp means the last build's toolchain is unknown, so force too.
242+
# On a first build that costs nothing: there are no artifacts to reuse.
243+
if previous != toolchain:
244+
print(f"Toolchain of last build: {previous} (building {toolchain}); forcing a full rebuild")
245+
force_build_ext = True
246+
247+
248+
def record_build_toolchain() -> None:
249+
"""Stamp the toolchain of the build that just completed.
250+
251+
setup.py calls this after build_ext succeeds, so that a build which failed
252+
partway through does not claim outputs it never produced. Re-derives the
253+
toolchain name from the environment rather than caching it in a global.
254+
"""
255+
name, *_ = _resolve_toolchain_name()
256+
_BUILD_TOOLCHAIN_STAMP.parent.mkdir(parents=True, exist_ok=True)
257+
_BUILD_TOOLCHAIN_STAMP.write_text(name + "\n", encoding="utf-8")
258+
259+
83260
# -----------------------------------------------------------------------
84261
# Extension preparation helpers
85262

@@ -155,6 +332,17 @@ def _build_cuda_bindings(debug=False):
155332

156333
compile_for_coverage = bool(int(os.environ.get("CUDA_PYTHON_COVERAGE", "0")))
157334

335+
# Resolve the C/C++ toolchain (CUDA_PYTHON_TOOLCHAIN). The default (gnu on
336+
# Linux, msvc on Windows) reproduces the previous build behavior and does
337+
# not touch CC/CXX, so an externally-set compiler (e.g. sccache) survives.
338+
toolchain, _cc, _cxx, extra_compile_args, extra_link_args = _resolve_toolchain(
339+
debug=debug, compile_for_coverage=compile_for_coverage
340+
)
341+
_check_toolchain_available(toolchain)
342+
extra_cythonize_kwargs = {}
343+
if debug and sys.platform != "win32":
344+
extra_cythonize_kwargs["gdb_debug"] = True
345+
158346
# Prepare compile/link arguments
159347
include_path_list = [os.path.join(cuda_path, "include")]
160348
include_dirs = [
@@ -167,31 +355,6 @@ def _build_cuda_bindings(debug=False):
167355
cudalib_subdirs = ["lib64", "lib"]
168356
library_dirs.extend(os.path.join(cuda_path, subdir) for subdir in cudalib_subdirs)
169357

170-
extra_compile_args = []
171-
extra_link_args = []
172-
extra_cythonize_kwargs = {}
173-
if sys.platform == "win32":
174-
if debug:
175-
raise RuntimeError("Debuggable builds are not supported on Windows.")
176-
else:
177-
extra_compile_args += [
178-
"-std=c++14",
179-
"-fpermissive",
180-
"-Wno-deprecated-declarations",
181-
"-fno-var-tracking-assignments",
182-
]
183-
if debug:
184-
extra_cythonize_kwargs["gdb_debug"] = True
185-
extra_compile_args += ["-g", "-O0"]
186-
extra_compile_args += ["-D _GLIBCXX_ASSERTIONS"]
187-
else:
188-
extra_compile_args += ["-g0", "-O3"]
189-
extra_link_args += ["-Wl,--strip-all"]
190-
if compile_for_coverage:
191-
# CYTHON_TRACE_NOGIL indicates to trace nogil functions. It is not
192-
# related to free-threading builds.
193-
extra_compile_args += ["-DCYTHON_TRACE_NOGIL=1", "-DCYTHON_USE_SYS_MONITORING=0"]
194-
195358
# Rename architecture-specific files
196359
dst_files = _rename_architecture_specific_files()
197360

@@ -236,6 +399,10 @@ def get_static_libraries(f):
236399
if compile_for_coverage:
237400
cython_directives["linetrace"] = True
238401

402+
# Force a full rebuild when the toolchain changed since the last successful
403+
# build, so a stale .so from a previous toolchain is never packaged.
404+
_check_build_toolchain(toolchain)
405+
239406
_extensions = cythonize(
240407
extensions,
241408
nthreads=nthreads,

‎cuda_bindings/setup.py‎

Lines changed: 6 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,8 @@
11
# SPDX-FileCopyrightText: Copyright (c) 2021-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
2+
#
23
# SPDX-License-Identifier: Apache-2.0
34

4-
import functools
55
import os
6-
import subprocess
76
from warnings import warn
87

98
import build_hooks
@@ -24,29 +23,16 @@
2423
coverage_mode = bool(int(os.environ.get("CUDA_PYTHON_COVERAGE", "0")))
2524

2625

27-
def _is_clang(compiler):
28-
@functools.lru_cache
29-
def _check(compiler_cxx):
30-
try:
31-
output = subprocess.check_output([*compiler_cxx, "--version"]) # noqa: S603
32-
except subprocess.CalledProcessError:
33-
return False
34-
lines = output.decode().splitlines()
35-
return len(lines) > 0 and "clang" in lines[0]
36-
37-
if not hasattr(compiler, "compiler_cxx"):
38-
return False
39-
return _check(tuple(compiler.compiler_cxx))
40-
41-
4226
class build_ext(_build_ext):
4327
def build_extensions(self):
4428
if nthreads > 0:
4529
self.parallel = nthreads
46-
if _is_clang(self.compiler):
47-
for ext in self.extensions:
48-
ext.extra_compile_args = [a for a in ext.extra_compile_args if a != "-fno-var-tracking-assignments"]
30+
# A stale .so from a previous toolchain looks perfectly fresh;
31+
# see build_hooks._check_build_toolchain().
32+
if build_hooks.force_build_ext:
33+
self.force = True
4934
super().build_extensions()
35+
build_hooks.record_build_toolchain()
5036

5137

5238
class build_py(_build_py):

0 commit comments

Comments
 (0)