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/__init__.py +6 -0
- llmperfscope/__main__.py +5 -0
- llmperfscope/adapters.py +396 -0
- llmperfscope/analyze.py +598 -0
- llmperfscope/api.py +202 -0
- llmperfscope/capabilities.py +83 -0
- llmperfscope/cli.py +502 -0
- llmperfscope/compare.py +232 -0
- llmperfscope/container.py +79 -0
- llmperfscope/cpuprof.py +238 -0
- llmperfscope/diagnose.py +525 -0
- llmperfscope/driver.py +361 -0
- llmperfscope/figures.py +1096 -0
- llmperfscope/gpu.py +277 -0
- llmperfscope/html.py +320 -0
- llmperfscope/machine.py +313 -0
- llmperfscope/markers.py +107 -0
- llmperfscope/native/cpuprof.c +274 -0
- llmperfscope/nsys.py +287 -0
- llmperfscope/procfs.py +165 -0
- llmperfscope/report.py +264 -0
- llmperfscope/runner.py +567 -0
- llmperfscope/sampler.py +237 -0
- llmperfscope/share.py +67 -0
- llmperfscope/system.py +223 -0
- llmperfscope-0.1.0.dist-info/METADATA +207 -0
- llmperfscope-0.1.0.dist-info/RECORD +31 -0
- llmperfscope-0.1.0.dist-info/WHEEL +5 -0
- llmperfscope-0.1.0.dist-info/entry_points.txt +2 -0
- llmperfscope-0.1.0.dist-info/licenses/LICENSE +190 -0
- llmperfscope-0.1.0.dist-info/top_level.txt +1 -0
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)}
|