Files
CthomasdesignandClaude Fable 5 59deb1e3e5 tools: benchmark + output-parity harness for engine optimization work
bench.py attributes generation time to encode / per-step / decode stages
via the pipeline's own step_callback; parity.py snapshots decoded float32
pixels for a fixed prompt/seed/size (+LoRA) matrix and asserts later runs
stay uint8-identical — the quality gate for every optimization commit.
Reference outputs and results land in tools/reference/ (gitignored).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-07 21:07:07 -04:00

104 lines
3.8 KiB
Python

"""Stage-attributed generation benchmark for the krea2_engine pipeline.
Times model load, text encode, each denoise step, and VAE decode, then prints a table and
writes JSON (tools/reference/bench_<label>.json) for before/after comparison.
python tools/bench.py --model /path/to/transformer_mixed_4_8.safetensors --label baseline
python tools/bench.py --model ... --label opt1 --quick # 512² short prompt only
Per-step timing uses the pipeline's own step_callback (fires after each step's mx.eval), so it
measures exactly what ComfyUI sees. The first timed configuration run is reported separately as
warmup (Metal kernel compilation).
"""
import argparse
import json
import os
import time
import _shared # noqa: F401 (sys.path bootstrap)
import mlx.core as mx
from _shared import PROMPT_LONG, PROMPT_SHORT, load_pipeline
from krea2_engine.sampling import sample
class _TimedEncode:
"""Wrap the conditioner so encode time is attributable (forces eval of its outputs)."""
def __init__(self, encoder):
self.encoder = encoder
self.seconds = None
def __call__(self, prompts):
t0 = time.perf_counter()
ctx, mask = self.encoder(prompts)
mx.eval(ctx, mask)
self.seconds = time.perf_counter() - t0
return ctx, mask
def run_case(pipe, prompt, size, steps=8, seed=0):
enc = _TimedEncode(pipe.encoder)
marks = []
def cb(step, total):
marks.append(time.perf_counter())
t0 = time.perf_counter()
decoded = sample(pipe.transformer, pipe.vae, enc, [prompt],
width=size, height=size, steps=steps, guidance=0.0, seed=seed,
step_callback=cb)
mx.eval(decoded)
total = time.perf_counter() - t0
# attribute: encode | step1 (includes any per-run setup) | steps 2..N | decode+overhead
step_walls = [marks[0] - t0 - enc.seconds] + [b - a for a, b in zip(marks, marks[1:])]
rest = sorted(step_walls[1:])
return {
"encode_s": round(enc.seconds, 3),
"step1_s": round(step_walls[0], 3),
"step_median_s": round(rest[len(rest) // 2], 3) if rest else None,
"steps_total_s": round(sum(step_walls), 3),
"decode_s": round(total - enc.seconds - sum(step_walls), 3),
"total_s": round(total, 3),
}
def main():
ap = argparse.ArgumentParser()
ap.add_argument("--model", required=True)
ap.add_argument("--label", required=True, help="name for the JSON result file")
ap.add_argument("--quick", action="store_true", help="512² short prompt only")
ap.add_argument("--steps", type=int, default=8)
args = ap.parse_args()
configs = [("short", PROMPT_SHORT, 512)]
if not args.quick:
configs += [("long", PROMPT_LONG, 512), ("short", PROMPT_SHORT, 1024)]
pipe, load_s = load_pipeline(args.model)
print(f"model load: {load_s:.1f}s")
results = {"label": args.label, "model": os.path.basename(args.model),
"steps": args.steps, "load_s": round(load_s, 1), "runs": []}
for i, (name, prompt, size) in enumerate(configs):
for phase in (["warmup", "timed"] if i == 0 else ["timed"]):
r = run_case(pipe, prompt, size, steps=args.steps)
r.update(config=f"{name}-{size}", phase=phase)
results["runs"].append(r)
print(f"{r['config']:>11} {phase:>6}: encode {r['encode_s']:6.2f}s | "
f"step1 {r['step1_s']:6.2f}s | step~ {r['step_median_s']:6.2f}s | "
f"decode {r['decode_s']:6.2f}s | total {r['total_s']:7.2f}s")
out_dir = os.path.join(os.path.dirname(os.path.abspath(__file__)), "reference")
os.makedirs(out_dir, exist_ok=True)
out = os.path.join(out_dir, f"bench_{args.label}.json")
with open(out, "w") as f:
json.dump(results, f, indent=2)
print(f"wrote {out}")
if __name__ == "__main__":
main()