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,160 @@
|
|
|
1
|
+
"""CUDA toolkits on disk and the nvcc on PATH."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import os
|
|
7
|
+
import re
|
|
8
|
+
import shutil
|
|
9
|
+
from collections.abc import Callable, Mapping, Sequence
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
|
|
12
|
+
from gpu_stack_inspector.models import CudaToolkit, FailureKind, ProbeFailure, ToolkitFacts
|
|
13
|
+
from gpu_stack_inspector.runner import CommandRunner, probe_failure
|
|
14
|
+
|
|
15
|
+
TIMEOUT = 8.0
|
|
16
|
+
DEFAULT_ROOTS: tuple[Path, ...] = () if os.name == "nt" else (Path("/usr/local"), Path("/opt"))
|
|
17
|
+
"""Where toolkits are installed: /usr/local/cuda-12.4 (NVIDIA's installer), /opt/cuda (Arch)."""
|
|
18
|
+
|
|
19
|
+
_MAX_FILE_BYTES = 64 * 1024
|
|
20
|
+
_PROBE = "cuda-toolkits"
|
|
21
|
+
_ENV_DIRS = ("CUDA_HOME", "CUDA_PATH", "CUDA_ROOT")
|
|
22
|
+
"""Variables naming a toolkit; Windows also sets one CUDA_PATH_V<major>_<minor> per version."""
|
|
23
|
+
_TOOLKIT_DIR = re.compile(r"cuda(?:-[0-9]+(?:\.[0-9]+)*)?")
|
|
24
|
+
_VERSION_TXT = re.compile(r"CUDA Version\s+(?P<version>[0-9]+(?:\.[0-9]+)+)")
|
|
25
|
+
_CUDA_H = re.compile(r"^\s*#\s*define\s+CUDA_VERSION\s+(?P<number>[0-9]{4,6})\b", re.MULTILINE)
|
|
26
|
+
_ENV = {"LC_ALL": "C"}
|
|
27
|
+
_NVCC_RELEASE = re.compile(
|
|
28
|
+
r"release\s+(?P<release>[0-9]+(?:\.[0-9]+)*),\s*V(?P<full>[0-9]+(?:\.[0-9]+)*)"
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def collect_toolkits(
|
|
33
|
+
runner: CommandRunner,
|
|
34
|
+
*,
|
|
35
|
+
which: Callable[[str], str | None] = shutil.which,
|
|
36
|
+
roots: Sequence[Path] = DEFAULT_ROOTS,
|
|
37
|
+
environ: Mapping[str, str] = os.environ,
|
|
38
|
+
timeout: float = TIMEOUT,
|
|
39
|
+
) -> ToolkitFacts:
|
|
40
|
+
"""Find CUDA toolkit directories and the nvcc on PATH.
|
|
41
|
+
|
|
42
|
+
Directories are only listed and small version files read; the only command run is
|
|
43
|
+
``nvcc --version``.
|
|
44
|
+
"""
|
|
45
|
+
toolkits, dir_failures = find_toolkits(roots, environ)
|
|
46
|
+
nvcc_facts = _collect_nvcc(runner, which, timeout)
|
|
47
|
+
return ToolkitFacts(
|
|
48
|
+
nvcc_path=nvcc_facts.nvcc_path,
|
|
49
|
+
nvcc_version=nvcc_facts.nvcc_version,
|
|
50
|
+
toolkits=tuple(toolkits),
|
|
51
|
+
failures=(*dir_failures, *nvcc_facts.failures),
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def find_toolkits(
|
|
56
|
+
roots: Sequence[Path], environ: Mapping[str, str] | None = None
|
|
57
|
+
) -> tuple[list[CudaToolkit], list[ProbeFailure]]:
|
|
58
|
+
"""Toolkit directories under each root and named by CUDA_HOME/CUDA_PATH/….
|
|
59
|
+
|
|
60
|
+
Each directory is reported once under its real path; symlinks to it, such as
|
|
61
|
+
``/usr/local/cuda``, are listed in ``linked_from``. A variable that names a missing
|
|
62
|
+
directory is a failure worth reporting: tools that read it will not find CUDA.
|
|
63
|
+
"""
|
|
64
|
+
candidates: list[Path] = []
|
|
65
|
+
for root in roots:
|
|
66
|
+
try:
|
|
67
|
+
entries = sorted(root.iterdir())
|
|
68
|
+
except OSError: # a missing or unreadable root is normal
|
|
69
|
+
continue
|
|
70
|
+
candidates.extend(entry for entry in entries if _TOOLKIT_DIR.fullmatch(entry.name))
|
|
71
|
+
|
|
72
|
+
failures: list[ProbeFailure] = []
|
|
73
|
+
env = environ or {}
|
|
74
|
+
names = [*_ENV_DIRS, *sorted(n for n in env if n.startswith("CUDA_PATH_V"))]
|
|
75
|
+
for name in names:
|
|
76
|
+
value = env.get(name, "")
|
|
77
|
+
if not value:
|
|
78
|
+
continue
|
|
79
|
+
if Path(value).is_dir():
|
|
80
|
+
candidates.append(Path(value))
|
|
81
|
+
else:
|
|
82
|
+
failures.append(
|
|
83
|
+
ProbeFailure(
|
|
84
|
+
_PROBE, FailureKind.NOT_FOUND, f"{name} names {value}, which is not a directory"
|
|
85
|
+
)
|
|
86
|
+
)
|
|
87
|
+
|
|
88
|
+
aliases: dict[Path, list[str]] = {}
|
|
89
|
+
for candidate in candidates:
|
|
90
|
+
try:
|
|
91
|
+
target = candidate.resolve(strict=True)
|
|
92
|
+
except OSError: # dangling symlink
|
|
93
|
+
continue
|
|
94
|
+
if not target.is_dir():
|
|
95
|
+
continue
|
|
96
|
+
links = aliases.setdefault(target, [])
|
|
97
|
+
if candidate.is_symlink() and str(candidate) not in links:
|
|
98
|
+
links.append(str(candidate))
|
|
99
|
+
|
|
100
|
+
toolkits = []
|
|
101
|
+
for target, links in aliases.items():
|
|
102
|
+
version, source = read_toolkit_version(target)
|
|
103
|
+
toolkits.append(CudaToolkit(str(target), version, source, tuple(links)))
|
|
104
|
+
return toolkits, failures
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def read_toolkit_version(directory: Path) -> tuple[str | None, str | None]:
|
|
108
|
+
"""The toolkit's version and the file it came from, or ``(None, None)``."""
|
|
109
|
+
text = _read_small(directory / "version.json")
|
|
110
|
+
if text is not None:
|
|
111
|
+
try:
|
|
112
|
+
version = json.loads(text)["cuda"]["version"]
|
|
113
|
+
except (ValueError, KeyError, TypeError):
|
|
114
|
+
version = None
|
|
115
|
+
if isinstance(version, str) and version:
|
|
116
|
+
return version, "version.json"
|
|
117
|
+
text = _read_small(directory / "version.txt") # CUDA 10.x and older
|
|
118
|
+
match = _VERSION_TXT.search(text) if text is not None else None
|
|
119
|
+
if match:
|
|
120
|
+
return match["version"], "version.txt"
|
|
121
|
+
text = _read_small(directory / "include" / "cuda.h")
|
|
122
|
+
match = _CUDA_H.search(text) if text is not None else None
|
|
123
|
+
if match:
|
|
124
|
+
# CUDA_VERSION is 1000 * major + 10 * minor: 12040 is 12.4.
|
|
125
|
+
number = int(match["number"])
|
|
126
|
+
return f"{number // 1000}.{number % 1000 // 10}", "include/cuda.h"
|
|
127
|
+
return None, None
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def _read_small(path: Path) -> str | None:
|
|
131
|
+
try:
|
|
132
|
+
with path.open("rb") as handle:
|
|
133
|
+
return handle.read(_MAX_FILE_BYTES).decode("utf-8", errors="replace")
|
|
134
|
+
except OSError:
|
|
135
|
+
return None
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def _collect_nvcc(
|
|
139
|
+
runner: CommandRunner, which: Callable[[str], str | None], timeout: float
|
|
140
|
+
) -> ToolkitFacts:
|
|
141
|
+
nvcc = which("nvcc")
|
|
142
|
+
if nvcc is None:
|
|
143
|
+
return ToolkitFacts()
|
|
144
|
+
result = runner.run([nvcc, "--version"], timeout=timeout, env_overrides=_ENV)
|
|
145
|
+
failure = probe_failure("nvcc", result)
|
|
146
|
+
if failure is not None:
|
|
147
|
+
return ToolkitFacts(nvcc_path=nvcc, failures=(failure,))
|
|
148
|
+
version = parse_nvcc_version(result.stdout)
|
|
149
|
+
failures: tuple[ProbeFailure, ...] = ()
|
|
150
|
+
if version is None:
|
|
151
|
+
failures = (
|
|
152
|
+
ProbeFailure("nvcc", FailureKind.MALFORMED_OUTPUT, "nvcc --version printed no release"),
|
|
153
|
+
)
|
|
154
|
+
return ToolkitFacts(nvcc_path=nvcc, nvcc_version=version, failures=failures)
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def parse_nvcc_version(stdout: str) -> str | None:
|
|
158
|
+
"""``Cuda compilation tools, release 12.4, V12.4.131`` → ``12.4.131``."""
|
|
159
|
+
match = _NVCC_RELEASE.search(stdout)
|
|
160
|
+
return match["full"] if match else None
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Versioned, source-tagged compatibility data and its loader."""
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
{
|
|
2
|
+
"schema": 1,
|
|
3
|
+
"retrieved": "2026-11-03",
|
|
4
|
+
"sources": {
|
|
5
|
+
"nvidia-cuda-compatibility": {
|
|
6
|
+
"title": "NVIDIA: CUDA Compatibility",
|
|
7
|
+
"url": "https://docs.nvidia.com/deploy/cuda-compatibility/latest/"
|
|
8
|
+
},
|
|
9
|
+
"nvidia-minor-version-compatibility": {
|
|
10
|
+
"title": "NVIDIA: CUDA Compatibility, Minor Version Compatibility",
|
|
11
|
+
"url": "https://docs.nvidia.com/deploy/cuda-compatibility/minor-version-compatibility.html"
|
|
12
|
+
}
|
|
13
|
+
},
|
|
14
|
+
"minor_version_compatibility": {
|
|
15
|
+
"from_cuda_major": 11,
|
|
16
|
+
"source": "nvidia-minor-version-compatibility",
|
|
17
|
+
"note": "Applications built within a CUDA major release family run on a driver of that family's minimum or newer, with a limited feature set; code compiled to PTX does not work on older drivers."
|
|
18
|
+
},
|
|
19
|
+
"minimum_driver": [
|
|
20
|
+
{"cuda_major": 11, "driver": "450", "source": "nvidia-minor-version-compatibility"},
|
|
21
|
+
{"cuda_major": 12, "driver": "525", "source": "nvidia-minor-version-compatibility"},
|
|
22
|
+
{"cuda_major": 13, "driver": "580", "source": "nvidia-minor-version-compatibility"}
|
|
23
|
+
]
|
|
24
|
+
}
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
"""Loads data.json and refuses data that is not tied to a source."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import functools
|
|
6
|
+
import json
|
|
7
|
+
import re
|
|
8
|
+
from dataclasses import dataclass
|
|
9
|
+
from importlib.resources import files
|
|
10
|
+
from typing import Any
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@dataclass(frozen=True, slots=True)
|
|
14
|
+
class Source:
|
|
15
|
+
id: str
|
|
16
|
+
title: str
|
|
17
|
+
url: str
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@dataclass(frozen=True, slots=True)
|
|
21
|
+
class CompatibilityData:
|
|
22
|
+
"""CUDA compatibility facts, each tied to the document it was taken from."""
|
|
23
|
+
|
|
24
|
+
retrieved: str
|
|
25
|
+
sources: dict[str, Source]
|
|
26
|
+
minor_version_from_major: int
|
|
27
|
+
minor_version_source: Source
|
|
28
|
+
minor_version_note: str
|
|
29
|
+
minimum_driver: dict[int, tuple[str, Source]]
|
|
30
|
+
"""CUDA major version → (minimum driver version, source)."""
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class CompatibilityDataError(ValueError):
|
|
34
|
+
pass
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
@functools.cache
|
|
38
|
+
def load() -> CompatibilityData:
|
|
39
|
+
raw = files("gpu_stack_inspector.compatibility").joinpath("data.json").read_text("utf-8")
|
|
40
|
+
return parse(json.loads(raw))
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def parse(data: dict[str, Any]) -> CompatibilityData:
|
|
44
|
+
if data.get("schema") != 1:
|
|
45
|
+
raise CompatibilityDataError(f"unsupported schema {data.get('schema')!r}")
|
|
46
|
+
retrieved = data.get("retrieved", "")
|
|
47
|
+
if not re.fullmatch(r"\d{4}-\d{2}-\d{2}", str(retrieved)):
|
|
48
|
+
raise CompatibilityDataError("retrieved must be a YYYY-MM-DD date")
|
|
49
|
+
sources = {
|
|
50
|
+
key: Source(key, value["title"], value["url"]) for key, value in data["sources"].items()
|
|
51
|
+
}
|
|
52
|
+
for source in sources.values():
|
|
53
|
+
if not source.url.startswith("https://"):
|
|
54
|
+
raise CompatibilityDataError(f"source {source.id} has no https URL")
|
|
55
|
+
|
|
56
|
+
def source_of(entry: dict[str, Any]) -> Source:
|
|
57
|
+
key = entry.get("source")
|
|
58
|
+
if key not in sources:
|
|
59
|
+
raise CompatibilityDataError(f"unknown source {key!r} in {entry}")
|
|
60
|
+
return sources[key]
|
|
61
|
+
|
|
62
|
+
minor = data["minor_version_compatibility"]
|
|
63
|
+
minimum: dict[int, tuple[str, Source]] = {}
|
|
64
|
+
for row in data["minimum_driver"]:
|
|
65
|
+
if not re.fullmatch(r"\d+(\.\d+)*", str(row["driver"])):
|
|
66
|
+
raise CompatibilityDataError(f"driver version {row['driver']!r} is not a version")
|
|
67
|
+
minimum[int(row["cuda_major"])] = (str(row["driver"]), source_of(row))
|
|
68
|
+
return CompatibilityData(
|
|
69
|
+
retrieved=retrieved,
|
|
70
|
+
sources=sources,
|
|
71
|
+
minor_version_from_major=int(minor["from_cuda_major"]),
|
|
72
|
+
minor_version_source=source_of(minor),
|
|
73
|
+
minor_version_note=str(minor["note"]),
|
|
74
|
+
minimum_driver=minimum,
|
|
75
|
+
)
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
"""Comparing dotted version numbers as reported by drivers, nvcc and wheels."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
|
|
7
|
+
_LEADING_VERSION = re.compile(r"[0-9]+(?:\.[0-9]+)*")
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def version_tuple(text: str | None) -> tuple[int, ...] | None:
|
|
11
|
+
"""``12.8`` → ``(12, 8)``, ``595.99.02`` → ``(595, 99, 2)``; no leading number → None."""
|
|
12
|
+
if not text:
|
|
13
|
+
return None
|
|
14
|
+
match = _LEADING_VERSION.match(text.strip())
|
|
15
|
+
if match is None:
|
|
16
|
+
return None
|
|
17
|
+
return tuple(int(part) for part in match.group(0).split("."))
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def major_minor(text: str | None) -> tuple[int, int] | None:
|
|
21
|
+
"""The CUDA-relevant part of a version: ``12.4.131`` → ``(12, 4)``, ``13`` → ``(13, 0)``."""
|
|
22
|
+
parts = version_tuple(text)
|
|
23
|
+
if parts is None:
|
|
24
|
+
return None
|
|
25
|
+
return parts[0], parts[1] if len(parts) > 1 else 0
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
"""Exit codes are a contract: scripts and CI jobs depend on them."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from enum import IntEnum
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class ExitCode(IntEnum):
|
|
9
|
+
OK = 0
|
|
10
|
+
"""The inspection ran. Findings, even failing ones, do not change this."""
|
|
11
|
+
|
|
12
|
+
STRICT_FAILURE = 1
|
|
13
|
+
"""``--strict`` was given and a finding reached the configured fail level."""
|
|
14
|
+
|
|
15
|
+
ERROR = 2
|
|
16
|
+
"""Wrong usage, or the tool itself failed."""
|
|
17
|
+
|
|
18
|
+
CONTEXT_UNAVAILABLE = 3
|
|
19
|
+
"""Something the command needs is missing, e.g. an interpreter that was named."""
|
|
20
|
+
|
|
21
|
+
INTERRUPTED = 130
|
|
22
|
+
"""Stopped with Ctrl+C (128 + SIGINT, as shells report it)."""
|
|
@@ -0,0 +1,349 @@
|
|
|
1
|
+
"""Typed facts and findings shared by collectors, rules and renderers."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
from datetime import UTC, datetime
|
|
8
|
+
from enum import StrEnum
|
|
9
|
+
|
|
10
|
+
SCHEMA_VERSION = 1
|
|
11
|
+
"""Version of the snapshot/JSON report layout; bumped only for incompatible changes."""
|
|
12
|
+
|
|
13
|
+
FINDING_ID = re.compile(r"GSI-[A-Z]+-[0-9]{3}")
|
|
14
|
+
"""Finding codes are part of the public contract: ``GSI-<AREA>-<NNN>``, e.g. ``GSI-TORCH-002``."""
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class Status(StrEnum):
|
|
18
|
+
"""Outcome of one check.
|
|
19
|
+
|
|
20
|
+
Only ``PASS`` means that a check ran and succeeded. A check that could not run is
|
|
21
|
+
``UNKNOWN``; one that was deliberately skipped (an opt-in probe nobody asked for) is
|
|
22
|
+
``NOT_TESTED``; one that does not apply to this machine is ``NOT_APPLICABLE``.
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
PASS = "pass" # noqa: S105 - a status name, not a password
|
|
26
|
+
WARN = "warn"
|
|
27
|
+
FAIL = "fail"
|
|
28
|
+
UNKNOWN = "unknown"
|
|
29
|
+
NOT_APPLICABLE = "not_applicable"
|
|
30
|
+
NOT_TESTED = "not_tested"
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class Severity(StrEnum):
|
|
34
|
+
"""How much a finding matters to the user's goal, independent of certainty."""
|
|
35
|
+
|
|
36
|
+
INFO = "info"
|
|
37
|
+
LOW = "low"
|
|
38
|
+
MEDIUM = "medium"
|
|
39
|
+
HIGH = "high"
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class Confidence(StrEnum):
|
|
43
|
+
"""How well the evidence supports a finding."""
|
|
44
|
+
|
|
45
|
+
LOW = "low"
|
|
46
|
+
MEDIUM = "medium"
|
|
47
|
+
HIGH = "high"
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
@dataclass(frozen=True, slots=True)
|
|
51
|
+
class Evidence:
|
|
52
|
+
"""One observed fact that a finding relies on.
|
|
53
|
+
|
|
54
|
+
``source`` names where the fact came from (``nvidia-smi``, ``python-metadata``…),
|
|
55
|
+
``key`` what it is (``driver_version``…). ``value`` is ``None`` when the fact is
|
|
56
|
+
missing, which is itself evidence.
|
|
57
|
+
"""
|
|
58
|
+
|
|
59
|
+
source: str
|
|
60
|
+
key: str
|
|
61
|
+
value: str | None
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
@dataclass(frozen=True, slots=True)
|
|
65
|
+
class Finding:
|
|
66
|
+
"""A diagnosis with everything a user needs to judge and verify it.
|
|
67
|
+
|
|
68
|
+
``subject`` says what the finding is about: ``host`` or the label of an inspected
|
|
69
|
+
environment. ``verify_command`` is a command the user can run to check the result
|
|
70
|
+
after acting on ``recommended_action``. ``caused_by`` names the finding this one
|
|
71
|
+
follows from (``GSI-SYS-001`` for "driver not checked because nvidia-smi is missing"),
|
|
72
|
+
so reports can show one root cause instead of a cascade.
|
|
73
|
+
"""
|
|
74
|
+
|
|
75
|
+
id: str
|
|
76
|
+
status: Status
|
|
77
|
+
severity: Severity
|
|
78
|
+
confidence: Confidence
|
|
79
|
+
summary: str
|
|
80
|
+
reason: str
|
|
81
|
+
subject: str = "host"
|
|
82
|
+
evidence: tuple[Evidence, ...] = ()
|
|
83
|
+
recommended_action: str | None = None
|
|
84
|
+
verify_command: str | None = None
|
|
85
|
+
limitations: tuple[str, ...] = ()
|
|
86
|
+
caused_by: str | None = None
|
|
87
|
+
|
|
88
|
+
def __post_init__(self) -> None:
|
|
89
|
+
if not FINDING_ID.fullmatch(self.id):
|
|
90
|
+
raise ValueError(f"finding id {self.id!r} does not match GSI-<AREA>-<NNN>")
|
|
91
|
+
if self.caused_by is not None and not FINDING_ID.fullmatch(self.caused_by):
|
|
92
|
+
raise ValueError(f"caused_by {self.caused_by!r} does not match GSI-<AREA>-<NNN>")
|
|
93
|
+
if not self.summary.strip():
|
|
94
|
+
raise ValueError(f"finding {self.id} has an empty summary")
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
class FailureKind(StrEnum):
|
|
98
|
+
"""Why a probe produced no usable facts."""
|
|
99
|
+
|
|
100
|
+
NOT_FOUND = "not_found"
|
|
101
|
+
PERMISSION_DENIED = "permission_denied"
|
|
102
|
+
TIMEOUT = "timeout"
|
|
103
|
+
NONZERO_EXIT = "nonzero_exit"
|
|
104
|
+
MALFORMED_OUTPUT = "malformed_output"
|
|
105
|
+
OS_ERROR = "os_error"
|
|
106
|
+
PROBE_ERROR = "probe_error"
|
|
107
|
+
"""The probe ran, but reported that part of its own work failed."""
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
@dataclass(frozen=True, slots=True)
|
|
111
|
+
class ProbeFailure:
|
|
112
|
+
"""A probe that could not deliver its facts.
|
|
113
|
+
|
|
114
|
+
Collectors return these instead of raising, so one broken tool never hides the
|
|
115
|
+
results of the others, and rules can report ``unknown`` with the reason.
|
|
116
|
+
"""
|
|
117
|
+
|
|
118
|
+
probe: str
|
|
119
|
+
kind: FailureKind
|
|
120
|
+
detail: str
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def utc_timestamp() -> str:
|
|
124
|
+
"""Current time as an ISO 8601 UTC string with second precision."""
|
|
125
|
+
return datetime.now(UTC).strftime("%Y-%m-%dT%H:%M:%SZ")
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
@dataclass(frozen=True, slots=True)
|
|
129
|
+
class RedactionCount:
|
|
130
|
+
"""How many values of one kind (``home_path``, ``secret_value``…) were redacted."""
|
|
131
|
+
|
|
132
|
+
kind: str
|
|
133
|
+
count: int
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
@dataclass(frozen=True, slots=True)
|
|
137
|
+
class Snapshot:
|
|
138
|
+
"""Everything one run observed and concluded; the input of every renderer.
|
|
139
|
+
|
|
140
|
+
``probe_failures`` collects the failures of every collector in one place, so a report
|
|
141
|
+
can list what could not be checked.
|
|
142
|
+
"""
|
|
143
|
+
|
|
144
|
+
tool_version: str
|
|
145
|
+
created_at: str
|
|
146
|
+
schema_version: int = SCHEMA_VERSION
|
|
147
|
+
host: HostFacts | None = None
|
|
148
|
+
nvidia: NvidiaFacts | None = None
|
|
149
|
+
toolkit: ToolkitFacts | None = None
|
|
150
|
+
cuda_environment: tuple[EnvironmentVariable, ...] = ()
|
|
151
|
+
interpreters: tuple[InterpreterFacts, ...] = ()
|
|
152
|
+
comparison: tuple[FieldComparison, ...] = ()
|
|
153
|
+
"""Field-by-field differences between the interpreters, when they were compared."""
|
|
154
|
+
probe_failures: tuple[ProbeFailure, ...] = ()
|
|
155
|
+
findings: tuple[Finding, ...] = ()
|
|
156
|
+
redactions: tuple[RedactionCount, ...] = ()
|
|
157
|
+
"""What redaction removed before this snapshot was output; empty if nothing was."""
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
@dataclass(frozen=True, slots=True)
|
|
161
|
+
class HostFacts:
|
|
162
|
+
"""The operating system the inspection ran on."""
|
|
163
|
+
|
|
164
|
+
system: str
|
|
165
|
+
"""``platform.system()``: ``Linux``, ``Windows``, ``Darwin``…"""
|
|
166
|
+
machine: str
|
|
167
|
+
"""CPU architecture as the OS reports it: ``x86_64``, ``AMD64``, ``aarch64``…"""
|
|
168
|
+
kernel: str | None
|
|
169
|
+
distribution: str | None = None
|
|
170
|
+
"""``PRETTY_NAME`` from os-release on Linux, e.g. ``Ubuntu 26.04.1 LTS``."""
|
|
171
|
+
wsl: bool = False
|
|
172
|
+
"""Running inside Windows Subsystem for Linux (a fact only; WSL is not validated yet)."""
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
@dataclass(frozen=True, slots=True)
|
|
176
|
+
class GpuDevice:
|
|
177
|
+
"""One GPU as nvidia-smi lists it. Values it reports as N/A are ``None``."""
|
|
178
|
+
|
|
179
|
+
index: int
|
|
180
|
+
name: str
|
|
181
|
+
memory_total_mib: int | None = None
|
|
182
|
+
compute_capability: str | None = None
|
|
183
|
+
"""``major.minor``, e.g. ``8.6``; decides which CUDA kernels a wheel can run."""
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
@dataclass(frozen=True, slots=True)
|
|
187
|
+
class NvidiaFacts:
|
|
188
|
+
"""What nvidia-smi reported.
|
|
189
|
+
|
|
190
|
+
``smi_path`` is ``None`` when nvidia-smi is not installed (or not on ``PATH``), which
|
|
191
|
+
is a fact, not a failure. ``failures`` say why other fields are missing.
|
|
192
|
+
"""
|
|
193
|
+
|
|
194
|
+
smi_path: str | None
|
|
195
|
+
driver_version: str | None = None
|
|
196
|
+
max_cuda_version: str | None = None
|
|
197
|
+
"""The newest CUDA version the driver supports (nvidia-smi's "CUDA Version").
|
|
198
|
+
|
|
199
|
+
It is not the version of any installed CUDA toolkit or of PyTorch's CUDA runtime.
|
|
200
|
+
"""
|
|
201
|
+
gpus: tuple[GpuDevice, ...] = ()
|
|
202
|
+
failures: tuple[ProbeFailure, ...] = ()
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
@dataclass(frozen=True, slots=True)
|
|
206
|
+
class CudaToolkit:
|
|
207
|
+
"""A CUDA toolkit directory found on disk.
|
|
208
|
+
|
|
209
|
+
``linked_from`` lists symlinks that point at it, e.g. ``/usr/local/cuda`` for the
|
|
210
|
+
default toolkit. ``version_source`` names the file the version was read from.
|
|
211
|
+
"""
|
|
212
|
+
|
|
213
|
+
path: str
|
|
214
|
+
version: str | None = None
|
|
215
|
+
version_source: str | None = None
|
|
216
|
+
linked_from: tuple[str, ...] = ()
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
@dataclass(frozen=True, slots=True)
|
|
220
|
+
class ToolkitFacts:
|
|
221
|
+
"""Locally installed CUDA toolkits and the ``nvcc`` on ``PATH``.
|
|
222
|
+
|
|
223
|
+
Prebuilt PyTorch wheels bring their own CUDA runtime, so a missing toolkit is not a
|
|
224
|
+
problem by itself; it matters for compiling extensions.
|
|
225
|
+
"""
|
|
226
|
+
|
|
227
|
+
nvcc_path: str | None = None
|
|
228
|
+
nvcc_version: str | None = None
|
|
229
|
+
toolkits: tuple[CudaToolkit, ...] = ()
|
|
230
|
+
failures: tuple[ProbeFailure, ...] = ()
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
@dataclass(frozen=True, slots=True)
|
|
234
|
+
class EnvironmentVariable:
|
|
235
|
+
"""An allowlisted variable that was set. An empty value is kept: for
|
|
236
|
+
``CUDA_VISIBLE_DEVICES`` it means "no GPU is visible"."""
|
|
237
|
+
|
|
238
|
+
name: str
|
|
239
|
+
value: str
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
@dataclass(frozen=True, slots=True)
|
|
243
|
+
class Distribution:
|
|
244
|
+
"""An installed distribution relevant to GPU use, as the target interpreter sees it.
|
|
245
|
+
|
|
246
|
+
``location`` is the directory it was found in; the same name in two locations (a
|
|
247
|
+
user site and the environment, say) is how shadowed installs show up.
|
|
248
|
+
"""
|
|
249
|
+
|
|
250
|
+
name: str
|
|
251
|
+
version: str
|
|
252
|
+
location: str | None = None
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
@dataclass(frozen=True, slots=True)
|
|
256
|
+
class TorchFacts:
|
|
257
|
+
"""Static evidence about the torch an interpreter would import. Nothing was imported.
|
|
258
|
+
|
|
259
|
+
``built_cuda``/``built_hip`` come from torch/version.py; they are only meaningful when
|
|
260
|
+
``version_py_found`` is true, since ``None`` there means "built without CUDA".
|
|
261
|
+
``conda_records`` is ``None`` outside conda environments.
|
|
262
|
+
"""
|
|
263
|
+
|
|
264
|
+
version: str
|
|
265
|
+
location: str | None = None
|
|
266
|
+
requires_nvidia: tuple[str, ...] = ()
|
|
267
|
+
cuda_library: bool | None = None
|
|
268
|
+
hip_library: bool | None = None
|
|
269
|
+
version_py_found: bool = False
|
|
270
|
+
built_cuda: str | None = None
|
|
271
|
+
built_hip: str | None = None
|
|
272
|
+
conda_records: tuple[str, ...] | None = None
|
|
273
|
+
|
|
274
|
+
|
|
275
|
+
@dataclass(frozen=True, slots=True)
|
|
276
|
+
class RuntimeStep:
|
|
277
|
+
"""One step of the runtime probe: import torch, cuda available, devices, tensor operation."""
|
|
278
|
+
|
|
279
|
+
step: str
|
|
280
|
+
ok: bool
|
|
281
|
+
seconds: float | None = None
|
|
282
|
+
error: str | None = None
|
|
283
|
+
|
|
284
|
+
|
|
285
|
+
@dataclass(frozen=True, slots=True)
|
|
286
|
+
class RuntimeFacts:
|
|
287
|
+
"""What happened when torch was imported and CUDA used (only with --runtime).
|
|
288
|
+
|
|
289
|
+
``failure`` is set when the interpreter itself crashed, hung or printed no result; the
|
|
290
|
+
steps then end with the one it was in.
|
|
291
|
+
"""
|
|
292
|
+
|
|
293
|
+
torch_version: str | None = None
|
|
294
|
+
cuda_version: str | None = None
|
|
295
|
+
"""torch.version.cuda at runtime: the CUDA version the build targets."""
|
|
296
|
+
cuda_available: bool | None = None
|
|
297
|
+
devices: tuple[GpuDevice, ...] = ()
|
|
298
|
+
tensor_ok: bool | None = None
|
|
299
|
+
steps: tuple[RuntimeStep, ...] = ()
|
|
300
|
+
failure: ProbeFailure | None = None
|
|
301
|
+
|
|
302
|
+
|
|
303
|
+
@dataclass(frozen=True, slots=True)
|
|
304
|
+
class InterpreterFacts:
|
|
305
|
+
"""What one target Python interpreter reported about itself.
|
|
306
|
+
|
|
307
|
+
``path`` is the interpreter as the user named it, made absolute but with symlinks
|
|
308
|
+
kept: a virtual environment's ``bin/python`` is a symlink, and following it would
|
|
309
|
+
start the base interpreter outside the environment.
|
|
310
|
+
"""
|
|
311
|
+
|
|
312
|
+
label: str
|
|
313
|
+
path: str
|
|
314
|
+
executable: str | None = None
|
|
315
|
+
version: str | None = None
|
|
316
|
+
implementation: str | None = None
|
|
317
|
+
prefix: str | None = None
|
|
318
|
+
base_prefix: str | None = None
|
|
319
|
+
conda: bool | None = None
|
|
320
|
+
platform: str | None = None
|
|
321
|
+
user_site: bool | None = None
|
|
322
|
+
pythonpath_set: bool | None = None
|
|
323
|
+
distributions: tuple[Distribution, ...] = ()
|
|
324
|
+
torch: TorchFacts | None = None
|
|
325
|
+
runtime: RuntimeFacts | None = None
|
|
326
|
+
"""None unless the runtime probe was run (``probe --runtime``)."""
|
|
327
|
+
failures: tuple[ProbeFailure, ...] = ()
|
|
328
|
+
|
|
329
|
+
@property
|
|
330
|
+
def virtualenv(self) -> bool | None:
|
|
331
|
+
"""Whether the interpreter runs inside a venv/virtualenv, if that is known."""
|
|
332
|
+
if self.prefix is None or self.base_prefix is None:
|
|
333
|
+
return None
|
|
334
|
+
return self.prefix != self.base_prefix
|
|
335
|
+
|
|
336
|
+
|
|
337
|
+
@dataclass(frozen=True, slots=True)
|
|
338
|
+
class FieldComparison:
|
|
339
|
+
"""One field across the compared interpreters, in their order.
|
|
340
|
+
|
|
341
|
+
``None`` means the interpreter has no value for it (not installed, not inspected).
|
|
342
|
+
"""
|
|
343
|
+
|
|
344
|
+
field: str
|
|
345
|
+
values: tuple[str | None, ...]
|
|
346
|
+
|
|
347
|
+
@property
|
|
348
|
+
def differs(self) -> bool:
|
|
349
|
+
return len(set(self.values)) > 1
|