Skip to content

Commit d180766

Browse files
authored
Merge branch 'main' into fix/program-options-name-none
2 parents c882579 + 25062e4 commit d180766

52 files changed

Lines changed: 2169 additions & 183 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.pre-commit-config.yaml

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,12 @@ repos:
5858
files: '^(ci/versions\.yml|cuda_bindings/pixi\.toml|cuda_core/pixi\.toml)$'
5959
pass_filenames: false
6060

61+
- id: check-mempool-hygiene
62+
name: Check tests do not create uncapped memory pools
63+
entry: python ./ci/tools/check_mempool_hygiene.py
64+
language: python
65+
files: '^cuda_core/tests/.*\.py$'
66+
6167
- id: no-markdown-in-docs-source
6268
name: Prevent markdown files in docs/source directories
6369
entry: bash -c

ci/tools/check_mempool_hygiene.py

Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,113 @@
1+
# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
2+
# SPDX-License-Identifier: Apache-2.0
3+
4+
"""Check that tests do not create uncapped CUDA memory pools.
5+
6+
A pool created without ``max_size`` reserves an address-space window sized from
7+
installed device memory rather than from what the test allocates, and the whole
8+
cuda_core suite shares one process. Enough of those reservations exhaust the
9+
address space, after which the rest of the session fails with
10+
CUDA_ERROR_OUT_OF_MEMORY on a device with free physical memory.
11+
12+
See cuda_core/tests/AGENTS.md for the rule this enforces.
13+
"""
14+
15+
from __future__ import annotations
16+
17+
import argparse
18+
import ast
19+
import sys
20+
from pathlib import Path
21+
22+
ROOT = Path(__file__).resolve().parents[2]
23+
DEFAULT_TREE = ROOT / "cuda_core" / "tests"
24+
25+
# Managed pools cannot be right-sized: cuMemPoolCreate requires maxSize == 0 for
26+
# managed pools, so ManagedMemoryResourceOptions has no max_size to set.
27+
CAPPABLE_OPTIONS = frozenset({"DeviceMemoryResourceOptions", "PinnedMemoryResourceOptions"})
28+
CAPPABLE_RESOURCES = frozenset({"DeviceMemoryResource", "PinnedMemoryResource"})
29+
30+
OPT_OUT_MARKER = "uncapped-pool-ok"
31+
32+
33+
def _callee_name(node: ast.Call) -> str:
34+
func = node.func
35+
if isinstance(func, ast.Attribute):
36+
return func.attr
37+
if isinstance(func, ast.Name):
38+
return func.id
39+
return ""
40+
41+
42+
def _is_capped(node: ast.Call) -> bool:
43+
# ``**kwargs`` (arg is None) may carry max_size; do not guess.
44+
return any(kw.arg is None or kw.arg == "max_size" for kw in node.keywords)
45+
46+
47+
def _dict_is_capped(node: ast.Dict) -> bool:
48+
for key in node.keys:
49+
if key is None: # ``**other`` inside the literal
50+
return True
51+
if isinstance(key, ast.Constant) and key.value == "max_size":
52+
return True
53+
return False
54+
55+
56+
def _opted_out(lines: list[str], node: ast.AST) -> bool:
57+
"""True if the call, or the line above it, carries the opt-out marker."""
58+
start = max(node.lineno - 2, 0) # -1 for 0-based, -1 more for a preceding comment
59+
end = getattr(node, "end_lineno", node.lineno)
60+
return any(OPT_OUT_MARKER in line for line in lines[start:end])
61+
62+
63+
def violations_in(path: Path) -> list[str]:
64+
"""Return one message per uncapped pool construction in ``path``."""
65+
source = path.read_text(encoding="utf-8")
66+
lines = source.splitlines()
67+
found = []
68+
for node in ast.walk(ast.parse(source, filename=str(path))):
69+
if not isinstance(node, ast.Call):
70+
continue
71+
name = _callee_name(node)
72+
if name in CAPPABLE_OPTIONS:
73+
uncapped = not _is_capped(node)
74+
elif name in CAPPABLE_RESOURCES:
75+
# The options may also be given as a dict literal.
76+
dicts = [arg for arg in [*node.args, *(kw.value for kw in node.keywords)] if isinstance(arg, ast.Dict)]
77+
uncapped = any(not _dict_is_capped(d) for d in dicts)
78+
else:
79+
continue
80+
if uncapped and not _opted_out(lines, node):
81+
found.append(f"{path.as_posix()}:{node.lineno}: {name} without max_size")
82+
return found
83+
84+
85+
def main(argv: list[str] | None = None) -> int:
86+
parser = argparse.ArgumentParser(description=__doc__)
87+
parser.add_argument(
88+
"paths",
89+
nargs="*",
90+
type=Path,
91+
help=f"Files to check. Defaults to every .py under {DEFAULT_TREE.relative_to(ROOT).as_posix()}.",
92+
)
93+
args = parser.parse_args(argv)
94+
95+
paths = args.paths or sorted(DEFAULT_TREE.rglob("*.py"))
96+
violations = sorted(v for path in paths if path.suffix == ".py" for v in violations_in(path))
97+
if not violations:
98+
return 0
99+
100+
print("error: memory pools created by tests must set max_size:", file=sys.stderr)
101+
for violation in violations:
102+
print(f" - {violation}", file=sys.stderr)
103+
print(
104+
f"Use the suite-wide POOL_SIZE from cuda_core/tests/helpers/constants.py, or annotate a\n"
105+
f"deliberate exception with a '# {OPT_OUT_MARKER}: <reason>' comment.\n"
106+
f"See cuda_core/tests/AGENTS.md.",
107+
file=sys.stderr,
108+
)
109+
return 1
110+
111+
112+
if __name__ == "__main__":
113+
sys.exit(main())

