llmperfscope 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.
llmperfscope/driver.py ADDED
@@ -0,0 +1,361 @@
1
+ """Load driver for any OpenAI-compatible server (llama-server, vLLM, SGLang, TGI, Ollama, ...). The same client measures the
2
+ same things everywhere, which is what makes numbers comparable across engines: time to first token, the time between
3
+ tokens, and throughput under a given concurrency."""
4
+
5
+ from __future__ import annotations
6
+
7
+ import http.client
8
+ import json
9
+ import math
10
+ import queue
11
+ import random
12
+ import re
13
+ import threading
14
+ import time
15
+ import urllib.error
16
+ import urllib.parse
17
+ import urllib.request
18
+
19
+
20
+ def percentile(values, q):
21
+ """Nearest rank."""
22
+ if not values:
23
+ return None
24
+ ordered = sorted(values)
25
+ index = max(0, min(len(ordered) - 1, math.ceil(q / 100 * len(ordered)) - 1))
26
+ return ordered[index]
27
+
28
+
29
+ def distribution(values, unit=1000.0, digits=2):
30
+ """values in seconds -> mean and percentiles in ms by default."""
31
+ if not values:
32
+ return None
33
+ return {
34
+ "n": len(values),
35
+ "mean": round(sum(values) / len(values) * unit, digits),
36
+ "p50": round(percentile(values, 50) * unit, digits),
37
+ "p90": round(percentile(values, 90) * unit, digits),
38
+ "p99": round(percentile(values, 99) * unit, digits),
39
+ "max": round(max(values) * unit, digits),
40
+ }
41
+
42
+
43
+ def load_prompts(path, filters=None):
44
+ """JSONL with `prompt` (string) or `messages` (chat) per row; extra columns can be used in `filters`."""
45
+ rows = []
46
+ with open(path, encoding="utf-8") as f:
47
+ for line in f:
48
+ line = line.strip()
49
+ if not line:
50
+ continue
51
+ row = json.loads(line)
52
+ if not ("prompt" in row or "messages" in row):
53
+ raise ValueError(f"{path}: row without prompt or messages")
54
+ if all(str(row.get(k)) == v for k, v in (filters or {}).items()):
55
+ rows.append(row)
56
+ return rows
57
+
58
+
59
+ # About 512 tokens of neutral English, for when no prompt file is given. Length scales by repetition.
60
+ _FILLER = ("The history of measurement is a history of agreeing on what a number means. A careful observer records what was "
61
+ "done, under which conditions, and how much the result varies when nothing changes. ")
62
+
63
+
64
+ def synthetic_prompts(words, count):
65
+ unit = _FILLER.split()
66
+ return [{"prompt": f"[{i}] " + " ".join((unit * (words // len(unit) + 1))[:words]) + "\nSummarize the text above."}
67
+ for i in range(count)]
68
+
69
+
70
+ def _text_of(choice):
71
+ """The new text in a streamed choice. Reasoning models stream into a separate field; those are tokens too."""
72
+ if "text" in choice:
73
+ return choice["text"]
74
+ delta = choice.get("delta") or {}
75
+ return delta.get("content") or delta.get("reasoning_content") or delta.get("reasoning") or ""
76
+
77
+
78
+ class Client:
79
+ def __init__(self, base_url, model=None, max_tokens=64, extra_body=None, timeout_s=1800.0, ignore_eos=True, api_key=None):
80
+ parsed = urllib.parse.urlsplit(base_url)
81
+ self.host = parsed.hostname
82
+ self.port = parsed.port or (443 if parsed.scheme == "https" else 80)
83
+ self.https = parsed.scheme == "https"
84
+ self.path = parsed.path.rstrip("/")
85
+ self.model = model
86
+ self.max_tokens = max_tokens
87
+ self.extra_body = extra_body or {}
88
+ self.timeout_s = timeout_s
89
+ self.ignore_eos = ignore_eos
90
+ self.api_key = api_key
91
+
92
+ def _connection(self):
93
+ kind = http.client.HTTPSConnection if self.https else http.client.HTTPConnection
94
+ return kind(self.host, self.port, timeout=self.timeout_s)
95
+
96
+ def discover_model(self):
97
+ with urllib.request.urlopen(f"{'https' if self.https else 'http'}://{self.host}:{self.port}{self.path}/models",
98
+ timeout=30) as response:
99
+ data = json.load(response)
100
+ self.model = data["data"][0]["id"]
101
+ return self.model
102
+
103
+ def request(self, row, nonce=""):
104
+ """One streamed request. Returns timing per token as seen by the client. A nonce in front of the prompt keeps a server's prefix
105
+ cache from answering, so prefill is really measured; without it the second load level would mostly hit the cache."""
106
+ body = {"stream": True, "stream_options": {"include_usage": True}, "max_tokens": row.get("max_tokens", self.max_tokens),
107
+ "temperature": 0}
108
+ if self.model:
109
+ body["model"] = self.model
110
+ if self.ignore_eos:
111
+ body["ignore_eos"] = True
112
+ if "messages" in row:
113
+ body["messages"] = _with_nonce(row["messages"], nonce)
114
+ path = f"{self.path}/chat/completions"
115
+ else:
116
+ body["prompt"] = nonce + row["prompt"]
117
+ path = f"{self.path}/completions"
118
+ body.update(self.extra_body)
119
+ payload = json.dumps(body).encode()
120
+ headers = {"Content-Type": "application/json", "Accept": "text/event-stream"}
121
+ if self.api_key:
122
+ headers["Authorization"] = f"Bearer {self.api_key}"
123
+
124
+ result = {"id": row.get("id"), "ok": False, "chunks": 0, "itl_s": []}
125
+ connection = self._connection()
126
+ t_send = time.monotonic()
127
+ result["t_send"] = t_send
128
+ try:
129
+ connection.request("POST", path, body=payload, headers=headers)
130
+ response = connection.getresponse()
131
+ if response.status != 200:
132
+ result["error"] = f"HTTP {response.status}: {response.read(300).decode('utf-8', 'replace')}"
133
+ return result
134
+ t_previous = None
135
+ t_first_event = None
136
+ finish = None
137
+ while True:
138
+ line = response.readline()
139
+ if not line:
140
+ break
141
+ t_now = time.monotonic()
142
+ if not line.startswith(b"data:"):
143
+ continue
144
+ data = line[5:].strip()
145
+ if data == b"[DONE]":
146
+ break
147
+ event = json.loads(data)
148
+ if event.get("usage"):
149
+ result["usage"] = event["usage"]
150
+ cached = (event["usage"].get("prompt_tokens_details") or {}).get("cached_tokens")
151
+ if cached is not None:
152
+ result["cached_tokens"] = cached
153
+ if event.get("timings"):
154
+ result["server_timings"] = event["timings"]
155
+ choices = event.get("choices") or []
156
+ if not choices:
157
+ continue
158
+ finish = choices[0].get("finish_reason") or finish
159
+ if t_first_event is None:
160
+ t_first_event = t_now
161
+ if _text_of(choices[0]):
162
+ result["chunks"] += 1
163
+ if t_previous is None:
164
+ result["ttft_s"] = t_now - t_send
165
+ else:
166
+ result["itl_s"].append(t_now - t_previous)
167
+ t_previous = t_now
168
+ result["t_done"] = time.monotonic()
169
+ result["finish_reason"] = finish
170
+ result["ok"] = result["chunks"] > 0
171
+ if not result["ok"] and (result.get("usage") or {}).get("completion_tokens") and t_first_event is not None:
172
+ # The tokens exist but are not shown, as with a model's hidden reasoning. No per-token times, but the request worked.
173
+ result["ok"] = True
174
+ result["hidden_tokens"] = True
175
+ result["ttft_s"] = t_first_event - t_send
176
+ if not result["ok"]:
177
+ result["error"] = "the stream ended without a token"
178
+ except (OSError, http.client.HTTPException, ValueError) as e:
179
+ result["error"] = repr(e)
180
+ finally:
181
+ connection.close()
182
+ return result
183
+
184
+
185
+ def _with_nonce(messages, nonce):
186
+ if not nonce:
187
+ return messages
188
+ messages = [dict(m) for m in messages]
189
+ for message in messages:
190
+ if isinstance(message.get("content"), str):
191
+ message["content"] = nonce + message["content"]
192
+ break
193
+ return messages
194
+
195
+
196
+ def run_load(client, rows, concurrency=1, repeat=1, seed=0, emit_each=None, level="", unique=True):
197
+ """Closed loop: `concurrency` clients, each sending its next request when the previous one finishes."""
198
+ work = [row for _ in range(repeat) for row in rows]
199
+ random.Random(seed).shuffle(work)
200
+ todo = queue.Queue()
201
+ for i, row in enumerate(work):
202
+ todo.put((row, f"[{level}.{i}] " if unique else ""))
203
+ results = []
204
+ lock = threading.Lock()
205
+
206
+ def worker():
207
+ while True:
208
+ try:
209
+ row, nonce = todo.get_nowait()
210
+ except queue.Empty:
211
+ return
212
+ result = client.request(row, nonce)
213
+ with lock:
214
+ results.append(result)
215
+ if emit_each:
216
+ emit_each(result)
217
+
218
+ t0 = time.monotonic()
219
+ threads = [threading.Thread(target=worker, daemon=True) for _ in range(concurrency)]
220
+ for thread in threads:
221
+ thread.start()
222
+ for thread in threads:
223
+ thread.join()
224
+ return results, time.monotonic() - t0
225
+
226
+
227
+ def summarize_load(results, wall_s):
228
+ """What a client saw. Token counts come from the server's usage when it sends one, otherwise from stream chunks."""
229
+ ok = [r for r in results if r["ok"]]
230
+ out = {"requests": len(results), "failed": len(results) - len(ok), "wall_s": round(wall_s, 3)}
231
+ errors = [r["error"] for r in results if r.get("error")]
232
+ if errors:
233
+ out["first_error"] = errors[0][:300]
234
+ if not ok:
235
+ return out
236
+ completion = prompt = cached = 0
237
+ exact = True
238
+ per_request_decode = []
239
+ per_request_prefill = []
240
+ itl = []
241
+ for r in ok:
242
+ usage = r.get("usage") or {}
243
+ tokens = usage.get("completion_tokens")
244
+ if tokens is None:
245
+ tokens = r["chunks"]
246
+ exact = False
247
+ completion += tokens
248
+ cached += r.get("cached_tokens") or 0
249
+ prompt_tokens = usage.get("prompt_tokens")
250
+ if prompt_tokens:
251
+ prompt += prompt_tokens
252
+ per_request_prefill.append(prompt_tokens / r["ttft_s"])
253
+ itl += r["itl_s"]
254
+ if r["itl_s"]:
255
+ per_request_decode.append(len(r["itl_s"]) / sum(r["itl_s"]))
256
+ hidden = sum(1 for r in ok if r.get("hidden_tokens"))
257
+ if hidden:
258
+ out["requests_with_hidden_tokens"] = hidden
259
+ out.update({
260
+ "completion_tokens": completion,
261
+ "prompt_tokens": prompt,
262
+ "prompt_tokens_cached": cached,
263
+ "tokens_exact": exact,
264
+ "output_tokens_per_s": round(completion / wall_s, 3) if wall_s > 0 else None,
265
+ "ttft_ms": distribution([r["ttft_s"] for r in ok]),
266
+ "itl_ms": distribution(itl),
267
+ "decode_tokens_per_s_mean": round(sum(per_request_decode) / len(per_request_decode), 3) if per_request_decode else None,
268
+ "prefill_tokens_per_s_mean": round(sum(per_request_prefill) / len(per_request_prefill), 3) if per_request_prefill else None,
269
+ })
270
+ return out
271
+
272
+
273
+ def wait_ready(url, is_alive, timeout_s=3600.0, poll_s=0.2):
274
+ """Polls a health URL until it answers 200. Returns the time it took."""
275
+ t0 = time.monotonic()
276
+ while time.monotonic() - t0 < timeout_s:
277
+ if not is_alive():
278
+ raise RuntimeError("the server exited while loading")
279
+ try:
280
+ with urllib.request.urlopen(url, timeout=5) as response:
281
+ if response.status == 200:
282
+ return time.monotonic() - t0
283
+ except (urllib.error.URLError, OSError, http.client.HTTPException):
284
+ pass
285
+ time.sleep(poll_s)
286
+ raise TimeoutError(f"{url} did not become ready in {timeout_s:.0f} s")
287
+
288
+
289
+ def fetch_metrics(url):
290
+ try:
291
+ with urllib.request.urlopen(url, timeout=30) as response:
292
+ return response.read().decode("utf-8", "replace")
293
+ except (urllib.error.URLError, OSError, http.client.HTTPException):
294
+ return None
295
+
296
+
297
+ def pull_ollama_model(base, model):
298
+ """Ollama fetches models over its own API; the stream of progress ends when the download is done."""
299
+ request = urllib.request.Request(f"{base}/api/pull", data=json.dumps({"model": model}).encode(),
300
+ headers={"Content-Type": "application/json"})
301
+ with urllib.request.urlopen(request, timeout=3600) as response:
302
+ for line in response:
303
+ event = json.loads(line)
304
+ if event.get("error"):
305
+ raise RuntimeError(f"ollama pull: {event['error']}")
306
+
307
+
308
+ class GaugeWatcher(threading.Thread):
309
+ """Polls the server's metrics during a load and keeps the peak of every usage gauge. Counters can be read at the end, but how
310
+ full a cache pool got can only be seen while it happens."""
311
+
312
+ WORDS = {"usage", "utilization", "perc", "percent", "ratio", "waiting", "running", "processing", "queue", "pending", "deferred"}
313
+ NOT_GAUGES = ("_total", "_sum", "_count", "_bucket", "_created", "_info")
314
+
315
+ def __init__(self, url, period_s=1.0):
316
+ super().__init__(daemon=True, name="llmperfscope-gauges")
317
+ self.url = url
318
+ self.period_s = period_s
319
+ self.peaks = {}
320
+ self._stop_event = threading.Event()
321
+
322
+ def stop(self):
323
+ self._stop_event.set()
324
+ self.join(timeout=10)
325
+
326
+ def run(self):
327
+ while not self._stop_event.is_set():
328
+ text = fetch_metrics(self.url)
329
+ if text:
330
+ for name, value in parse_prometheus(text).items():
331
+ if self.is_usage_gauge(name):
332
+ self.peaks[name] = max(self.peaks.get(name, value), value)
333
+ self._stop_event.wait(self.period_s)
334
+
335
+ @classmethod
336
+ def is_usage_gauge(cls, name):
337
+ """Whole words only: `ratio` must not match `generation`, and timestamps must not pass for gauges."""
338
+ base = name.split("{")[0].lower()
339
+ if base.endswith(cls.NOT_GAUGES):
340
+ return False
341
+ return bool(cls.WORDS & set(re.split(r"[_:.]", base)))
342
+
343
+
344
+ def parse_prometheus(text):
345
+ """name{labels} -> value."""
346
+ out = {}
347
+ for line in text.splitlines():
348
+ if not line or line.startswith("#"):
349
+ continue
350
+ name, _, value = line.rpartition(" ")
351
+ try:
352
+ out[name] = float(value)
353
+ except ValueError:
354
+ continue
355
+ return out
356
+
357
+
358
+ def metrics_delta(before, after):
359
+ """Counters that moved during the run. Engine-specific names pass through untouched."""
360
+ a, b = parse_prometheus(before or ""), parse_prometheus(after or "")
361
+ return {k: round(b[k] - a.get(k, 0.0), 6) for k in b if b[k] != a.get(k, 0.0)}