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,317 @@
|
|
|
1
|
+
"""report: one fixed-format write-up from a set of evidence ids.
|
|
2
|
+
|
|
3
|
+
Sections, always in this order: verdict, environment card, results table, findings,
|
|
4
|
+
limitations, reproduce. A fixed shape makes reports comparable and makes omissions
|
|
5
|
+
visible (an empty limitations section is a claim in itself).
|
|
6
|
+
|
|
7
|
+
Claim verdicts: PASS | FAIL | UNPROVEN | NOT RUN.
|
|
8
|
+
comparison expect=improved: improved -> PASS, regressed / within_noise -> FAIL,
|
|
9
|
+
inconclusive -> UNPROVEN. expect=no_regression: improved / within_noise
|
|
10
|
+
-> PASS, regressed -> FAIL, inconclusive -> UNPROVEN. A PASS is
|
|
11
|
+
downgraded to UNPROVEN when rounds were not verified as interleaved or
|
|
12
|
+
either arm violates a hard gate.
|
|
13
|
+
correctness its own PASS / FAIL / UNPROVEN.
|
|
14
|
+
benchmark "runs under production-like settings": hard gates clean -> PASS,
|
|
15
|
+
violated or unchecked -> UNPROVEN (the number is real, the claim is not).
|
|
16
|
+
any record whose id does not match its content -> UNPROVEN.
|
|
17
|
+
Overall: any FAIL -> FAIL, else any UNPROVEN -> UNPROVEN, else PASS; no claims -> NOT RUN.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
from __future__ import annotations
|
|
21
|
+
|
|
22
|
+
import json
|
|
23
|
+
|
|
24
|
+
from inferencekit import evidence as ev
|
|
25
|
+
from inferencekit import registry, stats
|
|
26
|
+
from inferencekit.canonical import sha256_hex
|
|
27
|
+
from inferencekit.registry import Arg, operation
|
|
28
|
+
|
|
29
|
+
COMPARISON_TO_CLAIM = {
|
|
30
|
+
"improved": {"improved": "PASS", "regressed": "FAIL", "within_noise": "FAIL", "inconclusive": "UNPROVEN"},
|
|
31
|
+
"no_regression": {"improved": "PASS", "regressed": "FAIL", "within_noise": "PASS", "inconclusive": "UNPROVEN"},
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def short(eid: str | None) -> str:
|
|
36
|
+
return (eid or "?")[:15]
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _fmt(x, digits: int = 4) -> str:
|
|
40
|
+
if x is None:
|
|
41
|
+
return "-"
|
|
42
|
+
if isinstance(x, float):
|
|
43
|
+
return f"{x:.{digits}g}" if abs(x) < 1e6 else f"{x:.4e}"
|
|
44
|
+
return str(x)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _cell(x) -> str:
|
|
48
|
+
return str(x).replace("|", "\\|").replace("\n", " ")
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def claims_of(rec: dict, expect: str, integrity: dict) -> list[dict]:
|
|
52
|
+
kind, data, eid = rec.get("kind"), rec.get("data", {}), rec.get("evidence_id")
|
|
53
|
+
out = []
|
|
54
|
+
if kind == "comparison":
|
|
55
|
+
verdict = COMPARISON_TO_CLAIM[expect].get(data.get("verdict"), "UNPROVEN")
|
|
56
|
+
reason = f"{data.get('metric')} {data.get('verdict')}: {data.get('decision', {}).get('reason', '')}"
|
|
57
|
+
if verdict == "PASS" and data.get("interleaved") is not True:
|
|
58
|
+
verdict, reason = "UNPROVEN", reason + "; rounds not verified as interleaved"
|
|
59
|
+
if verdict == "PASS" and any("hard gates" in lim for lim in rec.get("limitations", [])):
|
|
60
|
+
verdict, reason = "UNPROVEN", reason + "; an arm violates hard gates"
|
|
61
|
+
out.append({"source": eid, "claim": f"candidate vs baseline ({expect})", "verdict": verdict,
|
|
62
|
+
"reason": reason})
|
|
63
|
+
if data.get("source_kind") in ("image_benchmark", "audio_benchmark"):
|
|
64
|
+
for claim, proof in data.get("required_proofs", {}).items():
|
|
65
|
+
out.append({"source": eid, "claim": claim, "verdict": proof,
|
|
66
|
+
"reason": "A timing comparison does not supply this proof; cite external modality evidence."})
|
|
67
|
+
if data.get("source_kind") == "audio_benchmark":
|
|
68
|
+
for arm, timing in data.get("arm_timing_verdicts", {}).items():
|
|
69
|
+
out.append({"source": eid, "claim": f"{arm} speech latency and streaming SLOs",
|
|
70
|
+
"verdict": timing if timing in ("PASS", "FAIL") else "UNPROVEN",
|
|
71
|
+
"reason": "Absolute first-audio, RTF and underrun limits from the source benchmark."})
|
|
72
|
+
elif kind in ("image_benchmark", "audio_benchmark"):
|
|
73
|
+
validity = data.get("claim_validity") or {}
|
|
74
|
+
structure = "PASS" if validity.get("valid") is True else "FAIL" if validity.get("valid") is False else "UNPROVEN"
|
|
75
|
+
out.append({"source": eid, "claim": "complete media output and matching request settings",
|
|
76
|
+
"verdict": structure, "reason": "; ".join(validity.get("violations", []))})
|
|
77
|
+
if kind == "audio_benchmark":
|
|
78
|
+
timing = data.get("timing_verdict")
|
|
79
|
+
out.append({"source": eid, "claim": "speech latency and streaming SLOs",
|
|
80
|
+
"verdict": timing if timing in ("PASS", "FAIL") else "UNPROVEN",
|
|
81
|
+
"reason": "First-audio p95, complete-audio RTF and simulated playback underruns."})
|
|
82
|
+
out.append({"source": eid, "claim": "media quality and usability",
|
|
83
|
+
"verdict": "UNPROVEN", "reason": "Timing and structural checks do not establish image quality, "
|
|
84
|
+
"speech intelligibility, speaker fidelity or repeated-run reliability; cite task-specific quality evidence."})
|
|
85
|
+
elif kind == "correctness":
|
|
86
|
+
out.append({"source": eid, "claim": "greedy outputs agree", "verdict": data.get("verdict", "UNPROVEN"),
|
|
87
|
+
"reason": data.get("reason", "")})
|
|
88
|
+
elif kind == "benchmark":
|
|
89
|
+
validity = data.get("claim_validity") or {}
|
|
90
|
+
if validity.get("valid") is True:
|
|
91
|
+
out.append({"source": eid, "claim": "benchmark passes hard gates", "verdict": "PASS",
|
|
92
|
+
"reason": "checked: " + ", ".join(validity.get("checked", []))})
|
|
93
|
+
else:
|
|
94
|
+
why = "; ".join(validity.get("violations", [])) or "engine flags not visible: gates not checked"
|
|
95
|
+
out.append({"source": eid, "claim": "benchmark passes hard gates", "verdict": "UNPROVEN", "reason": why})
|
|
96
|
+
if not integrity["id_ok"]:
|
|
97
|
+
for c in out:
|
|
98
|
+
c["verdict"], c["reason"] = "UNPROVEN", "evidence id does not match content (edited after capture?)"
|
|
99
|
+
if not out:
|
|
100
|
+
out.append({"source": eid, "claim": "evidence integrity", "verdict": "UNPROVEN",
|
|
101
|
+
"reason": "evidence id does not match content"})
|
|
102
|
+
return out
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def workload_text(w: dict) -> str:
|
|
106
|
+
"""One line for the environment card: bench decode's prompt/gen/batch, or bench serve's mode, bucket,
|
|
107
|
+
generation length, load grid and prompt set (a serve record has no single prompt length)."""
|
|
108
|
+
if w.get("prompt_len") is not None:
|
|
109
|
+
return f"prompt {w.get('prompt_len')}, gen {w.get('gen_len')}, batch {w.get('batch')}"
|
|
110
|
+
if not w.get("mode"):
|
|
111
|
+
return "-"
|
|
112
|
+
parts = [f"{w['mode']}"]
|
|
113
|
+
parts.append(f"bucket {w['bucket']}" if w.get("bucket") else "all buckets")
|
|
114
|
+
if w.get("gen_len") is not None:
|
|
115
|
+
parts.append(f"gen {w['gen_len']}")
|
|
116
|
+
if w.get("concurrency") is not None:
|
|
117
|
+
parts.append(f"concurrency {w['concurrency']}")
|
|
118
|
+
if w.get("rate_rps") is not None:
|
|
119
|
+
parts.append(f"rate {w['rate_rps']} req/s")
|
|
120
|
+
ps = w.get("prompt_set") or {}
|
|
121
|
+
if ps.get("name") or ps.get("sha256"):
|
|
122
|
+
parts.append(f"set {ps.get('name') or '?'} ({str(ps.get('sha256') or '')[:12]})")
|
|
123
|
+
if w.get("prefix_cache"):
|
|
124
|
+
parts.append(f"{w['prefix_cache']} prefix cache")
|
|
125
|
+
return ", ".join(parts)
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def environment_rows(recs: list[dict]) -> list[dict]:
|
|
129
|
+
seen, rows = set(), []
|
|
130
|
+
for r in recs:
|
|
131
|
+
i = r.get("identity", {})
|
|
132
|
+
g, e, m, w = i.get("gpu", {}), i.get("engine", {}), i.get("model", {}), i.get("workload", {})
|
|
133
|
+
gpu = g.get("name") or "?"
|
|
134
|
+
row = {"gpu": f"{g['count']}x {gpu}" if g.get("count") else gpu,
|
|
135
|
+
"driver / CUDA": f"{g.get('driver') or '?'} / {g.get('cuda') or '?'}", "clocks": g.get("clocks") or "?",
|
|
136
|
+
"engine": " ".join(str(x) for x in (e.get("name"), e.get("version")) if x) or "-",
|
|
137
|
+
"model": " ".join(str(x) for x in (m.get("id"), m.get("dtype"), m.get("quant")) if x) or "-",
|
|
138
|
+
"workload": workload_text(w)}
|
|
139
|
+
key = json.dumps(row, sort_keys=True)
|
|
140
|
+
if key not in seen:
|
|
141
|
+
seen.add(key)
|
|
142
|
+
rows.append(row)
|
|
143
|
+
return rows
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def context_note(roof: dict, bench: dict, tolerance: float = 0.01) -> str:
|
|
147
|
+
"""A caveat when the roofline's KV-cache context does not match the benchmark's.
|
|
148
|
+
|
|
149
|
+
The ceiling counts the KV cache at one context length. During a benchmark the context
|
|
150
|
+
grows from prompt_len to prompt_len + gen_len, so its mean is prompt_len + gen_len / 2.
|
|
151
|
+
If that changes the bytes read per token by more than ``tolerance``, the "% of ceiling"
|
|
152
|
+
compares different workloads.
|
|
153
|
+
"""
|
|
154
|
+
counts = roof.get("data", {}).get("counts", {})
|
|
155
|
+
w = bench.get("identity", {}).get("workload", {})
|
|
156
|
+
kv_per_ctx, per_token, ctx = (counts.get("kv_bytes_per_context_token"), counts.get("bytes_per_token"),
|
|
157
|
+
counts.get("context"))
|
|
158
|
+
if not kv_per_ctx or not per_token or ctx is None or w.get("prompt_len") is None:
|
|
159
|
+
return ""
|
|
160
|
+
mean_ctx = w["prompt_len"] + (w.get("gen_len") or 0) / 2
|
|
161
|
+
diff = kv_per_ctx * abs(mean_ctx - ctx) / per_token
|
|
162
|
+
if diff <= tolerance:
|
|
163
|
+
return ""
|
|
164
|
+
return (f" (roofline context {ctx} vs benchmark mean context {mean_ctx:.0f}: bytes per token differ by "
|
|
165
|
+
f"~{100 * diff:.1f}%; rerun roofline --context {mean_ctx:.0f})")
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def findings(recs: list[dict]) -> list[str]:
|
|
169
|
+
out = [f"`{short(r['evidence_id'])}` ({r['kind']}, {r['claim_class']}): {r['summary']}" for r in recs]
|
|
170
|
+
# Cross-record: measured decode speed as a share of the bandwidth ceiling.
|
|
171
|
+
ceilings = [(r, m) for r in recs if r["kind"] == "roofline" for m in r["metrics"] if m["name"] == "ceiling_tok_s"]
|
|
172
|
+
for b in (r for r in recs if r["kind"] == "benchmark"):
|
|
173
|
+
tok = next((m["value"] for m in b["metrics"] if m["name"] == "decode_tok_s"), None)
|
|
174
|
+
for roof, m in ceilings:
|
|
175
|
+
if tok and m["value"]:
|
|
176
|
+
same = roof["identity"]["model"].get("id") == b["identity"]["model"].get("id")
|
|
177
|
+
note = "" if same else " (model ids differ: check they are the same weights)"
|
|
178
|
+
note += context_note(roof, b)
|
|
179
|
+
out.append(f"`{short(b['evidence_id'])}` decodes at {100 * tok / m['value']:.1f}% of the "
|
|
180
|
+
f"bandwidth ceiling from `{short(roof['evidence_id'])}` ({tok:.1f} of {m['value']:.1f} "
|
|
181
|
+
f"tok/s){note}")
|
|
182
|
+
for r in recs:
|
|
183
|
+
if r["kind"] == "ncu_kernels":
|
|
184
|
+
for k in r["data"].get("kernels", [])[:8]:
|
|
185
|
+
out.append(f"`{short(r['evidence_id'])}` kernel #{k['index']} `{k['kernel'][:60]}`: {k['bound']}, "
|
|
186
|
+
f"DRAM {_fmt(k['dram_pct'])}% / SM {_fmt(k['sm_pct'])}% of peak")
|
|
187
|
+
if r["kind"] == "nsys_timeline":
|
|
188
|
+
hist = r["data"].get("idle_gap_histogram", {})
|
|
189
|
+
small = hist.get("10-100us", {}).get("count", 0)
|
|
190
|
+
if small:
|
|
191
|
+
out.append(f"`{short(r['evidence_id'])}`: {small} idle gaps of 10-100 us (typical of per-kernel "
|
|
192
|
+
f"launch overhead; CUDA graphs remove most of them)")
|
|
193
|
+
return out
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
def reproduce_command(rec: dict) -> str | None:
|
|
197
|
+
req = rec.get("data", {}).get("request")
|
|
198
|
+
if not req:
|
|
199
|
+
return None
|
|
200
|
+
try:
|
|
201
|
+
op = registry.get(req["op"])
|
|
202
|
+
except registry.OpError:
|
|
203
|
+
return None
|
|
204
|
+
args = req.get("args", {})
|
|
205
|
+
parts = ["inferencekit", *op.cli]
|
|
206
|
+
tail = []
|
|
207
|
+
for a in op.args:
|
|
208
|
+
v = args.get(a.name)
|
|
209
|
+
if v is None or v == a.default:
|
|
210
|
+
continue
|
|
211
|
+
shown = f"<{v['digest'][:19]}...>" if isinstance(v, dict) and "digest" in v else v
|
|
212
|
+
if a.name == "argv":
|
|
213
|
+
tail = ["--", str(shown)]
|
|
214
|
+
elif a.positional:
|
|
215
|
+
parts += [str(x) for x in (shown if isinstance(shown, list) else [shown])]
|
|
216
|
+
elif a.type == "boolean":
|
|
217
|
+
parts.append(f"--{a.name.replace('_', '-')}" if v else f"--no-{a.name.replace('_', '-')}")
|
|
218
|
+
elif isinstance(shown, list):
|
|
219
|
+
for x in shown:
|
|
220
|
+
parts += [f"--{a.name.replace('_', '-')}", json.dumps(x) if not isinstance(x, str) else x]
|
|
221
|
+
else:
|
|
222
|
+
text = str(shown)
|
|
223
|
+
parts += [f"--{a.name.replace('_', '-')}", f"'{text}'" if (" " in text or "{" in text) else text]
|
|
224
|
+
return " ".join(parts + tail)
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
def _subject(rec: dict, m: dict) -> str:
|
|
228
|
+
"""The metric's subject; for bench serve records also the mode (and the record's bucket when the
|
|
229
|
+
metric does not name one), so a serve row never reads like a bench decode row."""
|
|
230
|
+
subject = m.get("subject") or ""
|
|
231
|
+
w = (rec.get("identity") or {}).get("workload") or {}
|
|
232
|
+
if rec.get("kind") not in ("serve_benchmark", "capacity") or not w.get("mode"):
|
|
233
|
+
return subject
|
|
234
|
+
if not subject and w.get("bucket"):
|
|
235
|
+
subject = f"bucket {w['bucket']}"
|
|
236
|
+
return f"{w['mode']}: {subject}" if subject else str(w["mode"])
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
def build_report(recs: list[dict], expect: str, title: str) -> dict:
|
|
240
|
+
integrity = {r["evidence_id"]: ev.verify(r) for r in recs}
|
|
241
|
+
claims = [c for r in recs for c in claims_of(r, expect, integrity[r["evidence_id"]])]
|
|
242
|
+
verdict = stats.combine_claims(c["verdict"] for c in claims)
|
|
243
|
+
results = []
|
|
244
|
+
for r in recs:
|
|
245
|
+
for m in r.get("metrics", [])[:15]:
|
|
246
|
+
results.append({"evidence": short(r["evidence_id"]), "kind": r["kind"], "metric": m["name"],
|
|
247
|
+
"subject": _subject(r, m), "value": m["value"], "unit": m["unit"],
|
|
248
|
+
"stat": m["stat"], "n": m.get("n"), "ci95": m.get("ci95")})
|
|
249
|
+
limitations = []
|
|
250
|
+
for r in recs:
|
|
251
|
+
for lim in r.get("limitations", []):
|
|
252
|
+
limitations.append({"evidence": short(r["evidence_id"]), "text": lim})
|
|
253
|
+
if not r.get("coverage", {}).get("complete", True):
|
|
254
|
+
limitations.append({"evidence": short(r["evidence_id"]),
|
|
255
|
+
"text": "incomplete coverage, missing: " + ", ".join(r["coverage"]["missing"])})
|
|
256
|
+
return {"title": title, "verdict": verdict, "expect": expect, "claims": claims,
|
|
257
|
+
"environment": environment_rows(recs), "results": results, "findings": findings(recs),
|
|
258
|
+
"limitations": limitations,
|
|
259
|
+
"reproduce": [{"evidence": short(r["evidence_id"]), "command": reproduce_command(r)} for r in recs],
|
|
260
|
+
"evidence": [r["evidence_id"] for r in recs], "integrity": integrity}
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
def to_markdown(rep: dict) -> str:
|
|
264
|
+
lines = [f"# {rep['title']}", "", "## Verdict", "", f"**{rep['verdict']}**", ""]
|
|
265
|
+
if rep["claims"]:
|
|
266
|
+
lines += ["| claim | verdict | evidence | why |", "|---|---|---|---|"]
|
|
267
|
+
lines += [f"| {_cell(c['claim'])} | {c['verdict']} | `{short(c['source'])}` | {_cell(c['reason'])} |"
|
|
268
|
+
for c in rep["claims"]]
|
|
269
|
+
else:
|
|
270
|
+
lines.append("No comparison, correctness or benchmark evidence: nothing was claimed (NOT RUN).")
|
|
271
|
+
lines += ["", "## Environment card", ""]
|
|
272
|
+
cols = ["gpu", "driver / CUDA", "clocks", "engine", "model", "workload"]
|
|
273
|
+
lines += ["| " + " | ".join(cols) + " |", "|" + "---|" * len(cols)]
|
|
274
|
+
lines += ["| " + " | ".join(_cell(row[c]) for c in cols) + " |" for row in rep["environment"]]
|
|
275
|
+
lines += ["", "## Results", "", "| evidence | metric | subject | value | unit | stat | n | 95% CI |",
|
|
276
|
+
"|---|---|---|---|---|---|---|---|"]
|
|
277
|
+
for x in rep["results"]:
|
|
278
|
+
ci = x["ci95"]
|
|
279
|
+
ci_text = f"{_fmt(ci[0])} - {_fmt(ci[1])}" if ci and ci[0] is not None else "-"
|
|
280
|
+
lines.append(f"| `{x['evidence']}` | {x['metric']} | {_cell(x['subject'])[:60]} | {_fmt(x['value'])} | "
|
|
281
|
+
f"{x['unit']} | {x['stat']} | {_fmt(x['n'])} | {ci_text} |")
|
|
282
|
+
lines += ["", "## Findings", ""] + [f"- {_cell(f)}" for f in rep["findings"]]
|
|
283
|
+
lines += ["", "## Limitations", ""]
|
|
284
|
+
lines += [f"- `{x['evidence']}`: {x['text']}" for x in rep["limitations"]] or ["- none recorded"]
|
|
285
|
+
lines += ["", "## Reproduce", "", "```sh"]
|
|
286
|
+
lines += [f"# {x['evidence']}\n{x['command'] or '(no recorded request)'}" for x in rep["reproduce"]]
|
|
287
|
+
lines += ["```", "", "Evidence ids (full): " + ", ".join(f"`{e}`" for e in rep["evidence"]), ""]
|
|
288
|
+
return "\n".join(lines)
|
|
289
|
+
|
|
290
|
+
|
|
291
|
+
@operation("report", ("report",), "Fixed-format report from evidence ids: verdict, environment card, results "
|
|
292
|
+
"table, findings, limitations, reproduce commands.",
|
|
293
|
+
[Arg("ids", "array", "Evidence ids (or unique prefixes) to include.", required=True, positional=True),
|
|
294
|
+
Arg("format", "string", "Output format.", default="md", choices=("md", "json")),
|
|
295
|
+
Arg("expect", "string", "What the comparisons are meant to show.", default="improved",
|
|
296
|
+
choices=("improved", "no_regression")),
|
|
297
|
+
Arg("title", "string", "Report title.", default="Inference optimisation report")])
|
|
298
|
+
def report(call, ids, format, expect, title): # noqa: A002
|
|
299
|
+
store = ev.Store()
|
|
300
|
+
recs, seen = [], set()
|
|
301
|
+
for ref in ids:
|
|
302
|
+
rec = store.load(ref)
|
|
303
|
+
if rec["evidence_id"] not in seen:
|
|
304
|
+
seen.add(rec["evidence_id"])
|
|
305
|
+
recs.append(rec)
|
|
306
|
+
rep = build_report(recs, expect, title)
|
|
307
|
+
out_dir = store.root / "reports"
|
|
308
|
+
out_dir.mkdir(parents=True, exist_ok=True)
|
|
309
|
+
name = "report-" + sha256_hex("|".join(rep["evidence"]) + expect)[:12]
|
|
310
|
+
if format == "json":
|
|
311
|
+
path = out_dir / f"{name}.json"
|
|
312
|
+
path.write_text(json.dumps(rep, indent=2) + "\n")
|
|
313
|
+
return {"format": "json", "path": str(path), "verdict": rep["verdict"], "report": rep}
|
|
314
|
+
text = to_markdown(rep)
|
|
315
|
+
path = out_dir / f"{name}.md"
|
|
316
|
+
path.write_text(text)
|
|
317
|
+
return {"format": "md", "path": str(path), "verdict": rep["verdict"], "markdown": text}
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
"""review build: one self-contained HTML page comparing every engine's outputs, per input bucket.
|
|
2
|
+
|
|
3
|
+
Reads ``runs/<run-id>/{inputs,outputs,metrics}`` (see ``inferencekit.review.rundir``) and
|
|
4
|
+
writes ``runs/<run-id>/review/index.html`` plus ``review/data.json``, a machine-readable
|
|
5
|
+
copy of what the page shows with the sha256 of every file it was built from.
|
|
6
|
+
|
|
7
|
+
The page is a view, not a measurement: speed and quality numbers come from the run's
|
|
8
|
+
metrics files (marked observed) and the page adds only derived comparisons (first
|
|
9
|
+
divergence from the reference, word error alignment), marked derived. A missing output
|
|
10
|
+
is shown as NOT RUN, never as a blank cell.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import json
|
|
16
|
+
import os
|
|
17
|
+
import tempfile
|
|
18
|
+
from pathlib import Path
|
|
19
|
+
|
|
20
|
+
from inferencekit import __version__
|
|
21
|
+
from inferencekit import evidence as ev
|
|
22
|
+
from inferencekit import prompts as _sets
|
|
23
|
+
from inferencekit.canonical import file_sha256
|
|
24
|
+
from inferencekit.registry import Arg, OpError, operation, utc_now
|
|
25
|
+
from inferencekit.review.page import render
|
|
26
|
+
from inferencekit.review.rundir import load_run
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def store_records_for(store: ev.Store, root: Path) -> list[dict]:
|
|
30
|
+
"""Evidence in the store made for this run dir (its request's ``run_dir`` argument resolves to ``root``).
|
|
31
|
+
|
|
32
|
+
bench serve and gates save their records in the evidence store, not in <run-dir>/evidence/,
|
|
33
|
+
so without this the page would show their verdicts as NOT RUN.
|
|
34
|
+
"""
|
|
35
|
+
want = root.resolve()
|
|
36
|
+
out = []
|
|
37
|
+
for rec in store.records():
|
|
38
|
+
args = ((rec.get("data") or {}).get("request") or {}).get("args") or {}
|
|
39
|
+
rd = args.get("run_dir") if isinstance(args, dict) else None
|
|
40
|
+
if isinstance(rd, str) and rd:
|
|
41
|
+
try:
|
|
42
|
+
if Path(rd).expanduser().resolve() == want: # the evidence layer writes home as ~
|
|
43
|
+
out.append(rec)
|
|
44
|
+
except (OSError, RuntimeError):
|
|
45
|
+
continue
|
|
46
|
+
return out
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _write(path: Path, text: str) -> None:
|
|
50
|
+
"""Write via a temp file and rename, so a reader never sees half a page."""
|
|
51
|
+
tmp = None
|
|
52
|
+
try:
|
|
53
|
+
with tempfile.NamedTemporaryFile(dir=path.parent, prefix=".review-", suffix=".tmp", mode="w",
|
|
54
|
+
encoding="utf-8", delete=False) as stream:
|
|
55
|
+
tmp = Path(stream.name)
|
|
56
|
+
stream.write(text)
|
|
57
|
+
os.replace(tmp, path)
|
|
58
|
+
finally:
|
|
59
|
+
if tmp is not None:
|
|
60
|
+
tmp.unlink(missing_ok=True)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
@operation("build_review", ("review", "build"), "Build the run's review page (runs/<id>/review/index.html): inputs "
|
|
64
|
+
"by bucket x engines, first divergence from the reference marked, word errors for speech, summary "
|
|
65
|
+
"table and filters; one offline HTML file.",
|
|
66
|
+
[Arg("run_dir", "string", "Run directory (runs/<run-id>) holding inputs/, outputs/ and metrics/.",
|
|
67
|
+
required=True, positional=True),
|
|
68
|
+
Arg("reference", "string", "Label of the outputs/<label> column the others are compared against "
|
|
69
|
+
"(default: 'reference' when that directory exists)."),
|
|
70
|
+
Arg("prompts", "string", "Prompt-set JSONL ({id, bucket, messages} per line) for the input column "
|
|
71
|
+
"when the run has no inputs/ directory."),
|
|
72
|
+
Arg("evidence", "array", "More evidence ids (gates, quality, capacity records) to show per engine; "
|
|
73
|
+
"records in <run-dir>/evidence/ and store records made with --run-dir <run-dir> are read "
|
|
74
|
+
"automatically.")])
|
|
75
|
+
def build_review(call, run_dir, reference, prompts, evidence):
|
|
76
|
+
root = Path(run_dir)
|
|
77
|
+
if not root.is_dir():
|
|
78
|
+
raise OpError(f"run directory not found: {run_dir}")
|
|
79
|
+
if not (root / "review").resolve().is_relative_to(root.resolve()):
|
|
80
|
+
raise OpError("review directory escapes the run directory")
|
|
81
|
+
if not (root / "outputs").is_dir() and not (root / "inputs").is_dir() and not (root / "metrics").is_dir():
|
|
82
|
+
raise OpError(f"{run_dir} has no inputs/, outputs/ or metrics/: is it a run directory (runs/<run-id>)?")
|
|
83
|
+
if prompts and not _sets.existing(prompts).is_file():
|
|
84
|
+
raise OpError(f"prompt set not found: {prompts}")
|
|
85
|
+
store = ev.Store()
|
|
86
|
+
extra = [store.load(ref) for ref in evidence or []]
|
|
87
|
+
found = store_records_for(store, root)
|
|
88
|
+
run = load_run(root, reference, prompts, extra + found)
|
|
89
|
+
from inferencekit.review.media import prepare_pcm
|
|
90
|
+
|
|
91
|
+
playback_copies = prepare_pcm(run)
|
|
92
|
+
if not run.evidence_attached:
|
|
93
|
+
run.warnings.insert(0, "no evidence attached: <run-dir>/evidence/ is missing or empty, no --evidence was "
|
|
94
|
+
"passed and no stored record names this run dir; quality, gates and N* come only "
|
|
95
|
+
"from the metrics files (pass --evidence <id> for gates, quality and capacity records)")
|
|
96
|
+
generated = utc_now()
|
|
97
|
+
page, data = render(run, generated, __version__)
|
|
98
|
+
out = root / "review"
|
|
99
|
+
out.mkdir(parents=True, exist_ok=True)
|
|
100
|
+
sources = []
|
|
101
|
+
for rel in run.sources:
|
|
102
|
+
try:
|
|
103
|
+
sha, size = file_sha256(root / rel)
|
|
104
|
+
except OSError:
|
|
105
|
+
continue
|
|
106
|
+
sources.append({"path": rel, "sha256": sha, "bytes": size})
|
|
107
|
+
data.update({"generated": generated, "tool_version": __version__, "request": call.request(),
|
|
108
|
+
"sources": sources, "playback_copies": playback_copies})
|
|
109
|
+
_write(out / "index.html", page)
|
|
110
|
+
_write(out / "data.json", json.dumps(data, indent=2, ensure_ascii=False, default=str) + "\n")
|
|
111
|
+
cells = len(run.items) * len(run.engines)
|
|
112
|
+
not_run = sum(1 for c in run.cells.values() if c.status == "not_run")
|
|
113
|
+
failed = sum(1 for c in run.cells.values() if c.status == "failed")
|
|
114
|
+
return {"path": str(out / "index.html"), "data": str(out / "data.json"), "modality": run.modality,
|
|
115
|
+
"reference": run.reference, "engines": [e.label for e in run.engines], "inputs": len(run.items),
|
|
116
|
+
"buckets": run.buckets(), "cells": cells, "not_run": not_run, "failed": failed,
|
|
117
|
+
"bytes": len(page.encode("utf-8")), "warnings": run.warnings[:20],
|
|
118
|
+
"note": "a view of the run's files: speed/quality numbers are the metrics files' (observed); "
|
|
119
|
+
"divergence and word errors are computed here (derived)"}
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
"""ROCm tracing via rocprofv3. Raw traces are sensitive and never benchmark proof."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import csv
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
from inferencekit import evidence as ev
|
|
8
|
+
from inferencekit.proc import clean_env, dropped_config_env, require, run, tool_version
|
|
9
|
+
from inferencekit.registry import Arg, OpError, operation
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
@operation('capture_rocprof', ('rocprof', 'capture'),
|
|
13
|
+
'Capture AMD kernel and HIP API traces with rocprofv3 under an allowlisted environment.',
|
|
14
|
+
[Arg('keep_env', 'array', 'Additional environment variable names (never values).', default=[]),
|
|
15
|
+
Arg('gpu_name', 'string', 'Verified GPU name, if known.'),
|
|
16
|
+
Arg('timeout', 'integer', 'Time limit in seconds for the profiled process group.', default=3600)],
|
|
17
|
+
passthrough=True)
|
|
18
|
+
def capture_rocprof(call, keep_env, gpu_name, timeout, argv):
|
|
19
|
+
if timeout < 1:
|
|
20
|
+
raise OpError('timeout must be positive')
|
|
21
|
+
profiler = require('rocprofv3', 'Install ROCprofiler-SDK for the installed ROCm release.')
|
|
22
|
+
env, names = clean_env(keep_env)
|
|
23
|
+
dropped = dropped_config_env(env)
|
|
24
|
+
store = ev.Store()
|
|
25
|
+
with store.staging_dir() as staging:
|
|
26
|
+
# CSV supports these two trace types on both current and older ROCm versions.
|
|
27
|
+
# Current ROCm recommends rocpd for other trace features.
|
|
28
|
+
cmd = [profiler, '--kernel-trace', '--hip-trace', '--output-format', 'csv',
|
|
29
|
+
'-d', str(staging), '-o', 'trace', '--', *argv]
|
|
30
|
+
result = run(cmd, timeout=timeout, env=env, stdout_path=staging / 'stdout.txt',
|
|
31
|
+
stderr_path=staging / 'stderr.txt')
|
|
32
|
+
for name in ('stdout.txt', 'stderr.txt'):
|
|
33
|
+
path = staging / name
|
|
34
|
+
path.write_text(ev.redact_text(path.read_text(errors='replace')))
|
|
35
|
+
kernels = sorted(staging.glob('*kernel_trace.csv'))
|
|
36
|
+
hip = sorted(staging.glob('*hip_api_trace.csv'))
|
|
37
|
+
missing = ([] if kernels else ['kernel trace']) + ([] if result.ok else ['clean exit'])
|
|
38
|
+
limits = ['Raw profiler traces may contain sensitive names and paths; inspect before sharing.',
|
|
39
|
+
'Tracing can add overhead; compare unprofiled end-to-end timings separately.',
|
|
40
|
+
'No kernel counters are collected by this operation.']
|
|
41
|
+
if dropped:
|
|
42
|
+
limits.append('Configuration variables not passed: ' + ', '.join(dropped))
|
|
43
|
+
artifacts = [ev.artifact(p, staging, 'kernel_trace') for p in kernels]
|
|
44
|
+
artifacts += [ev.artifact(p, staging, 'hip_trace') for p in hip]
|
|
45
|
+
artifacts += [ev.artifact(staging / n, staging, n[:-4]) for n in ('stdout.txt', 'stderr.txt')]
|
|
46
|
+
record = ev.build(call, kind='rocprof_capture', claim_class='observed',
|
|
47
|
+
summary=f'rocprofv3: {len(kernels)} kernel trace(s), exit {result.returncode}',
|
|
48
|
+
identity_=ev.identity(gpu={'name': gpu_name}), artifacts=artifacts,
|
|
49
|
+
missing=missing, limitations=limits,
|
|
50
|
+
data={'rocprof_version': tool_version('rocprofv3'), 'exit_code': result.returncode,
|
|
51
|
+
'env_names': names, 'env_dropped': dropped, 'output_format': 'csv'})
|
|
52
|
+
return store.save(record, staging)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
@operation('analyze_rocprof', ('rocprof', 'analyze'),
|
|
56
|
+
'Summarize kernel dispatch counts and durations from a rocprofv3 CSV trace.',
|
|
57
|
+
[Arg('source', 'string', 'Kernel trace CSV path.', required=True, positional=True, sensitive=True)])
|
|
58
|
+
def analyze_rocprof(call, source):
|
|
59
|
+
path = Path(source)
|
|
60
|
+
if not path.is_file():
|
|
61
|
+
raise OpError('kernel trace CSV not found')
|
|
62
|
+
totals = {}
|
|
63
|
+
try:
|
|
64
|
+
with path.open(newline='', encoding='utf-8') as f:
|
|
65
|
+
reader = csv.DictReader(f)
|
|
66
|
+
if not {'Kernel_Name', 'Start_Timestamp', 'End_Timestamp'} <= set(reader.fieldnames or []):
|
|
67
|
+
raise OpError('not a rocprofv3 kernel trace CSV')
|
|
68
|
+
for row in reader:
|
|
69
|
+
name = row['Kernel_Name']
|
|
70
|
+
start, end = int(row['Start_Timestamp']), int(row['End_Timestamp'])
|
|
71
|
+
if not name or end < start:
|
|
72
|
+
raise OpError('invalid kernel trace row')
|
|
73
|
+
item = totals.setdefault(name, {'kernel': name, 'dispatches': 0, 'duration_ns': 0})
|
|
74
|
+
item['dispatches'] += 1
|
|
75
|
+
item['duration_ns'] += end - start
|
|
76
|
+
except (ValueError, UnicodeError, csv.Error, KeyError) as exc:
|
|
77
|
+
raise OpError('invalid kernel trace CSV') from exc
|
|
78
|
+
rows = sorted(totals.values(), key=lambda x: x['duration_ns'], reverse=True)
|
|
79
|
+
store = ev.Store()
|
|
80
|
+
with store.staging_dir() as staging:
|
|
81
|
+
# Store only the raw file's hash, not a second sensitive copy.
|
|
82
|
+
source_hash, _ = ev.file_sha256(path)
|
|
83
|
+
record = ev.build(call, kind='rocprof_summary', claim_class='observed',
|
|
84
|
+
summary=f'rocprofv3 kernel trace: {sum(x["dispatches"] for x in rows)} dispatches',
|
|
85
|
+
missing=[] if rows else ['kernel dispatches'],
|
|
86
|
+
limitations=['Summed kernel time can overlap and is not critical-path or end-to-end time.',
|
|
87
|
+
'Source trace may contain sensitive data.'],
|
|
88
|
+
data={'source_sha256': source_hash, 'kernels': rows})
|
|
89
|
+
return store.save(record, staging)
|