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/cli.py
ADDED
|
@@ -0,0 +1,1480 @@
|
|
|
1
|
+
"""The ``mjolnir`` CLI — one command surface for the whole repo.
|
|
2
|
+
|
|
3
|
+
mjolnir hw setup|status pre-configure the Thor host (HW
|
|
4
|
+
provisioning, idempotent)
|
|
5
|
+
mjolnir serve up|down|status|logs drive the vLLM container
|
|
6
|
+
mjolnir model [use|list] active model/config (bare = arrow-key
|
|
7
|
+
picker over configs/)
|
|
8
|
+
mjolnir image [use|list|build|gates] active vLLM image (bare = arrow-key
|
|
9
|
+
picker over local docker images)
|
|
10
|
+
mjolnir verify <stage> kernel correctness + ncu benchmarks (Mjolnir vs
|
|
11
|
+
FlashInfer vs FA2) -> benchmarks/raw/verify
|
|
12
|
+
mjolnir autoresearch ... the autonomous kernel-research loop (OpenCode
|
|
13
|
+
agents, every experiment on the ledger)
|
|
14
|
+
mjolnir gate the clean-window gate
|
|
15
|
+
mjolnir bench perf|ab end-to-end perf / A/B (vLLM server)
|
|
16
|
+
mjolnir plot|history charts (+ README.md with --readme) + the raw-data log
|
|
17
|
+
mjolnir litellm up|down|status|logs the LiteLLM proxy stack (independent
|
|
18
|
+
of the vLLM server)
|
|
19
|
+
mjolnir perses up|down|status|logs the Perses + Prometheus observability
|
|
20
|
+
stack (independent of the vLLM server)
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
from __future__ import annotations
|
|
24
|
+
|
|
25
|
+
import json
|
|
26
|
+
import os
|
|
27
|
+
import re
|
|
28
|
+
import shlex
|
|
29
|
+
import subprocess
|
|
30
|
+
import sys
|
|
31
|
+
from pathlib import Path
|
|
32
|
+
from typing import Optional
|
|
33
|
+
|
|
34
|
+
import typer
|
|
35
|
+
from rich import box
|
|
36
|
+
from rich.console import Console
|
|
37
|
+
from rich.style import Style
|
|
38
|
+
from rich.table import Table
|
|
39
|
+
|
|
40
|
+
from mjolnir_cli import __version__
|
|
41
|
+
from mjolnir_cli import legs as legs_
|
|
42
|
+
from mjolnir_cli.e2e import MAX_CTX as _E2E_MAX_CTX
|
|
43
|
+
from mjolnir_cli.config import (
|
|
44
|
+
BACKEND_LABELS,
|
|
45
|
+
DEFAULT_IMAGE,
|
|
46
|
+
DEFAULT_QUANT,
|
|
47
|
+
E2E_DEPTHS,
|
|
48
|
+
STOCK_IMAGE,
|
|
49
|
+
VLLM_VERSION,
|
|
50
|
+
Settings,
|
|
51
|
+
ConfigEntry,
|
|
52
|
+
load_layout,
|
|
53
|
+
resolve,
|
|
54
|
+
configs_root,
|
|
55
|
+
scan_all_configs,
|
|
56
|
+
user_configs_dir,
|
|
57
|
+
)
|
|
58
|
+
from mjolnir_cli import benchy, cachectl, charts, dockerctl, litellm, perses, picker, readme, tasks, thor
|
|
59
|
+
from mjolnir_cli.verify.cli import app as verify_app
|
|
60
|
+
from mjolnir_cli.autoresearch import app as autoresearch_app
|
|
61
|
+
|
|
62
|
+
_console = Console()
|
|
63
|
+
|
|
64
|
+
_HELP_CTX = {"help_option_names": ["-h", "--help"]}
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def _picker_group(bare_fn) -> type[typer.core.TyperGroup]:
|
|
68
|
+
"""A group that, when invoked bare (no subcommand), runs ``bare_fn``
|
|
69
|
+
(the arrow-key picker) instead of erroring out."""
|
|
70
|
+
|
|
71
|
+
class PickerGroup(typer.core.TyperGroup):
|
|
72
|
+
def invoke(self, ctx):
|
|
73
|
+
if not ctx._protected_args:
|
|
74
|
+
bare_fn()
|
|
75
|
+
return
|
|
76
|
+
return super().invoke(ctx)
|
|
77
|
+
|
|
78
|
+
return PickerGroup
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
_NEXT_STEPS = """
|
|
82
|
+
Next steps:
|
|
83
|
+
mjolnir serve up serve the model (baked-in defaults: Qwen3.8-27B NVFP4,
|
|
84
|
+
--attention-backend MJOLNIR, port 6001)
|
|
85
|
+
mjolnir serve status container + health + request queue
|
|
86
|
+
mjolnir verify sweep Mjolnir vs FlashInfer vs FA2 at every L (ncu) -> raw data
|
|
87
|
+
mjolnir verify all correctness + every benchmark stage -> charts + README
|
|
88
|
+
mjolnir bench ab end-to-end A/B: Mjolnir vs FlashInfer vs FA2 configs
|
|
89
|
+
mjolnir model / image pick another model config / serving image
|
|
90
|
+
"""
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def _root_group() -> type[typer.core.TyperGroup]:
|
|
94
|
+
"""The root group: bare ``mjolnir`` prints the help plus a next-steps
|
|
95
|
+
guide instead of just the help."""
|
|
96
|
+
|
|
97
|
+
class RootGroup(typer.core.TyperGroup):
|
|
98
|
+
def invoke(self, ctx):
|
|
99
|
+
if not ctx._protected_args:
|
|
100
|
+
ctx.command.get_help(ctx)
|
|
101
|
+
print(_NEXT_STEPS, flush=True)
|
|
102
|
+
return
|
|
103
|
+
return super().invoke(ctx)
|
|
104
|
+
|
|
105
|
+
return RootGroup
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
serve_app = typer.Typer(
|
|
109
|
+
help="Drive the vLLM container (the patched image).", no_args_is_help=True, context_settings=_HELP_CTX
|
|
110
|
+
)
|
|
111
|
+
bench_app = typer.Typer(
|
|
112
|
+
help="Benchmarks — gated, raw data, chartable.", no_args_is_help=True, context_settings=_HELP_CTX
|
|
113
|
+
)
|
|
114
|
+
model_app = typer.Typer(
|
|
115
|
+
help="Pick the active model / config.", context_settings=_HELP_CTX, cls=_picker_group(lambda: model_use(None, None))
|
|
116
|
+
)
|
|
117
|
+
image_app = typer.Typer(
|
|
118
|
+
help="Pick the active vLLM image / build the patched one.",
|
|
119
|
+
context_settings=_HELP_CTX,
|
|
120
|
+
cls=_picker_group(lambda: image_use(None)),
|
|
121
|
+
)
|
|
122
|
+
litellm_app = typer.Typer(
|
|
123
|
+
help="The LiteLLM proxy stack (proxy + postgres + redis) — independent "
|
|
124
|
+
"of the vLLM server: 'litellm down' leaves vLLM running, and "
|
|
125
|
+
"'serve down' leaves the proxy up. Reads your template from the "
|
|
126
|
+
"litellm data dir, ~/.local/share/mjolnir/litellm/ (see "
|
|
127
|
+
"docker/litellm/).",
|
|
128
|
+
no_args_is_help=True,
|
|
129
|
+
context_settings=_HELP_CTX,
|
|
130
|
+
)
|
|
131
|
+
perses_app = typer.Typer(
|
|
132
|
+
help="The Perses + Prometheus observability stack for the vLLM server — "
|
|
133
|
+
"independent of the vLLM server: 'perses down' leaves the server "
|
|
134
|
+
"running, and 'serve down' leaves the stack up. Prometheus scrapes "
|
|
135
|
+
"the server's /metrics (host.docker.internal + the resolved port); "
|
|
136
|
+
"Perses serves the two vLLM dashboards (vendored from "
|
|
137
|
+
"vllm-project/vllm, docker/perses/). Data: "
|
|
138
|
+
"~/.local/share/mjolnir/perses/.",
|
|
139
|
+
no_args_is_help=True,
|
|
140
|
+
context_settings=_HELP_CTX,
|
|
141
|
+
)
|
|
142
|
+
hw_app = typer.Typer(
|
|
143
|
+
help="Thor host pre-configuration — one-time, idempotent provisioning "
|
|
144
|
+
"(sudo where needed): headless boot target, apt upgrade, Docker + "
|
|
145
|
+
"NVIDIA default runtime, pip, jtop, swap, fan profiles "
|
|
146
|
+
"(recommended + max installed, default selected), service "
|
|
147
|
+
"cleanup, locked clocks, MAXN power mode. Run 'hw setup' before "
|
|
148
|
+
"serving or benchmarking.",
|
|
149
|
+
no_args_is_help=True,
|
|
150
|
+
context_settings=_HELP_CTX,
|
|
151
|
+
)
|
|
152
|
+
|
|
153
|
+
app = typer.Typer(
|
|
154
|
+
context_settings=_HELP_CTX,
|
|
155
|
+
cls=_root_group(),
|
|
156
|
+
help="Mjolnir — a decode-attention kernel for Jetson AGX Thor (sm_110) and the "
|
|
157
|
+
"vLLM image that serves it: serve / verify / bench / plot.",
|
|
158
|
+
)
|
|
159
|
+
app.add_typer(serve_app, name="serve")
|
|
160
|
+
app.add_typer(bench_app, name="bench")
|
|
161
|
+
app.add_typer(model_app, name="model")
|
|
162
|
+
app.add_typer(image_app, name="image")
|
|
163
|
+
app.add_typer(verify_app, name="verify")
|
|
164
|
+
app.add_typer(autoresearch_app, name="autoresearch")
|
|
165
|
+
app.add_typer(litellm_app, name="litellm")
|
|
166
|
+
app.add_typer(perses_app, name="perses")
|
|
167
|
+
app.add_typer(hw_app, name="hw")
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def _state_file() -> Path:
|
|
171
|
+
return Path(os.environ.get("MJOLNIR_STATE", str(Path.home() / ".mjolnir-state.json"))).expanduser()
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def _load_state() -> dict:
|
|
175
|
+
p = _state_file()
|
|
176
|
+
if p.exists():
|
|
177
|
+
try:
|
|
178
|
+
return json.loads(p.read_text())
|
|
179
|
+
except Exception: # noqa: BLE001
|
|
180
|
+
pass
|
|
181
|
+
return {}
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def _save_state(patch: dict) -> None:
|
|
185
|
+
"""Merge ``patch`` into the state file (the active model/config/image)."""
|
|
186
|
+
st = _load_state()
|
|
187
|
+
st.update(patch)
|
|
188
|
+
_state_file().parent.mkdir(parents=True, exist_ok=True)
|
|
189
|
+
_state_file().write_text(json.dumps(st, indent=2))
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
def _settings(model: Optional[str], quant: Optional[str], image: Optional[str], port: Optional[int]) -> Settings:
|
|
193
|
+
st = _load_state()
|
|
194
|
+
return resolve(model or st.get("model"), quant or st.get("quant"), image or st.get("image"), port)
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
# ── serve ───────────────────────────────────────────────────────────────────
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
@serve_app.command("up")
|
|
201
|
+
def serve_up(
|
|
202
|
+
model: Optional[str] = typer.Option(None, "--model"),
|
|
203
|
+
config: Optional[str] = typer.Option(None, "--config", help="config quant name, e.g. NVFP4_FA4hd256"),
|
|
204
|
+
image: Optional[str] = typer.Option(None, "--image"),
|
|
205
|
+
port: Optional[int] = typer.Option(None, "--port"),
|
|
206
|
+
wait: bool = typer.Option(True, "--wait/--no-wait", help="wait for /health before returning"),
|
|
207
|
+
wait_timeout: float = typer.Option(1800.0, "--wait-timeout"),
|
|
208
|
+
dry_run: bool = typer.Option(False, "--dry-run"),
|
|
209
|
+
):
|
|
210
|
+
"""Start the vLLM server (custom image, config from --model/--config)."""
|
|
211
|
+
s = _settings(model, config, image, port)
|
|
212
|
+
if not s.config_path.exists():
|
|
213
|
+
typer.secho(f"config not found: {s.config_path}", fg=typer.colors.RED, err=True)
|
|
214
|
+
raise typer.Exit(4)
|
|
215
|
+
stale = dockerctl.stale_image(s.image)
|
|
216
|
+
if stale:
|
|
217
|
+
typer.secho(f"warning: {stale}", fg=typer.colors.YELLOW, err=True)
|
|
218
|
+
try:
|
|
219
|
+
dockerctl.serve_up(s, wait=wait, wait_timeout_s=wait_timeout, dry_run=dry_run)
|
|
220
|
+
except (dockerctl.DockerError, TimeoutError) as e:
|
|
221
|
+
typer.secho(str(e), fg=typer.colors.RED, err=True)
|
|
222
|
+
raise typer.Exit(1)
|
|
223
|
+
typer.secho(f"\n serving {s.model} ({s.quant}, {s.backend_label}) at {s.base_url}", fg=typer.colors.GREEN)
|
|
224
|
+
typer.echo(" gate: mjolnir gate --once")
|
|
225
|
+
typer.echo(" bench: mjolnir bench perf · kernel: mjolnir verify sweep")
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
@serve_app.command("down")
|
|
229
|
+
def serve_down(
|
|
230
|
+
model: Optional[str] = typer.Option(None, "--model"),
|
|
231
|
+
config: Optional[str] = typer.Option(None, "--config"),
|
|
232
|
+
image: Optional[str] = typer.Option(None, "--image"),
|
|
233
|
+
port: Optional[int] = typer.Option(None, "--port"),
|
|
234
|
+
dry_run: bool = typer.Option(False, "--dry-run"),
|
|
235
|
+
):
|
|
236
|
+
"""Stop the vLLM container."""
|
|
237
|
+
s = _settings(model, config, image, port)
|
|
238
|
+
dockerctl.serve_down(s, dry_run=dry_run)
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
@serve_app.command("status")
|
|
242
|
+
def serve_status(port: Optional[int] = typer.Option(None, "--port")):
|
|
243
|
+
"""Container state + /health + current queue load."""
|
|
244
|
+
s = _settings(None, None, None, port)
|
|
245
|
+
st = dockerctl.status(s)
|
|
246
|
+
typer.echo(json.dumps(st, indent=2))
|
|
247
|
+
from mjolnir_cli.gate import server_load
|
|
248
|
+
|
|
249
|
+
load = server_load(s.metrics_url)
|
|
250
|
+
if load is not None:
|
|
251
|
+
typer.echo(f" queue: running={load[0]:g} waiting={load[1]:g}")
|
|
252
|
+
pport = perses.running_port()
|
|
253
|
+
if pport:
|
|
254
|
+
typer.echo(f" dashboard: http://127.0.0.1:{pport} (mjolnir perses status)")
|
|
255
|
+
|
|
256
|
+
|
|
257
|
+
@serve_app.command("logs")
|
|
258
|
+
def serve_logs(
|
|
259
|
+
port: Optional[int] = typer.Option(None, "--port"),
|
|
260
|
+
follow: bool = typer.Option(False, "--follow", "-f"),
|
|
261
|
+
tail: int = typer.Option(100, "--tail"),
|
|
262
|
+
):
|
|
263
|
+
"""Tail the vLLM container logs."""
|
|
264
|
+
s = _settings(None, None, None, port)
|
|
265
|
+
raise typer.Exit(dockerctl.logs(s, follow=follow, tail=tail))
|
|
266
|
+
|
|
267
|
+
|
|
268
|
+
# ── model ───────────────────────────────────────────────────────────────────
|
|
269
|
+
|
|
270
|
+
|
|
271
|
+
def _config_table(entries: list[ConfigEntry], active_model, active_quant) -> Table:
|
|
272
|
+
t = Table(box=box.SIMPLE_HEAD, pad_edge=False)
|
|
273
|
+
t.add_column("", justify="center")
|
|
274
|
+
t.add_column("model", style="bold cyan")
|
|
275
|
+
t.add_column("quant", style="green")
|
|
276
|
+
t.add_column("backend")
|
|
277
|
+
t.add_column("where")
|
|
278
|
+
for e in entries:
|
|
279
|
+
active = e.model == active_model and e.quant == active_quant
|
|
280
|
+
t.add_row(
|
|
281
|
+
"[green]●[/green]" if active else "",
|
|
282
|
+
e.model,
|
|
283
|
+
e.quant,
|
|
284
|
+
e.backend,
|
|
285
|
+
e.source,
|
|
286
|
+
style=Style(bold=True) if active else None,
|
|
287
|
+
)
|
|
288
|
+
return t
|
|
289
|
+
|
|
290
|
+
|
|
291
|
+
def _config_labels(entries: list[ConfigEntry]) -> list[str]:
|
|
292
|
+
return [f"{e.model} / {e.quant}" for e in entries]
|
|
293
|
+
|
|
294
|
+
|
|
295
|
+
def _pick_config(
|
|
296
|
+
entries: list[ConfigEntry],
|
|
297
|
+
st: dict,
|
|
298
|
+
candidates: list[ConfigEntry] | None = None,
|
|
299
|
+
title: str = "Pick the active model / config:",
|
|
300
|
+
) -> ConfigEntry:
|
|
301
|
+
"""Arrow-key pick over the candidate configs (default: the active one)."""
|
|
302
|
+
pool = candidates or entries
|
|
303
|
+
labels = _config_labels(pool)
|
|
304
|
+
cur = st.get("model") and st.get("quant")
|
|
305
|
+
default = f"{cur[0]} / {cur[1]}" if cur and f"{cur[0]} / {cur[1]}" in labels else None
|
|
306
|
+
if not sys.stdin.isatty():
|
|
307
|
+
_console.print(_config_table(entries, st.get("model"), st.get("quant")))
|
|
308
|
+
typer.secho("non-interactive session — pass model + quant explicitly", fg=typer.colors.RED, err=True)
|
|
309
|
+
raise typer.Exit(4)
|
|
310
|
+
try:
|
|
311
|
+
pick = picker.select(title, labels, default=default)
|
|
312
|
+
except picker.Cancelled:
|
|
313
|
+
typer.secho("cancelled — active config unchanged", fg=typer.colors.YELLOW, err=True)
|
|
314
|
+
raise typer.Exit(1)
|
|
315
|
+
return pool[labels.index(pick)]
|
|
316
|
+
|
|
317
|
+
|
|
318
|
+
@model_app.command("use")
|
|
319
|
+
def model_use(
|
|
320
|
+
model: Optional[str] = typer.Argument(None, help="vendor/Model, e.g. Qwen3.8-27B (omit to pick interactively)"),
|
|
321
|
+
quant: Optional[str] = typer.Argument(None, help="config name, e.g. NVFP4_FA4hd256 (omit to pick interactively)"),
|
|
322
|
+
):
|
|
323
|
+
"""Remember the active model/config (bare ``mjolnir model`` or no args
|
|
324
|
+
here = arrow-key picker over configs: repo configs/ + user-local
|
|
325
|
+
~/.local/share/mjolnir/configs).
|
|
326
|
+
|
|
327
|
+
Persisted to a small state file ($MJOLNIR_STATE, default
|
|
328
|
+
~/.mjolnir-state.json) — not an env file."""
|
|
329
|
+
layout = load_layout()
|
|
330
|
+
entries = scan_all_configs(layout)
|
|
331
|
+
if not entries:
|
|
332
|
+
typer.secho(
|
|
333
|
+
f"no configs found under {configs_root()} or {user_configs_dir()}",
|
|
334
|
+
fg=typer.colors.RED,
|
|
335
|
+
err=True,
|
|
336
|
+
)
|
|
337
|
+
raise typer.Exit(4)
|
|
338
|
+
st = _load_state()
|
|
339
|
+
|
|
340
|
+
if model and quant:
|
|
341
|
+
if not any(e.model == model and e.quant == quant for e in entries):
|
|
342
|
+
typer.secho(
|
|
343
|
+
f"config not found: {model}/{quant} (available: mjolnir model list)", fg=typer.colors.RED, err=True
|
|
344
|
+
)
|
|
345
|
+
raise typer.Exit(4)
|
|
346
|
+
chosen = next(e for e in entries if e.model == model and e.quant == quant)
|
|
347
|
+
elif model or quant:
|
|
348
|
+
candidates = [e for e in entries if (not model or e.model == model) and (not quant or e.quant == quant)]
|
|
349
|
+
if not candidates:
|
|
350
|
+
typer.secho(
|
|
351
|
+
"no config matches the given model/quant (available: mjolnir model list)", fg=typer.colors.RED, err=True
|
|
352
|
+
)
|
|
353
|
+
raise typer.Exit(4)
|
|
354
|
+
if len(candidates) == 1:
|
|
355
|
+
chosen = candidates[0]
|
|
356
|
+
else:
|
|
357
|
+
chosen = _pick_config(entries, st, candidates)
|
|
358
|
+
else:
|
|
359
|
+
chosen = _pick_config(entries, st)
|
|
360
|
+
|
|
361
|
+
_save_state({"model": chosen.model, "quant": chosen.quant})
|
|
362
|
+
typer.secho(
|
|
363
|
+
f"active: {chosen.model} / {chosen.quant} (→ {BACKEND_LABELS.get(chosen.quant, chosen.quant)})",
|
|
364
|
+
fg=typer.colors.GREEN,
|
|
365
|
+
)
|
|
366
|
+
typer.echo(f" state: {_state_file()}")
|
|
367
|
+
|
|
368
|
+
|
|
369
|
+
@model_app.command("list")
|
|
370
|
+
def model_list(as_json: bool = typer.Option(False, "--json", "-j", help="emit JSON (for scripts)")):
|
|
371
|
+
"""List every config (repo configs/ + the user-local dir; ● = the active
|
|
372
|
+
one; the state file is printed below the table)."""
|
|
373
|
+
layout = load_layout()
|
|
374
|
+
entries = scan_all_configs(layout)
|
|
375
|
+
st = _load_state()
|
|
376
|
+
if as_json:
|
|
377
|
+
typer.echo(
|
|
378
|
+
json.dumps(
|
|
379
|
+
{
|
|
380
|
+
"active": {"model": st.get("model"), "quant": st.get("quant")},
|
|
381
|
+
"state_file": str(_state_file()),
|
|
382
|
+
"local_configs_dir": str(user_configs_dir()),
|
|
383
|
+
"configs": [
|
|
384
|
+
{
|
|
385
|
+
"model": e.model,
|
|
386
|
+
"quant": e.quant,
|
|
387
|
+
"backend": e.backend,
|
|
388
|
+
"source": e.source,
|
|
389
|
+
"path": str(e.path),
|
|
390
|
+
}
|
|
391
|
+
for e in entries
|
|
392
|
+
],
|
|
393
|
+
},
|
|
394
|
+
indent=2,
|
|
395
|
+
)
|
|
396
|
+
)
|
|
397
|
+
return
|
|
398
|
+
if not entries:
|
|
399
|
+
typer.secho("no configs found", fg=typer.colors.YELLOW)
|
|
400
|
+
return
|
|
401
|
+
_console.print(_config_table(entries, st.get("model"), st.get("quant")))
|
|
402
|
+
if any(e.source == "local" for e in entries):
|
|
403
|
+
typer.echo(f" local = {user_configs_dir()}")
|
|
404
|
+
if st.get("model"):
|
|
405
|
+
typer.secho(f" ● active: {st['model']} / {st['quant']}", fg=typer.colors.GREEN)
|
|
406
|
+
typer.echo(f" state: {_state_file()} (change: mjolnir model)")
|
|
407
|
+
else:
|
|
408
|
+
typer.echo(" no active config yet — run 'mjolnir model' to pick one")
|
|
409
|
+
|
|
410
|
+
|
|
411
|
+
# ── image ───────────────────────────────────────────────────────────────────
|
|
412
|
+
|
|
413
|
+
|
|
414
|
+
def _local_images() -> list[str]:
|
|
415
|
+
"""Every local docker image (``repo:tag``), deduped, order preserved."""
|
|
416
|
+
try:
|
|
417
|
+
p = subprocess.run(
|
|
418
|
+
["docker", "images", "--format", "{{.Repository}}:{{.Tag}}"], capture_output=True, text=True, check=True
|
|
419
|
+
)
|
|
420
|
+
except (subprocess.CalledProcessError, FileNotFoundError):
|
|
421
|
+
return []
|
|
422
|
+
out: list[str] = []
|
|
423
|
+
for line in p.stdout.splitlines():
|
|
424
|
+
line = line.strip()
|
|
425
|
+
if line and line != "<none>:<none>" and line not in out:
|
|
426
|
+
out.append(line)
|
|
427
|
+
return out
|
|
428
|
+
|
|
429
|
+
|
|
430
|
+
def _active_image(st: dict) -> str:
|
|
431
|
+
return st.get("image") or _settings(None, None, None, None).image
|
|
432
|
+
|
|
433
|
+
|
|
434
|
+
@image_app.command("use")
|
|
435
|
+
def image_use(image: Optional[str] = typer.Argument(None, help="docker image reference (omit to pick interactively)")):
|
|
436
|
+
"""Remember the active vLLM image (bare ``mjolnir image`` or no args
|
|
437
|
+
here = arrow-key picker over local docker images).
|
|
438
|
+
|
|
439
|
+
Persisted to the state file ($MJOLNIR_STATE, default
|
|
440
|
+
~/.mjolnir-state.json) — serve/bench pick it up from there."""
|
|
441
|
+
st = _load_state()
|
|
442
|
+
imgs = _local_images()
|
|
443
|
+
if not imgs:
|
|
444
|
+
typer.secho("no local docker images found — build or pull one first", fg=typer.colors.RED, err=True)
|
|
445
|
+
raise typer.Exit(4)
|
|
446
|
+
if image:
|
|
447
|
+
if image not in imgs:
|
|
448
|
+
typer.secho(
|
|
449
|
+
f"image '{image}' not found locally ({len(imgs)} image(s) available: mjolnir image list)",
|
|
450
|
+
fg=typer.colors.RED,
|
|
451
|
+
err=True,
|
|
452
|
+
)
|
|
453
|
+
raise typer.Exit(4)
|
|
454
|
+
chosen = image
|
|
455
|
+
else:
|
|
456
|
+
if not sys.stdin.isatty():
|
|
457
|
+
for i in imgs:
|
|
458
|
+
typer.echo(f" {'●' if i == _active_image(st) else ' '}{i}")
|
|
459
|
+
typer.secho("non-interactive session — pass the image explicitly", fg=typer.colors.RED, err=True)
|
|
460
|
+
raise typer.Exit(4)
|
|
461
|
+
try:
|
|
462
|
+
chosen = picker.select("Pick the vLLM image:", imgs, default=_active_image(st))
|
|
463
|
+
except picker.Cancelled:
|
|
464
|
+
typer.secho("cancelled — active image unchanged", fg=typer.colors.YELLOW, err=True)
|
|
465
|
+
raise typer.Exit(1)
|
|
466
|
+
_save_state({"image": chosen})
|
|
467
|
+
typer.secho(f"active image: {chosen}", fg=typer.colors.GREEN)
|
|
468
|
+
typer.echo(f" state: {_state_file()}")
|
|
469
|
+
|
|
470
|
+
|
|
471
|
+
@image_app.command("list")
|
|
472
|
+
def image_list():
|
|
473
|
+
"""List local docker images (● = the active one)."""
|
|
474
|
+
st = _load_state()
|
|
475
|
+
active = _active_image(st)
|
|
476
|
+
for i in _local_images():
|
|
477
|
+
if i == active:
|
|
478
|
+
typer.secho(f" ● {i} (active)", fg=typer.colors.GREEN)
|
|
479
|
+
else:
|
|
480
|
+
typer.echo(f" {i}")
|
|
481
|
+
|
|
482
|
+
|
|
483
|
+
@image_app.command("build")
|
|
484
|
+
def image_build(
|
|
485
|
+
tag: Optional[str] = typer.Option(
|
|
486
|
+
None,
|
|
487
|
+
"--tag",
|
|
488
|
+
help=f"image tag (default: {DEFAULT_IMAGE} — the repo + the vLLM version the Dockerfile builds on)",
|
|
489
|
+
),
|
|
490
|
+
dry_run: bool = typer.Option(False, "--dry-run"),
|
|
491
|
+
):
|
|
492
|
+
"""Build the Mjolnir vLLM image: the patch stack + the mjolnir-kernel
|
|
493
|
+
package from this checkout (repo-root build context).
|
|
494
|
+
|
|
495
|
+
The default tag is always the canonical one (IMAGE_REPO:v<vLLM version>),
|
|
496
|
+
never the remembered active image — that may be an older build."""
|
|
497
|
+
tag = tag or DEFAULT_IMAGE
|
|
498
|
+
try:
|
|
499
|
+
root = load_layout().require_repo("image build")
|
|
500
|
+
except FileNotFoundError as e:
|
|
501
|
+
typer.secho(f"{e} — or pull the published image: docker pull {DEFAULT_IMAGE}", fg=typer.colors.RED, err=True)
|
|
502
|
+
raise typer.Exit(4)
|
|
503
|
+
rev = subprocess.run(["git", "-C", str(root), "rev-parse", "--short", "HEAD"], capture_output=True, text=True)
|
|
504
|
+
cmd = ["docker", "build", "-f", str(root / "docker" / "vllm-thor" / "Dockerfile"), "-t", tag]
|
|
505
|
+
if rev.returncode == 0 and rev.stdout.strip():
|
|
506
|
+
cmd += ["--build-arg", f"MJOLNIR_REVISION={rev.stdout.strip()}"]
|
|
507
|
+
cmd.append(str(root))
|
|
508
|
+
if dry_run:
|
|
509
|
+
print(" ".join(cmd))
|
|
510
|
+
return
|
|
511
|
+
rc = subprocess.call(cmd)
|
|
512
|
+
if rc == 0:
|
|
513
|
+
typer.secho(f"built {tag} — canary: mjolnir image gates --image {tag}", fg=typer.colors.GREEN)
|
|
514
|
+
active = _settings(None, None, None, None).image
|
|
515
|
+
if active != tag:
|
|
516
|
+
typer.secho(
|
|
517
|
+
f"note: the active image is {active} — make the new build active: mjolnir image use {tag}",
|
|
518
|
+
fg=typer.colors.YELLOW,
|
|
519
|
+
)
|
|
520
|
+
raise typer.Exit(rc)
|
|
521
|
+
|
|
522
|
+
|
|
523
|
+
@image_app.command("gates")
|
|
524
|
+
def image_gates(image: Optional[str] = typer.Option(None, "--image"), dry_run: bool = typer.Option(False, "--dry-run")):
|
|
525
|
+
"""Run the sm_110 gate-probe canaries against the image (fresh container,
|
|
526
|
+
no server needed)."""
|
|
527
|
+
s = _settings(None, None, image, None)
|
|
528
|
+
raise typer.Exit(tasks.run_task(s, "gates", [], dry_run=dry_run))
|
|
529
|
+
|
|
530
|
+
|
|
531
|
+
# ── litellm ─────────────────────────────────────────────────────────────────
|
|
532
|
+
|
|
533
|
+
|
|
534
|
+
@litellm_app.command("up")
|
|
535
|
+
def litellm_up(
|
|
536
|
+
model: Optional[str] = typer.Option(None, "--model"),
|
|
537
|
+
config: Optional[str] = typer.Option(None, "--config", help="config quant name, e.g. NVFP4_FA4hd256"),
|
|
538
|
+
port: Optional[int] = typer.Option(
|
|
539
|
+
None, "--port", help=f"host port of the proxy (default {litellm.DEFAULT_PROXY_PORT})"
|
|
540
|
+
),
|
|
541
|
+
wait: bool = typer.Option(True, "--wait/--no-wait", help="wait for the proxy's liveliness before returning"),
|
|
542
|
+
wait_timeout: float = typer.Option(600.0, "--wait-timeout"),
|
|
543
|
+
dry_run: bool = typer.Option(False, "--dry-run"),
|
|
544
|
+
):
|
|
545
|
+
"""Render the config from your template (in the litellm data dir,
|
|
546
|
+
~/.local/share/mjolnir/litellm/) with the currently selected
|
|
547
|
+
model/config, and start the stack (proxy + postgres + redis). The vLLM
|
|
548
|
+
server is reached via the host — it may be up or down."""
|
|
549
|
+
s = _settings(model, config, None, None)
|
|
550
|
+
try:
|
|
551
|
+
litellm.up(s, proxy_port=port, wait=wait, wait_timeout_s=wait_timeout, dry_run=dry_run)
|
|
552
|
+
except (litellm.LitellmError, FileNotFoundError) as e:
|
|
553
|
+
typer.secho(str(e), fg=typer.colors.RED, err=True)
|
|
554
|
+
raise typer.Exit(4 if "not found" in str(e) else 1)
|
|
555
|
+
typer.secho("\n proxy: mjolnir litellm status", fg=typer.colors.GREEN)
|
|
556
|
+
|
|
557
|
+
|
|
558
|
+
@litellm_app.command("down")
|
|
559
|
+
def litellm_down(
|
|
560
|
+
purge: bool = typer.Option(False, "--purge", help="also wipe the stack data (postgres + redis)"),
|
|
561
|
+
dry_run: bool = typer.Option(False, "--dry-run"),
|
|
562
|
+
):
|
|
563
|
+
"""Stop the stack (the vLLM server keeps running; data kept by
|
|
564
|
+
default, --purge wipes postgres + redis state)."""
|
|
565
|
+
raise typer.Exit(litellm.down(purge=purge, dry_run=dry_run))
|
|
566
|
+
|
|
567
|
+
|
|
568
|
+
@litellm_app.command("status")
|
|
569
|
+
def litellm_status(port: Optional[int] = typer.Option(None, "--port")):
|
|
570
|
+
"""Container state + proxy liveliness + the rendered config path."""
|
|
571
|
+
s = _settings(None, None, None, None)
|
|
572
|
+
try:
|
|
573
|
+
st = litellm.status(s, port)
|
|
574
|
+
except FileNotFoundError as e:
|
|
575
|
+
typer.secho(str(e), fg=typer.colors.RED, err=True)
|
|
576
|
+
raise typer.Exit(4)
|
|
577
|
+
typer.echo(json.dumps(st, indent=2))
|
|
578
|
+
|
|
579
|
+
|
|
580
|
+
@litellm_app.command("logs")
|
|
581
|
+
def litellm_logs(follow: bool = typer.Option(False, "--follow", "-f"), tail: int = typer.Option(100, "--tail")):
|
|
582
|
+
"""Tail the proxy container logs."""
|
|
583
|
+
raise typer.Exit(litellm.logs(follow=follow, tail=tail))
|
|
584
|
+
|
|
585
|
+
|
|
586
|
+
@litellm_app.command("config")
|
|
587
|
+
def litellm_config(
|
|
588
|
+
model: Optional[str] = typer.Option(None, "--model"),
|
|
589
|
+
config: Optional[str] = typer.Option(None, "--config"),
|
|
590
|
+
show: bool = typer.Option(False, "--show", help="print the rendered config instead of just validating it"),
|
|
591
|
+
):
|
|
592
|
+
"""Render the template with the current model/config (no containers) —
|
|
593
|
+
validates the variables and the YAML."""
|
|
594
|
+
s = _settings(model, config, None, None)
|
|
595
|
+
try:
|
|
596
|
+
p = litellm.render_config(s)
|
|
597
|
+
except (litellm.LitellmError, FileNotFoundError) as e:
|
|
598
|
+
typer.secho(str(e), fg=typer.colors.RED, err=True)
|
|
599
|
+
raise typer.Exit(4 if "not found" in str(e) else 1)
|
|
600
|
+
typer.secho(f"rendered ok: {p} (model: {s.served_model})", fg=typer.colors.GREEN)
|
|
601
|
+
if show:
|
|
602
|
+
typer.echo(p.read_text())
|
|
603
|
+
|
|
604
|
+
|
|
605
|
+
# ── perses ──────────────────────────────────────────────────────────────────
|
|
606
|
+
|
|
607
|
+
|
|
608
|
+
@perses_app.command("up")
|
|
609
|
+
def perses_up(
|
|
610
|
+
port: Optional[int] = typer.Option(
|
|
611
|
+
None, "--port", help=f"host port of the Perses UI (default {perses.DEFAULT_PORT})"
|
|
612
|
+
),
|
|
613
|
+
wait: bool = typer.Option(True, "--wait/--no-wait", help="wait for the Perses API before returning"),
|
|
614
|
+
wait_timeout: float = typer.Option(300.0, "--wait-timeout"),
|
|
615
|
+
dry_run: bool = typer.Option(False, "--dry-run"),
|
|
616
|
+
):
|
|
617
|
+
"""Start the observability stack (prometheus + perses) with the two vLLM
|
|
618
|
+
dashboards provisioned. The vLLM scrape target = the resolved server port
|
|
619
|
+
(state/$MJOLNIR_PORT — the same one `mjolnir serve` uses; override with
|
|
620
|
+
PERSER_VLLM_TARGET in the perses env file). The server may be up or down."""
|
|
621
|
+
s = _settings(None, None, None, None)
|
|
622
|
+
try:
|
|
623
|
+
perses.up(s, port=port, wait=wait, wait_timeout_s=wait_timeout, dry_run=dry_run)
|
|
624
|
+
except (perses.PersesError, FileNotFoundError) as e:
|
|
625
|
+
typer.secho(str(e), fg=typer.colors.RED, err=True)
|
|
626
|
+
raise typer.Exit(4 if "not found" in str(e) else 1)
|
|
627
|
+
typer.secho(
|
|
628
|
+
f"\n dashboards: mjolnir perses status (UI: http://127.0.0.1:{port or perses.DEFAULT_PORT})",
|
|
629
|
+
fg=typer.colors.GREEN,
|
|
630
|
+
)
|
|
631
|
+
|
|
632
|
+
|
|
633
|
+
@perses_app.command("down")
|
|
634
|
+
def perses_down(
|
|
635
|
+
purge: bool = typer.Option(False, "--purge", help="also wipe the stack data (prometheus TSDB + perses db)"),
|
|
636
|
+
dry_run: bool = typer.Option(False, "--dry-run"),
|
|
637
|
+
):
|
|
638
|
+
"""Stop the stack (the vLLM server keeps running; data kept by default,
|
|
639
|
+
--purge wipes the prometheus TSDB + the perses db)."""
|
|
640
|
+
raise typer.Exit(perses.down(purge=purge, dry_run=dry_run))
|
|
641
|
+
|
|
642
|
+
|
|
643
|
+
@perses_app.command("status")
|
|
644
|
+
def perses_status(port: Optional[int] = typer.Option(None, "--port")):
|
|
645
|
+
"""Container state + API health + the vLLM scrape target + the data dir."""
|
|
646
|
+
s = _settings(None, None, None, None)
|
|
647
|
+
typer.echo(json.dumps(perses.status(s, port), indent=2))
|
|
648
|
+
|
|
649
|
+
|
|
650
|
+
@perses_app.command("logs")
|
|
651
|
+
def perses_logs(
|
|
652
|
+
follow: bool = typer.Option(False, "--follow", "-f"),
|
|
653
|
+
tail: int = typer.Option(100, "--tail"),
|
|
654
|
+
service: Optional[str] = typer.Option(None, "--service", help="perses (default) | prometheus"),
|
|
655
|
+
):
|
|
656
|
+
"""Tail a stack service's logs (default: perses)."""
|
|
657
|
+
raise typer.Exit(perses.logs(follow=follow, tail=tail, service=service))
|
|
658
|
+
|
|
659
|
+
|
|
660
|
+
# ── hw ─────────────────────────────────────────────────────────────────────
|
|
661
|
+
|
|
662
|
+
|
|
663
|
+
@hw_app.command("setup")
|
|
664
|
+
def hw_setup(
|
|
665
|
+
yes: bool = typer.Option(False, "--yes", "-y", help="don't prompt to continue"),
|
|
666
|
+
upgrade: bool = typer.Option(
|
|
667
|
+
True,
|
|
668
|
+
"--upgrade/--no-upgrade",
|
|
669
|
+
help="apt update + full-upgrade (default on — the original script's NVIDIA-carrier path)",
|
|
670
|
+
),
|
|
671
|
+
keep_gui: bool = typer.Option(
|
|
672
|
+
False, "--keep-gui", help="keep the graphical boot target (default: headless multi-user)"
|
|
673
|
+
),
|
|
674
|
+
fan_profile: str = typer.Option(
|
|
675
|
+
"recommended",
|
|
676
|
+
"--fan-profile",
|
|
677
|
+
help="recommended (default) | max | cool | quiet — recommended/max are installed by this step if missing",
|
|
678
|
+
),
|
|
679
|
+
swap_size: int = typer.Option(32, "--swap-size", help="swap file size in GB (default 32 — the 128 GB Thor SoM)"),
|
|
680
|
+
skip: str = typer.Option(
|
|
681
|
+
"", "--skip", help="comma-separated steps to skip: gui,upgrade,pip,docker,jtop,memory,fan,host,clocks,power"
|
|
682
|
+
),
|
|
683
|
+
reboot: bool = typer.Option(False, "--reboot", help="reboot at the end if a step required one (non-interactive)"),
|
|
684
|
+
dry_run: bool = typer.Option(False, "--dry-run", help="print the plan, change nothing"),
|
|
685
|
+
):
|
|
686
|
+
"""Pre-configure the Thor host (port of the ~/thor HW setup script).
|
|
687
|
+
|
|
688
|
+
Runs ``scripts/hw/setup-thor.sh``: headless boot target, apt upgrade,
|
|
689
|
+
Docker + NVIDIA default runtime, pip, jtop, 32 GB swap (zRAM off),
|
|
690
|
+
the fan profiles (``recommended`` + ``max`` installed per-profile
|
|
691
|
+
gated, with the .bck rollback backup; default selected —
|
|
692
|
+
``recommended`` by default), service cleanup, locked max clocks,
|
|
693
|
+
MAXN power mode. Idempotent — safe to re-run; the script asks for
|
|
694
|
+
sudo."""
|
|
695
|
+
try:
|
|
696
|
+
rc = thor.run_setup(
|
|
697
|
+
yes=yes,
|
|
698
|
+
upgrade=upgrade,
|
|
699
|
+
keep_gui=keep_gui,
|
|
700
|
+
fan_profile=fan_profile,
|
|
701
|
+
swap_size=swap_size,
|
|
702
|
+
skip=skip,
|
|
703
|
+
reboot=reboot,
|
|
704
|
+
dry_run=dry_run,
|
|
705
|
+
)
|
|
706
|
+
except (ValueError, FileNotFoundError) as e:
|
|
707
|
+
typer.secho(str(e), fg=typer.colors.RED, err=True)
|
|
708
|
+
raise typer.Exit(4)
|
|
709
|
+
raise typer.Exit(rc)
|
|
710
|
+
|
|
711
|
+
|
|
712
|
+
@hw_app.command("status")
|
|
713
|
+
def hw_status():
|
|
714
|
+
"""Installed fan profiles and the selected default (read-only, no sudo).
|
|
715
|
+
|
|
716
|
+
The daemon's live state (which profile it is actually running) needs
|
|
717
|
+
sudo — check it with `sudo nvfancontrol -q`."""
|
|
718
|
+
conf = Path(os.environ.get("MJOLNIR_NVFANCONF", "/etc/nvfancontrol.conf"))
|
|
719
|
+
if not conf.exists():
|
|
720
|
+
typer.secho(f"no conf at {conf}", fg=typer.colors.RED, err=True)
|
|
721
|
+
raise typer.Exit(4)
|
|
722
|
+
text = conf.read_text()
|
|
723
|
+
profiles = re.findall(r"^[ \t]*FAN_PROFILE\s+(\w+)", text, re.M)
|
|
724
|
+
m = re.search(r"^[ \t]*FAN_DEFAULT_PROFILE\s+(\S+)", text, re.M)
|
|
725
|
+
bck = conf.with_name(conf.name + ".bck")
|
|
726
|
+
print(f"conf: {conf}")
|
|
727
|
+
print(f"profiles: {', '.join(profiles)}")
|
|
728
|
+
print(f"default: {m.group(1) if m else '<unset>'}")
|
|
729
|
+
print(f"backup: {bck}" + (" (exists)" if bck.exists() else " (missing)"))
|
|
730
|
+
|
|
731
|
+
|
|
732
|
+
# ── gate ────────────────────────────────────────────────────────────────────
|
|
733
|
+
|
|
734
|
+
|
|
735
|
+
@app.command()
|
|
736
|
+
def gate(
|
|
737
|
+
port: Optional[int] = typer.Option(None, "--port"),
|
|
738
|
+
once: bool = typer.Option(False, "--once", help="check now and exit"),
|
|
739
|
+
confirm: int = typer.Option(6, "--confirm"),
|
|
740
|
+
timeout: float = typer.Option(3600.0, "--timeout"),
|
|
741
|
+
):
|
|
742
|
+
"""The clean-window gate (vLLM request queue 0/0 × N consecutive samples)."""
|
|
743
|
+
s = _settings(None, None, None, port)
|
|
744
|
+
from mjolnir_cli.tasks import _run_gate
|
|
745
|
+
|
|
746
|
+
args = ["--once"] if once else ["--confirm", str(confirm), "--timeout", str(timeout)]
|
|
747
|
+
raise typer.Exit(_run_gate(s, args, False))
|
|
748
|
+
|
|
749
|
+
|
|
750
|
+
# ── calibrate (fp6 KV scales) ───────────────────────────────────────────────
|
|
751
|
+
|
|
752
|
+
|
|
753
|
+
@app.command()
|
|
754
|
+
def calibrate(
|
|
755
|
+
model: Optional[str] = typer.Option(None, "--model", help="vendor/Model (default: the active one)"),
|
|
756
|
+
config: Optional[str] = typer.Option(None, "--config", help="the config to calibrate (default: the active one)"),
|
|
757
|
+
image: Optional[str] = typer.Option(None, "--image", help="the Mjolnir image"),
|
|
758
|
+
out: Optional[Path] = typer.Option(
|
|
759
|
+
None,
|
|
760
|
+
"--out",
|
|
761
|
+
help="default: the model's kv_scales.json next to the config dirs — kv_scales.json inside the config "
|
|
762
|
+
"dir for a config whose own drafter (DSpark) runs on Mjolnir",
|
|
763
|
+
),
|
|
764
|
+
prompts: int = typer.Option(48, "--prompts", help="short prompts"),
|
|
765
|
+
long: int = typer.Option(6, "--long", help="long-context prompts"),
|
|
766
|
+
max_model_len: int = typer.Option(32768, "--max-model-len"),
|
|
767
|
+
draft_vocab: bool = typer.Option(
|
|
768
|
+
False,
|
|
769
|
+
"--draft-vocab",
|
|
770
|
+
help="instead: count the target's generated tokens -> draft_vocab.json (VocabTrim, MJOLNIR_DRAFT_VOCAB)",
|
|
771
|
+
),
|
|
772
|
+
dry_run: bool = typer.Option(False, "--dry-run", help="print the docker command, run nothing"),
|
|
773
|
+
):
|
|
774
|
+
"""Calibrate per-(layer, KV head, channel) K/V scales for the fp6 (e2m3) KV cache
|
|
775
|
+
(or, with --draft-vocab, the drafter's vocabulary for VocabTrim).
|
|
776
|
+
|
|
777
|
+
Runs the config's model on fp8 KV in the Mjolnir image, records the
|
|
778
|
+
per-channel amax of every K/V written to the cache over a varied prompt set,
|
|
779
|
+
and writes ``kv_scales.json`` next to the config — what an
|
|
780
|
+
``kv-cache-dtype: fp6_e2m3`` config is served with. Needs the GPU to
|
|
781
|
+
itself: refuses while the vLLM server is up (stop it with
|
|
782
|
+
``mjolnir serve down`` in a window you own)."""
|
|
783
|
+
s = _settings(model, config, image, None)
|
|
784
|
+
if not dry_run and dockerctl.container_running():
|
|
785
|
+
typer.secho(
|
|
786
|
+
"the vLLM server is running — calibration loads the model itself. Stop the server "
|
|
787
|
+
"first (mjolnir serve down), in a window you own.",
|
|
788
|
+
fg=typer.colors.RED,
|
|
789
|
+
err=True,
|
|
790
|
+
)
|
|
791
|
+
raise typer.Exit(3)
|
|
792
|
+
target = out or (
|
|
793
|
+
dockerctl.kv_scales_host_path(s).with_name("draft_vocab.json")
|
|
794
|
+
if draft_vocab
|
|
795
|
+
else dockerctl.kv_scales_host_path(s)
|
|
796
|
+
)
|
|
797
|
+
if not os.access(target.parent if target.parent.exists() else Path.home(), os.W_OK):
|
|
798
|
+
from mjolnir_cli.config import user_configs_dir
|
|
799
|
+
|
|
800
|
+
target = user_configs_dir() / s.model / target.name
|
|
801
|
+
target.parent.mkdir(parents=True, exist_ok=True)
|
|
802
|
+
mounts = [(str(target.parent.resolve()), "/out")]
|
|
803
|
+
env = []
|
|
804
|
+
try: # a checkout's kernel package over the image's (calibrate.py may be newer)
|
|
805
|
+
from mjolnir_cli.config import find_repo_root
|
|
806
|
+
|
|
807
|
+
src = find_repo_root() / "src" / "mjolnir"
|
|
808
|
+
if (src / "mjolnir" / ("draftvocab.py" if draft_vocab else "calibrate.py")).exists():
|
|
809
|
+
mounts.append((str(src), "/opt/mjolnir-src:ro"))
|
|
810
|
+
env.append("PYTHONPATH=/opt/mjolnir-src")
|
|
811
|
+
except FileNotFoundError:
|
|
812
|
+
pass
|
|
813
|
+
cmd = (
|
|
814
|
+
f"python3 -m mjolnir.{'draftvocab' if draft_vocab else 'calibrate'} "
|
|
815
|
+
f"--config /configs/{s.model}/{s.quant}/config.yaml --out /out/{target.name} "
|
|
816
|
+
f"--download-dir /data/models/huggingface --max-model-len {max_model_len} "
|
|
817
|
+
f"--prompts {prompts} --long {long}"
|
|
818
|
+
)
|
|
819
|
+
argv = dockerctl.build_run_args(
|
|
820
|
+
s,
|
|
821
|
+
name="mjolnir-calibrate",
|
|
822
|
+
detach=False,
|
|
823
|
+
command=cmd,
|
|
824
|
+
extra_env=tuple(env),
|
|
825
|
+
extra_mounts=tuple(mounts),
|
|
826
|
+
chown=(f"/out/{target.name}",),
|
|
827
|
+
)
|
|
828
|
+
if dry_run:
|
|
829
|
+
typer.echo(" ".join(argv))
|
|
830
|
+
return
|
|
831
|
+
typer.secho(f"calibrating {s.model}/{s.quant} on {s.image} -> {target}", fg=typer.colors.CYAN)
|
|
832
|
+
rc = dockerctl.run_once(argv, "mjolnir-calibrate")
|
|
833
|
+
if rc == 0:
|
|
834
|
+
typer.secho(f"wrote {target}", fg=typer.colors.GREEN)
|
|
835
|
+
raise typer.Exit(rc)
|
|
836
|
+
|
|
837
|
+
|
|
838
|
+
# ── permissions / cache ─────────────────────────────────────────────────────
|
|
839
|
+
|
|
840
|
+
|
|
841
|
+
@app.command("grant-permissions")
|
|
842
|
+
def grant_permissions(
|
|
843
|
+
yes: bool = typer.Option(False, "--yes", "-y", help="don't prompt to confirm the install"),
|
|
844
|
+
):
|
|
845
|
+
"""Check for — and, if missing, install — the NOPASSWD sudo grant the
|
|
846
|
+
kernel benches use to clear the page cache.
|
|
847
|
+
|
|
848
|
+
Idempotent: a present grant is left untouched. The install adds only the
|
|
849
|
+
cache-clear command (``sysctl -w vm.drop_caches=3``) — never full sudo —
|
|
850
|
+
and asks for the sudo password exactly once."""
|
|
851
|
+
raise typer.Exit(cachectl.grant_drop_caches(assume_yes=yes))
|
|
852
|
+
|
|
853
|
+
|
|
854
|
+
@app.command("drop-caches")
|
|
855
|
+
def drop_caches():
|
|
856
|
+
"""Clear the host page cache (``sysctl -w vm.drop_caches=3``) for a clean
|
|
857
|
+
kernel-bench baseline. Uses the NOPASSWD grant (run
|
|
858
|
+
``mjolnir grant-permissions`` if it's missing); never prompts."""
|
|
859
|
+
if not cachectl.drop_caches_granted():
|
|
860
|
+
typer.secho(
|
|
861
|
+
"drop-caches grant missing — run: mjolnir grant-permissions",
|
|
862
|
+
fg=typer.colors.YELLOW,
|
|
863
|
+
err=True,
|
|
864
|
+
)
|
|
865
|
+
raise typer.Exit(4)
|
|
866
|
+
rc = cachectl.drop_caches()
|
|
867
|
+
if rc == 0:
|
|
868
|
+
typer.secho("page cache cleared (vm.drop_caches=3)", fg=typer.colors.GREEN)
|
|
869
|
+
else:
|
|
870
|
+
typer.secho(f"drop_caches failed (exit {rc})", fg=typer.colors.RED, err=True)
|
|
871
|
+
raise typer.Exit(rc)
|
|
872
|
+
|
|
873
|
+
|
|
874
|
+
# ── bench ───────────────────────────────────────────────────────────────────
|
|
875
|
+
|
|
876
|
+
|
|
877
|
+
@bench_app.command("perf")
|
|
878
|
+
def bench_perf(
|
|
879
|
+
runs: int = typer.Option(6, "--runs", help="measured runs per cell (llama-benchy --runs)"),
|
|
880
|
+
warmup_runs: int = typer.Option(2, "--warmup-runs"),
|
|
881
|
+
repeat: int = typer.Option(1, "--repeat", help="independent gated sweeps (default: one sweep, 6 runs per cell)"),
|
|
882
|
+
depths: str = typer.Option(",".join(map(str, E2E_DEPTHS)), "--depths", help="context depths"),
|
|
883
|
+
concurrency: str = typer.Option("1,2,4", "--concurrency"),
|
|
884
|
+
pp: int = typer.Option(2048, "--pp"),
|
|
885
|
+
tg: int = typer.Option(128, "--tg"),
|
|
886
|
+
exact_tg: bool = typer.Option(
|
|
887
|
+
True, "--exact-tg/--no-exact-tg", help="pin output length to --tg (kills EOS variance)"
|
|
888
|
+
),
|
|
889
|
+
gate: bool = typer.Option(True, "--gate/--no-gate"),
|
|
890
|
+
confirm: int = typer.Option(3, "--confirm", help="clean-window confirm samples"),
|
|
891
|
+
gate_timeout: float = typer.Option(3600.0, "--gate-timeout"),
|
|
892
|
+
label: Optional[str] = typer.Option(None, "--label", help="backend label for the history log"),
|
|
893
|
+
api_key: Optional[str] = typer.Option(None, "--api-key"),
|
|
894
|
+
model: Optional[str] = typer.Option(None, "--model"),
|
|
895
|
+
config: Optional[str] = typer.Option(None, "--config"),
|
|
896
|
+
image: Optional[str] = typer.Option(None, "--image"),
|
|
897
|
+
port: Optional[int] = typer.Option(None, "--port"),
|
|
898
|
+
plot: bool = typer.Option(True, "--plot/--no-plot"),
|
|
899
|
+
):
|
|
900
|
+
"""End-to-end perf bench (gated clean window, raw JSON, history log)."""
|
|
901
|
+
s = _settings(model, config, image, port)
|
|
902
|
+
layout = load_layout()
|
|
903
|
+
|
|
904
|
+
def split_ints(v):
|
|
905
|
+
return [int(x) for x in str(v).split(",") if x.strip()]
|
|
906
|
+
|
|
907
|
+
try:
|
|
908
|
+
benchy.run_perf(
|
|
909
|
+
s,
|
|
910
|
+
layout,
|
|
911
|
+
runs=runs,
|
|
912
|
+
warmup_runs=warmup_runs,
|
|
913
|
+
exact_tg=exact_tg,
|
|
914
|
+
repeat=repeat,
|
|
915
|
+
depths=split_ints(depths),
|
|
916
|
+
concurrency=split_ints(concurrency),
|
|
917
|
+
pp=pp,
|
|
918
|
+
tg=tg,
|
|
919
|
+
gate=gate,
|
|
920
|
+
confirm=confirm,
|
|
921
|
+
gate_timeout_s=gate_timeout,
|
|
922
|
+
api_key=api_key,
|
|
923
|
+
label=label,
|
|
924
|
+
)
|
|
925
|
+
except benchy.BenchError as e:
|
|
926
|
+
typer.secho(str(e), fg=typer.colors.RED, err=True)
|
|
927
|
+
raise typer.Exit(1)
|
|
928
|
+
if plot:
|
|
929
|
+
try:
|
|
930
|
+
charts.render_all(layout)
|
|
931
|
+
except RuntimeError as e:
|
|
932
|
+
typer.secho(f"(plots skipped: {e})", fg=typer.colors.YELLOW, err=True)
|
|
933
|
+
try:
|
|
934
|
+
readme.render_readme(layout)
|
|
935
|
+
except (RuntimeError, FileNotFoundError) as e:
|
|
936
|
+
typer.secho(f"(readme skipped: {e})", fg=typer.colors.YELLOW, err=True)
|
|
937
|
+
|
|
938
|
+
|
|
939
|
+
def _ab_legs(
|
|
940
|
+
specs: str, default_model: str, default_image: str, entries: list[ConfigEntry]
|
|
941
|
+
) -> list[tuple[str, str, str]]:
|
|
942
|
+
"""Parse ``--backends`` into ``(model, image, quant)`` legs.
|
|
943
|
+
|
|
944
|
+
Each element is one of:
|
|
945
|
+
* ``<config>`` — the default model's config, served from
|
|
946
|
+
``default_image``;
|
|
947
|
+
* ``<image>:<config>`` — the default model's config pinned to an
|
|
948
|
+
image (the last ``:`` splits, so the image
|
|
949
|
+
may carry its own registry port / tag);
|
|
950
|
+
* ``<model>/<config>`` — a different model (``vendor/Model``) with
|
|
951
|
+
its config, served from ``default_image``;
|
|
952
|
+
* ``<image>:<model>/<config>`` — the same, with the image pinned too
|
|
953
|
+
(the last ``:`` splits the image off).
|
|
954
|
+
"""
|
|
955
|
+
quants = {e.quant for e in entries if e.model == default_model}
|
|
956
|
+
model_quants = {(e.model, e.quant) for e in entries}
|
|
957
|
+
legs: list[tuple[str, str, str]] = []
|
|
958
|
+
for spec in (b.strip() for b in specs.split(",")):
|
|
959
|
+
if not spec:
|
|
960
|
+
continue
|
|
961
|
+
if ":" in spec:
|
|
962
|
+
image, _, spec = spec.rpartition(":")
|
|
963
|
+
if not image.strip():
|
|
964
|
+
raise ValueError(f"bad leg '{spec}' — expected '<config>', '<model>/<config>' or '<image>:<config>'")
|
|
965
|
+
else:
|
|
966
|
+
image = None
|
|
967
|
+
if "/" in spec:
|
|
968
|
+
model, _, quant = spec.rpartition("/")
|
|
969
|
+
if not model or not quant or model.count("/") != 1:
|
|
970
|
+
raise ValueError(f"bad leg '{spec}' — expected '<config>', '<model>/<config>' or '<image>:<config>'")
|
|
971
|
+
if (model, quant) not in model_quants:
|
|
972
|
+
raise ValueError(f"unknown model config '{model}/{quant}' — available configs: mjolnir model list")
|
|
973
|
+
legs.append((model, image or default_image, quant))
|
|
974
|
+
else:
|
|
975
|
+
if spec not in quants:
|
|
976
|
+
raise ValueError(
|
|
977
|
+
f"unknown config '{spec}' for model {default_model} — available configs: mjolnir model list"
|
|
978
|
+
)
|
|
979
|
+
legs.append((default_model, image or default_image, spec))
|
|
980
|
+
if not legs:
|
|
981
|
+
raise ValueError("--backends is empty")
|
|
982
|
+
return legs
|
|
983
|
+
|
|
984
|
+
|
|
985
|
+
@bench_app.command("ab")
|
|
986
|
+
def bench_ab(
|
|
987
|
+
backends: str = typer.Option(
|
|
988
|
+
legs_.bench_legs("bench.ab"),
|
|
989
|
+
"--backends",
|
|
990
|
+
help="comma-separated legs: each is "
|
|
991
|
+
"'<config>' (the --model/state "
|
|
992
|
+
"model, served from --image), "
|
|
993
|
+
"'<image>:<config>', "
|
|
994
|
+
"'<model>/<config>' or "
|
|
995
|
+
"'<image>:<model>/<config>' — "
|
|
996
|
+
"available: mjolnir model list",
|
|
997
|
+
),
|
|
998
|
+
depths: str = typer.Option(
|
|
999
|
+
",".join(map(str, E2E_DEPTHS)), "--depths", help="context depths (tokens already in the KV)"
|
|
1000
|
+
),
|
|
1001
|
+
runs: int = typer.Option(6, "--runs"),
|
|
1002
|
+
repeat: int = typer.Option(1, "--repeat"),
|
|
1003
|
+
gate: bool = typer.Option(True, "--gate/--no-gate"),
|
|
1004
|
+
model: Optional[str] = typer.Option(None, "--model"),
|
|
1005
|
+
image: Optional[str] = typer.Option(
|
|
1006
|
+
None, "--image", help="image for bare-config legs (default: $MJOLNIR_IMAGE / built-in default)"
|
|
1007
|
+
),
|
|
1008
|
+
port: Optional[int] = typer.Option(None, "--port"),
|
|
1009
|
+
):
|
|
1010
|
+
"""Headline A/B: restart the server per leg, gated perf bench for each,
|
|
1011
|
+
one history row-set per leg.
|
|
1012
|
+
|
|
1013
|
+
Each leg is a model config (quant), optionally model-qualified and/or
|
|
1014
|
+
image-pinned: '<config>', '<image>:<config>', '<model>/<config>' or
|
|
1015
|
+
'<image>:<model>/<config>'. The image carries the backend (kernel stack
|
|
1016
|
+
is baked in); the config (configs/<model>/<quant>/config.yaml, repo or local)
|
|
1017
|
+
carries the serving parameters. Bare configs use --model/--image.
|
|
1018
|
+
Compare images on one model and/or models on one image. Charts
|
|
1019
|
+
re-render at the end."""
|
|
1020
|
+
s = _settings(model, None, image, port)
|
|
1021
|
+
layout = load_layout()
|
|
1022
|
+
entries = scan_all_configs(layout)
|
|
1023
|
+
try:
|
|
1024
|
+
legs = _ab_legs(backends, s.model, s.image, entries)
|
|
1025
|
+
except ValueError as e:
|
|
1026
|
+
typer.secho(str(e), fg=typer.colors.RED, err=True)
|
|
1027
|
+
raise typer.Exit(4)
|
|
1028
|
+
from mjolnir_cli import e2e
|
|
1029
|
+
|
|
1030
|
+
for m_, img, q in legs:
|
|
1031
|
+
if q != DEFAULT_QUANT and e2e.is_mjolnir({"image": img}):
|
|
1032
|
+
typer.secho(
|
|
1033
|
+
f"note: {q} on {img} is not native vLLM (the Mjolnir image patches FlashInfer too) — it is kept in "
|
|
1034
|
+
f"history but never charted; measure native backends on {STOCK_IMAGE}",
|
|
1035
|
+
fg=typer.colors.YELLOW,
|
|
1036
|
+
)
|
|
1037
|
+
_run_legs(
|
|
1038
|
+
s,
|
|
1039
|
+
layout,
|
|
1040
|
+
[(m, i, q, None) for m, i, q in legs],
|
|
1041
|
+
runs=runs,
|
|
1042
|
+
repeat=repeat,
|
|
1043
|
+
gate=gate,
|
|
1044
|
+
depths=[int(x) for x in depths.replace(" ", ",").split(",") if x.strip()],
|
|
1045
|
+
)
|
|
1046
|
+
_rerender(layout)
|
|
1047
|
+
|
|
1048
|
+
|
|
1049
|
+
@bench_app.command(
|
|
1050
|
+
"accuracy",
|
|
1051
|
+
context_settings={**_HELP_CTX, "allow_extra_args": True, "ignore_unknown_options": True},
|
|
1052
|
+
)
|
|
1053
|
+
def bench_accuracy(
|
|
1054
|
+
ctx: typer.Context,
|
|
1055
|
+
backends: str = typer.Option(
|
|
1056
|
+
legs_.bench_legs("bench.accuracy"),
|
|
1057
|
+
"--backends",
|
|
1058
|
+
help="comma-separated legs, as in 'bench ab': '<config>', '<image>:<config>', '<model>/<config>' or "
|
|
1059
|
+
"'<image>:<model>/<config>' (default: mjolnir_cli.legs)",
|
|
1060
|
+
),
|
|
1061
|
+
restart: bool = typer.Option(
|
|
1062
|
+
True,
|
|
1063
|
+
"--restart/--no-restart",
|
|
1064
|
+
help="serve each leg (restarts the server; the GPU must be yours) — --no-restart measures the running "
|
|
1065
|
+
"server as one leg (pass its config with --backends)",
|
|
1066
|
+
),
|
|
1067
|
+
model: Optional[str] = typer.Option(None, "--model"),
|
|
1068
|
+
image: Optional[str] = typer.Option(None, "--image", help="image for bare-config legs"),
|
|
1069
|
+
port: Optional[int] = typer.Option(None, "--port"),
|
|
1070
|
+
dry_run: bool = typer.Option(False, "--dry-run", help="print the legs and the tool-eval-bench command"),
|
|
1071
|
+
plot: bool = typer.Option(True, "--plot/--no-plot"),
|
|
1072
|
+
):
|
|
1073
|
+
"""Model quality per serving config with tool-eval-bench: does fp6 KV /
|
|
1074
|
+
the fp8 lm_head keep the model's answers?
|
|
1075
|
+
|
|
1076
|
+
Default per leg: the 69 standard tool-calling scenarios + the 28 Hard Mode
|
|
1077
|
+
ones (`tool-eval-bench run --hardmode --seed 42 --timeout 600`). Anything
|
|
1078
|
+
after `--` replaces that and is forwarded verbatim (the leg's --base-url is
|
|
1079
|
+
added), e.g. `-- run --hardmode --no-think --trials 3` or
|
|
1080
|
+
`-- --gsm8k --mmlu --ifeval --skip-tool-eval --gsm8k-limit 200`.
|
|
1081
|
+
Results: benchmarks/raw/accuracy/<run>/accuracy.json (committed; the
|
|
1082
|
+
tool's SQLite, reports and the server log stay next to it, untracked);
|
|
1083
|
+
docs/kernel/accuracy.md is rendered from the newest result per config and
|
|
1084
|
+
suite."""
|
|
1085
|
+
from mjolnir_cli import accuracy
|
|
1086
|
+
|
|
1087
|
+
s = _settings(model, None, image, port)
|
|
1088
|
+
layout = load_layout()
|
|
1089
|
+
entries = scan_all_configs(layout)
|
|
1090
|
+
try:
|
|
1091
|
+
legs = _ab_legs(backends, s.model, s.image, entries)
|
|
1092
|
+
except ValueError as e:
|
|
1093
|
+
typer.secho(str(e), fg=typer.colors.RED, err=True)
|
|
1094
|
+
raise typer.Exit(4)
|
|
1095
|
+
passthrough = list(ctx.args)
|
|
1096
|
+
if not restart and len(legs) != 1:
|
|
1097
|
+
typer.secho("--no-restart measures the running server: give exactly one leg", fg=typer.colors.RED, err=True)
|
|
1098
|
+
raise typer.Exit(2)
|
|
1099
|
+
if dry_run:
|
|
1100
|
+
try:
|
|
1101
|
+
tool = accuracy.find_tool()
|
|
1102
|
+
except accuracy.AccuracyError as e:
|
|
1103
|
+
tool = [f"<{e}>"]
|
|
1104
|
+
for m_, img, q in legs:
|
|
1105
|
+
typer.echo(f"{m_}/{q} on {img}:")
|
|
1106
|
+
typer.echo(" " + shlex.join(tool + accuracy.tool_args(passthrough, s.base_url)))
|
|
1107
|
+
return
|
|
1108
|
+
if restart:
|
|
1109
|
+
typer.secho(
|
|
1110
|
+
"bench accuracy restarts the vLLM server for each leg — the GPU must be yours for the whole run.",
|
|
1111
|
+
fg=typer.colors.YELLOW,
|
|
1112
|
+
)
|
|
1113
|
+
elif not dockerctl.container_running():
|
|
1114
|
+
typer.secho("--no-restart: no vLLM server is running (mjolnir serve up)", fg=typer.colors.RED, err=True)
|
|
1115
|
+
raise typer.Exit(3)
|
|
1116
|
+
try:
|
|
1117
|
+
out = accuracy.run_accuracy(s, layout, legs, passthrough, restart=restart)
|
|
1118
|
+
except accuracy.AccuracyError as e:
|
|
1119
|
+
typer.secho(str(e), fg=typer.colors.RED, err=True)
|
|
1120
|
+
raise typer.Exit(1)
|
|
1121
|
+
_print_accuracy(json.loads(out.read_text()))
|
|
1122
|
+
typer.secho(f"wrote {out}", fg=typer.colors.GREEN)
|
|
1123
|
+
if plot:
|
|
1124
|
+
_rerender(layout)
|
|
1125
|
+
|
|
1126
|
+
|
|
1127
|
+
def _print_accuracy(doc: dict) -> None:
|
|
1128
|
+
from mjolnir_cli import accuracy
|
|
1129
|
+
|
|
1130
|
+
rows = doc.get("legs", [])
|
|
1131
|
+
suites = accuracy.suites_in([{"suites": r.get("suites", {})} for r in rows])
|
|
1132
|
+
base = accuracy.baseline(rows)
|
|
1133
|
+
t = Table(box=box.SIMPLE_HEAD, title=f"accuracy · run {doc['run_id']} · tool-eval-bench {' '.join(doc['args'])}")
|
|
1134
|
+
t.add_column("config")
|
|
1135
|
+
for n in suites:
|
|
1136
|
+
t.add_column(f"{accuracy.suite_label(n)} %", justify="right")
|
|
1137
|
+
t.add_column("completion", justify="right")
|
|
1138
|
+
for r in rows:
|
|
1139
|
+
cells = []
|
|
1140
|
+
for n in suites:
|
|
1141
|
+
v = (r.get("suites", {}).get(n) or {}).get("score")
|
|
1142
|
+
b = ((base or {}).get("suites", {}).get(n) or {}).get("score")
|
|
1143
|
+
if v is None:
|
|
1144
|
+
cells.append("—")
|
|
1145
|
+
elif r is base or b is None:
|
|
1146
|
+
cells.append(f"{v:.1f}")
|
|
1147
|
+
else:
|
|
1148
|
+
cells.append(f"{v:.1f} ({v - b:+.1f})")
|
|
1149
|
+
comp = [x.get("completion_rate") for x in r.get("suites", {}).values() if x.get("completion_rate") is not None]
|
|
1150
|
+
t.add_row(r.get("label", r.get("id", "")), *cells, f"{min(comp):.0f}%" if comp else "—")
|
|
1151
|
+
Console().print(t)
|
|
1152
|
+
|
|
1153
|
+
|
|
1154
|
+
def _rerender(layout) -> None:
|
|
1155
|
+
try:
|
|
1156
|
+
charts.render_all(layout)
|
|
1157
|
+
except RuntimeError as e:
|
|
1158
|
+
typer.secho(f"(plots skipped: {e})", fg=typer.colors.YELLOW, err=True)
|
|
1159
|
+
try:
|
|
1160
|
+
readme.render_readme(layout)
|
|
1161
|
+
except (RuntimeError, FileNotFoundError) as e:
|
|
1162
|
+
typer.secho(f"(readme skipped: {e})", fg=typer.colors.YELLOW, err=True)
|
|
1163
|
+
|
|
1164
|
+
|
|
1165
|
+
def _run_legs(s: Settings, layout, legs: list[tuple], **perf) -> None:
|
|
1166
|
+
"""Serve each (model, image, config, label) leg in turn — the server is
|
|
1167
|
+
restarted per leg on purpose — run one gated perf bench on it, and stop
|
|
1168
|
+
the leg's server when it is done (or fails), so no vLLM stays up holding
|
|
1169
|
+
the GPU after the bench."""
|
|
1170
|
+
for leg_model, leg_image, quant, label in legs:
|
|
1171
|
+
s.model, s.image, s.quant = leg_model, leg_image, quant
|
|
1172
|
+
s.label = label
|
|
1173
|
+
typer.secho(f"\n{'=' * 62}\n leg: {leg_model}/{quant} (image {leg_image})\n{'=' * 62}", fg=typer.colors.CYAN)
|
|
1174
|
+
dockerctl.serve_down(s)
|
|
1175
|
+
try:
|
|
1176
|
+
dockerctl.serve_up(s, wait=True)
|
|
1177
|
+
benchy.run_perf(s, layout, warmup_runs=2, exact_tg=True, **perf)
|
|
1178
|
+
except benchy.BenchError as e:
|
|
1179
|
+
typer.secho(str(e), fg=typer.colors.RED, err=True)
|
|
1180
|
+
raise typer.Exit(1)
|
|
1181
|
+
finally:
|
|
1182
|
+
typer.secho(f" leg done: stopping the vLLM server ({leg_image})", fg=typer.colors.CYAN)
|
|
1183
|
+
dockerctl.serve_down(s)
|
|
1184
|
+
|
|
1185
|
+
|
|
1186
|
+
def _print_compare(
|
|
1187
|
+
layout, mjolnir_config: Optional[str] = None, as_json: bool = False, official_config: Optional[str] = None
|
|
1188
|
+
) -> int:
|
|
1189
|
+
from mjolnir_cli import e2e
|
|
1190
|
+
|
|
1191
|
+
off, mj = e2e.latest_pair(layout.history_file, mjolnir_config, official_config)
|
|
1192
|
+
if not off or not mj:
|
|
1193
|
+
missing = "the official vLLM image" if not off else "a Mjolnir image"
|
|
1194
|
+
typer.secho(f"no run on {missing} in {layout.history_file} — run: mjolnir bench e2e", fg=typer.colors.YELLOW)
|
|
1195
|
+
return 4
|
|
1196
|
+
rows = e2e.compare(off, mj)
|
|
1197
|
+
if as_json:
|
|
1198
|
+
typer.echo(
|
|
1199
|
+
json.dumps({"official": e2e.side_label(off), "mjolnir": e2e.side_label(mj), "cells": rows}, indent=2)
|
|
1200
|
+
)
|
|
1201
|
+
return 0
|
|
1202
|
+
con = Console()
|
|
1203
|
+
t = Table(
|
|
1204
|
+
box=box.SIMPLE_HEAD,
|
|
1205
|
+
title="official vLLM image → Mjolnir image (gain: + = Mjolnir better)",
|
|
1206
|
+
caption=f"official: {e2e.side_label(off)}\nmjolnir: {e2e.side_label(mj)}\nboth: {e2e.spec_label(off, mj)}"
|
|
1207
|
+
+ e2e.acceptance_line(off, mj),
|
|
1208
|
+
)
|
|
1209
|
+
t.add_column("ctx", justify="right")
|
|
1210
|
+
t.add_column("c", justify="right")
|
|
1211
|
+
for m in e2e.METRICS:
|
|
1212
|
+
t.add_column(
|
|
1213
|
+
f"{m.label} {m.unit} {'↑' if m.higher_better else '↓'} ({'throughput' if m.higher_better else 'latency'} change)",
|
|
1214
|
+
justify="right",
|
|
1215
|
+
)
|
|
1216
|
+
for r in rows:
|
|
1217
|
+
cells = []
|
|
1218
|
+
for m in e2e.METRICS:
|
|
1219
|
+
a, b, g = r[m.key]
|
|
1220
|
+
if a is None or b is None:
|
|
1221
|
+
cells.append("—")
|
|
1222
|
+
continue
|
|
1223
|
+
color = "green" if g >= e2e.E2E_NOISE_PCT else ("red" if g <= -e2e.E2E_NOISE_PCT else "grey50")
|
|
1224
|
+
cells.append(
|
|
1225
|
+
f"{m.fmt.format(a)} → {m.fmt.format(b)} [{color}]{e2e.pct(g if m.higher_better else -g)}[/{color}]"
|
|
1226
|
+
)
|
|
1227
|
+
t.add_row(e2e.ctx_label(r["ctx"]), str(r["conc"]), *cells)
|
|
1228
|
+
con.print(t)
|
|
1229
|
+
h = e2e.hero(off, mj)
|
|
1230
|
+
if h:
|
|
1231
|
+
con.print(
|
|
1232
|
+
f"[bold]1 request @ {e2e.ctx_label(h['ctx'])}:[/bold] decode {e2e.pct(h['tg_tps'][2])} · "
|
|
1233
|
+
f"prefill {e2e.pct(h['pp_tps'][2])} · TTFT {e2e.pct(-h['ttft_ms'][2])} · ITL {e2e.pct(-h['itl_ms'][2])} "
|
|
1234
|
+
"(throughput: % more tokens/s; latency: % change in ms)"
|
|
1235
|
+
)
|
|
1236
|
+
return 0
|
|
1237
|
+
|
|
1238
|
+
|
|
1239
|
+
@bench_app.command("e2e")
|
|
1240
|
+
def bench_e2e(
|
|
1241
|
+
official_image: str = typer.Option(STOCK_IMAGE, "--official-image", help="the upstream vLLM image (baseline)"),
|
|
1242
|
+
official_config: str = typer.Option(
|
|
1243
|
+
legs_.side_configs("bench.e2e", legs_.OFFICIAL)[0],
|
|
1244
|
+
"--official-config",
|
|
1245
|
+
help="config served on the official image (stock backends only)",
|
|
1246
|
+
),
|
|
1247
|
+
image: str = typer.Option(DEFAULT_IMAGE, "--image", help="the Mjolnir image"),
|
|
1248
|
+
config: str = typer.Option(
|
|
1249
|
+
legs_.side_configs("bench.e2e", legs_.MJOLNIR)[0], "--config", help="config served on the Mjolnir image"
|
|
1250
|
+
),
|
|
1251
|
+
model: Optional[str] = typer.Option(None, "--model"),
|
|
1252
|
+
runs: int = typer.Option(6, "--runs", help="measured runs per cell"),
|
|
1253
|
+
repeat: int = typer.Option(1, "--repeat", help="independent gated sweeps per leg"),
|
|
1254
|
+
depths: str = typer.Option(
|
|
1255
|
+
",".join(str(d) for d in E2E_DEPTHS if d <= _E2E_MAX_CTX),
|
|
1256
|
+
"--depths",
|
|
1257
|
+
help="context depths (tokens already in the KV); the comparison shows up to 32K",
|
|
1258
|
+
),
|
|
1259
|
+
concurrency: str = typer.Option("1,2,4", "--concurrency"),
|
|
1260
|
+
pp: int = typer.Option(2048, "--pp", help="prompt tokens per request (prefill)"),
|
|
1261
|
+
tg: int = typer.Option(128, "--tg", help="generated tokens per request (decode)"),
|
|
1262
|
+
gate: bool = typer.Option(True, "--gate/--no-gate"),
|
|
1263
|
+
port: Optional[int] = typer.Option(None, "--port"),
|
|
1264
|
+
only: Optional[str] = typer.Option(
|
|
1265
|
+
None,
|
|
1266
|
+
"--only",
|
|
1267
|
+
help="run one leg: 'mjolnir' or 'official'; the comparison pairs it with the other side's latest record",
|
|
1268
|
+
),
|
|
1269
|
+
also: str = typer.Option(
|
|
1270
|
+
",".join(legs_.side_configs("bench.e2e", legs_.MJOLNIR)[1:]),
|
|
1271
|
+
"--also",
|
|
1272
|
+
help="more legs to measure after the pair, comma-separated: a config (Mjolnir image) or "
|
|
1273
|
+
"official:<config> / mjolnir:<config>; '' = none. They land in the history (bench compare "
|
|
1274
|
+
"--config <cfg>); the README table, charts and infographic stay on --config",
|
|
1275
|
+
),
|
|
1276
|
+
optional: bool = typer.Option(
|
|
1277
|
+
False,
|
|
1278
|
+
"--optional",
|
|
1279
|
+
help="also measure the opt-in legs (legs.OPTIONAL: DSpark and DFlash2 on the official and the Mjolnir "
|
|
1280
|
+
"image) — hours per leg",
|
|
1281
|
+
),
|
|
1282
|
+
dry_run: bool = typer.Option(False, "--dry-run", help="print the serve commands, run nothing"),
|
|
1283
|
+
allow_stale_image: bool = typer.Option(
|
|
1284
|
+
False, "--allow-stale-image", help="benchmark a Mjolnir image older than the checkout's kernel"
|
|
1285
|
+
),
|
|
1286
|
+
):
|
|
1287
|
+
"""Official vLLM image vs the Mjolnir image, end to end: decode and prefill
|
|
1288
|
+
throughput, time to first token and inter-token latency per context x
|
|
1289
|
+
concurrency. Serves each image in turn (the server is restarted per leg),
|
|
1290
|
+
benchmarks it with llama-benchy, then prints the comparison and re-renders
|
|
1291
|
+
the README (infographic + table)."""
|
|
1292
|
+
s = _settings(model, None, None, port)
|
|
1293
|
+
layout = load_layout()
|
|
1294
|
+
|
|
1295
|
+
def ints(v: str) -> list[int]:
|
|
1296
|
+
return [int(x) for x in v.replace(" ", ",").split(",") if x.strip()]
|
|
1297
|
+
|
|
1298
|
+
legs = [
|
|
1299
|
+
(s.model, official_image, official_config, f"vLLM {VLLM_VERSION} (official image)"),
|
|
1300
|
+
(s.model, image, config, None),
|
|
1301
|
+
]
|
|
1302
|
+
if only:
|
|
1303
|
+
side = only.strip().lower()
|
|
1304
|
+
if side not in ("mjolnir", "official"):
|
|
1305
|
+
typer.secho(f"--only must be 'mjolnir' or 'official', got {only!r}", fg=typer.colors.RED, err=True)
|
|
1306
|
+
raise typer.Exit(2)
|
|
1307
|
+
legs = [legs[1] if side == "mjolnir" else legs[0]]
|
|
1308
|
+
extra = [legs_.parse_leg(q) for q in also.split(",") if q.strip()]
|
|
1309
|
+
if optional:
|
|
1310
|
+
extra += list(legs_.OPTIONAL["bench.e2e"])
|
|
1311
|
+
side_only = only.strip().lower() if only else None
|
|
1312
|
+
for side, q in dict.fromkeys(extra):
|
|
1313
|
+
if side_only and side != side_only:
|
|
1314
|
+
continue
|
|
1315
|
+
if side == legs_.OFFICIAL and q != official_config:
|
|
1316
|
+
legs.append((s.model, official_image, q, f"vLLM {VLLM_VERSION} (official image)"))
|
|
1317
|
+
elif side == legs_.MJOLNIR and q != config:
|
|
1318
|
+
legs.append((s.model, image, q, None))
|
|
1319
|
+
stale = dockerctl.stale_image(image) if any(leg[1] == image for leg in legs) else None
|
|
1320
|
+
if stale and not dry_run:
|
|
1321
|
+
if not allow_stale_image:
|
|
1322
|
+
typer.secho(f"{stale} (or --allow-stale-image)", fg=typer.colors.RED, err=True)
|
|
1323
|
+
raise typer.Exit(2)
|
|
1324
|
+
typer.secho(f"warning: {stale}", fg=typer.colors.YELLOW, err=True)
|
|
1325
|
+
if dry_run:
|
|
1326
|
+
for m_, img, q, _ in legs:
|
|
1327
|
+
s.model, s.image, s.quant = m_, img, q
|
|
1328
|
+
dockerctl.serve_up(s, wait=False, dry_run=True)
|
|
1329
|
+
return
|
|
1330
|
+
typer.secho(
|
|
1331
|
+
"bench e2e restarts the vLLM server for each image — the GPU must be yours for the whole run.",
|
|
1332
|
+
fg=typer.colors.YELLOW,
|
|
1333
|
+
)
|
|
1334
|
+
_run_legs(
|
|
1335
|
+
s,
|
|
1336
|
+
layout,
|
|
1337
|
+
legs,
|
|
1338
|
+
runs=runs,
|
|
1339
|
+
repeat=repeat,
|
|
1340
|
+
gate=gate,
|
|
1341
|
+
depths=ints(depths),
|
|
1342
|
+
concurrency=ints(concurrency),
|
|
1343
|
+
pp=pp,
|
|
1344
|
+
tg=tg,
|
|
1345
|
+
)
|
|
1346
|
+
_print_compare(layout, config)
|
|
1347
|
+
measured = [(img, q) for _, img, q, _ in legs if q not in (config, official_config)]
|
|
1348
|
+
if measured:
|
|
1349
|
+
typer.echo("optional legs — compare them with:")
|
|
1350
|
+
offs = [q for img, q in measured if img == official_image]
|
|
1351
|
+
for img, q in measured:
|
|
1352
|
+
if img != image:
|
|
1353
|
+
continue
|
|
1354
|
+
twin = next((o for o in offs if o.split("_")[-1] == q.split("_")[-1]), None)
|
|
1355
|
+
typer.echo(
|
|
1356
|
+
f" mjolnir bench compare --config {q}"
|
|
1357
|
+
+ (f" | --official-config {twin} --config {q}" if twin else "")
|
|
1358
|
+
)
|
|
1359
|
+
_rerender(layout)
|
|
1360
|
+
|
|
1361
|
+
|
|
1362
|
+
@bench_app.command("compare")
|
|
1363
|
+
def bench_compare(
|
|
1364
|
+
config: Optional[str] = typer.Option(None, "--config", help="Mjolnir-side config (default: the Mjolnir backend)"),
|
|
1365
|
+
official_config: Optional[str] = typer.Option(
|
|
1366
|
+
None, "--official-config", help="official-side config (default: the stock baseline, NVFP4)"
|
|
1367
|
+
),
|
|
1368
|
+
as_json: bool = typer.Option(False, "--json", "-j"),
|
|
1369
|
+
):
|
|
1370
|
+
"""Official vLLM image vs Mjolnir image from the history — no new run.
|
|
1371
|
+
`--official-config NVFP4_DSPARK --config NVFP4_MJOLNIR_E2M3_DSPARK` compares
|
|
1372
|
+
the same drafter on both images."""
|
|
1373
|
+
raise typer.Exit(_print_compare(load_layout(), config, as_json, official_config))
|
|
1374
|
+
|
|
1375
|
+
|
|
1376
|
+
# ── plot / history ──────────────────────────────────────────────────────────
|
|
1377
|
+
|
|
1378
|
+
|
|
1379
|
+
@app.command()
|
|
1380
|
+
def plot(
|
|
1381
|
+
render_readme: bool = typer.Option(
|
|
1382
|
+
False,
|
|
1383
|
+
"--readme",
|
|
1384
|
+
help="also render README.md from the README.md.j2 template "
|
|
1385
|
+
"(every table + number computed from the committed raw "
|
|
1386
|
+
"data)",
|
|
1387
|
+
),
|
|
1388
|
+
readme_template: Optional[Path] = typer.Option(
|
|
1389
|
+
None, "--readme-template", help="template override (default: <repo>/README.md.j2)"
|
|
1390
|
+
),
|
|
1391
|
+
readme_out: Optional[Path] = typer.Option(None, "--readme-out", help="output override (default: <repo>/README.md)"),
|
|
1392
|
+
site: Optional[Path] = typer.Option(
|
|
1393
|
+
None, "--site", help="also build the interactive benchmark site (index.html) into this directory"
|
|
1394
|
+
),
|
|
1395
|
+
):
|
|
1396
|
+
"""Render every chart (light + dark PNGs in assets/benchmarks/, from the
|
|
1397
|
+
latest default `mjolnir verify` run of each stage + the e2e history).
|
|
1398
|
+
--readme also renders README.md and docs/**/*.md from their .md.j2
|
|
1399
|
+
templates; --site builds the interactive GitHub Pages site. Everything is
|
|
1400
|
+
computed from the committed raw data."""
|
|
1401
|
+
layout = load_layout()
|
|
1402
|
+
try:
|
|
1403
|
+
charts.render_all(layout)
|
|
1404
|
+
except RuntimeError as e:
|
|
1405
|
+
typer.secho(str(e), fg=typer.colors.RED, err=True)
|
|
1406
|
+
raise typer.Exit(1)
|
|
1407
|
+
if layout.repo_root: # research/progress.png: the live ledger, else the newest exported run
|
|
1408
|
+
from mjolnir_cli import autoresearch
|
|
1409
|
+
|
|
1410
|
+
autoresearch._render_progress(layout.repo_root, autoresearch.latest_rows(layout.repo_root))
|
|
1411
|
+
if render_readme:
|
|
1412
|
+
try:
|
|
1413
|
+
readme.render_readme(layout, template=readme_template, out=readme_out)
|
|
1414
|
+
except (RuntimeError, FileNotFoundError, OSError) as e:
|
|
1415
|
+
typer.secho(str(e), fg=typer.colors.RED, err=True)
|
|
1416
|
+
raise typer.Exit(1)
|
|
1417
|
+
if site is not None:
|
|
1418
|
+
from mjolnir_cli import interactive
|
|
1419
|
+
|
|
1420
|
+
interactive.build(layout, site)
|
|
1421
|
+
|
|
1422
|
+
|
|
1423
|
+
def _ctx_label(ctx: int | None) -> str:
|
|
1424
|
+
if not ctx:
|
|
1425
|
+
return "0"
|
|
1426
|
+
return f"{ctx // 1024}K" if ctx % 1024 == 0 else str(ctx)
|
|
1427
|
+
|
|
1428
|
+
|
|
1429
|
+
def _history_record_table(r: dict) -> Table:
|
|
1430
|
+
"""One history record → a small tg-t/s (decode throughput) table."""
|
|
1431
|
+
by: dict[int | None, dict] = {}
|
|
1432
|
+
for c in r.get("cells", []):
|
|
1433
|
+
tg = (c.get("tg_tps") or {}).get("mean")
|
|
1434
|
+
if tg is None:
|
|
1435
|
+
continue
|
|
1436
|
+
by.setdefault(c.get("context"), {})[c.get("concurrency")] = tg
|
|
1437
|
+
concs = sorted({cc for v in by.values() for cc in v}, key=lambda x: (x is None, x or 0))
|
|
1438
|
+
t = Table(box=box.SIMPLE_HEAD, pad_edge=False)
|
|
1439
|
+
t.add_column("ctx")
|
|
1440
|
+
for cc in concs:
|
|
1441
|
+
t.add_column(f"c={cc} · t/s")
|
|
1442
|
+
for ctx in sorted(by, key=lambda x: (x is None, x or 0)):
|
|
1443
|
+
t.add_row(_ctx_label(ctx), *[f"{by[ctx][cc]:.1f}" if cc in by[ctx] else "—" for cc in concs])
|
|
1444
|
+
return t
|
|
1445
|
+
|
|
1446
|
+
|
|
1447
|
+
@app.command()
|
|
1448
|
+
def history(
|
|
1449
|
+
limit: int = typer.Option(5, "--limit", help="how many recent runs to show"),
|
|
1450
|
+
as_json: bool = typer.Option(False, "--json", "-j", help="emit the raw JSON records (for scripts)"),
|
|
1451
|
+
):
|
|
1452
|
+
"""Bench results at a glance: decode throughput (t/s) per recent run.
|
|
1453
|
+
|
|
1454
|
+
The raw substrate (every sweep, every cell) is committed in
|
|
1455
|
+
benchmarks/history.jsonl — ``--json`` dumps the records."""
|
|
1456
|
+
layout = load_layout()
|
|
1457
|
+
from mjolnir_cli.history import load_records
|
|
1458
|
+
|
|
1459
|
+
recs = load_records(layout.history_file)[-limit:]
|
|
1460
|
+
if not recs:
|
|
1461
|
+
typer.secho("no history yet — run: mjolnir bench perf", fg=typer.colors.YELLOW)
|
|
1462
|
+
return
|
|
1463
|
+
if as_json:
|
|
1464
|
+
typer.echo(json.dumps(recs, indent=2))
|
|
1465
|
+
return
|
|
1466
|
+
for r in recs:
|
|
1467
|
+
ts = str(r.get("ts", ""))[:16].replace("T", " ")
|
|
1468
|
+
_console.print(f" {ts} {r.get('backend', '')} — {r.get('image', '')}")
|
|
1469
|
+
_console.print(_history_record_table(r))
|
|
1470
|
+
_console.print()
|
|
1471
|
+
|
|
1472
|
+
|
|
1473
|
+
@app.command()
|
|
1474
|
+
def version():
|
|
1475
|
+
"""Print the version."""
|
|
1476
|
+
typer.echo(f"mjolnir {__version__}")
|
|
1477
|
+
|
|
1478
|
+
|
|
1479
|
+
if __name__ == "__main__":
|
|
1480
|
+
app()
|