modelbeast/scripts/perfcheck/bench.py
m3ultra 27564e80eb perfcheck: nightly fleet GPU canary + the contention lessons that shaped it
BENCHMARKS.md says what a model cost the day it was measured; nothing noticed if a
macOS/torch update or a thermal fault halved a node. perfcheck runs the whole fleet
in ~35s off godcheck's 03:30 cron on the m4mini and reports drift into
GODCHECK_LATEST.md.

Probes matmul (fp16/fp32), memory bandwidth, and SDPA at head_dim 64 vs 56 — the
latter turning CorridorKey's fast-path cliff into a permanent canary: it confirms
the padding win fleet-wide (1.96x-5.26x) and tells us if a future torch closes it.
Runs on venvs/rmbg/bin/python, already identical fleet-wide, so nothing new is
installed (nothing lands on the disk-tight m1max).

First cross-machine capability table for all 6 nodes. The M3 Ultra is ~2.2x the M1
Ultra on fp16 matmul, but they share ~625 GB/s — so bandwidth-bound stages run alike
while compute-bound ones scale. Both Ultras reach only ~78% of spec bandwidth on a
single kernel; the smaller Macs hit ~88%.

Measuring a fleet that is doing real work is the whole problem, and naive
benchmarking here is off by 11x:

- min, not median: a concurrent trellis_mac job dragged a median-of-5 matmul from
  ~24500 to ~2150 GFLOP/s, which reads exactly like a catastrophic regression.
- n=4096 not 2048: 2048 is dispatch-bound and swung 48% run-to-run; 4096 reproduces
  to 0.1% even while contended.
- sdpa 16x2048 not 8x1024: sub-ms probes are dispatch noise — 8x1024 gave ratios of
  0.79/3.95/2.35 on three runs of one machine, the first "proving" 56 is faster.
- busy nodes are excluded, not blamed: GPU contention is invisible to load average
  (M3 Ultra read load 2.45 with its GPU pinned), so bench.py samples ioreg GPU% before
  it touches the GPU — our own matmul pins the device, so ordering is the trick.
- baselines are the median of recent history, not a saved best: the M4 Pro also serves
  Ollama and is bimodal (~3200 vs ~5500 fp32), so a best-observed baseline pins to a
  lucky outlier and alerts forever.
- a regression must repeat before it is believed ([~] watching -> [!] CONFIRMED).

Validated by re-running the fleet against its own baselines: zero false alarms,
including a sweep where the M3 Ultra read 43 GB/s under load and was correctly
marked BUSY rather than reported as a 93% regression.

Also found: the m4mini is the only node with Tailscale SSH (RunSSH: true) and it does
NOT propagate remote exit codes — `ssh m4mini "exit 7"` returns 0, so any
`if ssh m4mini ...` test silently always passes. Test on output instead, which is
what godcheck already does (and why it is unaffected). It also cannot ssh to itself,
so run_fleet detects its own tailnet IP and benches the local node via the shell.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-17 18:03:03 +10:00

195 lines
7.8 KiB
Python

