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