mjolnir-cli 0.2.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (80) hide show
  1. mjolnir_cli/__init__.py +8 -0
  2. mjolnir_cli/__main__.py +4 -0
  3. mjolnir_cli/accuracy.py +333 -0
  4. mjolnir_cli/autoresearch.py +539 -0
  5. mjolnir_cli/benchy.py +475 -0
  6. mjolnir_cli/bundled/configs/Qwen/Qwen3.8-27B/NVFP4/config.yaml +78 -0
  7. mjolnir_cli/bundled/configs/Qwen/Qwen3.8-27B/NVFP4_DFLASH2/config.yaml +103 -0
  8. mjolnir_cli/bundled/configs/Qwen/Qwen3.8-27B/NVFP4_DSPARK/config.yaml +102 -0
  9. mjolnir_cli/bundled/configs/Qwen/Qwen3.8-27B/NVFP4_FA2/config.yaml +84 -0
  10. mjolnir_cli/bundled/configs/Qwen/Qwen3.8-27B/NVFP4_FA4hd256/config.yaml +104 -0
  11. mjolnir_cli/bundled/configs/Qwen/Qwen3.8-27B/NVFP4_MJOLNIR/config.yaml +86 -0
  12. mjolnir_cli/bundled/configs/Qwen/Qwen3.8-27B/NVFP4_MJOLNIR_E2M3/config.yaml +93 -0
  13. mjolnir_cli/bundled/configs/Qwen/Qwen3.8-27B/NVFP4_MJOLNIR_E2M3_DFLASH2/config.yaml +123 -0
  14. mjolnir_cli/bundled/configs/Qwen/Qwen3.8-27B/NVFP4_MJOLNIR_E2M3_DSPARK/config.yaml +124 -0
  15. mjolnir_cli/bundled/configs/Qwen/Qwen3.8-27B/NVFP4_MJOLNIR_E2M3_LMH8/.env +2 -0
  16. mjolnir_cli/bundled/configs/Qwen/Qwen3.8-27B/NVFP4_MJOLNIR_E2M3_LMH8/config.yaml +98 -0
  17. mjolnir_cli/bundled/configs/Qwen/Qwen3.8-27B/NVFP4_MJOLNIR_E2M3_LMH8_VT32K/.env +4 -0
  18. mjolnir_cli/bundled/configs/Qwen/Qwen3.8-27B/NVFP4_MJOLNIR_E2M3_LMH8_VT32K/config.yaml +98 -0
  19. mjolnir_cli/bundled/configs/Qwen/Qwen3.8-27B/NVFP4_MJOLNIR_E2M3_LMH8_VT64K/.env +4 -0
  20. mjolnir_cli/bundled/configs/Qwen/Qwen3.8-27B/NVFP4_MJOLNIR_E2M3_LMH8_VT64K/config.yaml +102 -0
  21. mjolnir_cli/bundled/configs/Qwen/Qwen3.8-27B/NVFP4_MJOLNIR_E2M3_VT32K/.env +3 -0
  22. mjolnir_cli/bundled/configs/Qwen/Qwen3.8-27B/NVFP4_MJOLNIR_E2M3_VT32K/config.yaml +98 -0
  23. mjolnir_cli/bundled/configs/Qwen/Qwen3.8-27B/draft_vocab.json +1 -0
  24. mjolnir_cli/bundled/configs/Qwen/Qwen3.8-27B/kv_scales.json +35197 -0
  25. mjolnir_cli/bundled/configs/Qwen/Qwen3.8_chat_template.jinja +456 -0
  26. mjolnir_cli/bundled/docker/litellm/docker-compose.yaml +113 -0
  27. mjolnir_cli/bundled/docker/litellm/litellm.env.example +51 -0
  28. mjolnir_cli/bundled/docker/litellm/litellm_config.template.example.yaml +192 -0
  29. mjolnir_cli/bundled/docker/perses/README.md +89 -0
  30. mjolnir_cli/bundled/docker/perses/dashboards/global-datasource.yaml +17 -0
  31. mjolnir_cli/bundled/docker/perses/dashboards/performance_statistics.yaml +771 -0
  32. mjolnir_cli/bundled/docker/perses/dashboards/project.yaml +13 -0
  33. mjolnir_cli/bundled/docker/perses/dashboards/query_statistics.yaml +399 -0
  34. mjolnir_cli/bundled/docker/perses/docker-compose.yaml +51 -0
  35. mjolnir_cli/bundled/docker/perses/perses.yml +16 -0
  36. mjolnir_cli/bundled/docker/perses/prometheus.template.yaml +20 -0
  37. mjolnir_cli/bundled/scripts/hw/setup-thor.sh +1287 -0
  38. mjolnir_cli/cachectl.py +146 -0
  39. mjolnir_cli/charts.py +1687 -0
  40. mjolnir_cli/cli.py +1480 -0
  41. mjolnir_cli/clidoc.py +170 -0
  42. mjolnir_cli/config.py +346 -0
  43. mjolnir_cli/dockerctl.py +533 -0
  44. mjolnir_cli/drivers/fa2_driver.py +89 -0
  45. mjolnir_cli/drivers/fi_driver.py +134 -0
  46. mjolnir_cli/e2e.py +302 -0
  47. mjolnir_cli/fonts/InterDisplay-Bold.ttf +0 -0
  48. mjolnir_cli/fonts/InterDisplay-Medium.ttf +0 -0
  49. mjolnir_cli/fonts/InterDisplay-Regular.ttf +0 -0
  50. mjolnir_cli/fonts/InterDisplay-SemiBold.ttf +0 -0
  51. mjolnir_cli/fonts/OFL.txt +92 -0
  52. mjolnir_cli/gate.py +233 -0
  53. mjolnir_cli/history.py +159 -0
  54. mjolnir_cli/interactive.py +383 -0
  55. mjolnir_cli/legs.py +109 -0
  56. mjolnir_cli/litellm.py +254 -0
  57. mjolnir_cli/perses.py +214 -0
  58. mjolnir_cli/picker.py +70 -0
  59. mjolnir_cli/readme.py +1020 -0
  60. mjolnir_cli/stack.py +340 -0
  61. mjolnir_cli/tasks.py +122 -0
  62. mjolnir_cli/theme.py +281 -0
  63. mjolnir_cli/thor.py +108 -0
  64. mjolnir_cli/verify/__init__.py +2 -0
  65. mjolnir_cli/verify/cli.py +816 -0
  66. mjolnir_cli/verify/docker.py +334 -0
  67. mjolnir_cli/verify/kvcache.py +180 -0
  68. mjolnir_cli/verify/legs.py +134 -0
  69. mjolnir_cli/verify/ncu.py +141 -0
  70. mjolnir_cli/verify/profile.py +312 -0
  71. mjolnir_cli/verify/report.py +177 -0
  72. mjolnir_cli/verify/results.py +157 -0
  73. mjolnir_cli/verify/spec.py +279 -0
  74. mjolnir_cli/verify/stages.py +817 -0
  75. mjolnir_cli-0.2.0.dist-info/METADATA +87 -0
  76. mjolnir_cli-0.2.0.dist-info/RECORD +80 -0
  77. mjolnir_cli-0.2.0.dist-info/WHEEL +4 -0
  78. mjolnir_cli-0.2.0.dist-info/entry_points.txt +2 -0
  79. mjolnir_cli-0.2.0.dist-info/licenses/LICENSE +202 -0
  80. mjolnir_cli-0.2.0.dist-info/licenses/NOTICE +64 -0
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()