Skip to content

Commit 66c4f2c

Browse files
authored
fix: derive CUDA major version from headers for build (#1395)
* fix: derive CUDA major version from headers for build Fixes build failures when cuda-bindings reports a different major version than the CUDA headers being compiled against. The new _get_cuda_major_version() function is used for both: 1. Determining which cuda-bindings version to install as a build dependency 2. Setting CUDA_CORE_BUILD_MAJOR for Cython compile-time conditionals Version is derived from (in order of priority): 1. CUDA_CORE_BUILD_MAJOR env var (explicit override, e.g. in CI) 2. CUDA_VERSION macro in cuda.h from CUDA_PATH or CUDA_HOME Since CUDA_PATH or CUDA_HOME is required for the build anyway, the cuda.h header should always be available, ensuring consistency between the installed cuda-bindings and the compile-time conditionals. * Minor refactoring.
1 parent dfd4aff commit 66c4f2c

2 files changed

Lines changed: 187 additions & 42 deletions

File tree

cuda_core/build_hooks.py

Lines changed: 56 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,6 @@
1111
import glob
1212
import os
1313
import re
14-
import subprocess
1514

1615
from Cython.Build import cythonize
1716
from setuptools import Extension
@@ -26,32 +25,60 @@
2625

2726

2827
@functools.cache
29-
def _get_proper_cuda_bindings_major_version() -> str:
30-
# for local development (with/without build isolation)
31-
try:
32-
import cuda.bindings
28+
def _get_cuda_paths() -> list[str]:
29+
CUDA_PATH = os.environ.get("CUDA_PATH", os.environ.get("CUDA_HOME", None))
30+
if not CUDA_PATH:
31+
raise RuntimeError("Environment variable CUDA_PATH or CUDA_HOME is not set")
32+
CUDA_PATH = CUDA_PATH.split(os.pathsep)
33+
print("CUDA paths:", CUDA_PATH)
34+
return CUDA_PATH
3335

34-
return cuda.bindings.__version__.split(".")[0]
35-
except ImportError:
36-
pass
3736

38-
# for custom overwrite, e.g. in CI
37+
@functools.cache
38+
def _determine_cuda_major_version() -> str:
39+
"""Determine the CUDA major version for building cuda.core.
40+
41+
This version is used for two purposes:
42+
1. Determining which cuda-bindings version to install as a build dependency
43+
2. Setting CUDA_CORE_BUILD_MAJOR for Cython compile-time conditionals
44+
45+
The version is derived from (in order of priority):
46+
1. CUDA_CORE_BUILD_MAJOR environment variable (explicit override, e.g. in CI)
47+
2. CUDA_VERSION macro in cuda.h from CUDA_PATH or CUDA_HOME
48+
49+
Since CUDA_PATH or CUDA_HOME is required for the build (to provide include
50+
directories), the cuda.h header should always be available.
51+
"""
52+
# Explicit override, e.g. in CI.
3953
cuda_major = os.environ.get("CUDA_CORE_BUILD_MAJOR")
4054
if cuda_major is not None:
55+
print("CUDA MAJOR VERSION:", cuda_major)
4156
return cuda_major
4257

43-
# also for local development
44-
try:
45-
out = subprocess.run("nvidia-smi", env=os.environ, capture_output=True, check=True) # noqa: S603, S607
46-
m = re.search(r"CUDA Version:\s*([\d\.]+)", out.stdout.decode())
47-
if m:
48-
return m.group(1).split(".")[0]
49-
except (FileNotFoundError, subprocess.CalledProcessError):
50-
# the build machine has no driver installed
51-
pass
52-
53-
# default fallback
54-
return "13"
58+
# Derive from the CUDA headers (the authoritative source for what we compile against).
59+
cuda_path = _get_cuda_paths()
60+
for root in cuda_path:
61+
cuda_h = os.path.join(root, "include", "cuda.h")
62+
try:
63+
with open(cuda_h, encoding="utf-8") as f:
64+
for line in f:
65+
m = re.match(r"^#\s*define\s+CUDA_VERSION\s+(\d+)\s*$", line)
66+
if m:
67+
v = int(m.group(1))
68+
# CUDA_VERSION is e.g. 12020 for 12.2.
69+
cuda_major = str(v // 1000)
70+
print("CUDA MAJOR VERSION:", cuda_major)
71+
return cuda_major
72+
except OSError:
73+
continue
74+
75+
# CUDA_PATH or CUDA_HOME is required for the build, so we should not reach here
76+
# in normal circumstances. Raise an error to make the issue clear.
77+
raise RuntimeError(
78+
"Cannot determine CUDA major version. "
79+
"Set CUDA_CORE_BUILD_MAJOR environment variable, or ensure CUDA_PATH or CUDA_HOME "
80+
"points to a valid CUDA installation with include/cuda.h."
81+
)
5582

5683

5784
# used later by setup()
@@ -68,25 +95,12 @@ def _build_cuda_core():
6895

6996
# It seems setuptools' wildcard support has problems for namespace packages,
7097
# so we explicitly spell out all Extension instances.
71-
root_module = "cuda.core"
72-
root_path = f"{os.path.sep}".join(root_module.split(".")) + os.path.sep
73-
ext_files = glob.glob(f"{root_path}/**/*.pyx", recursive=True)
74-
75-
def strip_prefix_suffix(filename):
76-
return filename[len(root_path) : -4]
77-
78-
module_names = (strip_prefix_suffix(f) for f in ext_files)
79-
80-
@functools.cache
81-
def get_cuda_paths():
82-
CUDA_PATH = os.environ.get("CUDA_PATH", os.environ.get("CUDA_HOME", None))
83-
if not CUDA_PATH:
84-
raise RuntimeError("Environment variable CUDA_PATH or CUDA_HOME is not set")
85-
CUDA_PATH = CUDA_PATH.split(os.pathsep)
86-
print("CUDA paths:", CUDA_PATH)
87-
return CUDA_PATH
98+
def module_names():
99+
root_path = os.path.sep.join(["cuda", "core", ""])
100+
for filename in glob.glob(f"{root_path}/**/*.pyx", recursive=True):
101+
yield filename[len(root_path) : -4]
88102

89-
all_include_dirs = list(os.path.join(root, "include") for root in get_cuda_paths())
103+
all_include_dirs = list(os.path.join(root, "include") for root in _get_cuda_paths())
90104
extra_compile_args = []
91105
if COMPILE_FOR_COVERAGE:
92106
# CYTHON_TRACE_NOGIL indicates to trace nogil functions. It is not
@@ -101,11 +115,11 @@ def get_cuda_paths():
101115
language="c++",
102116
extra_compile_args=extra_compile_args,
103117
)
104-
for mod in module_names
118+
for mod in module_names()
105119
)
106120

