inferencekit-cli 0.1.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.
- inferencekit/__init__.py +9 -0
- inferencekit/__main__.py +5 -0
- inferencekit/analysis/__init__.py +1 -0
- inferencekit/analysis/ncu_csv.py +284 -0
- inferencekit/analysis/nsys_sqlite.py +518 -0
- inferencekit/canonical.py +104 -0
- inferencekit/checkpoint.py +396 -0
- inferencekit/cli.py +274 -0
- inferencekit/cuda/bw_peak.cu +252 -0
- inferencekit/cuda/torch_bw.py +60 -0
- inferencekit/engines/__init__.py +7 -0
- inferencekit/engines/adapters.py +190 -0
- inferencekit/engines/clip/__init__.py +13 -0
- inferencekit/engines/clip/comfyui.py +749 -0
- inferencekit/engines/clip/diffusers_ltx.py +601 -0
- inferencekit/engines/clip/noise.py +46 -0
- inferencekit/engines/clip/official_ltx.py +316 -0
- inferencekit/engines/clip/server.py +542 -0
- inferencekit/engines/clip/stepdump.py +182 -0
- inferencekit/engines/gates.py +94 -0
- inferencekit/engines/sglang_worker.py +86 -0
- inferencekit/engines/vllm_worker.py +129 -0
- inferencekit/evidence.py +427 -0
- inferencekit/gpuinfo.py +93 -0
- inferencekit/hardware.py +135 -0
- inferencekit/mcp_server.py +178 -0
- inferencekit/ops/__init__.py +30 -0
- inferencekit/ops/bench.py +184 -0
- inferencekit/ops/bench_audio.py +384 -0
- inferencekit/ops/bench_clip.py +819 -0
- inferencekit/ops/bench_image.py +393 -0
- inferencekit/ops/bench_rounds.py +297 -0
- inferencekit/ops/bench_serve.py +866 -0
- inferencekit/ops/ceiling.py +135 -0
- inferencekit/ops/compare.py +378 -0
- inferencekit/ops/correctness.py +153 -0
- inferencekit/ops/doctor.py +486 -0
- inferencekit/ops/gates.py +1772 -0
- inferencekit/ops/gates_clip.py +930 -0
- inferencekit/ops/intake.py +1321 -0
- inferencekit/ops/ledger.py +749 -0
- inferencekit/ops/manifest.py +309 -0
- inferencekit/ops/ncu.py +183 -0
- inferencekit/ops/nsys.py +224 -0
- inferencekit/ops/prompts.py +316 -0
- inferencekit/ops/quality.py +2161 -0
- inferencekit/ops/quality_clip.py +690 -0
- inferencekit/ops/quality_clip_steps.py +341 -0
- inferencekit/ops/report.py +317 -0
- inferencekit/ops/review.py +119 -0
- inferencekit/ops/rocprof.py +89 -0
- inferencekit/ops/roofline.py +392 -0
- inferencekit/ops/store.py +61 -0
- inferencekit/proc.py +172 -0
- inferencekit/prompts/ATTRIBUTION.md +56 -0
- inferencekit/prompts/__init__.py +167 -0
- inferencekit/prompts/build.py +518 -0
- inferencekit/prompts/dev-load.jsonl.gz +0 -0
- inferencekit/prompts/dev-load.manifest.json +212 -0
- inferencekit/prompts/dev.jsonl.gz +0 -0
- inferencekit/prompts/dev.manifest.json +214 -0
- inferencekit/prompts/exclusions.json +54 -0
- inferencekit/quality/__init__.py +23 -0
- inferencekit/quality/client.py +66 -0
- inferencekit/quality/data/gsm8k_LICENSE.txt +21 -0
- inferencekit/quality/fake.py +329 -0
- inferencekit/quality/gsm8k.py +224 -0
- inferencekit/quality/logprobs.py +106 -0
- inferencekit/quality/metrics.py +235 -0
- inferencekit/quality/niah.py +63 -0
- inferencekit/quality/parquet.py +296 -0
- inferencekit/quality/sampling.py +262 -0
- inferencekit/quality/step_replay.py +217 -0
- inferencekit/quality/video_metrics.py +304 -0
- inferencekit/registry.py +206 -0
- inferencekit/review/__init__.py +6 -0
- inferencekit/review/diff.py +163 -0
- inferencekit/review/media.py +181 -0
- inferencekit/review/page.py +920 -0
- inferencekit/review/rundir.py +844 -0
- inferencekit/roofline_headers.py +504 -0
- inferencekit/schemas/evidence.v1.json +130 -0
- inferencekit/schemas/evidence.v2.json +499 -0
- inferencekit/schemas/intake.v1.json +276 -0
- inferencekit/schemas/run-manifest.v1.json +118 -0
- inferencekit/serve/__init__.py +8 -0
- inferencekit/serve/audio_client.py +330 -0
- inferencekit/serve/client.py +446 -0
- inferencekit/serve/clip_client.py +351 -0
- inferencekit/serve/clip_rounds.py +429 -0
- inferencekit/serve/image_client.py +373 -0
- inferencekit/serve/loadgen.py +136 -0
- inferencekit/serve/metrics.py +203 -0
- inferencekit/serve/profiles.py +105 -0
- inferencekit/serve/prompts_io.py +125 -0
- inferencekit/serve/rounds.py +1111 -0
- inferencekit/serve/servers.py +897 -0
- inferencekit/stats.py +249 -0
- inferencekit_cli-0.1.0.dist-info/METADATA +233 -0
- inferencekit_cli-0.1.0.dist-info/RECORD +104 -0
- inferencekit_cli-0.1.0.dist-info/WHEEL +4 -0
- inferencekit_cli-0.1.0.dist-info/entry_points.txt +2 -0
- inferencekit_cli-0.1.0.dist-info/licenses/LICENSE +202 -0
- inferencekit_cli-0.1.0.dist-info/licenses/NOTICE +9 -0
|
@@ -0,0 +1,1111 @@
|
|
|
1
|
+
"""Round runner: every engine of a run manifest, R rounds, in a balanced order, one server at a time.
|
|
2
|
+
|
|
3
|
+
Why rounds, and why this order
|
|
4
|
+
------------------------------
|
|
5
|
+
A GPU's speed drifts during a session (temperature, clocks, other tenants on a rented
|
|
6
|
+
host). If engine A ran all its requests before engine B, that drift would be mixed into
|
|
7
|
+
"A vs B". So each round runs every engine once, and the order inside each round follows a
|
|
8
|
+
Williams design: a Latin square in which every engine runs once in every position, and
|
|
9
|
+
every ordered pair "X right after Y" occurs equally often. Position effects (the first
|
|
10
|
+
server after an idle GPU) and carry-over effects (a server starting on a GPU the previous
|
|
11
|
+
one just heated) then hit every engine equally. With n engines one balanced block takes
|
|
12
|
+
n rounds when n is even and 2n when n is odd; ``rounds`` that are not a multiple of that
|
|
13
|
+
still run, with ``balanced: false`` recorded. The default number of rounds is the smallest
|
|
14
|
+
multiple of that period that is at least 3 (2 engines: 4, 3 engines: 6, 4 engines: 4).
|
|
15
|
+
|
|
16
|
+
What one (round, engine) cell does: see ``servers.py`` (launch under a clean environment,
|
|
17
|
+
readiness, warm-up, /metrics before and after, stop of the whole process group, GPU
|
|
18
|
+
memory back to baseline). The measurement itself is ``inferencekit bench serve``, run as a
|
|
19
|
+
subprocess against the live server: the client-side timer is the only timer, and the
|
|
20
|
+
coupling to it is only its command line.
|
|
21
|
+
|
|
22
|
+
Summary: per engine x bucket x metric, the median over rounds with a bootstrap 95% CI
|
|
23
|
+
over rounds (rounds are the unit of repetition), and the ratio to the best engine with a
|
|
24
|
+
paired bootstrap CI (round k of an engine paired with round k of the best engine; both
|
|
25
|
+
ran within the same round, minutes apart). An engine with failed requests in a bucket is
|
|
26
|
+
never named best there and gets no ratio: its latencies cover only the requests it
|
|
27
|
+
answered, and a fast error is not a fast answer.
|
|
28
|
+
|
|
29
|
+
Load mode gives every engine the same arrival schedule: bench serve turns a concurrency
|
|
30
|
+
level N into a Poisson rate N / e2e_ref, so the runner passes one shared ``--ref-e2e-ms``
|
|
31
|
+
(given, or measured by the first load cell's warm-up) to every cell, unless ``--serve-args``
|
|
32
|
+
already fixes the schedule (``--rates``, ``--ref-e2e-ms``, ``--closed-loop``).
|
|
33
|
+
|
|
34
|
+
Sampling: ``extra_body`` (``--extra-body``, else the manifest's ``workload.extra_body``) goes to
|
|
35
|
+
every bench serve call as its ``--extra-body``, so every engine is timed with the same sampling
|
|
36
|
+
settings (e.g. the model's default temperature / top_k / top_p and a seed) instead of greedy.
|
|
37
|
+
|
|
38
|
+
Adaptive stop (``ci_target``, off by default): ``rounds`` becomes a maximum. After each whole
|
|
39
|
+
Williams period, once at least ``min_rounds`` rounds ran, the runner stops if every engine's
|
|
40
|
+
decode_tok_s ratio to the best engine (bucket ``all``) has a 95% CI whose half-width is at most
|
|
41
|
+
``ci_target`` times the ratio. Stopping only at whole periods keeps the order balanced. Stopping
|
|
42
|
+
when the CI looks narrow enough is optional stopping: the reported CIs come out slightly too
|
|
43
|
+
narrow, so the rule (target, minimum, maximum) is recorded in the plan and should be fixed before
|
|
44
|
+
the run. Without ``ci_target`` every round runs and nothing about the run changes.
|
|
45
|
+
"""
|
|
46
|
+
|
|
47
|
+
from __future__ import annotations
|
|
48
|
+
|
|
49
|
+
import hashlib
|
|
50
|
+
import json
|
|
51
|
+
import os
|
|
52
|
+
import shlex
|
|
53
|
+
import statistics
|
|
54
|
+
import sys
|
|
55
|
+
import time
|
|
56
|
+
from pathlib import Path
|
|
57
|
+
|
|
58
|
+
from inferencekit import evidence as ev
|
|
59
|
+
from inferencekit import proc, stats
|
|
60
|
+
from inferencekit.registry import OpError
|
|
61
|
+
from inferencekit.serve import servers
|
|
62
|
+
|
|
63
|
+
SERVE_CMD_ENV = "INFERENCEKIT_BENCH_SERVE_CMD"
|
|
64
|
+
STATUSES_OK = ("ok",)
|
|
65
|
+
# The adaptive stop reads this metric in this bucket (single mode: client-timed decode speed over all prompts).
|
|
66
|
+
ADAPTIVE_METRIC, ADAPTIVE_BUCKET = "decode_tok_s", "all"
|
|
67
|
+
ADAPTIVE_MAX_ROUNDS = 12 # default maximum with --ci-target, rounded up to whole Williams periods
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
# --------------------------------------------------------------------------- order design
|
|
71
|
+
|
|
72
|
+
def williams_square(n: int) -> list[list[int]]:
|
|
73
|
+
"""Rows of a Williams design for ``n`` treatments (n rows if n is even, 2n if odd).
|
|
74
|
+
|
|
75
|
+
First row 0, 1, n-1, 2, n-2, 3, ...; row i adds i (mod n). For odd n the mirrored
|
|
76
|
+
rows are appended, because a single square cannot balance carry-over then.
|
|
77
|
+
"""
|
|
78
|
+
if n <= 0:
|
|
79
|
+
return []
|
|
80
|
+
if n == 1:
|
|
81
|
+
return [[0]]
|
|
82
|
+
first, lo, hi = [0], 1, n - 1
|
|
83
|
+
take_lo = True
|
|
84
|
+
while len(first) < n:
|
|
85
|
+
if take_lo:
|
|
86
|
+
first.append(lo)
|
|
87
|
+
lo += 1
|
|
88
|
+
else:
|
|
89
|
+
first.append(hi)
|
|
90
|
+
hi -= 1
|
|
91
|
+
take_lo = not take_lo
|
|
92
|
+
rows = [[(x + i) % n for x in first] for i in range(n)]
|
|
93
|
+
if n % 2:
|
|
94
|
+
rows += [list(reversed(r)) for r in rows]
|
|
95
|
+
return rows
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def round_orders(labels: list[str], rounds: int) -> dict:
|
|
99
|
+
"""The engine order for each round, the design's period, and whether R balances it."""
|
|
100
|
+
square = williams_square(len(labels))
|
|
101
|
+
period = len(square)
|
|
102
|
+
orders = [[labels[i] for i in square[r % period]] for r in range(rounds)] if period else []
|
|
103
|
+
return {"design": "williams", "orders": orders, "period": period,
|
|
104
|
+
"balanced": bool(period) and rounds % period == 0}
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def default_rounds(n_engines: int, minimum: int = 3) -> int:
|
|
108
|
+
"""The smallest multiple of the Williams period for ``n_engines`` that is at least ``minimum``."""
|
|
109
|
+
period = len(williams_square(n_engines)) or 1
|
|
110
|
+
return period * max(1, -(-minimum // period))
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def carryover_counts(orders: list[list[str]]) -> dict[tuple[str, str], int]:
|
|
114
|
+
"""How often engine b ran right after engine a, inside rounds."""
|
|
115
|
+
counts: dict[tuple[str, str], int] = {}
|
|
116
|
+
for row in orders:
|
|
117
|
+
for a, b in zip(row, row[1:]):
|
|
118
|
+
counts[(a, b)] = counts.get((a, b), 0) + 1
|
|
119
|
+
return counts
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
# --------------------------------------------------------------------------- manifest and prompts
|
|
123
|
+
|
|
124
|
+
def load_manifest(path: str | os.PathLike) -> dict:
|
|
125
|
+
p = Path(path)
|
|
126
|
+
try:
|
|
127
|
+
manifest = json.loads(p.read_text())
|
|
128
|
+
except OSError as e:
|
|
129
|
+
raise OpError(f"cannot read manifest {path}: {e}") from None
|
|
130
|
+
except ValueError as e:
|
|
131
|
+
raise OpError(f"manifest {path} is not valid JSON: {e}") from None
|
|
132
|
+
if not isinstance(manifest, dict) or not isinstance(manifest.get("engines"), list) or not manifest["engines"]:
|
|
133
|
+
raise OpError(f"manifest {path} has no 'engines' list")
|
|
134
|
+
return manifest
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def engine_specs(manifest: dict, only: str | list[str] | None) -> list[dict]:
|
|
138
|
+
"""The manifest's engines (optionally only the given labels), checked for what a launch needs."""
|
|
139
|
+
specs, problems, labels = [], [], set()
|
|
140
|
+
for i, e in enumerate(manifest["engines"]):
|
|
141
|
+
if not isinstance(e, dict):
|
|
142
|
+
problems.append(f"engines[{i}] is not an object")
|
|
143
|
+
continue
|
|
144
|
+
label = str(e.get("label") or e.get("name") or "")
|
|
145
|
+
launch = e.get("launch")
|
|
146
|
+
if not label:
|
|
147
|
+
problems.append(f"engines[{i}] has no label")
|
|
148
|
+
elif label in labels:
|
|
149
|
+
problems.append(f"engine label '{label}' appears twice")
|
|
150
|
+
elif not all(c.isalnum() or c in "-_." for c in label):
|
|
151
|
+
problems.append(f"engine label '{label}' may only use letters, digits, '-', '_' and '.'")
|
|
152
|
+
labels.add(label)
|
|
153
|
+
if not isinstance(launch, list) or not launch or not all(isinstance(a, str) for a in launch):
|
|
154
|
+
problems.append(f"engine '{label}': 'launch' must be a non-empty list of strings")
|
|
155
|
+
if not isinstance(e.get("port"), int):
|
|
156
|
+
problems.append(f"engine '{label}': 'port' must be an integer")
|
|
157
|
+
if e.get("env") is not None and not isinstance(e.get("env"), dict):
|
|
158
|
+
problems.append(f"engine '{label}': 'env' must be an object")
|
|
159
|
+
specs.append({"label": label, "name": e.get("name"), "kind": servers.engine_kind(e.get("name"), launch),
|
|
160
|
+
"version": e.get("version"), "launch": launch, "env": e.get("env") or {},
|
|
161
|
+
"port": e.get("port"), "host": e.get("host") or "127.0.0.1",
|
|
162
|
+
"ready_check": "/" + str(e.get("ready_check") or "/v1/models").lstrip("/"),
|
|
163
|
+
"role": e.get("role"),
|
|
164
|
+
"cwd": e.get("cwd")})
|
|
165
|
+
if problems:
|
|
166
|
+
raise OpError("manifest engines: " + "; ".join(problems))
|
|
167
|
+
if only:
|
|
168
|
+
wanted = [w.strip() for w in (only.split(",") if isinstance(only, str) else only) if w.strip()]
|
|
169
|
+
unknown = [w for w in wanted if w not in labels]
|
|
170
|
+
if unknown:
|
|
171
|
+
raise OpError(f"--engines: unknown label(s) {unknown}; manifest has {sorted(labels)}")
|
|
172
|
+
specs = [s for s in specs if s["label"] in wanted]
|
|
173
|
+
return specs
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def read_prompts(path: str | os.PathLike) -> dict:
|
|
177
|
+
"""sha256, count and bucket counts of a prompt set (JSONL or .jsonl.gz; the sha256 is of the JSONL
|
|
178
|
+
bytes), plus its first rows for warm-up."""
|
|
179
|
+
from inferencekit import prompts as sets
|
|
180
|
+
|
|
181
|
+
p = sets.existing(path)
|
|
182
|
+
try:
|
|
183
|
+
raw = sets.read_bytes(p)
|
|
184
|
+
except (OSError, ValueError) as e: # missing, or a truncated / corrupt .jsonl.gz
|
|
185
|
+
raise OpError(f"cannot read prompts {path}: {e}") from None
|
|
186
|
+
rows, buckets = [], {}
|
|
187
|
+
for n, line in enumerate(raw.decode(errors="replace").splitlines(), 1):
|
|
188
|
+
if not line.strip():
|
|
189
|
+
continue
|
|
190
|
+
try:
|
|
191
|
+
row = json.loads(line)
|
|
192
|
+
except ValueError:
|
|
193
|
+
raise OpError(f"prompts {path}: line {n} is not JSON") from None
|
|
194
|
+
rows.append(row)
|
|
195
|
+
b = str(row.get("bucket") or "all") if isinstance(row, dict) else "all"
|
|
196
|
+
buckets[b] = buckets.get(b, 0) + 1
|
|
197
|
+
if not rows:
|
|
198
|
+
raise OpError(f"prompts {path} is empty")
|
|
199
|
+
name = sets.set_stem(p)
|
|
200
|
+
return {"name": name, "sha256": hashlib.sha256(raw).hexdigest(), "n": len(rows), "buckets": buckets,
|
|
201
|
+
"head": [r for r in rows[:8] if isinstance(r, dict)]}
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
# --------------------------------------------------------------------------- bench serve
|
|
205
|
+
|
|
206
|
+
def _drop_flag(words: list[str], flag: str) -> list[str]:
|
|
207
|
+
"""``words`` without ``flag VALUE`` / ``flag=VALUE``."""
|
|
208
|
+
out, skip = [], False
|
|
209
|
+
for w in words:
|
|
210
|
+
if skip:
|
|
211
|
+
skip = False
|
|
212
|
+
elif w == flag:
|
|
213
|
+
skip = True
|
|
214
|
+
elif not w.startswith(flag + "="):
|
|
215
|
+
out.append(w)
|
|
216
|
+
return out
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
def _flag_value(words: list[str], flag: str) -> str | None:
|
|
220
|
+
"""The last value given to ``flag`` in ``words`` (``flag VALUE`` or ``flag=VALUE``), else None."""
|
|
221
|
+
found = None
|
|
222
|
+
for i, w in enumerate(words):
|
|
223
|
+
name, eq, val = w.partition("=")
|
|
224
|
+
if name == flag:
|
|
225
|
+
found = val if eq else (words[i + 1] if i + 1 < len(words) else "")
|
|
226
|
+
return found
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
def serve_argv(template: str | None, ctx: dict, extra: str | None) -> list[str]:
|
|
230
|
+
"""The ``bench serve`` command for one cell (the flags ``bench serve`` accepts).
|
|
231
|
+
|
|
232
|
+
Default: ``<python> -m inferencekit bench serve --base-url ... --model ... --prompts ... --mode ...
|
|
233
|
+
--engine-kind ... --label ... --run-dir ...``, plus in single mode ``--rounds 1`` and, from the
|
|
234
|
+
second round of an engine on, ``--extend <its previous record>`` (so the engine's last record
|
|
235
|
+
holds all its rounds, interleaved with the other engines), plus ``--engine-version`` when the
|
|
236
|
+
manifest gives one, then ``extra``.
|
|
237
|
+
A template (``--serve-cmd`` or $INFERENCEKIT_BENCH_SERVE_CMD) containing ``{placeholders}`` is
|
|
238
|
+
used as written, with {base_url} {model} {prompts} {mode} {engine_kind} {label} {round} {run_dir}
|
|
239
|
+
filled in; a template without placeholders replaces only the program part.
|
|
240
|
+
In load mode with a shared reference latency (``ctx["ref_e2e_ms"]``), ``--ref-e2e-ms`` is
|
|
241
|
+
appended in both cases, so every engine gets the same arrival schedule.
|
|
242
|
+
"""
|
|
243
|
+
template = template or os.environ.get(SERVE_CMD_ENV) or ""
|
|
244
|
+
words = shlex.split(template) if template else [sys.executable, "-m", "inferencekit", "bench", "serve"]
|
|
245
|
+
if any("{" in w for w in words):
|
|
246
|
+
try:
|
|
247
|
+
argv = [w.format(**ctx) for w in words]
|
|
248
|
+
except (KeyError, IndexError, ValueError) as e:
|
|
249
|
+
raise OpError(f"--serve-cmd template: unknown placeholder {e}") from None
|
|
250
|
+
else:
|
|
251
|
+
argv = words + ["--base-url", ctx["base_url"], "--model", ctx["model"], "--prompts", ctx["prompts"],
|
|
252
|
+
"--mode", ctx["mode"], "--engine-kind", ctx["engine_kind"], "--label", ctx["label"],
|
|
253
|
+
"--run-dir", ctx["run_dir"]]
|
|
254
|
+
if ctx["mode"] == "single":
|
|
255
|
+
argv += ["--rounds", "1"]
|
|
256
|
+
if ctx.get("extend"):
|
|
257
|
+
argv += ["--extend", ctx["extend"]]
|
|
258
|
+
if ctx.get("engine_version"):
|
|
259
|
+
argv += ["--engine-version", str(ctx["engine_version"])]
|
|
260
|
+
if ctx.get("mode") == "load" and ctx.get("ref_e2e_ms") not in (None, ""):
|
|
261
|
+
argv += ["--ref-e2e-ms", str(ctx["ref_e2e_ms"])] # repr of the float: the exact same schedule
|
|
262
|
+
extra_words = shlex.split(extra) if extra else []
|
|
263
|
+
body = ctx.get("extra_body")
|
|
264
|
+
if body:
|
|
265
|
+
# The one resolved value (--extra-body, the manifest's workload.extra_body, or one in --serve-args; checked
|
|
266
|
+
# to agree by run_rounds) for every engine and round; a copy in --serve-args is dropped.
|
|
267
|
+
extra_words = _drop_flag(extra_words, "--extra-body")
|
|
268
|
+
argv += ["--extra-body", json.dumps(body, sort_keys=True, separators=(",", ":"))]
|
|
269
|
+
ctk = ctx.get("chat_template_kwargs")
|
|
270
|
+
if ctk:
|
|
271
|
+
# The same template settings for every engine and round (from --chat-template-kwargs, the manifest's
|
|
272
|
+
# workload.chat_template_kwargs, one in --serve-args, plus the intake pin; run_rounds has checked they
|
|
273
|
+
# agree). A copy in --serve-args is dropped so the one resolved value is the only one sent; a prompt
|
|
274
|
+
# record's own field still wins per key.
|
|
275
|
+
extra_words = _drop_flag(extra_words, "--chat-template-kwargs")
|
|
276
|
+
argv += ["--chat-template-kwargs", json.dumps(ctk, sort_keys=True, separators=(",", ":"))]
|
|
277
|
+
return argv + extra_words
|
|
278
|
+
|
|
279
|
+
|
|
280
|
+
def template_kwargs(flag: str | dict | None, manifest: dict) -> tuple[dict, str]:
|
|
281
|
+
"""chat_template_kwargs for every cell: the flag (JSON object), else the manifest's
|
|
282
|
+
``workload.chat_template_kwargs``, else none. Returns (value, where it came from)."""
|
|
283
|
+
if flag not in (None, ""):
|
|
284
|
+
try:
|
|
285
|
+
value = json.loads(flag) if isinstance(flag, str) else flag
|
|
286
|
+
except ValueError as e:
|
|
287
|
+
raise OpError(f"--chat-template-kwargs is not valid JSON: {e}") from None
|
|
288
|
+
if not isinstance(value, dict):
|
|
289
|
+
raise OpError("--chat-template-kwargs must be a JSON object")
|
|
290
|
+
return value, "--chat-template-kwargs"
|
|
291
|
+
value = (manifest.get("workload") or {}).get("chat_template_kwargs")
|
|
292
|
+
if isinstance(value, dict) and value:
|
|
293
|
+
return value, "manifest workload.chat_template_kwargs"
|
|
294
|
+
return {}, "none"
|
|
295
|
+
|
|
296
|
+
|
|
297
|
+
def _serve_args_template_kwargs(serve_args: str | None, ctk: dict, source: str) -> tuple[dict, str]:
|
|
298
|
+
"""A --chat-template-kwargs inside --serve-args is the value every cell sends: use it for the warm-up and
|
|
299
|
+
plan.json too, and refuse it when it contradicts the flag or the manifest."""
|
|
300
|
+
found = _flag_value(shlex.split(serve_args) if serve_args else [], "--chat-template-kwargs")
|
|
301
|
+
if found is None:
|
|
302
|
+
return ctk, source
|
|
303
|
+
value, _ = template_kwargs(found, {})
|
|
304
|
+
if ctk and value != ctk:
|
|
305
|
+
raise OpError(f"--serve-args sets --chat-template-kwargs {json.dumps(value, sort_keys=True)} but {source} "
|
|
306
|
+
f"says {json.dumps(ctk, sort_keys=True)}: give one value")
|
|
307
|
+
return value, (source if ctk else "--chat-template-kwargs in --serve-args")
|
|
308
|
+
|
|
309
|
+
|
|
310
|
+
def _pinned_template_kwargs(ctk: dict, source: str, dirs: list[Path]) -> tuple[dict, str]:
|
|
311
|
+
"""Apply the intake's pinned chat_template_kwargs (intake.normalized.json in the run dir or next to the
|
|
312
|
+
manifest): pinned keys are added, a contradicting value is refused."""
|
|
313
|
+
from inferencekit.ops.intake import apply_pin, intake_pin
|
|
314
|
+
|
|
315
|
+
for d in dirs:
|
|
316
|
+
if intake_pin(d):
|
|
317
|
+
merged = apply_pin(ctk, d, "bench rounds")
|
|
318
|
+
if merged != ctk:
|
|
319
|
+
source = f"{source} + intake pin" if ctk else "intake pin (intake.normalized.json)"
|
|
320
|
+
return merged, source
|
|
321
|
+
return ctk, source
|
|
322
|
+
|
|
323
|
+
|
|
324
|
+
def _extra_body_value(text, what: str) -> tuple[dict, list[str]]:
|
|
325
|
+
"""The parsed extra body and its warnings; an OpError lists every error (manifest.extra_body_problems)."""
|
|
326
|
+
from inferencekit.ops.manifest import extra_body_problems
|
|
327
|
+
|
|
328
|
+
try:
|
|
329
|
+
value = json.loads(text) if isinstance(text, str) else text
|
|
330
|
+
except ValueError as e:
|
|
331
|
+
raise OpError(f"{what} is not valid JSON: {e}") from None
|
|
332
|
+
if not isinstance(value, dict):
|
|
333
|
+
raise OpError(f"{what} must be a JSON object")
|
|
334
|
+
errors, warnings = extra_body_problems(value, what)
|
|
335
|
+
if errors:
|
|
336
|
+
raise OpError("; ".join(errors))
|
|
337
|
+
return value, warnings
|
|
338
|
+
|
|
339
|
+
|
|
340
|
+
def extra_body_for(flag: str | dict | None, manifest: dict,
|
|
341
|
+
serve_args: str | None = None) -> tuple[dict, str, list[str]]:
|
|
342
|
+
"""The JSON object every bench serve call gets as ``--extra-body``, where it came from, and the warnings
|
|
343
|
+
about it (recorded as limitations): the flag, else the manifest's ``workload.extra_body``; one inside
|
|
344
|
+
``--serve-args`` is used when neither is set and refused when it contradicts them."""
|
|
345
|
+
if flag not in (None, ""):
|
|
346
|
+
(value, warnings), source = _extra_body_value(flag, "--extra-body"), "--extra-body"
|
|
347
|
+
else:
|
|
348
|
+
found = (manifest.get("workload") or {}).get("extra_body") if isinstance(manifest.get("workload"),
|
|
349
|
+
dict) else None
|
|
350
|
+
source = "none" if found in (None, {}) else "manifest workload.extra_body"
|
|
351
|
+
value, warnings = ({}, []) if found in (None, {}) else _extra_body_value(found, source)
|
|
352
|
+
inner = _flag_value(shlex.split(serve_args) if serve_args else [], "--extra-body")
|
|
353
|
+
if inner is None:
|
|
354
|
+
return value, source, warnings
|
|
355
|
+
given, given_warnings = _extra_body_value(inner, "--extra-body in --serve-args")
|
|
356
|
+
if value and given != value:
|
|
357
|
+
raise OpError(f"--serve-args sets --extra-body {json.dumps(given, sort_keys=True)} but {source} says "
|
|
358
|
+
f"{json.dumps(value, sort_keys=True)}: give one value")
|
|
359
|
+
return (given, source, warnings) if value else (given, "--extra-body in --serve-args", given_warnings)
|
|
360
|
+
|
|
361
|
+
|
|
362
|
+
SCHEDULE_FLAGS = ("--rates", "--ref-e2e-ms", "--closed-loop")
|
|
363
|
+
|
|
364
|
+
|
|
365
|
+
def schedule_fixed_by(serve_args: str | None) -> str | None:
|
|
366
|
+
"""The ``--serve-args`` flag that already fixes the load schedule for every engine, if any."""
|
|
367
|
+
words = shlex.split(serve_args) if serve_args else []
|
|
368
|
+
return next((w.split("=", 1)[0] for w in words if w.split("=", 1)[0] in SCHEDULE_FLAGS), None)
|
|
369
|
+
|
|
370
|
+
|
|
371
|
+
def reference_e2e_ms(record: dict | None, store_root: Path) -> float | None:
|
|
372
|
+
"""The unloaded e2e latency a load-mode ``bench serve`` call used to turn concurrency into rates.
|
|
373
|
+
|
|
374
|
+
It is ``data.e2e_ref_ms`` of the measured ``serve_benchmark`` record, which the returned
|
|
375
|
+
``capacity`` record cites as ``data.measurement``.
|
|
376
|
+
"""
|
|
377
|
+
if not isinstance(record, dict):
|
|
378
|
+
return None
|
|
379
|
+
data = record.get("data") if isinstance(record.get("data"), dict) else {}
|
|
380
|
+
x = data.get("e2e_ref_ms")
|
|
381
|
+
if x is None and data.get("measurement"):
|
|
382
|
+
try:
|
|
383
|
+
measured = json.loads((store_root / str(data["measurement"]) / "evidence.json").read_text())
|
|
384
|
+
x = (measured.get("data") or {}).get("e2e_ref_ms")
|
|
385
|
+
except (OSError, ValueError, AttributeError):
|
|
386
|
+
x = None
|
|
387
|
+
return float(x) if isinstance(x, (int, float)) and not isinstance(x, bool) and x > 0 else None
|
|
388
|
+
|
|
389
|
+
|
|
390
|
+
def parse_serve_output(stdout: str) -> dict | None:
|
|
391
|
+
"""The JSON object ``bench serve`` printed (an evidence record), or None."""
|
|
392
|
+
text = (stdout or "").strip()
|
|
393
|
+
if not text:
|
|
394
|
+
return None
|
|
395
|
+
try:
|
|
396
|
+
value = json.loads(text)
|
|
397
|
+
return value if isinstance(value, dict) else None
|
|
398
|
+
except ValueError:
|
|
399
|
+
pass
|
|
400
|
+
i = text.find("{") # tolerate a log line before the JSON
|
|
401
|
+
while i >= 0:
|
|
402
|
+
try:
|
|
403
|
+
value = json.loads(text[i:])
|
|
404
|
+
return value if isinstance(value, dict) else None
|
|
405
|
+
except ValueError:
|
|
406
|
+
i = text.find("\n{", i + 1)
|
|
407
|
+
i = i + 1 if i >= 0 else -1
|
|
408
|
+
return None
|
|
409
|
+
|
|
410
|
+
|
|
411
|
+
def _num(x):
|
|
412
|
+
if isinstance(x, bool):
|
|
413
|
+
return None
|
|
414
|
+
if isinstance(x, (int, float)):
|
|
415
|
+
return float(x)
|
|
416
|
+
if isinstance(x, dict):
|
|
417
|
+
for k in ("value", "median", "p50"):
|
|
418
|
+
if isinstance(x.get(k), (int, float)) and not isinstance(x.get(k), bool):
|
|
419
|
+
return float(x[k])
|
|
420
|
+
return None
|
|
421
|
+
|
|
422
|
+
|
|
423
|
+
def _bucket_of(metric: dict) -> str:
|
|
424
|
+
if metric.get("bucket") is not None:
|
|
425
|
+
return str(metric["bucket"])
|
|
426
|
+
subject = str(metric.get("subject") or "")
|
|
427
|
+
if not subject:
|
|
428
|
+
return "all"
|
|
429
|
+
subject = subject.rsplit("/", 1)[-1] # "<engine>/short" -> "short"
|
|
430
|
+
for sep in ("=", ":"):
|
|
431
|
+
head, found, tail = subject.partition(sep)
|
|
432
|
+
if found and head.strip().lower() == "bucket":
|
|
433
|
+
return tail.strip()
|
|
434
|
+
return subject # "short", or a load level such as "concurrency=8"
|
|
435
|
+
|
|
436
|
+
|
|
437
|
+
def bucket_metrics(record: dict | None) -> dict:
|
|
438
|
+
"""``{bucket: {metric: {"value", "unit", "stat"}}}`` from a ``bench serve`` record.
|
|
439
|
+
|
|
440
|
+
Accepted shapes (``bench serve`` owns the format; this reads it tolerantly):
|
|
441
|
+
* ``metrics: [{"name", "value", "unit", "stat", "bucket"? , "subject"?}]`` (a stat other than
|
|
442
|
+
the median is appended to the name: ``ttft_ms_p95``); the bucket is
|
|
443
|
+
``bucket``, else ``subject`` (``"short"``, ``"bucket=short"``, ``"bucket:short"``,
|
|
444
|
+
``"<engine>/short"``), else ``"all"``;
|
|
445
|
+
* ``data.per_bucket`` or ``data.buckets``: ``{bucket: {metric: number | {"value"|"median": x}}}``.
|
|
446
|
+
"""
|
|
447
|
+
out: dict[str, dict] = {}
|
|
448
|
+
if not isinstance(record, dict):
|
|
449
|
+
return out
|
|
450
|
+
|
|
451
|
+
def put(bucket, name, value, unit=None, stat=None):
|
|
452
|
+
v = _num(value)
|
|
453
|
+
if v is None or not name:
|
|
454
|
+
return
|
|
455
|
+
# "ttft_ms" with stat p95 becomes "ttft_ms_p95"; a median (or unlabelled value) keeps its name.
|
|
456
|
+
key = str(name) if stat in (None, "", "median", "p50", "value", "threshold") else f"{name}_{stat}"
|
|
457
|
+
out.setdefault(str(bucket), {})[key] = {"value": v, "unit": unit, "stat": stat}
|
|
458
|
+
|
|
459
|
+
for m in record.get("metrics") or []:
|
|
460
|
+
if isinstance(m, dict):
|
|
461
|
+
put(_bucket_of(m), m.get("name"), m.get("value"), m.get("unit"), m.get("stat"))
|
|
462
|
+
data = record.get("data") if isinstance(record.get("data"), dict) else {}
|
|
463
|
+
for key in ("per_bucket", "buckets"):
|
|
464
|
+
block = data.get(key)
|
|
465
|
+
if isinstance(block, dict):
|
|
466
|
+
for b, mets in block.items():
|
|
467
|
+
if isinstance(mets, dict):
|
|
468
|
+
for name, value in mets.items():
|
|
469
|
+
if name not in out.get(str(b), {}):
|
|
470
|
+
unit = value.get("unit") if isinstance(value, dict) else None
|
|
471
|
+
put(b, name, value, unit, "median")
|
|
472
|
+
return out
|
|
473
|
+
|
|
474
|
+
|
|
475
|
+
ROW_METRICS = (("ttft_ms", "ms"), ("tpot_ms", "ms"), ("decode_tok_s", "tok/s"), ("e2e_ms", "ms"))
|
|
476
|
+
|
|
477
|
+
|
|
478
|
+
def request_rows(record: dict | None, store_root: Path) -> list[dict] | None:
|
|
479
|
+
"""The per-request rows of a ``bench serve`` record (its ``requests`` artifact), or None."""
|
|
480
|
+
if not isinstance(record, dict) or not record.get("evidence_id"):
|
|
481
|
+
return None
|
|
482
|
+
art = next((a for a in record.get("artifacts") or [] if isinstance(a, dict) and a.get("role") == "requests"),
|
|
483
|
+
None)
|
|
484
|
+
if not art:
|
|
485
|
+
return None
|
|
486
|
+
path = store_root / record["evidence_id"] / art["path"]
|
|
487
|
+
try:
|
|
488
|
+
return [json.loads(line) for line in path.read_text().splitlines() if line.strip()]
|
|
489
|
+
except (OSError, ValueError):
|
|
490
|
+
return None
|
|
491
|
+
|
|
492
|
+
|
|
493
|
+
def round_metrics_from_rows(rows: list[dict]) -> tuple[int | None, dict]:
|
|
494
|
+
"""``(round, {bucket: {metric: ...}})`` for the latest round in ``rows``: medians over its requests.
|
|
495
|
+
|
|
496
|
+
A single-mode record extended with ``--extend`` holds every earlier round too; its summary
|
|
497
|
+
metrics are cumulative, so the value of *this* round comes from its own requests. Buckets
|
|
498
|
+
are the prompts' buckets plus ``all``; ``failed_requests`` counts the round's failures.
|
|
499
|
+
"""
|
|
500
|
+
rounds = [r.get("round") for r in rows if isinstance(r.get("round"), int)]
|
|
501
|
+
if not rounds:
|
|
502
|
+
return None, {}
|
|
503
|
+
k = max(rounds)
|
|
504
|
+
mine = [r for r in rows if r.get("round") == k]
|
|
505
|
+
out: dict[str, dict] = {}
|
|
506
|
+
for bucket in sorted({str(r.get("bucket") or "all") for r in mine}) + ["all"]:
|
|
507
|
+
sel = [r for r in mine if bucket == "all" or str(r.get("bucket") or "all") == bucket]
|
|
508
|
+
ok = [r for r in sel if r.get("ok")]
|
|
509
|
+
mets = {}
|
|
510
|
+
for name, unit in ROW_METRICS:
|
|
511
|
+
vals = [float(r[name]) for r in ok if isinstance(r.get(name), (int, float))]
|
|
512
|
+
if vals:
|
|
513
|
+
mets[name] = {"value": statistics.median(vals), "unit": unit, "stat": "median", "n": len(vals)}
|
|
514
|
+
mets["failed_requests"] = {"value": float(len(sel) - len(ok)), "unit": "requests", "stat": "count",
|
|
515
|
+
"n": len(sel)}
|
|
516
|
+
out[bucket] = mets
|
|
517
|
+
return k, out
|
|
518
|
+
|
|
519
|
+
|
|
520
|
+
# Stat suffixes bucket_metrics() appends to a metric name ("ttft_ms" + "p95_at_n_star").
|
|
521
|
+
_STAT_SUFFIXES = ("_at_n_star", "_threshold", "_median", "_mean", "_p50", "_p90", "_p95", "_p99")
|
|
522
|
+
COUNT_METRICS = ("failed_requests",) # reported per engine, never ranked
|
|
523
|
+
|
|
524
|
+
|
|
525
|
+
def base_metric(metric: str) -> str:
|
|
526
|
+
"""The metric without its stat suffixes: ``ttft_ms_p95_at_n_star`` -> ``ttft_ms``."""
|
|
527
|
+
m = metric.lower()
|
|
528
|
+
stripped = True
|
|
529
|
+
while stripped:
|
|
530
|
+
stripped = False
|
|
531
|
+
for suffix in _STAT_SUFFIXES:
|
|
532
|
+
if m.endswith(suffix) and len(m) > len(suffix):
|
|
533
|
+
m, stripped = m[: -len(suffix)], True
|
|
534
|
+
return m
|
|
535
|
+
|
|
536
|
+
|
|
537
|
+
def higher_is_better(metric: str) -> bool | None:
|
|
538
|
+
"""True for rates and capacity, False for latencies, costs and failures, None when unknown.
|
|
539
|
+
|
|
540
|
+
The base name decides (stat suffixes such as ``_p95`` or ``_at_n_star`` are stripped first),
|
|
541
|
+
and latency markers are checked before rate markers, so ``ttft_ms_p95_at_n_star`` is a
|
|
542
|
+
latency (lower is better) although it mentions N*.
|
|
543
|
+
"""
|
|
544
|
+
m = base_metric(metric)
|
|
545
|
+
if any(k in m for k in ("usd", "cost", "fail", "error")):
|
|
546
|
+
return False
|
|
547
|
+
if any(k in m for k in ("ttft", "tpot", "itl", "e2e", "latency")) or m.endswith(("_ms", "_seconds")):
|
|
548
|
+
return False
|
|
549
|
+
if m in ("n_star", "rate_star") or m.startswith("rate_star") or \
|
|
550
|
+
any(k in m for k in ("tok_s", "tokens_per_s", "throughput", "goodput", "rps", "per_s", "per_hour")):
|
|
551
|
+
return True
|
|
552
|
+
if m.endswith("_s"):
|
|
553
|
+
return False
|
|
554
|
+
return None
|
|
555
|
+
|
|
556
|
+
|
|
557
|
+
def cell_status(returncode: int, record: dict | None, metrics: dict) -> tuple[str, str | None]:
|
|
558
|
+
"""``(status, detail)`` of a measured cell: ok, serve_failed or serve_no_metrics.
|
|
559
|
+
|
|
560
|
+
A record whose requests all failed is ``serve_failed`` even though bench serve exited 0
|
|
561
|
+
and the record has a ``failed_requests`` count: there is no latency to compare.
|
|
562
|
+
"""
|
|
563
|
+
if returncode != 0 or record is None:
|
|
564
|
+
return "serve_failed", None
|
|
565
|
+
if not metrics:
|
|
566
|
+
return "serve_no_metrics", None
|
|
567
|
+
failed = (metrics.get("all") or {}).get("failed_requests") or {}
|
|
568
|
+
n = failed.get("n")
|
|
569
|
+
if isinstance(n, (int, float)) and n > 0 and failed.get("value", 0) >= n:
|
|
570
|
+
return "serve_failed", f"all {int(n)} requests failed"
|
|
571
|
+
return "ok", None
|
|
572
|
+
|
|
573
|
+
|
|
574
|
+
# --------------------------------------------------------------------------- one cell
|
|
575
|
+
|
|
576
|
+
def _tail(path: Path, n: int = 4000) -> str:
|
|
577
|
+
try:
|
|
578
|
+
data = path.read_bytes()
|
|
579
|
+
except OSError:
|
|
580
|
+
return ""
|
|
581
|
+
return data[-n:].decode(errors="replace")
|
|
582
|
+
|
|
583
|
+
|
|
584
|
+
def run_cell(spec: dict, k: int, position: int, cfg: dict, prompts: dict) -> dict:
|
|
585
|
+
"""Launch, check, measure and stop one engine for round ``k``. Never raises for engine failures."""
|
|
586
|
+
run_dir: Path = cfg["run_dir"]
|
|
587
|
+
label = spec["label"]
|
|
588
|
+
base = f"http://{spec['host']}:{spec['port']}"
|
|
589
|
+
log_rel = f"logs/{label}.r{k}.log"
|
|
590
|
+
log_path = run_dir / log_rel
|
|
591
|
+
cell: dict = {"round": k, "position": position, "label": label, "kind": spec["kind"], "status": None,
|
|
592
|
+
"log": log_rel, "t_start": time.time()}
|
|
593
|
+
if log_path.exists():
|
|
594
|
+
log_path.unlink() # a rerun of the same round must not append to an old log
|
|
595
|
+
env, env_info = servers.server_env(spec["env"])
|
|
596
|
+
cell["env"] = env_info
|
|
597
|
+
if servers.port_in_use(spec["host"], spec["port"]):
|
|
598
|
+
cell.update(status="port_busy", detail=f"something already listens on {spec['host']}:{spec['port']}; "
|
|
599
|
+
"refusing to benchmark it in place of the engine")
|
|
600
|
+
cell["t_end"] = time.time()
|
|
601
|
+
return cell
|
|
602
|
+
gpu_before = cfg["gpu_reader"]()
|
|
603
|
+
server = None
|
|
604
|
+
try:
|
|
605
|
+
try:
|
|
606
|
+
server = servers.launch(spec["launch"], env, log_path, cwd=spec.get("cwd"))
|
|
607
|
+
except OSError as e:
|
|
608
|
+
cell.update(status="launch_failed", detail=str(e)[:300])
|
|
609
|
+
return cell
|
|
610
|
+
ready = servers.wait_ready(server, base + spec["ready_check"], cfg["startup_timeout_s"], cfg["poll_s"])
|
|
611
|
+
cell["ready"] = ready
|
|
612
|
+
if ready["status"] != "ready":
|
|
613
|
+
cell["status"] = "startup_timeout" if ready["status"] == "timeout" else "exited_early"
|
|
614
|
+
return cell
|
|
615
|
+
model = servers.served_model(base)
|
|
616
|
+
cell["served_model"] = model
|
|
617
|
+
model = model or cfg.get("model_id")
|
|
618
|
+
if not model:
|
|
619
|
+
cell.update(status="serve_failed", detail="no model name: /v1/models listed none and the manifest "
|
|
620
|
+
"has no model.id")
|
|
621
|
+
return cell
|
|
622
|
+
if cfg["warmup"] > 0:
|
|
623
|
+
cell["warmup"] = servers.warmup(base, model, prompts["head"], cfg["warmup"], cfg["warmup_max_tokens"],
|
|
624
|
+
salt=f"ik-rounds-warmup-r{k}",
|
|
625
|
+
chat_template_kwargs=cfg.get("chat_template_kwargs"))
|
|
626
|
+
if cell["warmup"]["ok"] == 0:
|
|
627
|
+
cell["status"] = "warmup_failed"
|
|
628
|
+
return cell
|
|
629
|
+
before = servers.scrape_metrics(base)
|
|
630
|
+
extend = cfg["last_ev"].get(label) if cfg["mode"] == "single" else None
|
|
631
|
+
ctx = {"base_url": base, "prompts": str(cfg["prompts_path"]), "mode": cfg["mode"], "label": label,
|
|
632
|
+
"round": k, "run_dir": str(run_dir), "model": model, "engine_kind": spec["kind"],
|
|
633
|
+
"engine_version": spec.get("version") or "", "extend": extend or "",
|
|
634
|
+
"ref_e2e_ms": cfg.get("ref_e2e_ms") if cfg["mode"] == "load" else None,
|
|
635
|
+
"chat_template_kwargs": cfg.get("chat_template_kwargs"), "extra_body": cfg.get("extra_body")}
|
|
636
|
+
if cfg["mode"] == "load":
|
|
637
|
+
sched = cfg["schedule"]
|
|
638
|
+
shared = sched.get("fixed_by") or ctx["ref_e2e_ms"] is not None
|
|
639
|
+
cell["load_schedule"] = {"ref_e2e_ms": ctx["ref_e2e_ms"],
|
|
640
|
+
"source": sched["source"] if shared else "this engine's own warm-up"}
|
|
641
|
+
argv = serve_argv(cfg["serve_cmd"], ctx, cfg["serve_args"])
|
|
642
|
+
child_env = dict(os.environ)
|
|
643
|
+
# Same evidence store as this run; and tell a lock-aware client that the GPU lock is already held
|
|
644
|
+
# (by this process) so it must not try to take it again.
|
|
645
|
+
root = str(ev.default_root().resolve())
|
|
646
|
+
# The child's record is read from its stdout (parse_serve_output): full JSON, never the terse view.
|
|
647
|
+
child_env.update({"INFERKIT_RUNS_DIR": root, "INFERENCEKIT_RUNS_DIR": root,
|
|
648
|
+
"INFERENCEKIT_GPU_LOCK_HELD": str(cfg.get("gpu_lock_path") or ""),
|
|
649
|
+
"INFERENCEKIT_OUTPUT": "json"})
|
|
650
|
+
t0 = time.monotonic()
|
|
651
|
+
res = proc.run(argv, timeout=cfg["serve_timeout_s"], env=child_env) # a timeout kills its process group
|
|
652
|
+
rc, so, se = res.returncode, res.stdout, res.stderr
|
|
653
|
+
after = servers.scrape_metrics(base)
|
|
654
|
+
record = parse_serve_output(so)
|
|
655
|
+
cell["serve"] = {"returncode": rc, "seconds": round(time.monotonic() - t0, 3),
|
|
656
|
+
"evidence_id": record.get("evidence_id") if record else None,
|
|
657
|
+
"stderr_tail": ev.redact_text(se[-1500:]) if rc != 0 else None}
|
|
658
|
+
prom_dir = run_dir / "prometheus"
|
|
659
|
+
if before is not None and after is not None:
|
|
660
|
+
prom_dir.mkdir(parents=True, exist_ok=True)
|
|
661
|
+
(prom_dir / f"{label}.r{k}.before.txt").write_text(before)
|
|
662
|
+
(prom_dir / f"{label}.r{k}.after.txt").write_text(after)
|
|
663
|
+
cell["prometheus"] = servers.metrics_delta(before, after)
|
|
664
|
+
rows = request_rows(record, ev.default_root())
|
|
665
|
+
if rows:
|
|
666
|
+
serve_round, cell["metrics"] = round_metrics_from_rows(rows)
|
|
667
|
+
cell["metrics_source"] = f"requests artifact, round {serve_round} (median over its requests)"
|
|
668
|
+
elif record is not None and (record.get("data") or {}).get("extends"):
|
|
669
|
+
cell["metrics"] = {} # cumulative over earlier rounds: not this round's value
|
|
670
|
+
cell["metrics_source"] = "extended record without per-request rows: not used"
|
|
671
|
+
else:
|
|
672
|
+
cell["metrics"] = bucket_metrics(record)
|
|
673
|
+
cell["metrics_source"] = "record metrics"
|
|
674
|
+
if record is not None and rc == 0 and record.get("evidence_id") and cfg["mode"] == "single":
|
|
675
|
+
cfg["last_ev"][label] = record["evidence_id"]
|
|
676
|
+
cell["status"], detail = cell_status(rc, record, cell["metrics"])
|
|
677
|
+
if detail:
|
|
678
|
+
cell["detail"] = detail
|
|
679
|
+
if cfg["mode"] == "load" and cell["status"] == "ok":
|
|
680
|
+
used = reference_e2e_ms(record, ev.default_root())
|
|
681
|
+
cell["load_schedule"]["ref_e2e_ms_used"] = used
|
|
682
|
+
sched = cfg["schedule"]
|
|
683
|
+
if not sched.get("fixed_by") and cfg.get("ref_e2e_ms") is None and used is not None:
|
|
684
|
+
# The first load cell measured it with its warm-up; every later cell gets the same value.
|
|
685
|
+
cfg["ref_e2e_ms"] = used
|
|
686
|
+
sched.update(ref_e2e_ms=used, source=f"warm-up of {label} r{k} (the first load cell), passed as "
|
|
687
|
+
"--ref-e2e-ms to every later cell", pilot=f"{label} r{k}")
|
|
688
|
+
return cell
|
|
689
|
+
finally:
|
|
690
|
+
if server is not None:
|
|
691
|
+
cell["stop"] = servers.stop(server, cfg["stop_timeout_s"])
|
|
692
|
+
if not cell["stop"]["group_gone"] and cell["status"] == "ok":
|
|
693
|
+
cell["status"] = "stop_failed"
|
|
694
|
+
cell["gpu_memory"] = servers.wait_gpu_memory(gpu_before, cfg["gpu_mem_tolerance_mib"],
|
|
695
|
+
cfg["gpu_mem_wait_s"], reader=cfg["gpu_reader"])
|
|
696
|
+
resolved = servers.parse_resolved_config(spec["kind"], log_path.read_text(errors="replace")
|
|
697
|
+
if log_path.exists() else "")
|
|
698
|
+
rc_dir = run_dir / "resolved_config"
|
|
699
|
+
rc_dir.mkdir(parents=True, exist_ok=True)
|
|
700
|
+
(rc_dir / f"{label}.r{k}.json").write_text(json.dumps(ev.redact(resolved), indent=2) + "\n")
|
|
701
|
+
cell["resolved_config"] = {"status": resolved["status"], "version": resolved.get("version"),
|
|
702
|
+
"validity": resolved.get("validity"),
|
|
703
|
+
"warnings": len(resolved["warnings"]),
|
|
704
|
+
"unknown_settings": resolved["unknown_settings"],
|
|
705
|
+
"self_reported": resolved.get("self_reported") or [],
|
|
706
|
+
"file": f"resolved_config/{label}.r{k}.json"}
|
|
707
|
+
if cell["status"] in ("startup_timeout", "exited_early"):
|
|
708
|
+
cell["log_tail"] = ev.redact_text(_tail(log_path, 1500))
|
|
709
|
+
cell["t_end"] = time.time()
|
|
710
|
+
|
|
711
|
+
|
|
712
|
+
# --------------------------------------------------------------------------- summary
|
|
713
|
+
|
|
714
|
+
def summarize(cells: list[dict], labels: list[str], resamples: int = stats.DEFAULT_RESAMPLES,
|
|
715
|
+
seed: int = 0, mode: str = "single") -> dict:
|
|
716
|
+
"""Medians over rounds with bootstrap CIs, and ratios to the best engine with paired CIs.
|
|
717
|
+
|
|
718
|
+
An engine with any failed request in a bucket (over all its rounds) is not eligible as
|
|
719
|
+
the best engine in that bucket and gets no ratio there: its latency medians cover only
|
|
720
|
+
the requests it answered. ``failed_requests`` itself is reported (median and total over
|
|
721
|
+
rounds) but never ranked.
|
|
722
|
+
"""
|
|
723
|
+
values: dict[tuple[str, str, str], dict[int, float]] = {}
|
|
724
|
+
units: dict[str, str | None] = {}
|
|
725
|
+
requests: dict[tuple[str, str], float] = {}
|
|
726
|
+
for c in cells:
|
|
727
|
+
if c.get("status") not in STATUSES_OK:
|
|
728
|
+
continue
|
|
729
|
+
for bucket, mets in (c.get("metrics") or {}).items():
|
|
730
|
+
for name, m in mets.items():
|
|
731
|
+
values.setdefault((c["label"], bucket, name), {})[c["round"]] = m["value"]
|
|
732
|
+
units.setdefault(name, m.get("unit"))
|
|
733
|
+
if name == "failed_requests" and isinstance(m.get("n"), (int, float)):
|
|
734
|
+
requests[(c["label"], bucket)] = requests.get((c["label"], bucket), 0) + m["n"]
|
|
735
|
+
failures = {(lb, b): sum(v.values()) for (lb, b, n), v in values.items() if n == "failed_requests"}
|
|
736
|
+
rows = []
|
|
737
|
+
groups = sorted({(b, n) for (_, b, n) in values})
|
|
738
|
+
for bucket, name in groups:
|
|
739
|
+
direction = None if name in COUNT_METRICS else higher_is_better(name)
|
|
740
|
+
per = {}
|
|
741
|
+
for label in labels:
|
|
742
|
+
v = values.get((label, bucket, name))
|
|
743
|
+
if not v:
|
|
744
|
+
continue
|
|
745
|
+
xs = [v[r] for r in sorted(v)]
|
|
746
|
+
ci = stats.bootstrap_ci(xs, ps=(50,), resamples=resamples, seed=seed)[50]
|
|
747
|
+
per[label] = {"median": statistics.median(xs), "ci95": list(ci), "n": len(xs), "by_round": v}
|
|
748
|
+
if not per:
|
|
749
|
+
continue
|
|
750
|
+
eligible = [lb for lb in per if not failures.get((lb, bucket))]
|
|
751
|
+
best = None
|
|
752
|
+
if direction is not None and eligible:
|
|
753
|
+
pick = max if direction else min
|
|
754
|
+
best = pick(eligible, key=lambda lb: per[lb]["median"])
|
|
755
|
+
for label, info in per.items():
|
|
756
|
+
failed = failures.get((label, bucket), 0.0)
|
|
757
|
+
row = {"engine": label, "bucket": bucket, "metric": name, "unit": units.get(name),
|
|
758
|
+
"higher_is_better": direction, "median": info["median"], "ci95": info["ci95"], "n": info["n"],
|
|
759
|
+
"best": best, "ratio_to_best": None, "ratio_ci95": [None, None], "n_pairs": 0,
|
|
760
|
+
"failed_requests": failed}
|
|
761
|
+
if name in COUNT_METRICS:
|
|
762
|
+
row["total"] = sum(info["by_round"].values())
|
|
763
|
+
if failed and direction is not None:
|
|
764
|
+
row["excluded_from_best"] = (f"{failed:g} failed request(s) over {info['n']} round(s): latencies "
|
|
765
|
+
"cover only the answered requests")
|
|
766
|
+
elif best is not None:
|
|
767
|
+
b, c = per[best]["by_round"], info["by_round"]
|
|
768
|
+
paired = sorted(set(b) & set(c))
|
|
769
|
+
if label == best:
|
|
770
|
+
row.update(ratio_to_best=1.0, n_pairs=len(paired))
|
|
771
|
+
elif paired:
|
|
772
|
+
r = stats.paired_ratio_ci([b[i] for i in paired], [c[i] for i in paired], resamples=resamples,
|
|
773
|
+
seed=seed)
|
|
774
|
+
row.update(ratio_to_best=r["ratio"], ratio_ci95=list(r["ci95"]), n_pairs=r["n_pairs"])
|
|
775
|
+
rows.append(row)
|
|
776
|
+
fail_list = [{"engine": lb, "bucket": b, "failed_requests": f, "requests": requests.get((lb, b)),
|
|
777
|
+
"rounds": len(values.get((lb, b, "failed_requests")) or {})}
|
|
778
|
+
for (lb, b), f in sorted(failures.items()) if f]
|
|
779
|
+
if mode == "load":
|
|
780
|
+
median_def = ("median over rounds of the per-round value from bench serve's capacity record (N*, and "
|
|
781
|
+
"goodput, tok/s, p95 latencies and cost at N*) (derived)")
|
|
782
|
+
else:
|
|
783
|
+
median_def = ("median over rounds of the per-round value (median over that round's requests in the "
|
|
784
|
+
"bucket, from bench serve's requests artifact) (observed)")
|
|
785
|
+
return {"rows": rows, "failures": fail_list, "definitions": {
|
|
786
|
+
"median": median_def,
|
|
787
|
+
"ci95": f"bootstrap 95% CI of that median, rounds resampled ({'derived' if mode == 'load' else 'observed'})",
|
|
788
|
+
"ratio_to_best": "median(engine) / median(best engine) over rounds both completed, with a paired "
|
|
789
|
+
"bootstrap 95% CI resampling rounds (derived); best = lowest for latencies, highest "
|
|
790
|
+
"for rates, among engines with no failed request in the bucket",
|
|
791
|
+
"failed_requests": "requests that failed in a round (median over rounds; 'total' sums them); an engine "
|
|
792
|
+
"with any failure in a bucket is never best there and gets no ratio"}}
|
|
793
|
+
|
|
794
|
+
|
|
795
|
+
# --------------------------------------------------------------------------- adaptive stop
|
|
796
|
+
|
|
797
|
+
def ci_half_widths(summary: dict, labels: list[str], metric: str = ADAPTIVE_METRIC,
|
|
798
|
+
bucket: str = ADAPTIVE_BUCKET) -> dict:
|
|
799
|
+
"""Per engine, the relative half-width of the 95% CI the adaptive stop reads: (hi - lo) / 2 / value.
|
|
800
|
+
|
|
801
|
+
The value is the engine's ratio to the best engine (paired bootstrap CI over rounds). The best
|
|
802
|
+
engine is the reference of every ratio and is not checked (``counted`` False). An engine with no
|
|
803
|
+
ratio (failed requests, or no round shared with the best) and the only engine of a run use the CI
|
|
804
|
+
of their median instead. ``width`` None: fewer than 2 rounds, so no CI yet.
|
|
805
|
+
"""
|
|
806
|
+
rows = {r["engine"]: r for r in summary["rows"] if r["metric"] == metric and r["bucket"] == bucket}
|
|
807
|
+
out = {}
|
|
808
|
+
for label in labels:
|
|
809
|
+
r = rows.get(label)
|
|
810
|
+
if r is None:
|
|
811
|
+
out[label] = {"width": None, "basis": "no successful round", "counted": True}
|
|
812
|
+
continue
|
|
813
|
+
if len(labels) > 1 and r["best"] == label:
|
|
814
|
+
out[label] = {"width": None, "basis": "best engine (the reference of every ratio)", "counted": False}
|
|
815
|
+
continue
|
|
816
|
+
lo, hi = r["ratio_ci95"]
|
|
817
|
+
if r["ratio_to_best"] and lo is not None and hi is not None and r["n_pairs"] >= 2:
|
|
818
|
+
out[label] = {"width": (hi - lo) / 2 / r["ratio_to_best"], "basis": "ratio_ci95", "n": r["n_pairs"],
|
|
819
|
+
"counted": True}
|
|
820
|
+
continue
|
|
821
|
+
lo, hi = r["ci95"]
|
|
822
|
+
if r["median"] and lo is not None and hi is not None and r["n"] >= 2:
|
|
823
|
+
out[label] = {"width": (hi - lo) / 2 / abs(r["median"]), "basis": "ci95 of the median", "n": r["n"],
|
|
824
|
+
"counted": True}
|
|
825
|
+
else:
|
|
826
|
+
out[label] = {"width": None, "basis": "fewer than 2 rounds", "counted": True}
|
|
827
|
+
return out
|
|
828
|
+
|
|
829
|
+
|
|
830
|
+
def adaptive_check(cells: list[dict], labels: list[str], k: int, ci_target: float) -> dict:
|
|
831
|
+
"""After round ``k``: the CI half-widths (``ci_half_widths``) and whether every checked engine meets
|
|
832
|
+
``ci_target``. Only the primary metric is summarized (same seed as the final summary, so the same CIs)."""
|
|
833
|
+
primary = []
|
|
834
|
+
for c in cells:
|
|
835
|
+
mets = (c.get("metrics") or {}).get(ADAPTIVE_BUCKET) or {}
|
|
836
|
+
keep = {n: m for n, m in mets.items() if n in (ADAPTIVE_METRIC, "failed_requests")}
|
|
837
|
+
primary.append({**c, "metrics": {ADAPTIVE_BUCKET: keep} if keep else {}})
|
|
838
|
+
widths = ci_half_widths(summarize(primary, labels), labels)
|
|
839
|
+
checked = [w for w in widths.values() if w["counted"]]
|
|
840
|
+
met = bool(checked) and all(w["width"] is not None and w["width"] <= ci_target for w in checked)
|
|
841
|
+
return {"round": k, "met": met, "widths": widths}
|
|
842
|
+
|
|
843
|
+
|
|
844
|
+
def _adaptive_plan(n_engines: int, rounds: int | None, min_rounds: int | None, ci_target: float,
|
|
845
|
+
mode: str) -> tuple[dict, int]:
|
|
846
|
+
"""The adaptive-stop settings recorded in plan.json, and the maximum number of rounds."""
|
|
847
|
+
if mode != "single":
|
|
848
|
+
raise OpError("--ci-target (adaptive stop) is for single mode: it reads each round's decode_tok_s")
|
|
849
|
+
if not 0 < ci_target < 1:
|
|
850
|
+
raise OpError("--ci-target is a relative CI half-width between 0 and 1, e.g. 0.01 for +/-1%")
|
|
851
|
+
period = len(williams_square(n_engines)) or 1
|
|
852
|
+
rounds_source = "--rounds"
|
|
853
|
+
if rounds is None:
|
|
854
|
+
rounds, rounds_source = default_rounds(n_engines, ADAPTIVE_MAX_ROUNDS), \
|
|
855
|
+
f"default: {ADAPTIVE_MAX_ROUNDS} rounded up to whole Williams periods"
|
|
856
|
+
if min_rounds is None:
|
|
857
|
+
min_rounds = min(default_rounds(n_engines), rounds)
|
|
858
|
+
if min_rounds < 2:
|
|
859
|
+
raise OpError("--min-rounds must be at least 2: a confidence interval needs 2 rounds")
|
|
860
|
+
first = period * -(-min_rounds // period)
|
|
861
|
+
if first >= rounds:
|
|
862
|
+
raise OpError(f"--ci-target cannot stop anything: the first check comes after round {first} (whole Williams "
|
|
863
|
+
f"periods of {period} for {n_engines} engine(s), from --min-rounds {min_rounds} on) and "
|
|
864
|
+
f"--rounds is {rounds}; raise --rounds")
|
|
865
|
+
rule = (f"stop after a whole Williams period (every {period} round(s)) once at least {min_rounds} rounds ran, "
|
|
866
|
+
f"when every engine's {ADAPTIVE_METRIC} [{ADAPTIVE_BUCKET}] ratio to the best engine has a 95% CI "
|
|
867
|
+
f"half-width <= {ci_target:g} x the ratio (the best engine is the reference and is not checked; an "
|
|
868
|
+
f"engine without a ratio, or the only engine, uses the CI of its median); at most {rounds} rounds")
|
|
869
|
+
return {"ci_target": ci_target, "min_rounds": min_rounds, "max_rounds": rounds, "max_rounds_source": rounds_source,
|
|
870
|
+
"period": period, "first_check_round": first, "metric": ADAPTIVE_METRIC, "bucket": ADAPTIVE_BUCKET,
|
|
871
|
+
"rule": rule, "checks": []}, rounds
|
|
872
|
+
|
|
873
|
+
|
|
874
|
+
def _fmt(x, digits: int = 3) -> str:
|
|
875
|
+
if x is None:
|
|
876
|
+
return "n/a"
|
|
877
|
+
if isinstance(x, float):
|
|
878
|
+
return f"{x:.{digits}g}" if abs(x) < 1000 else f"{x:.0f}"
|
|
879
|
+
return str(x)
|
|
880
|
+
|
|
881
|
+
|
|
882
|
+
def markdown(summary: dict, plan: dict, cells: list[dict], resolved: dict) -> str:
|
|
883
|
+
load = plan["mode"] == "load"
|
|
884
|
+
cls = "derived" if load else "observed"
|
|
885
|
+
lines = [f"# bench rounds: {plan['mode']} mode, {plan['rounds']} rounds, {len(plan['engines'])} engines", "",
|
|
886
|
+
f"Order: {plan['design']['design']} design, period {plan['design']['period']}, "
|
|
887
|
+
f"balanced: {'yes' if plan['design']['balanced'] else 'NO'}. Prompt set {plan['prompts']['name']} "
|
|
888
|
+
f"(sha256 {plan['prompts']['sha256'][:12]}..., n={plan['prompts']['n']}).", ""]
|
|
889
|
+
adaptive = plan.get("adaptive")
|
|
890
|
+
if adaptive:
|
|
891
|
+
if plan.get("stopped_early"):
|
|
892
|
+
how = (f"stopped early after round {plan['stopped_early']['round']} of at most "
|
|
893
|
+
f"{adaptive['max_rounds']}: the CI target was met")
|
|
894
|
+
else:
|
|
895
|
+
how = f"ran all {plan['rounds']} rounds; the CI target was {'met' if adaptive.get('met') else 'NOT met'}"
|
|
896
|
+
lines += [f"Adaptive stop: {how}. Rule: {adaptive['rule']}.", ""]
|
|
897
|
+
if plan.get("extra_body"):
|
|
898
|
+
lines += [f"Every request carried --extra-body {json.dumps(plan['extra_body']['value'], sort_keys=True)} "
|
|
899
|
+
f"({plan['extra_body']['source']}).", ""]
|
|
900
|
+
if load:
|
|
901
|
+
sched = plan.get("load_schedule") or {}
|
|
902
|
+
same = "the same for every engine" if sched.get("shared") else "NOT the same for every engine"
|
|
903
|
+
ref = f" (--ref-e2e-ms {_fmt(sched['ref_e2e_ms'])} ms)" if sched.get("ref_e2e_ms") else ""
|
|
904
|
+
lines += ["Medians and CIs are [derived]: each round's value comes from bench serve's capacity record "
|
|
905
|
+
"(N* and the values at N*, derived from the client-side timings of the load levels); ratios "
|
|
906
|
+
"are [derived].", "",
|
|
907
|
+
f"Arrival schedule: {same}{ref}; {sched.get('source') or 'unknown'}.", ""]
|
|
908
|
+
else:
|
|
909
|
+
lines += ["Medians and CIs are [observed] (client-side timer in `bench serve`); ratios are [derived].", ""]
|
|
910
|
+
lines += ["An engine marked [FAILURES] had failed requests in that bucket: its latencies cover only the "
|
|
911
|
+
"requests it answered, so it is never named best there and gets no ratio.", "",
|
|
912
|
+
f"| engine | bucket | metric | median [{cls}] | 95% CI | rounds | ratio to best [derived] | "
|
|
913
|
+
"ratio 95% CI |", "|---|---|---|---|---|---|---|---|"]
|
|
914
|
+
for r in summary["rows"]:
|
|
915
|
+
unit = f" {r['unit']}" if r.get("unit") else ""
|
|
916
|
+
best = " (best)" if r["engine"] == r["best"] else ""
|
|
917
|
+
if r.get("failed_requests"):
|
|
918
|
+
best += " [FAILURES]"
|
|
919
|
+
if (resolved.get(r["engine"]) or {}).get("violations"):
|
|
920
|
+
best += " [HARD GATE]"
|
|
921
|
+
if (resolved.get(r["engine"]) or {}).get("status") == "SELF_REPORTED":
|
|
922
|
+
best += " [SELF-REPORTED CONFIG]"
|
|
923
|
+
ratio_ci = "-" if r["engine"] == r["best"] or r["ratio_to_best"] is None else \
|
|
924
|
+
f"{_fmt(r['ratio_ci95'][0])} - {_fmt(r['ratio_ci95'][1])}"
|
|
925
|
+
lines.append(f"| {r['engine']}{best} | {r['bucket']} | {r['metric']} | {_fmt(r['median'])}{unit} | "
|
|
926
|
+
f"{_fmt(r['ci95'][0])} - {_fmt(r['ci95'][1])} | {r['n']} | {_fmt(r['ratio_to_best'])} | "
|
|
927
|
+
f"{ratio_ci} |")
|
|
928
|
+
if not summary["rows"]:
|
|
929
|
+
lines.append("| (no successful rounds) | | | | | | | |")
|
|
930
|
+
if summary.get("failures"):
|
|
931
|
+
lines += ["", "## Failed requests", "", "| engine | bucket | failed | requests | rounds |",
|
|
932
|
+
"|---|---|---|---|---|"]
|
|
933
|
+
for f in summary["failures"]:
|
|
934
|
+
lines.append(f"| {f['engine']} | {f['bucket']} | {_fmt(f['failed_requests'])} | "
|
|
935
|
+
f"{_fmt(f['requests'])} | {f['rounds']} |")
|
|
936
|
+
lines += ["", "## Resolved configuration (from server logs)", "",
|
|
937
|
+
"SELF_REPORTED: the settings come only from the engine process's own `inferencekit_resolved_config` "
|
|
938
|
+
"line, which nothing verifies; for vllm, sglang, llamacpp and trtllm such a line is ignored.", "",
|
|
939
|
+
"| engine | status | version | warnings | unknown settings | hard-gate violations | "
|
|
940
|
+
"self-reported (unverified) |", "|---|---|---|---|---|---|---|"]
|
|
941
|
+
for label, info in resolved.items():
|
|
942
|
+
lines.append(f"| {label} | {info['status']} | {info.get('version') or 'n/a'} | {info['warnings']} | "
|
|
943
|
+
f"{', '.join(info['unknown_settings']) or '-'} | {'; '.join(info['violations']) or '-'} | "
|
|
944
|
+
f"{', '.join(info.get('self_reported') or []) or '-'} |")
|
|
945
|
+
failed = [c for c in cells if c.get("status") != "ok"]
|
|
946
|
+
if failed:
|
|
947
|
+
lines += ["", "## Failed cells", "", "| round | engine | status | detail |", "|---|---|---|---|"]
|
|
948
|
+
for c in failed:
|
|
949
|
+
detail = c.get("detail") or (c.get("ready") or {}).get("last") or (c.get("serve") or {}).get("returncode")
|
|
950
|
+
lines.append(f"| {c['round']} | {c['label']} | {c['status']} | {str(detail)[:120]} |")
|
|
951
|
+
return "\n".join(lines) + "\n"
|
|
952
|
+
|
|
953
|
+
|
|
954
|
+
def resolved_summary(cells: list[dict], labels: list[str]) -> dict:
|
|
955
|
+
"""Per engine: the weakest resolved-config status over rounds, and what varied."""
|
|
956
|
+
rank = servers.STATUS_RANK
|
|
957
|
+
out = {}
|
|
958
|
+
for label in labels:
|
|
959
|
+
mine = [c["resolved_config"] for c in cells if c["label"] == label and c.get("resolved_config")]
|
|
960
|
+
if not mine:
|
|
961
|
+
out[label] = {"status": "UNPROVEN", "version": None, "warnings": 0, "unknown_settings": [],
|
|
962
|
+
"violations": [], "attention_backends": [], "self_reported": [], "rounds": 0}
|
|
963
|
+
continue
|
|
964
|
+
worst = max(mine, key=lambda r: rank.get(r["status"], rank["UNPROVEN"]))
|
|
965
|
+
violations = sorted({v for r in mine for v in ((r.get("validity") or {}).get("violations") or [])})
|
|
966
|
+
backends = sorted({str((r.get("validity") or {}).get("attention_backend")) for r in mine
|
|
967
|
+
if (r.get("validity") or {}).get("attention_backend")})
|
|
968
|
+
out[label] = {"status": worst["status"], "version": next((r["version"] for r in mine if r.get("version")),
|
|
969
|
+
None),
|
|
970
|
+
"warnings": max(r["warnings"] for r in mine),
|
|
971
|
+
"unknown_settings": sorted({u for r in mine for u in r["unknown_settings"]}),
|
|
972
|
+
"violations": violations, "attention_backends": backends, "rounds": len(mine),
|
|
973
|
+
"self_reported": sorted({k for r in mine for k in (r.get("self_reported") or [])}),
|
|
974
|
+
"files": [r["file"] for r in mine]}
|
|
975
|
+
return out
|
|
976
|
+
|
|
977
|
+
|
|
978
|
+
# --------------------------------------------------------------------------- the run
|
|
979
|
+
|
|
980
|
+
def run_rounds(manifest_path: str, prompts_path: str, rounds: int | None, mode: str, engines: str | None = None,
|
|
981
|
+
run_dir: str | None = None, gpu_lock_path: str | None = None, startup_timeout_s: float = 1200,
|
|
982
|
+
stop_timeout_s: float = 30, serve_timeout_s: float = 7200, warmup: int = 2,
|
|
983
|
+
warmup_max_tokens: int = 16, serve_cmd: str | None = None, serve_args: str | None = None,
|
|
984
|
+
gpu_mem_tolerance_mib: int = 512, gpu_mem_wait_s: float = 60, poll_s: float = 0.5,
|
|
985
|
+
gpu_reader=None, ref_e2e_ms: float | None = None, chat_template_kwargs: str | dict | None = None,
|
|
986
|
+
ci_target: float | None = None, min_rounds: int | None = None,
|
|
987
|
+
extra_body: str | dict | None = None) -> dict:
|
|
988
|
+
"""Run every (round, engine) cell under the GPU lock; return plan, cells, summary and paths.
|
|
989
|
+
|
|
990
|
+
``rounds`` None: the smallest multiple of the Williams period that is at least 3.
|
|
991
|
+
``ref_e2e_ms`` (load mode): passed as ``--ref-e2e-ms`` to every cell; None: taken from the
|
|
992
|
+
first load cell's warm-up and passed to every later one (unless ``serve_args`` fixes the schedule).
|
|
993
|
+
``ci_target`` (single mode): adaptive stop, see the module docstring; ``rounds`` is then the maximum
|
|
994
|
+
(None: 12 rounded up to whole periods) and ``min_rounds`` the minimum (None: the default rounds).
|
|
995
|
+
``extra_body``: JSON object for every bench serve call's ``--extra-body`` (None: the manifest's
|
|
996
|
+
``workload.extra_body``).
|
|
997
|
+
"""
|
|
998
|
+
if mode not in ("single", "load"):
|
|
999
|
+
raise OpError("mode must be 'single' or 'load'")
|
|
1000
|
+
manifest = load_manifest(manifest_path)
|
|
1001
|
+
specs = engine_specs(manifest, engines)
|
|
1002
|
+
if not specs:
|
|
1003
|
+
raise OpError("no engines selected")
|
|
1004
|
+
if min_rounds is not None and ci_target is None:
|
|
1005
|
+
raise OpError("--min-rounds applies only with --ci-target (the adaptive stop)")
|
|
1006
|
+
if rounds is not None and rounds < 1:
|
|
1007
|
+
raise OpError("rounds must be >= 1")
|
|
1008
|
+
adaptive = None
|
|
1009
|
+
if ci_target is not None:
|
|
1010
|
+
adaptive, rounds = _adaptive_plan(len(specs), rounds, min_rounds, ci_target, mode)
|
|
1011
|
+
if rounds is None:
|
|
1012
|
+
rounds = default_rounds(len(specs))
|
|
1013
|
+
if ref_e2e_ms is not None and ref_e2e_ms <= 0:
|
|
1014
|
+
raise OpError("--ref-e2e-ms must be > 0")
|
|
1015
|
+
fixed_by = schedule_fixed_by(serve_args) if mode == "load" else None
|
|
1016
|
+
if mode != "load":
|
|
1017
|
+
schedule = {}
|
|
1018
|
+
elif fixed_by:
|
|
1019
|
+
schedule = {"fixed_by": fixed_by, "ref_e2e_ms": None,
|
|
1020
|
+
"source": f"{fixed_by} in --serve-args (the same for every engine)"}
|
|
1021
|
+
ref_e2e_ms = None
|
|
1022
|
+
elif ref_e2e_ms is not None:
|
|
1023
|
+
schedule = {"ref_e2e_ms": ref_e2e_ms, "source": "--ref-e2e-ms of bench rounds, passed to every cell"}
|
|
1024
|
+
else:
|
|
1025
|
+
schedule = {"ref_e2e_ms": None, "source": "not yet measured (the first load cell's warm-up sets it)"}
|
|
1026
|
+
ctk, ctk_source = template_kwargs(chat_template_kwargs, manifest)
|
|
1027
|
+
ctk, ctk_source = _serve_args_template_kwargs(serve_args, ctk, ctk_source)
|
|
1028
|
+
body, body_source, body_warnings = extra_body_for(extra_body, manifest, serve_args)
|
|
1029
|
+
prompts = read_prompts(prompts_path)
|
|
1030
|
+
rdir = Path(run_dir) if run_dir else Path(manifest_path).resolve().parent
|
|
1031
|
+
ctk, ctk_source = _pinned_template_kwargs(ctk, ctk_source, [rdir, Path(manifest_path).resolve().parent])
|
|
1032
|
+
rdir.mkdir(parents=True, exist_ok=True)
|
|
1033
|
+
labels = [s["label"] for s in specs]
|
|
1034
|
+
design = round_orders(labels, rounds)
|
|
1035
|
+
plan = {"mode": mode, "rounds": rounds, "engines": [{k: s[k] for k in ("label", "name", "kind", "version",
|
|
1036
|
+
"port", "ready_check", "role")}
|
|
1037
|
+
for s in specs],
|
|
1038
|
+
"design": design, "prompts": {k: prompts[k] for k in ("name", "sha256", "n", "buckets")},
|
|
1039
|
+
"settings": {"startup_timeout_s": startup_timeout_s, "stop_timeout_s": stop_timeout_s,
|
|
1040
|
+
"serve_timeout_s": serve_timeout_s, "warmup": warmup, "warmup_max_tokens": warmup_max_tokens,
|
|
1041
|
+
"gpu_mem_tolerance_mib": gpu_mem_tolerance_mib, "gpu_mem_wait_s": gpu_mem_wait_s,
|
|
1042
|
+
"serve_args": serve_args, "serve_cmd_custom": bool(serve_cmd or os.environ.get(SERVE_CMD_ENV)),
|
|
1043
|
+
"warmup_kind": servers.WARMUP_KIND},
|
|
1044
|
+
"manifest_sha256": hashlib.sha256(Path(manifest_path).read_bytes()).hexdigest()}
|
|
1045
|
+
if mode == "load":
|
|
1046
|
+
plan["load_schedule"] = schedule
|
|
1047
|
+
plan["chat_template_kwargs"] = {"value": ctk or None, "source": ctk_source}
|
|
1048
|
+
if body:
|
|
1049
|
+
plan["extra_body"] = {"value": body, "source": body_source}
|
|
1050
|
+
if body_warnings:
|
|
1051
|
+
plan["extra_body"]["warnings"] = body_warnings
|
|
1052
|
+
if adaptive is not None:
|
|
1053
|
+
plan["adaptive"] = adaptive
|
|
1054
|
+
cfg = {"run_dir": rdir, "prompts_path": Path(prompts_path).resolve(), "mode": mode,
|
|
1055
|
+
"startup_timeout_s": startup_timeout_s, "stop_timeout_s": stop_timeout_s,
|
|
1056
|
+
"serve_timeout_s": serve_timeout_s, "warmup": warmup, "warmup_max_tokens": warmup_max_tokens,
|
|
1057
|
+
"serve_cmd": serve_cmd, "serve_args": serve_args, "gpu_mem_tolerance_mib": gpu_mem_tolerance_mib,
|
|
1058
|
+
"gpu_mem_wait_s": gpu_mem_wait_s, "poll_s": poll_s,
|
|
1059
|
+
"gpu_reader": gpu_reader or servers.gpu_memory_used_mib,
|
|
1060
|
+
"model_id": (manifest.get("model") or {}).get("id"), "last_ev": {},
|
|
1061
|
+
"ref_e2e_ms": ref_e2e_ms, "schedule": schedule, "chat_template_kwargs": ctk or None,
|
|
1062
|
+
"extra_body": body or None}
|
|
1063
|
+
out_dir = rdir / "rounds"
|
|
1064
|
+
out_dir.mkdir(parents=True, exist_ok=True)
|
|
1065
|
+
cells: list[dict] = []
|
|
1066
|
+
with servers.gpu_lock(gpu_lock_path, owner=f"inferencekit bench rounds {rdir.name}") as lock:
|
|
1067
|
+
plan["gpu_lock"] = lock["path"]
|
|
1068
|
+
cfg["gpu_lock_path"] = lock["path"]
|
|
1069
|
+
plan["started"] = time.strftime("%Y-%m-%dT%H:%M:%S%z")
|
|
1070
|
+
(out_dir / "plan.json").write_text(json.dumps(ev.redact(plan), indent=2) + "\n")
|
|
1071
|
+
by_label = {s["label"]: s for s in specs}
|
|
1072
|
+
for k, order in enumerate(design["orders"], 1):
|
|
1073
|
+
for pos, label in enumerate(order):
|
|
1074
|
+
cell = run_cell(by_label[label], k, pos, cfg, prompts)
|
|
1075
|
+
cells.append(cell)
|
|
1076
|
+
(out_dir / f"{label}.r{k}.json").write_text(json.dumps(ev.redact(cell), indent=2) + "\n")
|
|
1077
|
+
if adaptive is None:
|
|
1078
|
+
continue
|
|
1079
|
+
whole = k % adaptive["period"] == 0 and k >= adaptive["first_check_round"]
|
|
1080
|
+
if whole or k == rounds: # the last round is checked too, to record whether the target was met
|
|
1081
|
+
check = adaptive_check(cells, labels, k, ci_target)
|
|
1082
|
+
adaptive["checks"].append(check)
|
|
1083
|
+
adaptive["met"] = check["met"]
|
|
1084
|
+
if check["met"] and whole and k < rounds:
|
|
1085
|
+
plan["stopped_early"] = {"round": k, "rule": adaptive["rule"], "widths": check["widths"]}
|
|
1086
|
+
plan["rounds"] = k
|
|
1087
|
+
plan["design"] = design = round_orders(labels, k)
|
|
1088
|
+
break
|
|
1089
|
+
if adaptive is not None:
|
|
1090
|
+
adaptive["stopped_early"] = "stopped_early" in plan
|
|
1091
|
+
adaptive.setdefault("met", False)
|
|
1092
|
+
plan["ended"] = time.strftime("%Y-%m-%dT%H:%M:%S%z")
|
|
1093
|
+
if mode == "load":
|
|
1094
|
+
refs = {c["load_schedule"].get("ref_e2e_ms_used") for c in cells
|
|
1095
|
+
if c.get("status") == "ok" and c.get("load_schedule")}
|
|
1096
|
+
schedule["shared"] = bool(schedule.get("fixed_by")) or ref_e2e_ms is not None or \
|
|
1097
|
+
(len(refs) == 1 and None not in refs)
|
|
1098
|
+
if not schedule.get("fixed_by") and schedule.get("ref_e2e_ms") is None:
|
|
1099
|
+
schedule["source"] = ("no reference latency reported by bench serve: each engine's concurrency levels "
|
|
1100
|
+
"were turned into rates with its own warm-up e2e")
|
|
1101
|
+
plan["load_schedule"] = schedule
|
|
1102
|
+
(out_dir / "plan.json").write_text(json.dumps(ev.redact(plan), indent=2) + "\n")
|
|
1103
|
+
summary = summarize(cells, labels, mode=mode)
|
|
1104
|
+
resolved = resolved_summary(cells, labels)
|
|
1105
|
+
plan["serve_evidence_final"] = dict(cfg["last_ev"]) # single mode: each engine's record with all its rounds
|
|
1106
|
+
(out_dir / "summary.json").write_text(json.dumps(ev.redact({"plan": plan, "summary": summary,
|
|
1107
|
+
"resolved_config": resolved}), indent=2) + "\n")
|
|
1108
|
+
md = markdown(summary, plan, cells, resolved)
|
|
1109
|
+
(out_dir / "summary.md").write_text(md)
|
|
1110
|
+
return {"plan": plan, "cells": cells, "summary": summary, "resolved_config": resolved, "markdown": md,
|
|
1111
|
+
"run_dir": rdir, "manifest": manifest, "prompts": prompts}
|