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.
@@ -0,0 +1,6 @@
1
+ """llmperfscope: whole-lifecycle profiler for LLM inference engines."""
2
+
3
+ __version__ = "0.1.0"
4
+
5
+ from .markers import mark # noqa: E402,F401
6
+ from .api import Profile, TokenTimer, mark_tokens, phase # noqa: E402,F401
@@ -0,0 +1,5 @@
1
+ import sys
2
+
3
+ from .cli import main
4
+
5
+ sys.exit(main())
@@ -0,0 +1,396 @@
1
+ """What llmperfscope knows about specific engines. The core works without any of this; an adapter only adds finer phases,
2
+ exact throughput and server defaults. A new engine needs one small class here, or just `--mark 'regex=phase'`.
3
+
4
+ Canonical phase groups, so reports line up across engines:
5
+ startup, load, setup, warmup, prefill, decode, serve, teardown
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import json
11
+ import os
12
+ import re
13
+
14
+ ANSI = re.compile(r"\x1b\[[0-9;]*[A-Za-z]")
15
+ WRAPPERS = {"env", "taskset", "numactl", "timeout", "nice", "ionice", "chrt", "flock", "stdbuf", "setsid", "nohup", "sudo"}
16
+
17
+
18
+ class Adapter:
19
+ name = "generic"
20
+ # Defaults for `llmperfscope serve`: paths on the server, signal to stop it, extra request fields.
21
+ server = None
22
+
23
+ def __init__(self, cmd, marks=()):
24
+ self.cmd = cmd
25
+ self.marks = [(re.compile(regex), phase, group) for regex, phase, group in marks]
26
+ self.report = {}
27
+
28
+ def prepare(self, cmd, env):
29
+ """Last chance to change the command and environment. Everything changed is stored in the report."""
30
+ return cmd, env
31
+
32
+ def on_line(self, stream, t, text, emit):
33
+ """A line of output arrived at CLOCK_MONOTONIC `t`. emit(phase, group=None, t=None, tokens=None, **meta)."""
34
+ for regex, phase, group in self.marks:
35
+ if regex.search(text):
36
+ emit(phase, group=group, t=t)
37
+
38
+ def finalize(self, phases, stdout_path, stderr_path):
39
+ """After exit: parse results, fill in tokens per phase. Returns a dict merged into the report."""
40
+ return {}
41
+
42
+ def model_path(self):
43
+ return None
44
+
45
+ def target_index(self):
46
+ """Index of the real engine in a command like `taskset -c 1 numactl ... engine ...`, for tools inserted before it."""
47
+ return _skip_wrappers(self.cmd)
48
+
49
+ def has_progress_lines(self):
50
+ """True when silence on stdout/stderr really means nothing is happening (the stall watchdog relies on it)."""
51
+ return False
52
+
53
+
54
+ def _skip_wrappers(cmd):
55
+ """Best effort. Options of wrappers are skipped by looking for the first word that is an executable or a known name."""
56
+ i = 0
57
+ while i < len(cmd):
58
+ base = os.path.basename(cmd[i])
59
+ if base in WRAPPERS or cmd[i].startswith("-") or "=" in cmd[i] or _looks_like_wrapper_value(cmd, i):
60
+ i += 1
61
+ continue
62
+ return i
63
+ return 0
64
+
65
+
66
+ def _looks_like_wrapper_value(cmd, i):
67
+ """The value of a wrapper option such as `taskset -c 0-3` or `timeout 600`."""
68
+ if i == 0:
69
+ return False
70
+ previous = cmd[i - 1]
71
+ if previous in ("-c", "-n", "-p", "-k") and os.path.basename(cmd[i]) not in ("sh", "bash"):
72
+ return True
73
+ return re.fullmatch(r"[0-9][0-9smhd.,-]*", cmd[i]) is not None
74
+
75
+
76
+ def _find(cmd, *flags):
77
+ for i, token in enumerate(cmd):
78
+ if token in flags and i + 1 < len(cmd):
79
+ return cmd[i + 1]
80
+ for flag in flags:
81
+ if flag.startswith("--") and token.startswith(flag + "="):
82
+ return token.split("=", 1)[1]
83
+ return None
84
+
85
+
86
+ class LlamaBench(Adapter):
87
+ """llama-bench: `--progress` gives every run's start on stderr, `-o json` gives exact per-rep times on stdout."""
88
+
89
+ name = "llama.cpp:llama-bench"
90
+
91
+ def __init__(self, cmd, marks=()):
92
+ super().__init__(cmd, marks)
93
+ self.first = True
94
+ self.n_runs = 0
95
+
96
+ def prepare(self, cmd, env):
97
+ cmd = list(cmd)
98
+ if "--progress" not in cmd:
99
+ cmd.append("--progress")
100
+ for flag in ("-o", "--output"):
101
+ if flag in cmd:
102
+ cmd[cmd.index(flag) + 1] = "json"
103
+ break
104
+ else:
105
+ cmd += ["-o", "json"]
106
+ return cmd, env
107
+
108
+ def model_path(self):
109
+ return _find(self.cmd, "-m", "--model")
110
+
111
+ def has_progress_lines(self):
112
+ return True
113
+
114
+ def on_line(self, stream, t, text, emit):
115
+ super().on_line(stream, t, text, emit)
116
+ if stream == "stdout":
117
+ if text.lstrip().startswith('"samples_ts"'):
118
+ emit("post_test", group="teardown", t=t)
119
+ return
120
+ m = re.search(r"benchmark (\d+)/(\d+): (.*)", text)
121
+ if not m:
122
+ return
123
+ test, what = int(m.group(1)), m.group(3).strip()
124
+ if what == "starting":
125
+ emit("load" if self.first else "setup", t=t, test=test)
126
+ self.first = False
127
+ elif what == "warmup prompt run":
128
+ emit("warmup_prefill", group="warmup", t=t, test=test)
129
+ elif what == "warmup generation run":
130
+ emit("warmup_decode", group="warmup", t=t, test=test)
131
+ elif what.startswith("depth run"):
132
+ emit("depth", group="setup", t=t, test=test)
133
+ elif what.startswith("prompt run"):
134
+ emit("prefill", t=t, test=test, rep=int(re.search(r"(\d+)/", what).group(1)))
135
+ elif what.startswith("generation run"):
136
+ emit("decode", t=t, test=test, rep=int(re.search(r"(\d+)/", what).group(1)))
137
+
138
+ def finalize(self, phases, stdout_path, stderr_path):
139
+ # Tools wrapping the engine can print around its output, so look for the array instead of assuming the whole file is one.
140
+ try:
141
+ with open(stdout_path, encoding="utf-8", errors="replace") as f:
142
+ text = f.read()
143
+ start = re.search(r"^\[\s*$", text, re.M)
144
+ tests, _ = json.JSONDecoder().raw_decode(text, start.start())
145
+ except (OSError, ValueError, AttributeError):
146
+ return {"notes": ["llama-bench JSON output was not readable (the run may have been cut short)"]}
147
+ summary = []
148
+ prefill, decode = [], []
149
+ for number, test in enumerate(tests, start=1):
150
+ n_prompt, n_gen = test.get("n_prompt", 0), test.get("n_gen", 0)
151
+ kind = "decode" if n_gen and not n_prompt else "prefill" if n_prompt and not n_gen else "mixed"
152
+ entry = {
153
+ "test": number,
154
+ "kind": kind,
155
+ "n_prompt": n_prompt,
156
+ "n_gen": n_gen,
157
+ "n_depth": test.get("n_depth", 0),
158
+ "tokens_per_s": test.get("avg_ts"),
159
+ "stddev": test.get("stddev_ts"),
160
+ "samples_tps": test.get("samples_ts"),
161
+ "samples_ns": test.get("samples_ns"),
162
+ }
163
+ summary.append(entry)
164
+ if kind == "decode":
165
+ decode.append(entry)
166
+ elif kind == "prefill":
167
+ prefill.append(entry)
168
+ for phase in phases:
169
+ if phase.get("meta", {}).get("test") == number:
170
+ if phase["group"] == "prefill":
171
+ phase["tokens"] = n_prompt
172
+ elif phase["group"] == "decode":
173
+ phase["tokens"] = n_gen
174
+ elif phase["name"] == "warmup_decode":
175
+ phase["tokens"] = 0
176
+ info = tests[0] if tests else {}
177
+ out = {
178
+ "tests": summary,
179
+ "engine": {k: info.get(k) for k in ("build_commit", "build_number", "cpu_info", "gpu_info", "backends",
180
+ "model_type", "model_size", "model_n_params", "n_batch", "n_ubatch",
181
+ "n_threads", "n_gpu_layers", "split_mode") if k in info},
182
+ }
183
+ throughput = {}
184
+ if prefill:
185
+ throughput["prefill"] = _merge_tests(prefill)
186
+ if decode:
187
+ throughput["decode"] = _merge_tests(decode)
188
+ out["throughput"] = throughput
189
+ out["consistency"] = _phase_vs_samples(phases, summary)
190
+ return out
191
+
192
+
193
+ def _merge_tests(tests):
194
+ if len(tests) == 1:
195
+ test = tests[0]
196
+ return {"tokens_per_s": test["tokens_per_s"], "stddev": test["stddev"], "samples": test["samples_tps"],
197
+ "tokens_per_run": test["n_prompt"] or test["n_gen"]}
198
+ return {"tests": [{"tokens_per_run": t["n_prompt"] or t["n_gen"], "tokens_per_s": t["tokens_per_s"],
199
+ "stddev": t["stddev"]} for t in tests],
200
+ "tokens_per_s": tests[0]["tokens_per_s"]}
201
+
202
+
203
+ def _phase_vs_samples(phases, tests):
204
+ """Self-check: the time llmperfscope saw for each run against what the engine timed itself. Far from 1 means the phase
205
+ boundaries cannot be trusted."""
206
+ wall = sampled = 0.0
207
+ for test in tests:
208
+ if test["kind"] == "mixed" or not test["samples_ns"]:
209
+ continue
210
+ group = "decode" if test["kind"] == "decode" else "prefill"
211
+ runs = [p for p in phases if p["group"] == group and p.get("meta", {}).get("test") == test["test"]]
212
+ wall += sum(p["t_end"] - p["t"] for p in runs if "t_end" in p)
213
+ sampled += sum(test["samples_ns"][:len(runs)])
214
+ return {"phase_wall_over_engine_time": round(wall / sampled, 4)} if sampled and wall else {}
215
+
216
+
217
+ # Sub-phases of model loading and serving, from llama.cpp's own log lines. The log lines are not an API: when one of
218
+ # them changes only the sub-phase is lost, because readiness comes from /health.
219
+ _SERVER_LINES = [
220
+ (re.compile(r"loading model '"), "load", None),
221
+ (re.compile(r"llama_context: constructing llama_context|constructing llama_context"), "setup", None),
222
+ (re.compile(r"initializing slots"), "setup_slots", "setup"),
223
+ (re.compile(r"cleaning up before exit"), "teardown", None),
224
+ ]
225
+
226
+
227
+ class _LlamaCommon(Adapter):
228
+ def model_path(self):
229
+ return _find(self.cmd, "-m", "--model")
230
+
231
+ def prepare(self, cmd, env):
232
+ # JSONL logs are the only form of the memory breakdown visible without trace verbosity, which would slow decode.
233
+ env = dict(env)
234
+ env.setdefault("LLAMA_ARG_LOG_JSONL", "1")
235
+ return cmd, env
236
+
237
+ def on_line(self, stream, t, text, emit):
238
+ super().on_line(stream, t, text, emit)
239
+ message = text
240
+ if text.startswith("{"):
241
+ try:
242
+ record = json.loads(text)
243
+ except ValueError:
244
+ record = None
245
+ if isinstance(record, dict):
246
+ if record.get("type") == "fit_memory_breakdown":
247
+ self.report["memory_breakdown"] = _breakdown_from_json(record)
248
+ return
249
+ message = str(record.get("msg", ""))
250
+ message = ANSI.sub("", message)
251
+ table_row = parse_breakdown_line(message)
252
+ if table_row:
253
+ self.report.setdefault("memory_breakdown", []).append(table_row)
254
+ return
255
+ for regex, phase, group in _SERVER_LINES:
256
+ if regex.search(message):
257
+ emit(phase, group=group, t=t)
258
+
259
+ def finalize(self, phases, stdout_path, stderr_path):
260
+ return dict(self.report)
261
+
262
+
263
+ class LlamaServer(_LlamaCommon):
264
+ name = "llama.cpp:llama-server"
265
+ server = {"ready_path": "/health", "metrics_path": "/metrics", "base_path": "/v1", "stop_signal": "TERM",
266
+ "extra_body": {"cache_prompt": False}}
267
+
268
+ def prepare(self, cmd, env):
269
+ cmd, env = super().prepare(cmd, env)
270
+ if "--metrics" not in cmd:
271
+ cmd = list(cmd) + ["--metrics"]
272
+ return cmd, env
273
+
274
+
275
+ class LlamaPerplexity(_LlamaCommon):
276
+ name = "llama.cpp:llama-perplexity"
277
+
278
+
279
+ class LlamaGeneric(_LlamaCommon):
280
+ name = "llama.cpp"
281
+
282
+
283
+ def _breakdown_from_json(record):
284
+ data = record.get("data", record)
285
+ rows = data.get("rows", []) if isinstance(data, dict) else []
286
+ return [{k: row.get(k) for k in ("kind", "name", "total", "free", "self", "model", "context", "compute", "unaccounted")
287
+ if k in row} for row in rows]
288
+
289
+
290
+ _BREAKDOWN_GPU = re.compile(
291
+ r"\|\s*-\s*(?P<name>.+?)\s*\|\s*(?P<total>\d+)\s*=\s*(?P<free>\d+)\s*\+\s*\(\s*(?P<self>\d+)\s*=\s*(?P<model>\d+)\s*\+\s*"
292
+ r"(?P<context>\d+)\s*\+\s*(?P<compute>\d+)\s*\)\s*\+\s*(?P<unaccounted>\d+)\s*\|")
293
+ _BREAKDOWN_OTHER = re.compile(
294
+ r"\|\s*-\s*(?P<name>.+?)\s*\|\s*(?P<self>\d+)\s*=\s*(?P<model>\d+)\s*\+\s*(?P<context>\d+)\s*\+\s*(?P<compute>\d+)\s*\|")
295
+
296
+
297
+ def parse_breakdown_line(text):
298
+ """One row of common_memory_breakdown_print's table."""
299
+ m = _BREAKDOWN_GPU.search(text)
300
+ if m:
301
+ row = {k: (int(v) if v.isdigit() else v) for k, v in m.groupdict().items()}
302
+ row["kind"] = "device"
303
+ return row
304
+ m = _BREAKDOWN_OTHER.search(text)
305
+ if m:
306
+ row = {k: (int(v) if v.isdigit() else v) for k, v in m.groupdict().items()}
307
+ row["kind"] = "host" if row["name"].lower().startswith("host") else "buffer_type"
308
+ return row
309
+ return None
310
+
311
+
312
+ # Engines recognised from outside only: they get readiness, requests and server metrics, phases from markers.
313
+ SERVER_PRESETS = {
314
+ "openai": {"ready_path": "/health", "metrics_path": None, "base_path": "/v1", "stop_signal": "TERM", "extra_body": {},
315
+ "port": 8000},
316
+ "llama-server": {**LlamaServer.server, "port": 8080},
317
+ "vllm": {"ready_path": "/health", "metrics_path": "/metrics", "base_path": "/v1", "stop_signal": "TERM", "extra_body": {},
318
+ "port": 8000},
319
+ "sglang": {"ready_path": "/health", "metrics_path": "/metrics", "base_path": "/v1", "stop_signal": "TERM", "extra_body": {},
320
+ "port": 30000, "extra_args": ["--enable-metrics"]},
321
+ "tgi": {"ready_path": "/health", "metrics_path": "/metrics", "base_path": "/v1", "stop_signal": "TERM", "extra_body": {},
322
+ "port": 3000},
323
+ "ollama": {"ready_path": "/api/version", "metrics_path": None, "base_path": "/v1", "stop_signal": "TERM", "extra_body": {},
324
+ "port": 11434},
325
+ }
326
+
327
+
328
+ def select(name, cmd, marks=()):
329
+ """`name` is "auto" or an adapter name."""
330
+ words = [os.path.basename(token) for token in cmd]
331
+ if name in ("auto", "llama-bench") and "llama-bench" in words:
332
+ return LlamaBench(cmd, marks)
333
+ if name in ("auto", "llama-server") and "llama-server" in words:
334
+ return LlamaServer(cmd, marks)
335
+ if name in ("auto", "llama-perplexity") and "llama-perplexity" in words:
336
+ return LlamaPerplexity(cmd, marks)
337
+ if name in ("auto", "llama.cpp") and any(w.startswith("llama-") for w in words):
338
+ return LlamaGeneric(cmd, marks)
339
+ return Adapter(cmd, marks)
340
+
341
+
342
+ def preset_for(cmd):
343
+ """Server defaults guessed from the command line, container images included."""
344
+ words = [os.path.basename(token).lower() for token in cmd]
345
+ joined = " ".join(token.lower() for token in cmd)
346
+ if "llama-server" in words or "llama.cpp" in joined:
347
+ return "llama-server"
348
+ if "vllm" in joined:
349
+ return "vllm"
350
+ if "sglang" in joined:
351
+ return "sglang"
352
+ if "text-generation-launcher" in words or "text-generation-inference" in joined:
353
+ return "tgi"
354
+ if "ollama" in joined:
355
+ return "ollama"
356
+ return "openai"
357
+
358
+
359
+ def docker_index(cmd):
360
+ """Index of `docker` in a `docker run ...` command, or None."""
361
+ i = _skip_wrappers(cmd)
362
+ if i + 1 < len(cmd) and os.path.basename(cmd[i]) in ("docker", "podman") and cmd[i + 1] == "run":
363
+ return i
364
+ return None
365
+
366
+
367
+ def guess_url(cmd, preset_name):
368
+ """Where a server will listen, so nobody has to say it twice. A container's published port wins over the flags inside."""
369
+ port = None
370
+ host = "127.0.0.1"
371
+ index = docker_index(cmd)
372
+ if index is not None:
373
+ for i, token in enumerate(cmd[index:], start=index):
374
+ value = None
375
+ if token in ("-p", "--publish") and i + 1 < len(cmd):
376
+ value = cmd[i + 1]
377
+ elif token.startswith("--publish="):
378
+ value = token.split("=", 1)[1]
379
+ if value:
380
+ parts = value.split("/")[0].split(":")
381
+ if len(parts) >= 2 and parts[-2].isdigit():
382
+ port = int(parts[-2])
383
+ break
384
+ if port is None:
385
+ for i, token in enumerate(cmd):
386
+ if token == "--port" and i + 1 < len(cmd) and cmd[i + 1].isdigit():
387
+ port = int(cmd[i + 1])
388
+ elif token.startswith("--port=") and token.split("=", 1)[1].isdigit():
389
+ port = int(token.split("=", 1)[1])
390
+ if port is None and preset_name == "ollama" and ":" in os.environ.get("OLLAMA_HOST", ""):
391
+ tail = os.environ["OLLAMA_HOST"].rsplit(":", 1)[1]
392
+ if tail.isdigit():
393
+ port = int(tail)
394
+ if port is None:
395
+ port = SERVER_PRESETS[preset_name]["port"]
396
+ return f"http://{host}:{port}"