@wendellresearch-ai/benchwarmers 0.1.1 → 0.2.1
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.
- package/AGENTS.md +147 -147
- package/BENCHMARKS.md +262 -262
- package/GATEWAY.md +272 -271
- package/NOTICE +28 -28
- package/PRIVACY.md +46 -46
- package/README.md +368 -275
- package/SECURITY.md +83 -83
- package/TERMS.md +22 -22
- package/USAGE.md +170 -155
- package/VERSION +1 -1
- package/bin/benchwarmers.js +16 -72
- package/cli.py +666 -0
- package/examples/config/gateway.json +5 -5
- package/examples/config/model-roles.json +11 -11
- package/gateway.py +755 -712
- package/launcher.py +120 -0
- package/mcp_server.py +266 -0
- package/metrics/alerts.py +116 -116
- package/metrics/aliases.py +41 -41
- package/metrics/bench_collections.py +141 -141
- package/metrics/catalog.py +295 -295
- package/metrics/client.py +394 -373
- package/metrics/cliproxy.py +191 -191
- package/metrics/connect.py +691 -691
- package/metrics/cost.py +152 -152
- package/metrics/decide.py +227 -227
- package/metrics/discover.py +318 -318
- package/metrics/endpoints.py +436 -436
- package/metrics/guard.py +617 -592
- package/metrics/learn.py +221 -221
- package/metrics/litellm_hub.py +206 -206
- package/metrics/model_roles.py +84 -84
- package/metrics/offload.py +166 -166
- package/metrics/owui.py +205 -205
- package/metrics/paths.py +60 -60
- package/metrics/route.py +579 -574
- package/metrics/scheduler.py +275 -275
- package/metrics/sources.py +167 -167
- package/metrics/wrong.py +144 -144
- package/package.json +68 -64
- package/perf/README.md +89 -89
- package/perf/run.py +261 -261
- package/scripts/auto_retire.py +367 -367
- package/scripts/check.py +108 -104
- package/scripts/check_deprecations.py +378 -378
- package/scripts/check_themes.py +103 -103
- package/scripts/init.py +124 -124
- package/scripts/list_served.py +78 -78
- package/scripts/notice.py +78 -78
- package/scripts/onboard.py +401 -331
- package/scripts/provider_tags.py +129 -129
- package/scripts/reprefix.py +136 -136
- package/scripts/sync_config.py +105 -105
- package/scripts/test_auth.py +255 -255
- package/scripts/test_cli.py +189 -0
- package/scripts/test_client.py +233 -233
- package/scripts/test_connect.py +222 -222
- package/scripts/test_first_run.py +180 -180
- package/scripts/test_hubs.py +328 -328
- package/scripts/test_launcher.py +67 -0
- package/scripts/test_llm_log.py +85 -0
- package/scripts/test_mcp.py +178 -0
- package/scripts/test_merged.py +214 -193
- package/scripts/test_no_owui.py +291 -291
- package/scripts/test_owui.py +90 -90
- package/scripts/test_serve_edit.py +448 -386
- package/scripts/test_verdict.py +98 -98
- package/scripts/test_vision.py +228 -228
- package/serve.py +3904 -3662
- package/suite/mine.py +154 -154
- package/suite/pace.py +145 -145
- package/suite/run.py +1046 -1040
- package/suite/seed.py +421 -421
- package/suite/tasks/claim-alias-unbound/fixtures/onboard.py +286 -286
- package/suite/tasks/claim-error-text-string/fixtures/decide.py +187 -187
- package/suite/tasks/claim-list-body/fixtures/gateway.py +235 -235
- package/suite/tasks/claim-member-nominated-twice/fixtures/gateway.py +284 -284
- package/suite/tasks/claim-nominee-uses-slot/fixtures/gateway.py +284 -284
- package/suite/tasks/claim-silent-exit/fixtures/gateway.py +235 -235
- package/suite/tasks/claim-sse-hang/fixtures/gateway.py +235 -235
- package/suite/tasks/claim-unknown-nominee-crash/fixtures/gateway.py +284 -284
- package/suite/tasks/rebench-matchms-809-bug/task.json +23 -23
- package/suite/tasks/rebench-matchms-809-control/task.json +27 -27
- package/suite/tasks/rebench-more-itertools-1028-bug/task.json +23 -23
- package/suite/tasks/rebench-more-itertools-1028-control/task.json +27 -27
- package/suite/tasks/rebench-narwhals-2864-bug/task.json +23 -23
- package/suite/tasks/rebench-narwhals-2864-control/task.json +27 -27
- package/suite/tasks/rebench-quantecon-py-769-bug/task.json +23 -23
- package/suite/tasks/rebench-quantecon-py-769-control/task.json +27 -27
- package/suite/tasks/rebench-scrapy-6867-bug/task.json +23 -23
- package/suite/tasks/rebench-scrapy-6867-control/task.json +27 -27
- package/ui/bench.css +430 -424
- package/ui/bench.js +1756 -1555
- package/ui/themes/dark.css +35 -35
- package/ui/themes/hardwood.css +52 -52
- package/ui/themes/rainforest.css +32 -32
package/perf/run.py
CHANGED
|
@@ -1,261 +1,261 @@
|
|
|
1
|
-
"""The PERFORMANCE tier: what the serving path does, tracked over time.
|
|
2
|
-
|
|
3
|
-
This measures the PATH, not the model. Latency through Open WebUI to whichever
|
|
4
|
-
provider is behind it depends on congestion, rate limits, routing and the time
|
|
5
|
-
of day — none of which are properties of the model, and all of which decide
|
|
6
|
-
whether an offload is worth making right now.
|
|
7
|
-
|
|
8
|
-
That makes it the opposite of `suite/` and `floor/`, which are built to hold
|
|
9
|
-
still. A single perf number is nearly meaningless. **The history is the
|
|
10
|
-
measurement**, which is why every run appends to `perf/history.csv` rather than
|
|
11
|
-
overwriting a report: a p50 that doubles over a month is a real finding, and a
|
|
12
|
-
p50 that is 40% higher this afternoon is a Tuesday.
|
|
13
|
-
|
|
14
|
-
WHAT IS RECORDED, AND WHY EACH
|
|
15
|
-
------------------------------
|
|
16
|
-
- **p50 and p95, not the mean.** One 300-second timeout drags a mean into
|
|
17
|
-
fiction. p95 is where rate limiting shows up, and rate limiting is the thing
|
|
18
|
-
most likely to make a model unusable while looking fine on average.
|
|
19
|
-
- **Two prompt sizes.** A one-word prompt measures the FLOOR — connection,
|
|
20
|
-
queueing, cold start. A large prompt measures THROUGHPUT. A model can be
|
|
21
|
-
fine on one and hopeless on the other, and `nemotron-lightning` (134 s on a
|
|
22
|
-
one-word prompt) is why the distinction earns its place.
|
|
23
|
-
- **Characters per second, not tokens.** The `llm` CLI does not report token
|
|
24
|
-
counts for these endpoints. Chars/s is a proxy, it is affected by how
|
|
25
|
-
verbose a model is, and calling it a proxy is more useful than quietly
|
|
26
|
-
publishing a number that looks like tokens/s and is not.
|
|
27
|
-
- **Outcome counts, split four ways.** answered / refused / error / timeout.
|
|
28
|
-
Collapsing these is the mistake this repo was built around: a safety filter,
|
|
29
|
-
an overloaded service and a slow model are three different facts.
|
|
30
|
-
- **A timestamp on every row**, because the whole point is the trend.
|
|
31
|
-
|
|
32
|
-
WHAT THIS CANNOT TELL YOU
|
|
33
|
-
-------------------------
|
|
34
|
-
Whether a model is any good. That is `suite/`. A model can be the fastest here
|
|
35
|
-
and still invent an API that does not exist.
|
|
36
|
-
"""
|
|
37
|
-
|
|
38
|
-
from __future__ import annotations
|
|
39
|
-
|
|
40
|
-
import argparse
|
|
41
|
-
import csv
|
|
42
|
-
import datetime as dt
|
|
43
|
-
import os
|
|
44
|
-
import pathlib
|
|
45
|
-
import statistics
|
|
46
|
-
import sys
|
|
47
|
-
|
|
48
|
-
HERE = pathlib.Path(__file__).resolve().parent
|
|
49
|
-
ROOT = HERE.parent
|
|
50
|
-
sys.path.insert(0, str(ROOT / "metrics"))
|
|
51
|
-
from paths import DATA # noqa: E402 -- the install's data folder (metrics/paths.py)
|
|
52
|
-
HISTORY = DATA / "perf" / "history.csv"
|
|
53
|
-
|
|
54
|
-
sys.path.insert(0, str(ROOT / "suite"))
|
|
55
|
-
from run import (_learn, _registered_names, ad_hoc_models, ask_once, FAILURE_CLASSES,
|
|
56
|
-
failure_class, llm_binary, outcome,
|
|
57
|
-
registered_models) # noqa: E402
|
|
58
|
-
|
|
59
|
-
# A one-word prompt measures overhead; a large one measures throughput. The
|
|
60
|
-
# large prompt is generated rather than stored so this file stays readable and
|
|
61
|
-
# the size is exact.
|
|
62
|
-
TINY = "Reply with exactly one word: ready"
|
|
63
|
-
LARGE = (
|
|
64
|
-
"Summarise the following configuration in at most three sentences. "
|
|
65
|
-
"Do not quote it back.\n\n"
|
|
66
|
-
+ "\n".join(f"setting_{i} = value_{i} # option {i} of many" for i in range(400))
|
|
67
|
-
)
|
|
68
|
-
|
|
69
|
-
FIELDS = ["timestamp", "model", "prompt", "n", "p50_s", "p95_s", "min_s",
|
|
70
|
-
"max_s", "chars_per_s", "answered", "refused", "error", "timeout",
|
|
71
|
-
# WHY it failed, not just that it did. `structural` means the model
|
|
72
|
-
# is not there and waiting will not help; `capacity` means it is
|
|
73
|
-
# there and busy. The deprecation check reads this to tell a
|
|
74
|
-
# retirement apart from a rate limit.
|
|
75
|
-
"failure",
|
|
76
|
-
# The provider's own words for the worst failure, first 300 chars.
|
|
77
|
-
# Without them `glm-5-3` sat as an unexplained `other` for a month
|
|
78
|
-
# when the message was a plain "404 page not found".
|
|
79
|
-
"detail"]
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
def percentile(values: list[float], pct: float) -> float:
|
|
83
|
-
"""Nearest-rank percentile.
|
|
84
|
-
|
|
85
|
-
`statistics.quantiles` interpolates, which invents a value that was never
|
|
86
|
-
measured. With n=5 that is misleading, so this returns a real observation.
|
|
87
|
-
"""
|
|
88
|
-
if not values:
|
|
89
|
-
return 0.0
|
|
90
|
-
ordered = sorted(values)
|
|
91
|
-
k = max(0, min(len(ordered) - 1, int(round(pct * (len(ordered) - 1)))))
|
|
92
|
-
return ordered[k]
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
def measure(binary: str, model: str, label: str, prompt: str, n: int,
|
|
96
|
-
timeout: int) -> dict:
|
|
97
|
-
latencies, rates = [], []
|
|
98
|
-
counts = {"answered": 0, "refused": 0, "error": 0, "timeout": 0}
|
|
99
|
-
failures: list[str] = []
|
|
100
|
-
details: dict[str, str] = {}
|
|
101
|
-
for i in range(1, n + 1):
|
|
102
|
-
body, seconds = ask_once(binary, model, prompt, timeout)
|
|
103
|
-
# A one-word reply is the CORRECT answer to the tiny prompt, so
|
|
104
|
-
# only a genuinely empty one counts as a failure here.
|
|
105
|
-
kind = (outcome(body, min_length=1) or ("answered", ""))[0]
|
|
106
|
-
counts[kind] += 1
|
|
107
|
-
# ONLY classify an actual non-answer. `failure_class` uses the
|
|
108
|
-
# default minimum reply length, so a correct one-word answer to the
|
|
109
|
-
# tiny prompt looked like an empty response and was filed as a
|
|
110
|
-
# failure -- on models that answered 3 of 3.
|
|
111
|
-
if kind != "answered" and (why := failure_class(body)) is not None:
|
|
112
|
-
failures.append(why)
|
|
113
|
-
details.setdefault(why, " ".join(body.split())[:300])
|
|
114
|
-
_learn(model, body, "perf") # a stated context window, rate limit or wait
|
|
115
|
-
# Only a real answer times anything meaningful. A refusal is fast
|
|
116
|
-
# because it is short, and averaging that in would flatter the model.
|
|
117
|
-
if kind == "answered":
|
|
118
|
-
latencies.append(seconds)
|
|
119
|
-
# Throughput ONLY from the large prompt. Six characters over ten
|
|
120
|
-
# seconds is 0.5 ch/s, which reads as a catastrophically slow
|
|
121
|
-
# model and is really just connection overhead with a one-word
|
|
122
|
-
# answer attached. A number that invites the wrong reading is
|
|
123
|
-
# worse than no number.
|
|
124
|
-
if seconds > 0 and label == "large":
|
|
125
|
-
rates.append(len(body) / seconds)
|
|
126
|
-
print(f" {model} {label} [{i}/{n}] {kind} {seconds:.1f}s", flush=True)
|
|
127
|
-
worst = next((c for c in FAILURE_CLASSES if c in failures), "")
|
|
128
|
-
return {
|
|
129
|
-
"timestamp": dt.datetime.now().isoformat(timespec="seconds"),
|
|
130
|
-
"model": model, "prompt": label, "n": n,
|
|
131
|
-
"p50_s": round(percentile(latencies, 0.50), 2),
|
|
132
|
-
"p95_s": round(percentile(latencies, 0.95), 2),
|
|
133
|
-
"min_s": round(min(latencies), 2) if latencies else 0.0,
|
|
134
|
-
"max_s": round(max(latencies), 2) if latencies else 0.0,
|
|
135
|
-
"chars_per_s": round(statistics.median(rates), 1) if rates else 0.0,
|
|
136
|
-
# The worst class seen: structural beats capacity beats timeout, so a
|
|
137
|
-
# model that failed structurally even once is not filed as merely busy.
|
|
138
|
-
# ORDER COMES FROM `suite/run.py`, not from a copy kept here -- a copy
|
|
139
|
-
# is what silently dropped `paywalled` and wrote a blank failure.
|
|
140
|
-
"failure": worst,
|
|
141
|
-
"detail": details.get(worst, ""),
|
|
142
|
-
**counts,
|
|
143
|
-
}
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
def append(rows: list[dict]) -> None:
|
|
147
|
-
"""Append, MIGRATING the file first if its header is out of date.
|
|
148
|
-
|
|
149
|
-
A column was added to FIELDS while a run was in flight. The running process
|
|
150
|
-
kept the old header, and the next append would have written fourteen values
|
|
151
|
-
under thirteen headings — every row silently shifted by one, which is worse
|
|
152
|
-
than a crash because the file still parses. History is only worth keeping
|
|
153
|
-
if it cannot quietly rot.
|
|
154
|
-
"""
|
|
155
|
-
if HISTORY.exists():
|
|
156
|
-
with HISTORY.open(encoding="utf-8", newline="") as fh:
|
|
157
|
-
existing = list(csv.DictReader(fh))
|
|
158
|
-
header = (csv.reader(HISTORY.open(encoding="utf-8"))
|
|
159
|
-
.__next__() if existing else FIELDS)
|
|
160
|
-
if header != FIELDS:
|
|
161
|
-
with HISTORY.open("w", encoding="utf-8", newline="") as fh:
|
|
162
|
-
writer = csv.DictWriter(fh, fieldnames=FIELDS)
|
|
163
|
-
writer.writeheader()
|
|
164
|
-
writer.writerows({k: r.get(k, "") for k in FIELDS}
|
|
165
|
-
for r in existing)
|
|
166
|
-
|
|
167
|
-
new = not HISTORY.exists()
|
|
168
|
-
with HISTORY.open("a", encoding="utf-8", newline="") as fh:
|
|
169
|
-
writer = csv.DictWriter(fh, fieldnames=FIELDS)
|
|
170
|
-
if new:
|
|
171
|
-
writer.writeheader()
|
|
172
|
-
writer.writerows(rows)
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
def trend(model: str, label: str) -> str:
|
|
176
|
-
"""Compare against this model's previous reading, if there is one."""
|
|
177
|
-
if not HISTORY.exists():
|
|
178
|
-
return ""
|
|
179
|
-
prior = [r for r in csv.DictReader(HISTORY.open(encoding="utf-8"))
|
|
180
|
-
if r["model"] == model and r["prompt"] == label]
|
|
181
|
-
if len(prior) < 2:
|
|
182
|
-
return ""
|
|
183
|
-
now, before = float(prior[-1]["p50_s"]), float(prior[-2]["p50_s"])
|
|
184
|
-
if before == 0:
|
|
185
|
-
return ""
|
|
186
|
-
change = (now - before) / before * 100
|
|
187
|
-
if abs(change) < 15:
|
|
188
|
-
return " (steady)"
|
|
189
|
-
return f" ({change:+.0f}% vs {prior[-2]['timestamp'][:10]})"
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
def main() -> None:
|
|
193
|
-
ap = argparse.ArgumentParser(description=__doc__)
|
|
194
|
-
ap.add_argument("--model", action="append", dest="models")
|
|
195
|
-
ap.add_argument("-n", type=int, default=5,
|
|
196
|
-
help="samples per prompt size; more is better and slower")
|
|
197
|
-
ap.add_argument("--timeout", type=int, default=180)
|
|
198
|
-
ap.add_argument("--tiny-only", action="store_true",
|
|
199
|
-
help="skip the large prompt (a quick availability check)")
|
|
200
|
-
ap.add_argument("--client", choices=("llm", "builtin"),
|
|
201
|
-
help="which model client makes the calls (metrics/client.py);"
|
|
202
|
-
" default: BENCH_CLIENT, else llm")
|
|
203
|
-
args = ap.parse_args()
|
|
204
|
-
if args.client:
|
|
205
|
-
# ask_once reads it per call; set here because a scheduled task's
|
|
206
|
-
# action can pass arguments but not environment variables.
|
|
207
|
-
os.environ["BENCH_CLIENT"] = args.client
|
|
208
|
-
|
|
209
|
-
# Every registered model, derived from the yaml — a perf run that
|
|
210
|
-
# skipped a model would hide exactly the outage it exists to find.
|
|
211
|
-
# Except the AD HOC ones: they are capped or expensive, and a daily
|
|
212
|
-
# probe would spend the cap on a latency number. Measured only when
|
|
213
|
-
# named with --model.
|
|
214
|
-
held = ad_hoc_models()
|
|
215
|
-
models = args.models or [m for m in registered_models() if m not in held]
|
|
216
|
-
if not args.models and held:
|
|
217
|
-
print(f"held back (ad hoc — name with --model to measure): "
|
|
218
|
-
f"{', '.join(sorted(held))}")
|
|
219
|
-
prompts = [("tiny", TINY)] if args.tiny_only else [("tiny", TINY),
|
|
220
|
-
("large", LARGE)]
|
|
221
|
-
binary = llm_binary()
|
|
222
|
-
print(f"{len(models)} models x {len(prompts)} prompts x {args.n} samples")
|
|
223
|
-
print(f"large prompt is {len(LARGE):,} chars\n")
|
|
224
|
-
|
|
225
|
-
# OpenRouter's `:free` models share ONE daily budget — 50 requests without
|
|
226
|
-
# credits — so a full weekly sweep at n=3 spends 54 on them alone and the
|
|
227
|
-
# last few get throttled. That is recorded honestly as `capacity` rather
|
|
228
|
-
# than as a fault, but it still means their numbers are measuring the
|
|
229
|
-
# quota. One sample each keeps the whole free tier inside the budget with
|
|
230
|
-
# room to spare, at the cost of no percentile spread for those models.
|
|
231
|
-
served = _registered_names()
|
|
232
|
-
rows = []
|
|
233
|
-
for model in models:
|
|
234
|
-
n = 1 if ":free" in served.get(model, "") else args.n
|
|
235
|
-
for label, prompt in prompts:
|
|
236
|
-
rows.append(measure(binary, model, label, prompt, n,
|
|
237
|
-
args.timeout))
|
|
238
|
-
try:
|
|
239
|
-
sys.path.insert(0, str(ROOT / "metrics"))
|
|
240
|
-
from cost import note_benchmark_calls
|
|
241
|
-
for row in rows:
|
|
242
|
-
note_benchmark_calls(row["model"], row["n"])
|
|
243
|
-
except Exception: # noqa: BLE001
|
|
244
|
-
pass
|
|
245
|
-
|
|
246
|
-
append(rows)
|
|
247
|
-
|
|
248
|
-
print(f"\n{'model':<24} {'prompt':<7} {'p50':>7} {'p95':>7} {'ch/s':>8}"
|
|
249
|
-
f" outcomes")
|
|
250
|
-
for r in rows:
|
|
251
|
-
oc = (f"{r['answered']}a/{r['refused']}r/{r['error']}e/{r['timeout']}t")
|
|
252
|
-
print(f"{r['model']:<24} {r['prompt']:<7} {r['p50_s']:>7.1f} "
|
|
253
|
-
f"{r['p95_s']:>7.1f} {r['chars_per_s']:>8.1f} {oc}"
|
|
254
|
-
+ trend(r["model"], r["prompt"]))
|
|
255
|
-
print(f"\nappended {len(rows)} rows to {HISTORY}")
|
|
256
|
-
print("These numbers measure the SERVING PATH, not the models. "
|
|
257
|
-
"One reading is weather; the history is climate.")
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
if __name__ == "__main__":
|
|
261
|
-
main()
|
|
1
|
+
"""The PERFORMANCE tier: what the serving path does, tracked over time.
|
|
2
|
+
|
|
3
|
+
This measures the PATH, not the model. Latency through Open WebUI to whichever
|
|
4
|
+
provider is behind it depends on congestion, rate limits, routing and the time
|
|
5
|
+
of day — none of which are properties of the model, and all of which decide
|
|
6
|
+
whether an offload is worth making right now.
|
|
7
|
+
|
|
8
|
+
That makes it the opposite of `suite/` and `floor/`, which are built to hold
|
|
9
|
+
still. A single perf number is nearly meaningless. **The history is the
|
|
10
|
+
measurement**, which is why every run appends to `perf/history.csv` rather than
|
|
11
|
+
overwriting a report: a p50 that doubles over a month is a real finding, and a
|
|
12
|
+
p50 that is 40% higher this afternoon is a Tuesday.
|
|
13
|
+
|
|
14
|
+
WHAT IS RECORDED, AND WHY EACH
|
|
15
|
+
------------------------------
|
|
16
|
+
- **p50 and p95, not the mean.** One 300-second timeout drags a mean into
|
|
17
|
+
fiction. p95 is where rate limiting shows up, and rate limiting is the thing
|
|
18
|
+
most likely to make a model unusable while looking fine on average.
|
|
19
|
+
- **Two prompt sizes.** A one-word prompt measures the FLOOR — connection,
|
|
20
|
+
queueing, cold start. A large prompt measures THROUGHPUT. A model can be
|
|
21
|
+
fine on one and hopeless on the other, and `nemotron-lightning` (134 s on a
|
|
22
|
+
one-word prompt) is why the distinction earns its place.
|
|
23
|
+
- **Characters per second, not tokens.** The `llm` CLI does not report token
|
|
24
|
+
counts for these endpoints. Chars/s is a proxy, it is affected by how
|
|
25
|
+
verbose a model is, and calling it a proxy is more useful than quietly
|
|
26
|
+
publishing a number that looks like tokens/s and is not.
|
|
27
|
+
- **Outcome counts, split four ways.** answered / refused / error / timeout.
|
|
28
|
+
Collapsing these is the mistake this repo was built around: a safety filter,
|
|
29
|
+
an overloaded service and a slow model are three different facts.
|
|
30
|
+
- **A timestamp on every row**, because the whole point is the trend.
|
|
31
|
+
|
|
32
|
+
WHAT THIS CANNOT TELL YOU
|
|
33
|
+
-------------------------
|
|
34
|
+
Whether a model is any good. That is `suite/`. A model can be the fastest here
|
|
35
|
+
and still invent an API that does not exist.
|
|
36
|
+
"""
|
|
37
|
+
|
|
38
|
+
from __future__ import annotations
|
|
39
|
+
|
|
40
|
+
import argparse
|
|
41
|
+
import csv
|
|
42
|
+
import datetime as dt
|
|
43
|
+
import os
|
|
44
|
+
import pathlib
|
|
45
|
+
import statistics
|
|
46
|
+
import sys
|
|
47
|
+
|
|
48
|
+
HERE = pathlib.Path(__file__).resolve().parent
|
|
49
|
+
ROOT = HERE.parent
|
|
50
|
+
sys.path.insert(0, str(ROOT / "metrics"))
|
|
51
|
+
from paths import DATA # noqa: E402 -- the install's data folder (metrics/paths.py)
|
|
52
|
+
HISTORY = DATA / "perf" / "history.csv"
|
|
53
|
+
|
|
54
|
+
sys.path.insert(0, str(ROOT / "suite"))
|
|
55
|
+
from run import (_learn, _registered_names, ad_hoc_models, ask_once, FAILURE_CLASSES,
|
|
56
|
+
failure_class, llm_binary, outcome,
|
|
57
|
+
registered_models) # noqa: E402
|
|
58
|
+
|
|
59
|
+
# A one-word prompt measures overhead; a large one measures throughput. The
|
|
60
|
+
# large prompt is generated rather than stored so this file stays readable and
|
|
61
|
+
# the size is exact.
|
|
62
|
+
TINY = "Reply with exactly one word: ready"
|
|
63
|
+
LARGE = (
|
|
64
|
+
"Summarise the following configuration in at most three sentences. "
|
|
65
|
+
"Do not quote it back.\n\n"
|
|
66
|
+
+ "\n".join(f"setting_{i} = value_{i} # option {i} of many" for i in range(400))
|
|
67
|
+
)
|
|
68
|
+
|
|
69
|
+
FIELDS = ["timestamp", "model", "prompt", "n", "p50_s", "p95_s", "min_s",
|
|
70
|
+
"max_s", "chars_per_s", "answered", "refused", "error", "timeout",
|
|
71
|
+
# WHY it failed, not just that it did. `structural` means the model
|
|
72
|
+
# is not there and waiting will not help; `capacity` means it is
|
|
73
|
+
# there and busy. The deprecation check reads this to tell a
|
|
74
|
+
# retirement apart from a rate limit.
|
|
75
|
+
"failure",
|
|
76
|
+
# The provider's own words for the worst failure, first 300 chars.
|
|
77
|
+
# Without them `glm-5-3` sat as an unexplained `other` for a month
|
|
78
|
+
# when the message was a plain "404 page not found".
|
|
79
|
+
"detail"]
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def percentile(values: list[float], pct: float) -> float:
|
|
83
|
+
"""Nearest-rank percentile.
|
|
84
|
+
|
|
85
|
+
`statistics.quantiles` interpolates, which invents a value that was never
|
|
86
|
+
measured. With n=5 that is misleading, so this returns a real observation.
|
|
87
|
+
"""
|
|
88
|
+
if not values:
|
|
89
|
+
return 0.0
|
|
90
|
+
ordered = sorted(values)
|
|
91
|
+
k = max(0, min(len(ordered) - 1, int(round(pct * (len(ordered) - 1)))))
|
|
92
|
+
return ordered[k]
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def measure(binary: str, model: str, label: str, prompt: str, n: int,
|
|
96
|
+
timeout: int) -> dict:
|
|
97
|
+
latencies, rates = [], []
|
|
98
|
+
counts = {"answered": 0, "refused": 0, "error": 0, "timeout": 0}
|
|
99
|
+
failures: list[str] = []
|
|
100
|
+
details: dict[str, str] = {}
|
|
101
|
+
for i in range(1, n + 1):
|
|
102
|
+
body, seconds = ask_once(binary, model, prompt, timeout)
|
|
103
|
+
# A one-word reply is the CORRECT answer to the tiny prompt, so
|
|
104
|
+
# only a genuinely empty one counts as a failure here.
|
|
105
|
+
kind = (outcome(body, min_length=1) or ("answered", ""))[0]
|
|
106
|
+
counts[kind] += 1
|
|
107
|
+
# ONLY classify an actual non-answer. `failure_class` uses the
|
|
108
|
+
# default minimum reply length, so a correct one-word answer to the
|
|
109
|
+
# tiny prompt looked like an empty response and was filed as a
|
|
110
|
+
# failure -- on models that answered 3 of 3.
|
|
111
|
+
if kind != "answered" and (why := failure_class(body)) is not None:
|
|
112
|
+
failures.append(why)
|
|
113
|
+
details.setdefault(why, " ".join(body.split())[:300])
|
|
114
|
+
_learn(model, body, "perf") # a stated context window, rate limit or wait
|
|
115
|
+
# Only a real answer times anything meaningful. A refusal is fast
|
|
116
|
+
# because it is short, and averaging that in would flatter the model.
|
|
117
|
+
if kind == "answered":
|
|
118
|
+
latencies.append(seconds)
|
|
119
|
+
# Throughput ONLY from the large prompt. Six characters over ten
|
|
120
|
+
# seconds is 0.5 ch/s, which reads as a catastrophically slow
|
|
121
|
+
# model and is really just connection overhead with a one-word
|
|
122
|
+
# answer attached. A number that invites the wrong reading is
|
|
123
|
+
# worse than no number.
|
|
124
|
+
if seconds > 0 and label == "large":
|
|
125
|
+
rates.append(len(body) / seconds)
|
|
126
|
+
print(f" {model} {label} [{i}/{n}] {kind} {seconds:.1f}s", flush=True)
|
|
127
|
+
worst = next((c for c in FAILURE_CLASSES if c in failures), "")
|
|
128
|
+
return {
|
|
129
|
+
"timestamp": dt.datetime.now().isoformat(timespec="seconds"),
|
|
130
|
+
"model": model, "prompt": label, "n": n,
|
|
131
|
+
"p50_s": round(percentile(latencies, 0.50), 2),
|
|
132
|
+
"p95_s": round(percentile(latencies, 0.95), 2),
|
|
133
|
+
"min_s": round(min(latencies), 2) if latencies else 0.0,
|
|
134
|
+
"max_s": round(max(latencies), 2) if latencies else 0.0,
|
|
135
|
+
"chars_per_s": round(statistics.median(rates), 1) if rates else 0.0,
|
|
136
|
+
# The worst class seen: structural beats capacity beats timeout, so a
|
|
137
|
+
# model that failed structurally even once is not filed as merely busy.
|
|
138
|
+
# ORDER COMES FROM `suite/run.py`, not from a copy kept here -- a copy
|
|
139
|
+
# is what silently dropped `paywalled` and wrote a blank failure.
|
|
140
|
+
"failure": worst,
|
|
141
|
+
"detail": details.get(worst, ""),
|
|
142
|
+
**counts,
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def append(rows: list[dict]) -> None:
|
|
147
|
+
"""Append, MIGRATING the file first if its header is out of date.
|
|
148
|
+
|
|
149
|
+
A column was added to FIELDS while a run was in flight. The running process
|
|
150
|
+
kept the old header, and the next append would have written fourteen values
|
|
151
|
+
under thirteen headings — every row silently shifted by one, which is worse
|
|
152
|
+
than a crash because the file still parses. History is only worth keeping
|
|
153
|
+
if it cannot quietly rot.
|
|
154
|
+
"""
|
|
155
|
+
if HISTORY.exists():
|
|
156
|
+
with HISTORY.open(encoding="utf-8", newline="") as fh:
|
|
157
|
+
existing = list(csv.DictReader(fh))
|
|
158
|
+
header = (csv.reader(HISTORY.open(encoding="utf-8"))
|
|
159
|
+
.__next__() if existing else FIELDS)
|
|
160
|
+
if header != FIELDS:
|
|
161
|
+
with HISTORY.open("w", encoding="utf-8", newline="") as fh:
|
|
162
|
+
writer = csv.DictWriter(fh, fieldnames=FIELDS)
|
|
163
|
+
writer.writeheader()
|
|
164
|
+
writer.writerows({k: r.get(k, "") for k in FIELDS}
|
|
165
|
+
for r in existing)
|
|
166
|
+
|
|
167
|
+
new = not HISTORY.exists()
|
|
168
|
+
with HISTORY.open("a", encoding="utf-8", newline="") as fh:
|
|
169
|
+
writer = csv.DictWriter(fh, fieldnames=FIELDS)
|
|
170
|
+
if new:
|
|
171
|
+
writer.writeheader()
|
|
172
|
+
writer.writerows(rows)
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def trend(model: str, label: str) -> str:
|
|
176
|
+
"""Compare against this model's previous reading, if there is one."""
|
|
177
|
+
if not HISTORY.exists():
|
|
178
|
+
return ""
|
|
179
|
+
prior = [r for r in csv.DictReader(HISTORY.open(encoding="utf-8"))
|
|
180
|
+
if r["model"] == model and r["prompt"] == label]
|
|
181
|
+
if len(prior) < 2:
|
|
182
|
+
return ""
|
|
183
|
+
now, before = float(prior[-1]["p50_s"]), float(prior[-2]["p50_s"])
|
|
184
|
+
if before == 0:
|
|
185
|
+
return ""
|
|
186
|
+
change = (now - before) / before * 100
|
|
187
|
+
if abs(change) < 15:
|
|
188
|
+
return " (steady)"
|
|
189
|
+
return f" ({change:+.0f}% vs {prior[-2]['timestamp'][:10]})"
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
def main() -> None:
|
|
193
|
+
ap = argparse.ArgumentParser(description=__doc__)
|
|
194
|
+
ap.add_argument("--model", action="append", dest="models")
|
|
195
|
+
ap.add_argument("-n", type=int, default=5,
|
|
196
|
+
help="samples per prompt size; more is better and slower")
|
|
197
|
+
ap.add_argument("--timeout", type=int, default=180)
|
|
198
|
+
ap.add_argument("--tiny-only", action="store_true",
|
|
199
|
+
help="skip the large prompt (a quick availability check)")
|
|
200
|
+
ap.add_argument("--client", choices=("llm", "builtin"),
|
|
201
|
+
help="which model client makes the calls (metrics/client.py);"
|
|
202
|
+
" default: BENCH_CLIENT, else llm")
|
|
203
|
+
args = ap.parse_args()
|
|
204
|
+
if args.client:
|
|
205
|
+
# ask_once reads it per call; set here because a scheduled task's
|
|
206
|
+
# action can pass arguments but not environment variables.
|
|
207
|
+
os.environ["BENCH_CLIENT"] = args.client
|
|
208
|
+
|
|
209
|
+
# Every registered model, derived from the yaml — a perf run that
|
|
210
|
+
# skipped a model would hide exactly the outage it exists to find.
|
|
211
|
+
# Except the AD HOC ones: they are capped or expensive, and a daily
|
|
212
|
+
# probe would spend the cap on a latency number. Measured only when
|
|
213
|
+
# named with --model.
|
|
214
|
+
held = ad_hoc_models()
|
|
215
|
+
models = args.models or [m for m in registered_models() if m not in held]
|
|
216
|
+
if not args.models and held:
|
|
217
|
+
print(f"held back (ad hoc — name with --model to measure): "
|
|
218
|
+
f"{', '.join(sorted(held))}")
|
|
219
|
+
prompts = [("tiny", TINY)] if args.tiny_only else [("tiny", TINY),
|
|
220
|
+
("large", LARGE)]
|
|
221
|
+
binary = llm_binary()
|
|
222
|
+
print(f"{len(models)} models x {len(prompts)} prompts x {args.n} samples")
|
|
223
|
+
print(f"large prompt is {len(LARGE):,} chars\n")
|
|
224
|
+
|
|
225
|
+
# OpenRouter's `:free` models share ONE daily budget — 50 requests without
|
|
226
|
+
# credits — so a full weekly sweep at n=3 spends 54 on them alone and the
|
|
227
|
+
# last few get throttled. That is recorded honestly as `capacity` rather
|
|
228
|
+
# than as a fault, but it still means their numbers are measuring the
|
|
229
|
+
# quota. One sample each keeps the whole free tier inside the budget with
|
|
230
|
+
# room to spare, at the cost of no percentile spread for those models.
|
|
231
|
+
served = _registered_names()
|
|
232
|
+
rows = []
|
|
233
|
+
for model in models:
|
|
234
|
+
n = 1 if ":free" in served.get(model, "") else args.n
|
|
235
|
+
for label, prompt in prompts:
|
|
236
|
+
rows.append(measure(binary, model, label, prompt, n,
|
|
237
|
+
args.timeout))
|
|
238
|
+
try:
|
|
239
|
+
sys.path.insert(0, str(ROOT / "metrics"))
|
|
240
|
+
from cost import note_benchmark_calls
|
|
241
|
+
for row in rows:
|
|
242
|
+
note_benchmark_calls(row["model"], row["n"])
|
|
243
|
+
except Exception: # noqa: BLE001
|
|
244
|
+
pass
|
|
245
|
+
|
|
246
|
+
append(rows)
|
|
247
|
+
|
|
248
|
+
print(f"\n{'model':<24} {'prompt':<7} {'p50':>7} {'p95':>7} {'ch/s':>8}"
|
|
249
|
+
f" outcomes")
|
|
250
|
+
for r in rows:
|
|
251
|
+
oc = (f"{r['answered']}a/{r['refused']}r/{r['error']}e/{r['timeout']}t")
|
|
252
|
+
print(f"{r['model']:<24} {r['prompt']:<7} {r['p50_s']:>7.1f} "
|
|
253
|
+
f"{r['p95_s']:>7.1f} {r['chars_per_s']:>8.1f} {oc}"
|
|
254
|
+
+ trend(r["model"], r["prompt"]))
|
|
255
|
+
print(f"\nappended {len(rows)} rows to {HISTORY}")
|
|
256
|
+
print("These numbers measure the SERVING PATH, not the models. "
|
|
257
|
+
"One reading is weather; the history is climate.")
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
if __name__ == "__main__":
|
|
261
|
+
main()
|