inferencekit-cli 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.
- inferencekit/__init__.py +9 -0
- inferencekit/__main__.py +5 -0
- inferencekit/analysis/__init__.py +1 -0
- inferencekit/analysis/ncu_csv.py +284 -0
- inferencekit/analysis/nsys_sqlite.py +518 -0
- inferencekit/canonical.py +104 -0
- inferencekit/checkpoint.py +396 -0
- inferencekit/cli.py +274 -0
- inferencekit/cuda/bw_peak.cu +252 -0
- inferencekit/cuda/torch_bw.py +60 -0
- inferencekit/engines/__init__.py +7 -0
- inferencekit/engines/adapters.py +190 -0
- inferencekit/engines/clip/__init__.py +13 -0
- inferencekit/engines/clip/comfyui.py +749 -0
- inferencekit/engines/clip/diffusers_ltx.py +601 -0
- inferencekit/engines/clip/noise.py +46 -0
- inferencekit/engines/clip/official_ltx.py +316 -0
- inferencekit/engines/clip/server.py +542 -0
- inferencekit/engines/clip/stepdump.py +182 -0
- inferencekit/engines/gates.py +94 -0
- inferencekit/engines/sglang_worker.py +86 -0
- inferencekit/engines/vllm_worker.py +129 -0
- inferencekit/evidence.py +427 -0
- inferencekit/gpuinfo.py +93 -0
- inferencekit/hardware.py +135 -0
- inferencekit/mcp_server.py +178 -0
- inferencekit/ops/__init__.py +30 -0
- inferencekit/ops/bench.py +184 -0
- inferencekit/ops/bench_audio.py +384 -0
- inferencekit/ops/bench_clip.py +819 -0
- inferencekit/ops/bench_image.py +393 -0
- inferencekit/ops/bench_rounds.py +297 -0
- inferencekit/ops/bench_serve.py +866 -0
- inferencekit/ops/ceiling.py +135 -0
- inferencekit/ops/compare.py +378 -0
- inferencekit/ops/correctness.py +153 -0
- inferencekit/ops/doctor.py +486 -0
- inferencekit/ops/gates.py +1772 -0
- inferencekit/ops/gates_clip.py +930 -0
- inferencekit/ops/intake.py +1321 -0
- inferencekit/ops/ledger.py +749 -0
- inferencekit/ops/manifest.py +309 -0
- inferencekit/ops/ncu.py +183 -0
- inferencekit/ops/nsys.py +224 -0
- inferencekit/ops/prompts.py +316 -0
- inferencekit/ops/quality.py +2161 -0
- inferencekit/ops/quality_clip.py +690 -0
- inferencekit/ops/quality_clip_steps.py +341 -0
- inferencekit/ops/report.py +317 -0
- inferencekit/ops/review.py +119 -0
- inferencekit/ops/rocprof.py +89 -0
- inferencekit/ops/roofline.py +392 -0
- inferencekit/ops/store.py +61 -0
- inferencekit/proc.py +172 -0
- inferencekit/prompts/ATTRIBUTION.md +56 -0
- inferencekit/prompts/__init__.py +167 -0
- inferencekit/prompts/build.py +518 -0
- inferencekit/prompts/dev-load.jsonl.gz +0 -0
- inferencekit/prompts/dev-load.manifest.json +212 -0
- inferencekit/prompts/dev.jsonl.gz +0 -0
- inferencekit/prompts/dev.manifest.json +214 -0
- inferencekit/prompts/exclusions.json +54 -0
- inferencekit/quality/__init__.py +23 -0
- inferencekit/quality/client.py +66 -0
- inferencekit/quality/data/gsm8k_LICENSE.txt +21 -0
- inferencekit/quality/fake.py +329 -0
- inferencekit/quality/gsm8k.py +224 -0
- inferencekit/quality/logprobs.py +106 -0
- inferencekit/quality/metrics.py +235 -0
- inferencekit/quality/niah.py +63 -0
- inferencekit/quality/parquet.py +296 -0
- inferencekit/quality/sampling.py +262 -0
- inferencekit/quality/step_replay.py +217 -0
- inferencekit/quality/video_metrics.py +304 -0
- inferencekit/registry.py +206 -0
- inferencekit/review/__init__.py +6 -0
- inferencekit/review/diff.py +163 -0
- inferencekit/review/media.py +181 -0
- inferencekit/review/page.py +920 -0
- inferencekit/review/rundir.py +844 -0
- inferencekit/roofline_headers.py +504 -0
- inferencekit/schemas/evidence.v1.json +130 -0
- inferencekit/schemas/evidence.v2.json +499 -0
- inferencekit/schemas/intake.v1.json +276 -0
- inferencekit/schemas/run-manifest.v1.json +118 -0
- inferencekit/serve/__init__.py +8 -0
- inferencekit/serve/audio_client.py +330 -0
- inferencekit/serve/client.py +446 -0
- inferencekit/serve/clip_client.py +351 -0
- inferencekit/serve/clip_rounds.py +429 -0
- inferencekit/serve/image_client.py +373 -0
- inferencekit/serve/loadgen.py +136 -0
- inferencekit/serve/metrics.py +203 -0
- inferencekit/serve/profiles.py +105 -0
- inferencekit/serve/prompts_io.py +125 -0
- inferencekit/serve/rounds.py +1111 -0
- inferencekit/serve/servers.py +897 -0
- inferencekit/stats.py +249 -0
- inferencekit_cli-0.1.0.dist-info/METADATA +233 -0
- inferencekit_cli-0.1.0.dist-info/RECORD +104 -0
- inferencekit_cli-0.1.0.dist-info/WHEEL +4 -0
- inferencekit_cli-0.1.0.dist-info/entry_points.txt +2 -0
- inferencekit_cli-0.1.0.dist-info/licenses/LICENSE +202 -0
- inferencekit_cli-0.1.0.dist-info/licenses/NOTICE +9 -0
|
@@ -0,0 +1,897 @@
|
|
|
1
|
+
"""Engine servers for round runs: launch, readiness, warm-up, /metrics, stop, GPU memory, GPU lock.
|
|
2
|
+
|
|
3
|
+
One (round, engine) cell of ``bench rounds`` does, in order:
|
|
4
|
+
|
|
5
|
+
1. check the port is free (a leftover server on it would answer the readiness probe
|
|
6
|
+
and get benchmarked in place of the engine under test);
|
|
7
|
+
2. launch the server under a clean environment (only the variables the manifest
|
|
8
|
+
lists, plus PATH/HOME/CUDA basics), in its own process group, stdout+stderr to a
|
|
9
|
+
log file;
|
|
10
|
+
3. poll ``GET <ready_check>`` until it answers 200, the process exits, or the
|
|
11
|
+
startup timeout passes;
|
|
12
|
+
4. warm up with a few short requests (untimed), each salted with a unique prefix tag so
|
|
13
|
+
the warm-up never seeds the prefix cache for a prompt that is timed afterwards;
|
|
14
|
+
5. scrape ``/metrics`` (Prometheus text) before and after the measured run, when the
|
|
15
|
+
engine exposes it;
|
|
16
|
+
6. stop the whole process group: SIGTERM, then SIGKILL after a grace period. Engines
|
|
17
|
+
fork workers (vLLM's engine core, SGLang's scheduler and detokenizer); signalling
|
|
18
|
+
only the launcher would leave them holding GPU memory;
|
|
19
|
+
7. check that GPU memory returned to the level measured before the launch
|
|
20
|
+
(nvidia-smi; skipped when absent), so the next engine starts on an empty GPU.
|
|
21
|
+
|
|
22
|
+
The whole run holds an exclusive lock file (``gpu_lock``), so two round runs (or a
|
|
23
|
+
round run and an agent's ad-hoc benchmark that also takes the lock) cannot share the GPU.
|
|
24
|
+
|
|
25
|
+
Environment values are passed to the server but never recorded: records list names only.
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
from __future__ import annotations
|
|
29
|
+
|
|
30
|
+
import ast
|
|
31
|
+
import contextlib
|
|
32
|
+
import fcntl
|
|
33
|
+
import json
|
|
34
|
+
import os
|
|
35
|
+
import re
|
|
36
|
+
import signal
|
|
37
|
+
import socket
|
|
38
|
+
import subprocess
|
|
39
|
+
import time
|
|
40
|
+
import urllib.error
|
|
41
|
+
import urllib.request
|
|
42
|
+
from dataclasses import dataclass, field
|
|
43
|
+
from pathlib import Path
|
|
44
|
+
|
|
45
|
+
from inferencekit.evidence import looks_secret_name
|
|
46
|
+
from inferencekit.proc import run, which
|
|
47
|
+
from inferencekit.registry import OpError
|
|
48
|
+
|
|
49
|
+
# Passed through from the caller in addition to the manifest's own variables: enough to
|
|
50
|
+
# find binaries, the home directory (HF cache) and the GPU, nothing that tunes an engine.
|
|
51
|
+
SERVER_BASE_ENV = ("PATH", "HOME", "USER", "LOGNAME", "LANG", "LC_ALL", "TMPDIR", "CUDA_VISIBLE_DEVICES",
|
|
52
|
+
"CUDA_HOME", "CUDA_PATH", "LD_LIBRARY_PATH", "NVIDIA_VISIBLE_DEVICES",
|
|
53
|
+
"NVIDIA_DRIVER_CAPABILITIES")
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
# --------------------------------------------------------------------------- environment
|
|
57
|
+
|
|
58
|
+
def server_env(listed: dict | None) -> tuple[dict, dict]:
|
|
59
|
+
"""The environment for one engine server, and a names-only description of it.
|
|
60
|
+
|
|
61
|
+
``listed`` is the manifest's ``env`` object. A string (or number) value is set as
|
|
62
|
+
given; ``null`` means "inherit this variable from the caller if it is set", which is
|
|
63
|
+
how a secret such as HF_TOKEN reaches the server without being written into the
|
|
64
|
+
manifest. Returns ``(env, info)`` where ``info`` holds only names.
|
|
65
|
+
"""
|
|
66
|
+
env = {k: os.environ[k] for k in SERVER_BASE_ENV if k in os.environ}
|
|
67
|
+
env.setdefault("PATH", "/usr/local/bin:/usr/bin:/bin")
|
|
68
|
+
set_names, inherited, missing, secret_values = [], [], [], []
|
|
69
|
+
for name, value in (listed or {}).items():
|
|
70
|
+
name = str(name)
|
|
71
|
+
if value is None:
|
|
72
|
+
if name in os.environ:
|
|
73
|
+
env[name] = os.environ[name]
|
|
74
|
+
inherited.append(name)
|
|
75
|
+
else:
|
|
76
|
+
missing.append(name)
|
|
77
|
+
continue
|
|
78
|
+
env[name] = value if isinstance(value, str) else json.dumps(value) if isinstance(value, (dict, list)) \
|
|
79
|
+
else str(value).lower() if isinstance(value, bool) else str(value)
|
|
80
|
+
set_names.append(name)
|
|
81
|
+
if looks_secret_name(name):
|
|
82
|
+
secret_values.append(name)
|
|
83
|
+
info = {"base": sorted(k for k in SERVER_BASE_ENV if k in env), "set": sorted(set_names),
|
|
84
|
+
"inherited": sorted(inherited), "missing": sorted(missing),
|
|
85
|
+
"secret_values_in_manifest": sorted(secret_values)}
|
|
86
|
+
return env, info
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
# --------------------------------------------------------------------------- HTTP helpers
|
|
90
|
+
|
|
91
|
+
def http_get(url: str, timeout: float = 5.0) -> tuple[int | None, bytes | str]:
|
|
92
|
+
"""(status, body) or (None, error text). Never raises for network errors."""
|
|
93
|
+
try:
|
|
94
|
+
with urllib.request.urlopen(url, timeout=timeout) as r: # noqa: S310 - local engine URLs only
|
|
95
|
+
return r.status, r.read()
|
|
96
|
+
except urllib.error.HTTPError as e:
|
|
97
|
+
return e.code, e.read() if e.fp else b""
|
|
98
|
+
except (urllib.error.URLError, OSError, ValueError) as e:
|
|
99
|
+
return None, str(getattr(e, "reason", e))
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def http_post_json(url: str, body: dict, timeout: float = 60.0) -> tuple[int | None, dict | str]:
|
|
103
|
+
data = json.dumps(body).encode()
|
|
104
|
+
req = urllib.request.Request(url, data=data, headers={"Content-Type": "application/json"}, method="POST")
|
|
105
|
+
try:
|
|
106
|
+
with urllib.request.urlopen(req, timeout=timeout) as r: # noqa: S310
|
|
107
|
+
raw = r.read()
|
|
108
|
+
try:
|
|
109
|
+
return r.status, json.loads(raw)
|
|
110
|
+
except ValueError:
|
|
111
|
+
return r.status, raw.decode(errors="replace")[:500]
|
|
112
|
+
except urllib.error.HTTPError as e:
|
|
113
|
+
return e.code, (e.read() if e.fp else b"").decode(errors="replace")[:500]
|
|
114
|
+
except (urllib.error.URLError, OSError, ValueError) as e:
|
|
115
|
+
return None, str(getattr(e, "reason", e))
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def port_in_use(host: str, port: int, timeout: float = 0.5) -> bool:
|
|
119
|
+
"""True when something already accepts connections on host:port."""
|
|
120
|
+
with contextlib.closing(socket.socket(socket.AF_INET, socket.SOCK_STREAM)) as s:
|
|
121
|
+
s.settimeout(timeout)
|
|
122
|
+
return s.connect_ex((host, port)) == 0
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def served_model(base_url: str, timeout: float = 5.0) -> str | None:
|
|
126
|
+
"""The first model id in ``GET /v1/models`` (the name requests must use), or None."""
|
|
127
|
+
status, body = http_get(base_url.rstrip("/") + "/v1/models", timeout)
|
|
128
|
+
if status != 200:
|
|
129
|
+
return None
|
|
130
|
+
try:
|
|
131
|
+
data = json.loads(body)
|
|
132
|
+
return str(data["data"][0]["id"])
|
|
133
|
+
except (ValueError, KeyError, IndexError, TypeError):
|
|
134
|
+
return None
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
# --------------------------------------------------------------------------- processes
|
|
138
|
+
|
|
139
|
+
@dataclass
|
|
140
|
+
class Server:
|
|
141
|
+
"""A launched engine server (leader of its own process group)."""
|
|
142
|
+
|
|
143
|
+
argv: list[str]
|
|
144
|
+
log_path: Path
|
|
145
|
+
popen: subprocess.Popen
|
|
146
|
+
started: float = field(default_factory=time.monotonic)
|
|
147
|
+
|
|
148
|
+
@property
|
|
149
|
+
def pgid(self) -> int:
|
|
150
|
+
return self.popen.pid # start_new_session=True: the child leads a new session and group
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def launch(argv: list[str], env: dict, log_path: Path, cwd: str | None = None) -> Server:
|
|
154
|
+
"""Start ``argv`` in a new process group with stdout and stderr appended to ``log_path``.
|
|
155
|
+
|
|
156
|
+
Raises OSError (e.g. FileNotFoundError) when the program cannot be started.
|
|
157
|
+
"""
|
|
158
|
+
log_path.parent.mkdir(parents=True, exist_ok=True)
|
|
159
|
+
with open(log_path, "ab") as log:
|
|
160
|
+
# The child keeps its own copy of the file descriptor; ours can close right away.
|
|
161
|
+
popen = subprocess.Popen(argv, env=env, cwd=cwd, stdin=subprocess.DEVNULL, stdout=log,
|
|
162
|
+
stderr=subprocess.STDOUT, start_new_session=True)
|
|
163
|
+
return Server(list(argv), log_path, popen)
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def group_members(pgid: int) -> list[int] | None:
|
|
167
|
+
"""Live (non-zombie) pids in process group ``pgid``; None when /proc is unavailable."""
|
|
168
|
+
proc = Path("/proc")
|
|
169
|
+
if not proc.is_dir():
|
|
170
|
+
return None
|
|
171
|
+
out = []
|
|
172
|
+
for d in proc.iterdir():
|
|
173
|
+
if not d.name.isdigit():
|
|
174
|
+
continue
|
|
175
|
+
try:
|
|
176
|
+
fields = (d / "stat").read_text().rsplit(")", 1)[1].split()
|
|
177
|
+
except (OSError, IndexError):
|
|
178
|
+
continue
|
|
179
|
+
# fields: state ppid pgrp ...
|
|
180
|
+
if len(fields) >= 3 and fields[2] == str(pgid) and fields[0] != "Z":
|
|
181
|
+
out.append(int(d.name))
|
|
182
|
+
return sorted(out)
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def group_alive(pgid: int) -> bool:
|
|
186
|
+
members = group_members(pgid)
|
|
187
|
+
if members is not None:
|
|
188
|
+
return bool(members)
|
|
189
|
+
try:
|
|
190
|
+
os.killpg(pgid, 0)
|
|
191
|
+
return True
|
|
192
|
+
except ProcessLookupError:
|
|
193
|
+
return False
|
|
194
|
+
except PermissionError:
|
|
195
|
+
return True
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
def _signal_group(pgid: int, sig: int) -> None:
|
|
199
|
+
try:
|
|
200
|
+
os.killpg(pgid, sig)
|
|
201
|
+
except (ProcessLookupError, PermissionError):
|
|
202
|
+
pass
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
def _wait_group_gone(server: Server, timeout: float) -> bool:
|
|
206
|
+
deadline = time.monotonic() + timeout
|
|
207
|
+
while True:
|
|
208
|
+
server.popen.poll() # reap the leader so it does not linger as a zombie
|
|
209
|
+
if not group_alive(server.pgid):
|
|
210
|
+
return True
|
|
211
|
+
if time.monotonic() >= deadline:
|
|
212
|
+
return False
|
|
213
|
+
time.sleep(0.05)
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
def stop(server: Server, grace_s: float = 30.0) -> dict:
|
|
217
|
+
"""Stop the server's whole process group: SIGTERM, then SIGKILL after ``grace_s``.
|
|
218
|
+
|
|
219
|
+
The group is signalled even when the leader already exited, because its children
|
|
220
|
+
(engine workers) can outlive it. Returns what happened, including whether every
|
|
221
|
+
process in the group is gone.
|
|
222
|
+
"""
|
|
223
|
+
t0 = time.monotonic()
|
|
224
|
+
leader_was_running = server.popen.poll() is None
|
|
225
|
+
_signal_group(server.pgid, signal.SIGTERM)
|
|
226
|
+
method = "sigterm" if leader_was_running else "already_exited"
|
|
227
|
+
gone = _wait_group_gone(server, grace_s)
|
|
228
|
+
if not gone:
|
|
229
|
+
method = "sigkill"
|
|
230
|
+
_signal_group(server.pgid, signal.SIGKILL)
|
|
231
|
+
gone = _wait_group_gone(server, 10.0)
|
|
232
|
+
with contextlib.suppress(subprocess.TimeoutExpired):
|
|
233
|
+
server.popen.wait(timeout=5)
|
|
234
|
+
return {"method": method, "exit_code": server.popen.returncode, "group_gone": gone,
|
|
235
|
+
"seconds": round(time.monotonic() - t0, 3), "uptime_s": round(time.monotonic() - server.started, 3)}
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
def wait_ready(server: Server, url: str, timeout_s: float, poll_s: float = 0.5) -> dict:
|
|
239
|
+
"""Poll ``GET url`` until it returns 200, the server exits, or ``timeout_s`` passes.
|
|
240
|
+
|
|
241
|
+
Returns ``{"status": "ready" | "exited" | "timeout", "seconds", "last": ...}``.
|
|
242
|
+
"""
|
|
243
|
+
t0 = time.monotonic()
|
|
244
|
+
last: str | int | None = None
|
|
245
|
+
while True:
|
|
246
|
+
code = server.popen.poll()
|
|
247
|
+
if code is not None:
|
|
248
|
+
return {"status": "exited", "seconds": round(time.monotonic() - t0, 3), "exit_code": code,
|
|
249
|
+
"last": last}
|
|
250
|
+
status, body = http_get(url, timeout=min(5.0, max(0.5, poll_s * 4)))
|
|
251
|
+
if status == 200:
|
|
252
|
+
return {"status": "ready", "seconds": round(time.monotonic() - t0, 3), "last": 200}
|
|
253
|
+
last = status if status is not None else str(body)[:200]
|
|
254
|
+
if time.monotonic() - t0 >= timeout_s:
|
|
255
|
+
return {"status": "timeout", "seconds": round(time.monotonic() - t0, 3), "last": last}
|
|
256
|
+
time.sleep(poll_s)
|
|
257
|
+
|
|
258
|
+
|
|
259
|
+
def salted_copy(messages: list | None, tag: str) -> list[dict]:
|
|
260
|
+
"""``messages`` with ``[tag] `` put in front of the first message's text.
|
|
261
|
+
|
|
262
|
+
The tag sits as early in the prompt as the chat template allows, so a request with the
|
|
263
|
+
salted copy shares no cacheable prefix with the original (vLLM automatic prefix caching,
|
|
264
|
+
the SGLang radix cache and llama-server ``cache_prompt`` all match from the start).
|
|
265
|
+
"""
|
|
266
|
+
msgs = [dict(m) for m in (messages or []) if isinstance(m, dict)] or [{"role": "user", "content": "Hello"}]
|
|
267
|
+
first = msgs[0]
|
|
268
|
+
content = first.get("content")
|
|
269
|
+
if isinstance(content, list): # multi-part content: a text part in front
|
|
270
|
+
first["content"] = [{"type": "text", "text": f"[{tag}] "}] + list(content)
|
|
271
|
+
else:
|
|
272
|
+
first["content"] = f"[{tag}] " + str(content if content is not None else "")
|
|
273
|
+
return msgs
|
|
274
|
+
|
|
275
|
+
|
|
276
|
+
WARMUP_KIND = ("salted copies of the prompt set's first prompts (unique prefix tag per request, so no timed "
|
|
277
|
+
"prompt's prefix is cached by the warm-up)")
|
|
278
|
+
|
|
279
|
+
|
|
280
|
+
def warmup(base_url: str, model: str | None, prompts: list[dict], n: int, max_tokens: int = 16,
|
|
281
|
+
timeout: float = 300.0, salt: str = "ik-rounds-warmup", chat_template_kwargs: dict | None = None) -> dict:
|
|
282
|
+
"""Send ``n`` short, untimed chat requests to warm the server.
|
|
283
|
+
|
|
284
|
+
The requests are the first prompts of the set (so kernels for those shapes are warm),
|
|
285
|
+
each salted with ``[<salt>-<i>]`` in front of its first message: the warm-up must not
|
|
286
|
+
fill the prefix cache for the very prompts ``bench serve`` times next, which would turn
|
|
287
|
+
their TTFT into cache hits for engines with prefix caching only.
|
|
288
|
+
"""
|
|
289
|
+
url = base_url.rstrip("/") + "/v1/chat/completions"
|
|
290
|
+
sent, ok, errors = 0, 0, []
|
|
291
|
+
for i, p in enumerate((prompts or [])[:max(0, n)]):
|
|
292
|
+
body = {"messages": salted_copy(p.get("messages"), f"{salt}-{i}"), "max_tokens": max_tokens,
|
|
293
|
+
"stream": False, "temperature": 0}
|
|
294
|
+
if model:
|
|
295
|
+
body["model"] = model
|
|
296
|
+
own = p.get("chat_template_kwargs")
|
|
297
|
+
kw = {**(chat_template_kwargs or {}), **(own if isinstance(own, dict) else {})}
|
|
298
|
+
if kw: # the same template settings bench serve sends (the warm-up must render the same prompt shape)
|
|
299
|
+
body["chat_template_kwargs"] = kw
|
|
300
|
+
status, resp = http_post_json(url, body, timeout)
|
|
301
|
+
sent += 1
|
|
302
|
+
if status == 200:
|
|
303
|
+
ok += 1
|
|
304
|
+
else:
|
|
305
|
+
errors.append(f"{status}: {str(resp)[:200]}")
|
|
306
|
+
return {"sent": sent, "ok": ok, "errors": errors[:5], "kind": WARMUP_KIND, "salt": salt}
|
|
307
|
+
|
|
308
|
+
|
|
309
|
+
# --------------------------------------------------------------------------- Prometheus /metrics
|
|
310
|
+
|
|
311
|
+
def scrape_metrics(base_url: str, timeout: float = 5.0) -> str | None:
|
|
312
|
+
"""Raw Prometheus text from ``GET /metrics``, or None when the engine does not expose it."""
|
|
313
|
+
status, body = http_get(base_url.rstrip("/") + "/metrics", timeout)
|
|
314
|
+
if status != 200 or not body:
|
|
315
|
+
return None
|
|
316
|
+
text = body.decode(errors="replace") if isinstance(body, bytes) else str(body)
|
|
317
|
+
return text if ("# TYPE" in text or "# HELP" in text or "{" in text) else None
|
|
318
|
+
|
|
319
|
+
|
|
320
|
+
def parse_prometheus(text: str | None) -> dict:
|
|
321
|
+
"""``{"types": {family: type}, "samples": {"name{labels}": value}}`` from Prometheus text format."""
|
|
322
|
+
types, samples = {}, {}
|
|
323
|
+
for line in (text or "").splitlines():
|
|
324
|
+
line = line.strip()
|
|
325
|
+
if not line:
|
|
326
|
+
continue
|
|
327
|
+
if line.startswith("#"):
|
|
328
|
+
parts = line.split()
|
|
329
|
+
if len(parts) >= 4 and parts[1] == "TYPE":
|
|
330
|
+
types[parts[2]] = parts[3]
|
|
331
|
+
continue
|
|
332
|
+
# name{labels} value [timestamp]; label values may contain spaces, so split at the closing brace.
|
|
333
|
+
if "}" in line:
|
|
334
|
+
key, _, rest = line.rpartition("}")
|
|
335
|
+
key += "}"
|
|
336
|
+
else:
|
|
337
|
+
key, _, rest = line.partition(" ")
|
|
338
|
+
fields = rest.split()
|
|
339
|
+
if not fields:
|
|
340
|
+
continue
|
|
341
|
+
try:
|
|
342
|
+
samples[key.strip()] = float(fields[0])
|
|
343
|
+
except ValueError:
|
|
344
|
+
continue
|
|
345
|
+
return {"types": types, "samples": samples}
|
|
346
|
+
|
|
347
|
+
|
|
348
|
+
def _family(sample_key: str, types: dict) -> tuple[str, str | None]:
|
|
349
|
+
name = sample_key.split("{", 1)[0]
|
|
350
|
+
if name in types:
|
|
351
|
+
return name, types[name]
|
|
352
|
+
for suffix in ("_total", "_count", "_sum", "_bucket", "_created"):
|
|
353
|
+
if name.endswith(suffix) and name[: -len(suffix)] in types:
|
|
354
|
+
return name[: -len(suffix)], types[name[: -len(suffix)]]
|
|
355
|
+
return name, None
|
|
356
|
+
|
|
357
|
+
|
|
358
|
+
def metrics_delta(before: str | None, after: str | None) -> dict:
|
|
359
|
+
"""Counter and histogram increases between two scrapes (what the measured run did).
|
|
360
|
+
|
|
361
|
+
Gauges are reported at their ``after`` value. Series without a TYPE line are left out:
|
|
362
|
+
without the type we cannot tell a counter from a gauge.
|
|
363
|
+
"""
|
|
364
|
+
if before is None or after is None:
|
|
365
|
+
return {"available": False}
|
|
366
|
+
b, a = parse_prometheus(before), parse_prometheus(after)
|
|
367
|
+
types = {**b["types"], **a["types"]}
|
|
368
|
+
counters, gauges = {}, {}
|
|
369
|
+
for key, value in a["samples"].items():
|
|
370
|
+
family, kind = _family(key, types)
|
|
371
|
+
if key.split("{", 1)[0].endswith("_created"):
|
|
372
|
+
continue
|
|
373
|
+
if kind in ("counter", "histogram", "summary"):
|
|
374
|
+
diff = value - b["samples"].get(key, 0.0)
|
|
375
|
+
if diff:
|
|
376
|
+
counters[key] = diff
|
|
377
|
+
elif kind == "gauge":
|
|
378
|
+
gauges[key] = value
|
|
379
|
+
return {"available": True, "counter_deltas": counters, "gauges_after": gauges}
|
|
380
|
+
|
|
381
|
+
|
|
382
|
+
# --------------------------------------------------------------------------- GPU memory
|
|
383
|
+
|
|
384
|
+
def gpu_memory_used_mib() -> list[int] | None:
|
|
385
|
+
"""Used memory per visible GPU in MiB, or None without nvidia-smi."""
|
|
386
|
+
smi = which("nvidia-smi")
|
|
387
|
+
if not smi:
|
|
388
|
+
return None
|
|
389
|
+
r = run([smi, "--query-gpu=memory.used", "--format=csv,noheader,nounits"], timeout=30)
|
|
390
|
+
if not r.ok:
|
|
391
|
+
return None
|
|
392
|
+
out = []
|
|
393
|
+
for line in r.stdout.strip().splitlines():
|
|
394
|
+
try:
|
|
395
|
+
out.append(int(float(line.strip())))
|
|
396
|
+
except ValueError:
|
|
397
|
+
return None
|
|
398
|
+
return out or None
|
|
399
|
+
|
|
400
|
+
|
|
401
|
+
def wait_gpu_memory(baseline: list[int] | None, tolerance_mib: int, timeout_s: float,
|
|
402
|
+
reader=gpu_memory_used_mib) -> dict:
|
|
403
|
+
"""Wait until every GPU is back within ``tolerance_mib`` of ``baseline`` (taken before launch)."""
|
|
404
|
+
if baseline is None:
|
|
405
|
+
return {"checked": False, "reason": "nvidia-smi not available"}
|
|
406
|
+
t0 = time.monotonic()
|
|
407
|
+
while True:
|
|
408
|
+
now = reader()
|
|
409
|
+
if now is None:
|
|
410
|
+
return {"checked": False, "reason": "nvidia-smi stopped answering", "baseline_mib": baseline}
|
|
411
|
+
excess = [max(0, n - b) for n, b in zip(now, baseline)]
|
|
412
|
+
if all(x <= tolerance_mib for x in excess):
|
|
413
|
+
return {"checked": True, "returned": True, "baseline_mib": baseline, "after_mib": now,
|
|
414
|
+
"seconds": round(time.monotonic() - t0, 3)}
|
|
415
|
+
if time.monotonic() - t0 >= timeout_s:
|
|
416
|
+
return {"checked": True, "returned": False, "baseline_mib": baseline, "after_mib": now,
|
|
417
|
+
"excess_mib": excess, "seconds": round(time.monotonic() - t0, 3)}
|
|
418
|
+
time.sleep(0.5)
|
|
419
|
+
|
|
420
|
+
|
|
421
|
+
# --------------------------------------------------------------------------- GPU lock
|
|
422
|
+
|
|
423
|
+
def default_lock_path() -> Path:
|
|
424
|
+
env = os.environ.get("INFERENCEKIT_GPU_LOCK")
|
|
425
|
+
if env:
|
|
426
|
+
return Path(env)
|
|
427
|
+
return Path(os.path.expanduser("~")) / ".cache" / "inferencekit" / "gpu.lock"
|
|
428
|
+
|
|
429
|
+
|
|
430
|
+
@contextlib.contextmanager
|
|
431
|
+
def gpu_lock(path: str | os.PathLike | None, owner: str):
|
|
432
|
+
"""Hold an exclusive, non-blocking ``flock`` on ``path`` for the duration of the block.
|
|
433
|
+
|
|
434
|
+
The kernel drops the lock if this process dies, so a crashed run never leaves a stale
|
|
435
|
+
lock behind. The file holds the current holder's pid and owner string, for the error
|
|
436
|
+
message another run prints.
|
|
437
|
+
"""
|
|
438
|
+
p = Path(path) if path else default_lock_path()
|
|
439
|
+
p.parent.mkdir(parents=True, exist_ok=True)
|
|
440
|
+
fd = os.open(p, os.O_RDWR | os.O_CREAT, 0o644)
|
|
441
|
+
try:
|
|
442
|
+
fcntl.flock(fd, fcntl.LOCK_EX | fcntl.LOCK_NB)
|
|
443
|
+
except BlockingIOError:
|
|
444
|
+
try:
|
|
445
|
+
holder = os.pread(fd, 4096, 0).decode(errors="replace").strip() or "unknown holder"
|
|
446
|
+
finally:
|
|
447
|
+
os.close(fd)
|
|
448
|
+
raise OpError(f"GPU lock {p} is held by {holder}: another run is using the GPU. Wait for it, or pass "
|
|
449
|
+
"a different --gpu-lock only if the runs use different GPUs.") from None
|
|
450
|
+
info = {"pid": os.getpid(), "owner": owner, "since": time.strftime("%Y-%m-%dT%H:%M:%S%z")}
|
|
451
|
+
try:
|
|
452
|
+
os.ftruncate(fd, 0)
|
|
453
|
+
os.pwrite(fd, json.dumps(info).encode(), 0)
|
|
454
|
+
yield {"path": str(p), **info}
|
|
455
|
+
finally:
|
|
456
|
+
with contextlib.suppress(OSError):
|
|
457
|
+
os.ftruncate(fd, 0)
|
|
458
|
+
fcntl.flock(fd, fcntl.LOCK_UN)
|
|
459
|
+
os.close(fd)
|
|
460
|
+
|
|
461
|
+
|
|
462
|
+
# --------------------------------------------------------------------------- resolved configuration from logs
|
|
463
|
+
#
|
|
464
|
+
# What an engine *actually* ran with is what it logged, not what the launch command asked
|
|
465
|
+
# for: engines silently fall back (attention backend), clamp (context length, KV cache) or
|
|
466
|
+
# ignore settings. Each parser below reads the server log for the lines the engine prints
|
|
467
|
+
# about its resolved settings. Patterns were checked against the upstream sources on
|
|
468
|
+
# 13 Oct 2026 and kept tolerant (anywhere in the line, any prefix):
|
|
469
|
+
# vLLM vllm/entrypoints/serve/utils/api_utils.py "non-default args: %s"
|
|
470
|
+
# vllm/v1/engine/core.py "Initializing a V1 LLM engine (v%s) with config: %s"
|
|
471
|
+
# vllm/platforms/cuda.py "Using %s backend." / "Using %s attention backend out of ..."
|
|
472
|
+
# vllm/v1/worker/gpu_model_runner.py "Capturing CUDA graphs (%s, %s)", "Graph capturing finished
|
|
473
|
+
# in %.0f secs, took %.2f GiB", "Model loading took ..."
|
|
474
|
+
# vllm/v1/core/kv_cache_utils.py "%s KV cache size: %s tokens", "Maximum concurrency for ..."
|
|
475
|
+
# vllm/platforms/interface.py "Unknown vLLM environment variable detected: %s"
|
|
476
|
+
# SGLang entrypoints/engine.py "server_args={...}" (older releases: "server_args=ServerArgs(...)"),
|
|
477
|
+
# managers/scheduler.py "max_total_num_tokens=..., chunked_prefill_size=..., ...",
|
|
478
|
+
# model_runner.py "Load weight end. ...", older "Capture cuda graph end. Time elapsed: ..."
|
|
479
|
+
# llama.cpp src/llama-context.cpp "<fn>: n_ctx = %u", "<fn>: flash_attn = %s" (auto/enabled/disabled; older 0/1),
|
|
480
|
+
# src/llama-model.cpp "<fn>: offloaded %d/%d layers to GPU", src/llama-kv-cache.cpp
|
|
481
|
+
# "... K (%s): %7.2f MiB, V (%s): ...", common "build: %d (%s) ...", "system_info: ..."
|
|
482
|
+
# TRT-LLM _torch/pyexecutor/py_executor_creator.py "LLM Args:\n%s" (pydantic repr), "[TRT-LLM] [W] ..."
|
|
483
|
+
# Anything else (or a format change) leaves the setting out; with nothing found the
|
|
484
|
+
# resolved config is UNPROVEN and the raw log stays in the run directory.
|
|
485
|
+
#
|
|
486
|
+
# A custom engine (engine kind ``generic``) can also print one line
|
|
487
|
+
# {"inferencekit_resolved_config": {...}}
|
|
488
|
+
# Those settings are SELF_REPORTED: the process under test says so, nothing checks it. A
|
|
489
|
+
# self-report can reveal a hard-gate violation (e.g. ``"cuda_graphs": false``) but never
|
|
490
|
+
# proves there is none, and for a known engine kind (vllm, sglang, llamacpp, trtllm) it is
|
|
491
|
+
# kept for reference only: it never changes the status or the hard-gate flags, which come
|
|
492
|
+
# from the engine's own log lines.
|
|
493
|
+
|
|
494
|
+
ENGINE_KINDS = ("vllm", "sglang", "llamacpp", "trtllm", "generic")
|
|
495
|
+
_ANSI = re.compile(r"\x1b\[[0-9;]*[A-Za-z]")
|
|
496
|
+
_WARN = re.compile(r"\bWARN(?:ING)?\b|\[W\]|\[TRT-LLM\]\[WARNING\]|\bWarning:|UserWarning|DeprecationWarning|"
|
|
497
|
+
r"FutureWarning")
|
|
498
|
+
_ERR = re.compile(r"\bERROR\b|\bCRITICAL\b|Traceback \(most recent call last\)|\berror:|\[E\]")
|
|
499
|
+
MAX_LINES = 100
|
|
500
|
+
|
|
501
|
+
|
|
502
|
+
def engine_kind(name: str | None, launch: list[str] | None = None) -> str:
|
|
503
|
+
"""Map a manifest engine (``name`` and launch argv) to a log parser."""
|
|
504
|
+
hay = " ".join([str(name or "")] + [str(a) for a in (launch or [])[:3]]).lower()
|
|
505
|
+
if "vllm" in hay:
|
|
506
|
+
return "vllm"
|
|
507
|
+
if "sglang" in hay:
|
|
508
|
+
return "sglang"
|
|
509
|
+
if "llama" in hay and ("cpp" in hay or "server" in hay or "llama.cpp" in hay):
|
|
510
|
+
return "llamacpp"
|
|
511
|
+
if "trtllm" in hay or "tensorrt" in hay or "trt-llm" in hay:
|
|
512
|
+
return "trtllm"
|
|
513
|
+
return "generic"
|
|
514
|
+
|
|
515
|
+
|
|
516
|
+
def _scalar(text: str):
|
|
517
|
+
"""'True' -> True, '4096' -> 4096, "'auto'" -> 'auto', 'torch.bfloat16' stays a string."""
|
|
518
|
+
t = text.strip()
|
|
519
|
+
if len(t) >= 2 and t[0] == t[-1] and t[0] in "'\"":
|
|
520
|
+
return t[1:-1]
|
|
521
|
+
low = t.lower()
|
|
522
|
+
if low in ("true", "false"):
|
|
523
|
+
return low == "true"
|
|
524
|
+
if low in ("none", "null"):
|
|
525
|
+
return None
|
|
526
|
+
try:
|
|
527
|
+
return int(t)
|
|
528
|
+
except ValueError:
|
|
529
|
+
pass
|
|
530
|
+
try:
|
|
531
|
+
return float(t)
|
|
532
|
+
except ValueError:
|
|
533
|
+
return t
|
|
534
|
+
|
|
535
|
+
|
|
536
|
+
def split_top_level(text: str, sep: str = ",") -> list[str]:
|
|
537
|
+
"""Split on ``sep`` outside brackets, braces, parentheses, angle brackets and quotes."""
|
|
538
|
+
parts, depth, quote, buf = [], 0, None, []
|
|
539
|
+
for ch in text:
|
|
540
|
+
if quote:
|
|
541
|
+
buf.append(ch)
|
|
542
|
+
if ch == quote:
|
|
543
|
+
quote = None
|
|
544
|
+
continue
|
|
545
|
+
if ch in "'\"":
|
|
546
|
+
quote = ch
|
|
547
|
+
elif ch in "([{<":
|
|
548
|
+
depth += 1
|
|
549
|
+
elif ch in ")]}>":
|
|
550
|
+
depth = max(0, depth - 1)
|
|
551
|
+
elif ch == sep and depth == 0:
|
|
552
|
+
parts.append("".join(buf))
|
|
553
|
+
buf = []
|
|
554
|
+
continue
|
|
555
|
+
buf.append(ch)
|
|
556
|
+
if "".join(buf).strip():
|
|
557
|
+
parts.append("".join(buf))
|
|
558
|
+
return parts
|
|
559
|
+
|
|
560
|
+
|
|
561
|
+
def parse_kv_pairs(text: str, sep: str = ",") -> dict:
|
|
562
|
+
"""``a=1, b='x', c={...}`` -> {"a": 1, "b": "x", "c": "{...}"} (nested values kept as text)."""
|
|
563
|
+
out = {}
|
|
564
|
+
for part in split_top_level(text, sep):
|
|
565
|
+
key, eq, value = part.strip().partition("=")
|
|
566
|
+
if eq and re.fullmatch(r"[A-Za-z_][A-Za-z0-9_.]*", key.strip()):
|
|
567
|
+
out[key.strip()] = _scalar(value)
|
|
568
|
+
return out
|
|
569
|
+
|
|
570
|
+
|
|
571
|
+
def _literal_dict(text: str) -> dict | None:
|
|
572
|
+
try:
|
|
573
|
+
value = ast.literal_eval(text)
|
|
574
|
+
except (ValueError, SyntaxError, MemoryError, RecursionError):
|
|
575
|
+
return None
|
|
576
|
+
return value if isinstance(value, dict) else None
|
|
577
|
+
|
|
578
|
+
|
|
579
|
+
def _lines(text: str) -> list[str]:
|
|
580
|
+
return [_ANSI.sub("", ln).rstrip() for ln in text.splitlines()]
|
|
581
|
+
|
|
582
|
+
|
|
583
|
+
def _collect(lines: list[str], rx: re.Pattern, limit: int = MAX_LINES) -> list[str]:
|
|
584
|
+
seen, out = set(), []
|
|
585
|
+
for ln in lines:
|
|
586
|
+
if rx.search(ln):
|
|
587
|
+
key = re.sub(r"^\S*\s*(INFO|WARNING|ERROR)?\s*\d\d-\d\d \d\d:\d\d:\d\d\S*\s*", "", ln)[:400]
|
|
588
|
+
if key not in seen:
|
|
589
|
+
seen.add(key)
|
|
590
|
+
out.append(ln[:400])
|
|
591
|
+
if len(out) >= limit:
|
|
592
|
+
break
|
|
593
|
+
return out
|
|
594
|
+
|
|
595
|
+
|
|
596
|
+
def _last(rx: re.Pattern, lines: list[str]):
|
|
597
|
+
m = None
|
|
598
|
+
for ln in lines:
|
|
599
|
+
found = rx.search(ln)
|
|
600
|
+
if found:
|
|
601
|
+
m = found
|
|
602
|
+
return m
|
|
603
|
+
|
|
604
|
+
|
|
605
|
+
def _custom(lines: list[str]) -> dict | None:
|
|
606
|
+
for ln in reversed(lines):
|
|
607
|
+
i = ln.find('{"inferencekit_resolved_config"')
|
|
608
|
+
if i >= 0:
|
|
609
|
+
try:
|
|
610
|
+
value = json.loads(ln[i:])["inferencekit_resolved_config"]
|
|
611
|
+
if isinstance(value, dict):
|
|
612
|
+
return value
|
|
613
|
+
except (ValueError, KeyError):
|
|
614
|
+
continue
|
|
615
|
+
return None
|
|
616
|
+
|
|
617
|
+
|
|
618
|
+
# CUDAGraphMode values (vllm/config/compilation.py): NONE=0, PIECEWISE=1, FULL=2, and the pairs
|
|
619
|
+
# FULL_DECODE_ONLY=(FULL, NONE), FULL_AND_PIECEWISE=(FULL, PIECEWISE). JSON dumps print the numbers.
|
|
620
|
+
_CG_NUMERIC = {"0": "NONE", "1": "PIECEWISE", "2": "FULL", "2,0": "FULL_DECODE_ONLY", "2,1": "FULL_AND_PIECEWISE"}
|
|
621
|
+
|
|
622
|
+
|
|
623
|
+
def _cudagraph_mode(comp: str) -> str | None:
|
|
624
|
+
m = re.search(r"['\"]cudagraph_mode['\"]:\s*(?:<CUDAGraphMode\.([A-Z_]+)|['\"]?([A-Z][A-Z_]+)|"
|
|
625
|
+
r"[\[(]\s*(\d)\s*,\s*(\d)\s*[\])]|(\d))", comp)
|
|
626
|
+
if not m:
|
|
627
|
+
return None
|
|
628
|
+
if m.group(1) or m.group(2):
|
|
629
|
+
return m.group(1) or m.group(2)
|
|
630
|
+
if m.group(3) is not None:
|
|
631
|
+
return _CG_NUMERIC.get(f"{m.group(3)},{m.group(4)}", f"({m.group(3)}, {m.group(4)})")
|
|
632
|
+
return _CG_NUMERIC.get(m.group(5), m.group(5))
|
|
633
|
+
|
|
634
|
+
|
|
635
|
+
def _vllm(lines: list[str], out: dict) -> None:
|
|
636
|
+
s, found = out["settings"], out["matched"]
|
|
637
|
+
m = _last(re.compile(r"Initializing a V1 LLM engine \(v([^)]+)\) with config: (.*)$"), lines)
|
|
638
|
+
if m:
|
|
639
|
+
out["version"] = m.group(1)
|
|
640
|
+
cfg = parse_kv_pairs(m.group(2))
|
|
641
|
+
keep = ("model", "served_model_name", "revision", "dtype", "max_seq_len", "quantization", "enforce_eager",
|
|
642
|
+
"kv_cache_dtype", "tensor_parallel_size", "pipeline_parallel_size", "data_parallel_size",
|
|
643
|
+
"enable_prefix_caching", "enable_chunked_prefill", "seed", "load_format", "speculative_config")
|
|
644
|
+
s["engine_config"] = {k: cfg[k] for k in keep if k in cfg}
|
|
645
|
+
comp = str(cfg.get("compilation_config") or "")
|
|
646
|
+
mode = _cudagraph_mode(comp)
|
|
647
|
+
if mode:
|
|
648
|
+
s["cudagraph_mode"] = mode
|
|
649
|
+
sizes = re.search(r"['\"]cudagraph_capture_sizes['\"]:\s*\[([^\]]*)\]", comp)
|
|
650
|
+
if sizes:
|
|
651
|
+
s["cudagraph_capture_sizes_count"] = len([x for x in sizes.group(1).split(",") if x.strip()])
|
|
652
|
+
level = re.search(r"['\"](?:level|mode)['\"]:\s*(?:<CompilationMode\.)?([A-Z_0-9]+)", comp)
|
|
653
|
+
if level:
|
|
654
|
+
s["compilation_mode"] = level.group(1)
|
|
655
|
+
found.append("engine_config")
|
|
656
|
+
if not out.get("version"):
|
|
657
|
+
v = _last(re.compile(r"vLLM (?:API server )?version:? v?([0-9][\w.+-]*)"), lines)
|
|
658
|
+
if v:
|
|
659
|
+
out["version"] = v.group(1)
|
|
660
|
+
m = _last(re.compile(r"non-default args: (\{.*\})\s*$"), lines)
|
|
661
|
+
if m:
|
|
662
|
+
parsed = _literal_dict(m.group(1))
|
|
663
|
+
s["non_default_args"] = parsed if parsed is not None else m.group(1)[:2000]
|
|
664
|
+
found.append("non_default_args")
|
|
665
|
+
from inferencekit.engines.gates import attention_backend_from_log
|
|
666
|
+
|
|
667
|
+
backend = attention_backend_from_log("\n".join(lines))
|
|
668
|
+
if backend:
|
|
669
|
+
s["attention_backend"] = backend
|
|
670
|
+
found.append("attention_backend")
|
|
671
|
+
caps = [m.group(1, 2) for m in (re.search(r"Capturing CUDA graphs \(([^,]+), (\w+)\)", ln) for ln in lines) if m]
|
|
672
|
+
if caps:
|
|
673
|
+
s["cuda_graph_captures"] = sorted({f"{a} {b}" for a, b in caps})
|
|
674
|
+
m = _last(re.compile(r"Graph capturing finished in ([\d.]+) secs?, took (-?[\d.]+) GiB"), lines)
|
|
675
|
+
if m:
|
|
676
|
+
s["cuda_graphs"] = {"captured": True, "seconds": float(m.group(1)), "gib": float(m.group(2))}
|
|
677
|
+
found.append("cuda_graphs")
|
|
678
|
+
m = _last(re.compile(r"(?:(\w+) )?KV cache size: ([\d,]+) tokens"), lines)
|
|
679
|
+
if m:
|
|
680
|
+
s["kv_cache_tokens"] = int(m.group(2).replace(",", ""))
|
|
681
|
+
found.append("kv_cache")
|
|
682
|
+
m = _last(re.compile(r"Maximum concurrency for ([\d,]+) tokens per request: ([\d.]+)x"), lines)
|
|
683
|
+
if m:
|
|
684
|
+
s["max_concurrency"] = {"tokens_per_request": int(m.group(1).replace(",", "")), "x": float(m.group(2))}
|
|
685
|
+
m = _last(re.compile(r"Available KV cache memory: ([\d.]+) GiB"), lines)
|
|
686
|
+
if m:
|
|
687
|
+
s["kv_cache_memory_gib"] = float(m.group(1))
|
|
688
|
+
m = _last(re.compile(r"Model loading took ([\d.]+) GiB(?: memory)? and ([\d.]+) s"), lines)
|
|
689
|
+
if m:
|
|
690
|
+
s["model_memory_gib"] = float(m.group(1))
|
|
691
|
+
for ln in lines:
|
|
692
|
+
u = re.search(r"Unknown vLLM environment variable detected: (\S+)", ln)
|
|
693
|
+
if u:
|
|
694
|
+
out["unknown_settings"].append(f"env {u.group(1)}")
|
|
695
|
+
u = re.search(r"error: unrecognized arguments: (.*)$", ln)
|
|
696
|
+
if u:
|
|
697
|
+
out["unknown_settings"].append(f"args {u.group(1).strip()}")
|
|
698
|
+
flags = {}
|
|
699
|
+
cfg = s.get("engine_config", {})
|
|
700
|
+
if "enforce_eager" in cfg:
|
|
701
|
+
flags["enforce_eager"] = cfg["enforce_eager"]
|
|
702
|
+
if "cudagraph_mode" in s:
|
|
703
|
+
flags["cudagraph_mode"] = s["cudagraph_mode"]
|
|
704
|
+
if s.get("cudagraph_capture_sizes_count") is not None:
|
|
705
|
+
flags["cudagraph_capture_sizes"] = {"count": s["cudagraph_capture_sizes_count"]}
|
|
706
|
+
out["_gates"] = ("vllm", flags, backend)
|
|
707
|
+
core = {"engine_config", "attention_backend"}
|
|
708
|
+
out["status"] = "RESOLVED" if core <= set(found) else "PARTIAL" if found else "UNPROVEN"
|
|
709
|
+
|
|
710
|
+
|
|
711
|
+
def _sglang(lines: list[str], out: dict) -> None:
|
|
712
|
+
s, found = out["settings"], out["matched"]
|
|
713
|
+
m = _last(re.compile(r"server_args=(\{.*\}|ServerArgs\(.*\))\s*$"), lines)
|
|
714
|
+
if m:
|
|
715
|
+
raw = m.group(1)
|
|
716
|
+
if raw.startswith("{"):
|
|
717
|
+
args = _literal_dict(raw)
|
|
718
|
+
if args is None: # repr of non-literal values: fall back to "'key': value" pairs
|
|
719
|
+
args = {k: _scalar(v) for k, v in re.findall(r"'(\w+)':\s*('[^']*'|[^,}]+)", raw)}
|
|
720
|
+
else:
|
|
721
|
+
args = parse_kv_pairs(raw[len("ServerArgs("):-1])
|
|
722
|
+
s["server_args"] = args
|
|
723
|
+
found.append("server_args")
|
|
724
|
+
m = _last(re.compile(r"(max_total_num_tokens=.*)$"), lines)
|
|
725
|
+
if m:
|
|
726
|
+
s["scheduler"] = {k: v for k, v in parse_kv_pairs(m.group(1)).items()}
|
|
727
|
+
found.append("scheduler")
|
|
728
|
+
m = _last(re.compile(r"Load weight end\.(.*)$"), lines)
|
|
729
|
+
if m:
|
|
730
|
+
s["load_weight"] = {k: v for k, v in parse_kv_pairs(m.group(1).rstrip(".")).items()}
|
|
731
|
+
m = _last(re.compile(r"Capture (?:cuda|CUDA) graph end\. Time elapsed: ([\d.]+) s"), lines)
|
|
732
|
+
if m:
|
|
733
|
+
s["cuda_graphs"] = {"captured": True, "seconds": float(m.group(1))}
|
|
734
|
+
found.append("cuda_graphs")
|
|
735
|
+
m = _last(re.compile(r"KV Cache is allocated\. #tokens: (\d+)"), lines)
|
|
736
|
+
if m:
|
|
737
|
+
s["kv_cache_tokens"] = int(m.group(1))
|
|
738
|
+
v = _last(re.compile(r"\bsglang(?:[ =]+version)?[:= ]+v?(\d+\.\d+\.\d+[\w.+-]*)", re.IGNORECASE), lines)
|
|
739
|
+
if v:
|
|
740
|
+
out["version"] = v.group(1)
|
|
741
|
+
for ln in lines:
|
|
742
|
+
u = re.search(r"error: unrecognized arguments: (.*)$", ln)
|
|
743
|
+
if u:
|
|
744
|
+
out["unknown_settings"].append(f"args {u.group(1).strip()}")
|
|
745
|
+
args = s.get("server_args") or {}
|
|
746
|
+
flags = {k: args[k] for k in ("disable_cuda_graph",) if k in args}
|
|
747
|
+
backend = args.get("attention_backend") or args.get("decode_attention_backend")
|
|
748
|
+
out["_gates"] = ("sglang", flags, str(backend) if backend else None)
|
|
749
|
+
out["status"] = "RESOLVED" if "server_args" in found else "PARTIAL" if found else "UNPROVEN"
|
|
750
|
+
|
|
751
|
+
|
|
752
|
+
def _llamacpp(lines: list[str], out: dict) -> None:
|
|
753
|
+
s, found = out["settings"], out["matched"]
|
|
754
|
+
m = _last(re.compile(r"\bbuild:\s*(\d+)\s*\(([0-9a-f]+)\)"), lines)
|
|
755
|
+
if m:
|
|
756
|
+
out["version"] = f"b{m.group(1)} ({m.group(2)})"
|
|
757
|
+
found.append("build")
|
|
758
|
+
m = _last(re.compile(r"system_info:\s*(.*)$"), lines)
|
|
759
|
+
if m:
|
|
760
|
+
s["system_info"] = m.group(1)[:600]
|
|
761
|
+
s["gpu_backend_in_build"] = bool(re.search(r"\b(CUDA|Vulkan|Metal|HIP|ROCm|SYCL|MUSA)\b", m.group(1)))
|
|
762
|
+
g = re.search(r"USE_GRAPHS\s*=\s*(\d)", m.group(1))
|
|
763
|
+
if g:
|
|
764
|
+
s["cuda_graphs_compiled"] = g.group(1) == "1"
|
|
765
|
+
ctx = {}
|
|
766
|
+
keys = ("n_ctx", "n_ctx_seq", "n_ctx_per_seq", "n_batch", "n_ubatch", "n_seq_max", "flash_attn", "causal_attn",
|
|
767
|
+
"kv_unified", "freq_base", "freq_scale")
|
|
768
|
+
rx = re.compile(r"(?:llama_context|llama_new_context_with_model|llama_init_from_model)\s*:\s*(\w+)\s*=\s*(\S+)")
|
|
769
|
+
for ln in lines:
|
|
770
|
+
c = rx.search(ln)
|
|
771
|
+
if c and c.group(1) in keys:
|
|
772
|
+
ctx[c.group(1)] = _scalar(c.group(2))
|
|
773
|
+
if ctx:
|
|
774
|
+
s["context"] = ctx
|
|
775
|
+
found.append("context")
|
|
776
|
+
m = _last(re.compile(r"offloaded (\d+)/(\d+) layers to GPU"), lines)
|
|
777
|
+
if m:
|
|
778
|
+
s["offloaded_layers"] = {"gpu": int(m.group(1)), "total": int(m.group(2))}
|
|
779
|
+
found.append("offload")
|
|
780
|
+
m = _last(re.compile(r"K \((\w+)\):\s*[\d.]+ MiB, V \((\w+)\)"), lines) or \
|
|
781
|
+
_last(re.compile(r"type_k = '(\w+)', type_v = '(\w+)'"), lines)
|
|
782
|
+
if m:
|
|
783
|
+
s["kv_cache_type"] = {"k": m.group(1), "v": m.group(2)}
|
|
784
|
+
m = _last(re.compile(r"Device \d+: (.+?), compute capability ([\d.]+)"), lines)
|
|
785
|
+
if m:
|
|
786
|
+
s["device"] = {"name": m.group(1), "compute_capability": m.group(2)}
|
|
787
|
+
m = _last(re.compile(r"n_slots\s*=\s*(\d+)"), lines)
|
|
788
|
+
if m:
|
|
789
|
+
s["n_slots"] = int(m.group(1))
|
|
790
|
+
for ln in lines:
|
|
791
|
+
u = re.search(r"error: (?:invalid|unknown) argument:? (\S+)", ln)
|
|
792
|
+
if u:
|
|
793
|
+
out["unknown_settings"].append(f"args {u.group(1)}")
|
|
794
|
+
viol = []
|
|
795
|
+
off = s.get("offloaded_layers")
|
|
796
|
+
if off and off["gpu"] < off["total"]:
|
|
797
|
+
viol.append(f"only {off['gpu']}/{off['total']} layers offloaded to the GPU")
|
|
798
|
+
if s.get("gpu_backend_in_build") is False:
|
|
799
|
+
viol.append("no GPU backend in this llama.cpp build (system_info)")
|
|
800
|
+
out["validity"] = {"valid": (not viol) if off or "system_info" in s else None, "violations": viol,
|
|
801
|
+
"warnings": [], "checked": [c for c, k in (("gpu_offload", "offloaded_layers"),
|
|
802
|
+
("gpu_backend", "system_info")) if k in s]}
|
|
803
|
+
core = {"build", "context", "offload"}
|
|
804
|
+
out["status"] = "RESOLVED" if core <= set(found) else "PARTIAL" if found else "UNPROVEN"
|
|
805
|
+
|
|
806
|
+
|
|
807
|
+
def _trtllm(lines: list[str], out: dict) -> None:
|
|
808
|
+
s, found = out["settings"], out["matched"]
|
|
809
|
+
v = _last(re.compile(r"TensorRT-LLM version:? v?([0-9][\w.+-]*)"), lines)
|
|
810
|
+
if v:
|
|
811
|
+
out["version"] = v.group(1)
|
|
812
|
+
text = "\n".join(lines)
|
|
813
|
+
i = text.find("LLM Args:")
|
|
814
|
+
if i >= 0:
|
|
815
|
+
dump = text[i: i + 20000]
|
|
816
|
+
args = {}
|
|
817
|
+
for key in ("backend", "model", "dtype", "max_batch_size", "max_num_tokens", "max_seq_len", "max_input_len",
|
|
818
|
+
"max_beam_width", "attn_backend", "tensor_parallel_size", "enable_chunked_prefill",
|
|
819
|
+
"enable_attention_dp", "disable_overlap_scheduler"):
|
|
820
|
+
m = re.search(rf"\b{key}=('[^']*'|[^\s,)]+)", dump)
|
|
821
|
+
if m:
|
|
822
|
+
args[key] = _scalar(m.group(1))
|
|
823
|
+
m = re.search(r"\bcuda_graph_config=(None|CudaGraphConfig\()", dump)
|
|
824
|
+
if m:
|
|
825
|
+
args["cuda_graphs"] = m.group(1) != "None"
|
|
826
|
+
m = re.search(r"kv_cache_config=KvCacheConfig\((.*?)\)", dump, re.DOTALL)
|
|
827
|
+
if m:
|
|
828
|
+
kv = {k: _scalar(v) for k, v in re.findall(r"\b(dtype|free_gpu_memory_fraction|max_tokens|"
|
|
829
|
+
r"enable_block_reuse)=('[^']*'|[^\s,)]+)", m.group(1))}
|
|
830
|
+
if kv:
|
|
831
|
+
args["kv_cache_config"] = kv
|
|
832
|
+
s["llm_args"] = args
|
|
833
|
+
if args:
|
|
834
|
+
found.append("llm_args")
|
|
835
|
+
flags = {}
|
|
836
|
+
if "cuda_graphs" in (s.get("llm_args") or {}):
|
|
837
|
+
flags["cuda_graphs"] = s["llm_args"]["cuda_graphs"]
|
|
838
|
+
backend = (s.get("llm_args") or {}).get("attn_backend")
|
|
839
|
+
out["_gates"] = ("cmd", flags, str(backend) if backend else None)
|
|
840
|
+
out["status"] = "RESOLVED" if "llm_args" in found and out.get("version") else "PARTIAL" if found or \
|
|
841
|
+
out.get("version") else "UNPROVEN"
|
|
842
|
+
|
|
843
|
+
|
|
844
|
+
_PARSERS = {"vllm": _vllm, "sglang": _sglang, "llamacpp": _llamacpp, "trtllm": _trtllm}
|
|
845
|
+
|
|
846
|
+
|
|
847
|
+
STATUS_RANK = {"RESOLVED": 0, "PARTIAL": 1, "SELF_REPORTED": 2, "UNPROVEN": 3}
|
|
848
|
+
|
|
849
|
+
|
|
850
|
+
def parse_resolved_config(kind: str, log_text: str) -> dict:
|
|
851
|
+
"""Settings an engine reported it resolved, from its server log.
|
|
852
|
+
|
|
853
|
+
Returns ``{"engine_kind", "status": RESOLVED | PARTIAL | SELF_REPORTED | UNPROVEN, "version",
|
|
854
|
+
"settings", "matched", "validity", "warnings", "errors", "unknown_settings", "self_reported"}``.
|
|
855
|
+
RESOLVED means the engine's own configuration dump was found; PARTIAL, only some of
|
|
856
|
+
the lines we look for; SELF_REPORTED, only an ``inferencekit_resolved_config`` line from a
|
|
857
|
+
generic (custom) engine; UNPROVEN, nothing (an unknown engine or a changed log format).
|
|
858
|
+
"""
|
|
859
|
+
from inferencekit.engines import gates
|
|
860
|
+
|
|
861
|
+
lines = _lines(log_text or "")
|
|
862
|
+
out: dict = {"engine_kind": kind if kind in ENGINE_KINDS else "generic", "status": "UNPROVEN", "version": None,
|
|
863
|
+
"settings": {}, "matched": [], "validity": None, "unknown_settings": [], "self_reported": []}
|
|
864
|
+
parser = _PARSERS.get(out["engine_kind"])
|
|
865
|
+
if parser is not None:
|
|
866
|
+
parser(lines, out)
|
|
867
|
+
custom = _custom(lines)
|
|
868
|
+
if custom is not None:
|
|
869
|
+
out["settings"]["self_reported"] = custom
|
|
870
|
+
out["self_reported"] = sorted(str(k) for k in custom)
|
|
871
|
+
if parser is None:
|
|
872
|
+
out["matched"].append("self_reported")
|
|
873
|
+
out["status"] = "SELF_REPORTED"
|
|
874
|
+
else: # a known engine: its own log lines decide; the self-report is only kept
|
|
875
|
+
out["matched"].append("self_reported (ignored: the engine's own log lines decide)")
|
|
876
|
+
g = out.pop("_gates", None)
|
|
877
|
+
if g is not None:
|
|
878
|
+
engine, flags, backend = g
|
|
879
|
+
out["validity"] = gates.check(engine, flags, backend)
|
|
880
|
+
elif out["validity"] is None and custom is not None and parser is None:
|
|
881
|
+
v = gates.check("cmd", custom, custom.get("attention_backend"))
|
|
882
|
+
v["source"] = "self_reported"
|
|
883
|
+
if not v.get("violations"):
|
|
884
|
+
v["valid"] = None # a self-report can show a violation, never prove there is none
|
|
885
|
+
v["warnings"] = list(v.get("warnings") or []) + ["settings self-reported by the engine process "
|
|
886
|
+
"(inferencekit_resolved_config line): not verified"]
|
|
887
|
+
out["validity"] = v
|
|
888
|
+
out["warnings"] = _collect(lines, _WARN)
|
|
889
|
+
out["errors"] = _collect(lines, _ERR, 50)
|
|
890
|
+
out["unknown_settings"] = sorted(set(out["unknown_settings"]))
|
|
891
|
+
if out["status"] == "UNPROVEN":
|
|
892
|
+
out["reason"] = (f"no resolved-configuration lines recognised for engine kind '{out['engine_kind']}'; "
|
|
893
|
+
"the raw log is kept")
|
|
894
|
+
elif out["status"] == "SELF_REPORTED":
|
|
895
|
+
out["reason"] = ("settings come only from the engine process's own inferencekit_resolved_config line "
|
|
896
|
+
"(self-reported, not an engine's configuration dump); the raw log is kept")
|
|
897
|
+
return out
|