107121
nthreads = int(os.environ.get("CUDA_PYTHON_PARALLEL_LEVEL", os.cpu_count() // 2))
108-
compile_time_env = {"CUDA_CORE_BUILD_MAJOR": int(_get_proper_cuda_bindings_major_version())}
122+
compile_time_env = {"CUDA_CORE_BUILD_MAJOR": int(_determine_cuda_major_version())}
109123
compiler_directives = {"embedsignature": True, "warn.deprecated.IF": False, "freethreading_compatible": True}
110124
if COMPILE_FOR_COVERAGE:
111125
compiler_directives["linetrace"] = True
@@ -132,7 +146,7 @@ def build_wheel(wheel_directory, config_settings=None, metadata_directory=None):
132146

133147

134148
def _get_cuda_bindings_require():
135-
cuda_major = _get_proper_cuda_bindings_major_version()
149+
cuda_major = _determine_cuda_major_version()
136150
return [f"cuda-bindings=={cuda_major}.*"]
137151

138152

Lines changed: 131 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,131 @@
1+
# SPDX-FileCopyrightText: Copyright (c) 2024-2025 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
2+
# SPDX-License-Identifier: Apache-2.0
3+
4+
"""Tests for build_hooks.py build infrastructure.
5+
6+
These tests verify the CUDA version detection logic used during builds,
7+
particularly the _determine_cuda_major_version() function which derives the
8+
CUDA major version from headers.
9+
10+
Note: These tests do NOT require cuda.core to be built/installed since they
11+
test build-time infrastructure. Run with --noconftest to avoid loading
12+
conftest.py which imports cuda.core modules:
13+
14+
pytest tests/test_build_hooks.py -v --noconftest
15+
16+
These tests require Cython to be installed (build_hooks.py imports it).
17+
"""
18+
19+
import importlib.util
20+
import os
21+
import tempfile
22+
from pathlib import Path
23+
from unittest import mock
24+
25+
import pytest
26+
27+
# build_hooks.py imports Cython at the top level, so skip if not available
28+
pytest.importorskip("Cython")
29+
30+
31+
def _load_build_hooks():
32+
"""Load build_hooks module from source without permanently modifying sys.path.
33+
34+
build_hooks.py is a PEP 517 build backend, not an installed module.
35+
We use importlib to load it directly from source to avoid polluting
36+
sys.path with the cuda_core/ directory (which contains cuda/core/ source
37+
that could shadow the installed package).
38+
"""
39+
build_hooks_path = Path(__file__).parent.parent / "build_hooks.py"
40+
spec = importlib.util.spec_from_file_location("build_hooks", build_hooks_path)
41+
module = importlib.util.module_from_spec(spec)
42+
spec.loader.exec_module(module)
43+
return module
44+
45+
46+
# Load the module once at import time
47+
build_hooks = _load_build_hooks()
48+
49+
50+
def _check_version_detection(
51+
cuda_version, expected_major, *, use_cuda_path=True, use_cuda_home=False, cuda_core_build_major=None
52+
):
53+
"""Test version detection with a mock cuda.h.
54+
55+
Args:
56+
cuda_version: CUDA_VERSION to write in mock cuda.h (e.g., 12080)
57+
expected_major: Expected return value (e.g., "12")
58+
use_cuda_path: If True, set CUDA_PATH to the mock headers directory
59+
use_cuda_home: If True, set CUDA_HOME to the mock headers directory
60+
cuda_core_build_major: If set, override with this CUDA_CORE_BUILD_MAJOR env var
61+
"""
62+
with tempfile.TemporaryDirectory() as tmpdir:
63+
include_dir = Path(tmpdir) / "include"
64+
include_dir.mkdir()
65+
cuda_h = include_dir / "cuda.h"
66+
cuda_h.write_text(f"#define CUDA_VERSION {cuda_version}\n")
67+
68+
build_hooks._get_cuda_paths.cache_clear()
69+
build_hooks._determine_cuda_major_version.cache_clear()
70+
71+
mock_env = {
72+
k: v
73+
for k, v in {
74+
"CUDA_CORE_BUILD_MAJOR": cuda_core_build_major,
75+
"CUDA_PATH": tmpdir if use_cuda_path else None,
76+
"CUDA_HOME": tmpdir if use_cuda_home else None,
77+
}.items()
78+
if v is not None
79+
}
80+
81+
with mock.patch.dict(os.environ, mock_env, clear=True):
82+
result = build_hooks._determine_cuda_major_version()
83+
assert result == expected_major
84+
85+
86+
class TestGetCudaMajorVersion:
87+
"""Tests for _determine_cuda_major_version()."""
88+
89+
@pytest.mark.parametrize("version", ["11", "12", "13", "14"])
90+
def test_env_var_override(self, version):
91+
"""CUDA_CORE_BUILD_MAJOR env var override works with various versions."""
92+
build_hooks._get_cuda_paths.cache_clear()
93+
build_hooks._determine_cuda_major_version.cache_clear()
94+
with mock.patch.dict(os.environ, {"CUDA_CORE_BUILD_MAJOR": version}, clear=False):
95+
result = build_hooks._determine_cuda_major_version()
96+
assert result == version
97+
98+
@pytest.mark.parametrize(
99+
("cuda_version", "expected_major"),
100+
[
101+
(11000, "11"), # CUDA 11.0
102+
(11080, "11"), # CUDA 11.8
103+
(12000, "12"), # CUDA 12.0
104+
(12020, "12"), # CUDA 12.2
105+
(12080, "12"), # CUDA 12.8
106+
(13000, "13"), # CUDA 13.0
107+
(13010, "13"), # CUDA 13.1
108+
],
109+
ids=["11.0", "11.8", "12.0", "12.2", "12.8", "13.0", "13.1"],
110+
)
111+
def test_cuda_headers_parsing(self, cuda_version, expected_major):
112+
"""CUDA_VERSION is correctly parsed from cuda.h headers."""
113+
_check_version_detection(cuda_version, expected_major)
114+
115+
def test_cuda_home_fallback(self):
116+
"""CUDA_HOME is used if CUDA_PATH is not set."""
117+
_check_version_detection(12050, "12", use_cuda_path=False, use_cuda_home=True)
118+
119+
def test_env_var_takes_priority_over_headers(self):
120+
"""Env var override takes priority even when headers exist."""
121+
_check_version_detection(12080, "11", cuda_core_build_major="11")
122+
123+
def test_missing_cuda_path_raises_error(self):
124+
"""RuntimeError is raised when CUDA_PATH/CUDA_HOME not set and no env var override."""
125+
build_hooks._get_cuda_paths.cache_clear()
126+
build_hooks._determine_cuda_major_version.cache_clear()
127+
with (
128+
mock.patch.dict(os.environ, {}, clear=True),
129+
pytest.raises(RuntimeError, match="CUDA_PATH or CUDA_HOME"),
130+
):
131+
build_hooks._determine_cuda_major_version()

0 commit comments

Comments
 (0)