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,178 @@
|
|
|
1
|
+
"""``inferencekit mcp serve``: every registered operation as an MCP tool, over stdio.
|
|
2
|
+
|
|
3
|
+
Built on the low-level server of the official ``mcp`` Python SDK (v2), because the
|
|
4
|
+
tool list and input schemas already exist in the registry; no per-tool code here.
|
|
5
|
+
|
|
6
|
+
Tool results are trimmed to stay well under ~10k tokens: big lists inside a record
|
|
7
|
+
are cut to their first items and replaced by a pointer that says how to page through
|
|
8
|
+
the rest with the ``inspect_evidence`` tool (``path``, ``offset``, ``limit``). A result
|
|
9
|
+
that is already a page (``list_evidence``, or ``inspect_evidence`` with a list path)
|
|
10
|
+
is shortened to fewer items instead, with ``next_offset`` moved back to the first item
|
|
11
|
+
that was cut, so following it skips nothing. The stored record is never modified; only
|
|
12
|
+
the view sent to the model is shortened.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import copy
|
|
18
|
+
import json
|
|
19
|
+
from functools import partial
|
|
20
|
+
|
|
21
|
+
from inferencekit import __version__, registry
|
|
22
|
+
from inferencekit.registry import OpError
|
|
23
|
+
|
|
24
|
+
# ~4 characters per token for JSON: 32k characters is about 8k tokens.
|
|
25
|
+
MAX_CHARS = 32_000
|
|
26
|
+
LIST_PREVIEW = 10
|
|
27
|
+
TOOLS_PAGE = 50
|
|
28
|
+
|
|
29
|
+
INSTRUCTIONS = (
|
|
30
|
+
"inferencekit measures inference performance and writes evidence records. Start with doctor; "
|
|
31
|
+
"use roofline (with gpu_bw from measure_bandwidth) for the ceiling. For an optimisation run: "
|
|
32
|
+
"intake_template / intake_validate (start the run dir), validate_manifest (the run manifest), "
|
|
33
|
+
"bench_rounds (every manifest engine, interleaved rounds) or bench_serve (one OpenAI-compatible server: "
|
|
34
|
+
"single-user TTFT/TPOT/decode tok/s, or load mode for N* and $/1M tokens) for published speed numbers; "
|
|
35
|
+
"bench_decode only for engines without a server. run_gates for the anti-cheat checks; quality_trajectory "
|
|
36
|
+
"+ quality_score (agreement with a reference), quality_gsm8k / quality_niah for task accuracy; "
|
|
37
|
+
"prompts_show / prompts_verify for the prompt sets. analyze_nsys / summarize_ncu to explain speed; "
|
|
38
|
+
"bench_image / bench_audio for client-timed media; compare with image_tradeoff=true only for a "
|
|
39
|
+
"labelled image step/precision tradeoff, with separate quality proof. AMD: capture_rocprof / "
|
|
40
|
+
"analyze_rocprof for supported ROCm kernel traces. "
|
|
41
|
+
"compare for A/B verdicts; report to write it up; "
|
|
42
|
+
"build_review for the run's review page; ledger_trailers / history for Perf-* commit trailers. "
|
|
43
|
+
"Pass chat_template_kwargs (e.g. {\"enable_thinking\": false}) identically to bench_serve, bench_rounds, "
|
|
44
|
+
"run_gates, quality_trajectory, quality_gsm8k and quality_niah (an intake pin in the run dir is applied and "
|
|
45
|
+
"enforced when run_dir is given). Long lists in results are truncated: page them with "
|
|
46
|
+
"inspect_evidence(id, path, offset, limit)."
|
|
47
|
+
)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _size(obj) -> int:
|
|
51
|
+
return len(json.dumps(obj, ensure_ascii=False, default=str))
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _lists(node, path=""):
|
|
55
|
+
"""Yield (path, parent, key, list) for every list inside node, largest first by caller."""
|
|
56
|
+
if isinstance(node, dict):
|
|
57
|
+
for k, v in node.items():
|
|
58
|
+
p = f"{path}.{k}" if path else k
|
|
59
|
+
if isinstance(v, list):
|
|
60
|
+
yield p, node, k, v
|
|
61
|
+
yield from _lists(v, p)
|
|
62
|
+
elif isinstance(node, list):
|
|
63
|
+
for i, v in enumerate(node):
|
|
64
|
+
yield from _lists(v, f"{path}.{i}")
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def _trim_strings(node, limit: int) -> None:
|
|
68
|
+
"""Cut every string longer than ``limit`` characters in place, saying how much was cut."""
|
|
69
|
+
items = node.items() if isinstance(node, dict) else enumerate(node) if isinstance(node, list) else []
|
|
70
|
+
for k, v in list(items):
|
|
71
|
+
if isinstance(v, str) and len(v) > limit:
|
|
72
|
+
node[k] = v[:limit] + f"\n... [truncated {len(v) - limit} characters]"
|
|
73
|
+
elif isinstance(v, (dict, list)):
|
|
74
|
+
_trim_strings(v, limit)
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def _is_page(view: dict) -> bool:
|
|
78
|
+
"""A paged result from store.page(): {..., total, offset, items, next_offset}."""
|
|
79
|
+
return isinstance(view.get("items"), list) and "offset" in view and "next_offset" in view
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def _fit_page(view: dict, max_chars: int) -> dict:
|
|
83
|
+
"""Keep the first k items of a page that fit, and point next_offset / the hint at item offset+k."""
|
|
84
|
+
items = view["items"]
|
|
85
|
+
k = len(items)
|
|
86
|
+
while k > 1 and _size({**view, "items": items[:k]}) > max_chars:
|
|
87
|
+
k = max(1, k * 3 // 4)
|
|
88
|
+
if k == len(items):
|
|
89
|
+
return view
|
|
90
|
+
nxt = view["offset"] + k
|
|
91
|
+
view["items"], view["next_offset"] = items[:k], nxt
|
|
92
|
+
if "path" in view: # inspect_evidence: repeat the same path, from the first item not shown
|
|
93
|
+
view["more"] = (f"page shortened to {k} items: inspect_evidence(id='{view.get('evidence_id')}', "
|
|
94
|
+
f"path='{view['path']}', offset={nxt}, limit={k})")
|
|
95
|
+
else: # list_evidence
|
|
96
|
+
view["more"] = f"page shortened to {k} items: call again with offset={nxt}, limit={k}"
|
|
97
|
+
return view
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def fit(result: dict, max_chars: int = MAX_CHARS) -> dict:
|
|
101
|
+
"""Shrink a result for a model's context window, with paging hints for what was cut."""
|
|
102
|
+
if _size(result) <= max_chars:
|
|
103
|
+
return result
|
|
104
|
+
view = copy.deepcopy(result)
|
|
105
|
+
if _is_page(view):
|
|
106
|
+
view = _fit_page(view, max_chars)
|
|
107
|
+
if _size(view) <= max_chars:
|
|
108
|
+
return view
|
|
109
|
+
record = view.get("record", view) # inspect_evidence wraps the record
|
|
110
|
+
eid = record.get("evidence_id") if isinstance(record, dict) else None
|
|
111
|
+
prefix = "record." if "record" in view else ""
|
|
112
|
+
candidates = sorted(_lists(view), key=lambda t: -_size(t[3]))
|
|
113
|
+
for path, parent, key, items in candidates:
|
|
114
|
+
if _size(view) <= max_chars:
|
|
115
|
+
break
|
|
116
|
+
if len(items) <= LIST_PREVIEW:
|
|
117
|
+
continue
|
|
118
|
+
inner = path[len(prefix):] if path.startswith(prefix) else path
|
|
119
|
+
if _is_page(view) and "path" in view and inner.startswith("items."):
|
|
120
|
+
# A list inside one item of a page: "items.0.x" is item offset+0 of the paged path.
|
|
121
|
+
i, _, rest = inner[len("items."):].partition(".")
|
|
122
|
+
inner = f"{view['path']}.{view['offset'] + int(i)}" + (f".{rest}" if rest else "")
|
|
123
|
+
parent[key] = {"truncated": True, "total": len(items), "first": items[:LIST_PREVIEW],
|
|
124
|
+
"more": f"inspect_evidence(id='{eid}', path='{inner}', offset={LIST_PREVIEW}, limit=50)"
|
|
125
|
+
if eid else "call again with offset/limit"}
|
|
126
|
+
if _size(view) > max_chars: # long text, e.g. a report's markdown (the result also gives its file path)
|
|
127
|
+
_trim_strings(view, max_chars // 2)
|
|
128
|
+
if _size(view) > max_chars and isinstance(record, dict) and "data" in record:
|
|
129
|
+
record["data"] = {"truncated": True,
|
|
130
|
+
"more": f"inspect_evidence(id='{eid}', path='data.<key>') for one part at a time",
|
|
131
|
+
"keys": sorted(result.get("record", result).get("data", {}).keys())}
|
|
132
|
+
if _size(view) > max_chars: # last resort: a summary that always fits
|
|
133
|
+
view = {"truncated": True, "evidence_id": eid, "summary": record.get("summary") if isinstance(record, dict)
|
|
134
|
+
else None, "more": "use inspect_evidence with a path"}
|
|
135
|
+
return view
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def build_server():
|
|
139
|
+
try:
|
|
140
|
+
import anyio
|
|
141
|
+
import mcp.types as types
|
|
142
|
+
from mcp.server.lowlevel import Server
|
|
143
|
+
except ImportError as e: # pragma: no cover - mcp is a declared dependency
|
|
144
|
+
raise SystemExit(f"inferencekit mcp serve needs the MCP SDK (pip install 'mcp>=2.3,<3'): {e}") from None
|
|
145
|
+
|
|
146
|
+
ops = registry.load_all()
|
|
147
|
+
tools = [types.Tool(name=op.name, description=f"{op.summary} (CLI: inferencekit {' '.join(op.cli)})",
|
|
148
|
+
input_schema=op.input_schema()) for op in ops.values()]
|
|
149
|
+
|
|
150
|
+
async def list_tools(ctx, params):
|
|
151
|
+
start = int(params.cursor) if params is not None and params.cursor else 0
|
|
152
|
+
page = tools[start:start + TOOLS_PAGE]
|
|
153
|
+
nxt = start + TOOLS_PAGE
|
|
154
|
+
return types.ListToolsResult(tools=page, next_cursor=str(nxt) if nxt < len(tools) else None)
|
|
155
|
+
|
|
156
|
+
async def call_tool(ctx, params):
|
|
157
|
+
try:
|
|
158
|
+
# Operations block on subprocesses for minutes; keep the event loop free.
|
|
159
|
+
result = await anyio.to_thread.run_sync(partial(registry.call, params.name, params.arguments or {}))
|
|
160
|
+
except OpError as e:
|
|
161
|
+
return types.CallToolResult(content=[types.TextContent(text=f"error: {e}")], is_error=True)
|
|
162
|
+
view = fit(result)
|
|
163
|
+
return types.CallToolResult(content=[types.TextContent(text=json.dumps(view, ensure_ascii=False))])
|
|
164
|
+
|
|
165
|
+
server = Server("inferencekit", version=__version__, instructions=INSTRUCTIONS, on_list_tools=list_tools,
|
|
166
|
+
on_call_tool=call_tool)
|
|
167
|
+
return server, anyio
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def serve() -> None:
|
|
171
|
+
server, anyio = build_server()
|
|
172
|
+
from mcp.server.stdio import stdio_server
|
|
173
|
+
|
|
174
|
+
async def main():
|
|
175
|
+
async with stdio_server() as (read_stream, write_stream):
|
|
176
|
+
await server.run(read_stream, write_stream, server.create_initialization_options())
|
|
177
|
+
|
|
178
|
+
anyio.run(main)
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
"""Every operation module registers itself on import. Order = order in ``inferencekit --help``."""
|
|
2
|
+
|
|
3
|
+
from inferencekit.ops import ( # noqa: F401
|
|
4
|
+
doctor,
|
|
5
|
+
roofline,
|
|
6
|
+
ceiling,
|
|
7
|
+
bench,
|
|
8
|
+
nsys,
|
|
9
|
+
ncu,
|
|
10
|
+
rocprof,
|
|
11
|
+
compare,
|
|
12
|
+
correctness,
|
|
13
|
+
store,
|
|
14
|
+
report,
|
|
15
|
+
review,
|
|
16
|
+
ledger,
|
|
17
|
+
manifest,
|
|
18
|
+
)
|
|
19
|
+
from inferencekit.ops import intake # noqa: F401,E402
|
|
20
|
+
from inferencekit.ops import quality # noqa: F401,E402
|
|
21
|
+
from inferencekit.ops import bench_serve # noqa: E402,F401
|
|
22
|
+
from inferencekit.ops import bench_rounds # noqa: F401,E402
|
|
23
|
+
from inferencekit.ops import prompts # noqa: F401,E402
|
|
24
|
+
from inferencekit.ops import gates # noqa: F401,E402
|
|
25
|
+
from inferencekit.ops import bench_clip # noqa: F401,E402 (video: bench clip)
|
|
26
|
+
from inferencekit.ops import quality_clip # noqa: F401,E402 (video: quality clip)
|
|
27
|
+
from inferencekit.ops import quality_clip_steps # noqa: F401,E402 (video: quality clip-steps)
|
|
28
|
+
from inferencekit.ops import gates_clip # noqa: F401,E402 (video: gates clip; needs bench_clip)
|
|
29
|
+
from inferencekit.ops import bench_image # noqa: F401,E402
|
|
30
|
+
from inferencekit.ops import bench_audio # noqa: F401,E402
|
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
"""bench decode: batch-1 decode speed, TTFT and per-token latency, with the engine's real flags.
|
|
2
|
+
|
|
3
|
+
Definitions (per round):
|
|
4
|
+
ttft_ms wall time of a 1-token generation: prefill + first sample (offline, no network)
|
|
5
|
+
decode_tok_s (tokens - 1) / (t_total - ttft), t_total = wall time of a gen_len generation
|
|
6
|
+
tpot_ms (t_total - ttft) / (tokens - 1): time per output token after the first
|
|
7
|
+
|
|
8
|
+
Reported value = median over rounds, with a 95% bootstrap CI over rounds. Rounds are
|
|
9
|
+
the unit of repetition because tokens inside one generation are not independent.
|
|
10
|
+
|
|
11
|
+
Interleaving: to compare A and B fairly, alternate them (A, B, A, B ...) so slow
|
|
12
|
+
drift (clocks, temperature, neighbours) hits both equally. ``--extend <ev_id>`` runs
|
|
13
|
+
more rounds of the same configuration and writes a new record holding the old and
|
|
14
|
+
new rounds, each with wall-clock timestamps; ``compare`` checks the alternation.
|
|
15
|
+
|
|
16
|
+
Environment: the engine runs with the caller's full environment (unlike profiler
|
|
17
|
+
captures, which use an allowlist). The record lists the *names* of the engine-
|
|
18
|
+
configuration variables that were set (``data.env_config_names``), so a profile taken
|
|
19
|
+
with a different configuration can be spotted. Secret-looking keys inside
|
|
20
|
+
``--engine-args`` (``hf_token``, ``api_key``...) are stored as <redacted>.
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
from __future__ import annotations
|
|
24
|
+
|
|
25
|
+
import json
|
|
26
|
+
import sys
|
|
27
|
+
|
|
28
|
+
from inferencekit import evidence as ev
|
|
29
|
+
from inferencekit import stats
|
|
30
|
+
from inferencekit.engines import adapters, gates
|
|
31
|
+
from inferencekit.gpuinfo import gpu_identity
|
|
32
|
+
from inferencekit.ops.doctor import gpu_processes
|
|
33
|
+
from inferencekit.proc import config_env_names
|
|
34
|
+
from inferencekit.registry import Arg, OpError, operation
|
|
35
|
+
|
|
36
|
+
ENGINES = ("vllm", "sglang", "llamacpp", "cmd")
|
|
37
|
+
# Arguments that must match for --extend: they define "the same configuration".
|
|
38
|
+
SAME_CONFIG = ("engine", "model", "prompt_len", "gen_len", "seed", "engine_args", "cmd")
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _engine_args(text: str) -> dict:
|
|
42
|
+
try:
|
|
43
|
+
value = json.loads(text or "{}")
|
|
44
|
+
except ValueError as e:
|
|
45
|
+
raise OpError(f"engine_args is not valid JSON: {e}") from None
|
|
46
|
+
if not isinstance(value, dict):
|
|
47
|
+
raise OpError("engine_args must be a JSON object, e.g. '{\"max_model_len\": 4096}'")
|
|
48
|
+
return value
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _ci_metric(name: str, unit: str, values: list[float]) -> dict:
|
|
52
|
+
if not values:
|
|
53
|
+
return ev.metric(name, None, unit, "median", n=0)
|
|
54
|
+
ci = stats.bootstrap_ci(values, ps=(50,))[50]
|
|
55
|
+
return ev.metric(name, stats.summarize(values)["median"], unit, "median", n=len(values), ci95=ci)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
@operation("bench_decode", ("bench", "decode"), "Batch-1 decode tok/s, per-token latency and TTFT for vllm, "
|
|
59
|
+
"sglang, llama.cpp or any command; records effective engine flags and hard-gate violations.",
|
|
60
|
+
[Arg("engine", "string", "Engine adapter.", required=True, choices=ENGINES),
|
|
61
|
+
Arg("model", "string", "HF id or local path (a .gguf file for llamacpp). Optional for cmd."),
|
|
62
|
+
Arg("prompt_len", "integer", "Prompt length in tokens.", default=128),
|
|
63
|
+
Arg("gen_len", "integer", "Generated tokens per round (>= 2).", default=128),
|
|
64
|
+
Arg("rounds", "integer", "Timed rounds.", default=5),
|
|
65
|
+
Arg("warmup", "integer", "Untimed warm-up generations (the first also records reference tokens).",
|
|
66
|
+
default=1),
|
|
67
|
+
Arg("seed", "integer", "Seed for synthetic prompts and the engine.", default=0),
|
|
68
|
+
Arg("engine_args", "string", "JSON object passed to the engine (vLLM LLM(...), SGLang Engine(...), "
|
|
69
|
+
"or llama-bench flags such as {\"ngl\": 99, \"fa\": 1}). Values of secret-looking keys "
|
|
70
|
+
"(hf_token, api_key...) are stored as <redacted>.", default="{}"),
|
|
71
|
+
Arg("python", "string", "Interpreter that has the engine installed (vllm/sglang). Default: this one."),
|
|
72
|
+
Arg("cmd", "string", "For engine=cmd: command printing a JSON line {\"decode_tok_s\": ..., "
|
|
73
|
+
"\"ttft_ms\": ...}. Stored only as a digest.", sensitive=True),
|
|
74
|
+
Arg("extend", "string", "Evidence id of an earlier run of the same configuration; its rounds are kept "
|
|
75
|
+
"and new rounds appended (use to interleave A/B rounds)."),
|
|
76
|
+
Arg("record_tokens", "boolean", "Store the reference generation's token ids (for correctness).",
|
|
77
|
+
default=True),
|
|
78
|
+
Arg("timeout", "integer", "Seconds before the engine run (and every process it started) is killed.",
|
|
79
|
+
default=3600)])
|
|
80
|
+
def bench_decode(call, engine, model, prompt_len, gen_len, rounds, warmup, seed, engine_args, python, cmd, extend,
|
|
81
|
+
record_tokens, timeout):
|
|
82
|
+
if gen_len < 2:
|
|
83
|
+
raise OpError("gen_len must be >= 2 (decode speed needs tokens after the first)")
|
|
84
|
+
if rounds < 1:
|
|
85
|
+
raise OpError("rounds must be >= 1")
|
|
86
|
+
if engine != "cmd" and not model:
|
|
87
|
+
raise OpError(f"engine={engine} needs --model")
|
|
88
|
+
if engine == "cmd" and not cmd:
|
|
89
|
+
raise OpError("engine=cmd needs --cmd")
|
|
90
|
+
eargs = _engine_args(engine_args)
|
|
91
|
+
store = ev.Store()
|
|
92
|
+
|
|
93
|
+
prev, prev_rounds = None, []
|
|
94
|
+
if extend:
|
|
95
|
+
prev = store.load(extend)
|
|
96
|
+
if prev.get("kind") != "benchmark":
|
|
97
|
+
raise OpError(f"--extend {extend}: not a benchmark record")
|
|
98
|
+
problem = ev.integrity_problem(prev)
|
|
99
|
+
if problem:
|
|
100
|
+
raise OpError(f"--extend: {problem}; refusing to build on it")
|
|
101
|
+
prev_args = prev["data"].get("request", {}).get("args", {})
|
|
102
|
+
mine = ev.redact(call.request()["args"]) # stored args went through the same redaction
|
|
103
|
+
diff = [k for k in SAME_CONFIG if prev_args.get(k) != mine.get(k)]
|
|
104
|
+
if diff:
|
|
105
|
+
raise OpError(f"--extend needs the same configuration; these differ: {diff}")
|
|
106
|
+
prev_rounds = prev["data"].get("rounds", [])
|
|
107
|
+
offset = len(prev_rounds)
|
|
108
|
+
|
|
109
|
+
# Leftovers from an earlier run (an orphaned engine, someone else's job) steal bandwidth.
|
|
110
|
+
others = gpu_processes()
|
|
111
|
+
with store.staging_dir() as staging: # removed again if the run fails
|
|
112
|
+
cfg = {"model": model, "prompt_len": prompt_len, "gen_len": gen_len, "rounds": rounds, "warmup": warmup,
|
|
113
|
+
"seed": seed, "engine_args": eargs, "round_offset": offset}
|
|
114
|
+
if engine in ("vllm", "sglang"):
|
|
115
|
+
result = adapters.run_python_worker(engine, python or sys.executable, cfg, staging, timeout)
|
|
116
|
+
elif engine == "llamacpp":
|
|
117
|
+
result = adapters.run_llamacpp(model, prompt_len, gen_len, rounds, eargs, staging, timeout)
|
|
118
|
+
for r in result["rounds"]:
|
|
119
|
+
r["index"] += offset
|
|
120
|
+
else:
|
|
121
|
+
result = adapters.run_cmd(cmd, rounds, offset, timeout)
|
|
122
|
+
|
|
123
|
+
flags = result.get("flags") or {}
|
|
124
|
+
backend = gates.attention_backend_from_log(result.get("log_text", "")) if engine == "vllm" else None
|
|
125
|
+
gate = gates.check(engine, flags, backend)
|
|
126
|
+
limitations = list(result.get("limitations", []))
|
|
127
|
+
if others:
|
|
128
|
+
limitations.append(f"{len(others)} other compute process(es) were on the GPU when the run started ("
|
|
129
|
+
+ ", ".join(f"{o['name']} {o['used_mib']} MiB" for o in others[:4])
|
|
130
|
+
+ "): timings may be contended")
|
|
131
|
+
limitations += [f"HARD GATE: {v}" for v in gate["violations"]]
|
|
132
|
+
limitations += gate["warnings"]
|
|
133
|
+
if gate["valid"] is None:
|
|
134
|
+
limitations.append("engine flags not visible to inferencekit: CUDA graphs / attention backend not checked")
|
|
135
|
+
if prev is not None:
|
|
136
|
+
old_flags = prev["identity"]["engine"].get("flags", {})
|
|
137
|
+
if old_flags != ev.redact(flags):
|
|
138
|
+
limitations.append(f"effective engine flags changed since {prev['evidence_id'][:15]}")
|
|
139
|
+
|
|
140
|
+
all_rounds = prev_rounds + result["rounds"]
|
|
141
|
+
decode = [r["decode_tok_s"] for r in all_rounds if r.get("decode_tok_s") is not None]
|
|
142
|
+
ttft = [r["ttft_ms"] for r in all_rounds if r.get("ttft_ms") is not None]
|
|
143
|
+
tpot = [r["tpot_ms"] for r in all_rounds if r.get("tpot_ms") is not None]
|
|
144
|
+
missing = []
|
|
145
|
+
if not ttft:
|
|
146
|
+
missing.append("ttft_ms")
|
|
147
|
+
if len(decode) < 2:
|
|
148
|
+
limitations.append("fewer than 2 rounds: no confidence interval")
|
|
149
|
+
metrics = [_ci_metric("decode_tok_s", "tok/s", decode), _ci_metric("ttft_ms", "ms", ttft),
|
|
150
|
+
_ci_metric("tpot_ms", "ms", tpot)]
|
|
151
|
+
|
|
152
|
+
eng = result.get("engine") or {}
|
|
153
|
+
ident = ev.identity(
|
|
154
|
+
gpu=gpu_identity(),
|
|
155
|
+
engine={"name": eng.get("name") or engine, "version": eng.get("version"), "commit": eng.get("commit"),
|
|
156
|
+
"flags": flags},
|
|
157
|
+
model={"id": model, "revision": eargs.get("revision"),
|
|
158
|
+
"dtype": str(flags["dtype"]) if flags.get("dtype") is not None else None,
|
|
159
|
+
"quant": str(flags.get("quantization")) if flags.get("quantization") else None},
|
|
160
|
+
workload={"prompt_len": prompt_len, "gen_len": gen_len, "batch": 1, "seed": seed})
|
|
161
|
+
med = metrics[0]["value"]
|
|
162
|
+
lo, hi = metrics[0]["ci95"] or (None, None)
|
|
163
|
+
summary = f"{ident['engine']['name']} {model or ''}".strip() + ": decode "
|
|
164
|
+
summary += f"{med:.1f} tok/s" if med is not None else "n/a"
|
|
165
|
+
if lo is not None and hi is not None:
|
|
166
|
+
summary += f" (95% CI {lo:.1f}-{hi:.1f}, n={len(decode)})"
|
|
167
|
+
if metrics[1]["value"] is not None:
|
|
168
|
+
summary += f", TTFT {metrics[1]['value']:.1f} ms"
|
|
169
|
+
if gate["violations"]:
|
|
170
|
+
summary += " [HARD GATE: " + "; ".join(gate["violations"]) + "]"
|
|
171
|
+
tokens = result.get("tokens") if record_tokens else None
|
|
172
|
+
if tokens is None and prev is not None:
|
|
173
|
+
tokens = prev["data"].get("tokens")
|
|
174
|
+
record = ev.build(
|
|
175
|
+
call, kind="benchmark", claim_class="observed", summary=summary, identity_=ident, metrics=metrics,
|
|
176
|
+
artifacts=[ev.artifact(p, staging, role) for p, role in result.get("files", []) if p.is_file()],
|
|
177
|
+
missing=missing, limitations=limitations,
|
|
178
|
+
data={"rounds": all_rounds, "tokens": tokens, "prompt": result.get("prompt"),
|
|
179
|
+
"claim_validity": gate, "extends": prev["evidence_id"] if prev else None,
|
|
180
|
+
"env_config_names": config_env_names(), "gpu_processes_at_start": len(others),
|
|
181
|
+
"definitions": {"ttft_ms": "wall time of a 1-token generation (prefill + first sample)",
|
|
182
|
+
"decode_tok_s": "(tokens - 1) / (t_total - ttft) per round",
|
|
183
|
+
"tpot_ms": "(t_total - ttft) / (tokens - 1) per round"}})
|
|
184
|
+
return store.save(record, staging)
|