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.
Files changed (104) hide show
  1. inferencekit/__init__.py +9 -0
  2. inferencekit/__main__.py +5 -0
  3. inferencekit/analysis/__init__.py +1 -0
  4. inferencekit/analysis/ncu_csv.py +284 -0
  5. inferencekit/analysis/nsys_sqlite.py +518 -0
  6. inferencekit/canonical.py +104 -0
  7. inferencekit/checkpoint.py +396 -0
  8. inferencekit/cli.py +274 -0
  9. inferencekit/cuda/bw_peak.cu +252 -0
  10. inferencekit/cuda/torch_bw.py +60 -0
  11. inferencekit/engines/__init__.py +7 -0
  12. inferencekit/engines/adapters.py +190 -0
  13. inferencekit/engines/clip/__init__.py +13 -0
  14. inferencekit/engines/clip/comfyui.py +749 -0
  15. inferencekit/engines/clip/diffusers_ltx.py +601 -0
  16. inferencekit/engines/clip/noise.py +46 -0
  17. inferencekit/engines/clip/official_ltx.py +316 -0
  18. inferencekit/engines/clip/server.py +542 -0
  19. inferencekit/engines/clip/stepdump.py +182 -0
  20. inferencekit/engines/gates.py +94 -0
  21. inferencekit/engines/sglang_worker.py +86 -0
  22. inferencekit/engines/vllm_worker.py +129 -0
  23. inferencekit/evidence.py +427 -0
  24. inferencekit/gpuinfo.py +93 -0
  25. inferencekit/hardware.py +135 -0
  26. inferencekit/mcp_server.py +178 -0
  27. inferencekit/ops/__init__.py +30 -0
  28. inferencekit/ops/bench.py +184 -0
  29. inferencekit/ops/bench_audio.py +384 -0
  30. inferencekit/ops/bench_clip.py +819 -0
  31. inferencekit/ops/bench_image.py +393 -0
  32. inferencekit/ops/bench_rounds.py +297 -0
  33. inferencekit/ops/bench_serve.py +866 -0
  34. inferencekit/ops/ceiling.py +135 -0
  35. inferencekit/ops/compare.py +378 -0
  36. inferencekit/ops/correctness.py +153 -0
  37. inferencekit/ops/doctor.py +486 -0
  38. inferencekit/ops/gates.py +1772 -0
  39. inferencekit/ops/gates_clip.py +930 -0
  40. inferencekit/ops/intake.py +1321 -0
  41. inferencekit/ops/ledger.py +749 -0
  42. inferencekit/ops/manifest.py +309 -0
  43. inferencekit/ops/ncu.py +183 -0
  44. inferencekit/ops/nsys.py +224 -0
  45. inferencekit/ops/prompts.py +316 -0
  46. inferencekit/ops/quality.py +2161 -0
  47. inferencekit/ops/quality_clip.py +690 -0
  48. inferencekit/ops/quality_clip_steps.py +341 -0
  49. inferencekit/ops/report.py +317 -0
  50. inferencekit/ops/review.py +119 -0
  51. inferencekit/ops/rocprof.py +89 -0
  52. inferencekit/ops/roofline.py +392 -0
  53. inferencekit/ops/store.py +61 -0
  54. inferencekit/proc.py +172 -0
  55. inferencekit/prompts/ATTRIBUTION.md +56 -0
  56. inferencekit/prompts/__init__.py +167 -0
  57. inferencekit/prompts/build.py +518 -0
  58. inferencekit/prompts/dev-load.jsonl.gz +0 -0
  59. inferencekit/prompts/dev-load.manifest.json +212 -0
  60. inferencekit/prompts/dev.jsonl.gz +0 -0
  61. inferencekit/prompts/dev.manifest.json +214 -0
  62. inferencekit/prompts/exclusions.json +54 -0
  63. inferencekit/quality/__init__.py +23 -0
  64. inferencekit/quality/client.py +66 -0
  65. inferencekit/quality/data/gsm8k_LICENSE.txt +21 -0
  66. inferencekit/quality/fake.py +329 -0
  67. inferencekit/quality/gsm8k.py +224 -0
  68. inferencekit/quality/logprobs.py +106 -0
  69. inferencekit/quality/metrics.py +235 -0
  70. inferencekit/quality/niah.py +63 -0
  71. inferencekit/quality/parquet.py +296 -0
  72. inferencekit/quality/sampling.py +262 -0
  73. inferencekit/quality/step_replay.py +217 -0
  74. inferencekit/quality/video_metrics.py +304 -0
  75. inferencekit/registry.py +206 -0
  76. inferencekit/review/__init__.py +6 -0
  77. inferencekit/review/diff.py +163 -0
  78. inferencekit/review/media.py +181 -0
  79. inferencekit/review/page.py +920 -0
  80. inferencekit/review/rundir.py +844 -0
  81. inferencekit/roofline_headers.py +504 -0
  82. inferencekit/schemas/evidence.v1.json +130 -0
  83. inferencekit/schemas/evidence.v2.json +499 -0
  84. inferencekit/schemas/intake.v1.json +276 -0
  85. inferencekit/schemas/run-manifest.v1.json +118 -0
  86. inferencekit/serve/__init__.py +8 -0
  87. inferencekit/serve/audio_client.py +330 -0
  88. inferencekit/serve/client.py +446 -0
  89. inferencekit/serve/clip_client.py +351 -0
  90. inferencekit/serve/clip_rounds.py +429 -0
  91. inferencekit/serve/image_client.py +373 -0
  92. inferencekit/serve/loadgen.py +136 -0
  93. inferencekit/serve/metrics.py +203 -0
  94. inferencekit/serve/profiles.py +105 -0
  95. inferencekit/serve/prompts_io.py +125 -0
  96. inferencekit/serve/rounds.py +1111 -0
  97. inferencekit/serve/servers.py +897 -0
  98. inferencekit/stats.py +249 -0
  99. inferencekit_cli-0.1.0.dist-info/METADATA +233 -0
  100. inferencekit_cli-0.1.0.dist-info/RECORD +104 -0
  101. inferencekit_cli-0.1.0.dist-info/WHEEL +4 -0
  102. inferencekit_cli-0.1.0.dist-info/entry_points.txt +2 -0
  103. inferencekit_cli-0.1.0.dist-info/licenses/LICENSE +202 -0
  104. 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}