| """ |
| Speed estimation: how fast will it actually feel? |
| |
| Two-tier design, with provenance the UI always shows: |
| |
| 1. TRAINED MODEL (when present): an XGBoost regressor trained on real |
| community measurements (LocalScore; 6,633 training rows derived from |
| those sources), following the |
| methodology of LLM-Pilot (IBM, SC'24, arXiv:2410.02425 — gradient |
| boosting over hardware+model features, grouped k-fold by accelerator |
| label; NOT a strict leave-one-hardware-out split, ~48% alias overlap). |
| Loaded from model/speed_model.skops if scripts/train_speed_model.py |
| has been run. method = "measured-model". |
| 2. ROOFLINE BASELINE (always available, fully offline): decode is memory- |
| bandwidth-bound — tok/s ~ bandwidth / bytes-read-per-token (weights + |
| KV), times an empirical efficiency factor. See kipply's "Transformer |
| Inference Arithmetic" and the JAX scaling book inference chapter. |
| method = "roofline". |
| |
| The anti-gimmick rule lives in the training script: the trained model ships |
| only if it beats this baseline on held-out hardware; otherwise the baseline |
| IS the product and the UI says so. |
| |
| Scope note (honest): this predicts LLM/VLM decode speed. Vision (YOLO) and |
| diffusion models are COMPUTE-bound, not bandwidth-bound — FPS scales with |
| TFLOPS / model GFLOPs, a different axis with different data (Ultralytics |
| publishes per-size GFLOPs and official T4 latencies; dbgpu has per-GPU |
| TFLOPS). That path is designed in SPEED-BRICK-RESEARCH.md §8 but not built; |
| non-LLM families keep their provenance-labelled memory verdicts only, rather |
| than getting fake speed numbers. |
| """ |
|
|
| import json |
| import re |
| from functools import lru_cache |
| from pathlib import Path |
|
|
| _ROOT = Path(__file__).resolve().parent.parent |
| _SPECS_PATH = _ROOT / "data" / "gpu_specs.json" |
| _MODEL_PATH = _ROOT / "model" / "speed_model.skops" |
|
|
| |
| |
| |
| _EFF_MID, _EFF_LO, _EFF_HI = 0.60, 0.42, 0.78 |
| |
| |
| |
| |
| _TOKEN_OVERHEAD_S = 0.0032 |
| |
| _RAM_BW_GBS = 48.0 |
| |
| _READING_TPS = 6.0 |
|
|
|
|
| @lru_cache(maxsize=1) |
| def _specs() -> dict: |
| try: |
| return json.loads(_SPECS_PATH.read_text(encoding="utf-8")) |
| except OSError: |
| return {"gpus": {}, "apple": {}, "sbc": {}} |
|
|
|
|
| def _norm(s: str) -> str: |
| return re.sub(r"\s+", " ", re.sub(r"[^a-z0-9 ]", " ", (s or "").lower())).strip() |
|
|
|
|
| def _key_matches(key: str, text: str) -> bool: |
| """Token-boundary containment: the key's whole tokens must appear as a |
| contiguous run in text. Plain substring matching (the old `key in text`) |
| let a short key match inside a longer token — e.g. Apple "m2" matched |
| "Tesla V100-SXM2" -> "tesla v100 sxm2" (the 'sxm2' token), assigning the |
| V100 Apple's bandwidth. Token matching kills that without losing real hits |
| like "4080 super" inside "nvidia geforce rtx 4080 super".""" |
| kt = key.split() |
| tt = text.split() |
| n = len(kt) |
| if not n: |
| return False |
| return any(tt[i:i + n] == kt for i in range(len(tt) - n + 1)) |
|
|
|
|
| @lru_cache(maxsize=1) |
| def _bw_index() -> tuple: |
| idx = [] |
| for name, d in _specs()["gpus"].items(): |
| idx.append((_norm(name), float(d["bw"]), float(d.get("vram", 0)))) |
| idx.sort(key=lambda t: -len(t[0])) |
| return tuple(idx) |
|
|
|
|
| |
| |
| _APPLE_TIER_BW = None |
|
|
|
|
| def _apple_bw(tier_hint: str) -> float: |
| global _APPLE_TIER_BW |
| if _APPLE_TIER_BW is None: |
| a = {k: v["bw"] for k, v in _specs()["apple"].items()} |
| _APPLE_TIER_BW = { |
| "ultra": a.get("m2 ultra") or a.get("m1 ultra") or 800.0, |
| "max": a.get("m2 max") or 400.0, |
| "pro": a.get("m2 pro") or 200.0, |
| "base": a.get("m2") or 100.0, |
| } |
| t = (tier_hint or "").lower() |
| for key in ("ultra", "max", "pro"): |
| if key in t: |
| return _APPLE_TIER_BW[key] |
| return _APPLE_TIER_BW["base"] |
|
|
|
|
| def bandwidth_for_spec(spec, gpu_label: str = "") -> tuple[float | None, str]: |
| """(memory bandwidth GB/s on the fast path, source-note) for a machine.""" |
| if spec.is_apple_silicon: |
| return _apple_bw(gpu_label or spec.gpu_label), "Apple unified memory (conservative M2-gen figure)" |
| if spec.gpu_vendor in ("nvidia", "amd", "intel") and spec.vram_gb > 0: |
| n = _norm(gpu_label or spec.gpu_label) |
| |
| |
| for check_vram in (True, False): |
| for key, bw, vram in _bw_index(): |
| if key and _key_matches(key, n): |
| if check_vram and vram and spec.vram_gb and abs(vram - spec.vram_gb) > 4: |
| continue |
| return bw, "vendor spec sheet" |
| return None, "" |
| return None, "" |
|
|
|
|
| |
| |
| |
|
|
| _MODEL_JSON_PATH = _ROOT / "model" / "speed_model.json" |
|
|
|
|
| @lru_cache(maxsize=1) |
| def _trained_model(): |
| |
| |
| |
| if _MODEL_JSON_PATH.exists(): |
| try: |
| from xgboost import XGBRegressor |
| model = XGBRegressor() |
| model.load_model(_MODEL_JSON_PATH) |
| print(f"[FitCheck] speed predictor loaded from {_MODEL_JSON_PATH.name}", flush=True) |
| return model |
| except Exception as e: |
| import sys |
| print(f"[FitCheck] WARNING: {_MODEL_JSON_PATH.name} exists but failed " |
| f"to load ({e!r}) — trying the skops artifact", |
| file=sys.stderr, flush=True) |
| if not _MODEL_PATH.exists(): |
| return None |
| try: |
| from skops.io import load as skops_load |
| |
| |
| model = skops_load(_MODEL_PATH, trusted=[ |
| "xgboost.core.Booster", "xgboost.sklearn.XGBRegressor", |
| ]) |
| print(f"[FitCheck] speed predictor loaded from {_MODEL_PATH.name}", flush=True) |
| return model |
| except Exception as e: |
| |
| |
| import sys |
| print(f"[FitCheck] WARNING: {_MODEL_PATH.name} exists but failed to " |
| f"load ({e!r}) — falling back to the labelled roofline estimate", |
| file=sys.stderr, flush=True) |
| return None |
|
|
|
|
| _METRICS_PATH = _ROOT / "model" / "metrics.json" |
|
|
|
|
| @lru_cache(maxsize=1) |
| def _envelope() -> dict: |
| """The region of feature space the training data actually covered. |
| |
| Decision trees cannot extrapolate: outside what they saw, they clamp to |
| the nearest seen value and quietly give wrong answers (e.g. a 32B model |
| gets a 14B's speed). The roofline DOES extrapolate — it's physics. So the |
| trained model only answers inside its measured envelope; outside it, the |
| labelled analytical estimate takes over. Bounds come from metrics.json |
| when the training script recorded them, else conservative defaults |
| matching the LocalScore grid (<=14B Q4 models, consumer hardware). |
| """ |
| env = {"bytes_gb": (0.8, 10.0), "eff_bw": (30.0, 1900.0)} |
| try: |
| rec = json.loads(_METRICS_PATH.read_text(encoding="utf-8")).get("envelope") |
| if rec: |
| env.update({k: tuple(v) for k, v in rec.items()}) |
| except OSError: |
| pass |
| return env |
|
|
|
|
| def _in_envelope(eff_bw: float, bytes_gb: float) -> bool: |
| env = _envelope() |
| return (env["bytes_gb"][0] <= bytes_gb <= env["bytes_gb"][1] |
| and env["eff_bw"][0] <= eff_bw <= env["eff_bw"][1]) |
|
|
|
|
| |
| |
| |
|
|
| def predict_decode_tps( |
| *, |
| bandwidth_gbs: float, |
| weights_gb: float, |
| kv_gb: float = 0.0, |
| active_fraction: float = 1.0, |
| offload_fraction: float = 0.0, |
| ) -> dict: |
| """Predict decode tokens/sec. |
| |
| active_fraction: MoE models only read their active experts per token. |
| offload_fraction: share of the model living in system RAM (0 = all on GPU). |
| """ |
| |
| bytes_gb = max(weights_gb * active_fraction + kv_gb, 0.05) |
| if active_fraction < 0.9: |
| |
| |
| |
| |
| bytes_gb *= 1.5 |
|
|
| eff_bw = bandwidth_gbs |
| if offload_fraction > 0: |
| f = min(max(offload_fraction, 0.0), 1.0) |
| eff_bw = 1.0 / ((1.0 - f) / bandwidth_gbs + f / _RAM_BW_GBS) |
|
|
| |
| |
| |
| |
| |
| learned_ok = active_fraction >= 0.999 and offload_fraction <= 0.0 |
|
|
| model = _trained_model() |
| if model is not None and learned_ok and _in_envelope(eff_bw, bytes_gb): |
| try: |
| import numpy as np |
| x = np.array([[eff_bw, bytes_gb, weights_gb, kv_gb, |
| active_fraction, offload_fraction, |
| eff_bw / bytes_gb]]) |
| tps = float(model.predict(x)[0]) |
| return {"tps": round(tps, 1), |
| "lo": round(tps * 0.8, 1), "hi": round(tps * 1.2, 1), |
| "bytes_gb": round(bytes_gb, 2), "eff_bw": round(eff_bw, 1), |
| "method": "measured-model", |
| "note": ("predicted by a model trained on real community " |
| "measurements (LocalScore), LLM-Pilot methodology")} |
| except Exception: |
| pass |
|
|
| |
| |
| def _tps(eff): |
| return 1.0 / (bytes_gb / (eff_bw * eff) + _TOKEN_OVERHEAD_S) |
| note = ("analytical estimate: decode speed is memory-bandwidth-bound " |
| "(bandwidth divided by bytes read per token), with a fixed per-token " |
| "overhead so small models plateau rather than scaling without limit") |
| if model is not None: |
| note += (" — this configuration is outside the measured data's range, " |
| "so the physics formula answers instead of the trained model") |
| return {"tps": round(_tps(_EFF_MID), 1), |
| "lo": round(_tps(_EFF_LO), 1), "hi": round(_tps(_EFF_HI), 1), |
| "bytes_gb": round(bytes_gb, 2), "eff_bw": round(eff_bw, 1), |
| "method": "roofline", "note": note} |
|
|
|
|
| def feel_text(pred: dict) -> str: |
| """One honest, plain-English line from a prediction.""" |
| tps = pred["tps"] |
| lo, hi = pred["lo"], pred["hi"] |
| if tps >= _READING_TPS * 4: |
| speed_word = "much faster than you read" |
| elif tps >= _READING_TPS * 1.5: |
| speed_word = "faster than you read" |
| elif tps >= _READING_TPS * 0.7: |
| speed_word = "about reading speed" |
| else: |
| speed_word = "slower than reading — fine for short tasks" |
| return f"~{tps:g} tok/s (likely {lo:g}-{hi:g}) — {speed_word}" |
|
|
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
|
|
| |
| _UTIL_DIFF_LO, _UTIL_DIFF_MID, _UTIL_DIFF_HI = 0.11, 0.14, 0.17 |
| |
| |
| _FPS_DISPLAY_CAP = 300.0 |
|
|
|
|
| @lru_cache(maxsize=1) |
| def _tflops_index() -> tuple: |
| idx = [] |
| for name, d in _specs()["gpus"].items(): |
| if d.get("tflops"): |
| idx.append((_norm(name), float(d["tflops"]))) |
| idx.sort(key=lambda t: -len(t[0])) |
| return tuple(idx) |
|
|
|
|
| def tflops_for_spec(spec, gpu_label: str = "") -> tuple[float | None, str]: |
| """(peak FP16 tensor TFLOPS, source-note) for a machine, or (None, ''). |
| |
| Only NVIDIA cards carry a calibrated tflops figure; Apple/AMD/Intel return |
| None on purpose (no calibrated vision utilisation band exists for them yet, |
| so the engine says 'out of scope' rather than guessing).""" |
| if getattr(spec, "is_apple_silicon", False): |
| return None, "" |
| if spec.gpu_vendor == "nvidia" and spec.vram_gb > 0: |
| n = _norm(gpu_label or spec.gpu_label) |
| for key, tf in _tflops_index(): |
| if key and _key_matches(key, n): |
| return tf, "vendor tensor-core spec" |
| return None, "" |
|
|
|
|
| def _util_det(gflops: float) -> float: |
| """Detection MFU vs model size. Log-linear fit to the YOLO11/26 T4 anchors |
| (GFLOPs -> measured util): 5.4->0.05, 20.7->0.13, 68->0.22, 87->0.22, |
| 194->0.27. Bigger models keep the tensor cores busier per launch.""" |
| import math |
| return min(0.30, max(0.05, 0.139 * math.log10(max(gflops, 1.0)) - 0.053)) |
|
|
|
|
| def predict_detection_fps(gflops: float, tflops: float) -> dict: |
| """Compute-roofline frames/sec for a single-shot detector at batch 1.""" |
| util = _util_det(gflops) |
|
|
| def _fps(u): |
| return tflops * 1000.0 * u / gflops |
|
|
| mid = _fps(util) |
| lo = _fps(util * 0.75) |
| hi = _fps(util * 1.25) |
| return { |
| "kind": "detection", |
| "fps": round(mid, 1), "lo": round(lo, 1), "hi": round(hi, 1), |
| "tflops": tflops, "util": round(util, 3), |
| "realtime": lo >= 30.0, |
| "method": "compute-roofline", |
| "note": ("compute-roofline estimate (tensor FLOPs / model FLOPs, " |
| "batch 1, FP16) calibrated on Ultralytics' T4 latencies. This " |
| "is a ceiling: small models on a fast GPU are often limited by " |
| "per-frame launch overhead below it."), |
| } |
|
|
|
|
| def predict_diffusion_time(gflops_step: float, steps: int, overhead_gflops: float, |
| tflops: float) -> dict: |
| """Compute-roofline seconds-per-image for a diffusion model at batch 1.""" |
| total_gflops = steps * gflops_step + overhead_gflops |
|
|
| def _sec(u): |
| return total_gflops / (tflops * 1000.0 * u) |
|
|
| mid = _sec(_UTIL_DIFF_MID) |
| fast = _sec(_UTIL_DIFF_HI) |
| slow = _sec(_UTIL_DIFF_LO) |
| return { |
| "kind": "diffusion", |
| "seconds": round(mid, 1), "sec_lo": round(fast, 1), "sec_hi": round(slow, 1), |
| "it_s": round(steps / mid, 1), "steps": steps, |
| "tflops": tflops, "util": _UTIL_DIFF_MID, |
| "method": "compute-roofline", |
| "note": ("compute-roofline estimate (tensor FLOPs / model FLOPs, " |
| "batch 1, FP16) calibrated on measured SDXL throughput. Assumes " |
| "an unoptimised baseline; step-skipping or caching is faster."), |
| } |
|
|
|
|
| def predict_compute_speed(entry: dict, spec, gpu_label: str = "") -> dict | None: |
| """Dispatch a catalogue entry to the right compute-roofline estimate, or |
| None when it's out of scope (non-NVIDIA GPU, or a family with no calibrated |
| utilisation band). The `compute_kind` tag is set in the catalogue by |
| scripts/add_model_gflops.py for the calibrated entries only.""" |
| kind = entry.get("compute_kind") |
| if kind not in ("detection", "diffusion"): |
| return None |
| tflops, src = tflops_for_spec(spec, gpu_label) |
| if not tflops: |
| return None |
| if kind == "detection" and entry.get("gflops"): |
| pred = predict_detection_fps(float(entry["gflops"]), tflops) |
| elif kind == "diffusion" and entry.get("gflops_step"): |
| pred = predict_diffusion_time( |
| float(entry["gflops_step"]), int(entry.get("steps_default", 30)), |
| float(entry.get("gflops_overhead", 0.0)), tflops) |
| else: |
| return None |
| pred["tflops_source"] = src |
| pred["proxy"] = bool(entry.get("gflops_proxy")) |
| return pred |
|
|
|
|
| def compute_feel_text(pred: dict) -> str: |
| """One honest, plain-English line from a compute-roofline prediction.""" |
| if pred["kind"] == "detection": |
| fps, lo, hi = pred["fps"], pred["lo"], pred["hi"] |
| if lo >= _FPS_DISPLAY_CAP: |
| return f"{int(_FPS_DISPLAY_CAP)}+ FPS — far beyond real-time (compute ceiling)" |
| rt = "real-time" if pred["realtime"] else "near real-time" if hi >= 30 else "below real-time" |
| return f"~{fps:g} FPS (likely {lo:g}-{hi:g}) — {rt} (compute ceiling, batch 1)" |
| |
| s, lo, hi = pred["seconds"], pred["sec_lo"], pred["sec_hi"] |
| return f"~{s:g}s per image (likely {lo:g}-{hi:g}s) at {pred['steps']} steps" |
|
|