ci/tools/merge_cuda_core_wheels.py

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
#!/usr/bin/env python3
22

3-
# SPDX-FileCopyrightText: Copyright (c) 2024-2025 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
3+
# SPDX-FileCopyrightText: Copyright (c) 2024-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
44
#
55
# SPDX-License-Identifier: Apache-2.0
66

@@ -27,10 +27,9 @@
2727
import tempfile
2828
import zipfile
2929
from pathlib import Path
30-
from typing import List
3130

3231

33-
def run_command(cmd: List[str], cwd: Path | None = None, env: dict = os.environ) -> subprocess.CompletedProcess:
32+
def run_command(cmd: list[str], cwd: Path | None = None, env: dict = os.environ) -> subprocess.CompletedProcess:
3433
"""Run a command with error handling."""
3534
print(f"Running: {' '.join(cmd)}")
3635
if cwd:
@@ -78,7 +77,7 @@ def print_wheel_directory_structure(wheel_path: Path, filter_prefix: str = "cuda
7877
print(f"Warning: Could not list wheel contents: {e}", file=sys.stderr)
7978

8079

81-
def merge_wheels(wheels: List[Path], output_dir: Path, show_wheel_contents: bool = True) -> Path:
80+
def merge_wheels(wheels: list[Path], output_dir: Path, show_wheel_contents: bool = True) -> Path:
8281
"""Merge multiple wheels into a single wheel with version-specific binaries."""
8382
print("\n=== Merging wheels ===", file=sys.stderr)
8483
print(f"Input wheels: {[w.name for w in wheels]}", file=sys.stderr)
Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
1+
# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
2+
# SPDX-License-Identifier: Apache-2.0
3+
4+
from __future__ import annotations
5+
6+
import os
7+
import sys
8+
9+
import pytest
10+
11+
sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
12+
from check_mempool_hygiene import DEFAULT_TREE, main, violations_in
13+
14+
15+
def write(tmp_path, source):
16+
path = tmp_path / "test_sample.py"
17+
path.write_text(source, encoding="utf-8")
18+
return path
19+
20+
21+
UNCAPPED = [
22+
pytest.param("DeviceMemoryResource(dev, DeviceMemoryResourceOptions(ipc_enabled=True))", id="options-kwarg"),
23+
pytest.param("PinnedMemoryResource(PinnedMemoryResourceOptions())", id="options-empty"),
24+
pytest.param('DeviceMemoryResource(dev, {"ipc_enabled": True})', id="options-dict"),
25+
]
26+
27+
CAPPED = [
28+
pytest.param("DeviceMemoryResource(dev, DeviceMemoryResourceOptions(max_size=POOL_SIZE))", id="capped-kwarg"),
29+
pytest.param('DeviceMemoryResource(dev, {"max_size": POOL_SIZE})', id="capped-dict"),
30+
pytest.param("DeviceMemoryResource(dev, DeviceMemoryResourceOptions(**opts))", id="opaque-kwargs"),
31+
# No options at all wraps the device's default pool and reserves nothing, so
32+
# capping it would convert a free wrapper into a new pool.
33+
pytest.param("DeviceMemoryResource(dev)", id="default-pool-wrapper"),
34+
# cuMemPoolCreate requires maxSize == 0 for managed pools, so these have no
35+
# max_size to set.
36+
pytest.param("ManagedMemoryResource(ManagedMemoryResourceOptions(preferred_location=0))", id="managed-exempt"),
37+
]
38+
39+
40+
@pytest.mark.agent_authored(model="claude-opus-5")
41+
@pytest.mark.parametrize("source", UNCAPPED)
42+
def test_uncapped_pool_is_reported(tmp_path, source):
43+
assert violations_in(write(tmp_path, source))
44+
45+
46+
@pytest.mark.agent_authored(model="claude-opus-5")
47+
@pytest.mark.parametrize("source", CAPPED)
48+
def test_acceptable_construction_is_not_reported(tmp_path, source):
49+
assert violations_in(write(tmp_path, source)) == []
50+
51+
52+
@pytest.mark.agent_authored(model="claude-opus-5")
53+
@pytest.mark.parametrize("comment_line", [0, 1], ids=["marker-above", "marker-inline"])
54+
def test_marker_opts_a_call_out(tmp_path, comment_line):
55+
# The escape hatch exists mainly for pytest.raises cases, where validation
56+
# rejects the arguments before any pool is created.
57+
call = "PinnedMemoryResource(PinnedMemoryResourceOptions())"
58+
marker = "# uncapped-pool-ok: raises before the pool is created"
59+
source = f"{marker}\n{call}" if comment_line == 0 else f"{call} {marker}"
60+
61+
assert violations_in(write(tmp_path, source)) == []
62+
63+
64+
@pytest.mark.agent_authored(model="claude-opus-5")
65+
def test_reported_message_names_file_line_and_symbol(tmp_path):
66+
path = write(tmp_path, "x = 1\nDeviceMemoryResource(dev, DeviceMemoryResourceOptions())\n")
67+
68+
(violation,) = violations_in(path)
69+
70+
assert violation.startswith(path.as_posix())
71+
assert ":2:" in violation
72+
assert "DeviceMemoryResourceOptions without max_size" in violation
73+
74+
75+
@pytest.mark.agent_authored(model="claude-opus-5")
76+
def test_main_reports_failure_for_the_files_it_is_given(tmp_path, capsys):
77+
path = write(tmp_path, "DeviceMemoryResource(dev, DeviceMemoryResourceOptions())")
78+
79+
assert main([str(path)]) == 1
80+
assert "must set max_size" in capsys.readouterr().err
81+
82+
83+
@pytest.mark.agent_authored(model="claude-opus-5")
84+
def test_main_ignores_non_python_files(tmp_path):
85+
unrelated = tmp_path / "notes.txt"
86+
unrelated.write_text("DeviceMemoryResourceOptions()", encoding="utf-8")
87+
88+
assert main([str(unrelated)]) == 0
89+
90+
91+
@pytest.mark.agent_authored(model="claude-opus-5")
92+
def test_the_live_test_suite_is_clean():
93+
# Without a default the hook would only ever see changed files, so a
94+
# violation could ride in on a rename or a merge.
95+
assert DEFAULT_TREE.is_dir()
96+
assert main([]) == 0

cuda_bindings/build_hooks.py

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616
import sys
1717
import sysconfig
1818
import tempfile
19+
from pathlib import Path
1920
from warnings import warn
2021

2122
from setuptools import build_meta as _build_meta
@@ -50,9 +51,9 @@ def _import_get_cuda_path_or_home():
5051
cuda = None
5152

5253
for p in sys.path:
53-
sp_cuda = os.path.join(p, "cuda")
54-
if os.path.isdir(os.path.join(sp_cuda, "pathfinder")):
55-
cuda.__path__ = list(cuda.__path__) + [sp_cuda]
54+
sp_cuda = Path(p) / "cuda"
55+
if (sp_cuda / "pathfinder").is_dir():
56+
cuda.__path__ = list(cuda.__path__) + [str(sp_cuda)]
5657
break
5758
else:
5859
raise ModuleNotFoundError(
@@ -61,6 +62,11 @@ def _import_get_cuda_path_or_home():
6162
)
6263
import cuda.pathfinder
6364

65+
pathfinder_dir = Path(cuda.pathfinder.__file__).parent
66+
print(
67+
f"Using cuda-pathfinder {cuda.pathfinder.__version__} from {pathfinder_dir}",
68+
file=sys.stderr,
69+
)
6470
return cuda.pathfinder.get_cuda_path_or_home
6571

6672

cuda_bindings/tests/nvml/test_init.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
import pytest
88

99
from cuda.bindings import nvml
10+
from cuda_python_test_helpers import driver_version_less_than
1011

1112

1213
def assert_nvml_is_initialized():
@@ -43,6 +44,7 @@ def get_architecture_name(arch):
4344

4445
@pytest.mark.skipif(sys.platform == "win32", reason="Test not supported on Windows")
4546
@pytest.mark.thread_unsafe(reason="nvml init affects other threads")
47+
@pytest.mark.skipif(not driver_version_less_than(13040), reason="Init behavior changed in CUDA 13.4")
4648
def test_init_ref_count():
4749
"""
4850
Verifies that we can call NVML shutdown and init(2) multiple times, and that ref counting works

0 commit comments

Comments
 (0)