mjolnir-cli 0.2.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.
Files changed (80) hide show
  1. mjolnir_cli/__init__.py +8 -0
  2. mjolnir_cli/__main__.py +4 -0
  3. mjolnir_cli/accuracy.py +333 -0
  4. mjolnir_cli/autoresearch.py +539 -0
  5. mjolnir_cli/benchy.py +475 -0
  6. mjolnir_cli/bundled/configs/Qwen/Qwen3.8-27B/NVFP4/config.yaml +78 -0
  7. mjolnir_cli/bundled/configs/Qwen/Qwen3.8-27B/NVFP4_DFLASH2/config.yaml +103 -0
  8. mjolnir_cli/bundled/configs/Qwen/Qwen3.8-27B/NVFP4_DSPARK/config.yaml +102 -0
  9. mjolnir_cli/bundled/configs/Qwen/Qwen3.8-27B/NVFP4_FA2/config.yaml +84 -0
  10. mjolnir_cli/bundled/configs/Qwen/Qwen3.8-27B/NVFP4_FA4hd256/config.yaml +104 -0
  11. mjolnir_cli/bundled/configs/Qwen/Qwen3.8-27B/NVFP4_MJOLNIR/config.yaml +86 -0
  12. mjolnir_cli/bundled/configs/Qwen/Qwen3.8-27B/NVFP4_MJOLNIR_E2M3/config.yaml +93 -0
  13. mjolnir_cli/bundled/configs/Qwen/Qwen3.8-27B/NVFP4_MJOLNIR_E2M3_DFLASH2/config.yaml +123 -0
  14. mjolnir_cli/bundled/configs/Qwen/Qwen3.8-27B/NVFP4_MJOLNIR_E2M3_DSPARK/config.yaml +124 -0
  15. mjolnir_cli/bundled/configs/Qwen/Qwen3.8-27B/NVFP4_MJOLNIR_E2M3_LMH8/.env +2 -0
  16. mjolnir_cli/bundled/configs/Qwen/Qwen3.8-27B/NVFP4_MJOLNIR_E2M3_LMH8/config.yaml +98 -0
  17. mjolnir_cli/bundled/configs/Qwen/Qwen3.8-27B/NVFP4_MJOLNIR_E2M3_LMH8_VT32K/.env +4 -0
  18. mjolnir_cli/bundled/configs/Qwen/Qwen3.8-27B/NVFP4_MJOLNIR_E2M3_LMH8_VT32K/config.yaml +98 -0
  19. mjolnir_cli/bundled/configs/Qwen/Qwen3.8-27B/NVFP4_MJOLNIR_E2M3_LMH8_VT64K/.env +4 -0
  20. mjolnir_cli/bundled/configs/Qwen/Qwen3.8-27B/NVFP4_MJOLNIR_E2M3_LMH8_VT64K/config.yaml +102 -0
  21. mjolnir_cli/bundled/configs/Qwen/Qwen3.8-27B/NVFP4_MJOLNIR_E2M3_VT32K/.env +3 -0
  22. mjolnir_cli/bundled/configs/Qwen/Qwen3.8-27B/NVFP4_MJOLNIR_E2M3_VT32K/config.yaml +98 -0
  23. mjolnir_cli/bundled/configs/Qwen/Qwen3.8-27B/draft_vocab.json +1 -0
  24. mjolnir_cli/bundled/configs/Qwen/Qwen3.8-27B/kv_scales.json +35197 -0
  25. mjolnir_cli/bundled/configs/Qwen/Qwen3.8_chat_template.jinja +456 -0
  26. mjolnir_cli/bundled/docker/litellm/docker-compose.yaml +113 -0
  27. mjolnir_cli/bundled/docker/litellm/litellm.env.example +51 -0
  28. mjolnir_cli/bundled/docker/litellm/litellm_config.template.example.yaml +192 -0
  29. mjolnir_cli/bundled/docker/perses/README.md +89 -0
  30. mjolnir_cli/bundled/docker/perses/dashboards/global-datasource.yaml +17 -0
  31. mjolnir_cli/bundled/docker/perses/dashboards/performance_statistics.yaml +771 -0
  32. mjolnir_cli/bundled/docker/perses/dashboards/project.yaml +13 -0
  33. mjolnir_cli/bundled/docker/perses/dashboards/query_statistics.yaml +399 -0
  34. mjolnir_cli/bundled/docker/perses/docker-compose.yaml +51 -0
  35. mjolnir_cli/bundled/docker/perses/perses.yml +16 -0
  36. mjolnir_cli/bundled/docker/perses/prometheus.template.yaml +20 -0
  37. mjolnir_cli/bundled/scripts/hw/setup-thor.sh +1287 -0
  38. mjolnir_cli/cachectl.py +146 -0
  39. mjolnir_cli/charts.py +1687 -0
  40. mjolnir_cli/cli.py +1480 -0
  41. mjolnir_cli/clidoc.py +170 -0
  42. mjolnir_cli/config.py +346 -0
  43. mjolnir_cli/dockerctl.py +533 -0
  44. mjolnir_cli/drivers/fa2_driver.py +89 -0
  45. mjolnir_cli/drivers/fi_driver.py +134 -0
  46. mjolnir_cli/e2e.py +302 -0
  47. mjolnir_cli/fonts/InterDisplay-Bold.ttf +0 -0
  48. mjolnir_cli/fonts/InterDisplay-Medium.ttf +0 -0
  49. mjolnir_cli/fonts/InterDisplay-Regular.ttf +0 -0
  50. mjolnir_cli/fonts/InterDisplay-SemiBold.ttf +0 -0
  51. mjolnir_cli/fonts/OFL.txt +92 -0
  52. mjolnir_cli/gate.py +233 -0
  53. mjolnir_cli/history.py +159 -0
  54. mjolnir_cli/interactive.py +383 -0
  55. mjolnir_cli/legs.py +109 -0
  56. mjolnir_cli/litellm.py +254 -0
  57. mjolnir_cli/perses.py +214 -0
  58. mjolnir_cli/picker.py +70 -0
  59. mjolnir_cli/readme.py +1020 -0
  60. mjolnir_cli/stack.py +340 -0
  61. mjolnir_cli/tasks.py +122 -0
  62. mjolnir_cli/theme.py +281 -0
  63. mjolnir_cli/thor.py +108 -0
  64. mjolnir_cli/verify/__init__.py +2 -0
  65. mjolnir_cli/verify/cli.py +816 -0
  66. mjolnir_cli/verify/docker.py +334 -0
  67. mjolnir_cli/verify/kvcache.py +180 -0
  68. mjolnir_cli/verify/legs.py +134 -0
  69. mjolnir_cli/verify/ncu.py +141 -0
  70. mjolnir_cli/verify/profile.py +312 -0
  71. mjolnir_cli/verify/report.py +177 -0
  72. mjolnir_cli/verify/results.py +157 -0
  73. mjolnir_cli/verify/spec.py +279 -0
  74. mjolnir_cli/verify/stages.py +817 -0
  75. mjolnir_cli-0.2.0.dist-info/METADATA +87 -0
  76. mjolnir_cli-0.2.0.dist-info/RECORD +80 -0
  77. mjolnir_cli-0.2.0.dist-info/WHEEL +4 -0
  78. mjolnir_cli-0.2.0.dist-info/entry_points.txt +2 -0
  79. mjolnir_cli-0.2.0.dist-info/licenses/LICENSE +202 -0
  80. mjolnir_cli-0.2.0.dist-info/licenses/NOTICE +64 -0
