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.
Files changed (104) hide show
  1. inferencekit/__init__.py +9 -0
  2. inferencekit/__main__.py +5 -0
  3. inferencekit/analysis/__init__.py +1 -0
  4. inferencekit/analysis/ncu_csv.py +284 -0
  5. inferencekit/analysis/nsys_sqlite.py +518 -0
  6. inferencekit/canonical.py +104 -0
  7. inferencekit/checkpoint.py +396 -0
  8. inferencekit/cli.py +274 -0
  9. inferencekit/cuda/bw_peak.cu +252 -0
  10. inferencekit/cuda/torch_bw.py +60 -0
  11. inferencekit/engines/__init__.py +7 -0
  12. inferencekit/engines/adapters.py +190 -0
  13. inferencekit/engines/clip/__init__.py +13 -0
  14. inferencekit/engines/clip/comfyui.py +749 -0
  15. inferencekit/engines/clip/diffusers_ltx.py +601 -0
  16. inferencekit/engines/clip/noise.py +46 -0
  17. inferencekit/engines/clip/official_ltx.py +316 -0
  18. inferencekit/engines/clip/server.py +542 -0
  19. inferencekit/engines/clip/stepdump.py +182 -0
  20. inferencekit/engines/gates.py +94 -0
  21. inferencekit/engines/sglang_worker.py +86 -0
  22. inferencekit/engines/vllm_worker.py +129 -0
  23. inferencekit/evidence.py +427 -0
  24. inferencekit/gpuinfo.py +93 -0
  25. inferencekit/hardware.py +135 -0
  26. inferencekit/mcp_server.py +178 -0
  27. inferencekit/ops/__init__.py +30 -0
  28. inferencekit/ops/bench.py +184 -0
  29. inferencekit/ops/bench_audio.py +384 -0
  30. inferencekit/ops/bench_clip.py +819 -0
  31. inferencekit/ops/bench_image.py +393 -0
  32. inferencekit/ops/bench_rounds.py +297 -0
  33. inferencekit/ops/bench_serve.py +866 -0
  34. inferencekit/ops/ceiling.py +135 -0
  35. inferencekit/ops/compare.py +378 -0
  36. inferencekit/ops/correctness.py +153 -0
  37. inferencekit/ops/doctor.py +486 -0
  38. inferencekit/ops/gates.py +1772 -0
  39. inferencekit/ops/gates_clip.py +930 -0
  40. inferencekit/ops/intake.py +1321 -0
  41. inferencekit/ops/ledger.py +749 -0
  42. inferencekit/ops/manifest.py +309 -0
  43. inferencekit/ops/ncu.py +183 -0
  44. inferencekit/ops/nsys.py +224 -0
  45. inferencekit/ops/prompts.py +316 -0
  46. inferencekit/ops/quality.py +2161 -0
  47. inferencekit/ops/quality_clip.py +690 -0
  48. inferencekit/ops/quality_clip_steps.py +341 -0
  49. inferencekit/ops/report.py +317 -0
  50. inferencekit/ops/review.py +119 -0
  51. inferencekit/ops/rocprof.py +89 -0
  52. inferencekit/ops/roofline.py +392 -0
  53. inferencekit/ops/store.py +61 -0
  54. inferencekit/proc.py +172 -0
  55. inferencekit/prompts/ATTRIBUTION.md +56 -0
  56. inferencekit/prompts/__init__.py +167 -0
  57. inferencekit/prompts/build.py +518 -0
  58. inferencekit/prompts/dev-load.jsonl.gz +0 -0
  59. inferencekit/prompts/dev-load.manifest.json +212 -0
  60. inferencekit/prompts/dev.jsonl.gz +0 -0
  61. inferencekit/prompts/dev.manifest.json +214 -0
  62. inferencekit/prompts/exclusions.json +54 -0
  63. inferencekit/quality/__init__.py +23 -0
  64. inferencekit/quality/client.py +66 -0
  65. inferencekit/quality/data/gsm8k_LICENSE.txt +21 -0
  66. inferencekit/quality/fake.py +329 -0
  67. inferencekit/quality/gsm8k.py +224 -0
  68. inferencekit/quality/logprobs.py +106 -0
  69. inferencekit/quality/metrics.py +235 -0
  70. inferencekit/quality/niah.py +63 -0
  71. inferencekit/quality/parquet.py +296 -0
  72. inferencekit/quality/sampling.py +262 -0
  73. inferencekit/quality/step_replay.py +217 -0
  74. inferencekit/quality/video_metrics.py +304 -0
  75. inferencekit/registry.py +206 -0
  76. inferencekit/review/__init__.py +6 -0
  77. inferencekit/review/diff.py +163 -0
  78. inferencekit/review/media.py +181 -0
  79. inferencekit/review/page.py +920 -0
  80. inferencekit/review/rundir.py +844 -0
  81. inferencekit/roofline_headers.py +504 -0
  82. inferencekit/schemas/evidence.v1.json +130 -0
  83. inferencekit/schemas/evidence.v2.json +499 -0
  84. inferencekit/schemas/intake.v1.json +276 -0
  85. inferencekit/schemas/run-manifest.v1.json +118 -0
  86. inferencekit/serve/__init__.py +8 -0
  87. inferencekit/serve/audio_client.py +330 -0
  88. inferencekit/serve/client.py +446 -0
  89. inferencekit/serve/clip_client.py +351 -0
  90. inferencekit/serve/clip_rounds.py +429 -0
  91. inferencekit/serve/image_client.py +373 -0
  92. inferencekit/serve/loadgen.py +136 -0
  93. inferencekit/serve/metrics.py +203 -0
  94. inferencekit/serve/profiles.py +105 -0
  95. inferencekit/serve/prompts_io.py +125 -0
  96. inferencekit/serve/rounds.py +1111 -0
  97. inferencekit/serve/servers.py +897 -0
  98. inferencekit/stats.py +249 -0
  99. inferencekit_cli-0.1.0.dist-info/METADATA +233 -0
  100. inferencekit_cli-0.1.0.dist-info/RECORD +104 -0
  101. inferencekit_cli-0.1.0.dist-info/WHEEL +4 -0
  102. inferencekit_cli-0.1.0.dist-info/entry_points.txt +2 -0
  103. inferencekit_cli-0.1.0.dist-info/licenses/LICENSE +202 -0
  104. inferencekit_cli-0.1.0.dist-info/licenses/NOTICE +9 -0
