inferencekit-cli 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- inferencekit/__init__.py +9 -0
- inferencekit/__main__.py +5 -0
- inferencekit/analysis/__init__.py +1 -0
- inferencekit/analysis/ncu_csv.py +284 -0
- inferencekit/analysis/nsys_sqlite.py +518 -0
- inferencekit/canonical.py +104 -0
- inferencekit/checkpoint.py +396 -0
- inferencekit/cli.py +274 -0
- inferencekit/cuda/bw_peak.cu +252 -0
- inferencekit/cuda/torch_bw.py +60 -0
- inferencekit/engines/__init__.py +7 -0
- inferencekit/engines/adapters.py +190 -0
- inferencekit/engines/clip/__init__.py +13 -0
- inferencekit/engines/clip/comfyui.py +749 -0
- inferencekit/engines/clip/diffusers_ltx.py +601 -0
- inferencekit/engines/clip/noise.py +46 -0
- inferencekit/engines/clip/official_ltx.py +316 -0
- inferencekit/engines/clip/server.py +542 -0
- inferencekit/engines/clip/stepdump.py +182 -0
- inferencekit/engines/gates.py +94 -0
- inferencekit/engines/sglang_worker.py +86 -0
- inferencekit/engines/vllm_worker.py +129 -0
- inferencekit/evidence.py +427 -0
- inferencekit/gpuinfo.py +93 -0
- inferencekit/hardware.py +135 -0
- inferencekit/mcp_server.py +178 -0
- inferencekit/ops/__init__.py +30 -0
- inferencekit/ops/bench.py +184 -0
- inferencekit/ops/bench_audio.py +384 -0
- inferencekit/ops/bench_clip.py +819 -0
- inferencekit/ops/bench_image.py +393 -0
- inferencekit/ops/bench_rounds.py +297 -0
- inferencekit/ops/bench_serve.py +866 -0
- inferencekit/ops/ceiling.py +135 -0
- inferencekit/ops/compare.py +378 -0
- inferencekit/ops/correctness.py +153 -0
- inferencekit/ops/doctor.py +486 -0
- inferencekit/ops/gates.py +1772 -0
- inferencekit/ops/gates_clip.py +930 -0
- inferencekit/ops/intake.py +1321 -0
- inferencekit/ops/ledger.py +749 -0
- inferencekit/ops/manifest.py +309 -0
- inferencekit/ops/ncu.py +183 -0
- inferencekit/ops/nsys.py +224 -0
- inferencekit/ops/prompts.py +316 -0
- inferencekit/ops/quality.py +2161 -0
- inferencekit/ops/quality_clip.py +690 -0
- inferencekit/ops/quality_clip_steps.py +341 -0
- inferencekit/ops/report.py +317 -0
- inferencekit/ops/review.py +119 -0
- inferencekit/ops/rocprof.py +89 -0
- inferencekit/ops/roofline.py +392 -0
- inferencekit/ops/store.py +61 -0
- inferencekit/proc.py +172 -0
- inferencekit/prompts/ATTRIBUTION.md +56 -0
- inferencekit/prompts/__init__.py +167 -0
- inferencekit/prompts/build.py +518 -0
- inferencekit/prompts/dev-load.jsonl.gz +0 -0
- inferencekit/prompts/dev-load.manifest.json +212 -0
- inferencekit/prompts/dev.jsonl.gz +0 -0
- inferencekit/prompts/dev.manifest.json +214 -0
- inferencekit/prompts/exclusions.json +54 -0
- inferencekit/quality/__init__.py +23 -0
- inferencekit/quality/client.py +66 -0
- inferencekit/quality/data/gsm8k_LICENSE.txt +21 -0
- inferencekit/quality/fake.py +329 -0
- inferencekit/quality/gsm8k.py +224 -0
- inferencekit/quality/logprobs.py +106 -0
- inferencekit/quality/metrics.py +235 -0
- inferencekit/quality/niah.py +63 -0
- inferencekit/quality/parquet.py +296 -0
- inferencekit/quality/sampling.py +262 -0
- inferencekit/quality/step_replay.py +217 -0
- inferencekit/quality/video_metrics.py +304 -0
- inferencekit/registry.py +206 -0
- inferencekit/review/__init__.py +6 -0
- inferencekit/review/diff.py +163 -0
- inferencekit/review/media.py +181 -0
- inferencekit/review/page.py +920 -0
- inferencekit/review/rundir.py +844 -0
- inferencekit/roofline_headers.py +504 -0
- inferencekit/schemas/evidence.v1.json +130 -0
- inferencekit/schemas/evidence.v2.json +499 -0
- inferencekit/schemas/intake.v1.json +276 -0
- inferencekit/schemas/run-manifest.v1.json +118 -0
- inferencekit/serve/__init__.py +8 -0
- inferencekit/serve/audio_client.py +330 -0
- inferencekit/serve/client.py +446 -0
- inferencekit/serve/clip_client.py +351 -0
- inferencekit/serve/clip_rounds.py +429 -0
- inferencekit/serve/image_client.py +373 -0
- inferencekit/serve/loadgen.py +136 -0
- inferencekit/serve/metrics.py +203 -0
- inferencekit/serve/profiles.py +105 -0
- inferencekit/serve/prompts_io.py +125 -0
- inferencekit/serve/rounds.py +1111 -0
- inferencekit/serve/servers.py +897 -0
- inferencekit/stats.py +249 -0
- inferencekit_cli-0.1.0.dist-info/METADATA +233 -0
- inferencekit_cli-0.1.0.dist-info/RECORD +104 -0
- inferencekit_cli-0.1.0.dist-info/WHEEL +4 -0
- inferencekit_cli-0.1.0.dist-info/entry_points.txt +2 -0
- inferencekit_cli-0.1.0.dist-info/licenses/LICENSE +202 -0
- inferencekit_cli-0.1.0.dist-info/licenses/NOTICE +9 -0
|
@@ -0,0 +1,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
|