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,232 @@
|
|
|
1
|
+
"""The metadata probe: run inside a *target* interpreter as ``python -c <this file>``.
|
|
2
|
+
|
|
3
|
+
Rules for this file, checked by tests:
|
|
4
|
+
|
|
5
|
+
- Python 3.8 syntax and the standard library only; targets can be much older than the
|
|
6
|
+
tool itself.
|
|
7
|
+
- Nothing from the target environment is imported. Package metadata is read from disk.
|
|
8
|
+
- Exactly one JSON document is printed, between BEGIN and END, so whatever a target's
|
|
9
|
+
sitecustomize or .pth files print cannot be mistaken for the result.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import json
|
|
15
|
+
import os
|
|
16
|
+
import re
|
|
17
|
+
import site
|
|
18
|
+
import sys
|
|
19
|
+
import sysconfig
|
|
20
|
+
from typing import Callable
|
|
21
|
+
|
|
22
|
+
PROTOCOL = 1
|
|
23
|
+
BEGIN = "GSI-PROBE-BEGIN"
|
|
24
|
+
END = "GSI-PROBE-END"
|
|
25
|
+
|
|
26
|
+
# Distributions that decide whether and how a Python environment uses an NVIDIA GPU.
|
|
27
|
+
# Only these are reported: a full package list is noise, and can reveal private projects.
|
|
28
|
+
EXACT = frozenset(
|
|
29
|
+
[
|
|
30
|
+
"torch", "torchvision", "torchaudio", "triton", "pytorch-triton", "pytorch-triton-rocm",
|
|
31
|
+
"xformers", "flash-attn", "bitsandbytes", "deepspeed", "vllm", "accelerate",
|
|
32
|
+
"jax", "jaxlib", "tensorflow", "tensorflow-cpu", "tensorflow-gpu", "tf-nightly", "keras",
|
|
33
|
+
"cupy", "onnxruntime", "onnxruntime-gpu", "tensorrt", "numpy",
|
|
34
|
+
]
|
|
35
|
+
) # fmt: skip
|
|
36
|
+
PREFIXES = ("nvidia-", "cupy-cuda", "jax-cuda", "tensorrt-")
|
|
37
|
+
MAX_METADATA_BYTES = 64 * 1024
|
|
38
|
+
|
|
39
|
+
# Libraries in torch/lib that only GPU builds ship (Linux, Windows, macOS names).
|
|
40
|
+
CUDA_LIBRARIES = ("libtorch_cuda.so", "torch_cuda.dll", "libtorch_cuda.dylib")
|
|
41
|
+
HIP_LIBRARIES = ("libtorch_hip.so", "torch_hip.dll")
|
|
42
|
+
|
|
43
|
+
# torch/version.py lines, old and new style: "cuda = '11.3'", "cuda: Optional[str] = '12.8'",
|
|
44
|
+
# "hip: Optional[str] = None". The file is read as text, never imported.
|
|
45
|
+
VERSION_PY_LINE = re.compile(
|
|
46
|
+
r"^(?P<key>cuda|hip)\s*(?::[^=\n]*)?=\s*(?:'(?P<single>[^'\n]*)'|\"(?P<double>[^\"\n]*)\"|None)\s*$",
|
|
47
|
+
re.MULTILINE,
|
|
48
|
+
)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def python_facts() -> dict[str, object]:
|
|
52
|
+
enable_user_site = site.ENABLE_USER_SITE
|
|
53
|
+
return {
|
|
54
|
+
"version": "%d.%d.%d" % tuple(sys.version_info[:3]),
|
|
55
|
+
"implementation": sys.implementation.name,
|
|
56
|
+
"executable": sys.executable,
|
|
57
|
+
"prefix": sys.prefix,
|
|
58
|
+
"base_prefix": getattr(sys, "base_prefix", sys.prefix),
|
|
59
|
+
"conda": os.path.isdir(os.path.join(sys.prefix, "conda-meta")),
|
|
60
|
+
"platform": sysconfig.get_platform(),
|
|
61
|
+
"user_site": None if enable_user_site is None else bool(enable_user_site),
|
|
62
|
+
"pythonpath_set": bool(os.environ.get("PYTHONPATH")),
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def normalize(name: str) -> str:
|
|
67
|
+
"""PEP 503 normalisation: ``Nvidia_CuBLAS.cu12`` → ``nvidia-cublas-cu12``."""
|
|
68
|
+
return re.sub(r"[-_.]+", "-", name).lower()
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def wanted(name: str) -> bool:
|
|
72
|
+
normalized = normalize(name)
|
|
73
|
+
return normalized in EXACT or normalized.startswith(PREFIXES)
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def distributions() -> list[dict[str, str]]:
|
|
77
|
+
"""Allowlisted distributions on sys.path, read from metadata without importing them."""
|
|
78
|
+
try:
|
|
79
|
+
from importlib import metadata
|
|
80
|
+
except ImportError: # Python before 3.8
|
|
81
|
+
return scan_dist_info(sys.path)
|
|
82
|
+
|
|
83
|
+
found = []
|
|
84
|
+
for dist in metadata.distributions():
|
|
85
|
+
name = dist.metadata["Name"] if dist.metadata is not None else None
|
|
86
|
+
version = dist.version
|
|
87
|
+
if not name or not version or not wanted(name):
|
|
88
|
+
continue
|
|
89
|
+
found.append(
|
|
90
|
+
{"name": normalize(name), "version": version, "location": str(dist.locate_file(""))}
|
|
91
|
+
)
|
|
92
|
+
return sorted(found, key=lambda d: (d["name"], d["location"]))
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def torch_facts() -> dict[str, object] | None:
|
|
96
|
+
"""Static signals about the torch that ``import torch`` would find, without importing it.
|
|
97
|
+
|
|
98
|
+
Returns None when torch is not installed.
|
|
99
|
+
"""
|
|
100
|
+
try:
|
|
101
|
+
from importlib import metadata
|
|
102
|
+
except ImportError: # Python before 3.8: the distributions section still lists torch
|
|
103
|
+
return None
|
|
104
|
+
try:
|
|
105
|
+
dist = metadata.distribution("torch")
|
|
106
|
+
except metadata.PackageNotFoundError:
|
|
107
|
+
return None
|
|
108
|
+
requirements = []
|
|
109
|
+
for requirement in dist.requires or []:
|
|
110
|
+
match = re.match(r"[A-Za-z0-9._-]+", requirement)
|
|
111
|
+
if match and normalize(match.group(0)).startswith("nvidia-"):
|
|
112
|
+
requirements.append(normalize(match.group(0)))
|
|
113
|
+
package = str(dist.locate_file("torch"))
|
|
114
|
+
return {
|
|
115
|
+
"version": dist.version,
|
|
116
|
+
"location": str(dist.locate_file("")),
|
|
117
|
+
"requires_nvidia": sorted(set(requirements)),
|
|
118
|
+
"libraries": torch_libraries(os.path.join(package, "lib")),
|
|
119
|
+
"version_py": torch_version_py(os.path.join(package, "version.py")),
|
|
120
|
+
"conda_records": conda_records(sys.prefix),
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def conda_records(prefix: str) -> list[str] | None:
|
|
125
|
+
"""Conda packages that decide the torch build, by file name in conda-meta.
|
|
126
|
+
|
|
127
|
+
Names like ``pytorch-1.12.0-py3.8_cuda11.3_cudnn8.3.2_0`` carry the build string, so
|
|
128
|
+
the (multi-megabyte) records are never opened. None outside conda environments.
|
|
129
|
+
"""
|
|
130
|
+
try:
|
|
131
|
+
names = os.listdir(os.path.join(prefix, "conda-meta"))
|
|
132
|
+
except OSError:
|
|
133
|
+
return None
|
|
134
|
+
wanted_prefixes = ("pytorch-", "cudatoolkit-", "cuda-version-", "cuda-runtime-")
|
|
135
|
+
return sorted(
|
|
136
|
+
name[: -len(".json")]
|
|
137
|
+
for name in names
|
|
138
|
+
if name.endswith(".json") and name.startswith(wanted_prefixes)
|
|
139
|
+
)
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def torch_version_py(path: str) -> dict[str, str | None] | None:
|
|
143
|
+
"""The cuda and hip versions torch was built with, read from torch/version.py as text.
|
|
144
|
+
|
|
145
|
+
None when the file is missing or names neither (a damaged or unusual install).
|
|
146
|
+
"""
|
|
147
|
+
try:
|
|
148
|
+
with open(path, "rb") as handle:
|
|
149
|
+
text = handle.read(MAX_METADATA_BYTES).decode("utf-8", "replace")
|
|
150
|
+
except OSError:
|
|
151
|
+
return None
|
|
152
|
+
found: dict[str, str | None] = {}
|
|
153
|
+
for match in VERSION_PY_LINE.finditer(text):
|
|
154
|
+
value = match.group("single")
|
|
155
|
+
if value is None:
|
|
156
|
+
value = match.group("double")
|
|
157
|
+
found.setdefault(match.group("key"), value)
|
|
158
|
+
if not found:
|
|
159
|
+
return None
|
|
160
|
+
return {"cuda": found.get("cuda"), "hip": found.get("hip")}
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def torch_libraries(lib_dir: str) -> dict[str, bool] | None:
|
|
164
|
+
"""Whether torch/lib holds CUDA or ROCm libraries; None if the folder cannot be read."""
|
|
165
|
+
try:
|
|
166
|
+
names = set(os.listdir(lib_dir))
|
|
167
|
+
except OSError:
|
|
168
|
+
return None
|
|
169
|
+
return {
|
|
170
|
+
"cuda": any(name in names for name in CUDA_LIBRARIES),
|
|
171
|
+
"hip": any(name in names for name in HIP_LIBRARIES),
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def scan_dist_info(paths: list[str]) -> list[dict[str, str]]:
|
|
176
|
+
"""Fallback without importlib.metadata: read the headers of ``*.dist-info/METADATA``."""
|
|
177
|
+
found = []
|
|
178
|
+
for entry in paths:
|
|
179
|
+
try:
|
|
180
|
+
folders = sorted(os.listdir(entry or "."))
|
|
181
|
+
except OSError:
|
|
182
|
+
continue
|
|
183
|
+
for folder in folders:
|
|
184
|
+
if not folder.endswith(".dist-info"):
|
|
185
|
+
continue
|
|
186
|
+
headers = read_headers(os.path.join(entry, folder, "METADATA"))
|
|
187
|
+
name, version = headers.get("name"), headers.get("version")
|
|
188
|
+
if name and version and wanted(name):
|
|
189
|
+
found.append({"name": normalize(name), "version": version, "location": entry})
|
|
190
|
+
return found
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
def read_headers(path: str) -> dict[str, str]:
|
|
194
|
+
"""``Name:``/``Version:`` style headers up to the first blank line."""
|
|
195
|
+
headers: dict[str, str] = {}
|
|
196
|
+
try:
|
|
197
|
+
with open(path, "rb") as handle:
|
|
198
|
+
text = handle.read(MAX_METADATA_BYTES).decode("utf-8", "replace")
|
|
199
|
+
except OSError:
|
|
200
|
+
return headers
|
|
201
|
+
for line in text.splitlines():
|
|
202
|
+
if not line.strip():
|
|
203
|
+
break
|
|
204
|
+
key, separator, value = line.partition(":")
|
|
205
|
+
if separator and key.strip().lower() not in headers:
|
|
206
|
+
headers[key.strip().lower()] = value.strip()
|
|
207
|
+
return headers
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
def collect(sections: list[tuple[str, Callable[[], object]]]) -> dict[str, object]:
|
|
211
|
+
"""Run each section; an exception is reported and the other sections still run."""
|
|
212
|
+
errors: list[str] = []
|
|
213
|
+
result: dict[str, object] = {"protocol": PROTOCOL, "errors": errors}
|
|
214
|
+
for key, function in sections:
|
|
215
|
+
try:
|
|
216
|
+
result[key] = function()
|
|
217
|
+
except Exception as exc: # a partial answer is worth more than none
|
|
218
|
+
result[key] = None
|
|
219
|
+
errors.append("%s: %s: %s" % (key, type(exc).__name__, exc))
|
|
220
|
+
return result
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
def main() -> None:
|
|
224
|
+
result = collect(
|
|
225
|
+
[("python", python_facts), ("distributions", distributions), ("torch", torch_facts)]
|
|
226
|
+
)
|
|
227
|
+
sys.stdout.write(BEGIN + json.dumps(result, ensure_ascii=True, sort_keys=True) + END + "\n")
|
|
228
|
+
sys.stdout.flush()
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
if __name__ == "__main__":
|
|
232
|
+
main()
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
"""Running the runtime probe in a target interpreter (only with --runtime) and reading it."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import functools
|
|
6
|
+
import json
|
|
7
|
+
import tempfile
|
|
8
|
+
from importlib.resources import files
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from typing import Any
|
|
11
|
+
|
|
12
|
+
from gpu_stack_inspector.models import (
|
|
13
|
+
FailureKind,
|
|
14
|
+
GpuDevice,
|
|
15
|
+
ProbeFailure,
|
|
16
|
+
RuntimeFacts,
|
|
17
|
+
RuntimeStep,
|
|
18
|
+
)
|
|
19
|
+
from gpu_stack_inspector.probes.metadata_probe import python_error_line
|
|
20
|
+
from gpu_stack_inspector.runner import CommandResult, CommandRunner, Outcome, probe_failure
|
|
21
|
+
|
|
22
|
+
PROBE = "runtime"
|
|
23
|
+
TIMEOUT = 60.0
|
|
24
|
+
"""Importing torch and creating a CUDA context takes a few seconds (2-4 s on the test
|
|
25
|
+
laptop); a driver that hangs must not hang the tool."""
|
|
26
|
+
|
|
27
|
+
BEGIN, END, STEP = "GSI-RUNTIME-BEGIN", "GSI-RUNTIME-END", "GSI-RUNTIME-STEP"
|
|
28
|
+
_SIGNALS = {
|
|
29
|
+
-11: "a segmentation fault (SIGSEGV)",
|
|
30
|
+
139: "a segmentation fault (SIGSEGV)",
|
|
31
|
+
-6: "an abort (SIGABRT)",
|
|
32
|
+
134: "an abort (SIGABRT)",
|
|
33
|
+
-9: "being killed (SIGKILL)",
|
|
34
|
+
137: "being killed (SIGKILL)",
|
|
35
|
+
-7: "a bus error (SIGBUS)",
|
|
36
|
+
135: "a bus error (SIGBUS)",
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
@functools.cache
|
|
41
|
+
def program_source() -> str:
|
|
42
|
+
resource = files("gpu_stack_inspector.probes").joinpath("runtime_program.py")
|
|
43
|
+
return resource.read_text(encoding="utf-8")
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def run_runtime_probe(
|
|
47
|
+
runner: CommandRunner, path: str, *, timeout: float = TIMEOUT
|
|
48
|
+
) -> RuntimeFacts:
|
|
49
|
+
"""Import torch in the interpreter at ``path`` and use CUDA once. Only with consent."""
|
|
50
|
+
with tempfile.TemporaryDirectory(prefix="gsi-runtime-") as empty:
|
|
51
|
+
result = runner.run([path, "-c", program_source()], timeout=timeout, cwd=Path(empty))
|
|
52
|
+
announced = [
|
|
53
|
+
line[len(STEP) + 1 :].strip()
|
|
54
|
+
for line in result.stdout.splitlines()
|
|
55
|
+
if line.startswith(STEP + " ")
|
|
56
|
+
]
|
|
57
|
+
if result.outcome is Outcome.TIMEOUT:
|
|
58
|
+
where = f" in step '{announced[-1]}'" if announced else ""
|
|
59
|
+
detail = (
|
|
60
|
+
f"no answer within {result.duration:.0f} s{where}; "
|
|
61
|
+
"CUDA initialisation can hang on a broken driver"
|
|
62
|
+
)
|
|
63
|
+
return _died(announced, ProbeFailure(PROBE, FailureKind.TIMEOUT, detail))
|
|
64
|
+
if result.outcome is Outcome.NONZERO:
|
|
65
|
+
return _died(announced, _crash(result, announced))
|
|
66
|
+
failure = probe_failure(PROBE, result)
|
|
67
|
+
if failure is not None:
|
|
68
|
+
return RuntimeFacts(failure=failure)
|
|
69
|
+
return parse_runtime_output(result.stdout)
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def _crash(result: CommandResult, announced: list[str]) -> ProbeFailure:
|
|
73
|
+
signal = _SIGNALS.get(result.returncode or 0)
|
|
74
|
+
how = f"crashed with {signal}" if signal else f"exited with code {result.returncode}"
|
|
75
|
+
where = f" during '{announced[-1]}'" if announced else ""
|
|
76
|
+
reason = python_error_line(result.stderr)
|
|
77
|
+
detail = f"the interpreter {how}{where}" + (f": {reason}" if reason and not signal else "")
|
|
78
|
+
return ProbeFailure(PROBE, FailureKind.NONZERO_EXIT, detail)
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def _died(announced: list[str], failure: ProbeFailure) -> RuntimeFacts:
|
|
82
|
+
"""Steps reconstructed from the markers: all finished but the last, which it died in."""
|
|
83
|
+
steps = tuple(
|
|
84
|
+
RuntimeStep(
|
|
85
|
+
name,
|
|
86
|
+
ok=index < len(announced) - 1,
|
|
87
|
+
error=None if index < len(announced) - 1 else failure.detail,
|
|
88
|
+
)
|
|
89
|
+
for index, name in enumerate(announced)
|
|
90
|
+
)
|
|
91
|
+
return RuntimeFacts(steps=steps, failure=failure)
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def parse_runtime_output(stdout: str) -> RuntimeFacts:
|
|
95
|
+
start = stdout.find(BEGIN)
|
|
96
|
+
end = stdout.find(END, start + len(BEGIN)) if start != -1 else -1
|
|
97
|
+
if start == -1 or end == -1:
|
|
98
|
+
return RuntimeFacts(failure=_malformed("the interpreter printed no runtime result"))
|
|
99
|
+
try:
|
|
100
|
+
document = json.loads(stdout[start + len(BEGIN) : end])
|
|
101
|
+
except ValueError:
|
|
102
|
+
return RuntimeFacts(failure=_malformed("the runtime result is not valid JSON"))
|
|
103
|
+
if not isinstance(document, dict) or document.get("protocol") != 1:
|
|
104
|
+
return RuntimeFacts(failure=_malformed("unexpected runtime probe protocol"))
|
|
105
|
+
steps: list[RuntimeStep] = []
|
|
106
|
+
values: dict[str, Any] = {}
|
|
107
|
+
for raw in document.get("steps") or []:
|
|
108
|
+
if not isinstance(raw, dict) or not isinstance(raw.get("step"), str):
|
|
109
|
+
continue
|
|
110
|
+
seconds = raw.get("seconds")
|
|
111
|
+
error = raw.get("error")
|
|
112
|
+
steps.append(
|
|
113
|
+
RuntimeStep(
|
|
114
|
+
raw["step"],
|
|
115
|
+
ok=raw.get("ok") is True,
|
|
116
|
+
seconds=float(seconds) if isinstance(seconds, int | float) else None,
|
|
117
|
+
error=error if isinstance(error, str) else None,
|
|
118
|
+
)
|
|
119
|
+
)
|
|
120
|
+
if raw.get("ok") is True:
|
|
121
|
+
values[raw["step"]] = raw.get("value")
|
|
122
|
+
imported = values.get("import torch")
|
|
123
|
+
imported = imported if isinstance(imported, dict) else {}
|
|
124
|
+
available = values.get("cuda available")
|
|
125
|
+
attempted = any(s.step == "tensor operation" for s in steps)
|
|
126
|
+
return RuntimeFacts(
|
|
127
|
+
torch_version=_string(imported.get("version")),
|
|
128
|
+
cuda_version=_string(imported.get("cuda")),
|
|
129
|
+
cuda_available=available if isinstance(available, bool) else None,
|
|
130
|
+
devices=_devices(values.get("devices")),
|
|
131
|
+
tensor_ok=("tensor operation" in values) if attempted else None,
|
|
132
|
+
steps=tuple(steps),
|
|
133
|
+
)
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def _devices(raw: object) -> tuple[GpuDevice, ...]:
|
|
137
|
+
if not isinstance(raw, list):
|
|
138
|
+
return ()
|
|
139
|
+
return tuple(
|
|
140
|
+
GpuDevice(item["index"], item["name"], None, _string(item.get("capability")))
|
|
141
|
+
for item in raw
|
|
142
|
+
if isinstance(item, dict)
|
|
143
|
+
and isinstance(item.get("index"), int)
|
|
144
|
+
and isinstance(item.get("name"), str)
|
|
145
|
+
)
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def _string(value: object) -> str | None:
|
|
149
|
+
return value if isinstance(value, str) else None
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def _malformed(detail: str) -> ProbeFailure:
|
|
153
|
+
return ProbeFailure(PROBE, FailureKind.MALFORMED_OUTPUT, detail)
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
"""The runtime probe: run inside a *target* interpreter only with --runtime (explicit consent).
|
|
2
|
+
|
|
3
|
+
Unlike the metadata probe, this imports torch, creates a CUDA context and runs a tiny
|
|
4
|
+
operation, because only that proves CUDA works. Rules for this file, checked by tests:
|
|
5
|
+
|
|
6
|
+
- Python 3.8 syntax and the standard library plus torch; nothing else is imported.
|
|
7
|
+
- The workload is tiny (16 floats) and bounded; nothing is written to disk.
|
|
8
|
+
- Each step reports its own failure, so the report can say exactly where CUDA broke.
|
|
9
|
+
- Exactly one JSON document is printed between BEGIN and END.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import json
|
|
15
|
+
import sys
|
|
16
|
+
import time
|
|
17
|
+
from typing import Any, Callable
|
|
18
|
+
|
|
19
|
+
PROTOCOL = 1
|
|
20
|
+
BEGIN = "GSI-RUNTIME-BEGIN"
|
|
21
|
+
END = "GSI-RUNTIME-END"
|
|
22
|
+
STEP = "GSI-RUNTIME-STEP"
|
|
23
|
+
"""Printed (and flushed) before each step: if CUDA initialisation kills the interpreter,
|
|
24
|
+
no JSON is printed, and the last marker says which step it died in."""
|
|
25
|
+
ELEMENTS = 16
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def first_line(exc: BaseException) -> str:
|
|
29
|
+
text = str(exc).strip().splitlines()
|
|
30
|
+
return "%s: %s" % (type(exc).__name__, text[0] if text else "")
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def run_steps() -> dict[str, object]:
|
|
34
|
+
result: dict[str, object] = {"protocol": PROTOCOL, "steps": []}
|
|
35
|
+
steps: list[dict[str, object]] = []
|
|
36
|
+
result["steps"] = steps
|
|
37
|
+
state: dict[str, Any] = {}
|
|
38
|
+
|
|
39
|
+
def step(name: str, action: Callable[[], object]) -> bool:
|
|
40
|
+
sys.stdout.write("%s %s\n" % (STEP, name))
|
|
41
|
+
sys.stdout.flush()
|
|
42
|
+
started = time.monotonic()
|
|
43
|
+
try:
|
|
44
|
+
value = action()
|
|
45
|
+
except BaseException as exc: # report where it broke, then stop
|
|
46
|
+
seconds = round(time.monotonic() - started, 3)
|
|
47
|
+
steps.append({"step": name, "ok": False, "error": first_line(exc), "seconds": seconds})
|
|
48
|
+
return False
|
|
49
|
+
seconds = round(time.monotonic() - started, 3)
|
|
50
|
+
steps.append({"step": name, "ok": True, "value": value, "seconds": seconds})
|
|
51
|
+
return True
|
|
52
|
+
|
|
53
|
+
def import_torch() -> object:
|
|
54
|
+
import torch # type: ignore[import-not-found] # lives in the target, never in the tool
|
|
55
|
+
|
|
56
|
+
state["torch"] = torch
|
|
57
|
+
return {"version": torch.__version__, "cuda": torch.version.cuda}
|
|
58
|
+
|
|
59
|
+
def cuda_available() -> object:
|
|
60
|
+
torch = state["torch"]
|
|
61
|
+
return bool(torch.cuda.is_available())
|
|
62
|
+
|
|
63
|
+
def devices() -> object:
|
|
64
|
+
torch = state["torch"]
|
|
65
|
+
count = int(torch.cuda.device_count())
|
|
66
|
+
found = []
|
|
67
|
+
for index in range(count):
|
|
68
|
+
major, minor = torch.cuda.get_device_capability(index)
|
|
69
|
+
name = str(torch.cuda.get_device_name(index))
|
|
70
|
+
found.append({"index": index, "name": name, "capability": "%d.%d" % (major, minor)})
|
|
71
|
+
return found
|
|
72
|
+
|
|
73
|
+
def tensor_operation() -> object:
|
|
74
|
+
torch = state["torch"]
|
|
75
|
+
x = torch.ones(ELEMENTS, device="cuda")
|
|
76
|
+
total = (x * 2).sum()
|
|
77
|
+
torch.cuda.synchronize()
|
|
78
|
+
value = float(total.cpu().item())
|
|
79
|
+
if value != 2.0 * ELEMENTS:
|
|
80
|
+
raise ArithmeticError("expected %s, got %s" % (2.0 * ELEMENTS, value))
|
|
81
|
+
return value
|
|
82
|
+
|
|
83
|
+
if not step("import torch", import_torch):
|
|
84
|
+
return result
|
|
85
|
+
if not step("cuda available", cuda_available):
|
|
86
|
+
return result
|
|
87
|
+
if steps[-1]["value"] is not True:
|
|
88
|
+
return result # nothing more to try: CUDA is not usable from this torch
|
|
89
|
+
if step("devices", devices):
|
|
90
|
+
step("tensor operation", tensor_operation)
|
|
91
|
+
return result
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def main() -> None:
|
|
95
|
+
result = run_steps()
|
|
96
|
+
sys.stdout.write(BEGIN + json.dumps(result, ensure_ascii=True, sort_keys=True) + END + "\n")
|
|
97
|
+
sys.stdout.flush()
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
if __name__ == "__main__":
|
|
101
|
+
main()
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Renderers: they turn an already redacted snapshot into output and never change it."""
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
"""JSON output: the snapshot as plain data, for scripts, CI and bug reports."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
from dataclasses import fields, is_dataclass
|
|
7
|
+
from enum import Enum
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
from gpu_stack_inspector.models import FieldComparison, InterpreterFacts, Snapshot
|
|
11
|
+
from gpu_stack_inspector.rules.torch_build import classify_torch
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def snapshot_to_dict(snapshot: Snapshot) -> dict[str, Any]:
|
|
15
|
+
"""Plain dicts, lists and scalars; enums become their documented string values."""
|
|
16
|
+
plain = _plain(snapshot)
|
|
17
|
+
if not isinstance(plain, dict): # cannot happen: a dataclass becomes a dict
|
|
18
|
+
raise TypeError("snapshot did not serialise to an object")
|
|
19
|
+
return plain
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def render_json(snapshot: Snapshot) -> str:
|
|
23
|
+
"""Indented JSON with a trailing newline. Pass a redacted snapshot."""
|
|
24
|
+
return json.dumps(snapshot_to_dict(snapshot), indent=2, ensure_ascii=False) + "\n"
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _plain(value: Any) -> Any:
|
|
28
|
+
if isinstance(value, Enum):
|
|
29
|
+
return value.value
|
|
30
|
+
if is_dataclass(value) and not isinstance(value, type):
|
|
31
|
+
result = {f.name: _plain(getattr(value, f.name)) for f in fields(value)}
|
|
32
|
+
if isinstance(value, InterpreterFacts):
|
|
33
|
+
# Derived from the facts above, but what readers and scripts look for.
|
|
34
|
+
result["virtualenv"] = value.virtualenv
|
|
35
|
+
result["torch_build"] = _plain(classify_torch(value.torch))
|
|
36
|
+
if isinstance(value, FieldComparison):
|
|
37
|
+
result["differs"] = value.differs
|
|
38
|
+
return result
|
|
39
|
+
if isinstance(value, tuple | list):
|
|
40
|
+
return [_plain(item) for item in value]
|
|
41
|
+
if isinstance(value, dict):
|
|
42
|
+
return {str(key): _plain(item) for key, item in value.items()}
|
|
43
|
+
return value
|