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/fakes.py
ADDED
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
"""Scripted fake GPUs for tests and the CLI --fake flag.
|
|
2
|
+
|
|
3
|
+
Each profile is a generator of TelemetrySample. After the scripted phase the
|
|
4
|
+
generator keeps yielding the final steady state, so it can be sampled forever.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from collections.abc import Callable, Iterator
|
|
10
|
+
|
|
11
|
+
from .adapter import GpuInventory, TelemetryAdapter, TelemetrySample, hash_uuid
|
|
12
|
+
|
|
13
|
+
MODEL = "NVIDIA H100 80GB HBM3"
|
|
14
|
+
FAKE_UUID = "GPU-00000000-0000-0000-0000-000000000000"
|
|
15
|
+
|
|
16
|
+
HEALTHY_TFLOPS = 950.0
|
|
17
|
+
DEGRADED_TFLOPS = 600.0
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _inventory(**overrides) -> GpuInventory:
|
|
21
|
+
base = dict(
|
|
22
|
+
model_name=MODEL,
|
|
23
|
+
uuid_hash=hash_uuid(FAKE_UUID),
|
|
24
|
+
driver="550.54.15",
|
|
25
|
+
cuda="12.4",
|
|
26
|
+
ecc_enabled=True,
|
|
27
|
+
power_limit_w=700.0,
|
|
28
|
+
default_power_limit_w=700.0,
|
|
29
|
+
mig_enabled=False,
|
|
30
|
+
fractional=False,
|
|
31
|
+
retired_pages=0,
|
|
32
|
+
remapped_rows=0,
|
|
33
|
+
)
|
|
34
|
+
base.update(overrides)
|
|
35
|
+
return GpuInventory(**base)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _sample(t: float, temp: float, **kw) -> TelemetrySample:
|
|
39
|
+
fields = dict(
|
|
40
|
+
t_s=t,
|
|
41
|
+
temp_c=temp,
|
|
42
|
+
power_w=650.0,
|
|
43
|
+
sm_clock_mhz=1980.0,
|
|
44
|
+
fan_pct=None,
|
|
45
|
+
throttle_reasons=frozenset(),
|
|
46
|
+
ecc_volatile=0,
|
|
47
|
+
achieved_tflops=HEALTHY_TFLOPS,
|
|
48
|
+
)
|
|
49
|
+
fields.update(kw)
|
|
50
|
+
return TelemetrySample(**fields)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _wobble(i: int) -> float:
|
|
54
|
+
return (0.0, 0.5, 1.0, 0.5)[i % 4] - 0.5 # deterministic, in [-0.5, 0.5]
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
IDLE_SAMPLES = 61 # preflight sample + the longest (60 s) baseline
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _healthy() -> Iterator[TelemetrySample]:
|
|
61
|
+
i = 0
|
|
62
|
+
while True:
|
|
63
|
+
if i < IDLE_SAMPLES: # cool, low-power idle through preflight and baseline
|
|
64
|
+
yield _sample(float(i), 35.0 + _wobble(i), power_w=80.0, sm_clock_mhz=210.0)
|
|
65
|
+
else:
|
|
66
|
+
warm = min(i - IDLE_SAMPLES, 20) / 20 # ramp 35 -> 62 C over 20 s, then steady
|
|
67
|
+
yield _sample(float(i), 35.0 + 27.0 * warm + _wobble(i))
|
|
68
|
+
i += 1
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def _hot_env() -> Iterator[TelemetrySample]:
|
|
72
|
+
i = 0
|
|
73
|
+
while True:
|
|
74
|
+
yield _sample(float(i), 55.0 + _wobble(i), power_w=80.0, sm_clock_mhz=210.0)
|
|
75
|
+
i += 1
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def _tim_degraded() -> Iterator[TelemetrySample]:
|
|
79
|
+
i = 0
|
|
80
|
+
while True:
|
|
81
|
+
temp = min(87.0, 35.0 + i * 0.52) # 87 C at sample 100
|
|
82
|
+
throttled = temp > 83.0
|
|
83
|
+
if temp <= 80.0:
|
|
84
|
+
tflops = HEALTHY_TFLOPS
|
|
85
|
+
else:
|
|
86
|
+
tflops = HEALTHY_TFLOPS - (HEALTHY_TFLOPS - DEGRADED_TFLOPS) * (temp - 80.0) / 7.0
|
|
87
|
+
yield _sample(
|
|
88
|
+
float(i),
|
|
89
|
+
temp,
|
|
90
|
+
sm_clock_mhz=1980.0 if not throttled else 1500.0,
|
|
91
|
+
throttle_reasons=frozenset({"thermal"}) if throttled else frozenset(),
|
|
92
|
+
achieved_tflops=tflops,
|
|
93
|
+
)
|
|
94
|
+
i += 1
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def _power_capped() -> Iterator[TelemetrySample]:
|
|
98
|
+
i = 0
|
|
99
|
+
while True:
|
|
100
|
+
yield _sample(
|
|
101
|
+
float(i),
|
|
102
|
+
55.0 + 8.0 * min(i, 20) / 20 + _wobble(i),
|
|
103
|
+
power_w=400.0,
|
|
104
|
+
sm_clock_mhz=1400.0,
|
|
105
|
+
throttle_reasons=frozenset({"power"}),
|
|
106
|
+
achieved_tflops=700.0,
|
|
107
|
+
)
|
|
108
|
+
i += 1
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
_PROFILES: dict[str, tuple[Callable[[], Iterator[TelemetrySample]], dict]] = {
|
|
112
|
+
"healthy": (_healthy, {}),
|
|
113
|
+
"tim_degraded": (_tim_degraded, {}),
|
|
114
|
+
"hot_env": (_hot_env, {}),
|
|
115
|
+
"power_capped": (_power_capped, {"power_limit_w": 400.0, "default_power_limit_w": 700.0}),
|
|
116
|
+
"mig": (_healthy, {"mig_enabled": True}),
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
class FakeAdapter:
|
|
121
|
+
def __init__(self, inventory: GpuInventory, samples: Iterator[TelemetrySample]) -> None:
|
|
122
|
+
self._inventory = inventory
|
|
123
|
+
self._samples = samples
|
|
124
|
+
|
|
125
|
+
def inventory(self) -> GpuInventory:
|
|
126
|
+
return self._inventory
|
|
127
|
+
|
|
128
|
+
def sample(self) -> TelemetrySample:
|
|
129
|
+
return next(self._samples)
|
|
130
|
+
|
|
131
|
+
def busy(self) -> bool | None:
|
|
132
|
+
return False
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def make_fake(profile: str) -> TelemetryAdapter:
|
|
136
|
+
try:
|
|
137
|
+
factory, inv = _PROFILES[profile]
|
|
138
|
+
except KeyError:
|
|
139
|
+
raise ValueError(f"unknown fake profile {profile!r}; choose from {sorted(_PROFILES)}") from None
|
|
140
|
+
return FakeAdapter(_inventory(**inv), factory())
|
gpucheck/load.py
ADDED
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
"""Torch bf16 GEMM load generator. torch is imported lazily (optional extra)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import math
|
|
6
|
+
import os
|
|
7
|
+
import time
|
|
8
|
+
|
|
9
|
+
MIN_N = 2048
|
|
10
|
+
MAX_N = 16384
|
|
11
|
+
MEM_FRACTION = 0.6
|
|
12
|
+
BYTES_PER_ELEM = 2 # bf16
|
|
13
|
+
TICK_S = 0.8
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class LoadUnavailable(Exception):
|
|
17
|
+
"""torch or a CUDA device is not available."""
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def pick_gemm_size(free_mem_bytes: int) -> int:
|
|
21
|
+
"""Largest n with 3 bf16 n*n matrices within 60% of free memory, clamped."""
|
|
22
|
+
budget = int(MEM_FRACTION * max(free_mem_bytes, 0))
|
|
23
|
+
n = math.isqrt(budget // (3 * BYTES_PER_ELEM))
|
|
24
|
+
return max(MIN_N, min(MAX_N, n))
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def apply_workload(base_n: int, offsets: list[int]) -> list[int]:
|
|
28
|
+
"""Resolve prescribed gemm_n offsets against an auto-sized base N.
|
|
29
|
+
|
|
30
|
+
The base is first clamped into [MIN_N - min(offsets), MAX_N - max(offsets)]
|
|
31
|
+
so that base + offset never needs clamping: the server verifies pairwise
|
|
32
|
+
differences between the sizes, which clamping an individual value would break.
|
|
33
|
+
"""
|
|
34
|
+
lo = MIN_N - min(offsets)
|
|
35
|
+
hi = MAX_N - max(offsets)
|
|
36
|
+
base = max(lo, min(hi, base_n))
|
|
37
|
+
return [base + o for o in offsets]
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def tflops(n: int, iters: int, elapsed_s: float) -> float:
|
|
41
|
+
return 2 * n**3 * iters / elapsed_s / 1e12
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class TorchGemmLoad:
|
|
45
|
+
def __init__(self, workload: dict | None = None) -> None:
|
|
46
|
+
# Make torch's cuda:0 the same physical card NVML index 0 reads; CUDA's
|
|
47
|
+
# default "fastest first" order can differ from NVML's PCI bus order.
|
|
48
|
+
# Must be set before torch initialises CUDA, so before the import.
|
|
49
|
+
os.environ.setdefault("CUDA_DEVICE_ORDER", "PCI_BUS_ID")
|
|
50
|
+
try:
|
|
51
|
+
import torch
|
|
52
|
+
except ImportError as e:
|
|
53
|
+
raise LoadUnavailable("torch is not installed") from e
|
|
54
|
+
try:
|
|
55
|
+
if not torch.cuda.is_available():
|
|
56
|
+
raise LoadUnavailable("CUDA is not available")
|
|
57
|
+
except RuntimeError as e:
|
|
58
|
+
raise LoadUnavailable(f"CUDA probe failed: {e}") from e
|
|
59
|
+
self._torch = torch
|
|
60
|
+
self._workload = dict(workload) if workload is not None else None
|
|
61
|
+
self._ns: list[int] = []
|
|
62
|
+
self._tick_i = 0
|
|
63
|
+
self.resolved_workload: dict | None = None
|
|
64
|
+
self._a = self._b = self._c = None
|
|
65
|
+
|
|
66
|
+
def start(self) -> None:
|
|
67
|
+
t = self._torch
|
|
68
|
+
# Memory sizing creates this process's CUDA context, so it must not
|
|
69
|
+
# happen in __init__: preflight reads the GPU's process list after the
|
|
70
|
+
# load is constructed, and a context of our own makes the card look busy.
|
|
71
|
+
try:
|
|
72
|
+
free, _total = t.cuda.mem_get_info()
|
|
73
|
+
except RuntimeError as e:
|
|
74
|
+
raise LoadUnavailable(f"CUDA probe failed: {e}") from e
|
|
75
|
+
n = pick_gemm_size(free)
|
|
76
|
+
self._ns = [n]
|
|
77
|
+
if self._workload is not None:
|
|
78
|
+
self._ns = apply_workload(n, list(self._workload["gemm_n_offsets"]))
|
|
79
|
+
self.resolved_workload = {
|
|
80
|
+
"gemm_n": list(self._ns),
|
|
81
|
+
"order_seed": self._workload["order_seed"],
|
|
82
|
+
}
|
|
83
|
+
# One flat buffer at the largest size; each tick views the first n*n
|
|
84
|
+
# elements as a contiguous n x n matrix (no per-size reallocation).
|
|
85
|
+
# Without a workload this is the plain single-size n x n path.
|
|
86
|
+
m = max(self._ns)
|
|
87
|
+
shape = (m * m,) if self.resolved_workload else (m, m)
|
|
88
|
+
self._a = t.randn(*shape, device="cuda", dtype=t.bfloat16)
|
|
89
|
+
self._b = t.randn(*shape, device="cuda", dtype=t.bfloat16)
|
|
90
|
+
self._c = t.empty(*shape, device="cuda", dtype=t.bfloat16)
|
|
91
|
+
a, b, c = self._views(self._ns[0])
|
|
92
|
+
t.matmul(a, b, out=c) # warmup
|
|
93
|
+
t.cuda.synchronize()
|
|
94
|
+
|
|
95
|
+
def _views(self, n: int):
|
|
96
|
+
if not self.resolved_workload:
|
|
97
|
+
return self._a, self._b, self._c
|
|
98
|
+
k = n * n
|
|
99
|
+
return (
|
|
100
|
+
self._a[:k].view(n, n),
|
|
101
|
+
self._b[:k].view(n, n),
|
|
102
|
+
self._c[:k].view(n, n),
|
|
103
|
+
)
|
|
104
|
+
|
|
105
|
+
def tick(self) -> float | None:
|
|
106
|
+
t = self._torch
|
|
107
|
+
n = self._ns[self._tick_i % len(self._ns)] # cycle prescribed sizes per tick
|
|
108
|
+
self._tick_i += 1
|
|
109
|
+
a, b, c = self._views(n)
|
|
110
|
+
iters = 0
|
|
111
|
+
t.cuda.synchronize()
|
|
112
|
+
t0 = time.perf_counter()
|
|
113
|
+
while time.perf_counter() - t0 < TICK_S:
|
|
114
|
+
t.matmul(a, b, out=c)
|
|
115
|
+
# Wait for each kernel: matmul only enqueues, so without this the loop
|
|
116
|
+
# would queue thousands of kernels in 0.8 s and the tick (and the
|
|
117
|
+
# watchdog sampling between ticks) would stall for minutes.
|
|
118
|
+
t.cuda.synchronize()
|
|
119
|
+
iters += 1
|
|
120
|
+
t.cuda.synchronize()
|
|
121
|
+
elapsed = time.perf_counter() - t0
|
|
122
|
+
return tflops(n, iters, elapsed) if iters else None
|
|
123
|
+
|
|
124
|
+
def stop(self) -> None:
|
|
125
|
+
self._a = self._b = self._c = None
|
|
126
|
+
self._torch.cuda.empty_cache()
|
gpucheck/metrics.py
ADDED
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
"""Derived metrics computed from a RawRun.
|
|
2
|
+
|
|
3
|
+
Every metric whose inputs are all None is None and its metric-source field
|
|
4
|
+
name is recorded in `telemetry_gaps`; nothing is guessed. Load-phase sample
|
|
5
|
+
timestamps are absolute, so time_to_throttle_s is measured from the first
|
|
6
|
+
load sample.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import statistics
|
|
12
|
+
from collections.abc import Iterable
|
|
13
|
+
from dataclasses import dataclass
|
|
14
|
+
|
|
15
|
+
from .adapter import TelemetrySample
|
|
16
|
+
from .run import RawRun
|
|
17
|
+
from .specs import GpuSpec
|
|
18
|
+
|
|
19
|
+
THERMAL_REASON = "thermal"
|
|
20
|
+
FINAL_FRACTION = 0.2
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@dataclass(frozen=True)
|
|
24
|
+
class Metrics:
|
|
25
|
+
idle_temp_c: float | None
|
|
26
|
+
time_to_throttle_s: float | None
|
|
27
|
+
steady_temp_c: float | None
|
|
28
|
+
sustained_tflops: float | None
|
|
29
|
+
tflops_ratio_vs_spec: float | None
|
|
30
|
+
sm_clock_sigma: float | None
|
|
31
|
+
power_sigma_w: float | None
|
|
32
|
+
ecc_delta: int | None
|
|
33
|
+
throttle_reasons_seen: list[str]
|
|
34
|
+
telemetry_gaps: list[str]
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _present(values: Iterable[float | int | None]) -> list[float | int]:
|
|
38
|
+
return [v for v in values if v is not None]
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _mean(values: Iterable[float | None]) -> float | None:
|
|
42
|
+
vals = _present(values)
|
|
43
|
+
return sum(vals) / len(vals) if vals else None
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _pstdev(values: Iterable[float | None]) -> float | None:
|
|
47
|
+
vals = _present(values)
|
|
48
|
+
return statistics.pstdev(vals) if len(vals) >= 2 else None
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def derive(run: RawRun, spec: GpuSpec | None) -> Metrics:
|
|
52
|
+
gaps: list[str] = []
|
|
53
|
+
|
|
54
|
+
def gap(name: str) -> None:
|
|
55
|
+
if name not in gaps:
|
|
56
|
+
gaps.append(name)
|
|
57
|
+
|
|
58
|
+
baseline = run.baseline
|
|
59
|
+
load = run.load
|
|
60
|
+
|
|
61
|
+
idle_temp = _mean(s.temp_c for s in baseline)
|
|
62
|
+
if idle_temp is None:
|
|
63
|
+
gap("temp_c")
|
|
64
|
+
|
|
65
|
+
time_to_throttle: float | None = None
|
|
66
|
+
known_reasons = [s for s in load if s.throttle_reasons is not None]
|
|
67
|
+
if not known_reasons:
|
|
68
|
+
gap("throttle_reasons") # unknown throttle state is a gap, never "no throttling"
|
|
69
|
+
for s in known_reasons:
|
|
70
|
+
assert s.throttle_reasons is not None
|
|
71
|
+
if THERMAL_REASON in s.throttle_reasons:
|
|
72
|
+
time_to_throttle = s.t_s - load[0].t_s
|
|
73
|
+
break
|
|
74
|
+
|
|
75
|
+
window: list[TelemetrySample] = []
|
|
76
|
+
if load:
|
|
77
|
+
window = load[-max(1, round(FINAL_FRACTION * len(load))) :]
|
|
78
|
+
|
|
79
|
+
steady_temp = _mean(s.temp_c for s in window)
|
|
80
|
+
if steady_temp is None:
|
|
81
|
+
gap("temp_c")
|
|
82
|
+
|
|
83
|
+
sustained = _mean(s.achieved_tflops for s in window)
|
|
84
|
+
if sustained is None:
|
|
85
|
+
gap("achieved_tflops")
|
|
86
|
+
ratio = sustained / spec.peak_bf16_tflops if sustained is not None and spec is not None else None
|
|
87
|
+
|
|
88
|
+
sm_sigma = _pstdev(s.sm_clock_mhz for s in load)
|
|
89
|
+
if sm_sigma is None:
|
|
90
|
+
gap("sm_clock_mhz")
|
|
91
|
+
power_sigma = _pstdev(s.power_w for s in load)
|
|
92
|
+
if power_sigma is None:
|
|
93
|
+
gap("power_w")
|
|
94
|
+
|
|
95
|
+
all_ecc = _present(s.ecc_volatile for s in baseline + load)
|
|
96
|
+
if not all_ecc:
|
|
97
|
+
gap("ecc_volatile")
|
|
98
|
+
ecc_delta: int | None = None
|
|
99
|
+
load_ecc = _present(s.ecc_volatile for s in load)
|
|
100
|
+
if load_ecc and len(all_ecc) >= 2:
|
|
101
|
+
ecc_delta = load_ecc[-1] - all_ecc[0]
|
|
102
|
+
|
|
103
|
+
reasons_seen = sorted({r for s in known_reasons for r in (s.throttle_reasons or ())})
|
|
104
|
+
|
|
105
|
+
return Metrics(
|
|
106
|
+
idle_temp_c=idle_temp,
|
|
107
|
+
time_to_throttle_s=time_to_throttle,
|
|
108
|
+
steady_temp_c=steady_temp,
|
|
109
|
+
sustained_tflops=sustained,
|
|
110
|
+
tflops_ratio_vs_spec=ratio,
|
|
111
|
+
sm_clock_sigma=sm_sigma,
|
|
112
|
+
power_sigma_w=power_sigma,
|
|
113
|
+
ecc_delta=ecc_delta,
|
|
114
|
+
throttle_reasons_seen=reasons_seen,
|
|
115
|
+
telemetry_gaps=gaps,
|
|
116
|
+
)
|
gpucheck/preflight.py
ADDED
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
"""Preflight policy: decide whether a card may be tested, and how.
|
|
2
|
+
|
|
3
|
+
Precedence when several rules apply: refuse beats measurements_only beats full.
|
|
4
|
+
`reasons` collects every rule that fired, in evaluation order.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from dataclasses import dataclass, field, fields
|
|
10
|
+
from typing import Literal
|
|
11
|
+
|
|
12
|
+
from .adapter import TelemetryAdapter, TelemetrySample
|
|
13
|
+
from .specs import lookup
|
|
14
|
+
|
|
15
|
+
Mode = Literal["full", "measurements_only", "refuse"]
|
|
16
|
+
|
|
17
|
+
# TelemetrySample fields that are None by design at preflight, not missing hardware.
|
|
18
|
+
_EXPECTED_NONE = frozenset({"achieved_tflops"})
|
|
19
|
+
|
|
20
|
+
# "Active at idle" heuristic: a process list hidden by a container can make busy()
|
|
21
|
+
# report False/None while something else is running. Flag (never refuse) when the
|
|
22
|
+
# idle sample draws over half the power limit with SM clocks near boost.
|
|
23
|
+
ACTIVE_POWER_FRACTION = 0.5
|
|
24
|
+
ACTIVE_CLOCK_FRACTION = 0.9
|
|
25
|
+
ACTIVE_REASON = "GPU appears active at idle"
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
@dataclass(frozen=True)
|
|
29
|
+
class Preflight:
|
|
30
|
+
mode: Mode
|
|
31
|
+
reasons: list[str] = field(default_factory=list)
|
|
32
|
+
missing_telemetry: list[str] = field(default_factory=list)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def run_preflight(adapter: TelemetryAdapter) -> Preflight:
|
|
36
|
+
inv = adapter.inventory()
|
|
37
|
+
busy = adapter.busy()
|
|
38
|
+
shared = inv.mig_enabled or inv.fractional
|
|
39
|
+
|
|
40
|
+
reasons: list[str] = []
|
|
41
|
+
refuse = False
|
|
42
|
+
measurements_only = False
|
|
43
|
+
|
|
44
|
+
# Rule 1: a compute process on a full (non-shared) card refuses the run.
|
|
45
|
+
if busy is True:
|
|
46
|
+
if shared:
|
|
47
|
+
reasons.append("busy neighbor on shared GPU (expected)")
|
|
48
|
+
else:
|
|
49
|
+
refuse = True
|
|
50
|
+
reasons.append("GPU busy: compute process running on a full card")
|
|
51
|
+
|
|
52
|
+
# Rule 2: shared configurations get measurements only.
|
|
53
|
+
if inv.mig_enabled:
|
|
54
|
+
measurements_only = True
|
|
55
|
+
reasons.append("MIG enabled: measurements only")
|
|
56
|
+
elif inv.fractional:
|
|
57
|
+
measurements_only = True
|
|
58
|
+
reasons.append("fractional sharing enabled: measurements only")
|
|
59
|
+
|
|
60
|
+
# Rule 3: busy state cannot be read (process list hidden); proceed in full mode.
|
|
61
|
+
if busy is None:
|
|
62
|
+
reasons.append("busy-state unknown")
|
|
63
|
+
|
|
64
|
+
try:
|
|
65
|
+
sample: TelemetrySample | None = adapter.sample()
|
|
66
|
+
except Exception: # an unreadable sample is missing telemetry, not a crash
|
|
67
|
+
sample = None
|
|
68
|
+
missing = [
|
|
69
|
+
f.name
|
|
70
|
+
for f in fields(TelemetrySample)
|
|
71
|
+
if f.name not in _EXPECTED_NONE and (sample is None or getattr(sample, f.name) is None)
|
|
72
|
+
]
|
|
73
|
+
|
|
74
|
+
# Rule 4: best-effort "something is already running" check (adds a reason only).
|
|
75
|
+
if busy is not True and sample is not None and _looks_active(sample, inv):
|
|
76
|
+
reasons.append(ACTIVE_REASON)
|
|
77
|
+
|
|
78
|
+
if refuse:
|
|
79
|
+
mode: Mode = "refuse"
|
|
80
|
+
elif measurements_only:
|
|
81
|
+
mode = "measurements_only"
|
|
82
|
+
else:
|
|
83
|
+
mode = "full"
|
|
84
|
+
return Preflight(mode=mode, reasons=reasons, missing_telemetry=missing)
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def _looks_active(sample: TelemetrySample, inv) -> bool:
|
|
88
|
+
if sample.power_w is None or not inv.power_limit_w:
|
|
89
|
+
return False
|
|
90
|
+
if sample.power_w <= ACTIVE_POWER_FRACTION * inv.power_limit_w:
|
|
91
|
+
return False
|
|
92
|
+
spec = lookup(inv.model_name)
|
|
93
|
+
if spec is None or sample.sm_clock_mhz is None:
|
|
94
|
+
return spec is None # no clock reference: high power alone is the signal
|
|
95
|
+
return sample.sm_clock_mhz >= ACTIVE_CLOCK_FRACTION * spec.boost_clock_mhz
|
gpucheck/report.py
ADDED
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
"""Plain-text terminal report rendered from a result dict (stdlib only)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
_MEASUREMENTS: list[tuple[str, str, str]] = [
|
|
8
|
+
("idle_temp_c", "Idle temperature", "C"),
|
|
9
|
+
("steady_temp_c", "Steady-state temperature", "C"),
|
|
10
|
+
("time_to_throttle_s", "Time to thermal throttle", "s"),
|
|
11
|
+
("sustained_tflops", "Sustained throughput", "TFLOPS"),
|
|
12
|
+
("tflops_ratio_vs_spec", "Throughput vs. datasheet", "x"),
|
|
13
|
+
("sm_clock_sigma", "SM clock variation (sigma)", "MHz"),
|
|
14
|
+
("power_sigma_w", "Power variation (sigma)", "W"),
|
|
15
|
+
("ecc_delta", "ECC errors during run", ""),
|
|
16
|
+
]
|
|
17
|
+
|
|
18
|
+
_ABORT_TEXT = {
|
|
19
|
+
"thermal": "GPU temperature exceeded the safety limit",
|
|
20
|
+
"ecc": "ECC errors jumped during the run",
|
|
21
|
+
"telemetry": "telemetry became unreadable",
|
|
22
|
+
"preflight_refused": "preflight refused the run",
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _fmt(value: Any, unit: str) -> str:
|
|
27
|
+
if value is None:
|
|
28
|
+
return "n/a"
|
|
29
|
+
if isinstance(value, float):
|
|
30
|
+
text = f"{value:.2f}".rstrip("0").rstrip(".")
|
|
31
|
+
else:
|
|
32
|
+
text = str(value)
|
|
33
|
+
return f"{text} {unit}".rstrip()
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def render_report(result: dict) -> str:
|
|
37
|
+
inv = result.get("inventory") or {}
|
|
38
|
+
pre = result.get("preflight") or {}
|
|
39
|
+
met = result.get("metrics") or {}
|
|
40
|
+
out: list[str] = ["gpucheck report", "=" * 15, ""]
|
|
41
|
+
|
|
42
|
+
if result.get("aborted") and result["aborted"] != "preflight_refused":
|
|
43
|
+
why = _ABORT_TEXT.get(result["aborted"], result["aborted"])
|
|
44
|
+
out += [f"!! Run aborted early ({why}); showing what was collected.", ""]
|
|
45
|
+
if pre.get("mode") == "measurements_only":
|
|
46
|
+
out += [
|
|
47
|
+
"NOTE: MIG/fractional GPU configuration - measurements only, no grade is given.",
|
|
48
|
+
"",
|
|
49
|
+
]
|
|
50
|
+
|
|
51
|
+
out.append("Inventory")
|
|
52
|
+
out.append("---------")
|
|
53
|
+
rows = [
|
|
54
|
+
("Model", inv.get("model_name")),
|
|
55
|
+
("Driver", inv.get("driver")),
|
|
56
|
+
("CUDA", inv.get("cuda")),
|
|
57
|
+
("ECC enabled", inv.get("ecc_enabled")),
|
|
58
|
+
("Power limit", _fmt(inv.get("power_limit_w"), "W")),
|
|
59
|
+
("Default power limit", _fmt(inv.get("default_power_limit_w"), "W")),
|
|
60
|
+
("MIG enabled", inv.get("mig_enabled")),
|
|
61
|
+
("Fractional sharing", inv.get("fractional")),
|
|
62
|
+
("Retired pages", inv.get("retired_pages")),
|
|
63
|
+
("Remapped rows", inv.get("remapped_rows")),
|
|
64
|
+
]
|
|
65
|
+
out += [f" {k + ':':<22}{'n/a' if v is None else v}" for k, v in rows]
|
|
66
|
+
for reason in pre.get("reasons") or []:
|
|
67
|
+
out.append(f" preflight: {reason}")
|
|
68
|
+
out.append("")
|
|
69
|
+
|
|
70
|
+
out.append("Measurements")
|
|
71
|
+
out.append("------------")
|
|
72
|
+
out += [f" {label + ':':<30}{_fmt(met.get(key), unit)}" for key, label, unit in _MEASUREMENTS]
|
|
73
|
+
seen = met.get("throttle_reasons_seen") or []
|
|
74
|
+
if seen:
|
|
75
|
+
seen_text = ", ".join(seen)
|
|
76
|
+
elif "throttle_reasons" in (met.get("telemetry_gaps") or []):
|
|
77
|
+
seen_text = "unknown"
|
|
78
|
+
else:
|
|
79
|
+
seen_text = "none"
|
|
80
|
+
out.append(f" {'Throttle reasons seen:':<30}{seen_text}")
|
|
81
|
+
out.append("")
|
|
82
|
+
|
|
83
|
+
out.append("Findings")
|
|
84
|
+
out.append("--------")
|
|
85
|
+
findings = result.get("findings") or []
|
|
86
|
+
if findings:
|
|
87
|
+
for f in findings:
|
|
88
|
+
out.append(f" [{f['severity'].upper()}] {f['code']} (confidence: {f['confidence']})")
|
|
89
|
+
out.append(f" {f['message']}")
|
|
90
|
+
else:
|
|
91
|
+
out.append(" none")
|
|
92
|
+
out.append("")
|
|
93
|
+
|
|
94
|
+
out.append("Telemetry gaps")
|
|
95
|
+
out.append("--------------")
|
|
96
|
+
gaps = list(met.get("telemetry_gaps") or [])
|
|
97
|
+
for m in pre.get("missing_telemetry") or []:
|
|
98
|
+
if m not in gaps:
|
|
99
|
+
gaps.append(m)
|
|
100
|
+
out.append(" " + (", ".join(gaps) if gaps else "none"))
|
|
101
|
+
return "\n".join(out) + "\n"
|
gpucheck/resultjson.py
ADDED
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
"""Canonical result.json: build the result dict and serialize it reproducibly.
|
|
2
|
+
|
|
3
|
+
canonical_dumps is deterministic and idempotent: keys sorted, compact
|
|
4
|
+
separators, every float rounded to 4 decimal places, sets/frozensets emitted
|
|
5
|
+
as sorted lists, NaN/Infinity rejected (ValueError). Dataclass instances are
|
|
6
|
+
also accepted defensively and converted via dataclasses.asdict.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import dataclasses
|
|
12
|
+
import hashlib
|
|
13
|
+
import json
|
|
14
|
+
from typing import Any
|
|
15
|
+
|
|
16
|
+
from .diagnose import Finding
|
|
17
|
+
from .metrics import Metrics
|
|
18
|
+
from .run import RawRun
|
|
19
|
+
|
|
20
|
+
SCHEMA_VERSION = "1"
|
|
21
|
+
FLOAT_DP = 4
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _normalize(obj: Any) -> Any:
|
|
25
|
+
if dataclasses.is_dataclass(obj) and not isinstance(obj, type):
|
|
26
|
+
obj = dataclasses.asdict(obj)
|
|
27
|
+
if isinstance(obj, dict):
|
|
28
|
+
return {k: _normalize(v) for k, v in obj.items()}
|
|
29
|
+
if isinstance(obj, (set, frozenset)):
|
|
30
|
+
return sorted(_normalize(v) for v in obj)
|
|
31
|
+
if isinstance(obj, (list, tuple)):
|
|
32
|
+
return [_normalize(v) for v in obj]
|
|
33
|
+
if isinstance(obj, float):
|
|
34
|
+
# round(nan/inf) returns them unchanged; json.dumps(allow_nan=False) rejects.
|
|
35
|
+
return round(obj, FLOAT_DP) + 0.0 # + 0.0 turns -0.0 into 0.0
|
|
36
|
+
return obj
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def build_result(
|
|
40
|
+
run: RawRun,
|
|
41
|
+
metrics: Metrics,
|
|
42
|
+
findings: list[Finding],
|
|
43
|
+
challenge: dict | None = None,
|
|
44
|
+
challenge_workload: dict | None = None,
|
|
45
|
+
) -> dict:
|
|
46
|
+
result = _normalize(
|
|
47
|
+
{
|
|
48
|
+
"schema_version": SCHEMA_VERSION,
|
|
49
|
+
"inventory": run.inventory,
|
|
50
|
+
"preflight": run.preflight,
|
|
51
|
+
"config": run.config,
|
|
52
|
+
"metrics": metrics,
|
|
53
|
+
"findings": list(findings),
|
|
54
|
+
"samples": {"baseline": list(run.baseline), "load": list(run.load)},
|
|
55
|
+
"aborted": run.aborted,
|
|
56
|
+
}
|
|
57
|
+
)
|
|
58
|
+
# Both omitted -> output is byte-identical to v1 (run_ids must not shift).
|
|
59
|
+
if challenge is not None:
|
|
60
|
+
result["challenge"] = _normalize(
|
|
61
|
+
{"challenge_id": challenge["challenge_id"], "nonce": challenge["nonce"]}
|
|
62
|
+
)
|
|
63
|
+
if challenge_workload is not None:
|
|
64
|
+
result["config"]["challenge_workload"] = _normalize(challenge_workload)
|
|
65
|
+
return result
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def canonical_dumps(d: dict) -> str:
|
|
69
|
+
return json.dumps(
|
|
70
|
+
_normalize(d), sort_keys=True, separators=(",", ":"), allow_nan=False
|
|
71
|
+
)
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def run_id(d: dict) -> str:
|
|
75
|
+
return hashlib.sha256(canonical_dumps(d).encode()).hexdigest()[:16]
|