corridorkey-mrp-mlx/docs/2026-07-16-m-series-fleet-ablation-results.md
modelbeast fd00c31b1d docs: add M5 — head_dim padding is worth ~5x on laptops (vs ~2x on Ultras)
M5 MacBook Pro (10-core GPU, 16GB) reproduces the head_dim=56 silent fallback
and shows the fix matters far MORE on narrow GPUs: padding 56->64 gives 4.9x
(1,16,4096), 5.1x (1,16,2048), 3.2x (4096,2,64). Raw fused-vs-unfused at
4096/fp32 is 0.14x on M5 vs ~0.53x on M3 Ultra — an M5 laptop matches an M1
Ultra on fused attention but is ~4x slower unfused. This reframes the fix as a
laptop optimization, not a big-iron one.
2026-07-16 23:54:49 +10:00

173 lines
8.9 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

---
title: "M-series fleet ablation: M3 Ultra / M1 Ultra / M4 Pro"
date: 2026-07-16
device: "M3 Ultra 256GB · M1 Ultra 128GB · M4 Pro mini 24GB · M5 MacBook Pro 16GB (10-core)"
script: scripts/bench_optimizations.py (unmodified) + engine-level tiled×compile extension
---
# M-series Fleet Ablation Benchmarks
Same methodology as the 2026-03-09 wave2 doc (`--sweep ablation --resolution 512 1024 2048`,
checkpoint weights, 3 warmup + 10 bench runs), executed on three machines spanning the
Apple Silicon range. Contributed from a 3-Mac render-farm setup; happy to run follow-ups.
## Headline findings
1. **`sdpa` is a ~4.5× regression on M1-class GPUs at 2048 — and free on M3.** Single-toggle
isolation at 2048 (median of 6, all-off baseline):
| toggle | M1 Ultra | M3 Ultra |
|---|---|---|
| all off | 4139 ms | 1743 ms |
| **bf16 only** | **4120 ms (neutral!)** | 1766 ms |
| **sdpa only** | **18592 ms (4.5× slower)** | 1763 ms |
| bf16+sdpa | 6388 ms | 1767 ms |
The suspected M1 danger (bf16 emulation — M1 lacks hardware bf16) is **innocent** on this
workload.
**Root cause (follow-up, same day):** it is *not* the SDPA kernel. Instrumenting a real
2048 forward shows the model's attention shapes are `head_dim=56` (global blocks
`(1,8,16384,56)` ×15, `(1,16,4096,56)` ×3; windowed `(4096,·,·,56)`). At head_dim 56,
`mx.fast.scaled_dot_product_attention` **silently falls back to the unfused path**
fused == unfused within 1 % on every shape on both M1 and M3 Ultra. So `use_sdpa=True`
currently buys zero kernel benefit on any machine, while its branch pays extra 5-D
transpose/reshape choreography — which is what costs ~4.5× on M1 (layout-sensitive),
~free on M3.
**The actionable win: pad head_dim 56 → 64.** The fast path then engages and beats
unfused decisively — *despite ~14 % more FLOPs* (fp32, exact model shapes):
| shape (hdim 64) | M3 fused/unfused | M1 fused/unfused |
|---|---|---|
| (1, 8, 16384, ·) | 28.6 / 63.1 ms → **2.2×** | 53.8 / 144.5 ms → **2.7×** |
| (1, 16, 4096, ·) | 3.8 / 7.2 ms → 1.9× | 7.5 / 11.1 ms → 1.5× |
| (4096, 2, 64, ·) | 1.3 / 2.5 ms → 1.9× | 1.5 / 2.6 ms → 1.7× |
**…and on M5 it's worth ~5×, i.e. this fix matters MOST for laptop users.** Same
experiment on an M5 MacBook Pro (10-core GPU, 16 GB) — hdim 56 vs 64, fp32:
| shape | hdim 56 (today) | hdim 64 (padded) | **speedup from padding** |
|---|---|---|---|
| (1, 16, 4096) | 44.00 ms *(fallback: 1.05× vs unfused)* | **8.91 ms** *(fast path: 0.21×)* | **4.9×** |
| (1, 16, 2048) | 11.44 ms *(1.06×)* | **2.23 ms** *(0.22×)* | **5.1×** |
| (4096, 2, 64) | 16.72 ms *(0.97×)* | **5.29 ms** *(0.25×)* | **3.2×** |
Why bigger on the laptop: the fused kernel's advantage over unfused scales inversely
with GPU width. At seq 4096 fp32 (hdim 64) fused-vs-unfused is **7.1× on M5** vs
~1.9× on M3 Ultra — a 10-core GPU cannot brute-force the materialized N×N path the
way an 80-core Ultra can. So the head_dim-56 fallback costs Studio owners ~2× and
**MacBook owners ~5×**. Padding is not a big-iron optimization; it's a laptop one.
Suggested changes: (a) pad qkv projections to head_dim 64 (at minimum for the global
blocks) so sdpa's fast kernel actually engages — M1 benefits *more* than M3; (b) until
then, default `use_sdpa=False` (it is currently overhead-only). For MLX upstream: a
warning (or doc note) when sdpa silently falls back on unsupported head dims would have
made this obvious much sooner.
2. **`stage_gc` is harmful on Ultra-class machines at every resolution** — 0.53× at 512,
0.64× at 1024, 0.66× at 2048 on M3 Ultra, for a 15 % peak-memory saving. Wave2 measured
a mild 0.790.99× on its reference hardware; on big-memory machines it's pure overhead.
Suggestion: document as a low-memory-only flag.
3. **Tiled + compile is the best 2048 config on both Ultras** — upstream `engine.py` forced
`compile=False` in tiled mode; tiles are fixed-shape, so fused compilation applies
(patched in this fork, output bit-identical, verified against ground-truth alpha):
| engine config (2048 input) | M3 Ultra | M1 Ultra | peak | alpha MAE* |
|---|---|---|---|---|
| full-frame 2048 | 2788 ms | 5373 ms | 27.9 GB | 0.00906 |
| tiled 512/64 | 2760 ms | 4576 ms | 2.2 GB | **0.00821** |
| tiled 512/64 + compile | 2478 ms | 4222 ms | 2.3 GB | **0.00821** |
| **tiled 768/64 + compile** | **1949 ms** | **3275 ms** | 2.4 GB | 0.00842 |
| tiled 1024/64 + compile | 3475 ms | 5864 ms | 3.7 GB | 0.00915 |
\*alpha MAE vs exact ground truth: synthetic 2048² green-screen plates (soft-alpha
subject + motion-blur stripes + defocus disk composited over chroma green), hint =
8× downscaled truth. Tiled beats full-frame on *accuracy* as well as memory — the model
runs at native tile scale over full-res input. Confirms wave2's tiled-768 pick and adds
~813 % from compiling the tile graph.
4. **24 GB Macs must tile at 2048.** On the M4 Pro mini (24 GB), every full-frame 2048
config lands at 2024 s/run — the ~26 GB working set swaps; toggle choice becomes noise.
At ≤1024 the M4 is healthy (218 ms @512, 1149 ms @1024). Tiled 2048 runs in ~2.3 GB.
## Cross-machine baselines (all-off)
| res | M3 Ultra | M1 Ultra | M4 Pro 24GB | wave2 reference |
|---|---|---|---|---|
| 512 | 53.3 ms | 80.8 ms | 218.1 ms | 119.6 ms |
| 1024 | 247.0 ms | 350.6 ms | 1149.2 ms | 610.7 ms |
| 2048 | 1750.2 ms | 3527.3 ms | 23147 ms (swap) | 4984.7 ms |
### Raw SDPA kernel scaling (pure MLX, no CorridorKey) — fused vs unfused, hdim 64, fp32
Useful context for *why* the head-dim fix matters differently per machine. Fused-vs-unfused
ratio (lower = fused is winning by more):
| seq | M3 Ultra (80c) | M1 Ultra (64c) | M5 (10c laptop) |
|---|---|---|---|
| 1024 | 0.46× | 0.54× | 0.28× |
| 2048 | 0.53× | 0.71× | **0.21×** |
| 4096 | 0.53× | 0.59× | **0.14×** |
Absolute fused times at 4096/fp32: M3 3.86 ms · M1 7.19 ms · **M5 7.22 ms** — an M5
laptop matches an M1 Ultra on *fused* attention while being ~4× slower unfused (51.5 ms
vs 12.2 ms). The narrower the GPU, the more the fast path is worth. (M5 numbers taken on
a machine in active desktop use; treat as indicative, not lab-clean.)
## Full ablation tables
### M3 Ultra 256GB
| Config | 512 | 1024 | 2048 | peak @2048 |
|---|---|---|---|---|
| baseline | 53.3 | 247.0 | 1750.2 | 26689 MB |
| slim+sdpa+bf16+fused_decode+gpu_preprocess | 53.5 | 248.2 | 1790.1 | 27245 MB |
| slim+stage_gc+bf16+fused_decode+gpu_preprocess | 95.4 | 382.7 | 2644.3 | 26689 MB |
| slim+stage_gc+sdpa+fused_decode+gpu_preprocess | 96.3 | 385.9 | 2676.9 | 26661 MB |
| slim+stage_gc+sdpa+bf16+gpu_preprocess | 99.4 | 372.1 | 2618.6 | 26661 MB |
| slim+stage_gc+sdpa+bf16+fused_decode | 99.0 | 386.5 | 2688.0 | 26661 MB |
| stage_gc+sdpa+bf16+fused_decode+gpu_preprocess | 100.1 | 391.4 | 2628.2 | 26661 MB |
| slim+stage_gc+sdpa+bf16+fused_decode+gpu_preprocess | 101.0 | 390.9 | 2617.9 | 26661 MB |
### M1 Ultra 128GB
| Config | 512 | 1024 | 2048 | peak @2048 |
|---|---|---|---|---|
| baseline | 80.8 | 350.6 | 3527.3 | 26689 MB |
| slim+sdpa+bf16+fused_decode+gpu_preprocess | 79.1 | 353.3 | **33137.8** | 27245 MB |
| slim+stage_gc+bf16+fused_decode+gpu_preprocess | 189.5 | 705.5 | 4919.0 | 26689 MB |
| slim+stage_gc+sdpa+fused_decode+gpu_preprocess | 195.6 | 711.1 | 4939.2 | 26661 MB |
| slim+stage_gc+sdpa+bf16+gpu_preprocess | 198.2 | 681.6 | 4853.8 | 26661 MB |
| slim+stage_gc+sdpa+bf16+fused_decode | 197.7 | 714.1 | 4822.9 | 26661 MB |
| stage_gc+sdpa+bf16+fused_decode+gpu_preprocess | 194.2 | 733.2 | 6274.1 | 26661 MB |
| slim+stage_gc+sdpa+bf16+fused_decode+gpu_preprocess | 193.8 | 737.1 | 5128.2 | 26661 MB |
Note the 33.1 s outlier: the only 2048 config *without* `stage_gc` but *with* `sdpa`
sdpa's slow path dominating once nothing throttles it (see headline 1 for the isolation).
The sdpa cliff appears only at 2048; 512/1024 are unaffected.
### M4 Pro Mac mini 24GB
| Config | 512 | 1024 | 2048 (swap-bound) |
|---|---|---|---|
| baseline | 218.1 | 1149.2 | 23147.1 |
| slim+sdpa+bf16+fused_decode+gpu_preprocess | 221.4 | 1139.0 | 20994.5 |
| slim+stage_gc+… (all stage_gc combos) | 266273 | 12681292 | 2023324253 |
At 2048 the ~26 GB working set exceeds 24 GB unified memory; all configs swap and
differences are not meaningful. ≤1024 full-frame or tiled-anything is the usable envelope.
## Recommended settings by hardware
| Hardware | 2048 recommendation |
|---|---|
| M3-class (Max/Ultra) | full-frame or tiled 768/64 + compile; every toggle optional; avoid `stage_gc` |
| M1/M2-class | **avoid `sdpa`**; tiled 768/64 + compile; avoid `stage_gc` |
| ≤2432 GB any gen | **tiled required** (2.3 GB vs 26 GB); tiled 768/64 + compile |
*Environment: MLX (venv per `uv sync --extra mlx`), macOS 26.5, checkpoint v1.0.0. Quality
harness (ground-truth plates + scoring) available on request — it's ~150 lines and
reproduces the alpha-MAE column.*