gpu-stack-inspector 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- gpu_stack_inspector/__init__.py +8 -0
- gpu_stack_inspector/__main__.py +6 -0
- gpu_stack_inspector/cli.py +335 -0
- gpu_stack_inspector/collectors/__init__.py +1 -0
- gpu_stack_inspector/collectors/environment.py +31 -0
- gpu_stack_inspector/collectors/host.py +48 -0
- gpu_stack_inspector/collectors/nvidia.py +166 -0
- gpu_stack_inspector/collectors/python_env.py +63 -0
- gpu_stack_inspector/collectors/toolkit.py +160 -0
- gpu_stack_inspector/compatibility/__init__.py +1 -0
- gpu_stack_inspector/compatibility/data.json +24 -0
- gpu_stack_inspector/compatibility/loader.py +75 -0
- gpu_stack_inspector/compatibility/versions.py +25 -0
- gpu_stack_inspector/exit_codes.py +22 -0
- gpu_stack_inspector/models.py +349 -0
- gpu_stack_inspector/orchestrator.py +132 -0
- gpu_stack_inspector/privacy.py +237 -0
- gpu_stack_inspector/probes/__init__.py +1 -0
- gpu_stack_inspector/probes/metadata_probe.py +230 -0
- gpu_stack_inspector/probes/metadata_program.py +232 -0
- gpu_stack_inspector/probes/runtime_probe.py +153 -0
- gpu_stack_inspector/probes/runtime_program.py +101 -0
- gpu_stack_inspector/reports/__init__.py +1 -0
- gpu_stack_inspector/reports/json_report.py +43 -0
- gpu_stack_inspector/reports/markdown.py +182 -0
- gpu_stack_inspector/reports/terminal.py +298 -0
- gpu_stack_inspector/rules/__init__.py +1 -0
- gpu_stack_inspector/rules/catalog.py +21 -0
- gpu_stack_inspector/rules/comparison.py +73 -0
- gpu_stack_inspector/rules/compatibility.py +256 -0
- gpu_stack_inspector/rules/differences.py +64 -0
- gpu_stack_inspector/rules/engine.py +101 -0
- gpu_stack_inspector/rules/host.py +259 -0
- gpu_stack_inspector/rules/interpreters.py +422 -0
- gpu_stack_inspector/rules/torch_build.py +227 -0
- gpu_stack_inspector/runner.py +273 -0
- gpu_stack_inspector-0.1.0.dist-info/METADATA +134 -0
- gpu_stack_inspector-0.1.0.dist-info/RECORD +41 -0
- gpu_stack_inspector-0.1.0.dist-info/WHEEL +4 -0
- gpu_stack_inspector-0.1.0.dist-info/entry_points.txt +3 -0
- gpu_stack_inspector-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,256 @@
|
|
|
1
|
+
"""Checks of the CUDA toolchain against what the driver supports."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Iterator
|
|
6
|
+
from enum import Enum
|
|
7
|
+
|
|
8
|
+
from gpu_stack_inspector.compatibility.loader import load
|
|
9
|
+
from gpu_stack_inspector.compatibility.versions import major_minor
|
|
10
|
+
from gpu_stack_inspector.models import (
|
|
11
|
+
Confidence,
|
|
12
|
+
Evidence,
|
|
13
|
+
Finding,
|
|
14
|
+
Severity,
|
|
15
|
+
Snapshot,
|
|
16
|
+
Status,
|
|
17
|
+
)
|
|
18
|
+
from gpu_stack_inspector.rules.catalog import RULES
|
|
19
|
+
from gpu_stack_inspector.rules.engine import RuleContext
|
|
20
|
+
from gpu_stack_inspector.rules.torch_build import TorchBuildKind, classify_torch
|
|
21
|
+
|
|
22
|
+
_MINOR_URL = "https://docs.nvidia.com/deploy/cuda-compatibility/minor-version-compatibility.html"
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class _Support(Enum):
|
|
26
|
+
SUPPORTED = "supported"
|
|
27
|
+
NEWER_MINOR = "newer minor"
|
|
28
|
+
NEWER_MAJOR = "newer major"
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _support(target: tuple[int, int], driver_max: tuple[int, int]) -> _Support:
|
|
32
|
+
if target <= driver_max:
|
|
33
|
+
return _Support.SUPPORTED
|
|
34
|
+
if target[0] == driver_max[0] and target[0] >= load().minor_version_from_major:
|
|
35
|
+
return _Support.NEWER_MINOR # minor version compatibility applies
|
|
36
|
+
return _Support.NEWER_MAJOR
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _driver_cause(snapshot: Snapshot) -> str | None:
|
|
40
|
+
"""Which host finding explains a missing driver CUDA version, if any."""
|
|
41
|
+
nvidia = snapshot.nvidia
|
|
42
|
+
if nvidia is not None and nvidia.smi_path is None:
|
|
43
|
+
return "GSI-SYS-001"
|
|
44
|
+
if nvidia is not None and nvidia.driver_version is None:
|
|
45
|
+
return "GSI-SYS-002"
|
|
46
|
+
return None
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _not_compared(snapshot: Snapshot, rule_id: str, what: str, subject: str = "host") -> Finding:
|
|
50
|
+
"""No driver CUDA version: not tested because of a host finding, or plainly unknown."""
|
|
51
|
+
cause = _driver_cause(snapshot)
|
|
52
|
+
if cause is not None:
|
|
53
|
+
return Finding(
|
|
54
|
+
id=rule_id,
|
|
55
|
+
status=Status.NOT_TESTED,
|
|
56
|
+
severity=Severity.INFO,
|
|
57
|
+
confidence=Confidence.HIGH,
|
|
58
|
+
summary=f"{what} was not compared with the driver",
|
|
59
|
+
reason="The driver's CUDA version is not known.",
|
|
60
|
+
subject=subject,
|
|
61
|
+
caused_by=cause,
|
|
62
|
+
)
|
|
63
|
+
return Finding(
|
|
64
|
+
id=rule_id,
|
|
65
|
+
status=Status.UNKNOWN,
|
|
66
|
+
severity=Severity.LOW,
|
|
67
|
+
confidence=Confidence.LOW,
|
|
68
|
+
summary=f"{what} could not be compared with the driver",
|
|
69
|
+
reason="The driver answered but did not report the newest CUDA version it supports.",
|
|
70
|
+
subject=subject,
|
|
71
|
+
recommended_action="Check the 'CUDA Version' in the header of `nvidia-smi` by hand.",
|
|
72
|
+
verify_command="nvidia-smi",
|
|
73
|
+
)
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def _minimum_driver_sentence(major: int) -> str:
|
|
77
|
+
row = load().minimum_driver.get(major)
|
|
78
|
+
return f" CUDA {major}.x needs driver {row[0]} or newer." if row else ""
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
@RULES.rule("GSI-CUDA-001", "nvcc is installed")
|
|
82
|
+
def nvcc_installed(snapshot: Snapshot, context: RuleContext) -> Iterator[Finding]:
|
|
83
|
+
"""Whether the CUDA compiler nvcc is on PATH. Missing is not a problem by itself.
|
|
84
|
+
|
|
85
|
+
Prebuilt PyTorch wheels bring their own CUDA runtime and do not need a local CUDA
|
|
86
|
+
toolkit. nvcc only matters for compiling CUDA code yourself, such as building an
|
|
87
|
+
extension from source.
|
|
88
|
+
"""
|
|
89
|
+
toolkit = snapshot.toolkit
|
|
90
|
+
if toolkit is None:
|
|
91
|
+
return
|
|
92
|
+
if toolkit.nvcc_path is not None:
|
|
93
|
+
yield Finding(
|
|
94
|
+
id="GSI-CUDA-001",
|
|
95
|
+
status=Status.PASS,
|
|
96
|
+
severity=Severity.INFO,
|
|
97
|
+
confidence=Confidence.HIGH,
|
|
98
|
+
summary=f"nvcc {toolkit.nvcc_version or '(unknown version)'} is installed",
|
|
99
|
+
reason=f"Found at {toolkit.nvcc_path}.",
|
|
100
|
+
evidence=(Evidence("nvcc", "version", toolkit.nvcc_version),),
|
|
101
|
+
)
|
|
102
|
+
return
|
|
103
|
+
yield Finding(
|
|
104
|
+
id="GSI-CUDA-001",
|
|
105
|
+
status=Status.NOT_APPLICABLE,
|
|
106
|
+
severity=Severity.INFO,
|
|
107
|
+
confidence=Confidence.HIGH,
|
|
108
|
+
summary="No CUDA compiler (nvcc) on PATH",
|
|
109
|
+
reason=(
|
|
110
|
+
"Prebuilt PyTorch wheels do not need it; it is only needed to compile CUDA code, "
|
|
111
|
+
"such as building an extension from source."
|
|
112
|
+
),
|
|
113
|
+
evidence=(Evidence("nvcc", "path", None),),
|
|
114
|
+
)
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
@RULES.rule("GSI-CUDA-002", "The driver supports nvcc's CUDA version", sources=(_MINOR_URL,))
|
|
118
|
+
def nvcc_supported(snapshot: Snapshot, context: RuleContext) -> Iterator[Finding]:
|
|
119
|
+
"""Whether programs compiled with the local nvcc can run on this driver.
|
|
120
|
+
|
|
121
|
+
nvcc from CUDA 13.x produces programs that need a driver supporting CUDA 13. Within one
|
|
122
|
+
major release family, NVIDIA's minor version compatibility usually lets newer-minor
|
|
123
|
+
programs run, with a limited feature set; code compiled to PTX does not.
|
|
124
|
+
"""
|
|
125
|
+
toolkit, nvidia = snapshot.toolkit, snapshot.nvidia
|
|
126
|
+
nvcc = major_minor(toolkit.nvcc_version) if toolkit else None
|
|
127
|
+
if nvcc is None:
|
|
128
|
+
return
|
|
129
|
+
driver_max = major_minor(nvidia.max_cuda_version) if nvidia else None
|
|
130
|
+
if driver_max is None:
|
|
131
|
+
if nvidia is not None:
|
|
132
|
+
yield _not_compared(snapshot, "GSI-CUDA-002", "nvcc")
|
|
133
|
+
return
|
|
134
|
+
version = toolkit.nvcc_version if toolkit else None
|
|
135
|
+
driver_text = nvidia.max_cuda_version if nvidia else None
|
|
136
|
+
evidence = (
|
|
137
|
+
Evidence("nvcc", "version", version),
|
|
138
|
+
Evidence("nvidia-smi", "max_cuda_version", driver_text),
|
|
139
|
+
)
|
|
140
|
+
support = _support(nvcc, driver_max)
|
|
141
|
+
if support is _Support.SUPPORTED:
|
|
142
|
+
yield Finding(
|
|
143
|
+
id="GSI-CUDA-002",
|
|
144
|
+
status=Status.PASS,
|
|
145
|
+
severity=Severity.INFO,
|
|
146
|
+
confidence=Confidence.HIGH,
|
|
147
|
+
summary=f"The driver supports nvcc's CUDA {nvcc[0]}.{nvcc[1]}",
|
|
148
|
+
reason=f"The driver supports CUDA up to {driver_text}.",
|
|
149
|
+
evidence=evidence,
|
|
150
|
+
)
|
|
151
|
+
return
|
|
152
|
+
yield Finding(
|
|
153
|
+
id="GSI-CUDA-002",
|
|
154
|
+
status=Status.WARN,
|
|
155
|
+
severity=Severity.MEDIUM if support is _Support.NEWER_MAJOR else Severity.LOW,
|
|
156
|
+
confidence=Confidence.HIGH,
|
|
157
|
+
summary=f"Programs built with nvcc {version} may need a newer driver",
|
|
158
|
+
reason=(
|
|
159
|
+
f"nvcc builds for CUDA {nvcc[0]}.{nvcc[1]}, but the driver supports CUDA up to "
|
|
160
|
+
f"{driver_text}."
|
|
161
|
+
+ (
|
|
162
|
+
_minimum_driver_sentence(nvcc[0])
|
|
163
|
+
if support is _Support.NEWER_MAJOR
|
|
164
|
+
else " Minor version compatibility usually lets them run, but code compiled "
|
|
165
|
+
"to PTX will not."
|
|
166
|
+
)
|
|
167
|
+
),
|
|
168
|
+
evidence=evidence,
|
|
169
|
+
recommended_action="Update the NVIDIA driver, or use a CUDA toolkit the driver supports.",
|
|
170
|
+
verify_command="nvidia-smi",
|
|
171
|
+
)
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
@RULES.rule(
|
|
175
|
+
"GSI-CUDA-003",
|
|
176
|
+
"The driver supports the CUDA version PyTorch was built for",
|
|
177
|
+
sources=(_MINOR_URL,),
|
|
178
|
+
)
|
|
179
|
+
def torch_cuda_supported(snapshot: Snapshot, context: RuleContext) -> Iterator[Finding]:
|
|
180
|
+
"""Whether the driver can run the CUDA version each CUDA build of PyTorch targets.
|
|
181
|
+
|
|
182
|
+
A build for a newer CUDA major release than the driver supports cannot initialise
|
|
183
|
+
CUDA at all (unless NVIDIA's forward-compatibility package is installed, which is
|
|
184
|
+
rare outside data centres). A newer minor version of the same release family usually
|
|
185
|
+
works through minor version compatibility; operations that need the newer driver fail
|
|
186
|
+
with cudaErrorCallRequiresNewerDriver.
|
|
187
|
+
"""
|
|
188
|
+
nvidia = snapshot.nvidia
|
|
189
|
+
driver_max = major_minor(nvidia.max_cuda_version) if nvidia else None
|
|
190
|
+
driver_text = nvidia.max_cuda_version if nvidia else None
|
|
191
|
+
for interpreter in snapshot.interpreters:
|
|
192
|
+
if interpreter.torch is None:
|
|
193
|
+
continue
|
|
194
|
+
build = classify_torch(interpreter.torch)
|
|
195
|
+
target = major_minor(build.cuda_version)
|
|
196
|
+
if build.kind is not TorchBuildKind.CUDA or target is None:
|
|
197
|
+
continue
|
|
198
|
+
if driver_max is None:
|
|
199
|
+
if nvidia is not None:
|
|
200
|
+
what = f"CUDA {build.cuda_version}"
|
|
201
|
+
yield _not_compared(snapshot, "GSI-CUDA-003", what, interpreter.label)
|
|
202
|
+
continue
|
|
203
|
+
evidence = (
|
|
204
|
+
Evidence("torch", "cuda_version", build.cuda_version),
|
|
205
|
+
Evidence("nvidia-smi", "max_cuda_version", driver_text),
|
|
206
|
+
)
|
|
207
|
+
support = _support(target, driver_max)
|
|
208
|
+
if support is _Support.SUPPORTED:
|
|
209
|
+
yield Finding(
|
|
210
|
+
id="GSI-CUDA-003",
|
|
211
|
+
status=Status.PASS,
|
|
212
|
+
severity=Severity.INFO,
|
|
213
|
+
confidence=Confidence.HIGH,
|
|
214
|
+
summary=f"The driver supports CUDA {build.cuda_version}",
|
|
215
|
+
reason=(
|
|
216
|
+
f"torch targets CUDA {build.cuda_version}; "
|
|
217
|
+
f"the driver supports up to {driver_text}."
|
|
218
|
+
),
|
|
219
|
+
subject=interpreter.label,
|
|
220
|
+
evidence=evidence,
|
|
221
|
+
)
|
|
222
|
+
continue
|
|
223
|
+
newer_major = support is _Support.NEWER_MAJOR
|
|
224
|
+
yield Finding(
|
|
225
|
+
id="GSI-CUDA-003",
|
|
226
|
+
status=Status.FAIL if newer_major else Status.WARN,
|
|
227
|
+
severity=Severity.HIGH if newer_major else Severity.MEDIUM,
|
|
228
|
+
confidence=Confidence.HIGH,
|
|
229
|
+
summary=(
|
|
230
|
+
f"torch is built for CUDA {build.cuda_version}, newer than the driver supports"
|
|
231
|
+
),
|
|
232
|
+
reason=(
|
|
233
|
+
f"The driver supports CUDA up to {driver_text}."
|
|
234
|
+
+ (
|
|
235
|
+
_minimum_driver_sentence(target[0]) + " CUDA will not initialise."
|
|
236
|
+
if newer_major
|
|
237
|
+
else " Minor version compatibility usually lets it run, but operations "
|
|
238
|
+
"that need a newer driver fail with cudaErrorCallRequiresNewerDriver."
|
|
239
|
+
)
|
|
240
|
+
),
|
|
241
|
+
subject=interpreter.label,
|
|
242
|
+
evidence=evidence,
|
|
243
|
+
recommended_action=(
|
|
244
|
+
f"Update the NVIDIA driver, or install a torch build for CUDA {driver_text} or "
|
|
245
|
+
"older from https://pytorch.org/get-started/locally/."
|
|
246
|
+
),
|
|
247
|
+
verify_command="nvidia-smi",
|
|
248
|
+
limitations=(
|
|
249
|
+
(
|
|
250
|
+
"NVIDIA's forward-compatibility package (cuda-compat) can bridge major "
|
|
251
|
+
"versions on supported data-centre GPUs; it is not detected.",
|
|
252
|
+
)
|
|
253
|
+
if newer_major
|
|
254
|
+
else ()
|
|
255
|
+
),
|
|
256
|
+
)
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
"""Checks across the inspected interpreters: what tells them apart."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Iterator
|
|
6
|
+
|
|
7
|
+
from gpu_stack_inspector.models import (
|
|
8
|
+
Confidence,
|
|
9
|
+
Evidence,
|
|
10
|
+
Finding,
|
|
11
|
+
Severity,
|
|
12
|
+
Snapshot,
|
|
13
|
+
Status,
|
|
14
|
+
)
|
|
15
|
+
from gpu_stack_inspector.rules.catalog import RULES
|
|
16
|
+
from gpu_stack_inspector.rules.comparison import torch_build_label
|
|
17
|
+
from gpu_stack_inspector.rules.engine import RuleContext
|
|
18
|
+
|
|
19
|
+
COMPARISON = "comparison"
|
|
20
|
+
"""Subject of findings about the interpreters as a group."""
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@RULES.rule("GSI-DIFF-001", "The compared environments have the same PyTorch build")
|
|
24
|
+
def same_torch_build(snapshot: Snapshot, context: RuleContext) -> Iterator[Finding]:
|
|
25
|
+
"""Whether every compared environment has the same kind of PyTorch build.
|
|
26
|
+
|
|
27
|
+
When one environment has a CUDA build and another a CPU-only build (or no torch, or a
|
|
28
|
+
build for another CUDA version), that difference is the most likely reason the GPU
|
|
29
|
+
works in one and not the other. Only interpreters that could be inspected count, and
|
|
30
|
+
only when at least two of them are compared.
|
|
31
|
+
"""
|
|
32
|
+
usable = [i for i in snapshot.interpreters if i.version is not None]
|
|
33
|
+
if len(usable) < 2:
|
|
34
|
+
return
|
|
35
|
+
builds = [(i.label, torch_build_label(i) or "unknown") for i in usable]
|
|
36
|
+
evidence = tuple(Evidence("comparison", label, build) for label, build in builds)
|
|
37
|
+
listing = ", ".join(f"{label} {build}" for label, build in builds)
|
|
38
|
+
if len({build for _, build in builds}) == 1:
|
|
39
|
+
yield Finding(
|
|
40
|
+
id="GSI-DIFF-001",
|
|
41
|
+
status=Status.PASS,
|
|
42
|
+
severity=Severity.INFO,
|
|
43
|
+
confidence=Confidence.HIGH,
|
|
44
|
+
summary=f"All compared environments have the same PyTorch build ({builds[0][1]})",
|
|
45
|
+
reason="The difference, if any, is elsewhere; see the comparison table.",
|
|
46
|
+
subject=COMPARISON,
|
|
47
|
+
evidence=evidence,
|
|
48
|
+
)
|
|
49
|
+
return
|
|
50
|
+
yield Finding(
|
|
51
|
+
id="GSI-DIFF-001",
|
|
52
|
+
status=Status.WARN,
|
|
53
|
+
severity=Severity.MEDIUM,
|
|
54
|
+
confidence=Confidence.HIGH,
|
|
55
|
+
summary=f"The environments have different PyTorch builds: {listing}",
|
|
56
|
+
reason=("This is the most likely reason CUDA works in one environment and not in another."),
|
|
57
|
+
subject=COMPARISON,
|
|
58
|
+
evidence=evidence,
|
|
59
|
+
recommended_action=(
|
|
60
|
+
"Install the same CUDA build of torch in the environment that should use the GPU; "
|
|
61
|
+
"GSI-TORCH-002 says which build each one has."
|
|
62
|
+
),
|
|
63
|
+
verify_command="gpu-stack-inspector compare --python … --python …",
|
|
64
|
+
)
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
"""Rules: pure functions from a snapshot to findings, registered under stable codes."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import inspect
|
|
6
|
+
from collections.abc import Callable, Iterable, Iterator
|
|
7
|
+
from dataclasses import dataclass
|
|
8
|
+
|
|
9
|
+
from gpu_stack_inspector.models import FINDING_ID, Confidence, Finding, Severity, Snapshot, Status
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
@dataclass(frozen=True, slots=True)
|
|
13
|
+
class RuleContext:
|
|
14
|
+
"""What the user asked for, which changes how some facts are judged.
|
|
15
|
+
|
|
16
|
+
``cuda_expected``: the user wants these environments to use an NVIDIA GPU (the
|
|
17
|
+
reason to run this tool), so a missing driver is a problem rather than a fact.
|
|
18
|
+
``shell_python``: the interpreter the user's shell runs, when known.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
cuda_expected: bool = True
|
|
22
|
+
shell_python: str | None = None
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
RuleCheck = Callable[[Snapshot, RuleContext], Iterable[Finding]]
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
@dataclass(frozen=True, slots=True)
|
|
29
|
+
class Rule:
|
|
30
|
+
id: str
|
|
31
|
+
title: str
|
|
32
|
+
explanation: str
|
|
33
|
+
check: RuleCheck
|
|
34
|
+
sources: tuple[str, ...] = ()
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class Registry:
|
|
38
|
+
"""Rules in registration order, which is the order they are evaluated and listed."""
|
|
39
|
+
|
|
40
|
+
def __init__(self) -> None:
|
|
41
|
+
self._rules: dict[str, Rule] = {}
|
|
42
|
+
|
|
43
|
+
def rule(
|
|
44
|
+
self, rule_id: str, title: str, *, sources: Iterable[str] = ()
|
|
45
|
+
) -> Callable[[RuleCheck], RuleCheck]:
|
|
46
|
+
"""Register a check; its docstring becomes the explanation shown by `explain`."""
|
|
47
|
+
|
|
48
|
+
def register(check: RuleCheck) -> RuleCheck:
|
|
49
|
+
if not FINDING_ID.fullmatch(rule_id):
|
|
50
|
+
raise ValueError(f"rule id {rule_id!r} does not match GSI-<AREA>-<NNN>")
|
|
51
|
+
if rule_id in self._rules:
|
|
52
|
+
raise ValueError(f"rule {rule_id} is registered twice")
|
|
53
|
+
explanation = inspect.cleandoc(check.__doc__ or "")
|
|
54
|
+
if not explanation:
|
|
55
|
+
raise ValueError(f"rule {rule_id} has no docstring to explain it")
|
|
56
|
+
self._rules[rule_id] = Rule(rule_id, title, explanation, check, tuple(sources))
|
|
57
|
+
return check
|
|
58
|
+
|
|
59
|
+
return register
|
|
60
|
+
|
|
61
|
+
def __iter__(self) -> Iterator[Rule]:
|
|
62
|
+
return iter(self._rules.values())
|
|
63
|
+
|
|
64
|
+
def get(self, rule_id: str) -> Rule | None:
|
|
65
|
+
return self._rules.get(rule_id.upper())
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def evaluate(
|
|
69
|
+
snapshot: Snapshot, rules: Iterable[Rule], context: RuleContext | None = None
|
|
70
|
+
) -> tuple[Finding, ...]:
|
|
71
|
+
"""Run every rule in order and collect their findings.
|
|
72
|
+
|
|
73
|
+
A rule may only report findings under its own code. A rule that raises is a bug in
|
|
74
|
+
this tool; it becomes an ``unknown`` finding for that check instead of hiding every
|
|
75
|
+
other result, and never a pass.
|
|
76
|
+
"""
|
|
77
|
+
context = context or RuleContext()
|
|
78
|
+
findings: list[Finding] = []
|
|
79
|
+
for rule in rules:
|
|
80
|
+
try:
|
|
81
|
+
produced = list(rule.check(snapshot, context))
|
|
82
|
+
except Exception as exc: # report the broken check, keep the others
|
|
83
|
+
findings.append(_internal_error(rule, exc))
|
|
84
|
+
continue
|
|
85
|
+
for finding in produced:
|
|
86
|
+
if finding.id != rule.id:
|
|
87
|
+
raise ValueError(f"rule {rule.id} reported a finding as {finding.id}")
|
|
88
|
+
findings.extend(produced)
|
|
89
|
+
return tuple(findings)
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def _internal_error(rule: Rule, exc: Exception) -> Finding:
|
|
93
|
+
return Finding(
|
|
94
|
+
id=rule.id,
|
|
95
|
+
status=Status.UNKNOWN,
|
|
96
|
+
severity=Severity.LOW,
|
|
97
|
+
confidence=Confidence.LOW,
|
|
98
|
+
summary=f"{rule.title}: not checked because of an internal error",
|
|
99
|
+
reason=f"The check failed with {type(exc).__name__}: {exc}",
|
|
100
|
+
recommended_action="Please report this as a bug, with the redacted report.",
|
|
101
|
+
)
|
|
@@ -0,0 +1,259 @@
|
|
|
1
|
+
"""Checks of the machine: driver tools, the driver, the GPUs, variables that hide GPUs."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Iterator
|
|
6
|
+
|
|
7
|
+
from gpu_stack_inspector.models import (
|
|
8
|
+
Confidence,
|
|
9
|
+
Evidence,
|
|
10
|
+
Finding,
|
|
11
|
+
Severity,
|
|
12
|
+
Snapshot,
|
|
13
|
+
Status,
|
|
14
|
+
)
|
|
15
|
+
from gpu_stack_inspector.rules.catalog import RULES
|
|
16
|
+
from gpu_stack_inspector.rules.engine import RuleContext
|
|
17
|
+
|
|
18
|
+
SMI = "nvidia-smi"
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@RULES.rule("GSI-SYS-001", "NVIDIA driver tools are installed")
|
|
22
|
+
def driver_tools(snapshot: Snapshot, context: RuleContext) -> Iterator[Finding]:
|
|
23
|
+
"""Whether nvidia-smi, which every NVIDIA driver installs, is on PATH.
|
|
24
|
+
|
|
25
|
+
Without it the driver and the GPUs cannot be checked. Its absence usually means no
|
|
26
|
+
NVIDIA driver is installed, but some container images run GPU workloads without it,
|
|
27
|
+
so this is a warning, not a failure. When CUDA is not expected it does not apply.
|
|
28
|
+
"""
|
|
29
|
+
nvidia = snapshot.nvidia
|
|
30
|
+
if nvidia is None:
|
|
31
|
+
return
|
|
32
|
+
if nvidia.smi_path is not None:
|
|
33
|
+
yield Finding(
|
|
34
|
+
id="GSI-SYS-001",
|
|
35
|
+
status=Status.PASS,
|
|
36
|
+
severity=Severity.INFO,
|
|
37
|
+
confidence=Confidence.HIGH,
|
|
38
|
+
summary="nvidia-smi is installed",
|
|
39
|
+
reason=f"Found at {nvidia.smi_path}.",
|
|
40
|
+
evidence=(Evidence(SMI, "path", nvidia.smi_path),),
|
|
41
|
+
)
|
|
42
|
+
return
|
|
43
|
+
yield Finding(
|
|
44
|
+
id="GSI-SYS-001",
|
|
45
|
+
status=Status.WARN if context.cuda_expected else Status.NOT_APPLICABLE,
|
|
46
|
+
severity=Severity.MEDIUM if context.cuda_expected else Severity.INFO,
|
|
47
|
+
confidence=Confidence.MEDIUM,
|
|
48
|
+
summary="nvidia-smi was not found",
|
|
49
|
+
reason=(
|
|
50
|
+
"Without NVIDIA's driver tools on PATH the driver and the GPUs cannot be "
|
|
51
|
+
"checked. A system with a working NVIDIA driver normally provides nvidia-smi."
|
|
52
|
+
),
|
|
53
|
+
evidence=(Evidence(SMI, "path", None),),
|
|
54
|
+
recommended_action=(
|
|
55
|
+
"Install the NVIDIA driver for your distribution (it provides nvidia-smi), or "
|
|
56
|
+
"add its directory to PATH. On WSL it is in /usr/lib/wsl/lib."
|
|
57
|
+
),
|
|
58
|
+
verify_command="nvidia-smi",
|
|
59
|
+
limitations=("Some container images run GPU workloads without nvidia-smi installed.",),
|
|
60
|
+
)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
@RULES.rule("GSI-SYS-002", "The NVIDIA driver answers")
|
|
64
|
+
def driver_answers(snapshot: Snapshot, context: RuleContext) -> Iterator[Finding]:
|
|
65
|
+
"""Whether nvidia-smi could talk to the NVIDIA driver and report its version.
|
|
66
|
+
|
|
67
|
+
nvidia-smi installed but failing usually means the kernel module is not loaded: after
|
|
68
|
+
a driver update without a reboot, when Secure Boot blocks an unsigned module, or when
|
|
69
|
+
the installed module does not match the running kernel. The failure message from
|
|
70
|
+
nvidia-smi is quoted as evidence.
|
|
71
|
+
"""
|
|
72
|
+
nvidia = snapshot.nvidia
|
|
73
|
+
if nvidia is None:
|
|
74
|
+
return
|
|
75
|
+
if nvidia.smi_path is None:
|
|
76
|
+
yield Finding(
|
|
77
|
+
id="GSI-SYS-002",
|
|
78
|
+
status=Status.NOT_TESTED,
|
|
79
|
+
severity=Severity.INFO,
|
|
80
|
+
confidence=Confidence.HIGH,
|
|
81
|
+
summary="The NVIDIA driver was not checked",
|
|
82
|
+
reason="nvidia-smi is not installed, so there was nothing to ask.",
|
|
83
|
+
caused_by="GSI-SYS-001",
|
|
84
|
+
)
|
|
85
|
+
return
|
|
86
|
+
if nvidia.driver_version is None:
|
|
87
|
+
details = [f.detail for f in nvidia.failures] or ["nvidia-smi reported no driver version"]
|
|
88
|
+
yield Finding(
|
|
89
|
+
id="GSI-SYS-002",
|
|
90
|
+
status=Status.FAIL if context.cuda_expected else Status.WARN,
|
|
91
|
+
severity=Severity.HIGH,
|
|
92
|
+
confidence=Confidence.HIGH,
|
|
93
|
+
summary="nvidia-smi could not get an answer from the NVIDIA driver",
|
|
94
|
+
reason=details[0],
|
|
95
|
+
evidence=tuple(Evidence(SMI, "failure", detail) for detail in details),
|
|
96
|
+
recommended_action=(
|
|
97
|
+
"Check that the NVIDIA kernel module is loaded (`lsmod | grep nvidia`). After "
|
|
98
|
+
"a driver update a reboot is usually needed; with Secure Boot an unsigned "
|
|
99
|
+
"module is refused."
|
|
100
|
+
),
|
|
101
|
+
verify_command="nvidia-smi",
|
|
102
|
+
)
|
|
103
|
+
return
|
|
104
|
+
max_cuda = nvidia.max_cuda_version or "an unknown version"
|
|
105
|
+
yield Finding(
|
|
106
|
+
id="GSI-SYS-002",
|
|
107
|
+
status=Status.PASS,
|
|
108
|
+
severity=Severity.INFO,
|
|
109
|
+
confidence=Confidence.HIGH,
|
|
110
|
+
summary=f"NVIDIA driver {nvidia.driver_version} answers",
|
|
111
|
+
reason=(
|
|
112
|
+
f"The driver supports CUDA up to {max_cuda}. That is the newest CUDA it can run, "
|
|
113
|
+
"not a CUDA toolkit installed on this machine."
|
|
114
|
+
),
|
|
115
|
+
evidence=(
|
|
116
|
+
Evidence(SMI, "driver_version", nvidia.driver_version),
|
|
117
|
+
Evidence(SMI, "max_cuda_version", nvidia.max_cuda_version),
|
|
118
|
+
),
|
|
119
|
+
limitations=tuple(f.detail for f in nvidia.failures),
|
|
120
|
+
)
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
@RULES.rule("GSI-SYS-003", "The driver sees a GPU")
|
|
124
|
+
def gpu_visible(snapshot: Snapshot, context: RuleContext) -> Iterator[Finding]:
|
|
125
|
+
"""Whether the NVIDIA driver lists at least one GPU.
|
|
126
|
+
|
|
127
|
+
A driver that answers but lists no GPU means the GPU is switched off or not handed to
|
|
128
|
+
this system: a hybrid-graphics mode that powers the NVIDIA GPU down, a BIOS setting,
|
|
129
|
+
or a VM or container the GPU was not passed through to.
|
|
130
|
+
"""
|
|
131
|
+
nvidia = snapshot.nvidia
|
|
132
|
+
if nvidia is None:
|
|
133
|
+
return
|
|
134
|
+
if nvidia.smi_path is None or nvidia.driver_version is None:
|
|
135
|
+
cause = "GSI-SYS-001" if nvidia.smi_path is None else "GSI-SYS-002"
|
|
136
|
+
yield Finding(
|
|
137
|
+
id="GSI-SYS-003",
|
|
138
|
+
status=Status.NOT_TESTED,
|
|
139
|
+
severity=Severity.INFO,
|
|
140
|
+
confidence=Confidence.HIGH,
|
|
141
|
+
summary="GPUs were not listed",
|
|
142
|
+
reason="The driver could not be asked.",
|
|
143
|
+
caused_by=cause,
|
|
144
|
+
)
|
|
145
|
+
return
|
|
146
|
+
if not nvidia.gpus:
|
|
147
|
+
yield Finding(
|
|
148
|
+
id="GSI-SYS-003",
|
|
149
|
+
status=Status.FAIL if context.cuda_expected else Status.NOT_APPLICABLE,
|
|
150
|
+
severity=Severity.HIGH if context.cuda_expected else Severity.INFO,
|
|
151
|
+
confidence=Confidence.HIGH,
|
|
152
|
+
summary="The NVIDIA driver answers but sees no GPU",
|
|
153
|
+
reason=f"Driver {nvidia.driver_version} reported no devices.",
|
|
154
|
+
evidence=(Evidence(SMI, "gpus", "0"),),
|
|
155
|
+
recommended_action=(
|
|
156
|
+
"Make sure the NVIDIA GPU is switched on for this system: the hybrid-graphics "
|
|
157
|
+
"mode (for example prime-select or supergfxctl), the BIOS setting, or the "
|
|
158
|
+
"GPU passthrough of your VM or container."
|
|
159
|
+
),
|
|
160
|
+
verify_command="nvidia-smi -L",
|
|
161
|
+
)
|
|
162
|
+
return
|
|
163
|
+
names = ", ".join(
|
|
164
|
+
f"{gpu.name} (compute {gpu.compute_capability or 'unknown'})" for gpu in nvidia.gpus
|
|
165
|
+
)
|
|
166
|
+
count = len(nvidia.gpus)
|
|
167
|
+
yield Finding(
|
|
168
|
+
id="GSI-SYS-003",
|
|
169
|
+
status=Status.PASS,
|
|
170
|
+
severity=Severity.INFO,
|
|
171
|
+
confidence=Confidence.HIGH,
|
|
172
|
+
summary=f"{count} GPU{'s' if count != 1 else ''} visible to the driver",
|
|
173
|
+
reason=names,
|
|
174
|
+
evidence=tuple(Evidence(SMI, f"gpu{gpu.index}", gpu.name) for gpu in nvidia.gpus),
|
|
175
|
+
)
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
_HIDE_ALL = frozenset({"", "-1", "none", "nodevfiles"})
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
@RULES.rule(
|
|
182
|
+
"GSI-ENV-003",
|
|
183
|
+
"CUDA_VISIBLE_DEVICES does not hide the GPUs",
|
|
184
|
+
sources=("https://docs.nvidia.com/cuda/cuda-c-programming-guide/index.html#env-vars",),
|
|
185
|
+
)
|
|
186
|
+
def visible_devices(snapshot: Snapshot, context: RuleContext) -> Iterator[Finding]:
|
|
187
|
+
"""Whether CUDA_VISIBLE_DEVICES, when set, leaves the GPUs visible to CUDA programs.
|
|
188
|
+
|
|
189
|
+
An empty value (or -1) hides every GPU from CUDA while nvidia-smi still lists it,
|
|
190
|
+
which looks exactly like a broken install. CUDA also stops at the first index that
|
|
191
|
+
does not exist: with "1,0" on a one-GPU machine no GPU is visible at all. GPU UUIDs
|
|
192
|
+
are not checked, because the tool does not collect them.
|
|
193
|
+
"""
|
|
194
|
+
variable = next(
|
|
195
|
+
(v for v in snapshot.cuda_environment if v.name == "CUDA_VISIBLE_DEVICES"), None
|
|
196
|
+
)
|
|
197
|
+
if variable is None:
|
|
198
|
+
return
|
|
199
|
+
value = variable.value.strip()
|
|
200
|
+
evidence = (Evidence("environment", "CUDA_VISIBLE_DEVICES", variable.value),)
|
|
201
|
+
shown = repr(variable.value)
|
|
202
|
+
if value.lower() in _HIDE_ALL:
|
|
203
|
+
yield Finding(
|
|
204
|
+
id="GSI-ENV-003",
|
|
205
|
+
status=Status.WARN,
|
|
206
|
+
severity=Severity.HIGH,
|
|
207
|
+
confidence=Confidence.HIGH,
|
|
208
|
+
summary="CUDA_VISIBLE_DEVICES hides every GPU",
|
|
209
|
+
reason=(
|
|
210
|
+
f"It is set to {shown}, so CUDA programs started from this environment see no "
|
|
211
|
+
"GPU, even though nvidia-smi lists them."
|
|
212
|
+
),
|
|
213
|
+
evidence=evidence,
|
|
214
|
+
recommended_action=(
|
|
215
|
+
"Unset CUDA_VISIBLE_DEVICES, or set it to the GPU index to use (e.g. 0)."
|
|
216
|
+
),
|
|
217
|
+
verify_command='echo "$CUDA_VISIBLE_DEVICES"',
|
|
218
|
+
)
|
|
219
|
+
return
|
|
220
|
+
entries = [entry.strip() for entry in value.split(",")]
|
|
221
|
+
count = len(snapshot.nvidia.gpus) if snapshot.nvidia else 0
|
|
222
|
+
if count and all(entry.isdigit() for entry in entries):
|
|
223
|
+
invalid = next((int(e) for e in entries if int(e) >= count), None)
|
|
224
|
+
if invalid is not None:
|
|
225
|
+
usable = entries[: entries.index(str(invalid))]
|
|
226
|
+
yield Finding(
|
|
227
|
+
id="GSI-ENV-003",
|
|
228
|
+
status=Status.WARN,
|
|
229
|
+
severity=Severity.HIGH if not usable else Severity.MEDIUM,
|
|
230
|
+
confidence=Confidence.HIGH,
|
|
231
|
+
summary=f"CUDA_VISIBLE_DEVICES names GPU {invalid}, which does not exist",
|
|
232
|
+
reason=(
|
|
233
|
+
f"It is set to {shown}, but the driver lists {count} GPU(s). CUDA stops at "
|
|
234
|
+
"the first index that does not exist, so "
|
|
235
|
+
+ (
|
|
236
|
+
f"only GPU(s) {', '.join(usable)} are visible."
|
|
237
|
+
if usable
|
|
238
|
+
else "no GPU is visible."
|
|
239
|
+
)
|
|
240
|
+
),
|
|
241
|
+
evidence=evidence,
|
|
242
|
+
recommended_action="Use indices from `nvidia-smi -L`, or unset the variable.",
|
|
243
|
+
verify_command="nvidia-smi -L",
|
|
244
|
+
)
|
|
245
|
+
return
|
|
246
|
+
yield Finding(
|
|
247
|
+
id="GSI-ENV-003",
|
|
248
|
+
status=Status.PASS,
|
|
249
|
+
severity=Severity.INFO,
|
|
250
|
+
confidence=Confidence.MEDIUM if not all(e.isdigit() for e in entries) else Confidence.HIGH,
|
|
251
|
+
summary=f"CUDA_VISIBLE_DEVICES limits CUDA to {value}",
|
|
252
|
+
reason="CUDA programs only see the GPUs listed there.",
|
|
253
|
+
evidence=evidence,
|
|
254
|
+
limitations=(
|
|
255
|
+
("GPU UUIDs and MIG devices are not checked: the tool does not collect them.",)
|
|
256
|
+
if not all(e.isdigit() for e in entries)
|
|
257
|
+
else ()
|
|
258
|
+
),
|
|
259
|
+
)
|