gpucheck-cli 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.
- gpucheck/__init__.py +1 -0
- gpucheck/adapter.py +192 -0
- gpucheck/cli.py +237 -0
- gpucheck/diagnose.py +203 -0
- gpucheck/fakes.py +140 -0
- gpucheck/load.py +126 -0
- gpucheck/metrics.py +116 -0
- gpucheck/preflight.py +95 -0
- gpucheck/report.py +101 -0
- gpucheck/resultjson.py +75 -0
- gpucheck/run.py +155 -0
- gpucheck/specs.py +59 -0
- gpucheck/upload.py +95 -0
- gpucheck_cli-0.1.0.dist-info/METADATA +122 -0
- gpucheck_cli-0.1.0.dist-info/RECORD +18 -0
- gpucheck_cli-0.1.0.dist-info/WHEEL +4 -0
- gpucheck_cli-0.1.0.dist-info/entry_points.txt +3 -0
- gpucheck_cli-0.1.0.dist-info/licenses/LICENSE +177 -0
gpucheck/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""gpucheck: health-check NVIDIA GPUs."""
|
gpucheck/adapter.py
ADDED
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
"""Telemetry types and the adapter protocol, plus the real NVML adapter.
|
|
2
|
+
|
|
3
|
+
All NVML access here is read-only: no NVML setter is ever called.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import hashlib
|
|
9
|
+
import time
|
|
10
|
+
import warnings
|
|
11
|
+
from dataclasses import dataclass
|
|
12
|
+
from typing import Protocol
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class AdapterUnavailable(Exception):
|
|
16
|
+
"""Raised when no GPU or NVIDIA driver is available."""
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@dataclass(frozen=True)
|
|
20
|
+
class GpuInventory:
|
|
21
|
+
model_name: str
|
|
22
|
+
uuid_hash: str
|
|
23
|
+
driver: str
|
|
24
|
+
cuda: str | None
|
|
25
|
+
ecc_enabled: bool | None
|
|
26
|
+
power_limit_w: float | None
|
|
27
|
+
default_power_limit_w: float | None
|
|
28
|
+
mig_enabled: bool
|
|
29
|
+
fractional: bool
|
|
30
|
+
retired_pages: int | None
|
|
31
|
+
remapped_rows: int | None # correctable + uncorrectable
|
|
32
|
+
row_remap_pending: bool | None = None
|
|
33
|
+
row_remap_failure: bool | None = None
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
@dataclass(frozen=True)
|
|
37
|
+
class TelemetrySample:
|
|
38
|
+
t_s: float
|
|
39
|
+
temp_c: float | None
|
|
40
|
+
power_w: float | None
|
|
41
|
+
sm_clock_mhz: float | None
|
|
42
|
+
fan_pct: float | None
|
|
43
|
+
throttle_reasons: frozenset[str] | None # None = NVML could not report reasons (unknown)
|
|
44
|
+
ecc_volatile: int | None
|
|
45
|
+
achieved_tflops: float | None
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class TelemetryAdapter(Protocol):
|
|
49
|
+
def inventory(self) -> GpuInventory: ...
|
|
50
|
+
|
|
51
|
+
def sample(self) -> TelemetrySample: ...
|
|
52
|
+
|
|
53
|
+
def busy(self) -> bool | None: ...
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def hash_uuid(uuid: str) -> str:
|
|
57
|
+
return hashlib.sha256(("gpucheck.v1." + uuid).encode()).hexdigest()[:16]
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _text(value: object) -> str:
|
|
61
|
+
return value.decode() if isinstance(value, bytes) else str(value)
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _try(fn, *args):
|
|
65
|
+
"""Call an NVML query; None if the GPU does not support it."""
|
|
66
|
+
try:
|
|
67
|
+
return fn(*args)
|
|
68
|
+
except Exception:
|
|
69
|
+
return None
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
# NVML ABI values, used only when pynvml exposes neither naming family.
|
|
73
|
+
_ABI = {
|
|
74
|
+
"SwPowerCap": 0x4,
|
|
75
|
+
"SwThermalSlowdown": 0x20,
|
|
76
|
+
"HwThermalSlowdown": 0x40,
|
|
77
|
+
"HwPowerBrakeSlowdown": 0x80,
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def _const(nv, *names: str, default: int) -> int:
|
|
82
|
+
"""First of `names` that pynvml defines, else `default`; never raises."""
|
|
83
|
+
for name in names:
|
|
84
|
+
value = getattr(nv, name, None)
|
|
85
|
+
if isinstance(value, int):
|
|
86
|
+
return value
|
|
87
|
+
return default
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def _reason_bit(nv, suffix: str) -> int:
|
|
91
|
+
# pynvml renamed "ClocksThrottleReason" to "ClocksEventReason" (NVML 12.x); the
|
|
92
|
+
# upper-case NVML_CLOCKS_THROTTLE_REASON_* spelling never existed in pynvml.
|
|
93
|
+
return _const(
|
|
94
|
+
nv,
|
|
95
|
+
f"nvmlClocksEventReason{suffix}",
|
|
96
|
+
f"nvmlClocksThrottleReason{suffix}",
|
|
97
|
+
default=_ABI[suffix],
|
|
98
|
+
)
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
class NvmlAdapter(TelemetryAdapter):
|
|
102
|
+
"""Reads GPU 0 through NVML (pynvml)."""
|
|
103
|
+
|
|
104
|
+
def __init__(self, index: int = 0) -> None:
|
|
105
|
+
try:
|
|
106
|
+
with warnings.catch_warnings():
|
|
107
|
+
warnings.simplefilter("ignore", FutureWarning) # pynvml deprecation notice
|
|
108
|
+
import pynvml
|
|
109
|
+
|
|
110
|
+
pynvml.nvmlInit()
|
|
111
|
+
self._nv = pynvml
|
|
112
|
+
self._h = pynvml.nvmlDeviceGetHandleByIndex(index)
|
|
113
|
+
except Exception as exc: # ImportError, NVMLError, missing library...
|
|
114
|
+
raise AdapterUnavailable(f"NVML unavailable: {exc}") from exc
|
|
115
|
+
self._t0 = time.monotonic()
|
|
116
|
+
|
|
117
|
+
def _q(self, name: str, *args):
|
|
118
|
+
"""Optional NVML query by name: None if pynvml lacks it or the GPU does not support it."""
|
|
119
|
+
fn = getattr(self._nv, name, None)
|
|
120
|
+
return None if fn is None else _try(fn, *args)
|
|
121
|
+
|
|
122
|
+
def inventory(self) -> GpuInventory:
|
|
123
|
+
nv, h = self._nv, self._h
|
|
124
|
+
uuid = _text(nv.nvmlDeviceGetUUID(h))
|
|
125
|
+
cuda_raw = self._q("nvmlSystemGetCudaDriverVersion")
|
|
126
|
+
cuda = f"{cuda_raw // 1000}.{(cuda_raw % 1000) // 10}" if cuda_raw else None
|
|
127
|
+
ecc = self._q("nvmlDeviceGetEccMode", h)
|
|
128
|
+
limit = self._q("nvmlDeviceGetPowerManagementLimit", h)
|
|
129
|
+
default = self._q("nvmlDeviceGetPowerManagementDefaultLimit", h)
|
|
130
|
+
mig = self._q("nvmlDeviceGetMigMode", h)
|
|
131
|
+
cause = _const(nv, "NVML_PAGE_RETIREMENT_CAUSE_MULTIPLE_SINGLE_BIT_ECC_ERRORS", default=0)
|
|
132
|
+
retired = self._q("nvmlDeviceGetRetiredPages", h, cause)
|
|
133
|
+
# (correctable rows, uncorrectable rows, remap pending, remap failure occurred)
|
|
134
|
+
remap = self._q("nvmlDeviceGetRemappedRows", h)
|
|
135
|
+
remap_ok = isinstance(remap, (tuple, list)) and len(remap) >= 4
|
|
136
|
+
return GpuInventory(
|
|
137
|
+
model_name=_text(nv.nvmlDeviceGetName(h)),
|
|
138
|
+
uuid_hash=hash_uuid(uuid),
|
|
139
|
+
driver=_text(nv.nvmlSystemGetDriverVersion()),
|
|
140
|
+
cuda=cuda,
|
|
141
|
+
ecc_enabled=bool(ecc[0]) if ecc else None,
|
|
142
|
+
power_limit_w=limit / 1000.0 if limit is not None else None,
|
|
143
|
+
default_power_limit_w=default / 1000.0 if default is not None else None,
|
|
144
|
+
mig_enabled=bool(mig and mig[0]),
|
|
145
|
+
fractional=False,
|
|
146
|
+
retired_pages=len(retired) if retired is not None else None,
|
|
147
|
+
remapped_rows=int(remap[0]) + int(remap[1]) if remap_ok else None,
|
|
148
|
+
row_remap_pending=bool(remap[2]) if remap_ok else None,
|
|
149
|
+
row_remap_failure=bool(remap[3]) if remap_ok else None,
|
|
150
|
+
)
|
|
151
|
+
|
|
152
|
+
def _throttle_mask(self) -> int | None:
|
|
153
|
+
for name in ("nvmlDeviceGetCurrentClocksEventReasons", "nvmlDeviceGetCurrentClocksThrottleReasons"):
|
|
154
|
+
mask = self._q(name, self._h)
|
|
155
|
+
if mask is not None:
|
|
156
|
+
return int(mask)
|
|
157
|
+
return None # unknown: recorded as a telemetry gap, never as "no throttling"
|
|
158
|
+
|
|
159
|
+
def sample(self) -> TelemetrySample:
|
|
160
|
+
nv, h = self._nv, self._h
|
|
161
|
+
temp = self._q("nvmlDeviceGetTemperature", h, _const(nv, "NVML_TEMPERATURE_GPU", default=0))
|
|
162
|
+
power = self._q("nvmlDeviceGetPowerUsage", h)
|
|
163
|
+
clock = self._q("nvmlDeviceGetClockInfo", h, _const(nv, "NVML_CLOCK_SM", default=1))
|
|
164
|
+
fan = self._q("nvmlDeviceGetFanSpeed", h)
|
|
165
|
+
mask = self._throttle_mask()
|
|
166
|
+
ecc = self._q(
|
|
167
|
+
"nvmlDeviceGetTotalEccErrors",
|
|
168
|
+
h,
|
|
169
|
+
_const(nv, "NVML_MEMORY_ERROR_TYPE_UNCORRECTED", default=1),
|
|
170
|
+
_const(nv, "NVML_VOLATILE_ECC", default=0),
|
|
171
|
+
)
|
|
172
|
+
reasons: frozenset[str] | None = None
|
|
173
|
+
if mask is not None:
|
|
174
|
+
names = {
|
|
175
|
+
"thermal": _reason_bit(nv, "HwThermalSlowdown") | _reason_bit(nv, "SwThermalSlowdown"),
|
|
176
|
+
"power": _reason_bit(nv, "SwPowerCap") | _reason_bit(nv, "HwPowerBrakeSlowdown"),
|
|
177
|
+
}
|
|
178
|
+
reasons = frozenset(name for name, bits in names.items() if mask & bits)
|
|
179
|
+
return TelemetrySample(
|
|
180
|
+
t_s=time.monotonic() - self._t0,
|
|
181
|
+
temp_c=float(temp) if temp is not None else None,
|
|
182
|
+
power_w=power / 1000.0 if power is not None else None,
|
|
183
|
+
sm_clock_mhz=float(clock) if clock is not None else None,
|
|
184
|
+
fan_pct=float(fan) if fan is not None else None,
|
|
185
|
+
throttle_reasons=reasons,
|
|
186
|
+
ecc_volatile=int(ecc) if ecc is not None else None,
|
|
187
|
+
achieved_tflops=None, # supplied by the load generator, not NVML
|
|
188
|
+
)
|
|
189
|
+
|
|
190
|
+
def busy(self) -> bool | None:
|
|
191
|
+
procs = self._q("nvmlDeviceGetComputeRunningProcesses", self._h)
|
|
192
|
+
return None if procs is None else len(procs) > 0
|
gpucheck/cli.py
ADDED
|
@@ -0,0 +1,237 @@
|
|
|
1
|
+
"""Command-line entry point: run, analyse, write result.json, print report."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import dataclasses
|
|
7
|
+
import sys
|
|
8
|
+
from datetime import datetime, timezone
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
|
|
11
|
+
from .adapter import AdapterUnavailable, NvmlAdapter
|
|
12
|
+
from .diagnose import diagnose
|
|
13
|
+
from .fakes import make_fake
|
|
14
|
+
from .load import LoadUnavailable, TorchGemmLoad
|
|
15
|
+
from .metrics import derive
|
|
16
|
+
from .report import render_report
|
|
17
|
+
from .resultjson import build_result, canonical_dumps
|
|
18
|
+
from .run import NullLoad, RunConfig, execute_run
|
|
19
|
+
from .specs import lookup
|
|
20
|
+
from .upload import ChallengeUnavailable, UploadOutcome, fetch_challenge, upload
|
|
21
|
+
|
|
22
|
+
DEFAULT_SERVER = "https://gpucheck-api.fly.dev"
|
|
23
|
+
|
|
24
|
+
EXIT_OK, EXIT_REFUSED, EXIT_NO_GPU, EXIT_ABORTED = 0, 1, 2, 3
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _parser() -> argparse.ArgumentParser:
|
|
28
|
+
p = argparse.ArgumentParser(prog="gpucheck", description="GPU health report card")
|
|
29
|
+
g = p.add_mutually_exclusive_group()
|
|
30
|
+
g.add_argument("--quick", action="store_true", help="shorter run (30s baseline, 150s load)")
|
|
31
|
+
g.add_argument("--long", action="store_true", help="longer run (60s baseline, 1800s load)")
|
|
32
|
+
p.add_argument("--offline", action="store_true", help="never upload; report locally only")
|
|
33
|
+
p.add_argument("--certify", action="store_true", help="request a one-time server challenge so the result can earn a certificate (requires a server; excludes --offline/--dry-run/--quick)")
|
|
34
|
+
p.add_argument("--yes-upload", action="store_true", help="upload without prompting")
|
|
35
|
+
p.add_argument("--dry-run", action="store_true", help="show what would be uploaded")
|
|
36
|
+
p.add_argument("--json", metavar="PATH", default="./gpucheck-result.json", help="where to write result.json")
|
|
37
|
+
p.add_argument("--server", metavar="URL", default=None, help="upload server URL")
|
|
38
|
+
p.add_argument("--fake", metavar="PROFILE", help=argparse.SUPPRESS)
|
|
39
|
+
return p
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _parse(argv):
|
|
43
|
+
p = _parser()
|
|
44
|
+
args = p.parse_args(argv)
|
|
45
|
+
if args.certify and args.offline:
|
|
46
|
+
p.error("--certify cannot be combined with --offline")
|
|
47
|
+
if args.certify and args.dry_run:
|
|
48
|
+
p.error("--certify cannot be combined with --dry-run")
|
|
49
|
+
if args.certify and args.quick:
|
|
50
|
+
p.error("--certify cannot be combined with --quick")
|
|
51
|
+
return args
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _now() -> datetime:
|
|
55
|
+
return datetime.now(timezone.utc)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def challenge_expired(challenge: dict, now: datetime | None = None) -> bool:
|
|
59
|
+
"""True when the challenge's expires_at (ISO 8601) is in the past.
|
|
60
|
+
|
|
61
|
+
An unparseable or missing expiry is not treated as expired: the server decides."""
|
|
62
|
+
raw = challenge.get("expires_at")
|
|
63
|
+
if not isinstance(raw, str):
|
|
64
|
+
return False
|
|
65
|
+
try:
|
|
66
|
+
exp = datetime.fromisoformat(raw.replace("Z", "+00:00"))
|
|
67
|
+
except ValueError:
|
|
68
|
+
return False
|
|
69
|
+
if exp.tzinfo is None:
|
|
70
|
+
exp = exp.replace(tzinfo=timezone.utc)
|
|
71
|
+
return (now or _now()) > exp
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def format_certify(out: UploadOutcome) -> str | None:
|
|
75
|
+
"""The server's certification verdict for a --certify upload, or None if it said nothing."""
|
|
76
|
+
if out.certifiable is True:
|
|
77
|
+
return "Certifiable: yes"
|
|
78
|
+
if out.certifiable is False:
|
|
79
|
+
return f"Not certifiable: {out.certify_note or 'no reason given'}"
|
|
80
|
+
return None
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def _make_adapter(args):
|
|
84
|
+
return make_fake(args.fake) if args.fake else NvmlAdapter()
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def _make_load(args, challenge=None):
|
|
88
|
+
if args.fake: # fake adapters supply their own achieved_tflops
|
|
89
|
+
return NullLoad()
|
|
90
|
+
if challenge is not None:
|
|
91
|
+
# Certification requires the real load generator; LoadUnavailable propagates.
|
|
92
|
+
return TorchGemmLoad(challenge["workload"])
|
|
93
|
+
try:
|
|
94
|
+
return TorchGemmLoad()
|
|
95
|
+
except LoadUnavailable:
|
|
96
|
+
print(
|
|
97
|
+
"no torch/CUDA — running without load; performance metrics will be gaps",
|
|
98
|
+
file=sys.stderr,
|
|
99
|
+
)
|
|
100
|
+
return NullLoad()
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def _config(args) -> RunConfig:
|
|
104
|
+
if args.quick:
|
|
105
|
+
return RunConfig(baseline_s=30, load_s=150, hz=1.0)
|
|
106
|
+
if args.long:
|
|
107
|
+
return RunConfig(baseline_s=60, load_s=1800, hz=1.0)
|
|
108
|
+
return RunConfig(baseline_s=60, load_s=600, hz=1.0)
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def _execute(adapter, args, challenge=None):
|
|
112
|
+
spec = lookup(adapter.inventory().model_name)
|
|
113
|
+
load = _make_load(args, challenge)
|
|
114
|
+
kwargs = {}
|
|
115
|
+
if args.fake: # simulated hardware: use a virtual clock instead of real waiting
|
|
116
|
+
t = [0.0]
|
|
117
|
+
|
|
118
|
+
def clock() -> float:
|
|
119
|
+
return t[0]
|
|
120
|
+
|
|
121
|
+
def sleep(s: float) -> None:
|
|
122
|
+
t[0] += s
|
|
123
|
+
|
|
124
|
+
kwargs = {"clock": clock, "sleep": sleep}
|
|
125
|
+
return spec, load, execute_run(adapter, load, _config(args), spec, **kwargs)
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def format_grade(out: UploadOutcome, model: str | None) -> str | None:
|
|
129
|
+
"""One-line grade summary from the server's response, or None if it said nothing."""
|
|
130
|
+
g = out.grade
|
|
131
|
+
if g:
|
|
132
|
+
letter = g.get("letter", "?")
|
|
133
|
+
ratio = g.get("ratio")
|
|
134
|
+
if not isinstance(ratio, (int, float)) or isinstance(ratio, bool):
|
|
135
|
+
return f"Grade {letter}"
|
|
136
|
+
n = g.get("n", out.n)
|
|
137
|
+
extra = f"n={n}" + (", low confidence" if g.get("low_confidence") else "")
|
|
138
|
+
return f"Grade {letter} — delivers {ratio * 100:.1f}% of a healthy {model or 'card of this model'} ({extra})"
|
|
139
|
+
if out.reason:
|
|
140
|
+
return f"Grade: {out.reason}"
|
|
141
|
+
if out.n is not None:
|
|
142
|
+
return f"Grade: insufficient reference (n={out.n})"
|
|
143
|
+
return None
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def _confirm(server: str) -> bool:
|
|
147
|
+
try:
|
|
148
|
+
return input(f"Upload this result to {server}? [y/N] ").strip().lower() == "y"
|
|
149
|
+
except (EOFError, KeyboardInterrupt): # closed stdin or Ctrl-C at the prompt means "no"
|
|
150
|
+
print()
|
|
151
|
+
return False
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def _maybe_upload(result: dict, args, challenge: dict | None = None) -> None:
|
|
155
|
+
if args.dry_run:
|
|
156
|
+
print(canonical_dumps(result))
|
|
157
|
+
return
|
|
158
|
+
server = args.server or DEFAULT_SERVER
|
|
159
|
+
if not args.yes_upload:
|
|
160
|
+
if not sys.stdin.isatty():
|
|
161
|
+
print("Not uploaded. To upload this result, re-run with --yes-upload.")
|
|
162
|
+
return
|
|
163
|
+
if not _confirm(server):
|
|
164
|
+
return
|
|
165
|
+
out = upload(result, server)
|
|
166
|
+
if not out.ok:
|
|
167
|
+
print(f"warning: upload failed ({out.error}); result.json is saved locally")
|
|
168
|
+
return
|
|
169
|
+
if out.url:
|
|
170
|
+
print(f"Result page: {out.url}")
|
|
171
|
+
line = format_grade(out, (result.get("inventory") or {}).get("model_name"))
|
|
172
|
+
if line:
|
|
173
|
+
print(line)
|
|
174
|
+
if challenge is not None:
|
|
175
|
+
cline = format_certify(out)
|
|
176
|
+
if cline:
|
|
177
|
+
print(cline)
|
|
178
|
+
if out.delete_token:
|
|
179
|
+
print(f"Delete token: {out.delete_token}")
|
|
180
|
+
print("(save this token to delete the page later)")
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def main(argv: list[str] | None = None) -> int:
|
|
184
|
+
args = _parse(argv)
|
|
185
|
+
try:
|
|
186
|
+
adapter = _make_adapter(args)
|
|
187
|
+
except AdapterUnavailable as e:
|
|
188
|
+
print(f"gpucheck: no NVIDIA GPU or driver available ({e}).", file=sys.stderr)
|
|
189
|
+
return EXIT_NO_GPU
|
|
190
|
+
except ValueError as e: # only _make_adapter resolves --fake, so this is a bad profile name
|
|
191
|
+
print(f"gpucheck: {e}", file=sys.stderr)
|
|
192
|
+
return EXIT_NO_GPU
|
|
193
|
+
challenge = None
|
|
194
|
+
if args.certify:
|
|
195
|
+
try:
|
|
196
|
+
challenge = fetch_challenge(args.server or DEFAULT_SERVER)
|
|
197
|
+
except ChallengeUnavailable as e:
|
|
198
|
+
print(f"gpucheck: certification unavailable: {e}", file=sys.stderr)
|
|
199
|
+
return EXIT_REFUSED
|
|
200
|
+
try:
|
|
201
|
+
spec, load, run = _execute(adapter, args, challenge)
|
|
202
|
+
except LoadUnavailable as e:
|
|
203
|
+
print(f"gpucheck: certification requires the GPU load generator ({e}).", file=sys.stderr)
|
|
204
|
+
return EXIT_REFUSED
|
|
205
|
+
except AdapterUnavailable as e:
|
|
206
|
+
print(f"gpucheck: no NVIDIA GPU or driver available ({e}).", file=sys.stderr)
|
|
207
|
+
return EXIT_NO_GPU
|
|
208
|
+
|
|
209
|
+
if run.aborted == "preflight_refused":
|
|
210
|
+
print("gpucheck: preflight refused to run:")
|
|
211
|
+
for reason in run.preflight.reasons:
|
|
212
|
+
print(f" - {reason}")
|
|
213
|
+
return EXIT_REFUSED
|
|
214
|
+
|
|
215
|
+
metrics = derive(run, spec)
|
|
216
|
+
findings = diagnose(metrics, run.preflight, run.inventory, spec)
|
|
217
|
+
challenge_workload = None
|
|
218
|
+
if challenge is not None:
|
|
219
|
+
challenge_workload = getattr(load, "resolved_workload", None)
|
|
220
|
+
if challenge_workload is None:
|
|
221
|
+
# Test/demo-only path (--fake): no real load ran, so synthesize the
|
|
222
|
+
# echo straight from the prescription. Real runs always echo what
|
|
223
|
+
# TorchGemmLoad actually resolved.
|
|
224
|
+
w = challenge["workload"]
|
|
225
|
+
challenge_workload = {
|
|
226
|
+
"gemm_n": [8192 + o for o in w["gemm_n_offsets"]],
|
|
227
|
+
"order_seed": w["order_seed"],
|
|
228
|
+
}
|
|
229
|
+
result = build_result(run, metrics, findings, challenge, challenge_workload)
|
|
230
|
+
Path(args.json).write_text(canonical_dumps(result)) # before any upload consideration
|
|
231
|
+
print(render_report(result))
|
|
232
|
+
print(f"Result written to {args.json}")
|
|
233
|
+
if challenge is not None and challenge_expired(challenge):
|
|
234
|
+
print("warning: challenge expired before upload; run again to certify — result saved locally")
|
|
235
|
+
elif not args.offline:
|
|
236
|
+
_maybe_upload(result, args, challenge)
|
|
237
|
+
return EXIT_ABORTED if run.aborted else EXIT_OK
|
gpucheck/diagnose.py
ADDED
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
"""Diagnosis rule table: turn derived metrics into plain-language findings.
|
|
2
|
+
|
|
3
|
+
Rules are evaluated in a fixed order (MEMORY_WEAR, TIM_DEGRADATION,
|
|
4
|
+
HOT_ENVIRONMENT, THERMAL_THROTTLING, POWER_CAPPED, UNDERPERFORMING_UNKNOWN,
|
|
5
|
+
INSUFFICIENT_EVIDENCE) and each rule fires at most once. Unknown throttle data
|
|
6
|
+
("throttle_reasons" in telemetry_gaps) is treated as unknown, never as "none". A rule whose required inputs are None does
|
|
7
|
+
not fire. Rules that compare against the datasheet need `spec`; they are
|
|
8
|
+
skipped when it is None.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
from dataclasses import dataclass
|
|
14
|
+
from typing import Literal
|
|
15
|
+
|
|
16
|
+
from .adapter import GpuInventory
|
|
17
|
+
from .metrics import THERMAL_REASON, Metrics
|
|
18
|
+
from .preflight import Preflight
|
|
19
|
+
from .specs import GpuSpec
|
|
20
|
+
|
|
21
|
+
Severity = Literal["info", "warn", "bad"]
|
|
22
|
+
Confidence = Literal["low", "medium", "high"]
|
|
23
|
+
|
|
24
|
+
TIM_TIME_TO_THROTTLE_MAX_S = 180.0
|
|
25
|
+
TIM_STEADY_MARGIN_C = 3.0
|
|
26
|
+
TIM_IDLE_MAX_C = 45.0
|
|
27
|
+
TIM_HIGH_CONFIDENCE_IDLE_C = 40.0
|
|
28
|
+
HOT_IDLE_C = 50.0
|
|
29
|
+
POWER_LIMIT_FRACTION = 0.9
|
|
30
|
+
POWER_REASON_HEADROOM_C = 15.0
|
|
31
|
+
UNDERPERFORMING_RATIO = 0.9
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
@dataclass(frozen=True)
|
|
35
|
+
class Finding:
|
|
36
|
+
code: str
|
|
37
|
+
severity: Severity
|
|
38
|
+
confidence: Confidence
|
|
39
|
+
message: str
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _g(value: float) -> str:
|
|
43
|
+
return f"{value:g}"
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _throttled(m: Metrics) -> bool:
|
|
47
|
+
return m.time_to_throttle_s is not None or THERMAL_REASON in m.throttle_reasons_seen
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _memory_wear(m: Metrics, inv: GpuInventory) -> Finding | None:
|
|
51
|
+
parts: list[str] = []
|
|
52
|
+
if m.ecc_delta is not None and m.ecc_delta > 0:
|
|
53
|
+
parts.append(f"ECC error count rose by {m.ecc_delta} during the run")
|
|
54
|
+
if inv.remapped_rows is not None and inv.remapped_rows > 0:
|
|
55
|
+
parts.append(f"{inv.remapped_rows} memory row(s) were remapped")
|
|
56
|
+
if inv.row_remap_failure:
|
|
57
|
+
parts.append("memory row remapping has failed")
|
|
58
|
+
if not parts:
|
|
59
|
+
return None
|
|
60
|
+
sentence = " and ".join(parts)
|
|
61
|
+
return Finding(
|
|
62
|
+
"MEMORY_WEAR",
|
|
63
|
+
"bad",
|
|
64
|
+
"high",
|
|
65
|
+
sentence[0].upper() + sentence[1:] + ", which points to memory wear.",
|
|
66
|
+
)
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _tim_degradation(m: Metrics, spec: GpuSpec | None) -> Finding | None:
|
|
70
|
+
if spec is None or m.time_to_throttle_s is None or m.steady_temp_c is None or m.idle_temp_c is None:
|
|
71
|
+
return None
|
|
72
|
+
if m.idle_temp_c > TIM_IDLE_MAX_C:
|
|
73
|
+
return None
|
|
74
|
+
if m.time_to_throttle_s >= TIM_TIME_TO_THROTTLE_MAX_S:
|
|
75
|
+
return None
|
|
76
|
+
if m.steady_temp_c < spec.max_temp_c - TIM_STEADY_MARGIN_C:
|
|
77
|
+
return None
|
|
78
|
+
confidence: Confidence = "high" if m.idle_temp_c <= TIM_HIGH_CONFIDENCE_IDLE_C else "medium"
|
|
79
|
+
return Finding(
|
|
80
|
+
"TIM_DEGRADATION",
|
|
81
|
+
"bad",
|
|
82
|
+
confidence,
|
|
83
|
+
f"Throttled after {_g(m.time_to_throttle_s)} s; steady {_g(m.steady_temp_c)} °C vs "
|
|
84
|
+
f"{_g(spec.max_temp_c)} °C limit; idle was {_g(m.idle_temp_c)} °C, which suggests the "
|
|
85
|
+
"thermal paste or cooler contact has degraded.",
|
|
86
|
+
)
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def _hot_environment(m: Metrics) -> Finding | None:
|
|
90
|
+
if m.idle_temp_c is None or m.idle_temp_c <= HOT_IDLE_C:
|
|
91
|
+
return None
|
|
92
|
+
return Finding(
|
|
93
|
+
"HOT_ENVIRONMENT",
|
|
94
|
+
"warn",
|
|
95
|
+
"high",
|
|
96
|
+
f"Idle temperature was {_g(m.idle_temp_c)} °C, above {_g(HOT_IDLE_C)} °C, so room "
|
|
97
|
+
"temperature or airflow may be the cause of the heat.",
|
|
98
|
+
)
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def _power_capped(m: Metrics, inv: GpuInventory, spec: GpuSpec | None) -> Finding | None:
|
|
102
|
+
limit_arm = (
|
|
103
|
+
inv.power_limit_w is not None
|
|
104
|
+
and inv.default_power_limit_w is not None
|
|
105
|
+
and inv.power_limit_w < POWER_LIMIT_FRACTION * inv.default_power_limit_w
|
|
106
|
+
)
|
|
107
|
+
reason_arm = (
|
|
108
|
+
spec is not None
|
|
109
|
+
and m.steady_temp_c is not None
|
|
110
|
+
and any("power" in r for r in m.throttle_reasons_seen)
|
|
111
|
+
and m.steady_temp_c < spec.max_temp_c - POWER_REASON_HEADROOM_C
|
|
112
|
+
)
|
|
113
|
+
if limit_arm:
|
|
114
|
+
assert inv.power_limit_w is not None and inv.default_power_limit_w is not None
|
|
115
|
+
return Finding(
|
|
116
|
+
"POWER_CAPPED",
|
|
117
|
+
"info",
|
|
118
|
+
"high",
|
|
119
|
+
f"Power limit is {_g(inv.power_limit_w)} W, below 90% of the "
|
|
120
|
+
f"{_g(inv.default_power_limit_w)} W default, so the card is capped below normal power.",
|
|
121
|
+
)
|
|
122
|
+
if reason_arm:
|
|
123
|
+
assert spec is not None and m.steady_temp_c is not None
|
|
124
|
+
return Finding(
|
|
125
|
+
"POWER_CAPPED",
|
|
126
|
+
"info",
|
|
127
|
+
"high",
|
|
128
|
+
f"Power capping was seen while steady at {_g(m.steady_temp_c)} °C, well below the "
|
|
129
|
+
f"{_g(spec.max_temp_c)} °C limit, so the power limit rather than heat held clocks down.",
|
|
130
|
+
)
|
|
131
|
+
return None
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def _thermal_throttling(m: Metrics) -> Finding | None:
|
|
135
|
+
"""Throttling that neither TIM_DEGRADATION nor INSUFFICIENT_EVIDENCE explains."""
|
|
136
|
+
if not _throttled(m):
|
|
137
|
+
return None
|
|
138
|
+
when = f" after {_g(m.time_to_throttle_s)} s of load" if m.time_to_throttle_s is not None else ""
|
|
139
|
+
return Finding(
|
|
140
|
+
"THERMAL_THROTTLING",
|
|
141
|
+
"warn",
|
|
142
|
+
"medium",
|
|
143
|
+
f"The card thermal-throttled{when} even though it idled cool, so sustained "
|
|
144
|
+
"clocks were limited by heat; check airflow and cooler.",
|
|
145
|
+
)
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def _underperforming(m: Metrics, spec: GpuSpec | None) -> Finding | None:
|
|
149
|
+
if spec is None or m.tflops_ratio_vs_spec is None or m.throttle_reasons_seen:
|
|
150
|
+
return None
|
|
151
|
+
if m.tflops_ratio_vs_spec >= UNDERPERFORMING_RATIO:
|
|
152
|
+
return None
|
|
153
|
+
if "throttle_reasons" in m.telemetry_gaps:
|
|
154
|
+
context = "and throttle-reason data was unavailable"
|
|
155
|
+
else:
|
|
156
|
+
context = "with no throttling seen"
|
|
157
|
+
return Finding(
|
|
158
|
+
"UNDERPERFORMING_UNKNOWN",
|
|
159
|
+
"warn",
|
|
160
|
+
"low",
|
|
161
|
+
f"Sustained throughput was {m.tflops_ratio_vs_spec:.1%} of the datasheet peak {context}, "
|
|
162
|
+
"and the cause is unknown.",
|
|
163
|
+
)
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def _insufficient_evidence(m: Metrics, spec: GpuSpec | None) -> Finding | None:
|
|
167
|
+
if not _throttled(m):
|
|
168
|
+
return None
|
|
169
|
+
if m.idle_temp_c is not None and m.idle_temp_c <= TIM_IDLE_MAX_C:
|
|
170
|
+
return None
|
|
171
|
+
if m.idle_temp_c is None:
|
|
172
|
+
detail = "no idle baseline was recorded"
|
|
173
|
+
else:
|
|
174
|
+
detail = f"idle was {_g(m.idle_temp_c)} °C, above {_g(TIM_IDLE_MAX_C)} °C"
|
|
175
|
+
return Finding(
|
|
176
|
+
"INSUFFICIENT_EVIDENCE",
|
|
177
|
+
"info",
|
|
178
|
+
"low",
|
|
179
|
+
f"Throttling was seen, but {detail}, so a cooler fault cannot be confirmed or ruled out.",
|
|
180
|
+
)
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def diagnose(
|
|
184
|
+
metrics: Metrics,
|
|
185
|
+
preflight: Preflight,
|
|
186
|
+
inventory: GpuInventory,
|
|
187
|
+
spec: GpuSpec | None,
|
|
188
|
+
) -> list[Finding]:
|
|
189
|
+
"""Return the findings for one card, in fixed rule order; empty means healthy."""
|
|
190
|
+
del preflight # the v1 rule table does not consult preflight
|
|
191
|
+
tim = _tim_degradation(metrics, spec)
|
|
192
|
+
insufficient = _insufficient_evidence(metrics, spec)
|
|
193
|
+
throttling = _thermal_throttling(metrics) if tim is None and insufficient is None else None
|
|
194
|
+
rules = (
|
|
195
|
+
_memory_wear(metrics, inventory),
|
|
196
|
+
tim,
|
|
197
|
+
_hot_environment(metrics),
|
|
198
|
+
throttling,
|
|
199
|
+
_power_capped(metrics, inventory, spec),
|
|
200
|
+
_underperforming(metrics, spec),
|
|
201
|
+
insufficient,
|
|
202
|
+
)
|
|
203
|
+
return [f for f in rules if f is not None]
|