mjolnir-cli 0.2.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (80) hide show
  1. mjolnir_cli/__init__.py +8 -0
  2. mjolnir_cli/__main__.py +4 -0
  3. mjolnir_cli/accuracy.py +333 -0
  4. mjolnir_cli/autoresearch.py +539 -0
  5. mjolnir_cli/benchy.py +475 -0
  6. mjolnir_cli/bundled/configs/Qwen/Qwen3.8-27B/NVFP4/config.yaml +78 -0
  7. mjolnir_cli/bundled/configs/Qwen/Qwen3.8-27B/NVFP4_DFLASH2/config.yaml +103 -0
  8. mjolnir_cli/bundled/configs/Qwen/Qwen3.8-27B/NVFP4_DSPARK/config.yaml +102 -0
  9. mjolnir_cli/bundled/configs/Qwen/Qwen3.8-27B/NVFP4_FA2/config.yaml +84 -0
  10. mjolnir_cli/bundled/configs/Qwen/Qwen3.8-27B/NVFP4_FA4hd256/config.yaml +104 -0
  11. mjolnir_cli/bundled/configs/Qwen/Qwen3.8-27B/NVFP4_MJOLNIR/config.yaml +86 -0
  12. mjolnir_cli/bundled/configs/Qwen/Qwen3.8-27B/NVFP4_MJOLNIR_E2M3/config.yaml +93 -0
  13. mjolnir_cli/bundled/configs/Qwen/Qwen3.8-27B/NVFP4_MJOLNIR_E2M3_DFLASH2/config.yaml +123 -0
  14. mjolnir_cli/bundled/configs/Qwen/Qwen3.8-27B/NVFP4_MJOLNIR_E2M3_DSPARK/config.yaml +124 -0
  15. mjolnir_cli/bundled/configs/Qwen/Qwen3.8-27B/NVFP4_MJOLNIR_E2M3_LMH8/.env +2 -0
  16. mjolnir_cli/bundled/configs/Qwen/Qwen3.8-27B/NVFP4_MJOLNIR_E2M3_LMH8/config.yaml +98 -0
  17. mjolnir_cli/bundled/configs/Qwen/Qwen3.8-27B/NVFP4_MJOLNIR_E2M3_LMH8_VT32K/.env +4 -0
  18. mjolnir_cli/bundled/configs/Qwen/Qwen3.8-27B/NVFP4_MJOLNIR_E2M3_LMH8_VT32K/config.yaml +98 -0
  19. mjolnir_cli/bundled/configs/Qwen/Qwen3.8-27B/NVFP4_MJOLNIR_E2M3_LMH8_VT64K/.env +4 -0
  20. mjolnir_cli/bundled/configs/Qwen/Qwen3.8-27B/NVFP4_MJOLNIR_E2M3_LMH8_VT64K/config.yaml +102 -0
  21. mjolnir_cli/bundled/configs/Qwen/Qwen3.8-27B/NVFP4_MJOLNIR_E2M3_VT32K/.env +3 -0
  22. mjolnir_cli/bundled/configs/Qwen/Qwen3.8-27B/NVFP4_MJOLNIR_E2M3_VT32K/config.yaml +98 -0
  23. mjolnir_cli/bundled/configs/Qwen/Qwen3.8-27B/draft_vocab.json +1 -0
  24. mjolnir_cli/bundled/configs/Qwen/Qwen3.8-27B/kv_scales.json +35197 -0
  25. mjolnir_cli/bundled/configs/Qwen/Qwen3.8_chat_template.jinja +456 -0
  26. mjolnir_cli/bundled/docker/litellm/docker-compose.yaml +113 -0
  27. mjolnir_cli/bundled/docker/litellm/litellm.env.example +51 -0
  28. mjolnir_cli/bundled/docker/litellm/litellm_config.template.example.yaml +192 -0
  29. mjolnir_cli/bundled/docker/perses/README.md +89 -0
  30. mjolnir_cli/bundled/docker/perses/dashboards/global-datasource.yaml +17 -0
  31. mjolnir_cli/bundled/docker/perses/dashboards/performance_statistics.yaml +771 -0
  32. mjolnir_cli/bundled/docker/perses/dashboards/project.yaml +13 -0
  33. mjolnir_cli/bundled/docker/perses/dashboards/query_statistics.yaml +399 -0
  34. mjolnir_cli/bundled/docker/perses/docker-compose.yaml +51 -0
  35. mjolnir_cli/bundled/docker/perses/perses.yml +16 -0
  36. mjolnir_cli/bundled/docker/perses/prometheus.template.yaml +20 -0
  37. mjolnir_cli/bundled/scripts/hw/setup-thor.sh +1287 -0
  38. mjolnir_cli/cachectl.py +146 -0
  39. mjolnir_cli/charts.py +1687 -0
  40. mjolnir_cli/cli.py +1480 -0
  41. mjolnir_cli/clidoc.py +170 -0
  42. mjolnir_cli/config.py +346 -0
  43. mjolnir_cli/dockerctl.py +533 -0
  44. mjolnir_cli/drivers/fa2_driver.py +89 -0
  45. mjolnir_cli/drivers/fi_driver.py +134 -0
  46. mjolnir_cli/e2e.py +302 -0
  47. mjolnir_cli/fonts/InterDisplay-Bold.ttf +0 -0
  48. mjolnir_cli/fonts/InterDisplay-Medium.ttf +0 -0
  49. mjolnir_cli/fonts/InterDisplay-Regular.ttf +0 -0
  50. mjolnir_cli/fonts/InterDisplay-SemiBold.ttf +0 -0
  51. mjolnir_cli/fonts/OFL.txt +92 -0
  52. mjolnir_cli/gate.py +233 -0
  53. mjolnir_cli/history.py +159 -0
  54. mjolnir_cli/interactive.py +383 -0
  55. mjolnir_cli/legs.py +109 -0
  56. mjolnir_cli/litellm.py +254 -0
  57. mjolnir_cli/perses.py +214 -0
  58. mjolnir_cli/picker.py +70 -0
  59. mjolnir_cli/readme.py +1020 -0
  60. mjolnir_cli/stack.py +340 -0
  61. mjolnir_cli/tasks.py +122 -0
  62. mjolnir_cli/theme.py +281 -0
  63. mjolnir_cli/thor.py +108 -0
  64. mjolnir_cli/verify/__init__.py +2 -0
  65. mjolnir_cli/verify/cli.py +816 -0
  66. mjolnir_cli/verify/docker.py +334 -0
  67. mjolnir_cli/verify/kvcache.py +180 -0
  68. mjolnir_cli/verify/legs.py +134 -0
  69. mjolnir_cli/verify/ncu.py +141 -0
  70. mjolnir_cli/verify/profile.py +312 -0
  71. mjolnir_cli/verify/report.py +177 -0
  72. mjolnir_cli/verify/results.py +157 -0
  73. mjolnir_cli/verify/spec.py +279 -0
  74. mjolnir_cli/verify/stages.py +817 -0
  75. mjolnir_cli-0.2.0.dist-info/METADATA +87 -0
  76. mjolnir_cli-0.2.0.dist-info/RECORD +80 -0
  77. mjolnir_cli-0.2.0.dist-info/WHEEL +4 -0
  78. mjolnir_cli-0.2.0.dist-info/entry_points.txt +2 -0
  79. mjolnir_cli-0.2.0.dist-info/licenses/LICENSE +202 -0
  80. mjolnir_cli-0.2.0.dist-info/licenses/NOTICE +64 -0
