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.
- mjolnir_cli/__init__.py +8 -0
- mjolnir_cli/__main__.py +4 -0
- mjolnir_cli/accuracy.py +333 -0
- mjolnir_cli/autoresearch.py +539 -0
- mjolnir_cli/benchy.py +475 -0
- mjolnir_cli/bundled/configs/Qwen/Qwen3.8-27B/NVFP4/config.yaml +78 -0
- mjolnir_cli/bundled/configs/Qwen/Qwen3.8-27B/NVFP4_DFLASH2/config.yaml +103 -0
- mjolnir_cli/bundled/configs/Qwen/Qwen3.8-27B/NVFP4_DSPARK/config.yaml +102 -0
- mjolnir_cli/bundled/configs/Qwen/Qwen3.8-27B/NVFP4_FA2/config.yaml +84 -0
- mjolnir_cli/bundled/configs/Qwen/Qwen3.8-27B/NVFP4_FA4hd256/config.yaml +104 -0
- mjolnir_cli/bundled/configs/Qwen/Qwen3.8-27B/NVFP4_MJOLNIR/config.yaml +86 -0
- mjolnir_cli/bundled/configs/Qwen/Qwen3.8-27B/NVFP4_MJOLNIR_E2M3/config.yaml +93 -0
- mjolnir_cli/bundled/configs/Qwen/Qwen3.8-27B/NVFP4_MJOLNIR_E2M3_DFLASH2/config.yaml +123 -0
- mjolnir_cli/bundled/configs/Qwen/Qwen3.8-27B/NVFP4_MJOLNIR_E2M3_DSPARK/config.yaml +124 -0
- mjolnir_cli/bundled/configs/Qwen/Qwen3.8-27B/NVFP4_MJOLNIR_E2M3_LMH8/.env +2 -0
- mjolnir_cli/bundled/configs/Qwen/Qwen3.8-27B/NVFP4_MJOLNIR_E2M3_LMH8/config.yaml +98 -0
- mjolnir_cli/bundled/configs/Qwen/Qwen3.8-27B/NVFP4_MJOLNIR_E2M3_LMH8_VT32K/.env +4 -0
- mjolnir_cli/bundled/configs/Qwen/Qwen3.8-27B/NVFP4_MJOLNIR_E2M3_LMH8_VT32K/config.yaml +98 -0
- mjolnir_cli/bundled/configs/Qwen/Qwen3.8-27B/NVFP4_MJOLNIR_E2M3_LMH8_VT64K/.env +4 -0
- mjolnir_cli/bundled/configs/Qwen/Qwen3.8-27B/NVFP4_MJOLNIR_E2M3_LMH8_VT64K/config.yaml +102 -0
- mjolnir_cli/bundled/configs/Qwen/Qwen3.8-27B/NVFP4_MJOLNIR_E2M3_VT32K/.env +3 -0
- mjolnir_cli/bundled/configs/Qwen/Qwen3.8-27B/NVFP4_MJOLNIR_E2M3_VT32K/config.yaml +98 -0
- mjolnir_cli/bundled/configs/Qwen/Qwen3.8-27B/draft_vocab.json +1 -0
- mjolnir_cli/bundled/configs/Qwen/Qwen3.8-27B/kv_scales.json +35197 -0
- mjolnir_cli/bundled/configs/Qwen/Qwen3.8_chat_template.jinja +456 -0
- mjolnir_cli/bundled/docker/litellm/docker-compose.yaml +113 -0
- mjolnir_cli/bundled/docker/litellm/litellm.env.example +51 -0
- mjolnir_cli/bundled/docker/litellm/litellm_config.template.example.yaml +192 -0
- mjolnir_cli/bundled/docker/perses/README.md +89 -0
- mjolnir_cli/bundled/docker/perses/dashboards/global-datasource.yaml +17 -0
- mjolnir_cli/bundled/docker/perses/dashboards/performance_statistics.yaml +771 -0
- mjolnir_cli/bundled/docker/perses/dashboards/project.yaml +13 -0
- mjolnir_cli/bundled/docker/perses/dashboards/query_statistics.yaml +399 -0
- mjolnir_cli/bundled/docker/perses/docker-compose.yaml +51 -0
- mjolnir_cli/bundled/docker/perses/perses.yml +16 -0
- mjolnir_cli/bundled/docker/perses/prometheus.template.yaml +20 -0
- mjolnir_cli/bundled/scripts/hw/setup-thor.sh +1287 -0
- mjolnir_cli/cachectl.py +146 -0
- mjolnir_cli/charts.py +1687 -0
- mjolnir_cli/cli.py +1480 -0
- mjolnir_cli/clidoc.py +170 -0
- mjolnir_cli/config.py +346 -0
- mjolnir_cli/dockerctl.py +533 -0
- mjolnir_cli/drivers/fa2_driver.py +89 -0
- mjolnir_cli/drivers/fi_driver.py +134 -0
- mjolnir_cli/e2e.py +302 -0
- mjolnir_cli/fonts/InterDisplay-Bold.ttf +0 -0
- mjolnir_cli/fonts/InterDisplay-Medium.ttf +0 -0
- mjolnir_cli/fonts/InterDisplay-Regular.ttf +0 -0
- mjolnir_cli/fonts/InterDisplay-SemiBold.ttf +0 -0
- mjolnir_cli/fonts/OFL.txt +92 -0
- mjolnir_cli/gate.py +233 -0
- mjolnir_cli/history.py +159 -0
- mjolnir_cli/interactive.py +383 -0
- mjolnir_cli/legs.py +109 -0
- mjolnir_cli/litellm.py +254 -0
- mjolnir_cli/perses.py +214 -0
- mjolnir_cli/picker.py +70 -0
- mjolnir_cli/readme.py +1020 -0
- mjolnir_cli/stack.py +340 -0
- mjolnir_cli/tasks.py +122 -0
- mjolnir_cli/theme.py +281 -0
- mjolnir_cli/thor.py +108 -0
- mjolnir_cli/verify/__init__.py +2 -0
- mjolnir_cli/verify/cli.py +816 -0
- mjolnir_cli/verify/docker.py +334 -0
- mjolnir_cli/verify/kvcache.py +180 -0
- mjolnir_cli/verify/legs.py +134 -0
- mjolnir_cli/verify/ncu.py +141 -0
- mjolnir_cli/verify/profile.py +312 -0
- mjolnir_cli/verify/report.py +177 -0
- mjolnir_cli/verify/results.py +157 -0
- mjolnir_cli/verify/spec.py +279 -0
- mjolnir_cli/verify/stages.py +817 -0
- mjolnir_cli-0.2.0.dist-info/METADATA +87 -0
- mjolnir_cli-0.2.0.dist-info/RECORD +80 -0
- mjolnir_cli-0.2.0.dist-info/WHEEL +4 -0
- mjolnir_cli-0.2.0.dist-info/entry_points.txt +2 -0
- mjolnir_cli-0.2.0.dist-info/licenses/LICENSE +202 -0
- 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
|
|
Binary file
|
|
Binary file
|
|
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.
|