mjolnir-cli 0.2.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- mjolnir_cli/__init__.py +8 -0
- mjolnir_cli/__main__.py +4 -0
- mjolnir_cli/accuracy.py +333 -0
- mjolnir_cli/autoresearch.py +539 -0
- mjolnir_cli/benchy.py +475 -0
- mjolnir_cli/bundled/configs/Qwen/Qwen3.8-27B/NVFP4/config.yaml +78 -0
- mjolnir_cli/bundled/configs/Qwen/Qwen3.8-27B/NVFP4_DFLASH2/config.yaml +103 -0
- mjolnir_cli/bundled/configs/Qwen/Qwen3.8-27B/NVFP4_DSPARK/config.yaml +102 -0
- mjolnir_cli/bundled/configs/Qwen/Qwen3.8-27B/NVFP4_FA2/config.yaml +84 -0
- mjolnir_cli/bundled/configs/Qwen/Qwen3.8-27B/NVFP4_FA4hd256/config.yaml +104 -0
- mjolnir_cli/bundled/configs/Qwen/Qwen3.8-27B/NVFP4_MJOLNIR/config.yaml +86 -0
- mjolnir_cli/bundled/configs/Qwen/Qwen3.8-27B/NVFP4_MJOLNIR_E2M3/config.yaml +93 -0
- mjolnir_cli/bundled/configs/Qwen/Qwen3.8-27B/NVFP4_MJOLNIR_E2M3_DFLASH2/config.yaml +123 -0
- mjolnir_cli/bundled/configs/Qwen/Qwen3.8-27B/NVFP4_MJOLNIR_E2M3_DSPARK/config.yaml +124 -0
- mjolnir_cli/bundled/configs/Qwen/Qwen3.8-27B/NVFP4_MJOLNIR_E2M3_LMH8/.env +2 -0
- mjolnir_cli/bundled/configs/Qwen/Qwen3.8-27B/NVFP4_MJOLNIR_E2M3_LMH8/config.yaml +98 -0
- mjolnir_cli/bundled/configs/Qwen/Qwen3.8-27B/NVFP4_MJOLNIR_E2M3_LMH8_VT32K/.env +4 -0
- mjolnir_cli/bundled/configs/Qwen/Qwen3.8-27B/NVFP4_MJOLNIR_E2M3_LMH8_VT32K/config.yaml +98 -0
- mjolnir_cli/bundled/configs/Qwen/Qwen3.8-27B/NVFP4_MJOLNIR_E2M3_LMH8_VT64K/.env +4 -0
- mjolnir_cli/bundled/configs/Qwen/Qwen3.8-27B/NVFP4_MJOLNIR_E2M3_LMH8_VT64K/config.yaml +102 -0
- mjolnir_cli/bundled/configs/Qwen/Qwen3.8-27B/NVFP4_MJOLNIR_E2M3_VT32K/.env +3 -0
- mjolnir_cli/bundled/configs/Qwen/Qwen3.8-27B/NVFP4_MJOLNIR_E2M3_VT32K/config.yaml +98 -0
- mjolnir_cli/bundled/configs/Qwen/Qwen3.8-27B/draft_vocab.json +1 -0
- mjolnir_cli/bundled/configs/Qwen/Qwen3.8-27B/kv_scales.json +35197 -0
- mjolnir_cli/bundled/configs/Qwen/Qwen3.8_chat_template.jinja +456 -0
- mjolnir_cli/bundled/docker/litellm/docker-compose.yaml +113 -0
- mjolnir_cli/bundled/docker/litellm/litellm.env.example +51 -0
- mjolnir_cli/bundled/docker/litellm/litellm_config.template.example.yaml +192 -0
- mjolnir_cli/bundled/docker/perses/README.md +89 -0
- mjolnir_cli/bundled/docker/perses/dashboards/global-datasource.yaml +17 -0
- mjolnir_cli/bundled/docker/perses/dashboards/performance_statistics.yaml +771 -0
- mjolnir_cli/bundled/docker/perses/dashboards/project.yaml +13 -0
- mjolnir_cli/bundled/docker/perses/dashboards/query_statistics.yaml +399 -0
- mjolnir_cli/bundled/docker/perses/docker-compose.yaml +51 -0
- mjolnir_cli/bundled/docker/perses/perses.yml +16 -0
- mjolnir_cli/bundled/docker/perses/prometheus.template.yaml +20 -0
- mjolnir_cli/bundled/scripts/hw/setup-thor.sh +1287 -0
- mjolnir_cli/cachectl.py +146 -0
- mjolnir_cli/charts.py +1687 -0
- mjolnir_cli/cli.py +1480 -0
- mjolnir_cli/clidoc.py +170 -0
- mjolnir_cli/config.py +346 -0
- mjolnir_cli/dockerctl.py +533 -0
- mjolnir_cli/drivers/fa2_driver.py +89 -0
- mjolnir_cli/drivers/fi_driver.py +134 -0
- mjolnir_cli/e2e.py +302 -0
- mjolnir_cli/fonts/InterDisplay-Bold.ttf +0 -0
- mjolnir_cli/fonts/InterDisplay-Medium.ttf +0 -0
- mjolnir_cli/fonts/InterDisplay-Regular.ttf +0 -0
- mjolnir_cli/fonts/InterDisplay-SemiBold.ttf +0 -0
- mjolnir_cli/fonts/OFL.txt +92 -0
- mjolnir_cli/gate.py +233 -0
- mjolnir_cli/history.py +159 -0
- mjolnir_cli/interactive.py +383 -0
- mjolnir_cli/legs.py +109 -0
- mjolnir_cli/litellm.py +254 -0
- mjolnir_cli/perses.py +214 -0
- mjolnir_cli/picker.py +70 -0
- mjolnir_cli/readme.py +1020 -0
- mjolnir_cli/stack.py +340 -0
- mjolnir_cli/tasks.py +122 -0
- mjolnir_cli/theme.py +281 -0
- mjolnir_cli/thor.py +108 -0
- mjolnir_cli/verify/__init__.py +2 -0
- mjolnir_cli/verify/cli.py +816 -0
- mjolnir_cli/verify/docker.py +334 -0
- mjolnir_cli/verify/kvcache.py +180 -0
- mjolnir_cli/verify/legs.py +134 -0
- mjolnir_cli/verify/ncu.py +141 -0
- mjolnir_cli/verify/profile.py +312 -0
- mjolnir_cli/verify/report.py +177 -0
- mjolnir_cli/verify/results.py +157 -0
- mjolnir_cli/verify/spec.py +279 -0
- mjolnir_cli/verify/stages.py +817 -0
- mjolnir_cli-0.2.0.dist-info/METADATA +87 -0
- mjolnir_cli-0.2.0.dist-info/RECORD +80 -0
- mjolnir_cli-0.2.0.dist-info/WHEEL +4 -0
- mjolnir_cli-0.2.0.dist-info/entry_points.txt +2 -0
- mjolnir_cli-0.2.0.dist-info/licenses/LICENSE +202 -0
- mjolnir_cli-0.2.0.dist-info/licenses/NOTICE +64 -0
mjolnir_cli/dockerctl.py
ADDED
|
@@ -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()
|