poolprobe 1.0.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.
poolprobe/bench.py ADDED
@@ -0,0 +1,368 @@
1
+ """Latency and throughput, with the measurement error stated instead of hidden.
2
+
3
+ A benchmark that derives tokens/second from SSE chunk counts is lying: chunk
4
+ boundaries are a transport artifact, not a token count. One live route (a
5
+ Claude-class model behind a self-hosted gateway) produced five chunks for a
6
+ complete answer and was reported at 2.2 tok/s while answering perfectly.
7
+
8
+ So every result records *where its token number came from* and refuses to
9
+ extrapolate:
10
+
11
+ ``usage``
12
+ ``usage.completion_tokens`` from the reply. Exact. The only source that may
13
+ be turned into a tok/s figure without a caveat.
14
+ ``chunks``
15
+ Streaming with no ``usage`` block. The count is a hard lower bound and is
16
+ flagged as such everywhere it is displayed.
17
+ ``none``
18
+ No evidence. ``tok_s`` stays ``0.0`` rather than being guessed, because a
19
+ plausible-looking wrong number gets copied into someone's model ranking.
20
+ """
21
+
22
+ from __future__ import annotations
23
+
24
+ import json
25
+ import statistics
26
+ import time
27
+ import urllib.error
28
+ import urllib.request
29
+ from collections.abc import Callable, Sequence
30
+ from dataclasses import dataclass
31
+
32
+ from . import taxonomy
33
+ from .probe import (
34
+ DEFAULT_TIMEOUT,
35
+ Gateway,
36
+ _flatten,
37
+ parallel_map,
38
+ )
39
+
40
+ #: A deterministic amount of work. "Say hello" measures the gateway's queue, not
41
+ #: the model; a fixed enumeration gives every route the same job and produces a
42
+ #: token count large enough that timing noise stays in the second decimal.
43
+ DEFAULT_BENCH_PROMPT = "Count from 1 to 60, one number per line."
44
+
45
+ DEFAULT_BENCH_MAX_TOKENS = 256
46
+
47
+ #: Deliberately lower than the sweep default. Parallel requests contend inside
48
+ #: the gateway, so a wide bench measures its own congestion; 4 keeps queueing
49
+ #: noise small while still finishing a 60-model pool in reasonable time.
50
+ DEFAULT_THREADS = 4
51
+
52
+
53
+ @dataclass
54
+ class BenchResult:
55
+ """One measured model. ``token_source`` says how much to trust ``tok_s``."""
56
+
57
+ model: str
58
+ provider: str = ""
59
+ ok: bool = False
60
+ status: int = 0
61
+ latency_s: float = 0.0
62
+ ttft_s: float = 0.0
63
+ tokens: int = 0
64
+ tok_s: float = 0.0
65
+ token_source: str = "none" # usage | chunks | none
66
+ verdict: str = ""
67
+ kind: str = ""
68
+ error: str = ""
69
+
70
+ @property
71
+ def tok_s_exact(self) -> bool:
72
+ """True only when the token count came from the gateway's own usage."""
73
+ return self.token_source == "usage"
74
+
75
+ def as_dict(self) -> dict[str, object]:
76
+ return {
77
+ "model": self.model,
78
+ "provider": self.provider,
79
+ "ok": self.ok,
80
+ "status": self.status,
81
+ "latency_s": round(self.latency_s, 2),
82
+ "ttft_s": round(self.ttft_s, 2),
83
+ "tokens": self.tokens,
84
+ "tok_s": round(self.tok_s, 2),
85
+ "token_source": self.token_source,
86
+ "tok_s_exact": self.tok_s_exact,
87
+ "verdict": self.verdict,
88
+ "kind": self.kind,
89
+ "error": self.error,
90
+ }
91
+
92
+
93
+ def bench_one(
94
+ gateway: Gateway,
95
+ model: str,
96
+ *,
97
+ prompt: str = DEFAULT_BENCH_PROMPT,
98
+ max_tokens: int = DEFAULT_BENCH_MAX_TOKENS,
99
+ timeout: float = DEFAULT_TIMEOUT,
100
+ stream: bool = False,
101
+ provider: str = "",
102
+ repeat: int = 1,
103
+ ) -> BenchResult:
104
+ """Measure one model, optionally ``repeat`` times, keeping the best truth.
105
+
106
+ With ``repeat > 1`` the reported latency is the **minimum** and the reported
107
+ throughput the **median** of the successful samples: the minimum is the
108
+ closest observation to an unloaded route, the median resists one lucky sample
109
+ inflating the ranking. Failed samples are never averaged into a success — a
110
+ dead attempt is not a slow one.
111
+ """
112
+ repeat = max(1, int(repeat))
113
+ samples = [
114
+ _sample(
115
+ gateway,
116
+ model,
117
+ prompt=prompt,
118
+ max_tokens=max_tokens,
119
+ timeout=timeout,
120
+ stream=stream,
121
+ provider=provider,
122
+ )
123
+ for _ in range(repeat)
124
+ ]
125
+ good = [sample for sample in samples if sample.ok]
126
+ if not good:
127
+ # A dead attempt is not a slow one: report the failure as measured,
128
+ # never averaged into a latency.
129
+ return samples[-1]
130
+
131
+ # Exact beats approximate: if any sample carried usage, the route does emit
132
+ # it, and that is the only source worth ranking on.
133
+ source = (
134
+ "usage"
135
+ if any(sample.token_source == "usage" for sample in good)
136
+ else "chunks"
137
+ if any(sample.token_source == "chunks" for sample in good)
138
+ else "none"
139
+ )
140
+ # Median over samples sharing the winning source. Folding chunk-counted and
141
+ # usage-counted throughputs into one median yields a number that describes
142
+ # neither measurement.
143
+ comparable = [sample for sample in good if sample.token_source == source] or good
144
+ throughputs = [sample.tok_s for sample in comparable if sample.tok_s > 0]
145
+ firsts = [sample.ttft_s for sample in good if sample.ttft_s > 0]
146
+
147
+ winner = min(good, key=lambda sample: sample.latency_s)
148
+ winner.latency_s = min(sample.latency_s for sample in good)
149
+ winner.ttft_s = min(firsts) if firsts else 0.0
150
+ winner.tokens = max(sample.tokens for sample in comparable)
151
+ winner.token_source = source
152
+ # Not winner.tokens / winner.latency_s: both of those are extremes taken
153
+ # across samples, so their ratio would be a number no sample ever produced.
154
+ winner.tok_s = _median(throughputs)
155
+ return winner
156
+
157
+
158
+ def _sample(
159
+ gateway: Gateway,
160
+ model: str,
161
+ *,
162
+ prompt: str,
163
+ max_tokens: int,
164
+ timeout: float,
165
+ stream: bool,
166
+ provider: str,
167
+ ) -> BenchResult:
168
+ """A single request. Own transport: :func:`probe.probe` discards ``usage``."""
169
+ body: dict[str, object] = {
170
+ "model": model,
171
+ "messages": [{"role": "user", "content": prompt}],
172
+ "max_tokens": max_tokens,
173
+ "stream": stream,
174
+ }
175
+ if stream:
176
+ # Ask for the final usage chunk. Gateways that support it make the
177
+ # measurement exact; the ones that ignore it fall back to chunk counting.
178
+ body["stream_options"] = {"include_usage": True}
179
+ request = urllib.request.Request(
180
+ gateway.chat_url,
181
+ data=json.dumps(body).encode(),
182
+ headers=gateway.headers(json_body=True),
183
+ )
184
+
185
+ result = BenchResult(model=model, provider=provider)
186
+ started = time.monotonic()
187
+ try:
188
+ with urllib.request.urlopen(request, timeout=timeout) as response:
189
+ result.status = getattr(response, "status", 200) or 200
190
+ if stream:
191
+ _read_stream(response, result, started)
192
+ else:
193
+ _read_json(response.read(), result)
194
+ except urllib.error.HTTPError as exc:
195
+ result.status = exc.code
196
+ try:
197
+ detail = exc.read().decode("utf-8", "replace")
198
+ except Exception:
199
+ detail = str(exc.reason or exc)
200
+ result.error = _flatten(detail)
201
+ result.verdict = taxonomy.ERR
202
+ except Exception as exc:
203
+ result.error = _flatten(f"{type(exc).__name__}: {exc}")
204
+ result.verdict = (
205
+ taxonomy.TIMEOUT if taxonomy._is_timeout(result.error) else taxonomy.ERR
206
+ )
207
+ finally:
208
+ result.latency_s = time.monotonic() - started
209
+
210
+ # Throughput is derived here, once, from the final latency. Computing it
211
+ # inside a reader and then overwriting latency_s in the finally block leaves
212
+ # tok/s describing a duration the result no longer reports.
213
+ result.tok_s = (
214
+ result.tokens / result.latency_s
215
+ if result.tokens and result.latency_s > 0
216
+ else 0.0
217
+ )
218
+ result.kind = taxonomy.classify_death(result.verdict, result.error, result.status)
219
+ result.ok = result.verdict not in taxonomy.DEAD
220
+ return result
221
+
222
+
223
+ def _read_json(raw: bytes, result: BenchResult) -> None:
224
+ """Non-streaming path: tokens come from ``usage`` or not at all."""
225
+ try:
226
+ body = json.loads(raw.decode("utf-8", "replace"))
227
+ except Exception as exc:
228
+ result.error = _flatten(f"non-JSON body: {type(exc).__name__}: {raw[:80]!r}")
229
+ result.verdict = taxonomy.ERR
230
+ return
231
+ if not isinstance(body, dict):
232
+ result.error = _flatten(f"reply is a {type(body).__name__}, not an object")
233
+ result.verdict = taxonomy.ERR
234
+ return
235
+
236
+ verdict, _note = taxonomy.classify(result.status, body)
237
+ result.verdict = verdict
238
+ usage = body.get("usage") if isinstance(body.get("usage"), dict) else {}
239
+ completion = usage.get("completion_tokens")
240
+ if isinstance(completion, int) and completion > 0:
241
+ result.tokens = completion
242
+ result.token_source = "usage"
243
+ else:
244
+ # No usage block. Falling back to len(content) would report characters
245
+ # as tokens, so the honest answer is "not measured".
246
+ result.token_source = "none"
247
+
248
+
249
+ def _read_stream(response: object, result: BenchResult, started: float) -> None:
250
+ """Streaming path: time-to-first-token is exact, token count usually is not.
251
+
252
+ ``ttft`` is measured on the first chunk that actually carries text — not the
253
+ first byte, which for many gateways is an immediate role-only delta and
254
+ would report a time-to-first-token of a few milliseconds for a route that
255
+ then thinks for ten seconds.
256
+ """
257
+ chunks = 0
258
+ usage_tokens = 0
259
+ text_seen = False
260
+ try:
261
+ for raw_line in response: # type: ignore[union-attr]
262
+ line = raw_line.decode("utf-8", "replace").strip()
263
+ if not line or line.startswith(":"):
264
+ continue # SSE comment / keepalive
265
+ if not line.startswith("data:"):
266
+ continue
267
+ payload = line[len("data:") :].strip()
268
+ if payload == "[DONE]":
269
+ break
270
+ try:
271
+ chunk = json.loads(payload)
272
+ except Exception:
273
+ continue # a half-flushed frame is not a failure of the model
274
+ if not isinstance(chunk, dict):
275
+ continue
276
+
277
+ usage = chunk.get("usage")
278
+ if isinstance(usage, dict):
279
+ completion = usage.get("completion_tokens")
280
+ if isinstance(completion, int) and completion > 0:
281
+ usage_tokens = completion
282
+
283
+ try:
284
+ delta = (chunk.get("choices") or [{}])[0].get("delta") or {}
285
+ piece = (delta.get("content") or delta.get("reasoning_content") or "")
286
+ except Exception:
287
+ piece = ""
288
+ if isinstance(piece, str) and piece.strip():
289
+ chunks += 1
290
+ if not text_seen:
291
+ text_seen = True
292
+ result.ttft_s = time.monotonic() - started
293
+ except Exception as exc:
294
+ # Mid-stream disconnect after some text: the route works, the measurement
295
+ # is partial. Keep what was counted and record the interruption.
296
+ result.error = _flatten(f"stream interrupted: {type(exc).__name__}: {exc}")
297
+
298
+ if usage_tokens:
299
+ result.tokens = usage_tokens
300
+ result.token_source = "usage"
301
+ elif chunks:
302
+ result.tokens = chunks
303
+ result.token_source = "chunks"
304
+ else:
305
+ result.token_source = "none"
306
+
307
+ result.verdict = taxonomy.OK if text_seen or usage_tokens else taxonomy.EMPTY
308
+
309
+
310
+ def bench_many(
311
+ gateway: Gateway,
312
+ models: Sequence[str],
313
+ *,
314
+ threads: int = DEFAULT_THREADS,
315
+ prompt: str = DEFAULT_BENCH_PROMPT,
316
+ max_tokens: int = DEFAULT_BENCH_MAX_TOKENS,
317
+ timeout: float = DEFAULT_TIMEOUT,
318
+ stream: bool = False,
319
+ repeat: int = 1,
320
+ providers: dict[str, str] | None = None,
321
+ on_result: Callable[[BenchResult], None] | None = None,
322
+ ) -> list[BenchResult]:
323
+ """Benchmark a pool. ``on_result`` fires from worker threads — lock it."""
324
+ lookup = providers or {}
325
+
326
+ def work(model: str) -> BenchResult:
327
+ measured = bench_one(
328
+ gateway,
329
+ model,
330
+ prompt=prompt,
331
+ max_tokens=max_tokens,
332
+ timeout=timeout,
333
+ stream=stream,
334
+ provider=lookup.get(model, ""),
335
+ repeat=repeat,
336
+ )
337
+ if on_result is not None:
338
+ on_result(measured)
339
+ return measured
340
+
341
+ return parallel_map(models, work, threads=threads)
342
+
343
+
344
+ def summarize(results: Sequence[BenchResult]) -> dict[str, object]:
345
+ """Roll a bench run up into the numbers a report header needs."""
346
+ alive = [item for item in results if item.ok]
347
+ exact = [item for item in alive if item.tok_s_exact]
348
+ latencies = sorted(item.latency_s for item in alive)
349
+
350
+ # Ranked by latency, which is always measured exactly. Ranking by tok/s
351
+ # would put chunk-counted routes next to usage-counted ones and imply the
352
+ # two are comparable — they are not.
353
+ fastest = sorted(alive, key=lambda item: item.latency_s)[:5]
354
+ slowest = sorted(alive, key=lambda item: item.latency_s, reverse=True)[:5]
355
+
356
+ return {
357
+ "total": len(results),
358
+ "alive": len(alive),
359
+ "dead": len(results) - len(alive),
360
+ "with_exact_tokens": len(exact),
361
+ "median_latency_s": round(_median(latencies), 2),
362
+ "fastest": [item.as_dict() for item in fastest],
363
+ "slowest": [item.as_dict() for item in slowest],
364
+ }
365
+
366
+
367
+ def _median(values: Sequence[float]) -> float:
368
+ return float(statistics.median(values)) if values else 0.0