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
mjolnir_cli/gate.py
ADDED
|
@@ -0,0 +1,233 @@
|
|
|
1
|
+
"""Reusable clean-window gate for Thor GPU benchmarks.
|
|
2
|
+
|
|
3
|
+
The Thor GPU is shared with the live vLLM server. A valid benchmark window
|
|
4
|
+
requires the vLLM request queue to be empty (no running, no waiting) for
|
|
5
|
+
several consecutive samples — the "clean window". This module is the single
|
|
6
|
+
source of truth for that gate; the kernel bench scripts carry inline copies
|
|
7
|
+
of the same logic.
|
|
8
|
+
|
|
9
|
+
The vLLM server is NEVER stopped or restarted to get a clean window — the
|
|
10
|
+
gate only waits for one to open on its own.
|
|
11
|
+
|
|
12
|
+
Metrics source: the vLLM server's Prometheus endpoint (``/metrics`` on its
|
|
13
|
+
serving port). We read ``vllm:num_requests_running`` and
|
|
14
|
+
``vllm:num_requests_waiting``; the window is clean when both are 0.0.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
import threading
|
|
20
|
+
import re
|
|
21
|
+
import time
|
|
22
|
+
import urllib.request
|
|
23
|
+
|
|
24
|
+
DEFAULT_POLL_S = 2.0
|
|
25
|
+
DEFAULT_CONFIRM = 6
|
|
26
|
+
DEFAULT_TIMEOUT_S = 3600
|
|
27
|
+
|
|
28
|
+
_RUNNING = "vllm:num_requests_running"
|
|
29
|
+
_WAITING = "vllm:num_requests_waiting"
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class PreflightError(RuntimeError):
|
|
33
|
+
"""The vLLM metrics endpoint was unreachable (server not up / wrong port)."""
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def server_load(metrics_url: str) -> tuple[float, float] | None:
|
|
37
|
+
"""Return ``(num_running, num_waiting)`` from the vLLM server, or ``None``
|
|
38
|
+
if the endpoint is unreachable or the metrics are missing."""
|
|
39
|
+
try:
|
|
40
|
+
with urllib.request.urlopen(metrics_url, timeout=5) as r:
|
|
41
|
+
text = r.read().decode()
|
|
42
|
+
except Exception: # noqa: BLE001
|
|
43
|
+
return None
|
|
44
|
+
out: dict[str, float] = {}
|
|
45
|
+
for line in text.splitlines():
|
|
46
|
+
for key in (_RUNNING, _WAITING):
|
|
47
|
+
if line.startswith(key + "{") or line.startswith(key + " "):
|
|
48
|
+
out[key.split(":", 1)[1]] = float(line.rsplit(" ", 1)[1])
|
|
49
|
+
if "num_requests_running" not in out or "num_requests_waiting" not in out:
|
|
50
|
+
return None
|
|
51
|
+
return (out["num_requests_running"], out["num_requests_waiting"])
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
_SPEC = {
|
|
55
|
+
"vllm:spec_decode_num_drafts_total": "drafts",
|
|
56
|
+
"vllm:spec_decode_num_draft_tokens_total": "draft_tokens",
|
|
57
|
+
"vllm:spec_decode_num_accepted_tokens_total": "accepted",
|
|
58
|
+
"vllm:num_preemptions_total": "preemptions",
|
|
59
|
+
"vllm:generation_tokens_total": "generation_tokens",
|
|
60
|
+
}
|
|
61
|
+
_POS = "vllm:spec_decode_num_accepted_tokens_per_pos_total"
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def server_counters(metrics_url: str) -> dict | None:
|
|
65
|
+
"""Speculative-decoding / scheduler counters (summed over label sets), for
|
|
66
|
+
before/after deltas around a bench. None if unreachable."""
|
|
67
|
+
try:
|
|
68
|
+
with urllib.request.urlopen(metrics_url, timeout=5) as r:
|
|
69
|
+
text = r.read().decode()
|
|
70
|
+
except Exception: # noqa: BLE001
|
|
71
|
+
return None
|
|
72
|
+
out: dict = {"per_pos": {}}
|
|
73
|
+
for line in text.splitlines():
|
|
74
|
+
if not line or line[0] == "#":
|
|
75
|
+
continue
|
|
76
|
+
name = line.split("{", 1)[0].split(" ", 1)[0]
|
|
77
|
+
try:
|
|
78
|
+
val = float(line.rsplit(" ", 1)[1])
|
|
79
|
+
except (IndexError, ValueError):
|
|
80
|
+
continue
|
|
81
|
+
if name in _SPEC:
|
|
82
|
+
out[_SPEC[name]] = out.get(_SPEC[name], 0.0) + val
|
|
83
|
+
elif name == _POS:
|
|
84
|
+
m = re.search(r'position="(\d+)"', line)
|
|
85
|
+
if m:
|
|
86
|
+
k = int(m.group(1))
|
|
87
|
+
out["per_pos"][k] = out["per_pos"].get(k, 0.0) + val
|
|
88
|
+
return out
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def spec_delta(before: dict | None, after: dict | None) -> dict | None:
|
|
92
|
+
"""Acceptance stats between two :func:`server_counters` samples:
|
|
93
|
+
mean acceptance length (1 + accepted / drafts: tokens per verify step),
|
|
94
|
+
per-position acceptance rate, preemptions."""
|
|
95
|
+
if not before or not after:
|
|
96
|
+
return None
|
|
97
|
+
d = {k: after.get(k, 0.0) - before.get(k, 0.0) for k in set(_SPEC.values())}
|
|
98
|
+
if d.get("drafts", 0) <= 0:
|
|
99
|
+
return {"preemptions": int(d.get("preemptions", 0))} if "preemptions" in after else None
|
|
100
|
+
counts = {
|
|
101
|
+
k: after["per_pos"].get(k, 0.0) - before.get("per_pos", {}).get(k, 0.0)
|
|
102
|
+
for k in sorted(after.get("per_pos", {}))
|
|
103
|
+
}
|
|
104
|
+
pos = {k: v / d["drafts"] for k, v in counts.items()}
|
|
105
|
+
return {
|
|
106
|
+
"drafts": int(d["drafts"]),
|
|
107
|
+
"draft_tokens": int(d.get("draft_tokens", 0)),
|
|
108
|
+
"accepted": int(d.get("accepted", 0)),
|
|
109
|
+
"acceptance_length": round(1 + d.get("accepted", 0) / d["drafts"], 3),
|
|
110
|
+
"acceptance_rate": round(d.get("accepted", 0) / d["draft_tokens"], 4) if d.get("draft_tokens") else None,
|
|
111
|
+
"per_pos": [round(v, 4) for v in pos.values()],
|
|
112
|
+
"per_pos_accepted": [int(v) for v in counts.values()], # raw counts, for pooling
|
|
113
|
+
"preemptions": int(d.get("preemptions", 0)),
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def wait_for_idle(
|
|
118
|
+
metrics_url: str,
|
|
119
|
+
poll_s: float = DEFAULT_POLL_S,
|
|
120
|
+
confirm: int = DEFAULT_CONFIRM,
|
|
121
|
+
timeout_s: float = DEFAULT_TIMEOUT_S,
|
|
122
|
+
) -> float | None:
|
|
123
|
+
"""Block until ``confirm`` consecutive samples read (0,0); return the
|
|
124
|
+
``time.time()`` of the sample that opened the window, or ``None`` on
|
|
125
|
+
timeout."""
|
|
126
|
+
t_start = time.perf_counter()
|
|
127
|
+
streak = 0
|
|
128
|
+
while time.perf_counter() - t_start < timeout_s:
|
|
129
|
+
load = server_load(metrics_url)
|
|
130
|
+
if load == (0.0, 0.0):
|
|
131
|
+
streak += 1
|
|
132
|
+
if streak >= confirm:
|
|
133
|
+
return time.time()
|
|
134
|
+
else:
|
|
135
|
+
streak = 0
|
|
136
|
+
time.sleep(poll_s)
|
|
137
|
+
return None
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
class _Monitor(threading.Thread):
|
|
141
|
+
"""Background thread that records ``(time, load)`` samples."""
|
|
142
|
+
|
|
143
|
+
def __init__(self, metrics_url: str, poll_s: float) -> None:
|
|
144
|
+
super().__init__(daemon=True)
|
|
145
|
+
self.metrics_url = metrics_url
|
|
146
|
+
self.poll_s = poll_s
|
|
147
|
+
self.trace: list[tuple[float, tuple[float, float] | None]] = []
|
|
148
|
+
self._stop = threading.Event()
|
|
149
|
+
|
|
150
|
+
def run(self) -> None:
|
|
151
|
+
while not self._stop.is_set():
|
|
152
|
+
self.trace.append((time.time(), server_load(self.metrics_url)))
|
|
153
|
+
self._stop.wait(self.poll_s)
|
|
154
|
+
|
|
155
|
+
def stop(self) -> None:
|
|
156
|
+
self._stop.set()
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
class CleanWindow:
|
|
160
|
+
"""Context manager: open a gated clean window, then report whether it
|
|
161
|
+
stayed clean for the whole body.
|
|
162
|
+
|
|
163
|
+
``open_ts`` is set once the window opens. ``clean`` is True iff every
|
|
164
|
+
in-window sample read (0,0). Raises :class:`PreflightError` if the
|
|
165
|
+
metrics endpoint is unreachable at start, :class:`TimeoutError` if the
|
|
166
|
+
window never opens within ``timeout_s``.
|
|
167
|
+
"""
|
|
168
|
+
|
|
169
|
+
def __init__(
|
|
170
|
+
self,
|
|
171
|
+
metrics_url: str,
|
|
172
|
+
confirm: int = DEFAULT_CONFIRM,
|
|
173
|
+
poll_s: float = DEFAULT_POLL_S,
|
|
174
|
+
timeout_s: float = DEFAULT_TIMEOUT_S,
|
|
175
|
+
) -> None:
|
|
176
|
+
self.metrics_url = metrics_url
|
|
177
|
+
self.confirm = confirm
|
|
178
|
+
self.poll_s = poll_s
|
|
179
|
+
self.timeout_s = timeout_s
|
|
180
|
+
self.open_ts: float | None = None
|
|
181
|
+
self.clean: bool | None = None
|
|
182
|
+
self.dirty_samples: list = []
|
|
183
|
+
|
|
184
|
+
def __enter__(self) -> "CleanWindow":
|
|
185
|
+
self._mon = _Monitor(self.metrics_url, self.poll_s)
|
|
186
|
+
self._mon.start()
|
|
187
|
+
time.sleep(self.poll_s)
|
|
188
|
+
if all(load is None for _, load in self._mon.trace):
|
|
189
|
+
self._mon.stop()
|
|
190
|
+
raise PreflightError(
|
|
191
|
+
f"vLLM metrics endpoint {self.metrics_url} is unreachable — is the server up? (mjolnir serve up)"
|
|
192
|
+
)
|
|
193
|
+
self._streak = 0
|
|
194
|
+
self._consumed = 0
|
|
195
|
+
t_start = time.perf_counter()
|
|
196
|
+
while True:
|
|
197
|
+
if time.perf_counter() - t_start > self.timeout_s:
|
|
198
|
+
self._mon.stop()
|
|
199
|
+
raise TimeoutError(
|
|
200
|
+
f"timed out after {self.timeout_s:.0f}s waiting for {self.confirm} consecutive 0/0 samples"
|
|
201
|
+
)
|
|
202
|
+
self._drain()
|
|
203
|
+
if self._streak >= self.confirm:
|
|
204
|
+
self.open_ts = self._mon.trace[-1][0]
|
|
205
|
+
return self
|
|
206
|
+
time.sleep(1.0)
|
|
207
|
+
|
|
208
|
+
def _drain(self) -> None:
|
|
209
|
+
while self._consumed < len(self._mon.trace):
|
|
210
|
+
_ts, load = self._mon.trace[self._consumed]
|
|
211
|
+
self._consumed += 1
|
|
212
|
+
self._streak = self._streak + 1 if load == (0.0, 0.0) else 0
|
|
213
|
+
|
|
214
|
+
def __exit__(self, exc_type, exc, tb) -> bool:
|
|
215
|
+
self._mon.stop()
|
|
216
|
+
self._drain()
|
|
217
|
+
in_window = [(ts, load) for (ts, load) in self._mon.trace if self.open_ts is not None and ts >= self.open_ts]
|
|
218
|
+
self.dirty_samples = [[ts, load] for (ts, load) in in_window if load is None or load != (0.0, 0.0)]
|
|
219
|
+
self.clean = not self.dirty_samples
|
|
220
|
+
return False
|
|
221
|
+
|
|
222
|
+
def summary(self) -> dict:
|
|
223
|
+
return {
|
|
224
|
+
"metrics_url": self.metrics_url,
|
|
225
|
+
"poll_s": self.poll_s,
|
|
226
|
+
"confirm": self.confirm,
|
|
227
|
+
"open_ts": self.open_ts,
|
|
228
|
+
"clean": self.clean,
|
|
229
|
+
"n_in_window": (
|
|
230
|
+
sum(1 for ts, _ in self._mon.trace if ts >= self.open_ts) if self.open_ts is not None else 0
|
|
231
|
+
),
|
|
232
|
+
"dirty_samples": self.dirty_samples,
|
|
233
|
+
}
|
mjolnir_cli/history.py
ADDED
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
"""Append-only benchmark history (``benchmarks/history.jsonl``).
|
|
2
|
+
|
|
3
|
+
One line per perf sweep repeat:
|
|
4
|
+
|
|
5
|
+
{
|
|
6
|
+
"ts": "2026-09-25T14:03:11+00:00", "epoch": 1758801791.123,
|
|
7
|
+
"host": "thor-01", "image": "mjolnir/vllm-thor:qwen38-sm110-v11",
|
|
8
|
+
"model": "Qwen/Qwen3.8-27B", "config": "NVFP4_MJOLNIR",
|
|
9
|
+
"backend": "Mjolnir",
|
|
10
|
+
"gate": {"clean": true, "confirm": 3, "open_ts": 1758801600.0},
|
|
11
|
+
"runs": 5, "warmup_runs": 2, "exact_tg": true,
|
|
12
|
+
"cells": [
|
|
13
|
+
{"concurrency": 1, "context": 8192, "prompt": 2048, "gen": 128,
|
|
14
|
+
"tg_tps": {"mean": 41.2, "std": 0.7, "values": [...]}, # decode t/s (all requests)
|
|
15
|
+
"tg_req_tps": {...}, # decode t/s per request
|
|
16
|
+
"pp_tps": {"mean": 9830.0, "std": 120.0, "values": [...]}, # prefill t/s
|
|
17
|
+
"ttfr_ms": {...}, "ttft_ms": {...}, # first response / e2e TTFT
|
|
18
|
+
"itl_ms": {...}}, # inter-token latency = 1000 / tg_req
|
|
19
|
+
...
|
|
20
|
+
]
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
The file is the raw, git-trackable substrate for the charts and for
|
|
24
|
+
"progress over time" — every nightly bump / kernel change adds a row.
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
from __future__ import annotations
|
|
28
|
+
|
|
29
|
+
import json
|
|
30
|
+
import sys
|
|
31
|
+
import platform
|
|
32
|
+
import time
|
|
33
|
+
from pathlib import Path
|
|
34
|
+
from typing import Any
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def append_record(history_file: Path, record: dict[str, Any]) -> None:
|
|
38
|
+
history_file.parent.mkdir(parents=True, exist_ok=True)
|
|
39
|
+
# a hand-edited file may lack the final newline: never glue a record onto it
|
|
40
|
+
lead = ""
|
|
41
|
+
if history_file.exists() and history_file.stat().st_size:
|
|
42
|
+
with history_file.open("rb") as f:
|
|
43
|
+
f.seek(-1, 2)
|
|
44
|
+
lead = "" if f.read(1) == b"\n" else "\n"
|
|
45
|
+
with history_file.open("a") as f:
|
|
46
|
+
f.write(lead + json.dumps(record) + "\n")
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def load_records(history_file: Path) -> list[dict[str, Any]]:
|
|
50
|
+
"""Every record, one JSON object per line — tolerant of objects glued
|
|
51
|
+
onto one line (an append after a hand edit that dropped the newline) and
|
|
52
|
+
of a truncated last line (an interrupted write), which is skipped with a
|
|
53
|
+
warning instead of failing every reader."""
|
|
54
|
+
if not history_file.exists():
|
|
55
|
+
return []
|
|
56
|
+
out = []
|
|
57
|
+
dec = json.JSONDecoder()
|
|
58
|
+
with history_file.open() as f:
|
|
59
|
+
for n, line in enumerate(f, 1):
|
|
60
|
+
line = line.strip()
|
|
61
|
+
i = 0
|
|
62
|
+
while i < len(line):
|
|
63
|
+
try:
|
|
64
|
+
rec, i = dec.raw_decode(line, i)
|
|
65
|
+
except json.JSONDecodeError as e:
|
|
66
|
+
print(f"[mjolnir] {history_file}:{n}: skipped unreadable data ({e})", file=sys.stderr)
|
|
67
|
+
break
|
|
68
|
+
out.append(rec)
|
|
69
|
+
while i < len(line) and line[i].isspace():
|
|
70
|
+
i += 1
|
|
71
|
+
return out
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def make_record(
|
|
75
|
+
s,
|
|
76
|
+
gate_summary: dict | None,
|
|
77
|
+
runs: int,
|
|
78
|
+
warmup_runs: int,
|
|
79
|
+
exact_tg: bool,
|
|
80
|
+
cells: list[dict[str, Any]],
|
|
81
|
+
raw_relpath: str,
|
|
82
|
+
) -> dict[str, Any]:
|
|
83
|
+
return {
|
|
84
|
+
"ts": time.strftime("%Y-%m-%dT%H:%M:%S%z", time.gmtime()).replace("+0000", "+00:00"),
|
|
85
|
+
"epoch": time.time(),
|
|
86
|
+
"host": platform.node() or "unknown",
|
|
87
|
+
"image": s.image,
|
|
88
|
+
"model": s.model,
|
|
89
|
+
"config": s.quant,
|
|
90
|
+
"backend": s.backend_label,
|
|
91
|
+
"spec": _spec(s),
|
|
92
|
+
"gate": gate_summary,
|
|
93
|
+
"runs": runs,
|
|
94
|
+
"warmup_runs": warmup_runs,
|
|
95
|
+
"exact_tg": exact_tg,
|
|
96
|
+
"cells": cells,
|
|
97
|
+
"raw": raw_relpath,
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def _spec(s) -> dict | None:
|
|
102
|
+
"""Speculative decoding the served config used (e2e numbers depend on it)."""
|
|
103
|
+
try:
|
|
104
|
+
from mjolnir_cli.config import config_speculative
|
|
105
|
+
|
|
106
|
+
return config_speculative(s.config_path)
|
|
107
|
+
except Exception: # noqa: BLE001
|
|
108
|
+
return None
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def parse_benchy_json(raw: dict[str, Any]) -> list[dict[str, Any]]:
|
|
112
|
+
"""llama-benchy JSON → normalized per-cell records with the metrics we
|
|
113
|
+
chart (tg / pp generation + prefill throughput, each with mean/std/values
|
|
114
|
+
from the per-run samples)."""
|
|
115
|
+
cells = []
|
|
116
|
+
for b in raw.get("benchmarks", []):
|
|
117
|
+
|
|
118
|
+
def metric(name: str) -> dict | None:
|
|
119
|
+
m = b.get(name)
|
|
120
|
+
if not isinstance(m, dict):
|
|
121
|
+
return None
|
|
122
|
+
vals = m.get("values")
|
|
123
|
+
out = {"mean": m.get("mean"), "std": m.get("std")}
|
|
124
|
+
if vals is not None:
|
|
125
|
+
out["values"] = [float(v) for v in vals]
|
|
126
|
+
return out
|
|
127
|
+
|
|
128
|
+
cell = {
|
|
129
|
+
"concurrency": b.get("concurrency"),
|
|
130
|
+
"context": b.get("context_size"),
|
|
131
|
+
"prompt": b.get("prompt_size"),
|
|
132
|
+
"gen": b.get("response_size"),
|
|
133
|
+
"prefill_phase": bool(b.get("is_context_prefill_phase", False)),
|
|
134
|
+
}
|
|
135
|
+
for src, dst in (
|
|
136
|
+
("tg_throughput", "tg_tps"),
|
|
137
|
+
("tg_req_throughput", "tg_req_tps"),
|
|
138
|
+
("pp_throughput", "pp_tps"),
|
|
139
|
+
("peak_throughput", "peak_tps"),
|
|
140
|
+
("ttfr", "ttfr_ms"),
|
|
141
|
+
("e2e_ttft", "ttft_ms"),
|
|
142
|
+
):
|
|
143
|
+
m = metric(src)
|
|
144
|
+
if m is not None:
|
|
145
|
+
cell[dst] = m
|
|
146
|
+
# inter-token latency: the per-request decode rate inverted, per run
|
|
147
|
+
# (at c=1 tg_req == tg). llama-benchy reports rates, not ITL itself.
|
|
148
|
+
if (b.get("concurrency") or 1) == 1:
|
|
149
|
+
rate = cell.get("tg_req_tps") or cell.get("tg_tps")
|
|
150
|
+
else:
|
|
151
|
+
rate = cell.get("tg_req_tps")
|
|
152
|
+
if rate and rate.get("values"):
|
|
153
|
+
itl = [1000.0 / v for v in rate["values"] if v and v > 0]
|
|
154
|
+
if itl:
|
|
155
|
+
mean = sum(itl) / len(itl)
|
|
156
|
+
std = (sum((v - mean) ** 2 for v in itl) / (len(itl) - 1)) ** 0.5 if len(itl) > 1 else 0.0
|
|
157
|
+
cell["itl_ms"] = {"mean": mean, "std": std, "values": itl}
|
|
158
|
+
cells.append(cell)
|
|
159
|
+
return cells
|