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,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)
|