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
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
"""Structured verify results — the raw substrate for every chart and table.
|
|
2
|
+
|
|
3
|
+
Each ``mjolnir verify <stage>`` run writes ONE JSON document:
|
|
4
|
+
|
|
5
|
+
benchmarks/raw/verify/<stage>/<run_id>.json
|
|
6
|
+
|
|
7
|
+
Schema ``mjolnir.verify/1`` (all fields optional except the header):
|
|
8
|
+
|
|
9
|
+
{
|
|
10
|
+
"schema": "mjolnir.verify/1",
|
|
11
|
+
"stage": "sweep", # stage name (see stages.STAGES)
|
|
12
|
+
"run_id": "20261010-131259", # UTC timestamp, sortable
|
|
13
|
+
"ts": "2026-10-10T13:12:59+00:00",
|
|
14
|
+
"default": true, # ran with the stage's default parameters
|
|
15
|
+
"host": "jetsonthor", "image": "narandill/mjolnir-vllm:v0.31.0",
|
|
16
|
+
"kernel": {"version": "0.1.0", "git": "68ef2b3", "dirty": false},
|
|
17
|
+
"gate": {"gated": true, "clean": true, "server": "up"},
|
|
18
|
+
"params": {...}, # every knob the stage ran with
|
|
19
|
+
"series": [ # ncu stages: one entry per leg / variant
|
|
20
|
+
{"id": "fi", "label": "FlashInfer", "kind": "fi", "role": "baseline",
|
|
21
|
+
"flags": "", "kv_bits": 8,
|
|
22
|
+
"points": [{"L": 8192, "total_us": 96.1, "main_us": 89.2,
|
|
23
|
+
"merge_us": 6.9, "l2_sectors": 1.1e6, "ctas": 40,
|
|
24
|
+
"invocations": 50, "gbps": 174.8}]}
|
|
25
|
+
],
|
|
26
|
+
"rows": [...], # harness stages: one dict per measurement
|
|
27
|
+
"cases": [...], # correctness: one dict per case
|
|
28
|
+
"profiles": [...], # fp6prof: full-ncu metric dicts
|
|
29
|
+
"summary": {...} # headline numbers computed at save time
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
Readers (charts, README, docs) pick the latest *default* run of a stage, so
|
|
33
|
+
an ad-hoc A/B with custom flags never replaces a headline chart by accident
|
|
34
|
+
(``latest(stage, default_only=False)`` returns any run).
|
|
35
|
+
"""
|
|
36
|
+
|
|
37
|
+
from __future__ import annotations
|
|
38
|
+
|
|
39
|
+
import datetime as _dt
|
|
40
|
+
import json
|
|
41
|
+
import platform
|
|
42
|
+
import subprocess
|
|
43
|
+
from pathlib import Path
|
|
44
|
+
from typing import Any
|
|
45
|
+
|
|
46
|
+
SCHEMA = "mjolnir.verify/1"
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def new_run_id() -> str:
|
|
50
|
+
return _dt.datetime.now(_dt.timezone.utc).strftime("%Y%m%d-%H%M%S")
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def git_info(root: Path | None) -> dict[str, Any]:
|
|
54
|
+
if root is None:
|
|
55
|
+
return {}
|
|
56
|
+
try:
|
|
57
|
+
sha = subprocess.run(
|
|
58
|
+
["git", "-C", str(root), "rev-parse", "--short", "HEAD"], capture_output=True, text=True, check=True
|
|
59
|
+
).stdout.strip()
|
|
60
|
+
dirty = bool(
|
|
61
|
+
subprocess.run(
|
|
62
|
+
["git", "-C", str(root), "status", "--porcelain", "--", "src/mjolnir"],
|
|
63
|
+
capture_output=True,
|
|
64
|
+
text=True,
|
|
65
|
+
check=True,
|
|
66
|
+
).stdout.strip()
|
|
67
|
+
)
|
|
68
|
+
return {"git": sha, "dirty": dirty}
|
|
69
|
+
except (subprocess.CalledProcessError, FileNotFoundError):
|
|
70
|
+
return {}
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def header(stage: str, run_id: str, *, image: str, default: bool, params: dict, kernel: dict, gate: dict) -> dict:
|
|
74
|
+
return {
|
|
75
|
+
"schema": SCHEMA,
|
|
76
|
+
"stage": stage,
|
|
77
|
+
"run_id": run_id,
|
|
78
|
+
"ts": _dt.datetime.now(_dt.timezone.utc).isoformat(timespec="seconds"),
|
|
79
|
+
"default": default,
|
|
80
|
+
"host": platform.node(),
|
|
81
|
+
"image": image,
|
|
82
|
+
"kernel": kernel,
|
|
83
|
+
"gate": gate,
|
|
84
|
+
"params": params,
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def stage_dir(verify_dir: Path, stage: str) -> Path:
|
|
89
|
+
return verify_dir / stage
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def save(verify_dir: Path, doc: dict) -> Path:
|
|
93
|
+
d = stage_dir(verify_dir, doc["stage"])
|
|
94
|
+
d.mkdir(parents=True, exist_ok=True)
|
|
95
|
+
p = d / f"{doc['run_id']}.json"
|
|
96
|
+
p.write_text(json.dumps(doc, indent=1, sort_keys=False) + "\n")
|
|
97
|
+
return p
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def load(path: Path) -> dict | None:
|
|
101
|
+
try:
|
|
102
|
+
doc = json.loads(Path(path).read_text())
|
|
103
|
+
except (OSError, json.JSONDecodeError):
|
|
104
|
+
return None
|
|
105
|
+
if not isinstance(doc, dict) or doc.get("schema") != SCHEMA:
|
|
106
|
+
return None
|
|
107
|
+
return doc
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def runs(verify_dir: Path, stage: str) -> list[dict]:
|
|
111
|
+
"""Every saved run of a stage, oldest first."""
|
|
112
|
+
d = stage_dir(verify_dir, stage)
|
|
113
|
+
out = []
|
|
114
|
+
for p in sorted(d.glob("*.json")):
|
|
115
|
+
doc = load(p)
|
|
116
|
+
if doc:
|
|
117
|
+
doc["_path"] = str(p)
|
|
118
|
+
out.append(doc)
|
|
119
|
+
return out
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def latest(verify_dir: Path, stage: str, default_only: bool = True) -> dict | None:
|
|
123
|
+
"""The newest run of a stage — the newest *default-parameter* run unless
|
|
124
|
+
``default_only`` is False (then any run). Falls back to any run when the
|
|
125
|
+
stage has no default run yet."""
|
|
126
|
+
rs = runs(verify_dir, stage)
|
|
127
|
+
if not rs:
|
|
128
|
+
return None
|
|
129
|
+
if default_only:
|
|
130
|
+
dflt = [r for r in rs if r.get("default")]
|
|
131
|
+
if dflt:
|
|
132
|
+
return dflt[-1]
|
|
133
|
+
return rs[-1]
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
# ── series helpers (shared by charts / README / docs) ───────────────────────
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def series_by_id(doc: dict) -> dict[str, dict]:
|
|
140
|
+
return {s["id"]: s for s in doc.get("series", [])}
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def point_map(series: dict, field: str = "total_us") -> dict[int, float]:
|
|
144
|
+
return {int(p["L"]): float(p[field]) for p in series.get("points", []) if p.get(field) is not None}
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def speedup(base: dict, other: dict) -> dict[int, float]:
|
|
148
|
+
"""base time / other time per L (>1 = ``other`` faster)."""
|
|
149
|
+
b, o = point_map(base), point_map(other)
|
|
150
|
+
return {L: b[L] / o[L] for L in sorted(b) if L in o and o[L] > 0}
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def geomean(xs) -> float | None:
|
|
154
|
+
import math
|
|
155
|
+
|
|
156
|
+
xs = [x for x in xs if x and x > 0]
|
|
157
|
+
return math.exp(sum(math.log(x) for x in xs) / len(xs)) if xs else None
|
|
@@ -0,0 +1,279 @@
|
|
|
1
|
+
"""``mjolnir verify spec`` — speculative decoding per config and draft count.
|
|
2
|
+
|
|
3
|
+
For every leg (``side:config``) and every number of draft tokens k, the
|
|
4
|
+
stage serves the config with ``--speculative-config`` overridden to k
|
|
5
|
+
(the server restarts per point, like ``kv-cache-size``), sends a fixed
|
|
6
|
+
prompt set at temperature 0 — the same text for every point — at each
|
|
7
|
+
concurrency, and records:
|
|
8
|
+
|
|
9
|
+
* tokens per verify step (1 + accepted / drafts) and per-position
|
|
10
|
+
acceptance, from vLLM's spec-decode counters before / after the block;
|
|
11
|
+
* decode tokens/s per request (streamed: tokens after the first ÷ time
|
|
12
|
+
from the first to the last token) and in aggregate, and TTFT.
|
|
13
|
+
|
|
14
|
+
Decode t/s = tokens per step ÷ step time, and every extra draft adds a
|
|
15
|
+
draft forward to the step — the best k is the one with the highest decode
|
|
16
|
+
t/s, which the stage reports per config and concurrency.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
import json
|
|
22
|
+
import threading
|
|
23
|
+
import time
|
|
24
|
+
import urllib.request
|
|
25
|
+
from pathlib import Path
|
|
26
|
+
|
|
27
|
+
import yaml
|
|
28
|
+
|
|
29
|
+
from mjolnir_cli import dockerctl
|
|
30
|
+
from mjolnir_cli.config import DEFAULT_IMAGE, STOCK_IMAGE, resolve
|
|
31
|
+
from mjolnir_cli.legs import verify_legs
|
|
32
|
+
from mjolnir_cli.gate import server_counters, spec_delta
|
|
33
|
+
|
|
34
|
+
DEFAULT_LEGS = verify_legs("verify.spec") # mjolnir_cli.legs
|
|
35
|
+
DEFAULT_DRAFTS = "1 2 3 4 5 6 7"
|
|
36
|
+
DEFAULT_CONCURRENCY = "1 4"
|
|
37
|
+
DEFAULT_MAX_TOKENS = 256
|
|
38
|
+
|
|
39
|
+
# A fixed, varied prompt set (acceptance depends on the text: code and
|
|
40
|
+
# lists accept more, prose less). Every point of the stage sends exactly these.
|
|
41
|
+
PROMPTS = [
|
|
42
|
+
"Explain how a transformer's attention mechanism works, step by step, with a small numeric example.",
|
|
43
|
+
"Write a Python function that parses an ISO-8601 timestamp without external libraries, then test it.",
|
|
44
|
+
"def quicksort(xs):\n # complete this implementation and explain its complexity\n",
|
|
45
|
+
"Summarize the causes and consequences of the 1929 stock market crash in five paragraphs.",
|
|
46
|
+
"Solve: a train leaves at 9:40 at 72 km/h, another at 10:05 at 90 km/h on the same track. When do they meet?",
|
|
47
|
+
"Write a SQL query that returns the top three customers by revenue per region, with window functions.",
|
|
48
|
+
"List the differences between TCP and UDP and when you would use each, as a table.",
|
|
49
|
+
"Rust: implement a thread-safe bounded queue with condition variables; explain the invariants.",
|
|
50
|
+
"Translate into German, French and Polish: 'The quick brown fox jumps over the lazy dog near the river bank.'",
|
|
51
|
+
"Write a bash script that finds the ten largest files under a directory and prints them in a table.",
|
|
52
|
+
"Prove that the square root of two is irrational.",
|
|
53
|
+
"Compose a short story about a lighthouse keeper who finds a message in a bottle.",
|
|
54
|
+
]
|
|
55
|
+
LONG_SOURCE = (
|
|
56
|
+
"In the long history of computing, memory bandwidth has repeatedly set the pace of progress. Each "
|
|
57
|
+
"generation of accelerators added arithmetic faster than it added bytes per second, and every workload "
|
|
58
|
+
"that streams data once - decoding a language model token by token is one - became a question of how "
|
|
59
|
+
"few bytes it could read. "
|
|
60
|
+
)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def long_prompt(words: int) -> str:
|
|
64
|
+
reps = max(1, words // len(LONG_SOURCE.split()))
|
|
65
|
+
body = "".join(f"[{i}] {LONG_SOURCE}" for i in range(reps))
|
|
66
|
+
return body + "\nSummarize the argument above in five sentences and name its weakest step."
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def prompt_set(long_words: int) -> list[str]:
|
|
70
|
+
return PROMPTS + ([long_prompt(long_words)] if long_words > 0 else [])
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def _ints(v: str) -> list[int]:
|
|
74
|
+
return [int(x) for x in str(v).replace(",", " ").split() if x.strip()]
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def _legs(spec: str) -> list[tuple[str, str]]:
|
|
78
|
+
from mjolnir_cli.verify.kvcache import _legs as legs
|
|
79
|
+
|
|
80
|
+
return legs(spec)
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def spec_override(config_path: Path, k: int) -> str | None:
|
|
84
|
+
"""The config's speculative-config with num_speculative_tokens = k, as
|
|
85
|
+
the JSON ``--speculative-config`` takes (None: the config has none)."""
|
|
86
|
+
try:
|
|
87
|
+
data = yaml.safe_load(Path(config_path).read_text()) or {}
|
|
88
|
+
except Exception: # noqa: BLE001
|
|
89
|
+
return None
|
|
90
|
+
sp = data.get("speculative-config")
|
|
91
|
+
if not isinstance(sp, dict):
|
|
92
|
+
return None
|
|
93
|
+
return json.dumps({**sp, "num_speculative_tokens": int(k)})
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def stream_one(base_url: str, model: str, prompt: str, max_tokens: int, timeout: float = 600.0) -> dict:
|
|
97
|
+
"""One streamed chat completion at temperature 0: TTFT, decode t/s, tokens."""
|
|
98
|
+
body = json.dumps(
|
|
99
|
+
{
|
|
100
|
+
"model": model,
|
|
101
|
+
"messages": [{"role": "user", "content": prompt}],
|
|
102
|
+
"max_tokens": max_tokens,
|
|
103
|
+
"temperature": 0.0,
|
|
104
|
+
"stream": True,
|
|
105
|
+
"stream_options": {"include_usage": True},
|
|
106
|
+
}
|
|
107
|
+
).encode()
|
|
108
|
+
req = urllib.request.Request(
|
|
109
|
+
f"{base_url}/chat/completions", data=body, headers={"Content-Type": "application/json"}
|
|
110
|
+
)
|
|
111
|
+
t0 = time.perf_counter()
|
|
112
|
+
first = last = None
|
|
113
|
+
chunks = 0
|
|
114
|
+
tokens = None
|
|
115
|
+
with urllib.request.urlopen(req, timeout=timeout) as r:
|
|
116
|
+
for raw in r:
|
|
117
|
+
line = raw.decode(errors="replace").strip()
|
|
118
|
+
if not line.startswith("data:"):
|
|
119
|
+
continue
|
|
120
|
+
data = line[5:].strip()
|
|
121
|
+
if data == "[DONE]":
|
|
122
|
+
break
|
|
123
|
+
try:
|
|
124
|
+
ev = json.loads(data)
|
|
125
|
+
except json.JSONDecodeError:
|
|
126
|
+
continue
|
|
127
|
+
if ev.get("usage"):
|
|
128
|
+
tokens = ev["usage"].get("completion_tokens")
|
|
129
|
+
for ch in ev.get("choices") or []:
|
|
130
|
+
delta = ch.get("delta") or {}
|
|
131
|
+
if delta.get("content") or delta.get("reasoning_content") or delta.get("reasoning"):
|
|
132
|
+
now = time.perf_counter()
|
|
133
|
+
first = first if first is not None else now
|
|
134
|
+
last = now
|
|
135
|
+
chunks += 1
|
|
136
|
+
tokens = tokens if tokens is not None else chunks
|
|
137
|
+
out = {"tokens": tokens, "chunks": chunks, "ttft_ms": None, "decode_tps": None}
|
|
138
|
+
if first is not None:
|
|
139
|
+
out["ttft_ms"] = (first - t0) * 1000
|
|
140
|
+
if last is not None and last > first and tokens > 1:
|
|
141
|
+
out["decode_tps"] = (tokens - 1) / (last - first)
|
|
142
|
+
out["tokens_per_chunk"] = tokens / max(1, chunks)
|
|
143
|
+
return out
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def run_block(s, prompts: list[str], conc: int, max_tokens: int) -> dict:
|
|
147
|
+
"""Every prompt once, ``conc`` in flight; spec counters around it."""
|
|
148
|
+
before = server_counters(s.metrics_url)
|
|
149
|
+
results: list[dict] = []
|
|
150
|
+
errors: list[str] = []
|
|
151
|
+
lock = threading.Lock()
|
|
152
|
+
queue = list(prompts)
|
|
153
|
+
|
|
154
|
+
def worker():
|
|
155
|
+
while True:
|
|
156
|
+
with lock:
|
|
157
|
+
if not queue:
|
|
158
|
+
return
|
|
159
|
+
p = queue.pop(0)
|
|
160
|
+
try:
|
|
161
|
+
r = stream_one(s.base_url, s.served_model, p, max_tokens)
|
|
162
|
+
except Exception as e: # noqa: BLE001
|
|
163
|
+
with lock:
|
|
164
|
+
errors.append(repr(e))
|
|
165
|
+
continue
|
|
166
|
+
with lock:
|
|
167
|
+
results.append(r)
|
|
168
|
+
|
|
169
|
+
t0 = time.perf_counter()
|
|
170
|
+
threads = [threading.Thread(target=worker) for _ in range(conc)]
|
|
171
|
+
for t in threads:
|
|
172
|
+
t.start()
|
|
173
|
+
for t in threads:
|
|
174
|
+
t.join()
|
|
175
|
+
wall = time.perf_counter() - t0
|
|
176
|
+
stats = spec_delta(before, server_counters(s.metrics_url)) or {}
|
|
177
|
+
tps = [r["decode_tps"] for r in results if r.get("decode_tps")]
|
|
178
|
+
ttft = [r["ttft_ms"] for r in results if r.get("ttft_ms") is not None]
|
|
179
|
+
tokens = sum(r["tokens"] or 0 for r in results)
|
|
180
|
+
return {
|
|
181
|
+
"concurrency": conc,
|
|
182
|
+
"requests": len(results),
|
|
183
|
+
"errors": errors[:3],
|
|
184
|
+
"tokens": tokens,
|
|
185
|
+
"wall_s": round(wall, 2),
|
|
186
|
+
"decode_tps": round(sum(tps) / len(tps), 3) if tps else None, # per request, mean
|
|
187
|
+
"agg_tps": round(tokens / wall, 3) if wall > 0 else None, # all requests, incl. prefill time
|
|
188
|
+
"ttft_ms": round(sum(ttft) / len(ttft), 1) if ttft else None,
|
|
189
|
+
# per request, in completion order: the mean hides a few slow first steps
|
|
190
|
+
"ttft_p50_ms": round(sorted(ttft)[len(ttft) // 2], 1) if ttft else None,
|
|
191
|
+
"ttft_list_ms": [round(x, 1) for x in ttft],
|
|
192
|
+
**{k: stats.get(k) for k in ("acceptance_length", "acceptance_rate", "per_pos", "drafts", "preemptions")},
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
def best_k(rows: list[dict]) -> dict:
|
|
197
|
+
"""{(config, concurrency): row with the highest per-request decode t/s}."""
|
|
198
|
+
out: dict = {}
|
|
199
|
+
for r in rows:
|
|
200
|
+
if r.get("decode_tps") is None:
|
|
201
|
+
continue
|
|
202
|
+
key = (r["config"], r["concurrency"])
|
|
203
|
+
if key not in out or r["decode_tps"] > out[key]["decode_tps"]:
|
|
204
|
+
out[key] = r
|
|
205
|
+
return out
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
def stage_spec(
|
|
209
|
+
ctx,
|
|
210
|
+
legs: str = DEFAULT_LEGS,
|
|
211
|
+
drafts: str = DEFAULT_DRAFTS,
|
|
212
|
+
concurrency: str = DEFAULT_CONCURRENCY,
|
|
213
|
+
max_tokens: int = DEFAULT_MAX_TOKENS,
|
|
214
|
+
long_words: int = 6000,
|
|
215
|
+
model: str | None = None,
|
|
216
|
+
restart: bool = False,
|
|
217
|
+
timeout: float = 1800.0,
|
|
218
|
+
) -> dict:
|
|
219
|
+
"""Serve every leg at every draft count; one block per concurrency."""
|
|
220
|
+
rows, cases = [], []
|
|
221
|
+
mj_image = ctx.runner.image or DEFAULT_IMAGE
|
|
222
|
+
params = {
|
|
223
|
+
"legs": legs,
|
|
224
|
+
"drafts": drafts,
|
|
225
|
+
"concurrency": concurrency,
|
|
226
|
+
"max_tokens": max_tokens,
|
|
227
|
+
"long_words": long_words,
|
|
228
|
+
"prompts": len(prompt_set(long_words)),
|
|
229
|
+
}
|
|
230
|
+
if dockerctl.container_running() and not restart and not ctx.runner.dry_run:
|
|
231
|
+
ctx.out(
|
|
232
|
+
"[red] a vLLM server is running — this stage restarts the server for every point. "
|
|
233
|
+
"Stop it in a window you own (mjolnir serve down), or pass --restart.[/red]"
|
|
234
|
+
)
|
|
235
|
+
return {"rows": [], "cases": [{"label": "server busy (not restarted)", "passed": False}], "params": params}
|
|
236
|
+
prompts = prompt_set(long_words)
|
|
237
|
+
logdir = ctx.logdir / "spec"
|
|
238
|
+
logdir.mkdir(parents=True, exist_ok=True)
|
|
239
|
+
for side, quant in _legs(legs):
|
|
240
|
+
s = resolve(model, quant, STOCK_IMAGE if side == "official" else mj_image, None)
|
|
241
|
+
for k in _ints(drafts):
|
|
242
|
+
point = f"{side}-{quant}-k{k}"
|
|
243
|
+
override = spec_override(s.config_path, k)
|
|
244
|
+
ctx.out(f" ▸ {point} ({s.image})")
|
|
245
|
+
if override is None:
|
|
246
|
+
cases.append({"label": point, "passed": False, "error": "config has no speculative-config"})
|
|
247
|
+
continue
|
|
248
|
+
serve_args = ("--speculative-config", override)
|
|
249
|
+
if ctx.runner.dry_run:
|
|
250
|
+
dockerctl.serve_up(s, wait=False, dry_run=True, serve_args=serve_args)
|
|
251
|
+
continue
|
|
252
|
+
err = None
|
|
253
|
+
log = logdir / f"{point}.log"
|
|
254
|
+
try:
|
|
255
|
+
dockerctl.serve_up(s, wait=True, wait_timeout_s=timeout, serve_args=serve_args)
|
|
256
|
+
for _ in range(2): # warm-up: graphs, caches, allocator
|
|
257
|
+
stream_one(s.base_url, s.served_model, PROMPTS[0], 32)
|
|
258
|
+
for conc in _ints(concurrency):
|
|
259
|
+
b = run_block(s, prompts, conc, max_tokens)
|
|
260
|
+
rows.append({"id": point, "side": side, "config": quant, "image": s.image, "k": k, **b})
|
|
261
|
+
ctx.out(
|
|
262
|
+
f" c={conc}: {b['acceptance_length'] or 0:.2f} tok/step · decode "
|
|
263
|
+
f"{b['decode_tps'] or 0:.1f} t/s/req · agg {b['agg_tps'] or 0:.1f} t/s · per pos "
|
|
264
|
+
f"{' '.join(f'{p:.2f}' for p in (b['per_pos'] or []))}"
|
|
265
|
+
+ (f" · {len(b['errors'])} errors" if b["errors"] else "")
|
|
266
|
+
)
|
|
267
|
+
except (dockerctl.DockerError, TimeoutError, OSError) as e:
|
|
268
|
+
err = str(e)
|
|
269
|
+
finally:
|
|
270
|
+
dockerctl.dump_server_log(None, log)
|
|
271
|
+
dockerctl.serve_down(s)
|
|
272
|
+
dockerctl._run_quiet([dockerctl._docker(), "rm", "-f", dockerctl.CONTAINER_NAME], ok_codes=(0, 1))
|
|
273
|
+
ok = err is None and any(r["id"] == point and r.get("decode_tps") for r in rows)
|
|
274
|
+
cases.append({"label": point, "passed": ok, **({"error": err} if err else {}), "log": str(log)})
|
|
275
|
+
if not ok:
|
|
276
|
+
ctx.out(f" ❌ {err or 'no decode measured'} — {log}")
|
|
277
|
+
for (cfg, conc), r in sorted(best_k(rows).items()):
|
|
278
|
+
ctx.out(f" best k for {cfg} at c={conc}: k={r['k']} ({r['decode_tps']:.1f} t/s/req)")
|
|
279
|
+
return {"rows": rows, "cases": cases, "params": params}
|