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,182 @@
|
|
|
1
|
+
"""Markdown output: an issue-ready dossier of an already redacted snapshot.
|
|
2
|
+
|
|
3
|
+
Collected text is escaped so a path containing ``|`` cannot break a table and ``<tag>``
|
|
4
|
+
cannot become HTML in an issue tracker; values go in code spans whose fence is longer
|
|
5
|
+
than any backtick run they contain.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import re
|
|
11
|
+
from collections import Counter
|
|
12
|
+
|
|
13
|
+
from gpu_stack_inspector.models import Finding, InterpreterFacts, Snapshot, Status
|
|
14
|
+
from gpu_stack_inspector.reports.terminal import redaction_line, runtime_summary
|
|
15
|
+
from gpu_stack_inspector.rules.comparison import torch_build_label
|
|
16
|
+
|
|
17
|
+
_SPECIAL = re.compile(r"([\\`*_\[\]<>|#])")
|
|
18
|
+
_PROBLEMS = (Status.FAIL, Status.WARN, Status.UNKNOWN)
|
|
19
|
+
_LABEL = {
|
|
20
|
+
Status.FAIL: "FAIL",
|
|
21
|
+
Status.WARN: "WARN",
|
|
22
|
+
Status.UNKNOWN: "UNKNOWN",
|
|
23
|
+
Status.PASS: "PASS",
|
|
24
|
+
Status.NOT_APPLICABLE: "N/A",
|
|
25
|
+
Status.NOT_TESTED: "NOT TESTED",
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def text(value: str) -> str:
|
|
30
|
+
"""Plain text that Markdown and HTML will not interpret; one line."""
|
|
31
|
+
return _SPECIAL.sub(r"\\\1", " ".join(value.split()))
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def code(value: str) -> str:
|
|
35
|
+
"""An inline code span that survives any backticks inside the value."""
|
|
36
|
+
flat = " ".join(value.split())
|
|
37
|
+
longest = max((len(run) for run in re.findall(r"`+", flat)), default=0)
|
|
38
|
+
fence = "`" * (longest + 1)
|
|
39
|
+
padding = " " if flat.startswith("`") or flat.endswith("`") else ""
|
|
40
|
+
return f"{fence}{padding}{flat}{padding}{fence}"
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _cell(value: str | None, *, as_code: bool = False) -> str:
|
|
44
|
+
if value is None:
|
|
45
|
+
return "—"
|
|
46
|
+
return code(value) if as_code else text(value)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def render_markdown(snapshot: Snapshot) -> str:
|
|
50
|
+
lines: list[str] = ["# GPU Stack Inspector report", ""]
|
|
51
|
+
lines.append(
|
|
52
|
+
f"Generated by gpu-stack-inspector {text(snapshot.tool_version)} on "
|
|
53
|
+
f"{text(snapshot.created_at)}. Read-only: nothing was installed or changed."
|
|
54
|
+
)
|
|
55
|
+
redacted = redaction_line(snapshot)
|
|
56
|
+
if redacted:
|
|
57
|
+
lines.append(f"{text(redacted)}.")
|
|
58
|
+
lines += ["", *_summary(snapshot), "", *_system(snapshot)]
|
|
59
|
+
if snapshot.interpreters:
|
|
60
|
+
lines += ["", *_environments(snapshot.interpreters)]
|
|
61
|
+
if snapshot.comparison:
|
|
62
|
+
lines += ["", *_differences(snapshot)]
|
|
63
|
+
if snapshot.findings:
|
|
64
|
+
lines += ["", *_findings(snapshot)]
|
|
65
|
+
if snapshot.probe_failures:
|
|
66
|
+
lines += ["", "## Not checked", ""]
|
|
67
|
+
lines += [f"- {text(f.probe)}: {text(f.detail)}" for f in snapshot.probe_failures]
|
|
68
|
+
return "\n".join(lines) + "\n"
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def _summary(snapshot: Snapshot) -> list[str]:
|
|
72
|
+
lines = ["## Summary", ""]
|
|
73
|
+
problems = [f for f in snapshot.findings if f.status in _PROBLEMS and not f.caused_by]
|
|
74
|
+
order = {status: i for i, status in enumerate(_PROBLEMS)}
|
|
75
|
+
for finding in sorted(problems, key=lambda f: order[f.status]):
|
|
76
|
+
where = "" if finding.subject == "host" else f" ({text(finding.subject)})"
|
|
77
|
+
lines.append(
|
|
78
|
+
f"- **{_LABEL[finding.status]}** {code(finding.id)}{where}: {text(finding.summary)}"
|
|
79
|
+
)
|
|
80
|
+
if not problems:
|
|
81
|
+
lines.append("No problems found." if snapshot.findings else "No checks were run.")
|
|
82
|
+
counts = Counter(f.status for f in snapshot.findings)
|
|
83
|
+
if snapshot.findings:
|
|
84
|
+
lines += [
|
|
85
|
+
"",
|
|
86
|
+
f"{counts[Status.FAIL]} failed, {counts[Status.WARN]} warnings, "
|
|
87
|
+
f"{counts[Status.UNKNOWN]} unknown, {counts[Status.PASS]} passed.",
|
|
88
|
+
]
|
|
89
|
+
return lines
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def _system(snapshot: Snapshot) -> list[str]:
|
|
93
|
+
rows: list[tuple[str, str]] = []
|
|
94
|
+
host = snapshot.host
|
|
95
|
+
if host is not None:
|
|
96
|
+
system = " · ".join(p for p in (f"{host.system} {host.machine}", host.distribution) if p)
|
|
97
|
+
rows.append(("OS", text(system)))
|
|
98
|
+
rows.append(("Kernel", _cell(host.kernel)))
|
|
99
|
+
nvidia = snapshot.nvidia
|
|
100
|
+
if nvidia is not None:
|
|
101
|
+
if nvidia.smi_path is None:
|
|
102
|
+
rows.append(("NVIDIA driver", "nvidia-smi not found"))
|
|
103
|
+
else:
|
|
104
|
+
driver = _cell(nvidia.driver_version)
|
|
105
|
+
cuda = _cell(nvidia.max_cuda_version)
|
|
106
|
+
rows.append(("NVIDIA driver", f"{driver} (supports CUDA up to {cuda})"))
|
|
107
|
+
for gpu in nvidia.gpus:
|
|
108
|
+
memory = f"{gpu.memory_total_mib} MiB" if gpu.memory_total_mib is not None else "? MiB"
|
|
109
|
+
details = f"{text(gpu.name)}, {memory}, compute {_cell(gpu.compute_capability)}"
|
|
110
|
+
rows.append((f"GPU {gpu.index}", details))
|
|
111
|
+
toolkit = snapshot.toolkit
|
|
112
|
+
if toolkit is not None:
|
|
113
|
+
rows.append(("nvcc", _cell(toolkit.nvcc_version) if toolkit.nvcc_path else "not installed"))
|
|
114
|
+
for found in toolkit.toolkits:
|
|
115
|
+
rows.append(("CUDA toolkit", f"{code(found.path)} ({_cell(found.version)})"))
|
|
116
|
+
for variable in snapshot.cuda_environment:
|
|
117
|
+
rows.append((text(variable.name), code(variable.value) if variable.value else '`""`'))
|
|
118
|
+
if not rows:
|
|
119
|
+
return ["## System", "", "Not inspected."]
|
|
120
|
+
return ["## System", "", "| | |", "| --- | --- |", *(f"| {k} | {v} |" for k, v in rows)]
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def _environments(interpreters: tuple[InterpreterFacts, ...]) -> list[str]:
|
|
124
|
+
lines = [
|
|
125
|
+
"## Environments",
|
|
126
|
+
"",
|
|
127
|
+
"| | Path | Python | Kind | torch | Build | Runtime |",
|
|
128
|
+
"| --- | --- | --- | --- | --- | --- | --- |",
|
|
129
|
+
]
|
|
130
|
+
for i in interpreters:
|
|
131
|
+
kind = ", ".join(k for k, on in (("conda", i.conda), ("venv", i.virtualenv)) if on) or "—"
|
|
132
|
+
torch = i.torch.version if i.torch else None
|
|
133
|
+
runtime = runtime_summary(i) or "not tested"
|
|
134
|
+
lines.append(
|
|
135
|
+
f"| {text(i.label)} | {code(i.path)} | {_cell(i.version)} | {kind} "
|
|
136
|
+
f"| {_cell(torch)} | {_cell(torch_build_label(i))} | {text(runtime)} |"
|
|
137
|
+
)
|
|
138
|
+
return lines
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def _differences(snapshot: Snapshot) -> list[str]:
|
|
142
|
+
rows = [row for row in snapshot.comparison if row.differs]
|
|
143
|
+
labels = [text(i.label) for i in snapshot.interpreters]
|
|
144
|
+
lines = ["## Differences", ""]
|
|
145
|
+
if not rows:
|
|
146
|
+
return [*lines, "No differences in the compared fields."]
|
|
147
|
+
lines.append("| Field | " + " | ".join(labels) + " |")
|
|
148
|
+
lines.append("| --- |" + " --- |" * len(labels))
|
|
149
|
+
for row in rows:
|
|
150
|
+
values = " | ".join(_cell(value) for value in row.values)
|
|
151
|
+
lines.append(f"| {text(row.field)} | {values} |")
|
|
152
|
+
return lines
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def _findings(snapshot: Snapshot) -> list[str]:
|
|
156
|
+
paths = {i.label: i.path for i in snapshot.interpreters}
|
|
157
|
+
lines = ["## Findings"]
|
|
158
|
+
for subject in ["host", *paths, "comparison"]:
|
|
159
|
+
group = [f for f in snapshot.findings if f.subject == subject]
|
|
160
|
+
if not group:
|
|
161
|
+
continue
|
|
162
|
+
heading = {"host": "Host", "comparison": "Comparison"}.get(subject)
|
|
163
|
+
lines += ["", f"### {heading or f'{text(subject)} ({code(paths[subject])})'}", ""]
|
|
164
|
+
for finding in group:
|
|
165
|
+
lines += _finding(finding)
|
|
166
|
+
return lines
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def _finding(finding: Finding) -> list[str]:
|
|
170
|
+
summary = text(finding.summary)
|
|
171
|
+
if finding.caused_by:
|
|
172
|
+
summary += f" (follows from {code(finding.caused_by)})"
|
|
173
|
+
lines = [f"- **{_LABEL[finding.status]}** {code(finding.id)} {summary}"]
|
|
174
|
+
if finding.status not in _PROBLEMS:
|
|
175
|
+
return lines
|
|
176
|
+
lines.append(f" - Why: {text(finding.reason)}")
|
|
177
|
+
if finding.recommended_action:
|
|
178
|
+
lines.append(f" - Next: {text(finding.recommended_action)}")
|
|
179
|
+
if finding.verify_command:
|
|
180
|
+
lines.append(f" - Check: {code(finding.verify_command)}")
|
|
181
|
+
lines += [f" - Note: {text(limitation)}" for limitation in finding.limitations]
|
|
182
|
+
return lines
|
|
@@ -0,0 +1,298 @@
|
|
|
1
|
+
"""Terminal output: a short human summary of an already redacted snapshot.
|
|
2
|
+
|
|
3
|
+
Collected text is always added as plain ``Text``, never as Rich markup, so a path or GPU
|
|
4
|
+
name containing ``[bold]`` is printed as it is instead of being interpreted.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from collections import Counter
|
|
10
|
+
|
|
11
|
+
from rich.console import Console
|
|
12
|
+
from rich.table import Table
|
|
13
|
+
from rich.text import Text
|
|
14
|
+
|
|
15
|
+
from gpu_stack_inspector.models import (
|
|
16
|
+
Finding,
|
|
17
|
+
HostFacts,
|
|
18
|
+
InterpreterFacts,
|
|
19
|
+
NvidiaFacts,
|
|
20
|
+
Snapshot,
|
|
21
|
+
Status,
|
|
22
|
+
ToolkitFacts,
|
|
23
|
+
)
|
|
24
|
+
from gpu_stack_inspector.rules.torch_build import TorchBuild, TorchBuildKind, classify_torch
|
|
25
|
+
|
|
26
|
+
_INDENT = " "
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _render_header(snapshot: Snapshot, console: Console) -> None:
|
|
30
|
+
console.print(Text(f"GPU Stack Inspector {snapshot.tool_version}", style="bold"))
|
|
31
|
+
console.print(_row("Host", _host(snapshot.host)))
|
|
32
|
+
nvidia_lines = _nvidia(snapshot.nvidia)
|
|
33
|
+
console.print(_row("NVIDIA", nvidia_lines[0]))
|
|
34
|
+
for line in nvidia_lines[1:]:
|
|
35
|
+
console.print(Text(_INDENT + line))
|
|
36
|
+
console.print(_row("Toolkit", _toolkit(snapshot.toolkit)))
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
_REDACTION_NAMES = {
|
|
40
|
+
"home_path": ("home path", "home paths"),
|
|
41
|
+
"user_name": ("user name", "user names"),
|
|
42
|
+
"host_name": ("host name", "host names"),
|
|
43
|
+
"secret_value": ("secret value", "secret values"),
|
|
44
|
+
"url_credentials": ("URL credential", "URL credentials"),
|
|
45
|
+
"token": ("token", "tokens"),
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def redaction_line(snapshot: Snapshot) -> str | None:
|
|
50
|
+
"""'Redacted from this output: 3 home paths, 1 secret value', or None if nothing was."""
|
|
51
|
+
if not snapshot.redactions:
|
|
52
|
+
return None
|
|
53
|
+
parts = []
|
|
54
|
+
for item in snapshot.redactions:
|
|
55
|
+
singular, plural = _REDACTION_NAMES.get(item.kind, (item.kind, item.kind))
|
|
56
|
+
parts.append(f"{item.count} {singular if item.count == 1 else plural}")
|
|
57
|
+
return "Redacted from this output: " + ", ".join(parts)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _render_failures(snapshot: Snapshot, console: Console) -> None:
|
|
61
|
+
if snapshot.probe_failures:
|
|
62
|
+
console.print()
|
|
63
|
+
console.print(Text("Could not check:", style="bold"))
|
|
64
|
+
for failure in snapshot.probe_failures:
|
|
65
|
+
console.print(Text(f" {failure.probe}: {failure.detail}"))
|
|
66
|
+
line = redaction_line(snapshot)
|
|
67
|
+
if line:
|
|
68
|
+
console.print(Text(line, style="dim"))
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def render_compare(snapshot: Snapshot, console: Console, *, show_all: bool = False) -> None:
|
|
72
|
+
"""The comparison table (only the fields that differ, unless ``show_all``), then findings."""
|
|
73
|
+
_render_header(snapshot, console)
|
|
74
|
+
console.print()
|
|
75
|
+
for interpreter in snapshot.interpreters:
|
|
76
|
+
console.print(Text.assemble((f"{interpreter.label:<3}", "bold"), interpreter.path))
|
|
77
|
+
rows = [row for row in snapshot.comparison if show_all or row.differs]
|
|
78
|
+
console.print()
|
|
79
|
+
if not rows:
|
|
80
|
+
console.print(Text("No differences in the compared fields."))
|
|
81
|
+
else:
|
|
82
|
+
table = Table(box=None, pad_edge=False, show_edge=False)
|
|
83
|
+
table.add_column("Field", no_wrap=True)
|
|
84
|
+
for interpreter in snapshot.interpreters:
|
|
85
|
+
table.add_column(interpreter.label, overflow="fold")
|
|
86
|
+
for row in rows:
|
|
87
|
+
style = "bold" if row.differs else "dim"
|
|
88
|
+
values = [Text(value if value is not None else "—") for value in row.values]
|
|
89
|
+
table.add_row(Text(row.field, style=style), *values)
|
|
90
|
+
console.print(table)
|
|
91
|
+
hidden = len(snapshot.comparison) - len(rows)
|
|
92
|
+
if hidden:
|
|
93
|
+
console.print(Text(f"{hidden} identical fields hidden; --all shows them.", style="dim"))
|
|
94
|
+
if snapshot.findings:
|
|
95
|
+
_render_findings(snapshot, console)
|
|
96
|
+
_render_failures(snapshot, console)
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def render_terminal(snapshot: Snapshot, console: Console) -> None:
|
|
100
|
+
_render_header(snapshot, console)
|
|
101
|
+
if snapshot.cuda_environment:
|
|
102
|
+
values = ", ".join(
|
|
103
|
+
f"{v.name}={v.value}" if v.value else f'{v.name}="" (hides every GPU)'
|
|
104
|
+
for v in snapshot.cuda_environment
|
|
105
|
+
)
|
|
106
|
+
console.print(_row("CUDA env", values))
|
|
107
|
+
for interpreter in snapshot.interpreters:
|
|
108
|
+
console.print()
|
|
109
|
+
console.print(Text.assemble((f"{interpreter.label:<3}", "bold"), interpreter.path))
|
|
110
|
+
console.print(Text(" " + _interpreter(interpreter)))
|
|
111
|
+
if snapshot.findings:
|
|
112
|
+
_render_findings(snapshot, console)
|
|
113
|
+
_render_failures(snapshot, console)
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def _row(name: str, value: str) -> Text:
|
|
117
|
+
return Text.assemble((f"{name:<9}", "bold"), value)
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def _host(host: HostFacts | None) -> str:
|
|
121
|
+
if host is None:
|
|
122
|
+
return "not inspected"
|
|
123
|
+
parts = [f"{host.system} {host.machine}"]
|
|
124
|
+
if host.distribution:
|
|
125
|
+
parts.append(host.distribution)
|
|
126
|
+
if host.kernel:
|
|
127
|
+
parts.append(f"kernel {host.kernel}")
|
|
128
|
+
if host.wsl:
|
|
129
|
+
parts.append("WSL")
|
|
130
|
+
return " · ".join(parts)
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def _nvidia(nvidia: NvidiaFacts | None) -> list[str]:
|
|
134
|
+
if nvidia is None:
|
|
135
|
+
return ["not inspected"]
|
|
136
|
+
if nvidia.smi_path is None:
|
|
137
|
+
return ["nvidia-smi not found (no NVIDIA driver tools on PATH)"]
|
|
138
|
+
if nvidia.driver_version is None and not nvidia.gpus:
|
|
139
|
+
return ["nvidia-smi did not answer (see below)"]
|
|
140
|
+
summary = (
|
|
141
|
+
f"driver {nvidia.driver_version or 'unknown'} · "
|
|
142
|
+
f"supports CUDA up to {nvidia.max_cuda_version or 'unknown'}"
|
|
143
|
+
)
|
|
144
|
+
gpus = [
|
|
145
|
+
f"GPU {gpu.index}: {gpu.name}"
|
|
146
|
+
+ f" ({gpu.memory_total_mib if gpu.memory_total_mib is not None else '?'} MiB,"
|
|
147
|
+
+ f" compute {gpu.compute_capability or 'unknown'})"
|
|
148
|
+
for gpu in nvidia.gpus
|
|
149
|
+
]
|
|
150
|
+
return [summary, *(gpus or ["no GPU visible to the driver"])]
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def _toolkit(toolkit: ToolkitFacts | None) -> str:
|
|
154
|
+
if toolkit is None:
|
|
155
|
+
return "not inspected"
|
|
156
|
+
nvcc = (
|
|
157
|
+
f"nvcc {toolkit.nvcc_version or 'unknown version'} ({toolkit.nvcc_path})"
|
|
158
|
+
if toolkit.nvcc_path
|
|
159
|
+
else "no nvcc on PATH"
|
|
160
|
+
)
|
|
161
|
+
directories = ", ".join(
|
|
162
|
+
f"{t.path} ({t.version or 'unknown version'})" for t in toolkit.toolkits
|
|
163
|
+
)
|
|
164
|
+
return f"{nvcc} · {directories or 'no CUDA toolkit directories found'}"
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def _interpreter(interpreter: InterpreterFacts) -> str:
|
|
168
|
+
if interpreter.version is None:
|
|
169
|
+
return "could not be inspected (see below)"
|
|
170
|
+
kind = [interpreter.implementation or "python"]
|
|
171
|
+
if interpreter.conda:
|
|
172
|
+
kind.append("conda")
|
|
173
|
+
if interpreter.virtualenv:
|
|
174
|
+
kind.append("venv")
|
|
175
|
+
wheels = sum(d.name.startswith("nvidia-") for d in interpreter.distributions)
|
|
176
|
+
parts = [
|
|
177
|
+
f"Python {interpreter.version} ({', '.join(kind)})",
|
|
178
|
+
_torch(interpreter),
|
|
179
|
+
f"{wheels} nvidia-* wheels",
|
|
180
|
+
]
|
|
181
|
+
runtime = runtime_summary(interpreter)
|
|
182
|
+
if runtime:
|
|
183
|
+
parts.append(runtime)
|
|
184
|
+
return " · ".join(parts)
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def runtime_summary(interpreter: InterpreterFacts) -> str | None:
|
|
188
|
+
"""What probe --runtime found, in a few words; None when it was not run."""
|
|
189
|
+
runtime = interpreter.runtime
|
|
190
|
+
if runtime is None:
|
|
191
|
+
return None
|
|
192
|
+
if runtime.tensor_ok:
|
|
193
|
+
device = runtime.devices[0].name if runtime.devices else "the GPU"
|
|
194
|
+
return f"CUDA works on {device}"
|
|
195
|
+
if runtime.cuda_available is False:
|
|
196
|
+
return "CUDA unavailable"
|
|
197
|
+
failed = next((step.step for step in runtime.steps if not step.ok), None)
|
|
198
|
+
return f"runtime check failed at '{failed}'" if failed else "runtime check failed"
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
def _torch(interpreter: InterpreterFacts) -> str:
|
|
202
|
+
if interpreter.torch is None:
|
|
203
|
+
listed = next((d for d in interpreter.distributions if d.name == "torch"), None)
|
|
204
|
+
return f"torch {listed.version} (build not inspected)" if listed else "no torch"
|
|
205
|
+
return f"torch {interpreter.torch.version} ({_build(classify_torch(interpreter.torch))})"
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
def _build(build: TorchBuild) -> str:
|
|
209
|
+
"""Say what the metadata shows, and only that: no CUDA operation has run yet."""
|
|
210
|
+
if build.kind is TorchBuildKind.CUDA:
|
|
211
|
+
target = f"CUDA {build.cuda_version}" if build.cuda_version else "CUDA"
|
|
212
|
+
text = f"{target} build"
|
|
213
|
+
elif build.kind is TorchBuildKind.CPU:
|
|
214
|
+
text = "CPU-only build"
|
|
215
|
+
elif build.kind is TorchBuildKind.ROCM:
|
|
216
|
+
text = "ROCm build, not checked by this tool"
|
|
217
|
+
else:
|
|
218
|
+
text = "build unknown"
|
|
219
|
+
if build.conflicts:
|
|
220
|
+
text += f"; {build.conflicts[0]}"
|
|
221
|
+
elif build.kind is not TorchBuildKind.UNKNOWN and build.confidence.value != "high":
|
|
222
|
+
text += f", {build.confidence.value} confidence"
|
|
223
|
+
return text
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
_LABELS = {
|
|
227
|
+
Status.FAIL: ("FAIL", "bold red"),
|
|
228
|
+
Status.WARN: ("WARN", "yellow"),
|
|
229
|
+
Status.UNKNOWN: ("UNKNOWN", "magenta"),
|
|
230
|
+
Status.PASS: ("PASS", "green"),
|
|
231
|
+
Status.NOT_APPLICABLE: ("N/A", "dim"),
|
|
232
|
+
Status.NOT_TESTED: ("NOT TESTED", "dim"),
|
|
233
|
+
}
|
|
234
|
+
_PROBLEMS = frozenset({Status.FAIL, Status.WARN, Status.UNKNOWN})
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
def _render_findings(snapshot: Snapshot, console: Console) -> None:
|
|
238
|
+
"""Findings per subject (host, then each interpreter) in evaluation order.
|
|
239
|
+
|
|
240
|
+
Problems show their reason, the next step and how to check it; consequences of a
|
|
241
|
+
root cause are dimmed and say which finding they follow from.
|
|
242
|
+
"""
|
|
243
|
+
paths = {interpreter.label: interpreter.path for interpreter in snapshot.interpreters}
|
|
244
|
+
subjects = ["host", *paths, "comparison"]
|
|
245
|
+
console.print()
|
|
246
|
+
console.print(Text("Findings", style="bold"))
|
|
247
|
+
for subject in subjects:
|
|
248
|
+
group = [f for f in snapshot.findings if f.subject == subject]
|
|
249
|
+
if not group:
|
|
250
|
+
continue
|
|
251
|
+
heading = (
|
|
252
|
+
"Host"
|
|
253
|
+
if subject == "host"
|
|
254
|
+
else "Comparison"
|
|
255
|
+
if subject == "comparison"
|
|
256
|
+
else f"{subject} {paths[subject]}"
|
|
257
|
+
)
|
|
258
|
+
console.print(Text(heading, style="bold"))
|
|
259
|
+
table = Table.grid(padding=(0, 1))
|
|
260
|
+
table.add_column(width=10, no_wrap=True)
|
|
261
|
+
table.add_column(width=13, no_wrap=True) # GSI-TORCH-002 is the longest code
|
|
262
|
+
# Fold, never crop: people copy the commands and paths shown here.
|
|
263
|
+
table.add_column(overflow="fold")
|
|
264
|
+
for finding in group:
|
|
265
|
+
_add_finding(table, finding)
|
|
266
|
+
console.print(table)
|
|
267
|
+
console.print(Text(_tally(snapshot.findings)))
|
|
268
|
+
|
|
269
|
+
|
|
270
|
+
def _add_finding(table: Table, finding: Finding) -> None:
|
|
271
|
+
label, style = _LABELS[finding.status]
|
|
272
|
+
summary = finding.summary
|
|
273
|
+
if finding.caused_by:
|
|
274
|
+
summary += f" (follows from {finding.caused_by})"
|
|
275
|
+
style = "dim"
|
|
276
|
+
table.add_row(Text(label, style=style), Text(finding.id, style="dim"), Text(summary))
|
|
277
|
+
if finding.status not in _PROBLEMS:
|
|
278
|
+
return
|
|
279
|
+
details = [finding.reason]
|
|
280
|
+
if finding.recommended_action:
|
|
281
|
+
details.append(f"Next: {finding.recommended_action}")
|
|
282
|
+
if finding.verify_command:
|
|
283
|
+
details.append(f"Check: {finding.verify_command}")
|
|
284
|
+
details.extend(f"Note: {limitation}" for limitation in finding.limitations)
|
|
285
|
+
for detail in details:
|
|
286
|
+
table.add_row(Text(""), Text(""), Text(detail))
|
|
287
|
+
|
|
288
|
+
|
|
289
|
+
def _tally(findings: tuple[Finding, ...]) -> str:
|
|
290
|
+
counts = Counter(f.status for f in findings)
|
|
291
|
+
parts = [
|
|
292
|
+
f"{counts[Status.FAIL]} failed",
|
|
293
|
+
f"{counts[Status.WARN]} warning{'' if counts[Status.WARN] == 1 else 's'}",
|
|
294
|
+
f"{counts[Status.UNKNOWN]} unknown",
|
|
295
|
+
f"{counts[Status.PASS]} passed",
|
|
296
|
+
f"{counts[Status.NOT_APPLICABLE] + counts[Status.NOT_TESTED]} not applicable or not tested",
|
|
297
|
+
]
|
|
298
|
+
return "Summary: " + " · ".join(parts)
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Pure inference: functions of collected facts. Nothing here runs commands or reads files."""
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
"""The built-in rules. Modules register into RULES when imported, in evaluation order."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import importlib
|
|
6
|
+
|
|
7
|
+
from gpu_stack_inspector.rules.engine import Registry
|
|
8
|
+
|
|
9
|
+
RULES = Registry()
|
|
10
|
+
|
|
11
|
+
_MODULES = ("host", "interpreters", "compatibility", "differences")
|
|
12
|
+
"""Evaluation order: the machine first, then each interpreter, then how they fit together,
|
|
13
|
+
then how the interpreters differ from each other.
|
|
14
|
+
Kept as data on purpose: import sorting would otherwise reorder the rules."""
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def builtin_rules() -> Registry:
|
|
18
|
+
"""All built-in rules, in evaluation order."""
|
|
19
|
+
for name in _MODULES:
|
|
20
|
+
importlib.import_module(f"gpu_stack_inspector.rules.{name}")
|
|
21
|
+
return RULES
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
"""Field-by-field comparison of inspected interpreters. Pure: facts in, rows out."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Callable, Sequence
|
|
6
|
+
|
|
7
|
+
from gpu_stack_inspector.models import FieldComparison, InterpreterFacts
|
|
8
|
+
from gpu_stack_inspector.rules.torch_build import TorchBuildKind, classify_torch
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def _flag(value: bool | None) -> str | None:
|
|
12
|
+
return None if value is None else ("yes" if value else "no")
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
_INTERPRETER_FIELDS: tuple[tuple[str, Callable[[InterpreterFacts], str | None]], ...] = (
|
|
16
|
+
("Python", lambda i: i.version),
|
|
17
|
+
("implementation", lambda i: i.implementation),
|
|
18
|
+
("conda env", lambda i: _flag(i.conda)),
|
|
19
|
+
("venv", lambda i: _flag(i.virtualenv)),
|
|
20
|
+
("platform", lambda i: i.platform),
|
|
21
|
+
("user site", lambda i: _flag(i.user_site)),
|
|
22
|
+
("PYTHONPATH set", lambda i: _flag(i.pythonpath_set)),
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def torch_build_label(interpreter: InterpreterFacts) -> str | None:
|
|
27
|
+
"""The build in plain words ("CUDA 12.8", "CPU only", "not installed"…)."""
|
|
28
|
+
if interpreter.version is None:
|
|
29
|
+
return None
|
|
30
|
+
build = classify_torch(interpreter.torch)
|
|
31
|
+
if build.kind is TorchBuildKind.CUDA:
|
|
32
|
+
return f"CUDA {build.cuda_version}" if build.cuda_version else "CUDA"
|
|
33
|
+
return {
|
|
34
|
+
TorchBuildKind.CPU: "CPU only",
|
|
35
|
+
TorchBuildKind.ROCM: "ROCm",
|
|
36
|
+
TorchBuildKind.UNKNOWN: "unknown",
|
|
37
|
+
TorchBuildKind.MISSING: "not installed",
|
|
38
|
+
}[build.kind]
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
_TORCH_FIELDS: tuple[tuple[str, Callable[[InterpreterFacts], str | None]], ...] = (
|
|
42
|
+
("torch", lambda i: i.torch.version if i.torch else None),
|
|
43
|
+
("torch build", torch_build_label),
|
|
44
|
+
("torch location", lambda i: i.torch.location if i.torch else None),
|
|
45
|
+
)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _package_version(interpreter: InterpreterFacts, name: str) -> str | None:
|
|
49
|
+
"""The installed version; several installs of one package (shadowing) are all named."""
|
|
50
|
+
versions = [d.version for d in interpreter.distributions if d.name == name]
|
|
51
|
+
if not versions:
|
|
52
|
+
return None
|
|
53
|
+
if len(versions) == 1:
|
|
54
|
+
return versions[0]
|
|
55
|
+
return f"{', '.join(sorted(set(versions)))} ({len(versions)} installs)"
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def compare_interpreters(interpreters: Sequence[InterpreterFacts]) -> tuple[FieldComparison, ...]:
|
|
59
|
+
"""Rows for every compared field, in a fixed order; ``differs`` marks disagreement.
|
|
60
|
+
|
|
61
|
+
After the interpreter and torch rows comes one row per GPU-relevant package found in
|
|
62
|
+
any of them, alphabetically; ``None`` there means "not installed".
|
|
63
|
+
"""
|
|
64
|
+
fixed = tuple(
|
|
65
|
+
FieldComparison(name, tuple(value(i) for i in interpreters))
|
|
66
|
+
for name, value in (*_INTERPRETER_FIELDS, *_TORCH_FIELDS)
|
|
67
|
+
)
|
|
68
|
+
names = sorted({d.name for i in interpreters for d in i.distributions} - {"torch"})
|
|
69
|
+
packages = tuple(
|
|
70
|
+
FieldComparison(name, tuple(_package_version(i, name) for i in interpreters))
|
|
71
|
+
for name in names
|
|
72
|
+
)
|
|
73
|
+
return fixed + packages
|