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,866 @@
1
+ """bench serve: time an OpenAI-compatible server from the client side, the one timer for published numbers.
2
+
3
+ The engine runs as its own server (vllm serve, SGLang launch_server, llama-server, trtllm-serve, a
4
+ custom engine); inferencekit is only a client, so nothing it does can flatter the engine's numbers and
5
+ nothing the engine does can touch the timer. See serve/client.py for the timestamps and
6
+ serve/metrics.py for the definitions.
7
+
8
+ Modes
9
+ single sequential requests over the prompt set, ``rounds`` times (plus ``warmup`` untimed requests).
10
+ Per bucket: TTFT, TPOT, decode tok/s, end-to-end; median and p95 with 95% CIs over rounds.
11
+ Writes one ``serve_benchmark`` record (observed). Its ``data.rounds`` has one row per round
12
+ in the row shape of bench decode records (decode_tok_s, ttft_ms, tpot_ms, total_ms, t_start).
13
+ To interleave engines A and B, alternate ``--rounds 1 --extend <previous ev>`` calls (A, B,
14
+ A, B ...), or use ``bench rounds``; ``compare`` takes two such records. The record's
15
+ ``workload.request_set`` hashes the selected prompts and per-request settings, so compare
16
+ can refuse two records that ran different subsets or template settings.
17
+ load open-loop seeded Poisson arrivals at a concurrency or rate grid (serve/loadgen.py). Per level:
18
+ p50/p95/p99 TTFT, TPOT, ITL, e2e; failures; goodput; output tok/s per GPU. Writes a
19
+ ``serve_benchmark`` record (observed, the levels) and a ``capacity`` record (derived: N*, the
20
+ highest level where the SLO holds there and at every lower level, and $/1M output tokens at
21
+ N*). Returns the capacity record. ``--closed-loop`` runs a labelled stress test instead.
22
+
23
+ Prefix cache (single mode): the warm-up prompts carry a unique tag, so round 0 is cold. By default
24
+ every later round tags its prompts too ("[ik-r<N>] ", a few tokens), so no timed request can be served
25
+ from the engine's prefix cache and TTFT is cold prefill in every round. ``--no-cache-salt`` resends
26
+ identical prompts on purpose (warm-cache TTFT); the record says which (``workload.prefix_cache``).
27
+
28
+ Files: every request's text, token ids, finish reason, usage and event timings, under
29
+ ``outputs/<label>/<bucket>/<id>.json`` (single mode: round 0, the untagged one) or
30
+ ``outputs/<label>/load-<level>/<bucket>/<id>.json`` (load mode), and the exact request body sent under
31
+ ``inputs/<label>/...`` with the same layout. Single mode also writes the shared
32
+ ``inputs/<bucket>/<id>.json`` (engine-independent path read by the gates; the last engine run in a run
33
+ dir wins). The files are kept in the evidence record's directory with an ``index.json`` of their
34
+ sha256 (the record's ``outputs_index`` artifact), and copied to ``--run-dir`` when given.
35
+ """
36
+
37
+ from __future__ import annotations
38
+
39
+ import asyncio
40
+ import hashlib
41
+ import hmac
42
+ import json
43
+ import os
44
+ import re
45
+ import secrets
46
+ import statistics
47
+ import time
48
+ from pathlib import Path
49
+
50
+ from inferencekit import evidence as ev
51
+ from inferencekit import stats
52
+ from inferencekit.canonical import canonical_json, file_sha256
53
+ from inferencekit.gpuinfo import gpu_identity
54
+ from inferencekit.ops.intake import apply_pin, intake_pin
55
+ from inferencekit.registry import Arg, Call, OpError, operation
56
+ from inferencekit.serve import client as cl
57
+ from inferencekit.serve import loadgen, metrics, profiles, prompts_io
58
+
59
+ DEFAULT_MAX_TOKENS = 256
60
+ # Arguments that must match for --extend: they define "the same configuration".
61
+ SAME_CONFIG = ("base_url", "model", "api", "engine_kind", "engine_version", "max_tokens", "buckets",
62
+ "limit_per_bucket", "token_ids", "exact_length", "cache_salt", "extra_body", "label", "seed",
63
+ "chat_template_kwargs")
64
+ JSON_ARGS = ("extra_body", "chat_template_kwargs") # compared and matched as parsed JSON, not as text
65
+ INFLIGHT_SHORTFALL = 0.9 # flag an N* whose measured mean in-flight count is below this fraction of N*
66
+ _LABEL = re.compile(r"^[A-Za-z0-9][A-Za-z0-9_.+-]{0,63}$")
67
+
68
+ ARGS = [
69
+ Arg("base_url", "string", "Server root, e.g. http://127.0.0.1:8000 (a trailing /v1 is fine). Never stored: "
70
+ "records keep only an HMAC of it under a per-machine key kept outside the evidence store, so a "
71
+ "published record does not reveal the host (hashing candidate addresses does not recover it).",
72
+ required=True, sensitive=True),
73
+ Arg("model", "string", "Model name the server expects in requests (as listed by /v1/models).", required=True),
74
+ Arg("prompts", "string", "Prompt set JSONL ({id, bucket, messages, max_tokens} per line).", required=True),
75
+ Arg("api", "string", "OpenAI endpoint to call.", default="chat", choices=profiles.APIS),
76
+ Arg("engine_kind", "string", "Server type: picks the documented fields for exact length and token ids.",
77
+ default="generic", choices=profiles.ENGINE_KINDS),
78
+ Arg("mode", "string", "single = one request at a time; load = open-loop arrivals at a grid.",
79
+ default="single", choices=("single", "load")),
80
+ Arg("max_tokens", "integer", "Tokens to generate per request (default: each prompt's max_tokens, else 256)."),
81
+ Arg("rounds", "integer", "Single mode: timed passes over the prompt set.", default=5),
82
+ Arg("warmup", "integer", "Untimed requests first, each with a unique prompt tag (they never seed the prefix "
83
+ "cache of a timed prompt).", default=2),
84
+ Arg("ref_requests", "integer", "Load mode with --concurrency: unloaded requests sent one at a time after the "
85
+ "warm-up; their median e2e is e2e_ref (not sent with --ref-e2e-ms).", default=5),
86
+ Arg("concurrency", "array", "Load mode: concurrency levels (streams); Poisson rate = N / e2e_ref.",
87
+ items="integer"),
88
+ Arg("rates", "array", "Load mode: arrival rates in requests/s (instead of --concurrency).", items="number"),
89
+ Arg("duration_s", "number", "Load mode: arrival window per level, seconds.", default=30.0),
90
+ Arg("max_requests_per_level", "integer", "Load mode: cap on requests per level (0 = no cap).", default=0),
91
+ Arg("ref_e2e_ms", "number", "Load mode: unloaded e2e latency used to turn concurrency into a rate. Pass "
92
+ "the same value for every engine to give them identical schedules (default: the median of "
93
+ "--ref-requests reference requests)."),
94
+ Arg("slo_ttft_p95_ms", "number", "SLO: p95 time to first token, ms."),
95
+ Arg("slo_tpot_p95_ms", "number", "SLO: p95 time per output token, ms."),
96
+ Arg("slo_max_fail", "integer", "SLO: failed requests allowed per level.", default=0),
97
+ Arg("sweep_all", "boolean", "Load mode: keep sweeping after the first level that misses the SLO.",
98
+ default=False),
99
+ Arg("closed_loop", "boolean", "Load mode: N back-to-back workers instead of open-loop arrivals. A STRESS "
100
+ "TEST: it slows its own arrivals when the server slows, so it is never used for N*.", default=False),
101
+ Arg("gpu_price_usd_h", "number", "GPU price per hour (per GPU), for $/1M output tokens at N*."),
102
+ Arg("gpu_count", "integer", "GPUs behind the server (throughput and cost are per GPU).", default=1),
103
+ Arg("gpu_name", "string", "GPU model, when the server is not on this machine (otherwise read locally)."),
104
+ Arg("seed", "integer", "Seed for arrivals and prompt order.", default=0),
105
+ Arg("label", "string", "Name for this engine configuration in outputs/ (default: engine_kind)."),
106
+ Arg("run_dir", "string", "Run directory: outputs/<label>/... and metrics/<label>.json are written there."),
107
+ Arg("buckets", "array", "Only these buckets.", items="string"),
108
+ Arg("limit_per_bucket", "integer", "Only the first N prompts of each bucket (0 = all).", default=0),
109
+ Arg("token_ids", "string", "auto = ask for streamed ids where it is free (vLLM return_token_ids); "
110
+ "logprobs = also through logprobs (can slow decode); off = never.", default="auto",
111
+ choices=profiles.TOKEN_ID_MODES),
112
+ Arg("exact_length", "boolean", "Ask the engine to generate exactly max_tokens (ignore_eos / min_tokens).",
113
+ default=True),
114
+ Arg("cache_salt", "boolean", "Single mode: rounds after the first prefix each prompt with a unique tag, so "
115
+ "no timed request can hit the prefix cache (cold-prefill TTFT in every round; round 0 is sent "
116
+ "untagged and is the one written to outputs/). --no-cache-salt resends identical prompts every "
117
+ "round on purpose (warm-cache TTFT).", default=True),
118
+ Arg("tokenizer", "string", "HF tokenizer to re-count tokens when the server returns neither ids nor usage "
119
+ "(needs transformers)."),
120
+ Arg("extra_body", "string", "JSON object merged into every request body (e.g. sampling settings). "
121
+ "Secret-looking keys are stored as <redacted>.", default="{}"),
122
+ Arg("api_key_env", "string", "Name of the environment variable holding the API key (never stored)."),
123
+ Arg("engine_version", "string", "Engine version, recorded in the identity."),
124
+ Arg("timeout_s", "number", "Per-request timeout, seconds.", default=600.0),
125
+ Arg("resamples", "integer", "Bootstrap resamples for CIs.", default=stats.DEFAULT_RESAMPLES),
126
+ Arg("extend", "string", "Single mode: evidence id of an earlier run of the same configuration; its "
127
+ "rounds are kept and new rounds appended (alternate engines this way)."),
128
+ Arg("chat_template_kwargs", "string", "JSON object sent as chat_template_kwargs on every chat request, e.g. "
129
+ "'{\"enable_thinking\": false}'. A prompt record's own chat_template_kwargs field is merged over it "
130
+ "(per key), so every engine gets the same template settings per request."),
131
+ ]
132
+
133
+
134
+ def _json_object(text: str, what: str) -> dict:
135
+ try:
136
+ value = json.loads(text or "{}")
137
+ except ValueError as e:
138
+ raise OpError(f"{what} is not valid JSON: {e}") from None
139
+ if not isinstance(value, dict):
140
+ raise OpError(f"{what} must be a JSON object")
141
+ return value
142
+
143
+
144
+ def normalized_json(text) -> str | None:
145
+ """A JSON-object argument in one canonical spelling (sorted keys, compact), so the same setting written
146
+ two ways compares equal ('{"enable_thinking": false}' == '{"enable_thinking":false}'). Text that is not
147
+ JSON is returned unchanged; empty / {} is None."""
148
+ if text in (None, ""):
149
+ return None
150
+ try:
151
+ value = json.loads(text) if isinstance(text, str) else text
152
+ except ValueError:
153
+ return text
154
+ if value in ({}, None):
155
+ return None
156
+ return json.dumps(value, sort_keys=True, separators=(",", ":"))
157
+
158
+
159
+ def request_set(pset, runner) -> dict:
160
+ """identity.workload.request_set: sha256 over exactly what was asked per prompt, independent of the
161
+ engine: the selected prompt ids and buckets (after --buckets / --limit-per-bucket), each prompt's
162
+ max_tokens and merged chat_template_kwargs, plus the API, exact-length request and --extra-body
163
+ (secret-looking values redacted). Two records with the same prompt-set file but a different selection
164
+ or template settings differ here, so compare can refuse them."""
165
+ items = []
166
+ for p in pset.prompts:
167
+ extra = runner.extra_for(p)
168
+ items.append({"id": p.id, "bucket": p.bucket, "max_tokens": runner.max_for(p),
169
+ "chat_template_kwargs": extra.get("chat_template_kwargs") if runner.api == "chat" else None})
170
+ settings = {"api": runner.api, "exact_length": bool(runner.exact_length),
171
+ "extra_body": ev.redact({k: v for k, v in runner.extra.items() if k != "chat_template_kwargs"})}
172
+ return {"sha256": hashlib.sha256(canonical_json({"items": items, "settings": settings}).encode()).hexdigest(),
173
+ "n": len(items)}
174
+
175
+
176
+ def chat_template_kwargs_for(prompt, *layers) -> dict:
177
+ """Merged chat_template_kwargs for one prompt: ``layers`` in order (lowest precedence first; None or
178
+ non-dict layers skipped), then the prompt record's own ``chat_template_kwargs`` field, per key.
179
+ ``prompt`` is a prompts_io.Prompt or a prompt-record dict. Shared by bench serve, gates and quality
180
+ trajectory so every op sends an engine the same template settings for the same prompt."""
181
+ own = prompt.get("chat_template_kwargs") if isinstance(prompt, dict) else \
182
+ (getattr(prompt, "extra", None) or {}).get("chat_template_kwargs")
183
+ out: dict = {}
184
+ for layer in (*layers, own):
185
+ if isinstance(layer, dict):
186
+ out.update(layer)
187
+ return out
188
+
189
+
190
+ def _tokenizer(name: str | None):
191
+ if not name:
192
+ return None
193
+ try:
194
+ from transformers import AutoTokenizer # optional; only for servers without ids or usage
195
+ except ImportError:
196
+ raise OpError("--tokenizer needs the transformers package installed") from None
197
+ tok = AutoTokenizer.from_pretrained(name)
198
+ return lambda text: tok.encode(text, add_special_tokens=False)
199
+
200
+
201
+ def _endpoint_key() -> tuple[bytes, bool]:
202
+ """The per-machine HMAC key for endpoint tags, and whether it persists across processes.
203
+
204
+ It lives outside the evidence store ($INFERENCEKIT_KEY_FILE, else $XDG_CONFIG_HOME or ~/.config,
205
+ /inferencekit/endpoint.key, mode 0600), so publishing records never publishes the key.
206
+ """
207
+ cfg = Path(os.environ.get("XDG_CONFIG_HOME") or Path.home() / ".config")
208
+ path = Path(os.environ.get("INFERENCEKIT_KEY_FILE") or cfg / "inferencekit" / "endpoint.key")
209
+ for _ in range(2):
210
+ try:
211
+ return bytes.fromhex(path.read_text().strip()), True
212
+ except FileNotFoundError:
213
+ pass
214
+ except (OSError, ValueError):
215
+ break
216
+ try:
217
+ path.parent.mkdir(parents=True, exist_ok=True)
218
+ fd = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600)
219
+ with os.fdopen(fd, "w") as f:
220
+ f.write(secrets.token_hex(32) + "\n")
221
+ except FileExistsError:
222
+ continue # another process created it first: read theirs
223
+ except OSError:
224
+ break
225
+ return _PROCESS_KEY, False
226
+
227
+
228
+ _PROCESS_KEY = secrets.token_bytes(32)
229
+
230
+
231
+ def _record_call(call: Call, ep) -> tuple[Call, bool]:
232
+ """``call`` with base_url replaced by a keyed tag, for everything that is stored.
233
+
234
+ The registry stores sensitive arguments as a plain sha256, and the argv digest covers every
235
+ argument; for a URL such as http://<IPv4>:8000 both can be brute-forced over the address space.
236
+ An HMAC under a per-machine key cannot, and it still lets --extend check "same server" on this
237
+ machine.
238
+ """
239
+ key, persistent = _endpoint_key()
240
+ canon = f"{ep.scheme}://{ep.host.lower()}:{ep.port}{ep.prefix}"
241
+ tag = "hmac-sha256:" + hmac.new(key, canon.encode(), hashlib.sha256).hexdigest()
242
+ return Call(call.op, {**call.args, "base_url": tag}, started=call.started), persistent
243
+
244
+
245
+ class Runner:
246
+ """Holds what every request needs: endpoint, body template, token counter, outputs."""
247
+
248
+ def __init__(self, ep, api, engine_kind, model, max_tokens, token_ids, exact_length, extra, tokenizer,
249
+ timeout_s):
250
+ self.ep, self.api, self.engine_kind, self.model = ep, api, engine_kind, model
251
+ self.max_tokens, self.token_ids, self.exact_length, self.extra = max_tokens, token_ids, exact_length, extra
252
+ self.tokenizer, self.timeout_s = tokenizer, timeout_s
253
+ self.chat_template_kwargs: dict = {}
254
+ self.asked = None
255
+
256
+ def max_for(self, prompt) -> int:
257
+ return int(self.max_tokens or prompt.max_tokens or DEFAULT_MAX_TOKENS)
258
+
259
+ def extra_for(self, prompt) -> dict:
260
+ """--extra-body, plus chat_template_kwargs: --extra-body's, then --chat-template-kwargs, then the
261
+ prompt record's own field, merged per key (chat API only; /v1/completions has no template)."""
262
+ kw = chat_template_kwargs_for(prompt, self.extra.get("chat_template_kwargs"), self.chat_template_kwargs)
263
+ if self.api != "chat" or not kw:
264
+ return self.extra
265
+ return {**self.extra, "chat_template_kwargs": kw}
266
+
267
+ def body(self, prompt, salt):
268
+ body, asked = profiles.build_body(self.api, self.engine_kind, self.model,
269
+ loadgen.salted_messages(prompt, salt), self.max_for(prompt),
270
+ token_ids=self.token_ids, exact_length=self.exact_length,
271
+ extra=self.extra_for(prompt))
272
+ self.asked = self.asked or asked
273
+ return body
274
+
275
+ async def send(self, prompt, salt=None, t_scheduled_ns=None):
276
+ body = self.body(prompt, salt)
277
+ res = await cl.stream(self.ep, self.api, body, rid=prompt.id, bucket=prompt.bucket,
278
+ timeout_s=self.timeout_s, t_scheduled_ns=t_scheduled_ns)
279
+ res.salt, res.request_body = salt, body
280
+ cl.count_tokens(res, self.tokenizer)
281
+ return res
282
+
283
+
284
+ def _output_doc(res, prompt, runner, **extra) -> dict:
285
+ doc = {"id": res.id, "bucket": res.bucket, "text": res.text, "token_ids": res.token_ids,
286
+ "finish_reason": res.finish_reason, "usage": res.usage, "n_tokens": res.n_tokens,
287
+ "token_count_method": res.token_count_method, "max_tokens": runner.max_for(prompt),
288
+ "error": res.error, "timings": metrics.timings(res), **extra}
289
+ if res.reasoning:
290
+ doc["reasoning"] = res.reasoning
291
+ return doc
292
+
293
+
294
+ def _dump(doc: dict) -> str:
295
+ return json.dumps(ev.redact(doc), ensure_ascii=False) + "\n"
296
+
297
+
298
+ def _write_run_files(staging: Path, run_dir: Path | None, label: str, outputs: list[tuple[str, dict]],
299
+ inputs: list[tuple[str, dict]], contract_inputs: bool = False) -> Path | None:
300
+ """Write this run's files and return the path of their index (None when there are none).
301
+
302
+ ``outputs/<label>/<rel>`` and ``inputs/<label>/<rel>`` go into the record's directory (``staging``),
303
+ with ``outputs/<label>/index.json`` listing exactly this run's files and their sha256: the record's
304
+ ``outputs_index`` artifact, which later runs cannot change. With ``run_dir`` the same bytes are also
305
+ written there, and the run dir's ``index.json`` is merged across runs as a convenience.
306
+ ``contract_inputs`` also writes ``inputs/<rel>`` (the engine-independent path).
307
+ """
308
+ if not outputs and not inputs:
309
+ return None
310
+ snap: dict = {"label": label, "files": {}, "inputs": {}}
311
+ for root in [staging] + ([Path(run_dir)] if run_dir else []):
312
+ for kind, docs, key in (("outputs", outputs, "files"), ("inputs", inputs, "inputs")):
313
+ for rel, doc in docs:
314
+ data = _dump(doc).encode("utf-8")
315
+ targets = [root / kind / label / rel]
316
+ if kind == "inputs" and contract_inputs:
317
+ targets.append(root / "inputs" / rel)
318
+ for t in targets:
319
+ t.parent.mkdir(parents=True, exist_ok=True)
320
+ t.write_bytes(data)
321
+ if root == staging:
322
+ snap[key][rel] = hashlib.sha256(data).hexdigest()
323
+ base = staging / "outputs" / label
324
+ base.mkdir(parents=True, exist_ok=True)
325
+ snap = {k: dict(sorted(v.items())) if isinstance(v, dict) else v for k, v in snap.items()}
326
+ (base / "index.json").write_text(json.dumps(snap, indent=1) + "\n")
327
+ if run_dir:
328
+ idx = Path(run_dir) / "outputs" / label / "index.json"
329
+ old = {}
330
+ if idx.exists():
331
+ try:
332
+ old = json.loads(idx.read_text())
333
+ except ValueError:
334
+ old = {}
335
+ merged = {"label": label, "note": "merged across runs of this label; each record keeps its own snapshot"}
336
+ for key in ("files", "inputs"):
337
+ merged[key] = dict(sorted({**(old.get(key) or {}), **snap[key]}.items()))
338
+ idx.parent.mkdir(parents=True, exist_ok=True)
339
+ idx.write_text(json.dumps(merged, indent=1) + "\n")
340
+ return base / "index.json"
341
+
342
+
343
+ def _write_jsonl(path: Path, rows: list[dict]) -> Path:
344
+ path.parent.mkdir(parents=True, exist_ok=True)
345
+ path.write_text("".join(json.dumps(r, ensure_ascii=False) + "\n" for r in rows))
346
+ return path
347
+
348
+
349
+ async def _check_server(ep, model) -> tuple[list[str], dict]:
350
+ """GET /v1/models: is the server up, and does it serve ``model``? Returns (limitations, info)."""
351
+ try:
352
+ status, body = await cl.get_json(ep, "/v1/models")
353
+ except (OSError, ConnectionError, asyncio.TimeoutError, ValueError) as e:
354
+ raise OpError(f"server at {ep.scheme}://{ep.host}:{ep.port} not reachable ({type(e).__name__}: {e}); "
355
+ "start it and wait for /v1/models to answer") from None
356
+ lim, info = [], {"models_status": status}
357
+ if status == 200 and isinstance(body, dict):
358
+ ids = [m.get("id") for m in body.get("data") or [] if isinstance(m, dict)]
359
+ info["served_models"] = ids[:20]
360
+ for m in body.get("data") or []:
361
+ if isinstance(m, dict) and m.get("id") == model and m.get("max_model_len"):
362
+ info["max_model_len"] = m["max_model_len"]
363
+ if ids and model not in ids:
364
+ lim.append(f"model {model!r} is not in the server's /v1/models list {ids[:5]}")
365
+ else:
366
+ lim.append(f"/v1/models answered HTTP {status}: model name not checked")
367
+ return lim, info
368
+
369
+
370
+ def _identity(ep, gpu_name, gpu_count, engine_kind, engine_version, model, workload):
371
+ missing = []
372
+ if gpu_name:
373
+ gpu = {"name": gpu_name, "count": gpu_count}
374
+ elif ep.is_local():
375
+ gpu = gpu_identity() or {}
376
+ else:
377
+ gpu = {"count": gpu_count}
378
+ if not gpu.get("name"):
379
+ missing.append("gpu identity (remote server or no nvidia-smi: pass --gpu-name)")
380
+ ident = ev.identity(gpu=gpu, engine={"name": engine_kind, "version": engine_version}, model={"id": model},
381
+ workload=workload)
382
+ return ident, missing
383
+
384
+
385
+ def _count_limitations(rows: list[dict], exact_asked, max_by_id: dict, token_ids_mode: str,
386
+ engine_kind: str) -> list[str]:
387
+ lim = []
388
+ ok = [r for r in rows if r["ok"]]
389
+ methods = sorted({r["token_count_method"] for r in ok if r["token_count_method"]})
390
+ if any(m == "usage" for m in methods):
391
+ lim.append("tokens counted from usage.completion_tokens (no streamed ids): decode tok/s assumes the first "
392
+ "token event holds one token")
393
+ if any(m == "sse_events" for m in methods):
394
+ lim.append("tokens counted as SSE events (no ids, no usage): only right if the server sends one token "
395
+ "per event; pass --tokenizer to re-count")
396
+ mism = [r for r in ok if r.get("count_mismatch")]
397
+ if mism:
398
+ lim.append(f"{len(mism)} request(s): streamed ids disagree with usage ({mism[0]['count_mismatch']})")
399
+ if exact_asked:
400
+ short = [r for r in ok if r["n_tokens"] is not None and r["n_tokens"] != max_by_id.get(r["id"])]
401
+ if short:
402
+ lim.append(f"{len(short)} request(s) did not generate exactly max_tokens although "
403
+ f"{'/'.join(exact_asked)} was sent (e.g. {short[0]['id']}: {short[0]['n_tokens']} tokens)")
404
+ else:
405
+ lim.append(f"engine_kind={engine_kind}: no exact-length control sent; outputs stop at EOS, so lengths can "
406
+ "differ between engines (the output-length gate applies)")
407
+ if token_ids_mode == "logprobs":
408
+ lim.append("token ids requested through logprobs where needed: computing logprobs can slow decode")
409
+ multi = [r for r in ok if (r.get("tokens_per_event_max") or 0) > 1]
410
+ if multi:
411
+ lim.append(f"{len(multi)} request(s) streamed more than one token in an SSE event (max "
412
+ f"{max(r['tokens_per_event_max'] for r in multi)}); counts use ids, ITL is per event")
413
+ return lim
414
+
415
+
416
+ def _fmt(x, nd=1):
417
+ return "n/a" if x is None else f"{x:.{nd}f}"
418
+
419
+
420
+ @operation("bench_serve", ("bench", "serve"), "Client-side streaming benchmark of an OpenAI-compatible server "
421
+ "(vLLM, SGLang, llama-server, trtllm-serve, custom): single-user TTFT/TPOT/decode tok/s per bucket "
422
+ "with CIs over rounds, or open-loop load with N* at an SLO and $/1M tokens.", ARGS)
423
+ def bench_serve(call, base_url, model, prompts, api, engine_kind, mode, max_tokens, rounds, warmup, ref_requests,
424
+ concurrency, rates, duration_s, max_requests_per_level, ref_e2e_ms, slo_ttft_p95_ms, slo_tpot_p95_ms,
425
+ slo_max_fail, sweep_all, closed_loop, gpu_price_usd_h, gpu_count, gpu_name, seed, label, run_dir,
426
+ buckets, limit_per_bucket, token_ids, exact_length, cache_salt, tokenizer, extra_body, api_key_env,
427
+ engine_version, timeout_s, resamples, extend, chat_template_kwargs=None):
428
+ if max_tokens is not None and max_tokens < 2:
429
+ raise OpError("max_tokens must be >= 2 (decode speed needs tokens after the first)")
430
+ if mode == "single" and rounds < 1:
431
+ raise OpError("rounds must be >= 1")
432
+ if mode == "load":
433
+ if bool(concurrency) == bool(rates):
434
+ raise OpError("load mode needs exactly one of --concurrency or --rates")
435
+ grid = concurrency or rates
436
+ if any(x <= 0 for x in grid):
437
+ raise OpError("grid levels must be > 0")
438
+ if concurrency and not closed_loop and ref_e2e_ms is None and ref_requests < 1:
439
+ raise OpError("--concurrency needs --ref-requests >= 1 (to measure the unloaded e2e) or --ref-e2e-ms")
440
+ if extend:
441
+ raise OpError("--extend is for single mode")
442
+ label = label or engine_kind
443
+ if not _LABEL.match(label):
444
+ raise OpError("label must be 1-64 characters of letters, digits and _ . + - (it names a directory)")
445
+ extra = _json_object(extra_body, "extra_body")
446
+ try:
447
+ ep = cl.Endpoint.parse(base_url, cl.api_key_from_env(api_key_env))
448
+ except ValueError as e:
449
+ raise OpError(str(e)) from None
450
+ if warmup < 0:
451
+ raise OpError("warmup must be >= 0")
452
+ pset = prompts_io.load(prompts, buckets or None, limit_per_bucket or None)
453
+ tok = _tokenizer(tokenizer)
454
+ rcall, key_persistent = _record_call(call, ep)
455
+ runner = Runner(ep, api, engine_kind, model, max_tokens, token_ids, exact_length, extra, tok, timeout_s)
456
+ runner.chat_template_kwargs = _json_object(chat_template_kwargs, "chat_template_kwargs")
457
+ if run_dir and intake_pin(run_dir): # the intake's pre-registered template settings: added, never contradicted
458
+ base_ctk = extra.get("chat_template_kwargs") if isinstance(extra.get("chat_template_kwargs"), dict) else {}
459
+ runner.chat_template_kwargs = apply_pin({**base_ctk, **runner.chat_template_kwargs}, run_dir, "bench serve")
460
+ slo = {k: v for k, v in (("ttft_p95_ms", slo_ttft_p95_ms), ("tpot_p95_ms", slo_tpot_p95_ms)) if v is not None}
461
+ if slo:
462
+ slo["max_fail"] = slo_max_fail
463
+ store = ev.Store()
464
+ common = dict(call=rcall, store=store, runner=runner, pset=pset, ep=ep, label=label, seed=seed,
465
+ run_dir=Path(run_dir) if run_dir else None, gpu_name=gpu_name, gpu_count=gpu_count,
466
+ engine_version=engine_version, resamples=resamples, warmup=warmup,
467
+ key_note=None if key_persistent else (
468
+ "no per-machine key file could be written: the endpoint tag in this record is valid for "
469
+ "this process only, so --extend from another process sees a different base_url"))
470
+ if mode == "single":
471
+ return _single(rounds=rounds, cache_salt=cache_salt, extend=extend, **common)
472
+ return _load(grid=sorted(concurrency or rates), level_kind="concurrency" if concurrency else "rate",
473
+ duration_s=duration_s, max_requests=max_requests_per_level or None, ref_e2e_ms=ref_e2e_ms,
474
+ ref_requests=ref_requests, slo=slo, sweep_all=sweep_all, closed_loop=closed_loop,
475
+ price=gpu_price_usd_h, **common)
476
+
477
+
478
+ # --------------------------------------------------------------------------- single-user mode
479
+
480
+ def _config_value(key, value):
481
+ """An argument as --extend compares it: JSON-object arguments by value, not by spelling."""
482
+ return normalized_json(value) if key in JSON_ARGS else value
483
+
484
+
485
+ def _load_previous(store, extend, call):
486
+ prev = store.load(extend)
487
+ if prev.get("kind") != "serve_benchmark" or prev["identity"]["workload"].get("mode") != "single":
488
+ raise OpError(f"--extend {extend}: not a single-mode serve_benchmark record")
489
+ problem = ev.integrity_problem(prev)
490
+ if problem:
491
+ raise OpError(f"--extend: {problem}; refusing to build on it")
492
+ prev_args = prev["data"].get("request", {}).get("args", {})
493
+ mine = ev.redact(call.request()["args"])
494
+ diff = [k for k in SAME_CONFIG if _config_value(k, prev_args.get(k)) != _config_value(k, mine.get(k))]
495
+ if prev["identity"]["workload"].get("prompt_set", {}).get("sha256") != prev["data"].get("prompt_set_sha256"):
496
+ diff.append("prompt_set")
497
+ if diff:
498
+ raise OpError(f"--extend needs the same configuration; these differ: {diff}")
499
+ art = next((a for a in prev["artifacts"] if a.get("role") == "requests"), None)
500
+ path = store.run_dir(prev["evidence_id"]) / art["path"] if art else None
501
+ if path is None or not path.is_file() or file_sha256(path)[0] != art["sha256"]:
502
+ raise OpError(f"--extend: the per-request rows of {prev['evidence_id'][:15]} are missing or changed")
503
+ rows = [json.loads(line) for line in path.read_text().splitlines() if line.strip()]
504
+ return prev, rows
505
+
506
+
507
+ def _single(*, call, store, runner, pset, ep, label, seed, run_dir, gpu_name, gpu_count, engine_version,
508
+ resamples, warmup, key_note, rounds, cache_salt, extend):
509
+ prev, prev_rows = (None, [])
510
+ if extend:
511
+ prev, prev_rows = _load_previous(store, extend, call)
512
+ if prev["data"].get("prompt_set_sha256") != pset.sha256:
513
+ raise OpError("--extend: the prompt set changed since the earlier run")
514
+ offset = max((r["round"] for r in prev_rows), default=-1) + 1
515
+ prompts = list(pset.prompts)
516
+
517
+ def salt_for(r: int) -> str | None:
518
+ # Round 0 goes untagged: it is cold anyway (the warm-up is tagged) and its outputs are comparable
519
+ # with an untagged reference. Later rounds are tagged so they cannot hit the prefix cache.
520
+ return f"ik-r{r}" if cache_salt and r > 0 else None
521
+
522
+ async def run():
523
+ lim, info = await _check_server(ep, runner.model)
524
+ for i in range(warmup): # untimed; tagged so warm-up never seeds the prefix cache of a timed prompt
525
+ await runner.send(prompts[i % len(prompts)], f"ik-warm-{i}")
526
+ timed, walls = [], {}
527
+ for r in range(offset, offset + rounds):
528
+ t0 = time.time()
529
+ for p in prompts:
530
+ timed.append((r, p, salt_for(r), await runner.send(p, salt_for(r))))
531
+ walls[r] = (t0, time.time())
532
+ return lim, info, timed, walls
533
+
534
+ lim, info, timed, walls = asyncio.run(run())
535
+ with store.staging_dir() as staging:
536
+ rows, out_docs, in_docs, text_sha = [], [], [], {}
537
+ for r, p, salt, res in timed:
538
+ rows.append(metrics.request_row(res, round=r))
539
+ if res.ok and salt is None:
540
+ text_sha.setdefault(p.id, set()).add(hashlib.sha256(res.text.encode()).hexdigest())
541
+ if r == 0: # outputs and exact request bodies of the untagged round only
542
+ rel = f"{p.bucket}/{p.id}.json"
543
+ out_docs.append((rel, _output_doc(res, p, runner, round=r, salt=salt)))
544
+ in_docs.append((rel, res.request_body or {}))
545
+ all_rows = prev_rows + rows
546
+ idx = _write_run_files(staging, run_dir, label, out_docs, in_docs, contract_inputs=True)
547
+ outputs_from = None if idx or not prev else (prev["data"].get("outputs_from") or prev["evidence_id"])
548
+ req_path = _write_jsonl(staging / "requests.jsonl", all_rows)
549
+ buckets = pset.buckets()
550
+ mets, table = metrics.bucket_metrics(all_rows, buckets, resamples, seed=0)
551
+ round_walls = {int(x["index"]): (x["t_start"], x["t_end"]) for x in (prev or {}).get("data", {}).get(
552
+ "rounds", [])}
553
+ round_walls.update(walls)
554
+ round_rows = metrics.round_rows(all_rows, round_walls)
555
+
556
+ max_by_id = {p.id: runner.max_for(p) for p in prompts}
557
+ limitations = list(lim) + _count_limitations(all_rows, (runner.asked or {}).get("exact_length"), max_by_id,
558
+ runner.token_ids, runner.engine_kind)
559
+ failed = [x for x in all_rows if not x["ok"]]
560
+ if failed:
561
+ limitations.append(f"{len(failed)} of {len(all_rows)} request(s) failed (e.g. {failed[0]['error']})")
562
+ if not cache_salt and len(round_rows) > 1:
563
+ limitations.append("--no-cache-salt: identical prompts were resent every round, so rounds after the first "
564
+ "can be served from the engine's prefix cache; TTFT here is warm-cache TTFT, not "
565
+ "cold prefill")
566
+ if not ep.is_local():
567
+ limitations.append("server is not on this host: TTFT and e2e include the network round trip")
568
+ if key_note:
569
+ limitations.append(key_note)
570
+ nondet = sorted(k for k, v in text_sha.items() if len(v) > 1)
571
+ if nondet:
572
+ limitations.append(f"{len(nondet)} prompt(s) produced different text across untagged rounds "
573
+ f"(non-deterministic decoding?), e.g. {nondet[0]}")
574
+ if len(round_rows) < 2:
575
+ limitations.append("fewer than 2 rounds: no confidence interval")
576
+
577
+ maxes = sorted({runner.max_for(p) for p in prompts})
578
+ workload = {"gen_len": maxes[0] if len(maxes) == 1 else None, "batch": 1, "seed": seed, "mode": "single",
579
+ "bucket": buckets[0] if len(buckets) == 1 else None, "prompt_set": pset.identity(),
580
+ "endpoint_kind": f"openai_{runner.api}", "prefix_cache": "cold" if cache_salt else "warm",
581
+ "request_set": request_set(pset, runner)}
582
+ ident, missing = _identity(ep, gpu_name, gpu_count, runner.engine_kind, engine_version, runner.model,
583
+ workload)
584
+ if not [x for x in all_rows if x["ok"]]:
585
+ missing.append("every request failed")
586
+ artifacts = [ev.artifact(req_path, staging, "requests")]
587
+ if idx:
588
+ artifacts.append(ev.artifact(idx, staging, "outputs_index"))
589
+ a = table["all"]
590
+ summary = (f"{label} {runner.model} ({runner.api}, single user, {len(round_rows)} round(s) x "
591
+ f"{len(prompts)} prompts): decode {_fmt((a.get('decode_tok_s') or {}).get('median'))} tok/s")
592
+ ci = (a.get("decode_tok_s") or {}).get("ci95_median") or [None, None]
593
+ if ci[0] is not None:
594
+ summary += f" (95% CI {_fmt(ci[0])}-{_fmt(ci[1])})"
595
+ summary += (f", TTFT {_fmt((a.get('ttft_ms') or {}).get('median'))} ms "
596
+ f"({'cold prefix cache' if cache_salt else 'prompts repeated: warm prefix cache'}), TPOT "
597
+ f"{_fmt((a.get('tpot_ms') or {}).get('median'), 2)} ms, all buckets; tokens by "
598
+ + ",".join(sorted({x["token_count_method"] or "none" for x in all_rows if x["ok"]})))
599
+ if failed:
600
+ summary += f"; {len(failed)} failed"
601
+ data = {"label": label, "base_url_kind": "local" if ep.is_local() else "remote", "server": info,
602
+ "prompt_set_sha256": pset.sha256, "prompt_set_name": pset.name, "buckets": table,
603
+ "rounds": round_rows, "request_profile": {"engine_kind": runner.engine_kind, "api": runner.api,
604
+ "asked": runner.asked,
605
+ "note": profiles.PROFILES[runner.engine_kind].note},
606
+ "prefix_cache": {"mode": "cold" if cache_salt else "warm",
607
+ "how": ("warm-up and every round after round 0 prefix the prompt with a unique "
608
+ "tag ([ik-warm-N] / [ik-rN], a few tokens); round 0 is untagged"
609
+ if cache_salt else "warm-up tagged; every round resends identical "
610
+ "prompts (--no-cache-salt)")},
611
+ "outputs_from": outputs_from,
612
+ "token_count_methods": _method_counts(all_rows), "nondeterministic_prompts": nondet[:50],
613
+ "extends": prev["evidence_id"] if prev else None, "timer": "client perf_counter_ns",
614
+ "definitions": {"ttft_ms": "first token-bearing SSE event - request send (includes connect)",
615
+ "tpot_ms": "(t_last - t_first) / (n_tokens - tokens in first event)",
616
+ "decode_tok_s": "(n_tokens - tokens in first event) / (t_last - t_first)",
617
+ "e2e_ms": "[DONE] (or end of stream) - request send"}}
618
+ if run_dir:
619
+ _write_metrics_file(run_dir, label, all_rows, table)
620
+ record = ev.build(call, kind="serve_benchmark", claim_class="observed", summary=summary, identity_=ident,
621
+ metrics=mets, artifacts=artifacts, missing=missing, limitations=limitations, data=data)
622
+ return store.save(record, staging)
623
+
624
+
625
+ def _method_counts(rows):
626
+ out = {}
627
+ for r in rows:
628
+ if r["ok"]:
629
+ out[r["token_count_method"] or "none"] = out.get(r["token_count_method"] or "none", 0) + 1
630
+ return out
631
+
632
+
633
+ def _write_metrics_file(run_dir: Path, label: str, rows: list[dict], table: dict) -> None:
634
+ p = Path(run_dir) / "metrics" / f"{label}.json"
635
+ p.parent.mkdir(parents=True, exist_ok=True)
636
+ per_input = {}
637
+ for r in rows:
638
+ if r["ok"]:
639
+ d = per_input.setdefault(r["id"], {"bucket": r["bucket"], "ttft_ms": [], "tpot_ms": [],
640
+ "decode_tok_s": [], "e2e_ms": []})
641
+ for k in ("ttft_ms", "tpot_ms", "decode_tok_s", "e2e_ms"):
642
+ if r.get(k) is not None:
643
+ d[k].append(r[k])
644
+ summary = {k: {"bucket": v["bucket"], **{m: (statistics.median(v[m]) if v[m] else None)
645
+ for m in ("ttft_ms", "tpot_ms", "decode_tok_s", "e2e_ms")}}
646
+ for k, v in sorted(per_input.items())}
647
+ p.write_text(json.dumps({"label": label, "speed": {"per_bucket": table, "per_input_median": summary}},
648
+ indent=1) + "\n")
649
+
650
+
651
+ # --------------------------------------------------------------------------- load mode
652
+
653
+ def _level_name(kind: str, level: float) -> str:
654
+ return f"c{level:g}" if kind == "concurrency" else f"r{level:g}"
655
+
656
+
657
+ def _load(*, call, store, runner, pset, ep, label, seed, run_dir, gpu_name, gpu_count, engine_version,
658
+ resamples, warmup, key_note, grid, level_kind, duration_s, max_requests, ref_e2e_ms, ref_requests, slo,
659
+ sweep_all, closed_loop, price):
660
+ pool = loadgen.PromptPool(list(pset.prompts))
661
+ need_ref = level_kind == "concurrency" and not closed_loop and ref_e2e_ms is None
662
+
663
+ def e2e_of(results):
664
+ return [(r.t_done_ns - r.t_send_ns) / 1e6 for r in results if r.ok and r.t_done_ns]
665
+
666
+ async def run():
667
+ lim, info = await _check_server(ep, runner.model)
668
+ # Warm-up, then (for a concurrency grid) dedicated reference requests, one at a time: e2e_ref is
669
+ # their median, so slow first requests (lazy init, JIT, a cold engine) do not lower every
670
+ # level's offered rate. Both are tagged and do not use up the pool's untagged prompts.
671
+ order = pool.order(loadgen.level_seed(seed, 10_000))
672
+ warm = [await runner.send(order[i % len(order)], f"ik-warm-{i}") for i in range(warmup)]
673
+ ref = [await runner.send(order[(warmup + i) % len(order)], f"ik-ref-{i}")
674
+ for i in range(ref_requests if need_ref else 0)]
675
+ e2e_ref = ref_e2e_ms if ref_e2e_ms is not None else (statistics.median(e2e_of(ref)) if e2e_of(ref) else None)
676
+ if level_kind == "concurrency" and not closed_loop and not e2e_ref:
677
+ raise OpError("could not measure the unloaded e2e latency (every reference request failed); "
678
+ "pass --ref-e2e-ms")
679
+ levels = []
680
+ for i, level in enumerate(grid):
681
+ name = _level_name(level_kind, level)
682
+ items_it = pool.stream(loadgen.level_seed(seed, i), name)
683
+ if closed_loop:
684
+ rate = None
685
+ results = await loadgen.closed_loop(runner.send, items_it, int(level), duration_s, max_requests)
686
+ else:
687
+ rate = level if level_kind == "rate" else level / (e2e_ref / 1000.0)
688
+ offsets = loadgen.poisson_schedule(rate, duration_s, loadgen.level_seed(seed, i), max_requests)
689
+ items = [next(items_it) for _ in offsets]
690
+ results = await loadgen.open_loop(runner.send, items, offsets)
691
+ levels.append({"level": level, "name": name, "rate_rps": rate, "results": results})
692
+ rows = [metrics.request_row(r, level=name) for r in results]
693
+ itls = [x for r in results if r.ok for x in metrics.itl_ms(r)]
694
+ levels[-1]["rows"], levels[-1]["summary"] = rows, metrics.level_summary(
695
+ rows, itls, slo, gpu_count, resamples=min(resamples, 2000), seed=0)
696
+ if levels[-1]["summary"]["slo_ok"] is False and not sweep_all:
697
+ break
698
+ return lim, info, e2e_of(warm), e2e_of(ref), e2e_ref, levels
699
+
700
+ lim, info, warm_e2e, ref_e2e, e2e_ref, levels = asyncio.run(run())
701
+ with store.staging_dir() as staging:
702
+ docs, all_rows = [], []
703
+ by_id = {q.id: q for q in pset.prompts}
704
+ for lv in levels:
705
+ for res, row in zip(lv["results"], lv["rows"]):
706
+ docs.append((f"load-{lv['name']}/{res.bucket}/{res.id}",
707
+ (_output_doc(res, by_id[res.id], runner, level=lv["name"], salt=res.salt),
708
+ res.request_body or {})))
709
+ all_rows.append(row)
710
+ docs = _unique_paths(docs)
711
+ idx = _write_run_files(staging, run_dir, label, [(rel, d[0]) for rel, d in docs],
712
+ [(rel, d[1]) for rel, d in docs])
713
+ req_path = _write_jsonl(staging / "requests.jsonl", all_rows)
714
+
715
+ max_by_id = {p.id: runner.max_for(p) for p in pset.prompts}
716
+ limitations = list(lim) + _count_limitations(all_rows, (runner.asked or {}).get("exact_length"), max_by_id,
717
+ runner.token_ids, runner.engine_kind)
718
+ if closed_loop:
719
+ limitations.insert(0, "CLOSED-LOOP STRESS TEST: workers wait for their previous request, so the arrival "
720
+ "rate falls as the server slows; not a capacity measurement and not used for N*")
721
+ if not ep.is_local():
722
+ limitations.append("server is not on this host: TTFT and e2e include the network round trip")
723
+ if level_kind == "concurrency" and not closed_loop:
724
+ src = ("--ref-e2e-ms" if ref_e2e_ms is not None else
725
+ f"the median of {len(ref_e2e)} reference request(s) after the warm-up")
726
+ limitations.append(f"concurrency N offered as Poisson rate N / e2e_ref, e2e_ref = {e2e_ref:.1f} ms from "
727
+ f"{src}; measured mean in-flight is reported per level")
728
+ if key_note:
729
+ limitations.append(key_note)
730
+ small = [lv["name"] for lv in levels if lv["summary"]["n_requests"] < 20]
731
+ if small:
732
+ limitations.append(f"levels with fewer than 20 requests (p95 is rough): {', '.join(small)}")
733
+ lag = [lv["name"] for lv in levels if (lv["summary"]["send_lag_ms"].get("p99") or 0) > 5]
734
+ if lag:
735
+ limitations.append(f"client send lag p99 > 5 ms at {', '.join(lag)}: the load generator fell behind "
736
+ "its schedule (client CPU bound?)")
737
+ if pool.salted:
738
+ limitations.append(f"{pool.salted} request(s) reused a prompt with a unique prefix tag (the pool is "
739
+ "smaller than the requests sent); the tag adds a few prompt tokens")
740
+ limitations.append("load-level CIs bootstrap requests as if independent; under load they are not, so "
741
+ "treat those CIs as optimistic")
742
+ skipped = [g for g in grid if g not in [lv["level"] for lv in levels]]
743
+ if skipped:
744
+ limitations.append(f"levels not run after the SLO failed: {', '.join(f'{g:g}' for g in skipped)} "
745
+ "(--sweep-all runs them)")
746
+
747
+ buckets = pset.buckets()
748
+ maxes = sorted({runner.max_for(p) for p in pset.prompts})
749
+ workload = {"gen_len": maxes[0] if len(maxes) == 1 else None, "seed": seed, "mode": "load",
750
+ "bucket": buckets[0] if len(buckets) == 1 else None, "duration_s": duration_s,
751
+ "prompt_set": pset.identity(), "slo": slo or None, "endpoint_kind": f"openai_{runner.api}",
752
+ "prefix_cache": "cold", "request_set": request_set(pset, runner)}
753
+ workload["concurrency" if level_kind == "concurrency" else "rate_rps"] = (
754
+ [int(g) for g in grid] if level_kind == "concurrency" else [float(g) for g in grid])
755
+ ident, missing = _identity(ep, gpu_name, gpu_count, runner.engine_kind, engine_version, runner.model,
756
+ workload)
757
+ mets = []
758
+ for lv in levels:
759
+ s, subj = lv["summary"], lv["name"]
760
+ for name, unit in (("ttft_ms", "ms"), ("tpot_ms", "ms"), ("itl_ms", "ms"), ("e2e_ms", "ms")):
761
+ for pct in ("p50", "p95", "p99"):
762
+ ci = s[name].get("ci95_p95") if pct == "p95" else None
763
+ mets.append(ev.metric(name, s[name][pct], unit, pct, n=s[name]["n"], ci95=ci, subject=subj))
764
+ mets += [ev.metric("output_tok_s_per_gpu", s["output_tok_s_per_gpu"], "tok/s", "mean",
765
+ n=s["n_ok"], subject=subj),
766
+ ev.metric("goodput_rps", s["goodput_rps"], "req/s", "mean", n=s["n_requests"], subject=subj),
767
+ ev.metric("failed_requests", s["n_failed"], "requests", "count", n=s["n_requests"],
768
+ subject=subj),
769
+ ev.metric("mean_inflight", s["mean_inflight"], "requests", "mean", subject=subj)]
770
+ level_data = [{"level": lv["level"], "name": lv["name"], "offered_rate_rps": lv["rate_rps"],
771
+ "summary": lv["summary"]} for lv in levels]
772
+ base = {"label": label, "base_url_kind": "local" if ep.is_local() else "remote", "server": info,
773
+ "prompt_set_sha256": pset.sha256, "prompt_set_name": pset.name, "level_kind": level_kind,
774
+ "open_loop": not closed_loop, "e2e_ref_ms": e2e_ref,
775
+ "e2e_ref_source": "ref_e2e_ms" if ref_e2e_ms is not None else (
776
+ "reference_requests" if need_ref else None),
777
+ "warmup_e2e_ms": warm_e2e, "reference_e2e_ms": ref_e2e,
778
+ "levels": level_data, "salted_requests": pool.salted, "timer": "client perf_counter_ns",
779
+ "request_profile": {"engine_kind": runner.engine_kind, "api": runner.api, "asked": runner.asked,
780
+ "note": profiles.PROFILES[runner.engine_kind].note},
781
+ "token_count_methods": _method_counts(all_rows)}
782
+ if not [r for r in all_rows if r["ok"]]:
783
+ missing.append("every request failed")
784
+ kind_word = "closed-loop stress" if closed_loop else "open-loop"
785
+ top = levels[-1] if levels else None
786
+ summary = (f"{label} {runner.model}: {kind_word} load, {len(levels)} level(s) "
787
+ f"({', '.join(lv['name'] for lv in levels)})")
788
+ if top:
789
+ summary += (f"; at {top['name']}: p95 TTFT {_fmt(top['summary']['ttft_ms']['p95'])} ms, p95 TPOT "
790
+ f"{_fmt(top['summary']['tpot_ms']['p95'], 2)} ms, {_fmt(top['summary']['output_tok_s_per_gpu'])}"
791
+ f" out tok/s/GPU, {top['summary']['n_failed']} failed")
792
+ artifacts = [ev.artifact(req_path, staging, "requests")]
793
+ if idx:
794
+ artifacts.append(ev.artifact(idx, staging, "outputs_index"))
795
+ if run_dir:
796
+ p = Path(run_dir) / "metrics" / f"{label}.load.json"
797
+ p.parent.mkdir(parents=True, exist_ok=True)
798
+ p.write_text(json.dumps({"label": label, "levels": level_data}, indent=1) + "\n")
799
+ measured = store.save(ev.build(call, kind="serve_benchmark", claim_class="observed", summary=summary,
800
+ identity_=ident, metrics=mets, artifacts=artifacts, missing=missing,
801
+ limitations=limitations, data=base), staging)
802
+
803
+ # The derived record: N* and cost, citing the measurement.
804
+ star, why = metrics.n_star(levels) if not closed_loop else (None, "closed-loop stress test: N* not computed")
805
+ at = next((lv for lv in levels if lv["level"] == star), None)
806
+ s = at["summary"] if at else {}
807
+ tok_s = s.get("output_tok_s_per_gpu")
808
+ inflight = s.get("mean_inflight")
809
+ cost = metrics.usd_per_1m_tokens(tok_s, price, 1) # per-GPU throughput x per-GPU price
810
+ cmiss = []
811
+ if not slo:
812
+ cmiss.append("slo (pass --slo-ttft-p95-ms and/or --slo-tpot-p95-ms)")
813
+ if price is None:
814
+ cmiss.append("gpu price (pass --gpu-price-usd-h)")
815
+ unit = "streams" if level_kind == "concurrency" else "req/s"
816
+ star_name = "n_star" if level_kind == "concurrency" else "rate_star_rps"
817
+ cmet = [ev.metric(star_name, star, unit, "threshold", n=len(levels)),
818
+ ev.metric("goodput_rps", s.get("goodput_rps"), "req/s", "at_n_star", n=s.get("n_requests")),
819
+ ev.metric("output_tok_s_per_gpu", tok_s, "tok/s", "at_n_star", n=s.get("n_ok")),
820
+ ev.metric("ttft_ms", (s.get("ttft_ms") or {}).get("p95"), "ms", "p95_at_n_star"),
821
+ ev.metric("tpot_ms", (s.get("tpot_ms") or {}).get("p95"), "ms", "p95_at_n_star"),
822
+ ev.metric("mean_inflight", inflight, "requests", "at_n_star", n=s.get("n_requests")),
823
+ ev.metric("usd_per_1m_output_tokens", cost, "USD", "at_n_star")]
824
+ slo_txt = ", ".join(f"{k} <= {v:g}" for k, v in slo.items()) if slo else "no SLO"
825
+ csum = (f"{label} {runner.model}: {'N*' if level_kind == 'concurrency' else 'max rate'} = "
826
+ f"{'n/a' if star is None else f'{star:g}'} {unit} at {slo_txt} ({why})")
827
+ if star is not None and inflight is not None:
828
+ csum += f"; measured mean in-flight at that level {inflight:.2f}"
829
+ if cost is not None:
830
+ csum += f"; ${cost:.3f} per 1M output tokens at ${price:g}/GPU-h"
831
+ clim = [f"derived from {measured['evidence_id']}: see its limitations"]
832
+ if (level_kind == "concurrency" and not closed_loop and star is not None and inflight is not None
833
+ and inflight < INFLIGHT_SHORTFALL * star):
834
+ clim.insert(0, f"N* = {star:g} is the nominal level, but only {inflight:.2f} requests were in flight on "
835
+ f"average there (< {INFLIGHT_SHORTFALL:g} x N*): the server was offered less load than N* "
836
+ "streams (short window, Poisson noise, or a high e2e_ref); read capacity as about "
837
+ f"{inflight:.1f} streams, or rerun with a longer --duration-s / measured --ref-e2e-ms")
838
+ if closed_loop:
839
+ clim.append("closed-loop stress test: no capacity claim")
840
+ if star is not None and star == grid[-1]:
841
+ clim.append("the SLO held at the top of the grid: N* is at least this; extend the grid")
842
+ cap = ev.build(call, kind="capacity", claim_class="derived", summary=csum, identity_=measured["identity"],
843
+ metrics=cmet, missing=cmiss, limitations=clim,
844
+ data={"measurement": measured["evidence_id"], "label": label, "level_kind": level_kind,
845
+ "n_star": star, "reason": why, "mean_inflight_at_n_star": inflight, "slo": slo,
846
+ "gpu_price_usd_h": price,
847
+ "gpu_count": gpu_count, "cost_formula": "price_per_gpu_h / (output_tok_s_per_gpu * 3600) "
848
+ "* 1e6",
849
+ "curve": [{"level": lv["level"], "slo_ok": lv["summary"]["slo_ok"],
850
+ "ttft_p95_ms": lv["summary"]["ttft_ms"]["p95"],
851
+ "tpot_p95_ms": lv["summary"]["tpot_ms"]["p95"],
852
+ "output_tok_s_per_gpu": lv["summary"]["output_tok_s_per_gpu"],
853
+ "goodput_rps": lv["summary"]["goodput_rps"],
854
+ "mean_inflight": lv["summary"]["mean_inflight"]} for lv in levels]})
855
+ return store.save(cap)
856
+
857
+
858
+ def _unique_paths(docs: list[tuple[str, dict]]) -> list[tuple[str, dict]]:
859
+ """``<level>/<bucket>/<id>.json``; a prompt sent twice in a level gets ``<id>~2.json`` and so on."""
860
+ seen: dict[str, int] = {}
861
+ out = []
862
+ for rel, doc in docs:
863
+ k = seen.get(rel, 0) + 1
864
+ seen[rel] = k
865
+ out.append((f"{rel}.json" if k == 1 else f"{rel}~{k}.json", doc))
866
+ return out