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.
173 lines
8.9 KiB
Markdown
173 lines
8.9 KiB
Markdown
---
|
||
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 1–5 % peak-memory saving. Wave2 measured
|
||
a mild 0.79–0.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
|
||
~8–13 % 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 20–24 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) | 266–273 | 1268–1292 | 20233–24253 |
|
||
|
||
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` |
|
||
| ≤24–32 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.*
|