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.
Files changed (41) hide show
  1. gpu_stack_inspector/__init__.py +8 -0
  2. gpu_stack_inspector/__main__.py +6 -0
  3. gpu_stack_inspector/cli.py +335 -0
  4. gpu_stack_inspector/collectors/__init__.py +1 -0
  5. gpu_stack_inspector/collectors/environment.py +31 -0
  6. gpu_stack_inspector/collectors/host.py +48 -0
  7. gpu_stack_inspector/collectors/nvidia.py +166 -0
  8. gpu_stack_inspector/collectors/python_env.py +63 -0
  9. gpu_stack_inspector/collectors/toolkit.py +160 -0
  10. gpu_stack_inspector/compatibility/__init__.py +1 -0
  11. gpu_stack_inspector/compatibility/data.json +24 -0
  12. gpu_stack_inspector/compatibility/loader.py +75 -0
  13. gpu_stack_inspector/compatibility/versions.py +25 -0
  14. gpu_stack_inspector/exit_codes.py +22 -0
  15. gpu_stack_inspector/models.py +349 -0
  16. gpu_stack_inspector/orchestrator.py +132 -0
  17. gpu_stack_inspector/privacy.py +237 -0
  18. gpu_stack_inspector/probes/__init__.py +1 -0
  19. gpu_stack_inspector/probes/metadata_probe.py +230 -0
  20. gpu_stack_inspector/probes/metadata_program.py +232 -0
  21. gpu_stack_inspector/probes/runtime_probe.py +153 -0
  22. gpu_stack_inspector/probes/runtime_program.py +101 -0
  23. gpu_stack_inspector/reports/__init__.py +1 -0
  24. gpu_stack_inspector/reports/json_report.py +43 -0
  25. gpu_stack_inspector/reports/markdown.py +182 -0
  26. gpu_stack_inspector/reports/terminal.py +298 -0
  27. gpu_stack_inspector/rules/__init__.py +1 -0
  28. gpu_stack_inspector/rules/catalog.py +21 -0
  29. gpu_stack_inspector/rules/comparison.py +73 -0
  30. gpu_stack_inspector/rules/compatibility.py +256 -0
  31. gpu_stack_inspector/rules/differences.py +64 -0
  32. gpu_stack_inspector/rules/engine.py +101 -0
  33. gpu_stack_inspector/rules/host.py +259 -0
  34. gpu_stack_inspector/rules/interpreters.py +422 -0
  35. gpu_stack_inspector/rules/torch_build.py +227 -0
  36. gpu_stack_inspector/runner.py +273 -0
  37. gpu_stack_inspector-0.1.0.dist-info/METADATA +134 -0
  38. gpu_stack_inspector-0.1.0.dist-info/RECORD +41 -0
  39. gpu_stack_inspector-0.1.0.dist-info/WHEEL +4 -0
  40. gpu_stack_inspector-0.1.0.dist-info/entry_points.txt +3 -0
  41. 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
+ )