"""PERFCHECK — per-node GPU performance canary. Prints one JSON object to stdout.
Runs on ``MODELBEAST/venvs/rmbg/bin/python``, which carries an identical torch on every node
in the fleet — so numbers are comparable machine-to-machine without installing anything new
(nothing lands on the disk-critical m1max).
Probes (~20s, sized to survive the 8GB M1 mini):
matmul_fp16 / matmul_fp32 GFLOP/s — raw compute
bandwidth GB/s — unified-memory read/write
sdpa_64 / sdpa_56 ms — attention at a fast-path head_dim (64) vs the
awkward one CorridorKey's Hiera actually uses (56)
The sdpa pair is the point. CorridorKey is slow on Apple Silicon because head_dim=56 misses
the fused attention path; padding to 64 buys 1.3x-4.9x depending on the machine. ``sdpa_ratio``
(56/64) tracks that cliff per node, so if a future torch closes it we find out — instead of
carrying the padding workaround forever without noticing it became unnecessary.
MEASUREMENT NOTES — these are load-bearing, do not "simplify" them:
* **min, not mean/median.** These nodes serve jobs. A concurrent trellis_mac run on the M3 Ultra
dragged a median-of-5 matmul from ~24500 down to ~2150 GFLOP/s — a 5x error that looks
exactly like a catastrophic regression. The minimum time is the least-interfered sample and
the only estimate of the machine's actual capability that survives a noisy neighbour.
* **Sizes are chosen for stability, not for speed.** n=4096 reproduced to within 0.1% across
runs *while the GPU was contended*; n=2048 swung 48% (11700 vs 17361) because it is
dispatch-bound rather than compute-bound. Shrinking these to make the bench faster silently
turns it back into a random number generator.
* **Every probe syncs MPS before stopping the clock.** MPS dispatch is async; an unsynced timer
measures queue-submission, not work.
* ``load1`` is reported so a consumer can distrust a sample taken on a busy node. Under
contention the mins mostly hold up, but a hard-loaded node can still read low.
"""
from __future__ import annotations
import json
import os
import platform
import subprocess
import sys
import time
os.environ.setdefault("PYTORCH_ENABLE_MPS_FALLBACK", "1")
os.environ.setdefault("KMP_DUPLICATE_LIB_OK", "TRUE")
import torch # noqa: E402
# 2: matmul 2048->4096, sdpa 8x1024 -> 16x2048, median->min, added gpu_busy_pre.
SCHEMA = 2
def _sync() -> None:
torch.mps.synchronize()
def _best(fn, runs: int = 7, warmup: int = 3) -> float:
"""Fastest of ``runs`` seconds-per-call, MPS-synced. See MEASUREMENT NOTES on why min."""
for _ in range(warmup):
fn()
_sync()
best = float("inf")
for _ in range(runs):
t0 = time.perf_counter()
fn()
_sync()
best = min(best, time.perf_counter() - t0)
return best
def bench_matmul(dtype: torch.dtype, n: int = 4096) -> float:
"""GFLOP/s for an n^3 matmul. n=4096 is the smallest size that is compute- not
dispatch-bound here (~67MB/tensor fp32 — fine on the 8GB mini)."""
a = torch.randn(n, n, device="mps", dtype=dtype)
b = torch.randn(n, n, device="mps", dtype=dtype)
secs = _best(lambda: a @ b)
return (2.0 * n**3) / secs / 1e9
def bench_bandwidth(mb: int = 512) -> float:
"""GB/s. x.add_(1) reads and writes the whole buffer -> 2x bytes moved.
runs=25 rather than the default: each call is only ~1.6ms, so extra samples are nearly
free and they reject neighbour interference. At runs=9 this spread 16% on a contended
node (479 vs 570) — enough to trip a 20% drift alarm on its own. At runs=25: 0.3%.
"""
n = (mb * 1024 * 1024) // 4
x = torch.randn(n, device="mps", dtype=torch.float32)
secs = _best(lambda: x.add_(1.0), runs=25)
return (2.0 * n * 4) / secs / 1e9
def bench_sdpa(head_dim: int, heads: int = 16, seq: int = 2048) -> float:
"""Milliseconds for one fused-attention call at the given head_dim.
heads=16/seq=2048 is deliberate: it lands ~1-4ms, where the ratio reproduces to 0.6%
(4.28/4.30/4.33 across runs on a contended M3 Ultra). The obvious smaller 8x1024 is
sub-millisecond and pure dispatch noise — it produced ratios of 0.79, 3.95 and 2.35 on
three consecutive runs of the same machine, i.e. it would have "proven" head_dim=56 is
*faster*, contradicting the real 8-machine result.
"""
shape = (1, heads, seq, head_dim)
q = torch.randn(*shape, device="mps", dtype=torch.float16)
k = torch.randn(*shape, device="mps", dtype=torch.float16)
v = torch.randn(*shape, device="mps", dtype=torch.float16)
secs = _best(lambda: torch.nn.functional.scaled_dot_product_attention(q, k, v), runs=9)
return secs * 1000.0
def gpu_busy_pre() -> int:
"""Peak GPU utilization % observed over ~1.2s, sampled BEFORE any torch work starts.
Must run before we touch the GPU: our own matmul pins the device at ~100%, so this can
only tell us about a *neighbour* while we are still idle. That ordering is the whole trick
— sampling during the bench is worthless.
``load1`` cannot substitute: the M3 Ultra sat at load 2.45 with its GPU fully held by a
trellis_mac job. Conversely a GPU job alternates phases (100% CPU / 0% GPU one moment,
97% GPU the next), so a single reading misses it — hence max-of-several. The first ioreg
read is discarded because the counter is since-last-read and reports garbage when cold (an
idle m4mini reported 97% on its first sample, 0% thereafter).
"""
def sample() -> int:
try:
out = subprocess.run(
["ioreg", "-r", "-d", "1", "-w", "0", "-c", "IOAccelerator"],
capture_output=True, text=True, timeout=8,
).stdout
except Exception:
return 0
vals = [int(s.split("=")[1]) for s in out.split(",")
if s.strip().startswith('"Device Utilization %"')]
return max(vals) if vals else 0
sample() # discard the cold read
peak = 0
for _ in range(4):
time.sleep(0.3)
peak = max(peak, sample())
return peak
def _sysctl(key: str) -> str:
try:
return subprocess.run(
["sysctl", "-n", key], capture_output=True, text=True, timeout=5
).stdout.strip()
except Exception:
return ""
def host_facts() -> dict:
mem = _sysctl("hw.memsize")
return {
"host": platform.node().split(".")[0],
"chip": _sysctl("machdep.cpu.brand_string"),
"cores": _sysctl("hw.ncpu"),
"ram_gb": round(int(mem) / 1024**3) if mem.isdigit() else None,
"macos": platform.mac_ver()[0],
"torch": torch.__version__,
"load1": round(os.getloadavg()[0], 2),
}
def main() -> int:
if not torch.backends.mps.is_available():
print(json.dumps({"error": "MPS unavailable", **host_facts()}))
return 1
# Before any torch work — see gpu_busy_pre().
busy = gpu_busy_pre()
# BUMP `schema` whenever a probe's shape/size changes. Numbers are only comparable within
# a schema, and run_fleet drops older samples from the median rather than averaging a
# 2048-matmul against a 4096-matmul and calling the difference a regression.
out = {"schema": SCHEMA, **host_facts(), "gpu_busy_pre": busy, "metrics": {}}
m = out["metrics"]
m["matmul_fp16_gflops"] = round(bench_matmul(torch.float16), 1)
m["matmul_fp32_gflops"] = round(bench_matmul(torch.float32), 1)
m["bandwidth_gbs"] = round(bench_bandwidth(), 1)
sdpa64 = bench_sdpa(64)
sdpa56 = bench_sdpa(56)
m["sdpa_64_ms"] = round(sdpa64, 3)
m["sdpa_56_ms"] = round(sdpa56, 3)
# >1 means the awkward head_dim is slower, i.e. CorridorKey's padding win still stands.
m["sdpa_ratio"] = round(sdpa56 / sdpa64, 2) if sdpa64 else None
out["load1_end"] = round(os.getloadavg()[0], 2)
print(json.dumps(out))
return 0
if __name__ == "__main__":
sys.exit(main())