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,533 @@
1
+ """Serve orchestration: drive the vLLM docker container from Python.
2
+
3
+ The vLLM server runs in a container because the whole point of this repo is
4
+ a *custom* image (the sm_110 patch stack + the Mjolnir decode kernel behind
5
+ ``--attention-backend MJOLNIR``) — ``mjolnir serve`` replaces the
6
+ bash/docker-compose glue, not the container.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import os
12
+ import re
13
+ import shutil
14
+ import subprocess
15
+ import sys
16
+ import time
17
+ import urllib.request
18
+ from pathlib import Path
19
+
20
+ from mjolnir_cli import cachectl
21
+ from mjolnir_cli.config import CONTAINER_NAME, Settings, config_field, configs_root, user_configs_dir
22
+
23
+ # Container-side env (the Thor-specific knobs from the old vllm.yaml service).
24
+ _CONTAINER_ENV: list[str] = [
25
+ "DO_NOT_TRACK=1",
26
+ "VLLM_NO_USAGE_STATS=1",
27
+ "MAX_JOBS=4",
28
+ "NVCC_THREADS=2",
29
+ "FLASHINFER_NVCC_THREADS=2",
30
+ "VLLM_HTTP_TIMEOUT_KEEP_ALIVE=600",
31
+ "FLASHINFER_DISABLE_VERSION_CHECK=1",
32
+ "VLLM_CACHE_ROOT=/data/cache",
33
+ "VLLM_ASSETS_CACHE=/data/assets",
34
+ "VLLM_TUNED_CONFIG_FOLDER=/data/kernels-cache",
35
+ "FLASHINFER_JIT_DIR=/data/flashinfer-cache",
36
+ # FlashInfer 0.7 keeps its JIT modules, CuTe-DSL objects and downloaded
37
+ # cubins under $FLASHINFER_WORKSPACE_BASE/.cache/flashinfer (default:
38
+ # $HOME, i.e. the container's throwaway /root): point it at the mounted
39
+ # cache so fp4_gemm_cutlass & co. build once, not on every server start
40
+ "FLASHINFER_WORKSPACE_BASE=/data/flashinfer-cache",
41
+ "TRITON_CACHE_DIR=/data/triton-cache",
42
+ "TORCHINDUCTOR_CACHE_DIR=/data/cache/inductor",
43
+ "TORCH_COMPILE_CACHE_DIR=/data/cache/torch_compile_cache",
44
+ "XDG_CACHE_HOME=/data/cache/xdg",
45
+ "TIKTOKEN_ENCODINGS_BASE=/data/tiktoken",
46
+ "TIKTOKEN_RS_CACHE_DIR=/data/tiktoken",
47
+ "VLLM_LOGGING_LEVEL=INFO",
48
+ "NVIDIA_VISIBLE_DEVICES=all",
49
+ "NVIDIA_DRIVER_CAPABILITIES=compute,utility,graphics",
50
+ "CUDA_VISIBLE_DEVICES=0",
51
+ "VLLM_TRITON_USE_TD=1",
52
+ "PYTHONUNBUFFERED=1",
53
+ "SAFETENSORS_FAST_GPU=1",
54
+ "NVIDIA_DISABLE_REQUIRE=1",
55
+ "NVIDIA_FORWARD_COMPAT=1",
56
+ "NCCL_P2P_DISABLE=1",
57
+ "ENABLE_TRIATTENTION=0",
58
+ "CUDA_MANAGED_FORCE_DEVICE_ALLOC=1",
59
+ "TORCH_CUDA_ARCH_LIST=11.0a",
60
+ "FLASHINFER_CUDA_ARCH_LIST=11.0a",
61
+ "CUDA_TOOLKIT_PATH=/usr/local/cuda-13",
62
+ "CUTE_DSL_ARCH=sm_110a",
63
+ "TORCH_MATMUL_PRECISION=high",
64
+ "VLLM_DISABLED_KERNELS=CutlassFP8ScaledMMLinearKernel",
65
+ "VLLM_USAGE_SOURCE=production",
66
+ "TRITON_PTXAS_PATH=/usr/local/cuda/bin/ptxas",
67
+ "VLLM_USE_BREAKABLE_CUDAGRAPH=0",
68
+ "PYTORCH_CUDA_ALLOC_CONF=expandable_segments:True",
69
+ # Thor-only host overrides from the old compose file:
70
+ "VLLM_ALLOW_LONG_MAX_MODEL_LEN=1",
71
+ "VLLM_MARLIN_USE_ATOMIC_ADD=1",
72
+ ]
73
+
74
+ _HOST_MOUNTS = [
75
+ "/etc/nv_tegra_release", # L4T id (Jetson)
76
+ "/tmp/nv_jetson_model",
77
+ "/etc/localtime",
78
+ "/etc/machine-id",
79
+ ]
80
+
81
+
82
+ class DockerError(RuntimeError):
83
+ pass
84
+
85
+
86
+ def _docker() -> str:
87
+ if shutil.which("docker") is None:
88
+ raise DockerError("docker not found on PATH")
89
+ return "docker"
90
+
91
+
92
+ def _run(args: list[str], **kw) -> subprocess.CompletedProcess:
93
+ return subprocess.run(args, check=True, **kw)
94
+
95
+
96
+ def _run_quiet(args: list[str], ok_codes: tuple[int, ...] = (0, 1)) -> tuple[int, str]:
97
+ p = subprocess.run(args, capture_output=True, text=True)
98
+ if p.returncode not in ok_codes:
99
+ raise DockerError(f"{' '.join(args[:3])}… failed ({p.returncode}): {(p.stderr or p.stdout).strip()[:500]}")
100
+ return p.returncode, p.stdout.strip()
101
+
102
+
103
+ def container_running(name: str = CONTAINER_NAME) -> bool:
104
+ _, out = _run_quiet([_docker(), "inspect", "-f", "{{.State.Running}}", name])
105
+ return out == "true"
106
+
107
+
108
+ # Mjolnir reads K/V fastest from head-major pages (each KV head's page is one
109
+ # contiguous block): +11-17% decode at long context in `mjolnir verify
110
+ # hndsweep`. vLLM allocates that layout (LBHNC) with VLLM_KV_CACHE_LAYOUT=HND;
111
+ # both fallbacks (FlashAttention 4, FlashInfer) read it through strides, so
112
+ # MJOLNIR configs get it by default.
113
+ MJOLNIR_ENV_DEFAULTS = {"VLLM_KV_CACHE_LAYOUT": "HND"}
114
+
115
+ # fp6 (e2m3) KV needs per-channel scales from `mjolnir calibrate`, stored next
116
+ # to the model's configs (mounted at /configs in the container).
117
+ KV_SCALES_FILE = "kv_scales.json"
118
+
119
+
120
+ # What the image's installed mjolnir package is built from: `mjolnir image
121
+ # build` stamps the checkout's HEAD into this label.
122
+ REVISION_LABEL = "org.opencontainers.image.revision"
123
+ KERNEL_PATHS = ("src/mjolnir", "docker/vllm-thor")
124
+
125
+
126
+ def stale_image(image: str) -> str | None:
127
+ """Why ``image`` serves an older Mjolnir than this checkout, or None.
128
+
129
+ Serving uses the package installed in the image (only verify and
130
+ calibrate overlay the checkout's source), so a checkout that changed
131
+ ``src/mjolnir`` or the patch stack after the build serves old code. None
132
+ when it cannot tell: no checkout, no label (e.g. the official image), or
133
+ a revision git does not know."""
134
+ try:
135
+ from mjolnir_cli.config import find_repo_root
136
+
137
+ root = find_repo_root()
138
+ except FileNotFoundError:
139
+ return None
140
+ try:
141
+ rc, rev = _run_quiet(
142
+ [_docker(), "image", "inspect", "-f", '{{ index .Config.Labels "' + REVISION_LABEL + '" }}', image]
143
+ )
144
+ except (DockerError, OSError): # no docker / no image: nothing to say
145
+ return None
146
+ rev = rev.strip()
147
+ if rc != 0 or not rev or rev in ("unknown", "<no value>"):
148
+ return None
149
+ r = subprocess.run(
150
+ ["git", "-C", str(root), "diff", "--quiet", rev, "HEAD", "--", *KERNEL_PATHS],
151
+ capture_output=True,
152
+ )
153
+ if r.returncode != 1: # 0: same code; >1: revision unknown here
154
+ return None
155
+ log = (
156
+ subprocess.run(
157
+ ["git", "-C", str(root), "log", "--oneline", f"{rev}..HEAD", "--", *KERNEL_PATHS],
158
+ capture_output=True,
159
+ text=True,
160
+ )
161
+ .stdout.strip()
162
+ .splitlines()
163
+ )
164
+ head = f"{len(log)} commit(s)" if log else "changes"
165
+ return (
166
+ f"{image} was built from {rev}; {head} to {', '.join(KERNEL_PATHS)} since "
167
+ f"({'; '.join(log[:3])}{' ...' if len(log) > 3 else ''}). It serves the old code — "
168
+ "rebuild: mjolnir image build"
169
+ )
170
+
171
+
172
+ def kv_scales_host_path(s: Settings) -> Path:
173
+ """Where `mjolnir calibrate` writes, and an fp6 config's scales are read
174
+ from (repo or user-local configs dir).
175
+
176
+ ``kv_scales.json`` is the model's — next to the config dirs, shared by
177
+ every config without its own drafter (the built-in MTP head is part of
178
+ the model). A config whose external drafter (DSpark, DFlash, ...) runs
179
+ on the Mjolnir backend has layers no other config has: it gets its own
180
+ ``kv_scales.json`` inside its config dir, target and drafter layers in
181
+ one file, written by ``mjolnir calibrate --config <config>``."""
182
+ from mjolnir_cli.config import config_drafter_on_mjolnir
183
+
184
+ cdir = s.config_path.parent
185
+ own = cdir / KV_SCALES_FILE
186
+ if own.exists() or config_drafter_on_mjolnir(s.config_path):
187
+ return own
188
+ return cdir.parent / KV_SCALES_FILE
189
+
190
+
191
+ def missing_kv_scales(s: Settings) -> str | None:
192
+ """Why an fp6 config cannot start (its calibration file is missing), or None."""
193
+ if (s.attention_backend or "") not in ("MJOLNIR", "CUSTOM") or os.environ.get("MJOLNIR_ATTN_KV_SCALES"):
194
+ return None
195
+ if not (config_field(s.config_path, "kv-cache-dtype") or "").startswith("fp6"):
196
+ return None
197
+ path = kv_scales_host_path(s)
198
+ if path.exists():
199
+ return None
200
+ return f"{s.quant} serves fp6 KV but {path} is missing — calibrate it first: mjolnir calibrate --config {s.quant}"
201
+
202
+
203
+ def backend_env(s: Settings) -> list[str]:
204
+ """Container env for the config's attention backend: the MJOLNIR
205
+ defaults (overridable from the host env) + every ``MJOLNIR_ATTN_*`` /
206
+ ``THOR_T5_*`` kernel knob set on the host. An fp6_e2m3 config also gets
207
+ MJOLNIR_ATTN_KV_SCALES pointed at its calibration file."""
208
+ out: list[str] = []
209
+ if (s.attention_backend or "") in ("MJOLNIR", "CUSTOM"):
210
+ out.append(f"MJOLNIR_CONFIG={s.quant}") # the boot banner's `Model:` line
211
+ for k, v in MJOLNIR_ENV_DEFAULTS.items():
212
+ out.append(f"{k}={os.environ.get(k, v)}")
213
+ if (config_field(s.config_path, "kv-cache-dtype") or "").startswith("fp6") and not os.environ.get(
214
+ "MJOLNIR_ATTN_KV_SCALES"
215
+ ):
216
+ out.append(f"MJOLNIR_ATTN_KV_SCALES={_container_path(s, kv_scales_host_path(s))}")
217
+ out += config_env(s)
218
+ for k, v in sorted(os.environ.items()):
219
+ if k.startswith(("MJOLNIR_ATTN_", "THOR_T5_")):
220
+ out.append(f"{k}={v}")
221
+ return out
222
+
223
+
224
+ def config_env(s: Settings) -> list[str]:
225
+ """``KEY=VALUE`` lines from the config's ``.env`` sidecar (inside its
226
+ config dir) — how an opt-in experiment config sets env vars vLLM's YAML
227
+ cannot carry (e.g. ``MJOLNIR_LM_HEAD=fp8``). ``{configs}`` expands to the
228
+ model's config dir as the container sees it; ``#`` starts a comment."""
229
+ side = s.config_path.parent / ".env"
230
+ if not side.exists():
231
+ return []
232
+ cdir = f"/configs/{s.model}"
233
+ out = []
234
+ for line in side.read_text().splitlines():
235
+ line = line.split("#", 1)[0].strip()
236
+ if "=" in line:
237
+ k, v = line.split("=", 1)
238
+ out.append(f"{k.strip()}={v.strip().replace('{configs}', cdir)}")
239
+ return out
240
+
241
+
242
+ def _container_path(s: Settings, host: Path) -> str:
243
+ """A file under the configs dir, as the container sees it."""
244
+ try:
245
+ return "/configs/" + str(Path(host).relative_to(configs_root()))
246
+ except ValueError: # user-local file: mirror its position under /configs/<model>/
247
+ try:
248
+ rel = str(Path(host).relative_to(user_configs_dir() / s.model))
249
+ except ValueError:
250
+ rel = Path(host).name
251
+ return f"/configs/{s.model}/{rel}"
252
+
253
+
254
+ def build_run_args(
255
+ s: Settings,
256
+ image: str | None = None,
257
+ *,
258
+ name: str = CONTAINER_NAME,
259
+ detach: bool = True,
260
+ command: str | None = None,
261
+ extra_env: tuple[str, ...] = (),
262
+ extra_mounts: tuple[tuple[str, str], ...] = (),
263
+ serve_args: tuple[str, ...] = (),
264
+ chown: tuple[str, ...] = (),
265
+ ) -> list[str]:
266
+ """Assemble the full ``docker run`` argv for the vLLM container (the
267
+ server by default; ``command`` runs something else with the same mounts,
268
+ env and GPU access — ``mjolnir calibrate``). ``serve_args`` are appended
269
+ to ``vllm serve`` and override the config (e.g. ``--speculative-config``
270
+ for ``mjolnir verify spec``). ``chown`` lists container paths a
271
+ ``command`` writes into host mounts: they are handed to the invoking
272
+ user when it ends (the container runs as root)."""
273
+ image = image or s.image
274
+ cmd = [
275
+ _docker(),
276
+ "run",
277
+ *(["-d"] if detach else ["--rm"]),
278
+ "--name",
279
+ name,
280
+ "--gpus",
281
+ "all",
282
+ "--ipc",
283
+ "host",
284
+ "--privileged",
285
+ "--user",
286
+ "0:0",
287
+ # no --memory-swappiness: cgroup v2 (JetPack 7) has no per-container
288
+ # swappiness, docker only warns and discards it
289
+ "--ulimit",
290
+ "memlock=-1",
291
+ "--ulimit",
292
+ "stack=67108864",
293
+ "--ulimit",
294
+ "nofile=65536:65536",
295
+ "--restart",
296
+ "no",
297
+ # base image ENTRYPOINT is ["vllm","serve"] — pin ours, so the command
298
+ # below is the full argv (unpinned, docker would compose
299
+ # "vllm serve bash -lc ..." and argparse would reject it at startup)
300
+ "--entrypoint",
301
+ "bash",
302
+ ]
303
+
304
+ for k in _CONTAINER_ENV:
305
+ cmd += ["-e", k]
306
+ for kv in backend_env(s):
307
+ cmd += ["-e", kv]
308
+ for kv in extra_env:
309
+ cmd += ["-e", kv]
310
+ if os.environ.get("HF_TOKEN"):
311
+ cmd += ["-e", f"HF_TOKEN={os.environ['HF_TOKEN']}"]
312
+
313
+ d = s.data_dir
314
+ mounts = [
315
+ (str(d / "models"), "/data/models"),
316
+ (str(d / "cache"), "/data/cache"),
317
+ (str(d / "kernels-cache"), "/data/kernels-cache"),
318
+ (str(d / "flashinfer-cache"), "/data/flashinfer-cache"),
319
+ (str(d / "triton-cache"), "/data/triton-cache"),
320
+ (str(d / "tiktoken"), "/data/tiktoken"),
321
+ (str(configs_root()), "/configs:ro"),
322
+ ("/dev/shm", "/dev/shm"),
323
+ ("/etc/localtime", "/etc/localtime:ro"),
324
+ ("/etc/machine-id", "/etc/machine-id:ro"),
325
+ ]
326
+ # A user-local model config (outside the repo's configs/) is mounted as
327
+ # its config dir on top of the /configs mount (the more-specific mount
328
+ # wins): config.yaml + its .env sidecar, and its own scales when it has
329
+ # them.
330
+ local_cfg = s.config_path
331
+ repo_cfg = configs_root() / s.model / s.quant / "config.yaml"
332
+ if local_cfg != repo_cfg and local_cfg.exists():
333
+ mounts.append((str(local_cfg.parent), f"/configs/{s.model}/{s.quant}:ro"))
334
+ scales = kv_scales_host_path(s)
335
+ if scales.exists() and scales.parent != local_cfg.parent:
336
+ mounts.append((str(scales), f"/configs/{s.model}/{scales.name}:ro"))
337
+ mounts += list(extra_mounts)
338
+ for host, container in mounts:
339
+ if host.startswith("/data") is False and not Path(host).exists() and not host.startswith(("/dev/shm", "/etc/")):
340
+ continue # optional host files (e.g. /etc/nv_tegra_release)
341
+ cmd += ["-v", f"{host}:{container}"]
342
+ if command is not None:
343
+ return [*cmd, image, "-lc", command + owner_suffix(chown)]
344
+ cmd += ["-p", f"{s.port}:8000"]
345
+
346
+ serve = (
347
+ "vllm serve "
348
+ f"--config /configs/{s.model}/{s.quant}/config.yaml "
349
+ "--host 0.0.0.0 "
350
+ "--cpu-offload-gb 0 "
351
+ "--pipeline-parallel-size 1 "
352
+ "--disable-fastapi-docs "
353
+ "--enable-force-include-usage "
354
+ "--enable-prompt-tokens-details "
355
+ "--download-dir /data/models/huggingface"
356
+ )
357
+ if serve_args:
358
+ import shlex
359
+
360
+ serve += " " + " ".join(shlex.quote(a) for a in serve_args)
361
+ cmd += [image, "-lc", serve]
362
+ return cmd
363
+
364
+
365
+ def host_owner() -> str | None:
366
+ """``uid:gid`` of the user running the CLI (the sudo caller under sudo);
367
+ ``None`` off POSIX."""
368
+ if not hasattr(os, "getuid"):
369
+ return None
370
+ uid, gid = os.getuid(), os.getgid()
371
+ if uid == 0 and os.environ.get("SUDO_UID"):
372
+ uid, gid = int(os.environ["SUDO_UID"]), int(os.environ.get("SUDO_GID", gid))
373
+ return f"{uid}:{gid}"
374
+
375
+
376
+ def owner_suffix(paths: tuple[str, ...] | list[str]) -> str:
377
+ """Shell tail for a root container command: hand ``paths`` (container
378
+ paths in host mounts) to the host user, keep the command's exit code.
379
+ Without it a file the container writes into the checkout is root-owned
380
+ and git hooks (end-of-file-fixer) cannot rewrite it."""
381
+ owner = host_owner()
382
+ if not paths or owner is None:
383
+ return ""
384
+ import shlex
385
+
386
+ quoted = " ".join(shlex.quote(p) for p in paths)
387
+ return f"; rc=$?; chown -R {owner} {quoted} 2>/dev/null; exit $rc"
388
+
389
+
390
+ def run_once(argv: list[str], name: str) -> int:
391
+ """Run a one-shot container in the foreground (``docker run --rm``) and
392
+ always clean up after it: remove the container even when the CLI is
393
+ interrupted (stopping the docker client leaves the container running,
394
+ holding the GPU), then clear the host page cache — on Thor the cache the
395
+ container filled stays resident after it exits."""
396
+ cachectl.prepare_bench()
397
+ try:
398
+ return subprocess.run(argv).returncode
399
+ except KeyboardInterrupt:
400
+ print(f"\ninterrupted — removing container {name}", file=sys.stderr)
401
+ return 130
402
+ finally:
403
+ try:
404
+ _run_quiet([_docker(), "rm", "-f", name])
405
+ except DockerError:
406
+ pass
407
+ cachectl.prepare_bench()
408
+
409
+
410
+ def serve_up(
411
+ s: Settings,
412
+ wait: bool = True,
413
+ wait_timeout_s: float = 1800.0,
414
+ dry_run: bool = False,
415
+ serve_args: tuple[str, ...] = (),
416
+ ) -> None:
417
+ cmd = build_run_args(s, serve_args=serve_args)
418
+ if dry_run:
419
+ print(" ".join(cmd))
420
+ return
421
+ why = missing_kv_scales(s)
422
+ if why:
423
+ raise DockerError(why)
424
+ # Always-before: clear the host page cache before starting vLLM (a clean,
425
+ # reproducible model-load). No-op + hint if the grant is missing; never
426
+ # prompts, never blocks.
427
+ cachectl.prepare_bench()
428
+ for sub in [
429
+ s.data_dir / p for p in ("models", "cache", "kernels-cache", "flashinfer-cache", "triton-cache", "tiktoken")
430
+ ]:
431
+ sub.mkdir(parents=True, exist_ok=True)
432
+ if container_running():
433
+ print("container already running — stopping the old one first")
434
+ serve_down(s)
435
+ _run(cmd)
436
+ if wait:
437
+ wait_healthy(s, wait_timeout_s)
438
+
439
+
440
+ def serve_down(s: Settings, dry_run: bool = False) -> None:
441
+ if not container_running():
442
+ print("nothing to stop (container not running)")
443
+ return
444
+ if dry_run:
445
+ print(f"{_docker()} rm -f {CONTAINER_NAME}")
446
+ return
447
+ _run_quiet([_docker(), "rm", "-f", CONTAINER_NAME])
448
+ print("stopped")
449
+ # After stopping: clear the page cache (release the serving file activity
450
+ # that populated it). No-op + hint if the grant is missing.
451
+ cachectl.prepare_bench()
452
+
453
+
454
+ def wait_healthy(s: Settings, timeout_s: float = 1800.0) -> None:
455
+ """Poll the OpenAI /health endpoint until the server answers (model load
456
+ + JIT on Thor takes minutes)."""
457
+ url = f"http://127.0.0.1:{s.port}/health"
458
+ t0 = time.time()
459
+ print(f"waiting for the server at {url} (up to {timeout_s:.0f}s)…")
460
+ while time.time() - t0 < timeout_s:
461
+ try:
462
+ with urllib.request.urlopen(url, timeout=5) as r:
463
+ if r.status == 200:
464
+ print(f"healthy after {time.time() - t0:.0f}s")
465
+ return
466
+ except Exception: # noqa: BLE001
467
+ time.sleep(5)
468
+ raise TimeoutError(f"server not healthy after {timeout_s:.0f}s — check: mjolnir serve logs")
469
+
470
+
471
+ def status(s: Settings) -> dict:
472
+ running = container_running()
473
+ state: dict = {"container": CONTAINER_NAME, "running": running}
474
+ if running:
475
+ _, out = _run_quiet(
476
+ [_docker(), "inspect", "-f", "{{.State.Status}} started {{.State.StartedAt}}", CONTAINER_NAME]
477
+ )
478
+ state["state"] = out
479
+ try:
480
+ with urllib.request.urlopen(f"http://127.0.0.1:{s.port}/health", timeout=5) as r:
481
+ state["health"] = "ok" if r.status == 200 else f"HTTP {r.status}"
482
+ except Exception: # noqa: BLE001
483
+ state["health"] = "unreachable"
484
+ return state
485
+
486
+
487
+ def logs(s: Settings, follow: bool = False, tail: int = 100) -> int:
488
+ if not container_running():
489
+ print("container not running", file=sys.stderr)
490
+ return 1
491
+ args = [_docker(), "logs", "--tail", str(tail)]
492
+ if follow:
493
+ args += ["-f"]
494
+ args += [CONTAINER_NAME]
495
+ try:
496
+ return subprocess.call(args)
497
+ except KeyboardInterrupt:
498
+ return 0
499
+
500
+
501
+ # ANSI escape sequences: CSI (SGR colors, cursor moves) + OSC (e.g. hyperlinks).
502
+ _ANSI_RE = re.compile(r"\x1b\[[0-9;?]*[ -/]*[@-~]|\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)")
503
+
504
+
505
+ def _strip_ansi(text: str) -> str:
506
+ return _ANSI_RE.sub("", text)
507
+
508
+
509
+ def dump_server_log(since_epoch: float | None, out_path: Path) -> bool:
510
+ """Capture the vLLM container's logs to ``out_path``, with ANSI colors
511
+ stripped. ``since_epoch`` (unix seconds) bounds the capture from that
512
+ instant; ``None`` captures the whole container log since it started
513
+ (startup kernel-dispatch lines + everything after — what ``bench perf``
514
+ saves for kernel debugging). Best-effort: returns ``False`` (no raise)
515
+ when the container is absent/not running or docker fails — a log
516
+ capture failure never fails the bench."""
517
+ if not container_running():
518
+ return False
519
+ args = [_docker(), "logs"]
520
+ if since_epoch is not None:
521
+ ts = time.strftime("%Y-%m-%dT%H:%M:%S.000000000Z", time.gmtime(since_epoch))
522
+ args += ["--since", ts]
523
+ args += [CONTAINER_NAME]
524
+ try:
525
+ with out_path.open("w") as f:
526
+ subprocess.run(args, stdout=f, stderr=f, check=False)
527
+ except (OSError, DockerError):
528
+ return False
529
+ try:
530
+ out_path.write_text(_strip_ansi(out_path.read_text()))
531
+ except OSError:
532
+ return False
533
+ return True
@@ -0,0 +1,89 @@
1
+ """FlashAttention 2 paged decode driver for ncu — the stock-vLLM baseline.
2
+
3
+ What vLLM 0.31.0 runs on sm_110 out of the box for a head_dim=256 model:
4
+ ``vllm.vllm_flash_attn.flash_attn_varlen_func(..., fa_version=2)`` over a
5
+ paged KV cache in NHD order ``(num_blocks, block_size, h_k, d)``. FA2 has no
6
+ fp8 KV path, so the cache is **bf16** — twice the bytes of the fp8 legs (the
7
+ GB/s column counts the real bytes). One sequence of BENCH_L tokens, a
8
+ single decode query, BENCH_ITERS launches, no warmup.
9
+
10
+ Env: BENCH_L, BENCH_ITERS, BENCH_HQ (24), BENCH_HK (4), BENCH_D (256),
11
+ BENCH_PAGE (128), BENCH_CONTIG=1, BENCH_SEED, FA2_NUM_SPLITS (0 = FA2's
12
+ own split heuristic, the vLLM default).
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import math
18
+ import os
19
+
20
+ import torch
21
+
22
+
23
+ def env_int(name: str, default: int) -> int:
24
+ return int(os.environ.get(f"BENCH_{name}", default))
25
+
26
+
27
+ def main():
28
+ from vllm.vllm_flash_attn import flash_attn_varlen_func
29
+
30
+ L, iters = env_int("L", 8192), env_int("ITERS", 50)
31
+ hq, hk, d, ps = env_int("HQ", 24), env_int("HK", 4), env_int("D", 256), env_int("PAGE", 128)
32
+ splits = int(os.environ.get("FA2_NUM_SPLITS", "0"))
33
+ torch.manual_seed(env_int("SEED", 1111))
34
+ dev = "cuda"
35
+
36
+ pages = (L + ps - 1) // ps
37
+ k_cache = torch.empty(pages, ps, hk, d, dtype=torch.bfloat16, device=dev)
38
+ v_cache = torch.empty_like(k_cache)
39
+ step = max(1, (64 << 20) // (ps * hk * d * 2))
40
+ for p0 in range(0, pages, step):
41
+ p1 = min(pages, p0 + step)
42
+ k_cache[p0:p1].normal_(0, 0.5)
43
+ v_cache[p0:p1].normal_(0, 0.5)
44
+ if os.environ.get("BENCH_CONTIG", "0") == "1":
45
+ table = torch.arange(pages, dtype=torch.int32, device=dev)
46
+ else:
47
+ table = torch.randperm(pages, device=dev).to(torch.int32)
48
+ block_table = table.view(1, pages)
49
+ q = torch.randn(1, hq, d, device=dev, dtype=torch.bfloat16)
50
+ cu_q = torch.tensor([0, 1], dtype=torch.int32, device=dev)
51
+ seqused_k = torch.tensor([L], dtype=torch.int32, device=dev)
52
+ out = torch.empty_like(q)
53
+ kw = dict(
54
+ q=q,
55
+ k=k_cache,
56
+ v=v_cache,
57
+ out=out,
58
+ cu_seqlens_q=cu_q,
59
+ max_seqlen_q=1,
60
+ seqused_k=seqused_k,
61
+ max_seqlen_k=L,
62
+ softmax_scale=1.0 / math.sqrt(d),
63
+ causal=True,
64
+ block_table=block_table,
65
+ fa_version=2,
66
+ num_splits=splits,
67
+ )
68
+
69
+ def run():
70
+ try:
71
+ return flash_attn_varlen_func(**kw)
72
+ except TypeError: # older signature: no num_splits
73
+ kw.pop("num_splits", None)
74
+ return flash_attn_varlen_func(**kw)
75
+
76
+ torch.cuda.synchronize()
77
+ print(f"FA2: L={L} pages={pages}x{ps} h_q={hq} h_k={hk} d={d} bf16 KV iters={iters}", flush=True)
78
+ ev0, ev1 = torch.cuda.Event(enable_timing=True), torch.cuda.Event(enable_timing=True)
79
+ ev0.record()
80
+ for _ in range(iters):
81
+ run()
82
+ ev1.record()
83
+ torch.cuda.synchronize()
84
+ print(f"FA2: mean {ev0.elapsed_time(ev1) * 1e3 / max(1, iters):.1f} us/iter (not ncu)", flush=True)
85
+ print("DONE")
86
+
87
+
88
+ if __name__ == "__main__":
89
+ main()