@@ -0,0 +1,334 @@
1
+ """Container plumbing for ``mjolnir verify``: harness runs, ncu legs, full
2
+ profiles and test runs — each in a named, timed-out, reaped container.
3
+
4
+ The kernel under test is ``mjolnir.t5_decode`` (``python3 -m
5
+ mjolnir.t5_decode``). In a checkout the repo's ``src/mjolnir`` is mounted
6
+ over the image's installed copy (``PYTHONPATH``), so every run measures the
7
+ current source; a PyPI install runs the kernel baked into the image.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import os
13
+ import random
14
+ import shlex
15
+ import subprocess
16
+ import sys
17
+ from dataclasses import dataclass, field
18
+ from pathlib import Path
19
+
20
+ from mjolnir_cli.verify import ncu
21
+ from mjolnir_cli.verify.legs import FILTERS, Leg
22
+
23
+ DRIVERS_DIR = Path(__file__).resolve().parent.parent / "drivers"
24
+ NCU_HOME_DEFAULT = "/opt/nvidia/nsight-compute/2025.3.0"
25
+ _IN_SRC = "/opt/mjolnir-src"
26
+
27
+
28
+ def ensure_image(image: str) -> str | None:
29
+ """Make sure ``image`` is available locally (pull it if not) BEFORE any
30
+ stage starts, so a missing image is one clear error instead of one
31
+ failed container per case. Returns None if ready, else a message."""
32
+ if subprocess.run(["docker", "image", "inspect", image], capture_output=True).returncode == 0:
33
+ return None
34
+ p = subprocess.run(["docker", "pull", image], capture_output=True, text=True)
35
+ if p.returncode == 0:
36
+ return None
37
+ why = (p.stderr or p.stdout).strip().splitlines()[-1:] or ["pull failed"]
38
+ return (
39
+ f"image {image} is not available locally and could not be pulled ({why[0]}).\n"
40
+ f" build it from this checkout: mjolnir image build --tag {image}\n"
41
+ f" or use another image: --image <tag> (or: mjolnir image use <tag>)"
42
+ )
43
+
44
+
45
+ _IN_DRIVERS = "/opt/mjolnir-drivers"
46
+ _IN_NCU = "/ncu"
47
+
48
+
49
+ def default_ref_image() -> str:
50
+ """Where the FlashInfer / FA2 reference legs run: the OFFICIAL upstream
51
+ vLLM image, never ours — the Mjolnir image patches FlashInfer paths too,
52
+ so a reference measured there would not be stock vLLM."""
53
+ from mjolnir_cli.config import STOCK_IMAGE
54
+
55
+ return os.environ.get("MJOLNIR_REF_IMAGE", STOCK_IMAGE)
56
+
57
+
58
+ @dataclass
59
+ class Runner:
60
+ image: str
61
+ logdir: Path
62
+ ref_image: str = field(default_factory=default_ref_image) # FlashInfer / FA2 legs
63
+ kernel_src: Path | None = None # checkout's src/mjolnir (mounted over the image copy)
64
+ ncu_home: str = field(default_factory=lambda: os.environ.get("NCU_HOME", NCU_HOME_DEFAULT))
65
+ timeout: float = 180.0
66
+ dry_run: bool = False
67
+ hq: int = 24
68
+ hk: int = 4
69
+ d: int = 256
70
+ page: int = 128
71
+ batch: int = 1 # sequences per decode step (each of L tokens) — `verify sweep --batch`
72
+ q_len: int = 1 # query tokens per sequence (MTP=3 verification is 4) — `verify sweep --q-len`
73
+
74
+ _ready: set = field(default_factory=set, repr=False)
75
+
76
+ # ── low level ──────────────────────────────────────────────────────────
77
+ def image_for(self, leg: Leg) -> str:
78
+ """Mjolnir legs run on the Mjolnir image; FlashInfer / FA2 references
79
+ on the official vLLM image."""
80
+ return self.image if leg.kind == "t5" else self.ref_image
81
+
82
+ def _ensure(self, image: str, log: Path) -> bool:
83
+ if self.dry_run or image in self._ready:
84
+ return True
85
+ err = ensure_image(image)
86
+ if err:
87
+ log.parent.mkdir(parents=True, exist_ok=True)
88
+ log.write_text(err + "\n")
89
+ print(f" ! {err}", file=sys.stderr)
90
+ return False
91
+ self._ready.add(image)
92
+ return True
93
+
94
+ def _name(self, prefix: str) -> str:
95
+ return f"{prefix}-{os.getpid()}-{random.randint(0, 1 << 30)}"
96
+
97
+ def _overlay(self) -> list[str]:
98
+ if self.kernel_src is None:
99
+ return []
100
+ return ["-v", f"{self.kernel_src}:{_IN_SRC}:ro", "-e", f"PYTHONPATH={_IN_SRC}"]
101
+
102
+ def _run(self, argv: list[str], log: Path, name: str, timeout: float | None = None) -> int:
103
+ log.parent.mkdir(parents=True, exist_ok=True)
104
+ if self.dry_run:
105
+ print(" $ " + " ".join(shlex.quote(a) for a in argv))
106
+ log.write_text("(dry run)\n")
107
+ return 0
108
+ with log.open("w") as fh:
109
+ try:
110
+ p = subprocess.run(argv, stdout=fh, stderr=subprocess.STDOUT, timeout=timeout or self.timeout)
111
+ return p.returncode
112
+ except KeyboardInterrupt:
113
+ # the docker client dies with the CLI; the container would keep the GPU
114
+ subprocess.run(["docker", "rm", "-f", name], capture_output=True)
115
+ raise
116
+ except subprocess.TimeoutExpired:
117
+ # stopping only the docker CLI leaves the container — and the GPU — stuck
118
+ subprocess.run(["docker", "kill", name], capture_output=True)
119
+ subprocess.run(["docker", "rm", "-f", name], capture_output=True)
120
+ fh.write(f"\nTIMEOUT after {timeout or self.timeout:.0f}s (container killed: hung kernel?)\n")
121
+ return 124
122
+
123
+ # ── the kernel harness ─────────────────────────────────────────────────
124
+ def harness_args(self, q: str, args: list[str] | str) -> list[str]:
125
+ if isinstance(args, str):
126
+ args = shlex.split(args)
127
+ qd = "Float8E4M3FN" if q == "fp8" else "BFloat16"
128
+ return ["-m", "mjolnir.t5_decode", "--q_dtype", qd, "--kv_dtype", "Float8E4M3FN", *args]
129
+
130
+ def harness(self, log: Path, q: str, args: list[str] | str, timeout: float | None = None) -> tuple[int, str]:
131
+ name = self._name("mjolnir-verify")
132
+ argv = ["docker", "run", "--rm", "--name", name, "--gpus", "all", "--entrypoint", "python3"]
133
+ argv += self._overlay() + [self.image] + self.harness_args(q, args)
134
+ rc = self._run(argv, log, name, timeout)
135
+ return rc, log.read_text(errors="replace") if log.exists() else ""
136
+
137
+ # ── ncu-profiled legs ──────────────────────────────────────────────────
138
+ def leg_command(self, leg: Leg, L: int, iters: int) -> tuple[str, list[str]]:
139
+ """(python command line inside the container, extra docker -e args)."""
140
+ common_env = [
141
+ f"BENCH_L={L}",
142
+ f"BENCH_ITERS={iters}",
143
+ f"BENCH_HQ={self.hq}",
144
+ f"BENCH_HK={self.hk}",
145
+ f"BENCH_D={self.d}",
146
+ f"BENCH_PAGE={self.page}",
147
+ f"BENCH_B={self.batch}",
148
+ f"BENCH_QLEN={self.q_len}",
149
+ ]
150
+ if leg.kind == "t5":
151
+ args = self.harness_args(
152
+ leg.q,
153
+ [
154
+ "--seqlen",
155
+ str(L),
156
+ "--heads_q",
157
+ str(self.hq),
158
+ "--heads_k",
159
+ str(self.hk),
160
+ "--headdim",
161
+ str(self.d),
162
+ "--page_size",
163
+ str(self.page),
164
+ *(["--batches", str(self.batch)] if self.batch != 1 else []),
165
+ *(["--q_len", str(self.q_len)] if self.q_len != 1 else []),
166
+ "--iterations",
167
+ str(iters),
168
+ "--warmup_iterations",
169
+ "0",
170
+ "--skip_ref_check",
171
+ "--no_l2_flush",
172
+ *shlex.split(leg.flags),
173
+ ],
174
+ )
175
+ return "python3 " + " ".join(shlex.quote(a) for a in args), []
176
+ script = {"fi": "fi_driver.py", "fa2": "fa2_driver.py"}[leg.kind]
177
+ env = common_env + [f"{k}={v}" for k, v in leg.env]
178
+ return f"python3 {_IN_DRIVERS}/{script}", env
179
+
180
+ def ncu_leg(
181
+ self, log: Path, leg: Leg, L: int, iters: int, cache: str = "all", clock: str = "base"
182
+ ) -> tuple[int, dict | None]:
183
+ cmd, env = self.leg_command(leg, L, iters)
184
+ name = self._name("mjolnir-ncu")
185
+ argv = [
186
+ "docker",
187
+ "run",
188
+ "--rm",
189
+ "--name",
190
+ name,
191
+ "--gpus",
192
+ "all",
193
+ "--network",
194
+ "host",
195
+ "--security-opt",
196
+ "seccomp=unconfined",
197
+ "--cap-add",
198
+ "SYS_ADMIN",
199
+ "--entrypoint",
200
+ "bash",
201
+ "-v",
202
+ f"{self.ncu_home}:{_IN_NCU}:ro",
203
+ "-v",
204
+ f"{DRIVERS_DIR}:{_IN_DRIVERS}:ro",
205
+ "-e",
206
+ "TMPDIR=/tmp/ncu-mjolnir",
207
+ ]
208
+ if leg.kind == "t5":
209
+ argv += self._overlay()
210
+ for e in env:
211
+ argv += ["-e", e]
212
+ flt = FILTERS[leg.kind]
213
+ inner = (
214
+ f"mkdir -p /tmp/ncu-mjolnir && {_IN_NCU}/ncu --target-processes all "
215
+ f"--clock-control {clock} --cache-control {cache} --kernel-name {shlex.quote(flt)} "
216
+ f"--print-summary per-kernel --metrics {ncu.METRICS} {cmd}"
217
+ )
218
+ image = self.image_for(leg)
219
+ if not self._ensure(image, log):
220
+ return 2, None
221
+ argv += [image, "-c", inner]
222
+ rc = self._run(argv, log, name, timeout=max(self.timeout, 600.0))
223
+ if rc != 0 or self.dry_run:
224
+ return rc, None
225
+ from mjolnir_cli.verify.legs import MAIN, MERGE
226
+
227
+ kv_bytes = self.batch * L * self.hk * self.d * 2 * leg.kv_bits / 8
228
+ return rc, ncu.parse_file(log, MAIN[leg.kind], MERGE[leg.kind], L, kv_bytes)
229
+
230
+ # ── full ncu profile (fp6prof) ─────────────────────────────────────────
231
+ def ncu_profile(
232
+ self, outdir: Path, name: str, leg: Leg, L: int, count: int = 3, section_set: str = "full"
233
+ ) -> tuple[int, dict]:
234
+ """Profile ``count`` launches of the leg's decode kernel with a full
235
+ section set; returns (rc, {"raw": {...}, "units": {...}, "details": {...}})."""
236
+ outdir.mkdir(parents=True, exist_ok=True)
237
+ cmd, env = self.leg_command(leg, L, count + 2)
238
+ secs = (
239
+ "--section SpeedOfLight --section MemoryWorkloadAnalysis --section WarpStateStats "
240
+ "--section SchedulerStats --section Occupancy --section LaunchStats --section ComputeWorkloadAnalysis"
241
+ )
242
+ rc = 1
243
+ for attempt in (f"--set {section_set}", secs):
244
+ cname = self._name("mjolnir-prof")
245
+ inner = (
246
+ f"mkdir -p /tmp/ncu-mjolnir && {_IN_NCU}/ncu --target-processes all --clock-control base "
247
+ f"--cache-control all --kernel-name regex:MixedInputFusedMultiHeadAttentionDecode "
248
+ f"--launch-skip 1 --launch-count {count} {attempt} -f -o /out/{name} {cmd} && "
249
+ f"{_IN_NCU}/ncu --import /out/{name}.ncu-rep --page raw --csv > /out/{name}.raw.csv && "
250
+ f"{_IN_NCU}/ncu --import /out/{name}.ncu-rep --page details --csv > /out/{name}.details.csv && "
251
+ f"chmod a+rw /out/{name}.*"
252
+ )
253
+ from mjolnir_cli.dockerctl import owner_suffix
254
+
255
+ inner += owner_suffix([f"/out/{name}.ncu-rep", f"/out/{name}.raw.csv", f"/out/{name}.details.csv"])
256
+ argv = [
257
+ "docker",
258
+ "run",
259
+ "--rm",
260
+ "--name",
261
+ cname,
262
+ "--gpus",
263
+ "all",
264
+ "--security-opt",
265
+ "seccomp=unconfined",
266
+ "--cap-add",
267
+ "SYS_ADMIN",
268
+ "--entrypoint",
269
+ "bash",
270
+ "-v",
271
+ f"{self.ncu_home}:{_IN_NCU}:ro",
272
+ "-v",
273
+ f"{DRIVERS_DIR}:{_IN_DRIVERS}:ro",
274
+ "-v",
275
+ f"{outdir}:/out",
276
+ "-e",
277
+ "TMPDIR=/tmp/ncu-mjolnir",
278
+ ]
279
+ image = self.image_for(leg)
280
+ if not self._ensure(image, outdir / f"{name}.log"):
281
+ return 2, {}
282
+ argv += (self._overlay() if leg.kind == "t5" else []) + [image, "-c", inner]
283
+ rc = self._run(argv, outdir / f"{name}.log", cname, timeout=max(self.timeout, 900.0))
284
+ if rc == 0:
285
+ break
286
+ print(f" ! {name}: ncu exit {rc} with [{attempt}] — retrying with fewer sections", file=sys.stderr)
287
+ if rc != 0 or self.dry_run:
288
+ return rc, {}
289
+ try:
290
+ raw, units = ncu.parse_raw_csv((outdir / f"{name}.raw.csv").read_text(errors="replace"))
291
+ det_p = outdir / f"{name}.details.csv"
292
+ details = ncu.parse_details_csv(det_p.read_text(errors="replace")) if det_p.exists() else {}
293
+ except (OSError, ValueError, ZeroDivisionError) as e: # a bad export loses one profile, not the run
294
+ print(f" ! {name}: could not parse the ncu export ({e}) — see {outdir}", file=sys.stderr)
295
+ return 1, {}
296
+ if not raw:
297
+ print(f" ! {name}: the ncu export has no metrics — see {outdir / (name + '.raw.csv')}", file=sys.stderr)
298
+ return 1, {}
299
+ return rc, {"raw": raw, "units": units, "details": details}
300
+
301
+ # ── vLLM-side tests (runner tests, smoke) ──────────────────────────────
302
+ def tests(self, log: Path, repo: Path, cmd: str, timeout: float, hf_home: Path) -> int:
303
+ name = self._name("mjolnir-vllm")
304
+ hf_home.mkdir(parents=True, exist_ok=True)
305
+ argv = [
306
+ "docker",
307
+ "run",
308
+ "--rm",
309
+ "--name",
310
+ name,
311
+ "--gpus",
312
+ "all",
313
+ "--ipc=host",
314
+ "--entrypoint",
315
+ "bash",
316
+ "-v",
317
+ f"{repo / 'src' / 'mjolnir'}:{_IN_SRC}:ro",
318
+ "-v",
319
+ f"{repo / 'tests' / 'kernel'}:/work/tests:ro",
320
+ "-v",
321
+ f"{hf_home}:/root/.cache/huggingface",
322
+ "-e",
323
+ f"PYTHONPATH={_IN_SRC}",
324
+ "-e",
325
+ f"HF_TOKEN={os.environ.get('HF_TOKEN', '')}",
326
+ "-e",
327
+ "TMPDIR=/tmp",
328
+ "-w",
329
+ "/work",
330
+ self.image,
331
+ "-c",
332
+ cmd,
333
+ ]
334
+ return self._run(argv, log, name, timeout)
@@ -0,0 +1,180 @@
1
+ """``mjolnir verify kv-cache-size`` — how many tokens of KV each config holds.
2
+
3
+ Serves every config in turn (the vLLM server is restarted per leg, like
4
+ ``bench e2e``), waits for the engine to size its KV cache, and reads the
5
+ numbers vLLM logs at startup::
6
+
7
+ Available KV cache memory: 38.12 GiB
8
+ GPU KV cache size: 2,656,033 tokens
9
+ Maximum concurrency for 204,800 tokens per request: 12.97x
10
+
11
+ The leg's server is stopped as soon as the numbers are in (no graph capture
12
+ or health wait needed). Charts turn the token count into the number of
13
+ concurrent requests each config fits at the e2e bench's request shapes.
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ import re
19
+ import time
20
+ from pathlib import Path
21
+
22
+ from mjolnir_cli import dockerctl
23
+ from mjolnir_cli.config import DEFAULT_IMAGE, STOCK_IMAGE, config_field, resolve
24
+ from mjolnir_cli.legs import verify_legs
25
+
26
+ DEFAULT_LEGS = verify_legs("verify.kv-cache-size") # mjolnir_cli.legs
27
+
28
+ _AVAIL = re.compile(r"Available KV cache memory:\s*([0-9.]+)\s*GiB")
29
+ _TOKENS = re.compile(r"GPU KV cache size:\s*([0-9,]+)\s*tokens")
30
+ _CONC = re.compile(r"Maximum concurrency for\s*([0-9,]+)\s*tokens per request:\s*([0-9.]+)x")
31
+ # where the rest of the memory budget went (vLLM's startup profile; optional —
32
+ # wording differs between vLLM versions, absent fields stay None)
33
+ _MEMORY = {
34
+ "load_gib": re.compile(r"Model loading took\s*([0-9.]+)\s*GiB"),
35
+ "weights_gib": re.compile(r"model weights take\s*([0-9.]+)\s*GiB"),
36
+ "activation_gib": re.compile(r"(?:PyTorch )?activation peak memory takes\s*([0-9.]+)\s*GiB"),
37
+ "non_torch_gib": re.compile(r"non[_ ]torch[_ ]memory takes\s*([0-9.]+)\s*GiB"),
38
+ "cudagraph_gib": re.compile(
39
+ r"(?:CUDA graph|cudagraph)[^\n]*?(?:take|took|estimate[ds]?|memory)[^\n]*?([0-9.]+)\s*GiB", re.I
40
+ ),
41
+ }
42
+ _FAILED = re.compile(r"EngineCore failed to start|Traceback \(most recent call last\)")
43
+
44
+ # the e2e bench's request: context depth + 2048 prompt + 128 generated tokens
45
+ E2E_PP, E2E_TG = 2048, 128
46
+
47
+
48
+ def parse_log(text: str) -> dict:
49
+ """The KV numbers from a vLLM startup log (absent ones are None)."""
50
+ out: dict = {"kv_gib": None, "kv_tokens": None, "max_model_len": None, "max_concurrency": None}
51
+ m = _AVAIL.search(text)
52
+ if m:
53
+ out["kv_gib"] = float(m.group(1))
54
+ m = _TOKENS.search(text)
55
+ if m:
56
+ out["kv_tokens"] = int(m.group(1).replace(",", ""))
57
+ m = _CONC.search(text)
58
+ if m:
59
+ out["max_model_len"] = int(m.group(1).replace(",", ""))
60
+ out["max_concurrency"] = float(m.group(2))
61
+ for key, rx in _MEMORY.items():
62
+ m = rx.search(text)
63
+ out[key] = float(m.group(1)) if m else None
64
+ return out
65
+
66
+
67
+ def fits(kv_tokens: int, tokens_per_request: int) -> int:
68
+ """Whole requests of ``tokens_per_request`` that fit in ``kv_tokens``."""
69
+ return kv_tokens // max(1, tokens_per_request)
70
+
71
+
72
+ def _legs(spec: str) -> list[tuple[str, str]]:
73
+ out = []
74
+ for part in spec.replace(" ", "").split(","):
75
+ if not part:
76
+ continue
77
+ side, _, quant = part.partition(":")
78
+ if side not in ("official", "mjolnir") or not quant:
79
+ raise ValueError(f"leg {part!r}: expected official:<config> or mjolnir:<config>")
80
+ out.append((side, quant))
81
+ return out
82
+
83
+
84
+ def _docker_logs() -> str:
85
+ import subprocess
86
+
87
+ p = subprocess.run([dockerctl._docker(), "logs", dockerctl.CONTAINER_NAME], capture_output=True, text=True)
88
+ return dockerctl._strip_ansi((p.stdout or "") + (p.stderr or ""))
89
+
90
+
91
+ def _container_state() -> str:
92
+ rc, out = dockerctl._run_quiet(
93
+ [dockerctl._docker(), "inspect", "-f", "{{.State.Status}}", dockerctl.CONTAINER_NAME], ok_codes=(0, 1)
94
+ )
95
+ return out.strip() if rc == 0 else "absent"
96
+
97
+
98
+ def stage_kvcache(
99
+ ctx,
100
+ legs: str = DEFAULT_LEGS,
101
+ model: str | None = None,
102
+ restart: bool = False,
103
+ timeout: float = 1800.0,
104
+ ) -> dict:
105
+ """Serve each leg, read its KV cache size, stop it."""
106
+ rows, cases = [], []
107
+ mj_image = ctx.runner.image or DEFAULT_IMAGE
108
+ if dockerctl.container_running() and not restart and not ctx.runner.dry_run:
109
+ ctx.out(
110
+ "[red] a vLLM server is running — this stage restarts the server for every leg. "
111
+ "Stop it in a window you own (mjolnir serve down), or pass --restart.[/red]"
112
+ )
113
+ return {
114
+ "rows": [],
115
+ "cases": [{"label": "server busy (not restarted)", "passed": False}],
116
+ "params": {"legs": legs},
117
+ }
118
+ (ctx.logdir / "kv-cache-size").mkdir(parents=True, exist_ok=True)
119
+ for side, quant in _legs(legs):
120
+ s = resolve(model, quant, STOCK_IMAGE if side == "official" else mj_image, None)
121
+ leg_id = f"{side}-{quant}"
122
+ log = ctx.logdir / "kv-cache-size" / f"{leg_id}.log"
123
+ kv_dtype = config_field(s.config_path, "kv-cache-dtype") or "auto"
124
+ row = {
125
+ "id": leg_id,
126
+ "side": side,
127
+ "config": quant,
128
+ "image": s.image,
129
+ "model": s.model,
130
+ "backend": s.attention_backend,
131
+ "kv_dtype": kv_dtype,
132
+ "gpu_memory_utilization": config_field(s.config_path, "gpu-memory-utilization"),
133
+ "log": str(log),
134
+ }
135
+ ctx.out(f" ▸ {leg_id} ({s.image}, kv {kv_dtype})")
136
+ if ctx.runner.dry_run:
137
+ dockerctl.serve_up(s, wait=False, dry_run=True)
138
+ continue
139
+ if not s.config_path.exists():
140
+ cases.append({"label": leg_id, "passed": False, "error": f"config not found: {s.config_path}"})
141
+ continue
142
+ stale = dockerctl.stale_image(s.image) if side == "mjolnir" else None
143
+ if stale:
144
+ ctx.out(f"[yellow] warning: {stale}[/yellow]")
145
+ t0 = time.time()
146
+ text, err = "", None
147
+ try:
148
+ dockerctl.serve_up(s, wait=False)
149
+ while time.time() - t0 < timeout:
150
+ time.sleep(5)
151
+ text = _docker_logs()
152
+ if _CONC.search(text) and _TOKENS.search(text):
153
+ break
154
+ if _FAILED.search(text) or _container_state() in ("exited", "dead", "absent"):
155
+ err = "the server failed before sizing its KV cache"
156
+ break
157
+ else:
158
+ err = f"no KV cache size after {timeout:.0f}s"
159
+ except dockerctl.DockerError as e:
160
+ err = str(e)
161
+ finally:
162
+ text = text or _docker_logs()
163
+ Path(log).write_text(text)
164
+ dockerctl.serve_down(s)
165
+ # an engine that crashed leaves an exited container behind, whose
166
+ # name would block the next leg's docker run
167
+ dockerctl._run_quiet([dockerctl._docker(), "rm", "-f", dockerctl.CONTAINER_NAME], ok_codes=(0, 1))
168
+ row.update(parse_log(text))
169
+ row["startup_s"] = round(time.time() - t0, 1)
170
+ ok = err is None and row["kv_tokens"] is not None
171
+ rows.append(row)
172
+ cases.append({"label": leg_id, "passed": ok, **({"error": err} if err else {}), "log": str(log)})
173
+ if ok:
174
+ ctx.out(
175
+ f" ✅ {row['kv_tokens']:,} tokens · {row['kv_gib'] or 0:.2f} GiB · "
176
+ f"{row['max_concurrency']:.2f}x at {row['max_model_len']:,} tokens/request"
177
+ )
178
+ else:
179
+ ctx.out(f" ❌ {err or 'KV cache size not found in the log'} — {log}")
180
+ return {"rows": rows, "cases": cases, "params": {"legs": legs, "model": model, "e2e_pp": E2E_PP, "e2e_tg": E2E_TG}}
@@ -0,0 +1,134 @@
1
+ """Benchmark legs: what one ncu-profiled series runs, and how to read it.
2
+
3
+ A *leg* is one kernel configuration swept over the L axis:
4
+
5
+ * ``mjolnir`` — the Mjolnir kernel at the harness default (fp8 Q, e2m3 KV
6
+ on head-major HND pages, 2 CTAs/SM, evict-first, the
7
+ 2-stage fp6 ring) — the kernel's best measured config;
8
+ * ``mjolnir-fp8`` — the Mjolnir kernel on fp8 KV on HND pages (the
9
+ `NVFP4_MJOLNIR` config; the default serves e2m3);
10
+ * ``fi`` — FlashInfer tensor-core decode as vLLM runs it (fp8 KV,
11
+ NHD pages) — the strongest stock baseline on Thor;
12
+ * ``fi-hnd`` — FlashInfer on the same data in HND pages (fair layout check);
13
+ * ``fa2`` — FlashAttention 2 as stock vLLM 0.31.0 runs it on sm_110
14
+ (bf16 KV — FA2 has no fp8 KV path, so 2x the bytes);
15
+ * T5 variants — the Mjolnir harness with extra flags (ab / ring / ...).
16
+
17
+ Every leg profiles only its own kernels (``filter``) and splits them into
18
+ the main decode kernel(s) and the split-KV merge (``main`` / ``merge``
19
+ substrings of the ncu kernel name).
20
+ """
21
+
22
+ from __future__ import annotations
23
+
24
+ import re
25
+ import shlex
26
+ from dataclasses import dataclass, field
27
+
28
+ # Bits per K/V element of each KV format (nvfp4 adds one e4m3 scale per 16).
29
+ KV_BITS = {"fp8": 8.0, "e3m2": 6.0, "e2m3": 6.0, "e2m1": 4.0, "nvfp4": 4.5, "bf16": 16.0}
30
+
31
+ # The harness default the `mjolnir` leg runs (kept in sync with
32
+ # mjolnir.t5_decode: HARNESS_KV_FORMAT_FP8Q / HARNESS_KV_LAYOUT).
33
+ HARNESS_KV_FORMAT = "e2m3"
34
+ HARNESS_KV_LAYOUT = "hnd"
35
+
36
+ FILTERS = {
37
+ "fi": "regex:BatchPrefillWithPagedKVCache|BatchDecode|MergeStates",
38
+ "fa2": "regex:flash_fwd",
39
+ "t5": "regex:MixedInputFusedMultiHeadAttentionDecode|reduction",
40
+ }
41
+ MAIN = {
42
+ "fi": ("BatchPrefillWithPagedKVCache", "BatchDecode"),
43
+ "fa2": ("flash_fwd_kernel", "flash_fwd_splitkv_kernel"),
44
+ "t5": ("MixedInputFusedMultiHeadAttentionDecode",),
45
+ }
46
+ MERGE = {
47
+ "fi": ("MergeStates",),
48
+ "fa2": ("combine",),
49
+ "t5": ("reduction",),
50
+ }
51
+
52
+
53
+ @dataclass(frozen=True)
54
+ class Leg:
55
+ id: str # stable series id (file names, chart colors, README lookups)
56
+ label: str # human label
57
+ kind: str # "fi" | "fa2" | "t5"
58
+ role: str = "variant" # "ours" | "baseline" | "variant"
59
+ flags: str = "" # harness flags (t5) — appended after the common args
60
+ env: tuple[tuple[str, str], ...] = field(default_factory=tuple) # driver env (fi / fa2)
61
+ q: str = "fp8" # t5 Q dtype: fp8 | bf16
62
+
63
+ @property
64
+ def kv_format(self) -> str:
65
+ if self.kind == "fa2":
66
+ return "bf16"
67
+ if self.kind == "fi":
68
+ return "fp8"
69
+ fmt = _last_flag(self.flags, "--kv_format")
70
+ if fmt in (None, "auto"):
71
+ return HARNESS_KV_FORMAT if self.q == "fp8" else "fp8"
72
+ return fmt
73
+
74
+ @property
75
+ def kv_layout(self) -> str:
76
+ if self.kind == "fi":
77
+ return dict(self.env).get("FI_LAYOUT", "NHD").lower()
78
+ if self.kind == "fa2":
79
+ return "nhd"
80
+ return _last_flag(self.flags, "--kv_layout") or HARNESS_KV_LAYOUT
81
+
82
+ @property
83
+ def kv_bits(self) -> float:
84
+ return KV_BITS.get(self.kv_format, 8.0)
85
+
86
+ def describe(self) -> str:
87
+ return f"{self.kv_format} KV · {self.kv_layout.upper()}" + (f" · {self.flags}" if self.flags.strip() else "")
88
+
89
+
90
+ def _last_flag(flags: str, name: str) -> str | None:
91
+ """Value of the last ``--name value`` in a flag string (argparse: last wins)."""
92
+ out = None
93
+ toks = shlex.split(flags or "")
94
+ for i, t in enumerate(toks):
95
+ if t == name and i + 1 < len(toks):
96
+ out = toks[i + 1]
97
+ elif t.startswith(name + "="):
98
+ out = t.split("=", 1)[1]
99
+ return out
100
+
101
+
102
+ # ── the canonical legs ──────────────────────────────────────────────────────
103
+ MJOLNIR = Leg("mjolnir", "Mjolnir (e2m3 KV, HND)", "t5", role="ours")
104
+ MJOLNIR_FP8 = Leg("mjolnir-fp8", "Mjolnir (fp8 KV, HND)", "t5", role="ours", flags="--kv_format fp8 --kv_layout hnd")
105
+ FI = Leg("fi", "FlashInfer (fp8 KV, NHD)", "fi", role="baseline", env=(("FI_LAYOUT", "NHD"),))
106
+ FI_HND = Leg("fi-hnd", "FlashInfer (fp8 KV, HND)", "fi", role="baseline", env=(("FI_LAYOUT", "HND"),))
107
+ FA2 = Leg("fa2", "FlashAttention 2 (bf16 KV)", "fa2", role="baseline")
108
+
109
+ CANONICAL = {leg.id: leg for leg in (MJOLNIR, MJOLNIR_FP8, FI, FI_HND, FA2)}
110
+
111
+
112
+ def t5_variant(name: str, flags: str, q: str = "fp8") -> Leg:
113
+ return Leg(f"t5:{name}", name, "t5", role="variant", flags=flags, q=q)
114
+
115
+
116
+ def parse_variants(spec: str) -> list[Leg]:
117
+ """``"name:flags|name:flags"`` (the verify.sh AB_VARIANTS syntax) → legs."""
118
+ out = []
119
+ for part in (spec or "").split("|"):
120
+ part = part.strip()
121
+ if not part:
122
+ continue
123
+ name, _, flags = part.partition(":")
124
+ out.append(t5_variant(name.strip(), flags.strip()))
125
+ return out
126
+
127
+
128
+ def legs_from_ids(ids: str | list[str]) -> list[Leg]:
129
+ if isinstance(ids, str):
130
+ ids = [x for x in re.split(r"[,\s]+", ids) if x]
131
+ unknown = [i for i in ids if i not in CANONICAL]
132
+ if unknown:
133
+ raise ValueError(f"unknown leg(s) {unknown}; known: {', '.join(CANONICAL)}")
134
+ return [CANONICAL[i] for i in ids]