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 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]