@@ -0,0 +1,134 @@
1
+ """FlashInfer paged fp8 decode driver for ncu (NHD or HND KV layout).
2
+
3
+ The FlashInfer leg of ``mjolnir verify``: BENCH_B sequences (default 1) of
4
+ BENCH_L tokens, bf16 Q, fp8 e4m3 KV, tensor-core decode
5
+ (``use_tensor_cores=True``, plan once, split-KV as FlashInfer chooses) — the
6
+ decode path vLLM's FlashInfer backend runs. BENCH_QLEN > 1 (speculative
7
+ verification, e.g. 4 for MTP=3) runs the paged *prefill* wrapper, causal,
8
+ BENCH_QLEN query tokens per sequence — how stock vLLM's FlashInfer backend
9
+ runs a multi-token verify batch. BENCH_ITERS launches, no warmup, so ncu sees exactly
10
+ BENCH_ITERS invocations. The KV data is generated once in NHD order; the
11
+ HND run uses the same values transposed, so NHD vs HND differ only in layout.
12
+
13
+ FI_LAYOUT=HND BENCH_L=65536 BENCH_ITERS=50 python3 fi_driver.py
14
+
15
+ Env: FI_LAYOUT (NHD|HND), BENCH_L, BENCH_ITERS, BENCH_B (1), BENCH_QLEN (1), BENCH_HQ (24), BENCH_HK (4),
16
+ BENCH_D (256), BENCH_PAGE (128), BENCH_CONTIG=1 (pages in order; default
17
+ is a random page permutation, as the Mjolnir harness), FI_CHECK=1 (also
18
+ run the other layout once and assert the outputs match), BENCH_SEED.
19
+ The pre-rename FI_L / FI_ITERS / FI_HQ / FI_HK / FI_D / FI_PAGE names
20
+ are still honored.
21
+ """
22
+
23
+ from __future__ import annotations
24
+
25
+ import os
26
+
27
+ import torch
28
+
29
+
30
+ def env_int(name: str, default: int) -> int:
31
+ for k in (f"BENCH_{name}", f"FI_{name}"):
32
+ if k in os.environ:
33
+ return int(os.environ[k])
34
+ return default
35
+
36
+
37
+ def main():
38
+ import flashinfer
39
+
40
+ layout = os.environ.get("FI_LAYOUT", "NHD").upper()
41
+ assert layout in ("NHD", "HND"), layout
42
+ L, iters = env_int("L", 8192), env_int("ITERS", 50)
43
+ hq, hk, d, ps = env_int("HQ", 24), env_int("HK", 4), env_int("D", 256), env_int("PAGE", 128)
44
+ b, qlen = env_int("B", 1), env_int("QLEN", 1)
45
+ check = os.environ.get("FI_CHECK", "0") == "1"
46
+ torch.manual_seed(env_int("SEED", 1111))
47
+ dev = "cuda"
48
+
49
+ pages_seq = (L + ps - 1) // ps
50
+ last = L - (pages_seq - 1) * ps
51
+ pages = b * pages_seq
52
+ # (pages, 2, PS, h_k, d) in fp8, filled in chunks to bound the temp size
53
+ nhd = torch.empty(pages, 2, ps, hk, d, dtype=torch.float8_e4m3fn, device=dev)
54
+ step = max(1, (64 << 20) // (2 * ps * hk * d))
55
+ for p0 in range(0, pages, step):
56
+ p1 = min(pages, p0 + step)
57
+ nhd[p0:p1] = (torch.randn(p1 - p0, 2, ps, hk, d, device=dev, dtype=torch.bfloat16) * 0.5).to(
58
+ torch.float8_e4m3fn
59
+ )
60
+ caches = {"NHD": None, "HND": None}
61
+
62
+ def cache(lay):
63
+ if caches[lay] is None:
64
+ caches[lay] = nhd if lay == "NHD" else nhd.transpose(2, 3).contiguous()
65
+ return caches[lay]
66
+
67
+ if os.environ.get("BENCH_CONTIG", os.environ.get("FI_CONTIG", "0")) == "1":
68
+ indices = torch.arange(pages, dtype=torch.int32, device=dev)
69
+ else:
70
+ indices = torch.randperm(pages, device=dev).to(torch.int32)
71
+ indptr = torch.arange(b + 1, dtype=torch.int32, device=dev) * pages_seq
72
+ last_len = torch.full((b,), last, dtype=torch.int32, device=dev)
73
+ q = torch.randn(b * qlen, hq, d, device=dev, dtype=torch.bfloat16)
74
+
75
+ def make(lay):
76
+ ws = torch.empty(256 << 20, dtype=torch.uint8, device=dev)
77
+ if qlen > 1:
78
+ w = flashinfer.BatchPrefillWithPagedKVCacheWrapper(ws, kv_layout=lay)
79
+ qo_indptr = torch.arange(b + 1, dtype=torch.int32, device=dev) * qlen
80
+ w.plan(
81
+ qo_indptr,
82
+ indptr,
83
+ indices,
84
+ last_len,
85
+ hq,
86
+ hk,
87
+ d,
88
+ ps,
89
+ causal=True,
90
+ q_data_type=torch.bfloat16,
91
+ kv_data_type=torch.float8_e4m3fn,
92
+ )
93
+ return w
94
+ w = flashinfer.BatchDecodeWithPagedKVCacheWrapper(ws, kv_layout=lay, use_tensor_cores=True)
95
+ kw = dict(pos_encoding_mode="NONE", q_data_type=torch.bfloat16, kv_data_type=torch.float8_e4m3fn)
96
+ try:
97
+ w.plan(indptr, indices, last_len, hq, hk, d, ps, **kw)
98
+ except TypeError: # older FlashInfer: data_type instead of kv_data_type
99
+ kw["data_type"] = kw.pop("kv_data_type")
100
+ w.plan(indptr, indices, last_len, hq, hk, d, ps, **kw)
101
+ return w
102
+
103
+ def run(w, lay):
104
+ return w.run(q, cache(lay), k_scale=1.0, v_scale=1.0)
105
+
106
+ w = make(layout)
107
+ kv = cache(layout)
108
+ torch.cuda.synchronize()
109
+ print(
110
+ f"FI {layout}: b={b} q_len={qlen} L={L} pages={pages}x{ps} h_q={hq} h_k={hk} d={d} "
111
+ f"cache {tuple(kv.shape)} stride {kv.stride()} iters={iters}",
112
+ flush=True,
113
+ )
114
+
115
+ ev0, ev1 = torch.cuda.Event(enable_timing=True), torch.cuda.Event(enable_timing=True)
116
+ ev0.record()
117
+ out = None
118
+ for _ in range(iters):
119
+ out = run(w, layout)
120
+ ev1.record()
121
+ torch.cuda.synchronize()
122
+ print(f"FI {layout}: mean {ev0.elapsed_time(ev1) * 1e3 / max(1, iters):.1f} us/iter (not ncu)", flush=True)
123
+
124
+ if check:
125
+ other = "HND" if layout == "NHD" else "NHD"
126
+ ref = run(make(other), other)
127
+ err = (out.float() - ref.float()).abs().max().item()
128
+ print(f"FI check {layout} vs {other}: max abs diff {err:.3e}")
129
+ assert err < 1e-2, "NHD and HND outputs differ"
130
+ print("DONE")
131
+
132
+
133
+ if __name__ == "__main__":
134
+ main()
mjolnir_cli/e2e.py ADDED
@@ -0,0 +1,302 @@
1
+ """End to end: the official vLLM image vs the Mjolnir image, from history.
2
+
3
+ Everything here reads ``benchmarks/history.jsonl`` (written by ``mjolnir bench
4
+ perf|ab|e2e``) — the comparison table, the README section and the opening
5
+ infographic all come from the same pair of records:
6
+
7
+ * **official** — the latest run on the upstream image (``vllm/vllm-openai:…``)
8
+ with a stock config: native vLLM, native FlashInfer;
9
+ * **mjolnir** — the latest run of the Mjolnir backend config
10
+ (``NVFP4_MJOLNIR``) on a Mjolnir image.
11
+
12
+ Stock backends (FlashInfer, FA2) measured *on the Mjolnir image* are never
13
+ used: that image patches FlashInfer paths too, so it is not native vLLM.
14
+
15
+ Metrics (per context × concurrency cell, mean over every measured run):
16
+
17
+ | key | what | better |
18
+ |-----------|----------------------------------------|--------|
19
+ | ``tg_tps`` | decode throughput, tokens/s (all requests) | higher |
20
+ | ``pp_tps`` | prefill throughput, prompt tokens/s | higher |
21
+ | ``ttft_ms`` | time to first token, ms (e2e; falls back to llama-benchy's ``ttfr``) | lower |
22
+ | ``itl_ms`` | inter-token latency, ms = 1000 / per-request decode rate | lower |
23
+ """
24
+
25
+ from __future__ import annotations
26
+
27
+ from dataclasses import dataclass
28
+ from pathlib import Path
29
+
30
+ from mjolnir_cli.config import BASELINE_QUANT, DEFAULT_QUANT, IMAGE_REPO
31
+ from mjolnir_cli.history import load_records
32
+
33
+ # Older images of this project (kept so history rows still classify).
34
+ _MJOLNIR_IMAGE_PREFIXES = (IMAGE_REPO, "narandill/mjolnir-", "mjolnir/vllm-thor")
35
+
36
+
37
+ @dataclass(frozen=True)
38
+ class Metric:
39
+ key: str
40
+ label: str
41
+ unit: str
42
+ higher_better: bool
43
+ fmt: str
44
+
45
+
46
+ METRICS: tuple[Metric, ...] = (
47
+ Metric("tg_tps", "decode", "t/s", True, "{:.1f}"),
48
+ Metric("pp_tps", "prefill", "t/s", True, "{:,.0f}"),
49
+ Metric("ttft_ms", "TTFT", "ms", False, "{:,.0f}"),
50
+ Metric("itl_ms", "ITL", "ms", False, "{:.1f}"),
51
+ )
52
+ BY_KEY = {m.key: m for m in METRICS}
53
+
54
+ # End-to-end numbers are wall-clock serving runs (two separate server launches,
55
+ # llama-benchy means over runs). The band that colors a gain: ±3%, the same as
56
+ # the kernel's same-run ncu band (verify.stages.NOISE_PCT). Caveat: with MTP
57
+ # speculative decoding the per-run decode t/s spreads ~10-15% (the acceptance
58
+ # rate varies by prompt), so a decode mean of 6 runs carries a ~2-6% standard
59
+ # error — a decode gain inside ~6% needs repeated sweeps (--repeat) to trust.
60
+ E2E_NOISE_PCT = 3.0
61
+ # the official-vs-Mjolnir comparison (README table, e2e charts, infographic)
62
+ # and `bench e2e`'s default depths stop here: the 64K / 128K cells take hours
63
+ # per leg; `bench perf|ab --depths` still measure beyond it
64
+ MAX_CTX = 32768
65
+
66
+
67
+ def is_official(rec: dict) -> bool:
68
+ return str(rec.get("image", "")).startswith("vllm/vllm-openai")
69
+
70
+
71
+ def is_mjolnir(rec: dict) -> bool:
72
+ return str(rec.get("image", "")).startswith(_MJOLNIR_IMAGE_PREFIXES)
73
+
74
+
75
+ def _merge(recs: list[dict]) -> dict | None:
76
+ """One side's records → the newest record, with every (concurrency,
77
+ context) cell taken from the newest run that measured it. A run at extra
78
+ depths (``bench e2e --depths 65536,131072``) adds cells instead of
79
+ replacing the table; runs with a different speculative setup than the
80
+ newest one are left out. ``merged_runs`` lists the raw dirs used."""
81
+ if not recs:
82
+ return None
83
+ recs = sorted(recs, key=lambda r: r.get("epoch", 0), reverse=True)
84
+ head = recs[0]
85
+ sp = spec_of(head)
86
+ cells: dict = {}
87
+ used: list[str] = []
88
+ for r in recs:
89
+ if spec_of(r) != sp:
90
+ continue
91
+ took = False
92
+ for c in r.get("cells", []):
93
+ k = (c.get("concurrency"), c.get("context") or 0, bool(c.get("prefill_phase")))
94
+ if k not in cells:
95
+ cells[k] = c
96
+ took = True
97
+ if took:
98
+ used.append(str(r.get("raw", "")))
99
+ out = dict(head)
100
+ out["cells"] = sorted(cells.values(), key=lambda c: (c.get("concurrency") or 0, c.get("context") or 0))
101
+ out["merged_runs"] = used
102
+ return out
103
+
104
+
105
+ def latest_pair(
106
+ history_file: Path, mjolnir_config: str | None = None, official_config: str | None = None
107
+ ) -> tuple[dict | None, dict | None]:
108
+ """(official, mjolnir): each side's runs merged cell by cell, newest run
109
+ first (:func:`_merge`) — a stock config on the official image, and the
110
+ Mjolnir backend config (default ``DEFAULT_QUANT``) on a Mjolnir image.
111
+
112
+ The official side is ``official_config`` (default ``BASELINE_QUANT``,
113
+ native FlashInfer) when the history has it, else the newest official
114
+ config — so an optional official leg (``bench e2e --optional``: DSpark,
115
+ DFlash2 on stock vLLM) never replaces the baseline by being newer."""
116
+ want = mjolnir_config or DEFAULT_QUANT
117
+ base = official_config or BASELINE_QUANT
118
+ offs, mjs = [], []
119
+ for r in load_records(history_file):
120
+ if not r.get("cells"):
121
+ continue
122
+ if is_official(r) and r.get("config") != want:
123
+ offs.append(r)
124
+ elif is_mjolnir(r) and r.get("config") == want:
125
+ mjs.append(r)
126
+ if any(r.get("config") == base for r in offs):
127
+ offs = [r for r in offs if r.get("config") == base]
128
+ elif offs: # no baseline run yet: one official config, the newest run's
129
+ newest = max(offs, key=lambda r: r.get("epoch", 0)).get("config")
130
+ offs = [r for r in offs if r.get("config") == newest]
131
+ return _merge(offs), _merge(mjs)
132
+
133
+
134
+ def cell(rec: dict, conc: int, ctx: int) -> dict | None:
135
+ return next(
136
+ (
137
+ c
138
+ for c in rec.get("cells", [])
139
+ if c.get("concurrency") == conc and (c.get("context") or 0) == ctx and not c.get("prefill_phase")
140
+ ),
141
+ None,
142
+ )
143
+
144
+
145
+ def value(c: dict | None, key: str) -> float | None:
146
+ """Mean of a metric in a cell, with fallbacks for records written before
147
+ TTFT/ITL were stored (ttft ← ttfr; ITL ← 1000 / decode t/s at c=1).
148
+ Prefill is not reported for a cell without context."""
149
+ if not c:
150
+ return None
151
+ if key == "pp_tps" and not (c.get("context") or 0):
152
+ return None # no context, no prefill to compare: the "no ctx" row is decode only
153
+ m = (c.get(key) or {}).get("mean")
154
+ if m is not None:
155
+ return float(m)
156
+ if key == "ttft_ms":
157
+ return value(c, "ttfr_ms")
158
+ if key == "itl_ms":
159
+ rate = (c.get("tg_req_tps") or {}).get("mean")
160
+ if rate is None and (c.get("concurrency") or 1) == 1:
161
+ rate = (c.get("tg_tps") or {}).get("mean")
162
+ return 1000.0 / rate if rate else None
163
+ return None
164
+
165
+
166
+ def change_pct(official: float, mjolnir: float) -> float:
167
+ """The relative change of the measured value, in %: (Mjolnir − official) ÷
168
+ official × 100. Throughput: +57% = 1.57× the tokens/s. Latency: −27% =
169
+ 27% fewer milliseconds (a latency reduction, NOT the reciprocal speedup
170
+ official ÷ Mjolnir − 1, which would read +37%)."""
171
+ return (mjolnir / official - 1.0) * 100.0
172
+
173
+
174
+ def gain_pct(key: str, official: float, mjolnir: float) -> float:
175
+ """Signed improvement of Mjolnir over official, in % — positive = better
176
+ whichever way the metric runs: the throughput increase for decode /
177
+ prefill, the latency reduction (official − Mjolnir) ÷ official for TTFT /
178
+ ITL. Same magnitude as :func:`change_pct`, sign flipped for latency."""
179
+ ch = change_pct(official, mjolnir)
180
+ return ch if BY_KEY[key].higher_better else -ch
181
+
182
+
183
+ def shared_cells(off: dict, mj: dict) -> list[tuple[int, int]]:
184
+ """(concurrency, context) cells both records measured, sorted."""
185
+ a = {(c.get("concurrency"), c.get("context") or 0) for c in off.get("cells", []) if not c.get("prefill_phase")}
186
+ b = {(c.get("concurrency"), c.get("context") or 0) for c in mj.get("cells", []) if not c.get("prefill_phase")}
187
+ return sorted(a & b, key=lambda k: (k[0], k[1]))
188
+
189
+
190
+ def all_cells(off: dict, mj: dict, max_ctx: int | None = MAX_CTX) -> list[tuple[int, int]]:
191
+ """(concurrency, context) cells either record measured, up to ``max_ctx``, sorted."""
192
+ a = {(c.get("concurrency"), c.get("context") or 0) for c in off.get("cells", []) if not c.get("prefill_phase")}
193
+ b = {(c.get("concurrency"), c.get("context") or 0) for c in mj.get("cells", []) if not c.get("prefill_phase")}
194
+ return sorted((k for k in a | b if max_ctx is None or k[1] <= max_ctx), key=lambda k: (k[0], k[1]))
195
+
196
+
197
+ def missing_contexts(off: dict, mj: dict, max_ctx: int | None = MAX_CTX) -> dict[str, list[int]]:
198
+ """Contexts (up to ``max_ctx``) only one side measured: {"official": [...], "mjolnir": [...]}."""
199
+ ok = lambda c: max_ctx is None or (c.get("context") or 0) <= max_ctx # noqa: E731
200
+ a = {c.get("context") or 0 for c in off.get("cells", []) if not c.get("prefill_phase") and ok(c)}
201
+ b = {c.get("context") or 0 for c in mj.get("cells", []) if not c.get("prefill_phase") and ok(c)}
202
+ return {"official": sorted(b - a), "mjolnir": sorted(a - b)}
203
+
204
+
205
+ def compare(off: dict, mj: dict, every: bool = False, max_ctx: int | None = MAX_CTX) -> list[dict]:
206
+ """One row per shared cell (``every``: per cell either side measured, the
207
+ other side None): {conc, ctx, <key>: (official, mjolnir, gain%)}."""
208
+ rows = []
209
+ for conc, ctx in all_cells(off, mj, max_ctx) if every else shared_cells(off, mj):
210
+ a, b = cell(off, conc, ctx), cell(mj, conc, ctx)
211
+ row: dict = {"conc": conc, "ctx": ctx}
212
+ for m in METRICS:
213
+ va, vb = value(a, m.key), value(b, m.key)
214
+ row[m.key] = (va, vb, gain_pct(m.key, va, vb) if va and vb else None)
215
+ rows.append(row)
216
+ return rows
217
+
218
+
219
+ def hero(off: dict, mj: dict, max_ctx: int | None = MAX_CTX) -> dict | None:
220
+ """The infographic scenario: one request (c=1), among the contexts up to
221
+ ``max_ctx`` where both sides measured decode AND prefill, the one with
222
+ the highest decode gain. {ctx, tg_tps: (off, mj, gain), pp_tps: (...), ...}."""
223
+ best = None
224
+ for conc, ctx in shared_cells(off, mj):
225
+ if conc != 1 or (max_ctx is not None and ctx > max_ctx):
226
+ continue
227
+ a, b = cell(off, 1, ctx), cell(mj, 1, ctx)
228
+ out: dict = {"ctx": ctx}
229
+ for key in ("tg_tps", "pp_tps", "ttft_ms", "itl_ms"):
230
+ va, vb = value(a, key), value(b, key)
231
+ out[key] = (va, vb, gain_pct(key, va, vb) if va and vb else None)
232
+ if out["tg_tps"][2] is None or out["pp_tps"][2] is None:
233
+ continue
234
+ if best is None or out["tg_tps"][2] > best["tg_tps"][2]:
235
+ best = out
236
+ return best
237
+
238
+
239
+ def ctx_label(ctx: int) -> str:
240
+ if not ctx:
241
+ return "no ctx"
242
+ return f"{ctx // 1024}K" if ctx % 1024 == 0 else f"{ctx:,}"
243
+
244
+
245
+ def pct(v: float | None) -> str:
246
+ """Signed percent with a real minus sign, 1 decimal."""
247
+ if v is None:
248
+ return "—"
249
+ return f"{'−' if v < 0 else '+'}{abs(v):.1f}%"
250
+
251
+
252
+ def spec_of(rec: dict) -> dict | None:
253
+ """The speculative decoding a record ran with: stored in the record since
254
+ the field exists, else read from the config it names."""
255
+ if rec.get("spec") is not None:
256
+ return rec["spec"] or None
257
+ from mjolnir_cli.config import config_speculative, configs_root
258
+
259
+ path = configs_root() / str(rec.get("model", "")) / str(rec.get("config", "")) / "config.yaml"
260
+ return config_speculative(path) if path.exists() else None
261
+
262
+
263
+ def spec_label(*recs: dict) -> str:
264
+ """``MTP, 3 speculative tokens`` (``on both sides`` / per side when they differ)."""
265
+
266
+ def one(sp: dict | None) -> str:
267
+ if not sp:
268
+ return "no speculative decoding"
269
+ n = sp["tokens"]
270
+ return f"{sp['method'].upper() or 'speculative'}, {n} speculative token{'s' if n != 1 else ''}"
271
+
272
+ labels = [one(spec_of(r)) for r in recs]
273
+ if len(set(labels)) == 1:
274
+ return labels[0]
275
+ return " vs ".join(labels)
276
+
277
+
278
+ def spec_short(*recs: dict) -> str:
279
+ """``MTP×3`` — the compact form for tight layouts (the infographic)."""
280
+ labels = []
281
+ for r in recs:
282
+ sp = spec_of(r)
283
+ labels.append(f"{(sp['method'] or 'spec').upper()}×{sp['tokens']}" if sp else "no spec. decoding")
284
+ return labels[0] if len(set(labels)) == 1 else " vs ".join(labels)
285
+
286
+
287
+ def acceptance_line(off: dict, mj: dict) -> str:
288
+ """'\nMTP tokens/step: 2.84 → 2.71 · preemptions 0 → 0' when both legs
289
+ recorded spec stats (bench runs since the counters were added). Decode
290
+ t/s = tokens per step / step time, so a lower acceptance length slows
291
+ decode with the same kernel time."""
292
+ a, b = off.get("spec_stats") or {}, mj.get("spec_stats") or {}
293
+ if not a.get("acceptance_length") or not b.get("acceptance_length"):
294
+ return ""
295
+ return (
296
+ f"\nspec. decoding tokens/step: {a['acceptance_length']:.2f} → {b['acceptance_length']:.2f}"
297
+ f" · preemptions {a.get('preemptions', 0)} → {b.get('preemptions', 0)}"
298
+ )
299
+
300
+
301
+ def side_label(rec: dict) -> str:
302
+ return f"{rec.get('image', '')} · {rec.get('config', '')} ({rec.get('backend', '')})"
Binary file
@@ -0,0 +1,92 @@
1
+ Copyright (c) 2016 The Inter Project Authors (https://github.com/rsms/inter)
2
+
3
+ This Font Software is licensed under the SIL Open Font License, Version 1.1.
4
+ This license is copied below, and is also available with a FAQ at:
5
+ http://scripts.sil.org/OFL
6
+
7
+ -----------------------------------------------------------
8
+ SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
9
+ -----------------------------------------------------------
10
+
11
+ PREAMBLE
12
+ The goals of the Open Font License (OFL) are to stimulate worldwide
13
+ development of collaborative font projects, to support the font creation
14
+ efforts of academic and linguistic communities, and to provide a free and
15
+ open framework in which fonts may be shared and improved in partnership
16
+ with others.
17
+
18
+ The OFL allows the licensed fonts to be used, studied, modified and
19
+ redistributed freely as long as they are not sold by themselves. The
20
+ fonts, including any derivative works, can be bundled, embedded,
21
+ redistributed and/or sold with any software provided that any reserved
22
+ names are not used by derivative works. The fonts and derivatives,
23
+ however, cannot be released under any other type of license. The
24
+ requirement for fonts to remain under this license does not apply
25
+ to any document created using the fonts or their derivatives.
26
+
27
+ DEFINITIONS
28
+ "Font Software" refers to the set of files released by the Copyright
29
+ Holder(s) under this license and clearly marked as such. This may
30
+ include source files, build scripts and documentation.
31
+
32
+ "Reserved Font Name" refers to any names specified as such after the
33
+ copyright statement(s).
34
+
35
+ "Original Version" refers to the collection of Font Software components as
36
+ distributed by the Copyright Holder(s).
37
+
38
+ "Modified Version" refers to any derivative made by adding to, deleting,
39
+ or substituting -- in part or in whole -- any of the components of the
40
+ Original Version, by changing formats or by porting the Font Software to a
41
+ new environment.
42
+
43
+ "Author" refers to any designer, engineer, programmer, technical
44
+ writer or other person who contributed to the Font Software.
45
+
46
+ PERMISSION AND CONDITIONS
47
+ Permission is hereby granted, free of charge, to any person obtaining
48
+ a copy of the Font Software, to use, study, copy, merge, embed, modify,
49
+ redistribute, and sell modified and unmodified copies of the Font
50
+ Software, subject to the following conditions:
51
+
52
+ 1) Neither the Font Software nor any of its individual components,
53
+ in Original or Modified Versions, may be sold by itself.
54
+
55
+ 2) Original or Modified Versions of the Font Software may be bundled,
56
+ redistributed and/or sold with any software, provided that each copy
57
+ contains the above copyright notice and this license. These can be
58
+ included either as stand-alone text files, human-readable headers or
59
+ in the appropriate machine-readable metadata fields within text or
60
+ binary files as long as those fields can be easily viewed by the user.
61
+
62
+ 3) No Modified Version of the Font Software may use the Reserved Font
63
+ Name(s) unless explicit written permission is granted by the corresponding
64
+ Copyright Holder. This restriction only applies to the primary font name as
65
+ presented to the users.
66
+
67
+ 4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
68
+ Software shall not be used to promote, endorse or advertise any
69
+ Modified Version, except to acknowledge the contribution(s) of the
70
+ Copyright Holder(s) and the Author(s) or with their explicit written
71
+ permission.
72
+
73
+ 5) The Font Software, modified or unmodified, in part or in whole,
74
+ must be distributed entirely under this license, and must not be
75
+ distributed under any other license. The requirement for fonts to
76
+ remain under this license does not apply to any document created
77
+ using the Font Software.
78
+
79
+ TERMINATION
80
+ This license becomes null and void if any of the above conditions are
81
+ not met.
82
+
83
+ DISCLAIMER
84
+ THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
85
+ EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
86
+ MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
87
+ OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
88
+ COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
89
+ INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
90
+ DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
91
+ FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
92
+ OTHER DEALINGS IN THE FONT SOFTWARE.