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,819 @@
1
+ """bench clip: time a video clip engine from the client side, one clip at a time, over a clip set.
2
+
3
+ The engine runs as its own server (the clip HTTP contract in serve/clip_client.py, or ComfyUI through
4
+ its adapter); inferencekit is only the client, so nothing the engine does can touch the timer.
5
+
6
+ clip_ms end to end per clip on the client's clock: from just before the request is sent to
7
+ the moment the client holds the whole clip (observed). The only published timer. Each
8
+ round's value is the MEAN over the round's clips (every round runs the same clips, so it
9
+ is the set's time per clip; a median over clips of different tasks or shapes would be one
10
+ clip's time and hide changes to the others); the record's value is the median over
11
+ rounds. ``clip_ms_<task>`` is the same over one task's clips (rounds carry both).
12
+ clips_per_hour 3,600,000 x clips / (sum of their clip_ms) per round, one clip at a time (derived; per
13
+ task too); divided by ``--gpu-count`` for clips_per_hour_per_gpu.
14
+ stage_ms the engine's own per-stage times (text encoding, denoising, decode...): SELF-REPORTED,
15
+ never published as a timer. Each clip's stages are checked to sum to no more than its
16
+ client-timed end to end; a clip whose stages sum to more is flagged (overlapping
17
+ stages, or wrong self-reports).
18
+
19
+ Clip set: JSONL (or .jsonl.gz), one clip per line:
20
+ {"id", "task": "t2v" | "i2v", "prompt", "image" (i2v only: image file, relative to the clip set),
21
+ "width", "height", "num_frames", "fps", "steps", "guidance_scale", "audio"? (true/false)}
22
+
23
+ Seeds (``--seed-policy``) are fixed before the run, so every engine gets the same seed in the same round:
24
+ per_round (default) seed(r) = first 31 bits of sha256("inferencekit-clip-seed:<base>:<r>"): the same for
25
+ every engine and clip in round r and different across rounds, so no timed clip
26
+ repeats an earlier request (the counterpart of bench serve's cache salt).
27
+ fixed seed(r) = base in every round: later rounds repeat the first round's requests
28
+ (for quality checks; the record says an output cache could serve them).
29
+ list seed(r) = --seeds[r].
30
+ A later re-run on another machine should pick a new ``--seed-base``, so no seed an engine may have seen before
31
+ repeats. The warm-up (``--warmup``, default 1, untimed) runs that many clips for EVERY distinct request
32
+ shape in the set (task, width, height, frames, fps, steps, guidance, audio, image or not), each at the
33
+ first clip of that shape with seeds of their own, all before the first timed clip: an engine compiles,
34
+ captures graphs and loads the image path per shape, and none of that may land in a timed clip. A warm-up
35
+ clip's prompt is its clip's prompt plus " (warm-up)", never a timed clip's prompt, so no timed clip can
36
+ reuse a text encoding the warm-up left behind (ComfyUI's node cache would).
37
+
38
+ Order: every round runs the clips in one order fixed by the clip set: the set's own order when no two
39
+ neighbours (the last clip and the first counted as neighbours too, for the next round) share a prompt or
40
+ an i2v image, else the first order found that has the fewest such neighbours (``data.order``). An engine
41
+ that caches encodings by their input would reuse them between such neighbours; the ones left are named in
42
+ the limitations.
43
+
44
+ Checks on every timed clip (``data.claim_validity``; compare refuses clip records that fail them):
45
+ * the engine's reported ``effective`` steps_run, width, height, frames and fps equal what was asked
46
+ (self-reported; gates clip decodes the outputs to check the real shape);
47
+ * the engine reports no stage served from a cache, except the encoding of its fixed negative prompt
48
+ (``negative_text_encode``: computed once and reused, allowed for every engine and stated as a limitation);
49
+ * different requests never return byte-identical clips (a cached or seed-ignoring engine would).
50
+
51
+ Files, in the record's directory and copied to ``--run-dir`` when given:
52
+ requests.jsonl one row per timed clip (role ``requests``)
53
+ outputs/<label>/r<round>/<id>.<fmt> every timed clip as returned (``--save-outputs hash_only`` keeps only
54
+ the hashes); outputs/<label>/index.json has each file's sha256
55
+ (role ``outputs_index``)
56
+ inputs/<label>/r<round>/<id>.json the exact request body (the image as its sha256 and size)
57
+ To interleave engines, alternate ``--rounds 1 --extend <previous ev>`` calls, or use bench rounds' clip
58
+ mode (serve/clip_rounds.py); ``compare --metric clip_ms`` takes two such records.
59
+ """
60
+
61
+ from __future__ import annotations
62
+
63
+ import hashlib
64
+ import json
65
+ import os
66
+ import re
67
+ import shutil
68
+ import statistics
69
+ import time
70
+ from dataclasses import dataclass
71
+ from pathlib import Path
72
+
73
+ from inferencekit import evidence as ev
74
+ from inferencekit import prompts as sets
75
+ from inferencekit import stats
76
+ from inferencekit.canonical import canonical_json, file_sha256
77
+ from inferencekit.gpuinfo import gpu_identity
78
+ from inferencekit.ops.bench_serve import _record_call
79
+ from inferencekit.registry import Arg, OpError, operation
80
+ from inferencekit.serve import clip_client as cc
81
+ from inferencekit.serve.client import Endpoint, api_key_from_env
82
+ from inferencekit.serve.metrics import pct
83
+
84
+ KIND = "clip_benchmark"
85
+ SEED_POLICIES = ("per_round", "fixed", "list")
86
+ SAVE_OUTPUTS = ("all", "hash_only")
87
+ _ID = re.compile(r"^[A-Za-z0-9][A-Za-z0-9_.+-]{0,127}$")
88
+ _LABEL = re.compile(r"^[A-Za-z0-9][A-Za-z0-9_.+-]{0,63}$")
89
+ _STAGE = re.compile(r"[^a-z0-9_]+")
90
+ CLIP_KEYS = ("id", "task", "prompt", "image", "width", "height", "num_frames", "fps", "steps", "guidance_scale",
91
+ "audio")
92
+ SHAPE_KEYS = ("task", "width", "height", "num_frames", "fps", "steps", "guidance_scale")
93
+ # effective key -> clip-set key it must equal
94
+ EFFECTIVE_CHECKS = (("steps_run", "steps"), ("width", "width"), ("height", "height"), ("frames", "num_frames"),
95
+ ("fps", "fps"))
96
+ # What a warm-up must cover besides SHAPE_KEYS: an engine compiles or captures graphs per request shape.
97
+ WARM_KEYS = SHAPE_KEYS + ("audio",)
98
+ ROUND_METRICS = ("clip_ms", "clips_per_hour") # per round, for 'all' and as <metric>_<task> per task
99
+ # Arguments that must match for --extend (plus the clip set's and the workflow's sha256).
100
+ SAME_CONFIG = ("base_url", "endpoint_kind", "label", "seed_policy", "seed_base", "seeds", "engine_version", "model")
101
+ MS_PER_HOUR = 3_600_000.0
102
+ WARMUP_SUFFIX = " (warm-up)"
103
+
104
+ ARGS = [
105
+ Arg("base_url", "string", "Engine root, e.g. http://127.0.0.1:8000. Never stored: records keep only an HMAC "
106
+ "of it under a per-machine key (as bench serve does).", required=True, sensitive=True),
107
+ Arg("clips", "string", "Clip set JSONL: {id, task t2v|i2v, prompt, image? (i2v), width, height, num_frames, "
108
+ "fps, steps, guidance_scale, audio?} per line.", required=True),
109
+ Arg("endpoint_kind", "string", "clip_http = the clip HTTP contract (POST /v1/clips, GET /health); comfyui = "
110
+ "a ComfyUI server through its adapter (needs --workflow).", default="clip_http", choices=cc.ENDPOINT_KINDS),
111
+ Arg("label", "string", "Name for this engine configuration in outputs/ (default: the endpoint kind)."),
112
+ Arg("model", "string", "Model id for the record's identity, e.g. the checkpoint's repo id."),
113
+ Arg("rounds", "integer", "Timed passes over the clip set.", default=3),
114
+ Arg("first_round", "integer", "Index of the first timed round (picks its seed). Default 0, or the previous "
115
+ "record's last round + 1 with --extend. bench rounds passes the round number so every engine gets the "
116
+ "same seed in the same round."),
117
+ Arg("warmup", "integer", "Untimed warm-up clips per distinct request shape (task, size, frames, fps, steps, "
118
+ "guidance, audio, image), at that shape's first clip with seeds of their own, before any timed clip.",
119
+ default=1),
120
+ Arg("seed_policy", "string", "per_round = a seed derived from --seed-base and the round (same for every "
121
+ "engine in a round, new every round); fixed = --seed-base every round; list = "
122
+ "--seeds in round order.", default="per_round", choices=SEED_POLICIES),
123
+ Arg("seed_base", "integer", "Base of the seed policy. Pick a new one when you re-run on another machine.", default=0),
124
+ Arg("seeds", "array", "--seed-policy list: the seed of each round, round 0 first.", items="integer"),
125
+ Arg("save_outputs", "string", "all = keep every timed clip; hash_only = record each clip's sha256 and size "
126
+ "but not its bytes.", default="all", choices=SAVE_OUTPUTS),
127
+ Arg("workflow", "string", "comfyui: the API-format workflow JSON with its node mapping (its sha256 is "
128
+ "recorded)."),
129
+ Arg("run_dir", "string", "Run directory: outputs/<label>/..., inputs/<label>/... and metrics/<label>.clip.json "
130
+ "are also written there."),
131
+ Arg("api_key_env", "string", "Name of the environment variable holding the API key (never stored)."),
132
+ Arg("engine_version", "string", "Engine version, recorded in the identity."),
133
+ Arg("gpu_name", "string", "GPU model, when the engine is not on this machine (otherwise read locally)."),
134
+ Arg("gpu_count", "integer", "GPUs behind the engine (clips_per_hour_per_gpu divides by it).", default=1),
135
+ Arg("timeout_s", "number", "Per-clip timeout, seconds.", default=cc.DEFAULT_TIMEOUT_S),
136
+ Arg("resamples", "integer", "Bootstrap resamples for CIs.", default=stats.DEFAULT_RESAMPLES),
137
+ Arg("extend", "string", "Evidence id of an earlier run of the same configuration: its rounds are kept and new "
138
+ "rounds appended (alternate engines this way)."),
139
+ ]
140
+
141
+
142
+ # --------------------------------------------------------------------------- the clip set
143
+
144
+ @dataclass
145
+ class ClipSet:
146
+ path: Path
147
+ name: str
148
+ sha256: str
149
+ clips: list[dict]
150
+ images: dict # clip id -> (bytes, sha256)
151
+ ignored_keys: list[str]
152
+
153
+ def identity(self) -> dict:
154
+ return {"name": self.name, "sha256": self.sha256}
155
+
156
+ def uniform(self, key: str):
157
+ """The value every clip has for ``key``, or None when clips differ."""
158
+ values = {json.dumps(c.get(key)) for c in self.clips}
159
+ return self.clips[0].get(key) if len(values) == 1 else None
160
+
161
+
162
+ def _positive_int(v) -> bool:
163
+ return isinstance(v, int) and not isinstance(v, bool) and v >= 1
164
+
165
+
166
+ def _number(v) -> bool:
167
+ return isinstance(v, (int, float)) and not isinstance(v, bool)
168
+
169
+
170
+ def load_clip_set(path: str | os.PathLike) -> ClipSet:
171
+ """Read and check a clip set; read the i2v images (paths relative to the clip set file)."""
172
+ p = Path(path)
173
+ try:
174
+ raw = sets.read_bytes(p)
175
+ except (OSError, ValueError) as e:
176
+ raise OpError(f"cannot read clip set {path}: {e}") from None
177
+ clips, problems, seen, ignored = [], [], set(), set()
178
+ for n, line in enumerate(raw.decode(errors="replace").splitlines(), 1):
179
+ if not line.strip():
180
+ continue
181
+ try:
182
+ row = json.loads(line)
183
+ except ValueError:
184
+ problems.append(f"line {n} is not JSON")
185
+ continue
186
+ if not isinstance(row, dict):
187
+ problems.append(f"line {n} is not an object")
188
+ continue
189
+ cid = row.get("id")
190
+ where = f"line {n} ({cid})" if isinstance(cid, str) else f"line {n}"
191
+ if not isinstance(cid, str) or not _ID.match(cid):
192
+ problems.append(f"{where}: id must be 1-128 letters, digits and _ . + - (it names a file)")
193
+ elif cid in seen:
194
+ problems.append(f"{where}: id appears twice")
195
+ seen.add(cid)
196
+ if row.get("task") not in cc.TASKS:
197
+ problems.append(f"{where}: task must be one of {list(cc.TASKS)}")
198
+ if not isinstance(row.get("prompt"), str) or not row["prompt"].strip():
199
+ problems.append(f"{where}: prompt must be non-empty text")
200
+ for key in ("width", "height", "num_frames", "steps"):
201
+ if not _positive_int(row.get(key)):
202
+ problems.append(f"{where}: {key} must be an integer >= 1")
203
+ if not _number(row.get("fps")) or row["fps"] <= 0:
204
+ problems.append(f"{where}: fps must be a number > 0")
205
+ if not _number(row.get("guidance_scale")) or row["guidance_scale"] < 0:
206
+ problems.append(f"{where}: guidance_scale must be a number >= 0")
207
+ if row.get("task") == "i2v" and not isinstance(row.get("image"), str):
208
+ problems.append(f"{where}: an i2v clip needs an image (a file path)")
209
+ if row.get("task") == "t2v" and row.get("image") is not None:
210
+ problems.append(f"{where}: a t2v clip takes no image")
211
+ if row.get("audio") is not None and not isinstance(row["audio"], bool):
212
+ problems.append(f"{where}: audio must be true or false")
213
+ ignored.update(k for k in row if k not in CLIP_KEYS)
214
+ clips.append(row)
215
+ if not clips and not problems:
216
+ problems.append("no clips")
217
+ images = {}
218
+ for c in clips:
219
+ if isinstance(c.get("image"), str) and isinstance(c.get("id"), str):
220
+ img = Path(c["image"]) if Path(c["image"]).is_absolute() else p.parent / c["image"]
221
+ try:
222
+ data = img.read_bytes()
223
+ except OSError as e:
224
+ problems.append(f"{c['id']}: image {c['image']} not readable ({type(e).__name__})")
225
+ continue
226
+ images[c["id"]] = (data, hashlib.sha256(data).hexdigest())
227
+ if problems:
228
+ raise OpError(f"clip set {path}: " + "; ".join(problems[:10]))
229
+ return ClipSet(p, sets.set_stem(p), hashlib.sha256(raw).hexdigest(), clips, images, sorted(ignored))
230
+
231
+
232
+ # --------------------------------------------------------------------------- seeds
233
+
234
+ def round_seed(policy: str, base: int, seeds: list[int] | None, r: int) -> int:
235
+ """The seed of round ``r`` under a seed policy (see the module docstring)."""
236
+ if policy == "fixed":
237
+ return base
238
+ if policy == "list":
239
+ return int(seeds[r])
240
+ digest = hashlib.sha256(f"inferencekit-clip-seed:{base}:{r}".encode()).digest()
241
+ return int.from_bytes(digest[:4], "big") & 0x7FFFFFFF
242
+
243
+
244
+ def warmup_seed(base: int, i: int) -> int:
245
+ digest = hashlib.sha256(f"inferencekit-clip-warmup:{base}:{i}".encode()).digest()
246
+ return int.from_bytes(digest[:4], "big") & 0x7FFFFFFF
247
+
248
+
249
+ def seed_policy_doc(policy: str, base: int, seeds: list[int] | None) -> dict:
250
+ rule = {"per_round": "seed(r) = first 31 bits of sha256('inferencekit-clip-seed:<base>:<r>'), every clip of "
251
+ "round r",
252
+ "fixed": "seed(r) = base, every clip and round",
253
+ "list": f"seed(r) = seeds[r], every clip of round r ({len(seeds or [])} seeds given)"}[policy]
254
+ return {"name": policy, "base": None if policy == "list" else base, "rule": rule}
255
+
256
+
257
+ def request_set(clipset: ClipSet, policy: dict, seeds: list[int] | None) -> dict:
258
+ """identity.workload.request_set: sha256 over what every clip asks for (its shape, prompt, image sha256) and
259
+ the seed policy, so compare can refuse two records that sent different requests."""
260
+ items = [{**{k: c.get(k) for k in CLIP_KEYS if k != "image"},
261
+ "image_sha256": (clipset.images.get(c["id"]) or (None, None))[1]} for c in clipset.clips]
262
+ settings = {"seed_policy": {k: policy.get(k) for k in ("name", "base")},
263
+ "seeds": list(seeds) if policy["name"] == "list" else None}
264
+ return {"sha256": hashlib.sha256(canonical_json({"items": items, "settings": settings}).encode()).hexdigest(),
265
+ "n": len(items)}
266
+
267
+
268
+ def warm_shapes(clipset: ClipSet) -> list[tuple[dict, dict]]:
269
+ """(shape, its first clip) for every distinct request shape of the set, in clip-set order."""
270
+ seen, out = set(), []
271
+ for c in clipset.clips:
272
+ shape = {**{k: c.get(k) for k in WARM_KEYS}, "image": c.get("image") is not None}
273
+ key = canonical_json(shape)
274
+ if key not in seen:
275
+ seen.add(key)
276
+ out.append((shape, c))
277
+ return out
278
+
279
+
280
+ def warmup_prompt(prompt: str, taken: set[str]) -> str:
281
+ """A warm-up prompt: ``prompt`` plus " (warm-up)" (numbered if needed), never one of the ``taken`` (timed)
282
+ prompts, so a timed clip never reuses a text encoding made by the warm-up."""
283
+ out, n = prompt + WARMUP_SUFFIX, 2
284
+ while out in taken:
285
+ out, n = f"{prompt} (warm-up {n})", n + 1
286
+ return out
287
+
288
+
289
+ def _clash(a: dict, b: dict, clipset: ClipSet) -> bool:
290
+ """Neighbouring clips that share a prompt or an i2v image (an encoding an engine could reuse)."""
291
+ ia, ib = (clipset.images.get(c["id"], (None, None))[1] for c in (a, b))
292
+ return a["prompt"] == b["prompt"] or (ia is not None and ia == ib)
293
+
294
+
295
+ def clashes(order: list[dict], clipset: ClipSet, wrap: bool = True) -> list[tuple[str, str]]:
296
+ """(id, next id) of neighbouring clips sharing a prompt or an image; ``wrap``: the last clip and the first
297
+ (the next round) count as neighbours."""
298
+ pairs = list(zip(order, order[1:])) + ([(order[-1], order[0])] if wrap and len(order) > 2 else [])
299
+ return [(a["id"], b["id"]) for a, b in pairs if _clash(a, b, clipset)]
300
+
301
+
302
+ def timed_order(clipset: ClipSet) -> list[dict]:
303
+ """The order every round runs: the clip set's own when no neighbours clash (prompt or image, the last and
304
+ the first included), else the order with the fewest clashes found by a greedy pass from each start (most
305
+ clashing clips first; deterministic, the same for every engine and call)."""
306
+ clips = clipset.clips
307
+ if not clashes(clips, clipset):
308
+ return list(clips)
309
+ n = len(clips)
310
+ clash = [[i != j and _clash(clips[i], clips[j], clipset) for j in range(n)] for i in range(n)]
311
+ best, best_bad = list(clips), len(clashes(clips, clipset))
312
+ for start in range(n):
313
+ seq, left = [start], set(range(n)) - {start}
314
+ deg = [sum(clash[j][k] for k in left) for j in range(n)] # clashing partners still to place
315
+ while left:
316
+ prev = seq[-1]
317
+ nxt = min(left, key=lambda j: (clash[prev][j], -deg[j], j))
318
+ seq.append(nxt)
319
+ left.discard(nxt)
320
+ for k in range(n):
321
+ deg[k] -= clash[k][nxt]
322
+ order = [clips[i] for i in seq]
323
+ bad = len(clashes(order, clipset))
324
+ if bad < best_bad:
325
+ best, best_bad = order, bad
326
+ return best
327
+
328
+
329
+ def warm_plan(shapes: list[tuple[dict, dict]], order: list[dict], clipset: ClipSet) -> list[tuple[dict, dict]]:
330
+ """warm_shapes, re-ordered (and, if needed, another clip of the last shape) so that the last warm-up clip's
331
+ i2v image is not the first timed clip's: the image encoding left by the warm-up must not serve it."""
332
+ first = clipset.images.get(order[0]["id"], (None, None))[1] if order else None
333
+ img = lambda c: clipset.images.get(c["id"], (None, None))[1] # noqa: E731
334
+ if first is None or not shapes or img(shapes[-1][1]) != first:
335
+ return shapes
336
+ ok = [k for k, (_, c) in enumerate(shapes) if img(c) != first]
337
+ if ok:
338
+ k = ok[-1]
339
+ return shapes[:k] + shapes[k + 1:] + [shapes[k]]
340
+ shape, _ = shapes[-1]
341
+ other = next((c for c in clipset.clips if img(c) not in (None, first)
342
+ and {**{k: c.get(k) for k in WARM_KEYS}, "image": True} == shape), None)
343
+ return shapes[:-1] + [(shape, other)] if other else shapes
344
+
345
+
346
+ def make_request(clip: dict, seed: int, clipset: ClipSet, prompt: str | None = None) -> cc.ClipRequest:
347
+ """The request for ``clip`` at ``seed``; ``prompt`` replaces its prompt (the warm-up's own prompt)."""
348
+ image = clipset.images.get(clip["id"])
349
+ return cc.ClipRequest(id=clip["id"], task=clip["task"], prompt=prompt or clip["prompt"], width=clip["width"],
350
+ height=clip["height"], num_frames=clip["num_frames"], fps=clip["fps"], steps=clip["steps"],
351
+ guidance_scale=clip["guidance_scale"], seed=seed, image=image[0] if image else None,
352
+ audio=clip.get("audio"))
353
+
354
+
355
+ # --------------------------------------------------------------------------- per clip
356
+
357
+ def _stage_name(name: str) -> str:
358
+ return _STAGE.sub("_", str(name).lower()).strip("_")[:48] or "stage"
359
+
360
+
361
+ def check_effective(effective: dict | None, clip: dict) -> tuple[list[str], int]:
362
+ """(mismatches, number of fields checked) between what the engine says it ran and what was asked."""
363
+ if not isinstance(effective, dict):
364
+ return [], 0
365
+ bad, checked = [], 0
366
+ for eff_key, asked_key in EFFECTIVE_CHECKS:
367
+ got = effective.get(eff_key)
368
+ if not _number(got):
369
+ continue
370
+ checked += 1
371
+ want = clip[asked_key]
372
+ if abs(float(got) - float(want)) > 1e-6:
373
+ bad.append(f"{eff_key} {got:g} (asked {want:g})")
374
+ return bad, checked
375
+
376
+
377
+ def clip_row(res: cc.ClipResult, clip: dict, r: int, seed: int, t_wall: float, out_sha: str | None) -> dict:
378
+ """One timed clip's numbers (no bytes): a row of requests.jsonl."""
379
+ e2e = res.e2e_ms
380
+ stage_sum = cc.stage_sum_ms(res.stage_ms) if res.ok else None
381
+ eff = res.effective or {}
382
+ mismatch, checked = check_effective(res.effective, clip) if res.ok else ([], 0)
383
+ return {"id": clip["id"], "task": clip["task"], "round": r, "seed": seed, "ok": res.ok, "error": res.error,
384
+ "status": res.status, "e2e_ms": e2e, "connect_ms": res.connect_ms,
385
+ "stage_ms": res.stage_ms if res.ok else None, "stage_sum_ms": stage_sum,
386
+ "stage_sum_ok": None if stage_sum is None or e2e is None else stage_sum <= e2e,
387
+ "steps_run": eff.get("steps_run"), "w": eff.get("width"), "h": eff.get("height"),
388
+ "frames": eff.get("frames"), "fps": eff.get("fps"), "dtype": eff.get("dtype"),
389
+ "attention": eff.get("attention"), "audio": eff.get("audio"),
390
+ "effective_checked": checked, "effective_mismatch": mismatch, "cache": res.cache,
391
+ "cache_allowed": res.cache_allowed or None,
392
+ "out_sha256": out_sha, "out_bytes": len(res.video) if res.video is not None else None,
393
+ "format": res.format if res.ok else None, "via": res.via,
394
+ "asked": {k: clip[k] for k in SHAPE_KEYS}, "t_start": t_wall,
395
+ "t_send_ns": res.t_send_ns, "t_end_ns": res.t_end_ns}
396
+
397
+
398
+ def _place(data: bytes, rel: str, roots: list[Path]) -> None:
399
+ """Write ``data`` at ``<root>/<rel>`` for each root (a hard link after the first when possible).
400
+
401
+ ``rel`` is built from checked ids and labels and a sanitised format; a path that would still land
402
+ outside a root is refused before anything is created or removed there."""
403
+ if Path(rel).is_absolute() or ".." in Path(rel).parts:
404
+ raise OpError(f"refusing to write {rel!r}: not a plain relative path")
405
+ first = None
406
+ for root in roots:
407
+ target = root / rel
408
+ if not target.resolve().is_relative_to(root.resolve()):
409
+ raise OpError(f"refusing to write {rel!r}: it resolves outside the output directory")
410
+ target.parent.mkdir(parents=True, exist_ok=True)
411
+ if target.exists():
412
+ target.unlink()
413
+ if first is not None:
414
+ try:
415
+ os.link(first, target)
416
+ continue
417
+ except OSError:
418
+ shutil.copyfile(first, target)
419
+ continue
420
+ target.write_bytes(data)
421
+ first = target
422
+
423
+
424
+ def _input_doc(req: cc.ClipRequest, clipset: ClipSet) -> dict:
425
+ body = req.body()
426
+ if "image" in body:
427
+ data, sha = clipset.images[req.id]
428
+ body["image"] = {"sha256": sha, "bytes": len(data)}
429
+ return body
430
+
431
+
432
+ # --------------------------------------------------------------------------- summaries
433
+
434
+ def round_stats(rows: list[dict]) -> dict[str, dict]:
435
+ """One round's values per task and 'all': clip_ms = mean client-timed ms over its ok clips, clips_per_hour
436
+ = 3,600,000 x clips / their sum (one clip at a time), n ok clips, n_failed. A mean, not a median: with one
437
+ t2v and one i2v clip a median is just the faster clip, and a change to the other would not show."""
438
+ out = {}
439
+ for subject in sorted({str(r["task"]) for r in rows if r.get("task")}) + ["all"]:
440
+ sel = [r for r in rows if subject == "all" or r.get("task") == subject]
441
+ ok = [float(r["e2e_ms"]) for r in sel if r.get("ok") and _number(r.get("e2e_ms"))]
442
+ total = sum(ok)
443
+ out[subject] = {"clip_ms": total / len(ok) if ok and total > 0 else None,
444
+ "clips_per_hour": MS_PER_HOUR * len(ok) / total if ok and total > 0 else None,
445
+ "n": len(ok), "n_failed": len(sel) - len(ok)}
446
+ return out
447
+
448
+
449
+ def round_key(metric: str, subject: str) -> str:
450
+ """The data.rounds key of a per-round value: clip_ms for every clip, clip_ms_i2v for one task."""
451
+ return metric if subject == "all" else f"{metric}_{subject}"
452
+
453
+
454
+ def round_rows(rows: list[dict], walls: dict[int, tuple[float, float]], seeds: dict[int, int]) -> list[dict]:
455
+ """One row per round in the shape compare reads: index, t_start, clip_ms and clips_per_hour (every clip),
456
+ clip_ms_<task> and clips_per_hour_<task> (one task)."""
457
+ out = []
458
+ for r, (t0, t1) in sorted(walls.items()):
459
+ mine = [x for x in rows if x["round"] == r]
460
+ vals = round_stats(mine)
461
+ row = {"index": r, "t_start": t0, "t_end": t1, "seed": seeds.get(r), "n_requests": vals["all"]["n"],
462
+ "n_failed": vals["all"]["n_failed"]}
463
+ for subject, v in vals.items():
464
+ row.update({round_key(m, subject): v[m] for m in ROUND_METRICS})
465
+ out.append(row)
466
+ return out
467
+
468
+
469
+ def clip_metrics(rows: list[dict], tasks: list[str], gpu_count: int, resamples: int) -> tuple[list[dict], dict]:
470
+ """Evidence metrics per task and 'all': clip_ms and clips_per_hour, each the median over rounds of the
471
+ round's value (round_stats) with a bootstrap CI over rounds, and the self-reported stage medians.
472
+
473
+ The median over rounds is the usual one (statistics.median: with an even number of rounds, the mean of the two
474
+ middle rounds), as in bench rounds' summary. It used to be the nearest-rank value, which with an even number
475
+ of rounds is the lower of the two: the faster round for clip_ms but the slower one for clips_per_hour, so one
476
+ record's two "medians" came from different rounds. The CIs are unchanged."""
477
+ out, table = [], {}
478
+ by_round = {k: round_stats([r for r in rows if r["round"] == k]) for k in sorted({r["round"] for r in rows})}
479
+ for subject in list(tasks) + ["all"]:
480
+ sel = [r for r in rows if r["ok"] and r["e2e_ms"] is not None and (subject == "all" or r["task"] == subject)]
481
+ values = [r["e2e_ms"] for r in sel]
482
+ done = [v[subject] for v in by_round.values() if (v.get(subject) or {}).get("clip_ms") is not None]
483
+ means, rates = [v["clip_ms"] for v in done], [v["clips_per_hour"] for v in done]
484
+ entry = {"n_clips": len(sel), "rounds": len(means)}
485
+ if means:
486
+ ci = stats.bootstrap_ci(means, ps=(50,), resamples=resamples, seed=0)[50]
487
+ entry["clip_ms"] = {"median": statistics.median(means), "ci95_median": list(ci), "n_rounds": len(means),
488
+ "per_clip": {"median": pct(values, 50), "max": max(values), "n": len(values)}}
489
+ out.append(ev.metric("clip_ms", statistics.median(means), "ms", "median", n=len(means), ci95=ci,
490
+ subject=subject))
491
+ rci = stats.bootstrap_ci(rates, ps=(50,), resamples=resamples, seed=0)[50]
492
+ rate = statistics.median(rates)
493
+ entry["clips_per_hour"] = {"median": rate, "ci95_median": list(rci), "n_rounds": len(rates)}
494
+ out.append(ev.metric("clips_per_hour", rate, "clips/h", "median_over_rounds", n=len(rates), ci95=rci,
495
+ subject=subject))
496
+ if gpu_count > 1:
497
+ out.append(ev.metric("clips_per_hour_per_gpu", rate / gpu_count, "clips/h/GPU",
498
+ "median_over_rounds", n=len(rates),
499
+ ci95=[x / gpu_count if x is not None else None for x in rci], subject=subject))
500
+ else:
501
+ out.append(ev.metric("clip_ms", None, "ms", "median", n=0, subject=subject))
502
+ table[subject] = entry
503
+ stages: dict[str, list[float]] = {}
504
+ for r in rows:
505
+ if r["ok"] and isinstance(r.get("stage_ms"), dict):
506
+ for k, v in r["stage_ms"].items():
507
+ if _number(v):
508
+ stages.setdefault(_stage_name(k), []).append(float(v))
509
+ for name, vals in sorted(stages.items()):
510
+ out.append(ev.metric(f"stage_{name}_ms", statistics.median(vals), "ms", "median_self_reported", n=len(vals),
511
+ subject="all"))
512
+ table["stages_self_reported"] = {k: {"median": statistics.median(v), "n": len(v)}
513
+ for k, v in sorted(stages.items())}
514
+ return out, table
515
+
516
+
517
+ def validity(rows: list[dict], policy: str) -> tuple[dict, list[str]]:
518
+ """claim_validity for the record (violations make compare refuse it) and plain limitations."""
519
+ violations, warnings, lim = [], [], []
520
+ ok = [r for r in rows if r["ok"]]
521
+ for r in ok:
522
+ if r["effective_mismatch"]:
523
+ violations.append(f"{r['id']} r{r['round']}: engine reports {', '.join(r['effective_mismatch'])}")
524
+ if r.get("cache"):
525
+ violations.append(f"{r['id']} r{r['round']}: engine reports cached stage(s) {r['cache'][:5]}")
526
+ by_sha: dict[str, set] = {}
527
+ for r in ok:
528
+ if r.get("out_sha256"):
529
+ by_sha.setdefault(r["out_sha256"], set()).add((r["id"], r["seed"]))
530
+ for sha, reqs in sorted(by_sha.items()):
531
+ if len(reqs) > 1:
532
+ shown = ", ".join(f"{i} seed {s}" for i, s in sorted(reqs)[:4])
533
+ violations.append(f"byte-identical clips for {len(reqs)} different requests ({shown}): cached outputs "
534
+ "or an ignored seed/prompt")
535
+ unchecked = [r for r in ok if not r["effective_checked"]]
536
+ if unchecked:
537
+ lim.append(f"{len(unchecked)} of {len(ok)} clip(s): the engine reported no effective steps/width/height/"
538
+ "frames/fps, so this record cannot say they were honoured (gates clip decodes the outputs)")
539
+ over = [r for r in ok if r.get("stage_sum_ok") is False]
540
+ if over:
541
+ warnings.append(f"{len(over)} clip(s): self-reported stage times sum to more than the client-timed end to "
542
+ f"end (e.g. {over[0]['id']} r{over[0]['round']}: {over[0]['stage_sum_ms']:.1f} > "
543
+ f"{over[0]['e2e_ms']:.1f} ms); overlapping stages or wrong self-reports, so the stage "
544
+ "breakdown is not trustworthy for them")
545
+ no_stage = [r for r in ok if r.get("stage_sum_ms") is None]
546
+ if no_stage:
547
+ lim.append(f"{len(no_stage)} clip(s) without self-reported stage times")
548
+ reused = [r for r in ok if r.get("cache_allowed")]
549
+ if reused:
550
+ lim.append(f"{len(reused)} of {len(ok)} clip(s): the engine reused the encoding of its fixed negative prompt "
551
+ f"({cc.NEGATIVE_PROMPT_CACHE}: computed once, not per clip). Allowed for every engine (a clip "
552
+ "HTTP engine reports it in cache; a ComfyUI workflow lists that encoder in allow_cached) and "
553
+ "stated here")
554
+ if policy == "fixed" and len({r["round"] for r in rows}) > 1:
555
+ lim.append("--seed-policy fixed: every round repeats the first round's requests, so an engine output cache "
556
+ "could serve rounds after the first (unique-output checks cannot see a cache that returns the "
557
+ "same bytes for the same request)")
558
+ lim += [f"claim invalid: {v}" for v in violations[:10]] + warnings
559
+ return {"valid": not violations, "violations": violations[:50], "warnings": warnings,
560
+ "cache_allowed_clips": len(reused),
561
+ "checked": ["effective_shape (self-reported)", "engine_cache (self-reported)", "unique_outputs",
562
+ "stage_sum <= e2e (self-reported stages)"]}, lim
563
+
564
+
565
+ # --------------------------------------------------------------------------- the operation
566
+
567
+ def _load_previous(store, extend, rcall, clipset, workflow_sha):
568
+ prev = store.load(extend)
569
+ if prev.get("kind") != KIND or (prev.get("data") or {}).get("source") != "bench clip":
570
+ raise OpError(f"--extend {extend}: not a bench clip record")
571
+ problem = ev.integrity_problem(prev)
572
+ if problem:
573
+ raise OpError(f"--extend: {problem}; refusing to build on it")
574
+ prev_args = prev["data"].get("request", {}).get("args", {})
575
+ mine = ev.redact(rcall.request()["args"])
576
+ diff = [k for k in SAME_CONFIG if prev_args.get(k) != mine.get(k)]
577
+ if (prev["identity"]["workload"].get("clip_set") or {}).get("sha256") != clipset.sha256:
578
+ diff.append("clip set")
579
+ if (prev["data"].get("backend") or {}).get("workflow_sha256") != workflow_sha:
580
+ diff.append("workflow")
581
+ if diff:
582
+ raise OpError(f"--extend needs the same configuration; these differ: {diff}")
583
+ art = next((a for a in prev["artifacts"] if a.get("role") == "requests"), None)
584
+ path = store.run_dir(prev["evidence_id"]) / art["path"] if art else None
585
+ if path is None or not path.is_file() or file_sha256(path)[0] != art["sha256"]:
586
+ raise OpError(f"--extend: the per-request rows of {prev['evidence_id'][:15]} are missing or changed")
587
+ rows = [json.loads(line) for line in path.read_text().splitlines() if line.strip()]
588
+ return prev, rows
589
+
590
+
591
+ def _fmt_s(ms) -> str:
592
+ return "n/a" if ms is None else f"{ms / 1000:.2f} s"
593
+
594
+
595
+ @operation("bench_clip", ("bench", "clip"), "Client-timed benchmark of a video clip engine (clip HTTP contract or "
596
+ "ComfyUI): end-to-end seconds per clip with CIs over rounds, clips/hour, self-reported stage times, "
597
+ "pre-registered seeds per round, and checks on steps, shape, caching and unique outputs.", ARGS)
598
+ def bench_clip(call, base_url, clips, endpoint_kind, label, model, rounds, first_round, warmup, seed_policy,
599
+ seed_base, seeds, save_outputs, workflow, run_dir, api_key_env, engine_version, gpu_name, gpu_count,
600
+ timeout_s, resamples, extend):
601
+ if rounds < 1:
602
+ raise OpError("rounds must be >= 1")
603
+ if warmup < 0:
604
+ raise OpError("warmup must be >= 0")
605
+ if gpu_count < 1:
606
+ raise OpError("gpu_count must be >= 1")
607
+ if first_round is not None and first_round < 0:
608
+ raise OpError("first_round must be >= 0")
609
+ if seeds and seed_policy != "list":
610
+ raise OpError("--seeds is for --seed-policy list")
611
+ if seed_policy == "list" and not seeds:
612
+ raise OpError("--seed-policy list needs --seeds (one per round, round 0 first)")
613
+ label = label or endpoint_kind
614
+ if not _LABEL.match(label):
615
+ raise OpError("label must be 1-64 characters of letters, digits and _ . + - (it names a directory)")
616
+ if endpoint_kind == "comfyui" and not workflow:
617
+ raise OpError("--endpoint-kind comfyui needs --workflow (the API-format workflow JSON)")
618
+ workflow_sha = file_sha256(Path(workflow))[0] if workflow and Path(workflow).is_file() else None
619
+ if workflow and workflow_sha is None:
620
+ raise OpError(f"--workflow {workflow}: not a readable file")
621
+ clipset = load_clip_set(clips)
622
+ try:
623
+ ep = Endpoint.parse(base_url, api_key_from_env(api_key_env))
624
+ except ValueError as e:
625
+ raise OpError(str(e)) from None
626
+ rcall, key_persistent = _record_call(call, ep)
627
+ store = ev.Store()
628
+ prev, prev_rows = (None, [])
629
+ if extend:
630
+ prev, prev_rows = _load_previous(store, extend, rcall, clipset, workflow_sha)
631
+ done = max((r["round"] for r in prev_rows), default=-1)
632
+ start = done + 1 if first_round is None else first_round
633
+ if start <= done:
634
+ raise OpError(f"--first-round {start}: the extended record already has rounds up to {done}")
635
+ last = start + rounds - 1
636
+ if seed_policy == "list" and len(seeds) <= last:
637
+ raise OpError(f"--seeds gives {len(seeds)} seed(s) but round {last} needs seed index {last}")
638
+ policy = seed_policy_doc(seed_policy, seed_base, seeds)
639
+ round_seeds = {r: round_seed(seed_policy, seed_base, seeds, r) for r in range(last + 1)}
640
+
641
+ try:
642
+ backend = cc.make_backend(endpoint_kind, ep, timeout_s=timeout_s, workflow=workflow)
643
+ except (ValueError, LookupError) as e:
644
+ raise OpError(str(e)) from None
645
+ status, detail = backend.health()
646
+ if status != 200:
647
+ backend.close()
648
+ raise OpError(f"engine not ready: GET {cc.HEALTH_PATH} answered {status or 'nothing'} ({detail}); start it "
649
+ "and wait for /health")
650
+ run = Path(run_dir) if run_dir else None
651
+ order = timed_order(clipset)
652
+ shapes = warm_plan(warm_shapes(clipset), order, clipset)
653
+ taken = {c["prompt"] for c in clipset.clips}
654
+ try:
655
+ with store.staging_dir() as staging:
656
+ # Untimed, before the first timed clip: every shape once compiled/captured, seeds and prompts of
657
+ # their own (a timed clip never reuses an encoding the warm-up made).
658
+ warm = {"per_shape": warmup, "shapes": [{**shape, "clip": c["id"]} for shape, c in shapes], "clips": [],
659
+ "prompt": f"the clip's prompt + {WARMUP_SUFFIX.strip()!r}, never a timed clip's prompt"}
660
+ first_img = clipset.images.get(order[0]["id"], (None, None))[1]
661
+ warm["last_image_is_first_clips"] = bool(warmup and shapes and first_img is not None
662
+ and clipset.images.get(shapes[-1][1]["id"], (None, None))[1]
663
+ == first_img)
664
+ for s, (_, clip) in enumerate(shapes):
665
+ for i in range(warmup):
666
+ seed = warmup_seed(seed_base, s * warmup + i)
667
+ res = backend.generate(make_request(clip, seed, clipset, warmup_prompt(clip["prompt"], taken)))
668
+ warm["clips"].append({"id": clip["id"], "shape": s, "seed": seed, "ok": res.ok,
669
+ "e2e_ms": res.e2e_ms, "error": res.error})
670
+ rows, walls, index = [], {}, {}
671
+ roots = [staging] + ([run] if run else [])
672
+ for r in range(start, last + 1):
673
+ t0 = time.time()
674
+ for clip in order:
675
+ req = make_request(clip, round_seeds[r], clipset)
676
+ t_wall = time.time()
677
+ res = backend.generate(req)
678
+ out_sha = hashlib.sha256(res.video).hexdigest() if res.ok and res.video is not None else None
679
+ base = f"r{r}/{clip['id']}"
680
+ _place(json.dumps(_input_doc(req, clipset), indent=1).encode() + b"\n",
681
+ f"inputs/{label}/{base}.json", roots)
682
+ if out_sha and save_outputs == "all":
683
+ rel = f"{base}.{res.format}"
684
+ _place(res.video, f"outputs/{label}/{rel}", roots)
685
+ index[rel] = out_sha
686
+ rows.append(clip_row(res, clip, r, round_seeds[r], t_wall, out_sha))
687
+ res.video = None # the bytes are on disk; do not hold every clip in memory
688
+ walls[r] = (t0, time.time())
689
+ return _record(rcall=rcall, store=store, staging=staging, run=run, backend=backend, ep=ep,
690
+ clipset=clipset, order=order, rounds=rounds, label=label, model=model,
691
+ endpoint_kind=endpoint_kind, policy=policy, seeds=seeds, round_seeds=round_seeds,
692
+ warm=warm, rows=rows, walls=walls, index=index,
693
+ prev=prev, prev_rows=prev_rows, save_outputs=save_outputs, workflow_sha=workflow_sha,
694
+ engine_version=engine_version, gpu_name=gpu_name, gpu_count=gpu_count,
695
+ resamples=resamples, key_persistent=key_persistent)
696
+ finally:
697
+ backend.close()
698
+
699
+
700
+ def _record(*, rcall, store, staging, run, backend, ep, clipset, order, rounds, label, model, endpoint_kind, policy,
701
+ seeds, round_seeds, warm, rows, walls, index, prev, prev_rows, save_outputs, workflow_sha, engine_version,
702
+ gpu_name, gpu_count, resamples, key_persistent):
703
+ all_rows = prev_rows + rows
704
+ req_path = staging / "requests.jsonl"
705
+ req_path.write_text("".join(json.dumps(ev.redact(r), ensure_ascii=False) + "\n" for r in all_rows))
706
+ artifacts = [ev.artifact(req_path, staging, "requests")]
707
+ if index:
708
+ idx = staging / "outputs" / label / "index.json"
709
+ idx.parent.mkdir(parents=True, exist_ok=True)
710
+ idx.write_text(json.dumps({"label": label, "files": dict(sorted(index.items()))}, indent=1) + "\n")
711
+ artifacts.append(ev.artifact(idx, staging, "outputs_index"))
712
+ tasks = sorted({c["task"] for c in clipset.clips})
713
+ mets, table = clip_metrics(all_rows, tasks, gpu_count, resamples)
714
+ prev_walls = {int(x["index"]): (x["t_start"], x["t_end"]) for x in (prev or {}).get("data", {}).get("rounds", [])}
715
+ rounds_table = round_rows(all_rows, {**prev_walls, **walls}, round_seeds)
716
+ claim, limitations = validity(all_rows, policy["name"])
717
+ failed = [r for r in all_rows if not r["ok"]]
718
+ if failed:
719
+ limitations.insert(0, f"{len(failed)} of {len(all_rows)} clip(s) failed (e.g. {failed[0]['id']} "
720
+ f"r{failed[0]['round']}: {failed[0]['error']})")
721
+ bad_warm = [w for w in warm["clips"] if not w["ok"]]
722
+ if bad_warm:
723
+ limitations.append(f"{len(bad_warm)} of {len(warm['clips'])} warm-up clip(s) failed (e.g. "
724
+ f"{bad_warm[0]['id']}: {bad_warm[0]['error']}): the first timed clip of that shape may "
725
+ "include compilation")
726
+ if not warm["clips"]:
727
+ limitations.append("--warmup 0: the first timed clip of each shape may include compilation or graph capture")
728
+ if warm.get("last_image_is_first_clips"):
729
+ limitations.append("the last warm-up clip used the first timed clip's image (the set has no other image of "
730
+ "that shape): an engine that caches image encodings could reuse it for that clip")
731
+ if len(rounds_table) < 2:
732
+ limitations.append("fewer than 2 rounds: no confidence interval")
733
+ if not ep.is_local():
734
+ limitations.append("engine is not on this host: clip_ms includes the network transfer of the clip")
735
+ if not key_persistent:
736
+ limitations.append("no per-machine key file could be written: the endpoint tag in this record is valid for "
737
+ "this process only, so --extend from another process sees a different base_url")
738
+ if clipset.ignored_keys:
739
+ limitations.append(f"clip set keys not sent to the engine: {clipset.ignored_keys[:8]}")
740
+ if save_outputs == "hash_only":
741
+ limitations.append("--save-outputs hash_only: clip bytes were not kept, only their sha256 and size")
742
+ near = clashes(order, clipset, wrap=rounds > 1) + (
743
+ [(order[0]["id"], order[0]["id"])] if len(order) == 1 and rounds > 1 else [])
744
+ if near:
745
+ limitations.append(f"{len(near)} pair(s) of neighbouring clips share a prompt or an image (e.g. "
746
+ f"{near[0][0]} -> {near[0][1]}): an engine that caches text or image encodings reuses "
747
+ "them there (ComfyUI does, and reports it: the claim is then invalid); give the clip set "
748
+ "other prompts and images")
749
+ cache_mode = (backend.describe() or {}).get("cache_mode")
750
+ if cache_mode in ("ram", "lru") and rounds > 1: # classic keeps the last prompt only; none keeps nothing
751
+ limitations.append(f"ComfyUI runs with its {cache_mode} cache, which keeps the outputs of earlier prompts: "
752
+ "each clip's prompt repeats in every round, so later rounds can reuse its encoding "
753
+ "(reported, and the claim is then invalid); time ComfyUI launched with --cache-classic")
754
+ missing = [] if any(r["ok"] for r in all_rows) else ["every clip failed"]
755
+
756
+ audio = clipset.uniform("audio")
757
+ reported = {r.get("audio") for r in all_rows if r["ok"]}
758
+ if audio is None and len(reported) == 1: # not asked: take it from the engine when every clip says the same
759
+ only = next(iter(reported))
760
+ audio = only if isinstance(only, bool) else None
761
+ workload = {"mode": "single", "batch": 1, "endpoint_kind": endpoint_kind, "clip_set": clipset.identity(),
762
+ "seed_policy": policy, "seeds": [round_seeds[r] for r in sorted(round_seeds)], "audio": audio,
763
+ "request_set": request_set(clipset, policy, seeds),
764
+ **{k: clipset.uniform(k) for k in SHAPE_KEYS}}
765
+ if gpu_name:
766
+ gpu = {"name": gpu_name, "count": gpu_count}
767
+ elif ep.is_local():
768
+ gpu = {**(gpu_identity() or {}), "count": gpu_count}
769
+ else:
770
+ gpu = {"count": gpu_count}
771
+ gpu_missing = [] if gpu.get("name") else ["gpu identity (remote engine or no nvidia-smi: pass --gpu-name)"]
772
+ ident = ev.identity(gpu=gpu, engine={"name": label, "version": engine_version}, model={"id": model},
773
+ workload=workload)
774
+ a = table["all"]
775
+ cm, cph = a.get("clip_ms") or {}, a.get("clips_per_hour") or {}
776
+ ci = cm.get("ci95_median") or [None, None]
777
+ summary = (f"{label} ({endpoint_kind}, one clip at a time, {len(rounds_table)} round(s) x {len(clipset.clips)} "
778
+ f"clips): {_fmt_s(cm.get('median'))} per clip, mean over the set [observed, client timer]")
779
+ if ci[0] is not None:
780
+ summary += f" (95% CI {_fmt_s(ci[0])}-{_fmt_s(ci[1])})"
781
+ if cph.get("median") is not None:
782
+ summary += f", {cph['median']:.1f} clips/h [derived]"
783
+ if failed:
784
+ summary += f"; {len(failed)} failed"
785
+ if not claim["valid"]:
786
+ summary += f"; CLAIM INVALID ({len(claim['violations'])} violation(s))"
787
+ data = {"source": "bench clip", "label": label, "base_url_kind": "local" if ep.is_local() else "remote",
788
+ "backend": {**backend.describe(), "workflow_sha256": workflow_sha},
789
+ "clip_set": {"name": clipset.name, "sha256": clipset.sha256, "n": len(clipset.clips),
790
+ "images": {k: v[1] for k, v in sorted(clipset.images.items())}},
791
+ "per_task": table, "rounds": rounds_table, "warmup": warm, "claim_validity": claim,
792
+ "order": {"ids": [c["id"] for c in order], "reordered": [c["id"] for c in order] != [
793
+ c["id"] for c in clipset.clips], "rule": "the clip set's order unless neighbours (last and first "
794
+ "included) share a prompt or an i2v image; then the order with the fewest such neighbours"},
795
+ "save_outputs": save_outputs, "extends": prev["evidence_id"] if prev else None,
796
+ "outputs_from": ([prev["evidence_id"]] + list(prev["data"].get("outputs_from") or [])) if prev else [],
797
+ "timer": "client perf_counter_ns",
798
+ "metric_claim_classes": {"clip_ms": "observed (client timer)", "clips_per_hour": "derived",
799
+ "clips_per_hour_per_gpu": "derived", "stage_*_ms": "self-reported by the engine "
800
+ "(not verified; never a published timer)"},
801
+ "definitions": {"clip_ms": "per clip: t_done - t_send on the client (t_send just before the connection "
802
+ "opens, t_done once the whole clip is in the client's hands). Per round: "
803
+ "the mean over the round's clips (data.rounds clip_ms; clip_ms_<task> over "
804
+ "one task's). The record's value: the median over rounds, 95% CI by "
805
+ "bootstrap over rounds; per_clip has the median and max over single clips",
806
+ "clips_per_hour": "3,600,000 x clips / sum of their clip_ms, per round (one clip at a "
807
+ "time; clips_per_hour_<task> per task); the record's value is the "
808
+ "median over rounds",
809
+ "warmup": "per_shape untimed clips for each distinct request shape, all before the "
810
+ "first timed clip, with prompts no timed clip has",
811
+ "stage_sum_ok": "the engine's numeric stage_ms (totals and per-step lists left out) "
812
+ "sum to no more than clip_ms"}}
813
+ if run:
814
+ p = run / "metrics" / f"{label}.clip.json"
815
+ p.parent.mkdir(parents=True, exist_ok=True)
816
+ p.write_text(json.dumps({"label": label, "speed": table, "rounds": rounds_table}, indent=1) + "\n")
817
+ record = ev.build(rcall, kind=KIND, claim_class="observed", summary=summary, identity_=ident, metrics=mets,
818
+ artifacts=artifacts, missing=missing + gpu_missing, limitations=limitations, data=data)
819
+ return store.save(record, staging)