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,422 @@
1
+ """Checks of each inspected interpreter: usable, the shell's own, its PyTorch."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Iterator
6
+ from pathlib import Path
7
+
8
+ from gpu_stack_inspector.models import (
9
+ Confidence,
10
+ Evidence,
11
+ FailureKind,
12
+ Finding,
13
+ InterpreterFacts,
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
+ _CANNOT_START = frozenset({FailureKind.NOT_FOUND, FailureKind.PERMISSION_DENIED})
23
+
24
+
25
+ def _usable(interpreter: InterpreterFacts) -> bool:
26
+ return interpreter.version is not None
27
+
28
+
29
+ @RULES.rule("GSI-ENV-001", "The interpreter can be inspected")
30
+ def interpreter_usable(snapshot: Snapshot, context: RuleContext) -> Iterator[Finding]:
31
+ """Whether each named interpreter started and answered the metadata probe.
32
+
33
+ "Cannot be started" (a wrong path, a deleted environment, no execute permission) is
34
+ told apart from "starts and crashes" (a broken environment, a damaged standard
35
+ library), because they need different fixes. An interpreter that answered only in
36
+ part passes with the problems listed as limitations.
37
+ """
38
+ for interpreter in snapshot.interpreters:
39
+ details = tuple(f.detail for f in interpreter.failures)
40
+ if _usable(interpreter):
41
+ kind = ", ".join(
42
+ part
43
+ for part in (
44
+ interpreter.implementation,
45
+ "conda env" if interpreter.conda else None,
46
+ "venv" if interpreter.virtualenv else None,
47
+ )
48
+ if part
49
+ )
50
+ yield Finding(
51
+ id="GSI-ENV-001",
52
+ status=Status.PASS,
53
+ severity=Severity.INFO,
54
+ confidence=Confidence.HIGH if not details else Confidence.MEDIUM,
55
+ summary=f"Python {interpreter.version} ({kind or 'python'})",
56
+ reason=f"Inspected {interpreter.path}.",
57
+ subject=interpreter.label,
58
+ evidence=(Evidence("python-probe", "executable", interpreter.executable),),
59
+ limitations=details,
60
+ )
61
+ continue
62
+ cannot_start = any(f.kind in _CANNOT_START for f in interpreter.failures)
63
+ yield Finding(
64
+ id="GSI-ENV-001",
65
+ status=Status.FAIL,
66
+ severity=Severity.HIGH,
67
+ confidence=Confidence.HIGH,
68
+ summary=(
69
+ "The interpreter cannot be started"
70
+ if cannot_start
71
+ else "The interpreter started but did not answer"
72
+ ),
73
+ reason=details[0] if details else "No answer from the metadata probe.",
74
+ subject=interpreter.label,
75
+ evidence=tuple(Evidence("python-probe", "failure", detail) for detail in details),
76
+ recommended_action=(
77
+ "Check the path: the environment may have been moved or deleted."
78
+ if cannot_start
79
+ else "Run the interpreter by hand to see the whole error; a damaged "
80
+ "environment usually has to be recreated."
81
+ ),
82
+ verify_command=f"{interpreter.path} --version",
83
+ )
84
+
85
+
86
+ def _environment_directory(path: str) -> str:
87
+ # Same directory means same environment: bin/python and bin/python3 of one venv match,
88
+ # two venvs do not. Symlinks are not followed, they would make every venv equal to
89
+ # its base interpreter, which is why this is absolute() and not resolve().
90
+ return str(Path(path).absolute().parent)
91
+
92
+
93
+ @RULES.rule("GSI-ENV-002", "The interpreter is the one the shell runs")
94
+ def shell_interpreter(snapshot: Snapshot, context: RuleContext) -> Iterator[Finding]:
95
+ """Whether an inspected interpreter is the python the user's shell would start.
96
+
97
+ A common source of confusion: the environment that was checked (or fixed) is not the
98
+ one a plain `python` command runs, for example because another venv or conda env is
99
+ active. Only checked when a single interpreter is inspected: naming several means
100
+ other environments are being compared on purpose. Interpreters count as the same when
101
+ they live in the same directory (symlinks are not followed), so two interpreters of
102
+ one environment in different directories are reported as different.
103
+ """
104
+ if context.shell_python is None or len(snapshot.interpreters) != 1:
105
+ return
106
+ shell_directory = _environment_directory(context.shell_python)
107
+ for interpreter in snapshot.interpreters:
108
+ if not _usable(interpreter):
109
+ continue
110
+ if _environment_directory(interpreter.path) == shell_directory:
111
+ continue
112
+ yield Finding(
113
+ id="GSI-ENV-002",
114
+ status=Status.WARN,
115
+ severity=Severity.LOW,
116
+ confidence=Confidence.MEDIUM,
117
+ summary="This is not the python your shell runs",
118
+ reason=(
119
+ f"Your shell runs {context.shell_python}. Results for {interpreter.path} do not "
120
+ "apply to programs started with a plain `python` command."
121
+ ),
122
+ subject=interpreter.label,
123
+ evidence=(Evidence("PATH", "python", context.shell_python),),
124
+ recommended_action=(
125
+ "Activate this environment before running your program, or start it with "
126
+ "this interpreter's full path."
127
+ ),
128
+ verify_command="which python",
129
+ )
130
+
131
+
132
+ # Installed packages that cannot work without torch.
133
+ TORCH_DEPENDENTS = frozenset(
134
+ {"torchvision", "torchaudio", "xformers", "flash-attn", "bitsandbytes", "accelerate",
135
+ "deepspeed", "vllm"}
136
+ ) # fmt: skip
137
+
138
+
139
+ @RULES.rule("GSI-TORCH-001", "PyTorch is installed")
140
+ def torch_installed(snapshot: Snapshot, context: RuleContext) -> Iterator[Finding]:
141
+ """Whether the interpreter has PyTorch, and whether it needs it.
142
+
143
+ An environment without torch is only a problem when it contains packages that need
144
+ it (accelerate, bitsandbytes, torchvision…): then the environment is incomplete.
145
+ Otherwise the PyTorch checks do not apply to it.
146
+ """
147
+ for interpreter in snapshot.interpreters:
148
+ if not _usable(interpreter):
149
+ continue
150
+ if interpreter.torch is not None:
151
+ yield Finding(
152
+ id="GSI-TORCH-001",
153
+ status=Status.PASS,
154
+ severity=Severity.INFO,
155
+ confidence=Confidence.HIGH,
156
+ summary=f"torch {interpreter.torch.version} is installed",
157
+ reason=f"Found in {interpreter.torch.location or 'an unknown location'}.",
158
+ subject=interpreter.label,
159
+ evidence=(Evidence("python-metadata", "torch", interpreter.torch.version),),
160
+ )
161
+ continue
162
+ dependents = sorted(d.name for d in interpreter.distributions if d.name in TORCH_DEPENDENTS)
163
+ if dependents:
164
+ yield Finding(
165
+ id="GSI-TORCH-001",
166
+ status=Status.WARN,
167
+ severity=Severity.MEDIUM,
168
+ confidence=Confidence.HIGH,
169
+ summary="PyTorch is missing, but packages that need it are installed",
170
+ reason=f"{', '.join(dependents)} cannot work without torch.",
171
+ subject=interpreter.label,
172
+ evidence=tuple(
173
+ Evidence("python-metadata", name, "installed") for name in dependents
174
+ ),
175
+ recommended_action=(
176
+ "Install PyTorch with the selector at https://pytorch.org/get-started/locally/"
177
+ ", choosing a CUDA version the driver supports."
178
+ ),
179
+ verify_command=f"gpu-stack-inspector inspect --python {interpreter.path}",
180
+ )
181
+ else:
182
+ yield Finding(
183
+ id="GSI-TORCH-001",
184
+ status=Status.NOT_APPLICABLE,
185
+ severity=Severity.INFO,
186
+ confidence=Confidence.HIGH,
187
+ summary="No PyTorch in this environment",
188
+ reason="Nothing installed here needs it, so the PyTorch checks do not apply.",
189
+ subject=interpreter.label,
190
+ )
191
+
192
+
193
+ _SELECTOR = "https://pytorch.org/get-started/locally/"
194
+
195
+
196
+ @RULES.rule("GSI-TORCH-002", "The PyTorch build supports CUDA", sources=(_SELECTOR,))
197
+ def torch_build_supports_cuda(snapshot: Snapshot, context: RuleContext) -> Iterator[Finding]:
198
+ """Whether the installed PyTorch is a CUDA build, judged from static evidence only.
199
+
200
+ The evidence is the version tag, torch/version.py, the CUDA libraries in torch/lib,
201
+ the nvidia-* requirements and conda's package records; nothing is imported. A CPU-only
202
+ build where CUDA is expected is the most common reason for
203
+ `torch.cuda.is_available()` returning False. Conflicting evidence gives "unknown",
204
+ never a guess. ROCm builds are named but not judged: this tool checks NVIDIA CUDA.
205
+ """
206
+ max_cuda = snapshot.nvidia.max_cuda_version if snapshot.nvidia else None
207
+ for interpreter in snapshot.interpreters:
208
+ if not _usable(interpreter) or interpreter.torch is None:
209
+ continue
210
+ build = classify_torch(interpreter.torch)
211
+ subject, confidence, evidence = interpreter.label, build.confidence, build.evidence
212
+ if build.kind is TorchBuildKind.CUDA:
213
+ target = f"CUDA {build.cuda_version}" if build.cuda_version else "CUDA"
214
+ yield Finding(
215
+ id="GSI-TORCH-002",
216
+ status=Status.PASS,
217
+ severity=Severity.INFO,
218
+ summary=f"torch {interpreter.torch.version} is a {target} build",
219
+ reason="The metadata of this torch shows a CUDA build. Nothing was imported.",
220
+ limitations=build.conflicts,
221
+ subject=subject,
222
+ confidence=confidence,
223
+ evidence=evidence,
224
+ )
225
+ elif build.kind is TorchBuildKind.CPU:
226
+ choose = (
227
+ f"a CUDA version up to {max_cuda} (what this driver supports)"
228
+ if max_cuda
229
+ else "a CUDA version your NVIDIA driver supports"
230
+ )
231
+ yield Finding(
232
+ id="GSI-TORCH-002",
233
+ status=Status.FAIL if context.cuda_expected else Status.PASS,
234
+ severity=Severity.HIGH if context.cuda_expected else Severity.INFO,
235
+ summary=f"torch {interpreter.torch.version} is a CPU-only build",
236
+ reason=(
237
+ "This build has no CUDA support, so torch.cuda.is_available() is False "
238
+ "here whatever the driver can do."
239
+ ),
240
+ recommended_action=(
241
+ f"Install a CUDA build with the selector at {_SELECTOR}, choosing {choose}."
242
+ ),
243
+ verify_command=f"gpu-stack-inspector inspect --python {interpreter.path}",
244
+ subject=subject,
245
+ confidence=confidence,
246
+ evidence=evidence,
247
+ )
248
+ elif build.kind is TorchBuildKind.ROCM:
249
+ yield Finding(
250
+ id="GSI-TORCH-002",
251
+ status=Status.NOT_APPLICABLE,
252
+ severity=Severity.INFO,
253
+ summary=f"torch {interpreter.torch.version} is a ROCm build",
254
+ reason="ROCm builds target AMD GPUs; this tool only checks NVIDIA CUDA.",
255
+ subject=subject,
256
+ confidence=confidence,
257
+ evidence=evidence,
258
+ )
259
+ else:
260
+ yield Finding(
261
+ id="GSI-TORCH-002",
262
+ status=Status.UNKNOWN,
263
+ severity=Severity.MEDIUM,
264
+ summary="The kind of PyTorch build could not be determined",
265
+ reason=(
266
+ "; ".join(build.conflicts)
267
+ if build.conflicts
268
+ else "No tag, version file, library or package record says which build it is."
269
+ ),
270
+ recommended_action=(
271
+ "The installation looks inconsistent; reinstalling torch in this "
272
+ "environment usually fixes it."
273
+ if build.conflicts
274
+ else None
275
+ ),
276
+ subject=subject,
277
+ confidence=confidence,
278
+ evidence=evidence,
279
+ )
280
+
281
+
282
+ def _unavailable_cause(snapshot: Snapshot, interpreter: InterpreterFacts) -> str | None:
283
+ """The finding that explains torch.cuda.is_available() being False, if one does."""
284
+ if classify_torch(interpreter.torch).kind is TorchBuildKind.CPU:
285
+ return "GSI-TORCH-002"
286
+ nvidia = snapshot.nvidia
287
+ if nvidia is not None and nvidia.smi_path is not None and nvidia.driver_version is None:
288
+ return "GSI-SYS-002"
289
+ if nvidia is not None and nvidia.driver_version is not None and not nvidia.gpus:
290
+ return "GSI-SYS-003"
291
+ hidden = next((v for v in snapshot.cuda_environment if v.name == "CUDA_VISIBLE_DEVICES"), None)
292
+ if hidden is not None and hidden.value.strip() in {"", "-1"}:
293
+ return "GSI-ENV-003"
294
+ return None
295
+
296
+
297
+ @RULES.rule("GSI-TORCH-003", "CUDA works at runtime")
298
+ def cuda_works(snapshot: Snapshot, context: RuleContext) -> Iterator[Finding]:
299
+ """Whether importing torch and running a tiny CUDA operation worked (probe --runtime).
300
+
301
+ The only check that proves CUDA works: it imports torch in the target interpreter,
302
+ asks torch.cuda.is_available(), lists the devices and runs a tensor operation on the
303
+ GPU. A failure names the step and the error. "No kernel image is available" means the
304
+ build has no code for this GPU's compute capability. When CUDA is unavailable, the
305
+ finding points at the check that explains why, if one does.
306
+ """
307
+ for interpreter in snapshot.interpreters:
308
+ runtime = interpreter.runtime
309
+ if runtime is None:
310
+ continue
311
+ check = f"gpu-stack-inspector probe --runtime --python {interpreter.path}"
312
+ failed = next((s for s in runtime.steps if not s.ok), None)
313
+ if runtime.failure is not None or (failed is not None and failed.step == "import torch"):
314
+ detail = runtime.failure.detail if runtime.failure else (failed.error if failed else "")
315
+ yield Finding(
316
+ id="GSI-TORCH-003",
317
+ status=Status.FAIL,
318
+ severity=Severity.HIGH,
319
+ confidence=Confidence.HIGH,
320
+ summary=(
321
+ "import torch fails"
322
+ if runtime.failure is None
323
+ else "The interpreter did not survive using CUDA"
324
+ ),
325
+ reason=detail or "No detail was reported.",
326
+ recommended_action=(
327
+ "Run `python -c \"import torch; print(torch.ones(1, device='cuda'))\"` in this "
328
+ "environment to see the whole error; missing or mismatched CUDA libraries in "
329
+ "the environment are the usual cause."
330
+ ),
331
+ evidence=tuple(Evidence("runtime", s.step, s.error or "ok") for s in runtime.steps),
332
+ subject=interpreter.label,
333
+ verify_command=check,
334
+ )
335
+ elif runtime.cuda_available is False:
336
+ yield Finding(
337
+ id="GSI-TORCH-003",
338
+ status=Status.FAIL if context.cuda_expected else Status.NOT_APPLICABLE,
339
+ severity=Severity.HIGH,
340
+ confidence=Confidence.HIGH,
341
+ summary="torch.cuda.is_available() is False",
342
+ reason=f"torch {runtime.torch_version or '?'} found no usable CUDA device.",
343
+ caused_by=_unavailable_cause(snapshot, interpreter),
344
+ evidence=(Evidence("runtime", "cuda available", "False"),),
345
+ subject=interpreter.label,
346
+ verify_command=check,
347
+ )
348
+ elif runtime.tensor_ok is False and failed is not None:
349
+ error = failed.error or ""
350
+ capability = runtime.devices[0].compute_capability if runtime.devices else None
351
+ no_kernels = "no kernel image" in error.lower()
352
+ yield Finding(
353
+ id="GSI-TORCH-003",
354
+ status=Status.FAIL,
355
+ severity=Severity.HIGH,
356
+ confidence=Confidence.HIGH,
357
+ summary="A CUDA operation failed",
358
+ reason=error,
359
+ recommended_action=(
360
+ f"This torch build has no GPU code for compute capability "
361
+ f"{capability or 'of this GPU'}; install a build that supports this GPU "
362
+ "(see the PyTorch release notes)."
363
+ if no_kernels
364
+ else "Check the driver with `nvidia-smi`, then run the operation by hand "
365
+ "to see the whole error."
366
+ ),
367
+ evidence=(Evidence("runtime", failed.step, error),),
368
+ subject=interpreter.label,
369
+ verify_command=check,
370
+ )
371
+ elif runtime.tensor_ok is True:
372
+ device = runtime.devices[0].name if runtime.devices else "the GPU"
373
+ seconds = next((s.seconds for s in runtime.steps if s.step == "tensor operation"), None)
374
+ yield Finding(
375
+ id="GSI-TORCH-003",
376
+ status=Status.PASS,
377
+ severity=Severity.INFO,
378
+ confidence=Confidence.HIGH,
379
+ summary=f"A CUDA operation ran on {device}",
380
+ reason=(
381
+ f"torch {runtime.torch_version or '?'} with CUDA {runtime.cuda_version or '?'}"
382
+ + (
383
+ f"; the tensor operation took {seconds:.2f} s."
384
+ if seconds is not None
385
+ else "."
386
+ )
387
+ ),
388
+ evidence=tuple(Evidence("runtime", s.step, "ok") for s in runtime.steps),
389
+ subject=interpreter.label,
390
+ verify_command=check,
391
+ )
392
+
393
+
394
+ @RULES.rule("GSI-TORCH-004", "CUDA was exercised at runtime")
395
+ def cuda_exercised(snapshot: Snapshot, context: RuleContext) -> Iterator[Finding]:
396
+ """Whether a CUDA build of torch was actually tried, not only read from metadata.
397
+
398
+ Metadata can show a CUDA build, but only importing torch and running an operation
399
+ proves it works: libraries can be missing, the driver can refuse, the build can lack
400
+ code for this GPU. Without probe --runtime this is "not tested", never a pass.
401
+ """
402
+ for interpreter in snapshot.interpreters:
403
+ if interpreter.version is None or interpreter.runtime is not None:
404
+ continue
405
+ if classify_torch(interpreter.torch).kind is not TorchBuildKind.CUDA:
406
+ continue
407
+ yield Finding(
408
+ id="GSI-TORCH-004",
409
+ status=Status.NOT_TESTED,
410
+ severity=Severity.LOW,
411
+ confidence=Confidence.HIGH,
412
+ summary="CUDA was not exercised; only metadata was read",
413
+ reason=(
414
+ "A CUDA build can still fail at runtime: missing libraries, a driver that "
415
+ "refuses, or no code for this GPU."
416
+ ),
417
+ subject=interpreter.label,
418
+ recommended_action=(
419
+ f"Run `gpu-stack-inspector probe --runtime --python {interpreter.path}` to import "
420
+ "torch and run a tiny CUDA operation (it uses a little GPU memory)."
421
+ ),
422
+ )
@@ -0,0 +1,227 @@
1
+ """What kind of PyTorch build an interpreter has, from static evidence only.
2
+
3
+ No single signal is reliable: PyPI's CUDA wheels have no ``+cu`` tag, conda's builds
4
+ have neither a tag nor nvidia-* wheels, and a damaged install can have the metadata of
5
+ one build and the files of another. Each signal votes; agreement gives confidence,
6
+ disagreement between strong signals gives ``unknown`` with the conflict spelled out.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import re
12
+ from dataclasses import dataclass
13
+ from enum import StrEnum
14
+
15
+ from gpu_stack_inspector.models import Confidence, Evidence, TorchFacts
16
+
17
+
18
+ class TorchBuildKind(StrEnum):
19
+ MISSING = "missing"
20
+ CUDA = "cuda"
21
+ CPU = "cpu"
22
+ ROCM = "rocm"
23
+ UNKNOWN = "unknown"
24
+
25
+
26
+ @dataclass(frozen=True, slots=True)
27
+ class TorchBuild:
28
+ """The verdict, the CUDA version the build targets when known, and why."""
29
+
30
+ kind: TorchBuildKind
31
+ confidence: Confidence
32
+ cuda_version: str | None = None
33
+ evidence: tuple[Evidence, ...] = ()
34
+ conflicts: tuple[str, ...] = ()
35
+
36
+
37
+ _TAG = re.compile(r"\+(?P<tag>[A-Za-z]+)(?P<number>[0-9.]*)")
38
+ _CONDA_PYTORCH = re.compile(r"pytorch-(?P<version>[0-9][^-]*)-(?P<build>.+)")
39
+ _CONDA_MUTEX = re.compile(r"pytorch-mutex-[0-9.]+-(?P<flavour>[A-Za-z]+)")
40
+ _CONDA_PYTORCH_CUDA = re.compile(r"pytorch-cuda-(?P<version>[0-9]+\.[0-9]+)-")
41
+ _CUDA_IN_BUILD = re.compile(r"cuda(?P<digits>[0-9]+)(?:\.(?P<minor>[0-9]+))?")
42
+
43
+ # Which signal's CUDA version wins when several name one: the build's own record first.
44
+ _VERSION_PY, _TAG_PRIORITY, _CONDA, _OTHER = 0, 1, 2, 9
45
+
46
+
47
+ @dataclass(frozen=True, slots=True)
48
+ class _Vote:
49
+ kind: TorchBuildKind
50
+ strong: bool
51
+ source: str
52
+ """Human description, used in conflict messages: "the version tag +cu128"."""
53
+ cuda_version: str | None = None
54
+ priority: int = _OTHER
55
+
56
+
57
+ def classify_torch(torch: TorchFacts | None) -> TorchBuild:
58
+ """Classify the build from the evidence of the torch an interpreter would import."""
59
+ if torch is None:
60
+ return TorchBuild(
61
+ TorchBuildKind.MISSING,
62
+ Confidence.HIGH,
63
+ evidence=(Evidence("python-metadata", "torch", None),),
64
+ )
65
+ votes: list[_Vote] = []
66
+ evidence: list[Evidence] = [Evidence("torch-metadata", "version", torch.version)]
67
+ _version_tag(torch, votes)
68
+ _version_py(torch, votes, evidence)
69
+ _libraries(torch, votes, evidence)
70
+ _requirements(torch, votes, evidence)
71
+ _conda(torch, votes, evidence)
72
+ return _decide(votes, tuple(evidence))
73
+
74
+
75
+ def cuda_from_digits(digits: str) -> str:
76
+ """``128`` → ``12.8``, ``92`` → ``9.2``: how PyTorch writes CUDA versions in tags."""
77
+ return f"{digits[:-1]}.{digits[-1]}" if len(digits) >= 2 else digits
78
+
79
+
80
+ def _version_tag(torch: TorchFacts, votes: list[_Vote]) -> None:
81
+ match = _TAG.search(torch.version)
82
+ if match is None:
83
+ return # PyPI's CUDA wheels and conda builds have no tag
84
+ tag, number = match["tag"].lower(), match["number"]
85
+ source = f"the version tag +{tag}{number}"
86
+ if tag == "cu" and number.isdigit():
87
+ votes.append(
88
+ _Vote(TorchBuildKind.CUDA, True, source, cuda_from_digits(number), _TAG_PRIORITY)
89
+ )
90
+ elif tag == "cpu":
91
+ votes.append(_Vote(TorchBuildKind.CPU, True, source))
92
+ elif tag == "rocm":
93
+ votes.append(_Vote(TorchBuildKind.ROCM, True, source))
94
+
95
+
96
+ def _version_py(torch: TorchFacts, votes: list[_Vote], evidence: list[Evidence]) -> None:
97
+ if not torch.version_py_found:
98
+ evidence.append(Evidence("torch/version.py", "found", "no"))
99
+ return
100
+ evidence.append(Evidence("torch/version.py", "cuda", torch.built_cuda or "None"))
101
+ evidence.append(Evidence("torch/version.py", "hip", torch.built_hip or "None"))
102
+ if torch.built_hip:
103
+ votes.append(_Vote(TorchBuildKind.ROCM, True, f"torch/version.py (hip {torch.built_hip})"))
104
+ elif torch.built_cuda:
105
+ source = f"torch/version.py (cuda {torch.built_cuda})"
106
+ votes.append(_Vote(TorchBuildKind.CUDA, True, source, torch.built_cuda, _VERSION_PY))
107
+ else:
108
+ votes.append(_Vote(TorchBuildKind.CPU, True, "torch/version.py (cuda None)"))
109
+
110
+
111
+ def _libraries(torch: TorchFacts, votes: list[_Vote], evidence: list[Evidence]) -> None:
112
+ for key, present in (("cuda_library", torch.cuda_library), ("hip_library", torch.hip_library)):
113
+ if present is not None:
114
+ evidence.append(Evidence("torch/lib", key, "present" if present else "absent"))
115
+ if torch.cuda_library:
116
+ votes.append(_Vote(TorchBuildKind.CUDA, True, "libtorch_cuda in torch/lib"))
117
+ elif torch.hip_library:
118
+ votes.append(_Vote(TorchBuildKind.ROCM, True, "libtorch_hip in torch/lib"))
119
+ elif torch.cuda_library is False and torch.hip_library is False:
120
+ # Weak: files can be missing from a damaged install of a GPU build.
121
+ votes.append(_Vote(TorchBuildKind.CPU, False, "no GPU library in torch/lib"))
122
+
123
+
124
+ def _requirements(torch: TorchFacts, votes: list[_Vote], evidence: list[Evidence]) -> None:
125
+ if torch.requires_nvidia:
126
+ evidence.append(
127
+ Evidence("torch-metadata", "requires_nvidia", ", ".join(torch.requires_nvidia))
128
+ )
129
+ votes.append(_Vote(TorchBuildKind.CUDA, False, "its nvidia-* requirements"))
130
+
131
+
132
+ def _conda(torch: TorchFacts, votes: list[_Vote], evidence: list[Evidence]) -> None:
133
+ for record in torch.conda_records or ():
134
+ if (match := _CONDA_PYTORCH.fullmatch(record)) is not None:
135
+ evidence.append(Evidence("conda-meta", "pytorch", record))
136
+ build = match["build"].lower()
137
+ source = f"conda's pytorch build {match['build']}"
138
+ if (cuda := _CUDA_IN_BUILD.search(build)) is not None:
139
+ version = (
140
+ f"{cuda['digits']}.{cuda['minor']}"
141
+ if cuda["minor"] is not None
142
+ else cuda_from_digits(cuda["digits"])
143
+ )
144
+ votes.append(_Vote(TorchBuildKind.CUDA, True, source, version, _CONDA))
145
+ elif "cpu" in build:
146
+ votes.append(_Vote(TorchBuildKind.CPU, True, source))
147
+ elif (mutex := _CONDA_MUTEX.fullmatch(record)) is not None:
148
+ evidence.append(Evidence("conda-meta", "pytorch-mutex", record))
149
+ flavour = mutex["flavour"].lower()
150
+ kind = {"cuda": TorchBuildKind.CUDA, "cpu": TorchBuildKind.CPU}.get(flavour)
151
+ if kind is not None:
152
+ votes.append(_Vote(kind, False, f"conda's pytorch-mutex ({flavour})"))
153
+ elif (pytorch_cuda := _CONDA_PYTORCH_CUDA.match(record)) is not None:
154
+ evidence.append(Evidence("conda-meta", "pytorch-cuda", record))
155
+ votes.append(
156
+ _Vote(
157
+ TorchBuildKind.CUDA,
158
+ False,
159
+ "conda's pytorch-cuda",
160
+ pytorch_cuda["version"],
161
+ _CONDA,
162
+ )
163
+ )
164
+
165
+
166
+ _LOWER = {
167
+ Confidence.HIGH: Confidence.MEDIUM,
168
+ Confidence.MEDIUM: Confidence.LOW,
169
+ Confidence.LOW: Confidence.LOW,
170
+ }
171
+
172
+
173
+ def _decide(votes: list[_Vote], evidence: tuple[Evidence, ...]) -> TorchBuild:
174
+ if not votes:
175
+ return TorchBuild(TorchBuildKind.UNKNOWN, Confidence.LOW, evidence=evidence)
176
+ strong_kinds = {vote.kind for vote in votes if vote.strong}
177
+ if len(strong_kinds) > 1:
178
+ return TorchBuild(
179
+ TorchBuildKind.UNKNOWN,
180
+ Confidence.LOW,
181
+ evidence=evidence,
182
+ conflicts=tuple(_conflicts(votes)),
183
+ )
184
+ if strong_kinds:
185
+ kind = next(iter(strong_kinds))
186
+ agreeing_strong = sum(vote.strong for vote in votes)
187
+ confidence = Confidence.HIGH if agreeing_strong >= 2 else Confidence.MEDIUM
188
+ else:
189
+ weak_kinds = {vote.kind for vote in votes}
190
+ if len(weak_kinds) > 1:
191
+ return TorchBuild(
192
+ TorchBuildKind.UNKNOWN,
193
+ Confidence.LOW,
194
+ evidence=evidence,
195
+ conflicts=tuple(_conflicts(votes)),
196
+ )
197
+ kind = next(iter(weak_kinds))
198
+ confidence = Confidence.MEDIUM if len(votes) >= 2 else Confidence.LOW
199
+
200
+ conflicts = _conflicts(votes) if any(vote.kind is not kind for vote in votes) else []
201
+ cuda_version = None
202
+ if kind is TorchBuildKind.CUDA:
203
+ named = sorted((v for v in votes if v.cuda_version), key=lambda v: v.priority)
204
+ if named:
205
+ cuda_version = named[0].cuda_version
206
+ for other in named[1:]:
207
+ if other.cuda_version != cuda_version:
208
+ conflicts.append(
209
+ f"{named[0].source} says CUDA {cuda_version}, "
210
+ f"but {other.source} says CUDA {other.cuda_version}"
211
+ )
212
+ if conflicts:
213
+ confidence = _LOWER[confidence]
214
+ return TorchBuild(kind, confidence, cuda_version, evidence, tuple(conflicts))
215
+
216
+
217
+ def _conflicts(votes: list[_Vote]) -> list[str]:
218
+ """One sentence per pair of disagreeing kinds, naming their strongest witnesses."""
219
+ witnesses: dict[TorchBuildKind, _Vote] = {}
220
+ for vote in sorted(votes, key=lambda v: not v.strong):
221
+ witnesses.setdefault(vote.kind, vote)
222
+ kinds = list(witnesses)
223
+ return [
224
+ f"{witnesses[a].source} says {a.value}, but {witnesses[b].source} says {b.value}"
225
+ for i, a in enumerate(kinds)
226
+ for b in kinds[i + 1 :]
227
+ ]