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/__init__.py +137 -0
- poolprobe/__main__.py +8 -0
- poolprobe/adapter.py +525 -0
- poolprobe/bench.py +368 -0
- poolprobe/chains.py +414 -0
- poolprobe/cli.py +1008 -0
- poolprobe/probe.py +352 -0
- poolprobe/report.py +1096 -0
- poolprobe/safewrite.py +66 -0
- poolprobe/state.py +272 -0
- poolprobe/taxonomy.py +312 -0
- poolprobe-1.0.0.dist-info/METADATA +259 -0
- poolprobe-1.0.0.dist-info/RECORD +17 -0
- poolprobe-1.0.0.dist-info/WHEEL +5 -0
- poolprobe-1.0.0.dist-info/entry_points.txt +2 -0
- poolprobe-1.0.0.dist-info/licenses/LICENSE +21 -0
- poolprobe-1.0.0.dist-info/top_level.txt +1 -0
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
|