- Cache nn.Upsample instances in DecoderHead/GreenFormer __init__ (eliminated ~7 allocations per forward pass) - Add mx.compile() support via load_model(compile=True) - Benchmark harness: eager vs compiled, multi-resolution, parity checks - Tiled inference with overlap blending for large images - Profiling utilities with forced mx.eval for accurate timing - Reference comparison script (scripts/compare_reference.py) - 12 new tests (compiled consistency + tiling) - README performance section with Apple Silicon guidance Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
196 lines
6.6 KiB
Python
196 lines
6.6 KiB
Python
#!/usr/bin/env python3
|
|
"""Benchmark MLX inference on Apple Silicon.
|
|
|
|
Reports eager vs compiled latency, warmup cost, and steady-state performance
|
|
across multiple resolutions.
|
|
|
|
Usage:
|
|
uv run python scripts/bench_mlx.py
|
|
uv run python scripts/bench_mlx.py --checkpoint checkpoints/corridorkey_mlx.safetensors
|
|
uv run python scripts/bench_mlx.py --resolutions 256 512 1024 --bench-runs 20
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import sys
|
|
from pathlib import Path
|
|
|
|
import mlx.core as mx
|
|
from rich.console import Console
|
|
from rich.table import Table
|
|
|
|
# Ensure package is importable when running from repo root
|
|
sys.path.insert(0, str(Path(__file__).resolve().parent.parent / "src"))
|
|
|
|
from corridorkey_mlx.model.corridorkey import GreenFormer
|
|
from corridorkey_mlx.utils.profiling import warmup_and_bench
|
|
|
|
console = Console()
|
|
|
|
DEFAULT_RESOLUTIONS = [256, 512, 1024]
|
|
DEFAULT_WARMUP_RUNS = 3
|
|
DEFAULT_BENCH_RUNS = 10
|
|
|
|
|
|
def make_dummy_input(img_size: int, batch_size: int = 1) -> mx.array:
|
|
"""Create random input tensor matching model expectations."""
|
|
mx.random.seed(42)
|
|
return mx.random.normal((batch_size, img_size, img_size, 4))
|
|
|
|
|
|
def load_model_weights(model: GreenFormer, checkpoint: Path | None) -> None:
|
|
"""Load real weights if checkpoint exists, otherwise use random init."""
|
|
if checkpoint and checkpoint.exists():
|
|
model.load_checkpoint(checkpoint)
|
|
else:
|
|
model.eval()
|
|
# NOTE: mx.eval here is MLX array materialization, not Python eval()
|
|
mx.eval(model.parameters()) # noqa: S307
|
|
|
|
|
|
def bench_resolution(
|
|
img_size: int,
|
|
checkpoint: Path | None,
|
|
warmup_runs: int,
|
|
bench_runs: int,
|
|
batch_size: int,
|
|
) -> dict[str, object]:
|
|
"""Benchmark eager and compiled inference at a given resolution."""
|
|
x = make_dummy_input(img_size, batch_size)
|
|
mx.eval(x) # noqa: S307 — materialize input
|
|
|
|
results: dict[str, object] = {"resolution": img_size, "batch_size": batch_size}
|
|
|
|
# --- Eager ---
|
|
model_eager = GreenFormer(img_size=img_size)
|
|
load_model_weights(model_eager, checkpoint)
|
|
|
|
def run_eager() -> dict[str, mx.array]:
|
|
return model_eager(x)
|
|
|
|
try:
|
|
eager_warmup, eager_steady, eager_times = warmup_and_bench(
|
|
run_eager,
|
|
warmup_runs=warmup_runs,
|
|
bench_runs=bench_runs,
|
|
label="eager",
|
|
)
|
|
results["eager_warmup_ms"] = round(eager_warmup.elapsed_ms, 1)
|
|
results["eager_steady_ms"] = round(eager_steady.elapsed_ms, 1)
|
|
results["eager_min_ms"] = round(min(eager_times), 1)
|
|
except Exception as e:
|
|
console.print(f" [red]Eager failed at {img_size}: {e}[/red]")
|
|
results["eager_warmup_ms"] = "FAIL"
|
|
results["eager_steady_ms"] = "FAIL"
|
|
results["eager_min_ms"] = "FAIL"
|
|
|
|
# --- Compiled (fixed-shape) ---
|
|
model_compiled = GreenFormer(img_size=img_size)
|
|
load_model_weights(model_compiled, checkpoint)
|
|
compiled_call = mx.compile(model_compiled.__call__)
|
|
|
|
def run_compiled() -> dict[str, mx.array]:
|
|
return compiled_call(x)
|
|
|
|
try:
|
|
comp_warmup, comp_steady, comp_times = warmup_and_bench(
|
|
run_compiled,
|
|
warmup_runs=warmup_runs,
|
|
bench_runs=bench_runs,
|
|
label="compiled",
|
|
)
|
|
results["compiled_warmup_ms"] = round(comp_warmup.elapsed_ms, 1)
|
|
results["compiled_steady_ms"] = round(comp_steady.elapsed_ms, 1)
|
|
results["compiled_min_ms"] = round(min(comp_times), 1)
|
|
except Exception as e:
|
|
console.print(f" [red]Compiled failed at {img_size}: {e}[/red]")
|
|
results["compiled_warmup_ms"] = "FAIL"
|
|
results["compiled_steady_ms"] = "FAIL"
|
|
results["compiled_min_ms"] = "FAIL"
|
|
|
|
# --- Parity check ---
|
|
try:
|
|
eager_out = run_eager()
|
|
compiled_out = run_compiled()
|
|
mx.eval(eager_out, compiled_out) # noqa: S307 — materialize for comparison
|
|
max_diff = max(
|
|
float(mx.max(mx.abs(eager_out[k] - compiled_out[k])))
|
|
for k in ("alpha_final", "fg_final")
|
|
)
|
|
results["parity_max_diff"] = f"{max_diff:.2e}"
|
|
except Exception:
|
|
results["parity_max_diff"] = "N/A"
|
|
|
|
return results
|
|
|
|
|
|
def main() -> None:
|
|
parser = argparse.ArgumentParser(description="Benchmark MLX CorridorKey inference")
|
|
parser.add_argument(
|
|
"--checkpoint",
|
|
type=Path,
|
|
default=Path("checkpoints/corridorkey_mlx.safetensors"),
|
|
help="MLX safetensors checkpoint (uses random weights if missing)",
|
|
)
|
|
parser.add_argument(
|
|
"--resolutions",
|
|
type=int,
|
|
nargs="+",
|
|
default=DEFAULT_RESOLUTIONS,
|
|
help="Resolutions to benchmark",
|
|
)
|
|
parser.add_argument("--warmup-runs", type=int, default=DEFAULT_WARMUP_RUNS)
|
|
parser.add_argument("--bench-runs", type=int, default=DEFAULT_BENCH_RUNS)
|
|
parser.add_argument("--batch-size", type=int, default=1)
|
|
args = parser.parse_args()
|
|
|
|
ckpt = args.checkpoint if args.checkpoint.exists() else None
|
|
weights_label = str(args.checkpoint) if ckpt else "random (no checkpoint)"
|
|
console.print("[bold]CorridorKey MLX Benchmark[/bold]")
|
|
console.print(f" Weights: {weights_label}")
|
|
console.print(f" Resolutions: {args.resolutions}")
|
|
console.print(
|
|
f" Warmup: {args.warmup_runs}, Bench: {args.bench_runs}, Batch: {args.batch_size}"
|
|
)
|
|
console.print()
|
|
|
|
all_results = []
|
|
for res in args.resolutions:
|
|
console.print(f" Benchmarking {res}x{res}...")
|
|
result = bench_resolution(res, ckpt, args.warmup_runs, args.bench_runs, args.batch_size)
|
|
all_results.append(result)
|
|
|
|
# Print results table
|
|
table = Table(title="Benchmark Results")
|
|
table.add_column("Resolution", justify="right")
|
|
table.add_column("Eager Warmup", justify="right")
|
|
table.add_column("Eager Steady", justify="right")
|
|
table.add_column("Compiled Warmup", justify="right")
|
|
table.add_column("Compiled Steady", justify="right")
|
|
table.add_column("Speedup", justify="right")
|
|
table.add_column("Parity", justify="right")
|
|
|
|
for r in all_results:
|
|
speedup = ""
|
|
eager_s = r.get("eager_steady_ms")
|
|
comp_s = r.get("compiled_steady_ms")
|
|
if isinstance(eager_s, (int, float)) and isinstance(comp_s, (int, float)) and comp_s > 0:
|
|
speedup = f"{eager_s / comp_s:.2f}x"
|
|
|
|
table.add_row(
|
|
f"{r['resolution']}x{r['resolution']}",
|
|
str(r.get("eager_warmup_ms", "")),
|
|
str(r.get("eager_steady_ms", "")),
|
|
str(r.get("compiled_warmup_ms", "")),
|
|
str(r.get("compiled_steady_ms", "")),
|
|
speedup,
|
|
str(r.get("parity_max_diff", "")),
|
|
)
|
|
|
|
console.print(table)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|