@@ -0,0 +1,135 @@
1
+ """ceiling bw: measure the DRAM bandwidth this GPU actually sustains.
2
+
3
+ Why measure: rooflines built on the datasheet number overstate the ceiling, and
4
+ "% of peak" for a kernel is only meaningful against what a pure streaming kernel
5
+ achieves on the same card, same clocks, same driver.
6
+
7
+ Runner: compile ``cuda/bw_peak.cu`` with nvcc (cached by source hash and GPU arch)
8
+ when nvcc is present; otherwise run the PyTorch fallback. Both print JSON lines.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import json
14
+ import os
15
+ import sys
16
+ from pathlib import Path
17
+
18
+ from inferencekit import evidence as ev
19
+ from inferencekit.canonical import sha256_hex
20
+ from inferencekit.gpuinfo import gpu_identity, query_gpus
21
+ from inferencekit.proc import run, tool_version, which
22
+ from inferencekit.registry import Arg, OpError, operation
23
+
24
+ CUDA_DIR = Path(__file__).resolve().parent.parent / "cuda"
25
+ SUSTAINED_MIN_BYTES = 256 << 20 # "sustained" = streams far larger than any L2
26
+
27
+
28
+ def _cache_dir() -> Path:
29
+ base = os.environ.get("XDG_CACHE_HOME") or str(Path.home() / ".cache")
30
+ d = Path(base) / "inferencekit"
31
+ d.mkdir(parents=True, exist_ok=True)
32
+ return d
33
+
34
+
35
+ def build_binary(nvcc: str, compute_cap: str | None) -> tuple[Path, list[str]]:
36
+ """Compile bw_peak.cu once per (source version, GPU architecture); reuse it afterwards."""
37
+ src = CUDA_DIR / "bw_peak.cu"
38
+ arch_tag = "sm" + (compute_cap or "unknown").replace(".", "")
39
+ out = _cache_dir() / f"bw_peak-{sha256_hex(src.read_bytes())[:12]}-{arch_tag}"
40
+ if out.is_file():
41
+ return out, []
42
+ notes = []
43
+ # -arch=native compiles for the GPU in this machine (nvcc 11.6+); fall back to a generic build.
44
+ for arch in (["-arch=native"], []):
45
+ r = run([nvcc, "-O3", "-std=c++17", *arch, "-o", str(out), str(src)], timeout=600)
46
+ if r.ok:
47
+ return out, notes
48
+ notes.append(f"nvcc {' '.join(arch) or '(default arch)'} failed: {r.stderr.strip()[-300:]}")
49
+ raise OpError("could not compile bw_peak.cu: " + " | ".join(notes))
50
+
51
+
52
+ def parse_lines(text: str) -> list[dict]:
53
+ rows = []
54
+ for line in text.splitlines():
55
+ line = line.strip()
56
+ if line.startswith("{"):
57
+ try:
58
+ rows.append(json.loads(line))
59
+ except ValueError:
60
+ continue
61
+ return rows
62
+
63
+
64
+ @operation("measure_bandwidth", ("ceiling", "bw"), "Measured DRAM read bandwidth (CUDA microbenchmark over "
65
+ "several buffer sizes; PyTorch fallback without nvcc).",
66
+ [Arg("sizes_mib", "array", "Buffer sizes in MiB.", default=[8, 32, 128, 512, 1024], items="integer"),
67
+ Arg("graph_mib", "array", "Per-launch sizes (MiB) for the graph-of-launches test.", default=[1, 4, 16],
68
+ items="integer"),
69
+ Arg("iters", "integer", "Timed iterations per test (median reported).", default=30),
70
+ Arg("runner", "string", "Force a runner.", default="auto", choices=("auto", "cuda", "torch")),
71
+ Arg("python", "string", "Interpreter with torch, for the fallback runner (default: this one).")])
72
+ def measure_bandwidth(call, sizes_mib, graph_mib, iters, runner, python):
73
+ store = ev.Store()
74
+ with store.staging_dir() as staging: # removed again if the benchmark fails
75
+ limitations, missing = [], []
76
+ nvcc = which("nvcc")
77
+ sizes = ",".join(str(int(s)) for s in sizes_mib)
78
+ if runner == "cuda" or (runner == "auto" and nvcc):
79
+ if not nvcc:
80
+ raise OpError("runner=cuda needs nvcc on PATH")
81
+ gpus = query_gpus()
82
+ binary, notes = build_binary(nvcc, gpus[0].get("compute_cap") if gpus else None)
83
+ limitations += notes
84
+ argv = [str(binary), "--sizes-mib", sizes, "--graph-mib", ",".join(str(int(s)) for s in graph_mib),
85
+ "--iters", str(iters)]
86
+ method = {"runner": "cuda", "nvcc": tool_version("nvcc"), "source": "cuda/bw_peak.cu"}
87
+ else:
88
+ argv = [python or sys.executable, str(CUDA_DIR / "torch_bw.py"), "--sizes-mib", sizes,
89
+ "--iters", str(iters)]
90
+ method = {"runner": "torch"}
91
+ limitations.append("PyTorch fallback: reductions read slightly below a pure load loop; no graph test")
92
+ missing.append("graph_read")
93
+ out_path = staging / "bw_peak.jsonl"
94
+ r = run(argv, timeout=1800, stdout_path=out_path, stderr_path=staging / "bw_peak.stderr.txt")
95
+ rows = parse_lines(r.stdout)
96
+ if not r.ok or not any(row.get("test") == "read" for row in rows):
97
+ raise OpError(f"bandwidth benchmark failed (exit {r.returncode}): {r.stderr.strip()[-500:]}")
98
+
99
+ device = next((row for row in rows if row.get("test") == "device"), {})
100
+ reads = [row for row in rows if row.get("test") == "read"]
101
+ copies = [row for row in rows if row.get("test") == "copy"]
102
+ graphs = [row for row in rows if row.get("test") == "graph_read"]
103
+ sustained = [row for row in reads if row["bytes"] >= SUSTAINED_MIN_BYTES] or reads
104
+ best = max(sustained, key=lambda row: row["gbs"])
105
+ metrics = [ev.metric("dram_read_gbs", best["gbs"], "GB/s", "best_sustained", n=best.get("iters"),
106
+ subject=f"{best['bytes'] >> 20} MiB read")]
107
+ # one row per size: the better of the launch configurations
108
+ for size in sorted({row["bytes"] for row in reads}):
109
+ row = max((x for x in reads if x["bytes"] == size), key=lambda x: x["gbs"])
110
+ metrics.append(ev.metric("dram_read_gbs", row["gbs"], "GB/s", "median", n=row.get("iters"),
111
+ subject=f"{size >> 20} MiB"))
112
+ # Same rule as reads: only buffers well past the L2 cache measure DRAM. A small copy can sit in
113
+ # L2 (96 MB on some consumer GPUs) and report more than the DRAM can physically deliver.
114
+ copies = [row for row in copies if row["bytes"] >= SUSTAINED_MIN_BYTES]
115
+ if copies:
116
+ c = max(copies, key=lambda x: x["gbs"])
117
+ metrics.append(ev.metric("dram_copy_gbs", c["gbs"], "GB/s", "best_median", n=c.get("iters"),
118
+ subject=f"{c['bytes'] >> 20} MiB read+write"))
119
+ theoretical = device.get("theoretical_gbs") or None
120
+ if theoretical:
121
+ metrics.append(ev.metric("theoretical_gbs", theoretical, "GB/s", "derived",
122
+ subject="2 x memory clock x bus width"))
123
+ ident = ev.identity(gpu=gpu_identity())
124
+ if ident["gpu"]["clocks"] != "locked":
125
+ limitations.append("clocks not locked: bandwidth can vary a few percent with memory clock boost")
126
+ pct = f" ({100 * best['gbs'] / theoretical:.0f}% of theoretical {theoretical:.0f})" if theoretical else ""
127
+ summary = f"{device.get('name', 'GPU')}: best sustained read {best['gbs']:.0f} GB/s{pct}"
128
+ record = ev.build(call, kind="ceiling", claim_class="observed", summary=summary, identity_=ident,
129
+ metrics=metrics,
130
+ artifacts=[ev.artifact(out_path, staging, "raw_output")],
131
+ missing=missing, limitations=limitations,
132
+ data={"device": device, "method": method, "reads": reads, "copies": copies,
133
+ "graph_reads": graphs,
134
+ "definition": "best sustained = highest median GB/s over read tests of >= 256 MiB"})
135
+ return store.save(record, staging)
@@ -0,0 +1,378 @@
1
+ """compare: is the candidate faster than the baseline, by more than noise and more than it matters?
2
+
3
+ Method:
4
+ 1. Take one metric per round from each benchmark record (default decode_tok_s).
5
+ 2. Pair rounds by round index (baseline round i with candidate round i).
6
+ 3. Check that the arms were interleaved in time: for every pair, the two rounds
7
+ are neighbours in the merged timeline (A B A B... or A B B A...). Without
8
+ interleaving, slow drift (temperature, clocks, other jobs) is confounded with
9
+ the change; the comparison still runs but says so in ``limitations``.
10
+ 4. Ratio of medians (candidate / baseline) with a paired bootstrap 95% CI
11
+ (10,000 resamples, seed 0).
12
+ 5. Verdict against a practical threshold (default 2%): see stats.decide.
13
+
14
+ Inputs are ``benchmark`` records (bench decode) or single-user ``serve_benchmark`` records (bench serve,
15
+ bench rounds cells; their ``data.rounds`` has the same row shape). Both must be the same kind and the
16
+ same workload (prompt set sha256, mode, bucket, generation length, prefix-cache state, endpoint); a
17
+ load-mode record is per level, so it is refused (compare capacity records' N* instead).
18
+
19
+ For serve_benchmark records "the same workload" also means the same requests: the prompt-set sha256 is
20
+ the whole file's, so the selected subset (--buckets, --limit-per-bucket) and the per-request settings
21
+ (max_tokens, chat_template_kwargs, API, exact length, extra body) must match too. The records'
22
+ ``workload.request_set`` hash says so when both have it; for records without it, the stored bench serve
23
+ arguments are compared instead (JSON arguments by value). A serve record with failed requests makes the result inconclusive:
24
+ the round medians drop failed requests, so an engine that fails the long prompts would look faster.
25
+
26
+ Clips (``clip_benchmark`` records from bench clip): metric ``clip_ms`` (lower is better) or ``clips_per_hour``
27
+ (higher is better). A round's clip_ms is the mean over its clips, so on a set that mixes tasks (t2v and i2v) a
28
+ change to either shows; ``clip_ms_<task>`` and ``clips_per_hour_<task>`` (e.g. clip_ms_i2v) compare one task's
29
+ clips only. The same clip set, requested shape (task, size, frames, fps, steps, guidance), request set and
30
+ seed policy are required, and each pair of rounds must have used the same seed. A failed clip, or a record whose
31
+ claim_validity is false (the engine reports fewer steps or another shape than asked, cached stages, or
32
+ byte-identical clips for different requests), makes the result inconclusive. Engines reached through different
33
+ endpoint kinds (clip_http, comfyui) can be compared: both are timed on the client until the clip is in hand. A
34
+ reused encoding of a fixed negative prompt is allowed for every engine; when only one side reused it, a limitation
35
+ says so.
36
+ """
37
+
38
+ from __future__ import annotations
39
+
40
+ import math
41
+
42
+ from inferencekit import evidence as ev
43
+ from inferencekit import stats
44
+ from inferencekit.registry import Arg, OpError, operation
45
+ from inferencekit.serve.clip_client import TASKS as CLIP_TASKS
46
+
47
+ # bench clip's rounds: every clip, then one task's clips (bench_clip.round_key).
48
+ CLIP_METRICS = ("clip_ms", "clips_per_hour") + tuple(f"{m}_{t}" for t in CLIP_TASKS
49
+ for m in ("clip_ms", "clips_per_hour"))
50
+ MEDIA_METRICS = {"image_benchmark": ("image_ms", "images_per_hour"),
51
+ "audio_benchmark": ("audio_ms", "first_audio_p95_ms", "rtf")}
52
+ HIGHER_IS_BETTER = {"decode_tok_s": True, "prefill_tok_s": True, "ttft_ms": False, "tpot_ms": False,
53
+ "total_ms": False, **{m: m.startswith("clips_per_hour") for m in CLIP_METRICS},
54
+ "image_ms": False, "images_per_hour": True, "audio_ms": False,
55
+ "first_audio_p95_ms": False, "rtf": False}
56
+ BENCH_KINDS = ("benchmark", "serve_benchmark", "clip_benchmark", *MEDIA_METRICS)
57
+ UNITS = {**{m: "clips/h" for m in CLIP_METRICS if m.startswith("clips_per_hour")},
58
+ "images_per_hour": "images/h", "rtf": "x"}
59
+ # Workload fields that must agree: bench decode's shape, then bench serve's (absent fields are None on both).
60
+ SAME_WORKLOAD = ("prompt_len", "gen_len", "batch", "mode", "bucket", "prefix_cache", "endpoint_kind")
61
+ # bench serve arguments that change which requests are sent or what they ask for (serve_benchmark only).
62
+ SAME_SERVE_ARGS = ("buckets", "limit_per_bucket", "max_tokens", "api", "exact_length", "cache_salt", "extra_body",
63
+ "chat_template_kwargs")
64
+ SERVE_METRICS = ("decode_tok_s", "ttft_ms", "tpot_ms", "total_ms") # what bench serve's rounds carry
65
+ # Clip workload fields that must agree (clip_benchmark only), beside SAME_WORKLOAD.
66
+ SAME_CLIP_WORKLOAD = ("task", "width", "height", "num_frames", "fps", "steps", "guidance_scale", "audio")
67
+
68
+
69
+ def _serve_arg(key: str, value):
70
+ """A stored bench serve argument as compared here: JSON objects by value, lists in order-free form."""
71
+ if key in ("extra_body", "chat_template_kwargs"):
72
+ from inferencekit.ops.bench_serve import normalized_json
73
+ return normalized_json(value)
74
+ if key == "buckets":
75
+ return sorted(value) if value else None
76
+ if key == "limit_per_bucket":
77
+ return value or None
78
+ return value
79
+
80
+
81
+ def serve_workload_reasons(a: dict, b: dict) -> list[str]:
82
+ """Why two serve_benchmark records did not send the same requests (empty when they did)."""
83
+ out = []
84
+ ra, rb = ((r["identity"].get("workload") or {}).get("request_set") or {} for r in (a, b))
85
+ if ra.get("sha256") and rb.get("sha256"):
86
+ # The hash covers what was actually sent (the selection, max_tokens, the merged template settings
87
+ # however they were given: flag, --extra-body, intake pin), so the arguments are not compared.
88
+ if ra["sha256"] != rb["sha256"]:
89
+ out.append(f"workloads differ (request_set {ra['sha256'][:12]} vs {rb['sha256'][:12]}: different "
90
+ "prompt subset or per-request settings)")
91
+ return out
92
+ args_a, args_b = (((r.get("data") or {}).get("request") or {}).get("args") or {} for r in (a, b))
93
+ for key in SAME_SERVE_ARGS:
94
+ va, vb = _serve_arg(key, args_a.get(key)), _serve_arg(key, args_b.get(key))
95
+ if va != vb:
96
+ out.append(f"workloads differ (bench serve --{key.replace('_', '-')}: {va} vs {vb})")
97
+ return out
98
+
99
+
100
+ def clip_workload_reasons(a: dict, b: dict) -> list[str]:
101
+ """Why two clip_benchmark records did not ask for the same clips (empty when they did)."""
102
+ out = []
103
+ wa, wb = (r["identity"].get("workload") or {} for r in (a, b))
104
+ for key in SAME_CLIP_WORKLOAD:
105
+ if wa.get(key) != wb.get(key):
106
+ out.append(f"workloads differ ({key}: {wa.get(key)} vs {wb.get(key)})")
107
+ for key, what in (("clip_set", "clip sets"), ("request_set", "request sets")):
108
+ sa, sb = ((w.get(key) or {}).get("sha256") for w in (wa, wb))
109
+ if sa != sb:
110
+ out.append(f"different {what} ({str(sa)[:12]} vs {str(sb)[:12]})")
111
+ pa, pb = ({k: (w.get("seed_policy") or {}).get(k) for k in ("name", "base")} for w in (wa, wb))
112
+ if pa != pb:
113
+ out.append(f"different seed policies ({pa} vs {pb})")
114
+ return out
115
+
116
+
117
+ def clip_seed_reasons(base: dict[int, dict], cand: dict[int, dict], pairs: list[int]) -> list[str]:
118
+ """Paired clip rounds must have used the same seed (the same requests, minutes apart)."""
119
+ bad = [i for i in pairs if base[i].get("seed") != cand[i].get("seed")]
120
+ if not bad:
121
+ return []
122
+ i = bad[0]
123
+ return [f"{len(bad)} paired round(s) used different seeds (round {i}: {base[i].get('seed')} vs "
124
+ f"{cand[i].get('seed')})"]
125
+
126
+
127
+ def media_workload_reasons(a: dict, b: dict, image_tradeoff: bool = False) -> list[str]:
128
+ """Media speed claims require complete request and checkpoint identity.
129
+
130
+ Quantization/offload studies may still report raw measurements and quality, but
131
+ different numerical formats cannot become a same-precision engine speed claim.
132
+ """
133
+ out = []
134
+ for label, rec in (("baseline", a), ("candidate", b)):
135
+ workload = rec["identity"].get("workload") or {}
136
+ if not (workload.get("request_set") or {}).get("sha256"):
137
+ out.append(f"{label} missing request_set hash")
138
+ if not rec["identity"]["gpu"].get("name"):
139
+ out.append(f"{label} missing hardware identity")
140
+ if not rec["identity"]["model"].get("id"):
141
+ out.append(f"{label} missing model identity")
142
+ for key in ("revision", "dtype", "quant"):
143
+ if not rec["identity"]["model"].get(key):
144
+ out.append(f"{label} missing model {key}; declare unquantized weights explicitly as quant=none")
145
+ if not rec["identity"]["engine"].get("version"):
146
+ out.append(f"{label} missing pinned engine version")
147
+ validity = rec.get("data", {}).get("claim_validity") or {}
148
+ if validity.get("valid") is not True:
149
+ out.append(f"{label} structural validity not proven")
150
+ wa, wb = (r["identity"].get("workload") or {} for r in (a, b))
151
+ if image_tradeoff:
152
+ ha, hb = ((w.get("image_task_set") or {}).get("sha256") for w in (wa, wb))
153
+ if not ha or not hb or ha != hb:
154
+ out.append("image task identity missing or differs (prompts, dimensions, seeds or other settings)")
155
+ elif (wa.get("request_set") or {}).get("sha256") != (wb.get("request_set") or {}).get("sha256"):
156
+ out.append("workloads differ (request_set: fixtures, sampling, precision or generation settings)")
157
+ for key in (("id", "revision") if image_tradeoff else ("id", "revision", "dtype", "quant")):
158
+ if a["identity"]["model"].get(key) != b["identity"]["model"].get(key):
159
+ out.append(f"model identity differs ({key}); format trade-offs require separate quality reporting")
160
+ return out
161
+
162
+
163
+ def round_values(record: dict, metric: str) -> dict[int, dict]:
164
+ """{round index: round} for rounds that have the metric."""
165
+ out = {}
166
+ for r in record.get("data", {}).get("rounds", []) or []:
167
+ if r.get(metric) is not None:
168
+ out[int(r["index"])] = r
169
+ return out
170
+
171
+
172
+ def interleaved(base: dict[int, dict], cand: dict[int, dict], pairs: list[int]) -> bool | None:
173
+ """True if each pair's rounds are adjacent in time; None if timestamps are missing."""
174
+ events = []
175
+ for arm, rounds in (("b", base), ("c", cand)):
176
+ for i in pairs:
177
+ t = rounds[i].get("t_start")
178
+ if t is None:
179
+ return None
180
+ events.append((t, arm, i))
181
+ events.sort()
182
+ position = {(arm, i): k for k, (_, arm, i) in enumerate(events)}
183
+ return all(abs(position[("b", i)] - position[("c", i)]) == 1 for i in pairs)
184
+
185
+
186
+ @operation("compare", ("compare",), "Interleaved-rounds check, ratio of medians with a paired bootstrap 95% CI, "
187
+ "and a verdict: improved / regressed / within_noise / inconclusive.",
188
+ [Arg("baseline", "string", "Baseline benchmark or single-user serve_benchmark evidence id.", required=True,
189
+ positional=True),
190
+ Arg("candidate", "string", "Candidate benchmark or single-user serve_benchmark evidence id.",
191
+ required=True, positional=True),
192
+ Arg("metric", "string", "Per-round metric to compare.", default="decode_tok_s",
193
+ choices=tuple(HIGHER_IS_BETTER)),
194
+ Arg("threshold", "number", "Practical threshold as a fraction (0.02 = 2%).", default=0.02),
195
+ Arg("image_tradeoff", "boolean", "Compare image timing with permitted step/precision differences; "
196
+ "quality and lifecycle require separate proof. Requires image task identities.", default=False),
197
+ Arg("resamples", "integer", "Bootstrap resamples.", default=stats.DEFAULT_RESAMPLES),
198
+ Arg("seed", "integer", "Bootstrap seed.", default=0)])
199
+ def compare(call, baseline, candidate, metric, threshold, resamples, seed, image_tradeoff=False):
200
+ store = ev.Store()
201
+ a, b = store.load(baseline), store.load(candidate)
202
+ higher = HIGHER_IS_BETTER[metric]
203
+ ra, rb = round_values(a, metric), round_values(b, metric)
204
+ limitations, missing = [], []
205
+ reasons_inconclusive = []
206
+
207
+ for rec, label in ((a, "baseline"), (b, "candidate")):
208
+ problem = ev.integrity_problem(rec)
209
+ if problem: # an edited input must not turn into a fresh, verifying comparison
210
+ reasons_inconclusive.append(f"{label} {problem}")
211
+ missing.append("input integrity")
212
+ if rec.get("kind") not in BENCH_KINDS:
213
+ reasons_inconclusive.append(f"{label} is a '{rec.get('kind')}' record, not a benchmark")
214
+ elif rec.get("kind") == "serve_benchmark" and (rec.get("identity", {}).get("workload") or {}).get(
215
+ "mode") == "load":
216
+ reasons_inconclusive.append(f"{label} is a load-mode serve_benchmark (one level, no rounds); compare "
217
+ "capacity records' N* instead")
218
+ failed = sum(int(r.get("n_failed") or 0) for r in rec.get("data", {}).get("rounds", []) or [])
219
+ if failed and rec.get("kind") in ("serve_benchmark", "clip_benchmark", *MEDIA_METRICS):
220
+ # Round medians drop failed requests: an arm that fails the slow prompts would look faster.
221
+ reasons_inconclusive.append(f"{label} had {failed} failed request(s); its round medians exclude them, "
222
+ "so the arms did not time the same requests")
223
+ elif failed:
224
+ limitations.append(f"{label} had {failed} failed request(s); its round medians exclude them")
225
+ validity = rec.get("data", {}).get("claim_validity") or {}
226
+ if validity.get("valid") is False and rec.get("kind") in ("clip_benchmark", *MEDIA_METRICS):
227
+ # Fewer steps, another shape, cached stages or repeated bytes: the clip was not the one asked for.
228
+ reasons_inconclusive.append(f"{label} claim invalid: {'; '.join(validity.get('violations', [])[:3])}")
229
+ elif validity.get("valid") is False:
230
+ limitations.append(f"{label} violates hard gates: {'; '.join(validity.get('violations', []))}")
231
+ if not rec.get("coverage", {}).get("complete", True):
232
+ limitations.append(f"{label} coverage incomplete: {rec['coverage'].get('missing')}")
233
+ clips = a.get("kind") == b.get("kind") == "clip_benchmark"
234
+ media = a.get("kind") == b.get("kind") and a.get("kind") in MEDIA_METRICS
235
+ if image_tradeoff and not (a.get("kind") == b.get("kind") == "image_benchmark"):
236
+ raise OpError("image_tradeoff is only available for two image benchmark records")
237
+ if a.get("kind") != b.get("kind") and a.get("kind") in BENCH_KINDS and b.get("kind") in BENCH_KINDS:
238
+ how = ("bench clip times whole clips, the others time tokens" if "clip_benchmark" in (a.get("kind"), b.get(
239
+ "kind")) else "bench decode times inside the engine process, bench serve on the client")
240
+ reasons_inconclusive.append(f"different timers ({a.get('kind')} vs {b.get('kind')}): {how}")
241
+ wa, wb = a["identity"]["workload"], b["identity"]["workload"]
242
+ for key in SAME_WORKLOAD:
243
+ if clips and key == "endpoint_kind":
244
+ continue # clip_http and comfyui share the client timer (until the clip is in hand); noted below
245
+ if wa.get(key) != wb.get(key):
246
+ reasons_inconclusive.append(f"workloads differ ({key}: {wa.get(key)} vs {wb.get(key)})")
247
+ if a.get("kind") == b.get("kind") == "serve_benchmark":
248
+ reasons_inconclusive += serve_workload_reasons(a, b)
249
+ if media:
250
+ reasons_inconclusive += media_workload_reasons(a, b, image_tradeoff=image_tradeoff)
251
+ if metric not in MEDIA_METRICS[a["kind"]]:
252
+ reasons_inconclusive.append(f"{metric} is not measured by {a['kind']}")
253
+ limitations.append("This compares client-timed performance only; image/speech quality, fresh-process "
254
+ "lifecycle and repeated-run reliability require their own evidence.")
255
+ if image_tradeoff:
256
+ limitations.append("Image format/work tradeoff: steps and numerical formats may differ. "
257
+ "Timing is not a quality or same-work engine comparison.")
258
+ if clips:
259
+ reasons_inconclusive += clip_workload_reasons(a, b)
260
+ if metric in ("clip_ms", "clips_per_hour") and wa.get("task") is None and wb.get("task") is None:
261
+ limitations.append(f"the clip set mixes tasks: each round's {metric} covers every clip (clip_ms is their "
262
+ f"mean), so a change to one task is diluted by the others; --metric {metric}_t2v or "
263
+ f"{metric}_i2v compares one task")
264
+ if wa.get("endpoint_kind") != wb.get("endpoint_kind"):
265
+ limitations.append(f"engines reached through different endpoint kinds ({wa.get('endpoint_kind')} vs "
266
+ f"{wb.get('endpoint_kind')}): the same client timer, different transport")
267
+ reuse = {label: int(((rec.get("data") or {}).get("claim_validity") or {}).get("cache_allowed_clips") or 0)
268
+ for rec, label in ((a, "baseline"), (b, "candidate"))}
269
+ if (reuse["baseline"] > 0) != (reuse["candidate"] > 0):
270
+ who = "baseline" if reuse["baseline"] else "candidate"
271
+ limitations.append(f"only the {who} reused its fixed negative prompt's encoding ({reuse[who]} clip(s)); "
272
+ "the other encoded it per clip. Allowed for every engine, so part of the difference "
273
+ "may be that reuse")
274
+ sa, sb = ((w.get("prompt_set") or {}).get("sha256") for w in (wa, wb))
275
+ if sa != sb and not image_tradeoff:
276
+ reasons_inconclusive.append(f"different prompt sets ({str(sa)[:12]} vs {str(sb)[:12]})")
277
+ ga, gb = a["identity"]["gpu"].get("name"), b["identity"]["gpu"].get("name")
278
+ if ga and gb and ga != gb:
279
+ reasons_inconclusive.append(f"different GPUs ({ga} vs {gb})")
280
+
281
+ unavailable = [label for rec, rounds, label in ((a, ra, "baseline"), (b, rb, "candidate"))
282
+ if not rounds and rec.get("data", {}).get("rounds")]
283
+ if unavailable:
284
+ if any(r.get("kind") == "clip_benchmark" for r in (a, b)) and metric not in CLIP_METRICS:
285
+ why = f"{metric} is not measured by bench clip (its rounds carry {', '.join(CLIP_METRICS)})"
286
+ elif any(r.get("kind") == "serve_benchmark" for r in (a, b)) and metric not in SERVE_METRICS:
287
+ why = f"{metric} is not measured by bench serve (its rounds carry {', '.join(SERVE_METRICS)})"
288
+ else:
289
+ why = f"{metric} is absent from every round of the {' and '.join(unavailable)} record"
290
+ reasons_inconclusive.append(why + "; more rounds will not help, pick another --metric")
291
+ pairs = sorted(set(ra) & set(rb))
292
+ if clips or media:
293
+ reasons_inconclusive += clip_seed_reasons(ra, rb, pairs)
294
+ paired = len(pairs) >= 2
295
+ inter = interleaved(ra, rb, pairs) if pairs else None
296
+ if media:
297
+ if len(pairs) < 3 or set(ra) != set(rb):
298
+ reasons_inconclusive.append("media comparisons need at least three complete matching round pairs")
299
+ if inter is not True:
300
+ reasons_inconclusive.append("media comparison requires verified interleaving")
301
+ for rec, label in ((a, "baseline"), (b, "candidate")):
302
+ indices = [r.get("index") for r in rec.get("data", {}).get("rounds", [])]
303
+ if len(indices) != len(set(indices)):
304
+ reasons_inconclusive.append(f"{label} has duplicate round indices")
305
+ windows = []
306
+ for label, rows in (("baseline", ra), ("candidate", rb)):
307
+ for index, row in rows.items():
308
+ begin, end = row.get("t_start"), row.get("t_end")
309
+ if not all(isinstance(t, (int, float)) and not isinstance(t, bool) and math.isfinite(t)
310
+ for t in (begin, end)) or end <= begin:
311
+ reasons_inconclusive.append(f"{label} round {index} has no valid timing window")
312
+ else:
313
+ windows.append((begin, end))
314
+ if not isinstance(row.get("seed"), int) or isinstance(row.get("seed"), bool):
315
+ reasons_inconclusive.append(f"{label} round {index} has no recorded integer seed")
316
+ windows.sort()
317
+ if any(right[0] < left[1] for left, right in zip(windows, windows[1:])):
318
+ reasons_inconclusive.append("media rounds overlapped in time")
319
+ if inter is None:
320
+ limitations.append("rounds carry no timestamps: interleaving cannot be verified")
321
+ elif inter is False:
322
+ limitations.append("rounds were not interleaved (A B A B ...): drift between runs is confounded with the "
323
+ "change; rerun alternating rounds (bench rounds, or bench decode / bench serve --extend)")
324
+ # Pair only when interleaving is verified: round i of each arm must have run back to back.
325
+ if paired and inter is True:
326
+ res = stats.paired_ratio_ci([ra[i][metric] for i in pairs], [rb[i][metric] for i in pairs], resamples, seed)
327
+ method = "paired bootstrap over round index"
328
+ n = res["n_pairs"]
329
+ else:
330
+ res = stats.unpaired_ratio_ci([r[metric] for r in ra.values()], [r[metric] for r in rb.values()],
331
+ resamples, seed)
332
+ method = "unpaired bootstrap (rounds could not be paired by interleaved index)"
333
+ n = min(res["n_baseline"], res["n_candidate"])
334
+ if pairs:
335
+ limitations.append("used an unpaired bootstrap: interleaving not verified, so rounds are not paired")
336
+ lo, hi = res["ci95"]
337
+ decision = stats.decide(res["ratio"], lo, hi, threshold, higher)
338
+ if reasons_inconclusive:
339
+ decision = {**decision, "verdict": "inconclusive", "reason": "; ".join(reasons_inconclusive)}
340
+ missing.append("comparable inputs")
341
+ if n < 2:
342
+ missing.append("rounds (need >= 2 per arm)")
343
+ # More rounds help only when the CI is too wide; not when the inputs cannot be compared.
344
+ needed = None
345
+ if decision["verdict"] == "inconclusive" and not reasons_inconclusive and decision.get("powered") is False:
346
+ needed = stats.rounds_for_power(n, decision.get("half_width"), threshold)
347
+ if needed:
348
+ limitations.append(f"about {needed} interleaved rounds per arm would likely make the CI narrow enough "
349
+ f"(+/-{stats.POWER_RATIO * threshold:.1%}); estimate scaled from this run's CI")
350
+ ratio = res["ratio"]
351
+ change = f"{(ratio - 1) * 100:+.1f}%" if ratio else "n/a"
352
+ ci_text = f" (95% CI {lo:.3f}-{hi:.3f})" if lo is not None and hi is not None else ""
353
+ summary = (f"{metric}: candidate/baseline = {ratio:.3f}{ci_text}, {change}, n={n}: {decision['verdict']}"
354
+ if ratio else f"{metric}: no ratio ({decision['reason']})")
355
+ if image_tradeoff:
356
+ summary = "image format/work tradeoff timing; quality requires separate evidence: " + summary
357
+ metrics = [ev.metric(f"{metric}_ratio", ratio, "x", "ratio_of_medians", n=n, ci95=[lo, hi])]
358
+ for rec, rounds, label in ((a, ra, "baseline"), (b, rb, "candidate")):
359
+ vals = [r[metric] for r in rounds.values()]
360
+ s = stats.summarize(vals)
361
+ metrics.append(ev.metric(metric, s.get("median"), UNITS.get(metric) or ("tok/s" if higher else "ms"),
362
+ "median", n=len(vals), subject=label))
363
+ record = ev.build(call, kind="comparison", claim_class="derived", summary=summary, identity_=b["identity"],
364
+ metrics=metrics, missing=missing, limitations=limitations,
365
+ data={"verdict": decision["verdict"], "decision": decision, "metric": metric,
366
+ "comparison_scope": "image_format_work_tradeoff" if image_tradeoff else "matched_workload",
367
+ "higher_is_better": higher, "threshold": threshold, "method": method,
368
+ "interleaved": inter, "pairs": len(pairs), "rounds_for_power": needed,
369
+ "resamples": resamples, "seed": seed,
370
+ "baseline": a["evidence_id"], "candidate": b["evidence_id"],
371
+ "baseline_identity": a["identity"],
372
+ **({"source_kind": a["kind"], "required_proofs": {
373
+ "media quality": "UNPROVEN", "fresh-process lifecycle": "UNPROVEN",
374
+ "repeated-run reliability": "UNPROVEN"},
375
+ "arm_timing_verdicts": {label: rec.get("data", {}).get("timing_verdict", "NOT RUN")
376
+ for label, rec in (("baseline", a), ("candidate", b))}}
377
+ if media else {})})
378
+ return store.save(record)