Skip to content

Metal SDPA: no fused path for head_dim=512 (vector or full kernel), and the fallback is silent #3885

Closed

Description

@apocryphx

Summary

mx.fast.scaled_dot_product_attention has no fused path for head_dim = 512 — neither the vector (decode) kernel nor the full (prefill) kernel accepts it — and the fallback to the composite eager path is silent, with no way to query or log it. Models with d=512 attention (e.g. Gemma 4's global layers) run every such attention op, at every sequence length, on the unfused path with materialized scores.

This complements #3658 / #3293, which cover extending the full kernel to d=192/256. This issue is about (a) d=512 having no coverage anywhere, including the vector kernel where decode lives, and (b) fallback discoverability.

Current routing (use_fallback, scaled_dot_product_attention.cpp#L621-L633)

Gemma 4 31B (dense) has a 5:1 hybrid layout: 50 sliding layers (n_q=32, n_kv=16, d=256) and 10 global layers (n_q=32, n_kv=4, d=512, 25% partial RoPE). The sliding layers get the vector kernel at decode; the global layers never fuse, and at prefill no layer of this model fuses at all.

Measured impact (M4 Max 128 GB, bf16, Gemma-4-31B QAT Q4; MLX pin 51b2768, routing re-verified against 0c537a4)

Decode (qL=1), isolated mx.fast.scaled_dot_product_attention calls, additive mask:

shape seqK=1024 seqK=4096
d=512, 32 q / 4 kv heads (fallback) 168 µs 260 µs
d=256, 32 q / 8 kv heads (same KV bytes, vector kernel) 146 µs 184 µs

≈ +15% @1k and +41% @4k per call for the same bytes, plus ~8 extra dispatches per call. Small per token (10 layers ≈ +0.8 ms at 4K), but it is pure routing loss.

Prefill is where it bites: the fallback materializes [B, n_kv, n_rep, qL, kL] scores. At L=4096 a single d=512 global layer runs 84 ms and materializes ~1 GB of transient scores; at the context lengths in #3658 this is the difference between running and OOM. Chunking bounds the transient but not the arithmetic (see #3658's analysis — same conclusion from our side).

Requests

  1. Vector-kernel d=512 (decode is the common case for these layers). If register pressure at d=512 is the blocker, a split-D variant (as the existing (192,128) combo suggests is feasible) would cover it.
  2. Full-kernel d≥192/256: already asked in Allow force using fused kernel in scaled_dot_product_attention #3658 / fix: add head_dim=256 to fused SDPA full attention kernel #3293 — just noting d=512 exists in the wild too (Gemma 4 global layers), so parameterizing rather than enumerating dims may be worth it.
  3. Fallback discoverability: today the only ways to notice the composite path are a Metal System Trace or reading use_fallback. Either of these would have saved us days:
    • an env var (e.g. MLX_FAST_LOG_FALLBACK=1) that logs once per distinct shape when a mx.fast.* op takes its fallback, and/or
    • a queryable predicate (mx.fast.sdpa_is_fused(q_shape, k_shape, ...) or a documented support matrix in the docs).

Context: found while building an MLX-based Gemma-4 inference engine and chasing why prefill attention time didn't match flash expectations; happy to provide traces or run candidate kernels on M4 Max.

Activity

  1. hxu296 commented on Jul 21, 2026

    @hxu296
    Contributor
    shape seqK=1024 seqK=4096
    d=512, 32 q / 4 kv heads (fallback) 168 µs 260 µs
    d=256, 32 q / 8 kv heads (same KV bytes, vector kernel) 146 µs 184 µs

    This is comparing d=512 fallback vs. d=256 fused? Maybe I'm not following this, but is this comparison apple-to-apple?

  2. apocryphx commented on Jul 22, 2026

    @apocryphx
    ContributorAuthor

    In plain terms: Gemma 4's global layers use an attention head size (d=512) that MLX's fast attention kernels don't support, so every such call silently falls back to a slower generic path. There is no fast d=512 path to compare against directly, so the table instead compares against a supported shape that reads exactly the same number of KV-cache bytes — and at decode time, attention speed is set almost entirely by how many bytes you read. The gap between the two rows therefore measures the cost of the missing kernel, not a difference in the work being done.

    So to your question, @hxu296: correct — it isn't the same shape, and it can't be, because no fused d=512 path exists; that absence is this issue. The second row is a control normalized on bytes, not geometry.

    The reasoning: decode attention (qL=1) at these sizes is KV-bandwidth-bound. The two rows move identical KV traffic — d·n_kv = 512·4 = 256·8, so K+V reads are byte-for-byte the same (33.5 MB at seqK=4096, bf16); Q/O differ but at qL=1 they're KB-scale against MB-scale KV. Arithmetic intensity differs (the d=512 row does 4 MACs per KV element vs 2), but both sit far below the M4 Max roofline balance point (~56 FLOP/byte at bf16), so the extra MACs ride along with the same bytes rather than adding wall time. Under that normalization, the same bytes arriving 15–41% later is routing loss, not workload difference.

    To check it isn't pin-specific, I re-ran this morning against the current wheel (mlx==0.29.3, routing identical for these shapes: vector = {64, 96, 128, 256}, full = {64, 80, 128} — scaled_dot_product_attention.cpp#L395-L400), one fresh process per row, Python-side timing (absolutes carry per-call dispatch overhead; the ratios are the point):

    shape (decode qL=1, bf16, additive mask) seqK=1024 seqK=4096
    d=512, 32 q / 4 kv (fallback) 234 µs 303 µs
    d=256, 32 q / 8 kv (vector kernel, same KV bytes) 196 µs 229 µs

    +19% @1k, +32% @4k — same pattern. Bench script below if you want to reproduce.

    bench (fresh process per row)
    # sdpa_row.py <d> <nq> <nkv> <seqK>
    import sys, time
    import mlx.core as mx
    d, nq, nkv, L = (int(a) for a in sys.argv[1:5])
    mx.random.seed(7)
    q = mx.random.normal((1, nq, 1, d)).astype(mx.bfloat16)
    k = mx.random.normal((1, nkv, L, d)).astype(mx.bfloat16)
    v = mx.random.normal((1, nkv, L, d)).astype(mx.bfloat16)
    mask = mx.zeros((1, 1, 1, L)).astype(mx.bfloat16)
    f = lambda: mx.fast.scaled_dot_product_attention(q, k, v, scale=d**-0.5, mask=mask)
    for _ in range(10): mx.eval(f())
    mx.synchronize()
    t0 = time.perf_counter()
    for _ in range(200): mx.eval(f())
    mx.synchronize()
    print(f"{(time.perf_counter()-t0)/200*1e6:.0f} us")
  3. hxu296 commented on Jul 22, 2026

    @hxu296
    Contributor

    Ah yes, the kv bytes are the same given that you doubled the kv heads for the d=256 config. But it'd be nice to add a template instantiation for the fused kernel in a custom build and compare d=512 fused vs. unfused (you could route on an env var to test the two variants in the same build), so we could see whether fused will be beneficial for this machine. My worry is that d=512 fused would have too much register pressure to be beneficial, but it requires some testing to know

  4. apocryphx commented on Jul 23, 2026

    @apocryphx
    ContributorAuthor

    Ran your experiment on the M4 Max — naive (512, 512) instantiation plus env-gated routing, so one build serves both paths for the same shape:

    --- a/mlx/backend/metal/kernels/scaled_dot_product_attention.metal
       instantiate_sdpa_vector(type, 256, 256)        \
    +  instantiate_sdpa_vector(type, 512, 512)        \
       ...
       instantiate_sdpa_vector_aggregation(type, 256) \
    +  instantiate_sdpa_vector_aggregation(type, 512)
    --- a/mlx/backend/metal/scaled_dot_product_attention.cpp
           query_head_dim == 64 || query_head_dim == 96 || query_head_dim == 128 ||
    -      query_head_dim == 256);
    +      query_head_dim == 256 ||
    +      (query_head_dim == 512 && env::get_var("MLX_SDPA_VECTOR_512", 0) == 1));

    Same shape (d=512, 32q/4kv, qL=1, bf16, additive mask), same static lib, min of 5 fresh processes × 500 evals each (C++ harness), M4 Max 128 GB:

    seqK fallback d512 vector d256 32q/8kv vector (equal KV bytes, reference)
    1024 168 µs 156 µs 138 µs
    2048 197 µs 178 µs 154 µs
    4096 243 µs 242 µs 177 µs
    8192 395 µs 370 µs 305 µs
    16384 733 µs 638 µs 537 µs

    Observations:

    • It compiles and runs — 2-pass path, threadgroup (32, gqa=8) = 256 threads, no pipeline-size failure. Outputs match the fallback to bf16 rounding.
    • Your register-pressure worry is half-right, quantified: per KV byte the d=512 kernel runs ~13–19% behind the d=256 kernel at every length (qk_per_thread 16 vs 8). Pressure costs, but doesn't erase the win.
    • The win is 6–13% except a tie at 4096 — and the tie is the fallback's doing, not the kernel's: the vector kernel scales linearly at ~31 ns/position across the whole 1K–16K range, while the composite is nonlinear (efficient mid-range, degrading ≥8K).
    • A split-D variant (two simdgroups per head, back to 8 elems/thread) is the natural way to close the remaining gap to the d=256 line; the existing (192, 128) combo suggests the machinery tolerates that kind of specialization.

    Honest framing on magnitude: for Gemma-4-31B decode (10 global layers) this saves ~0.1–1 ms/token depending on context — real but modest; the larger costs in this issue remain the prefill full-kernel gap and fallback discoverability. But it establishes that d=512 vector coverage is trivial to add, numerically correct, and never worse than the fallback. Happy to turn it into a PR (with or without the env gate) if that's useful.

  5. hxu296 commented on Jul 23, 2026

    @hxu296
    Contributor

    This looks encouraging. I think this has the potential to turn into a PR if a wider sweep demonstrates no regression vs. unfused. The ideal case is for d=512 fused to beat d=512 unfused on nearly all shape classes, so the kernel selection logic can stay simple.

    If the kernel selection logic is very complex, then we need to trade off perf vs. more complex gating. The MLX_SDPA_VECTOR_512 should stay as a test-time knob and be removed if we turn this into a PR.

    Some axes of the sweep I can think of:

    1. attention types, both MHA and GQA
    2. number of query heads
    3. number of kv heads (for GQA, for MHA axis 2 == axis 3)
    4. kv seq length

    You already demonstrated fused is better for a sweep across axis 4 when fixing other axes. It'd be great if you could sweep over all axes for the full landscape

  6. apocryphx commented on Jul 24, 2026

    @apocryphx
    ContributorAuthor

    Ran the full sweep — twice, as it turned out: once with background load on the machine, once quiet. The aggregate runtime was identical (ratio 0.994) and the win/loss regions came out the same in both runs, so the landscape below (quiet run) is robust, not a scheduling artifact. Methodology as before: qL=1 decode, bf16, B=1, min of 3 fresh processes per cell, 500/300/100 evals by depth, M4 Max 128 GB. Speedup = fallback/fused; bold ≥ 1.05×, italic < 0.95×:

    config 256 512 1024 4096 16384
    8q/8kv MHA 1.03× 1.03× 1.09× 0.93× 0.99×
    8q/4kv g2 1.12× 1.16× 1.06× 1.13× 1.19×
    8q/2kv g4 1.06× 1.04× 1.12× 1.11× 1.00×
    8q/1kv g8 1.30× 1.21× 1.33× 1.17× 1.01×
    16q/16kv MHA 1.10× 1.06× 1.02× 0.92× 0.95×
    16q/8kv g2 1.21× 1.09× 1.01× 0.96× 0.98×
    16q/4kv g4 1.10× 1.07× 0.99× 1.19× 1.04×
    16q/2kv g8 1.10× 1.06× 1.02× 0.98× 1.29×
    16q/1kv g16 1.23× 1.16× 1.15× 0.89× 0.67×
    32q/32kv MHA 1.07× 1.08× 0.88× 0.90× 0.98×
    32q/16kv g2 1.12× 1.08× 1.06× 0.94× 0.94×
    32q/8kv g4 1.12× 1.06× 1.07× 0.95× 0.97×
    32q/4kv g8 1.12× 1.07× 1.06× 0.99× 1.16×
    32q/2kv g16 1.12× 1.16× 1.04× 1.02× 1.31×
    32q/1kv g32 1.23× 1.09× 1.03× 0.85× 0.62×
    64q/64kv MHA 1.02× 0.91× 0.96× 0.95× 0.98×
    64q/32kv g2 1.02× 1.03× 0.86× 0.91× 0.92×
    64q/16kv g4 1.04× 1.04× 1.01× 1.00× 0.91×
    64q/8kv g8 1.06× 1.00× 1.07× 0.97× 1.09×
    64q/4kv g16 1.04× 1.04× 1.09× 1.05× 1.18×
    64q/2kv g32 1.04× 1.02× 0.99× 1.02× 1.17×

    105 cells: 50 wins (≥1.05×), 39 ties, 16 losses (<0.95×), zero failures — including the small-N cells that exercise the 1-pass kernel at 1024-thread groups, so no register cliff anywhere.

    The losses aren't scattered; they're two mechanisms:

    1. MQA (nkv=1) at depth — worst cells 0.62×/0.67× at 32q/1kv and 16q/1kv @16k. Pass-1 parallelism is nkv × blocks threadgroups, so one KV head starves the grid while carrying d=512's register load. Kernel-side fixable later (scale blocks with 1/nkv).
    2. MHA / g2 at L ≥ 1024 — 0.86–0.95×. With ≤2 queries per KV head there's little per-byte reuse, and the composite's batched GEMM is at its best.

    Which makes the selection logic land nicely for your "no regression, stay simple" bar:

    • nkv >= 2 && gqa >= 8 → zero regressions. Worst covered cell 0.97×; everything else ties or wins up to 1.31×. One condition, and it covers the d=512 models that actually exist (Gemma 4 global layers: 32q/4kv, g8). Everything outside keeps today's fallback, so nothing changes for other shapes.
    • nkv >= 2 && gqa >= 4 widens coverage (g4 wins up to 1.19×) at the cost of one 0.91× cell (64q/16kv @16k).
    • Unconditional routing: worst case 0.62× on deep MQA — a shape class with no d=512 occupant I'm aware of, but it's a real regression to carry.

    I'd PR the first gate (env knob removed, condition folded into sdpa_vector_supported_head_dim), with the MQA blocks scaling as a possible follow-up that could widen it later. Raw µs in the fold.

    raw µs (fallback/fused per cell, quiet run)
    config 256 512 1024 4096 16384
    8q/8kv MHA 130/126 143/139 161/148 260/280 720/729
    8q/4kv g2 145/129 156/134 160/151 216/191 529/443
    8q/2kv g4 141/133 140/134 154/137 199/180 453/451
    8q/1kv g8 162/125 159/131 180/135 209/178 276/274
    16q/16kv MHA 138/126 147/139 169/166 394/429 1218/1278
    16q/8kv g2 142/117 147/135 154/153 272/283 742/758
    16q/4kv g4 141/128 143/134 156/158 243/204 592/569
    16q/2kv g8 136/124 149/141 151/148 207/212 503/391
    16q/1kv g16 152/124 154/133 175/152 183/206 271/406
    32q/32kv MHA 145/135 168/156 252/288 679/752 2308/2345
    32q/16kv g2 143/128 151/140 184/173 376/401 1267/1347
    32q/8kv g4 141/126 146/138 168/157 305/321 853/881
    32q/4kv g8 145/129 160/150 166/156 263/267 762/657
    32q/2kv g16 146/130 156/134 176/170 249/244 728/556
    32q/1kv g32 154/125 151/138 174/169 198/232 337/546
    64q/64kv MHA 174/171 257/281 430/447 1223/1290 4396/4484
    64q/32kv g2 154/151 181/176 264/307 694/766 2350/2545
    64q/16kv g4 151/145 167/160 205/203 491/489 1472/1612
    64q/8kv g8 152/144 169/169 196/184 382/393 1195/1096
    64q/4kv g16 150/144 167/161 196/180 369/350 1155/976
    64q/2kv g32 150/144 164/161 194/195 351/344 1111/953
  7. apocryphx commented on Jul 24, 2026

    @apocryphx
    ContributorAuthor

    Ran the same sweep on an M1 Max 64 GB — and it turned into a correctness find, not just a perf datapoint. Short version: the d=512 vector kernels are not launchable within pipeline limits on M1-class GPUs, and the 1-pass path fails silently.

    The register cliff is real, and it bites twice

    On M1 Max (applegpu_g13s) the compiler register-limits both d=512 pipelines to 832 threads per threadgroup (probed via maxTotalThreadsPerThreadgroup; every other head dim gets 1024):

    sdpa_vector_bfloat16_t_256_256           maxThreads=1024
    sdpa_vector_bfloat16_t_512_512           maxThreads= 832
    sdpa_vector_2pass_1_bfloat16_t_256_256   maxThreads=1024
    sdpa_vector_2pass_1_bfloat16_t_512_512   maxThreads= 832
    

    Two different failure modes fall out:

    1. 2-pass (L >= 1024 on this device): threadgroup is 32 × gqa × qL, so gqa=32 requests 1024 > 832 and check_kernel_threadgroup_size throws. Visible, at least.
    2. 1-pass (L < 1024): dispatches a flat 1024 with no size check at all. The launch is invalid, the GPU drops it, and the kernel returns all zeros — for every d=512 shape, not just gqa=32. MTL_DEBUG_LAYER=1 flags it immediately (threadsPerThreadgroup ... must be <= 832). My first sweep's short-L columns showed 1.4–2× "wins" that were a no-op kernel returning garbage quickly.

    The M4 numbers above are unaffected — its pipelines genuinely allow 1024 — but the missing check in sdpa_vector's dispatch would ship silent wrong results to every M1/M2-class user of a d=512 model.

    Two fixes tried

    • [[max_total_threads_per_threadgroup(1024)]] on both kernels: makes every launch legal, numerics correct — and spills registers on every d=512 launch. 90/105 cells regress (worst 0.37×). Dead on arrival.
    • BN=16 variant for D ≥ 512: halve the simdgroup count in the 1-pass kernel (512 threads < 832 cap; per-thread register footprint unchanged). Needs lane guards in the cross-simd reduction, a BD×BD transpose buffer, and each simd writing BD/BN output slices. Numerically verified at all depths, validation clean — patch available. But it doesn't rescue perf on M1: short-L fused still loses to the composite fallback nearly everywhere (0.5–1.0×). The M4's short-L wins just don't transfer.

    Corrected M1 Max landscape

    Same methodology (qL=1 decode, bf16, B=1, min of 3 fresh processes, 500/300/100 evals by depth). Short-L columns use the BN=16 kernel (the only correct fused option here); L >= 1024 columns are the unmodified 2-pass. Speedup = fallback/fused, bold ≥ 1.05×, italic < 0.95×:

    config 256 512 1024 4096 16384
    8q/8kv MHA 0.66× 0.53× 0.92× 1.00× 1.00×
    8q/4kv g2 0.88× 0.57× 1.05× 1.20× 1.40×
    8q/2kv g4 0.66× 0.54× 1.04× 1.17× 1.41×
    8q/1kv g8 0.71× 0.48× 1.14× 0.97× 0.98×
    16q/16kv MHA 0.69× 0.73× 0.79× 0.91× 0.83×
    16q/8kv g2 0.96× 0.86× 0.99× 1.01× 1.07×
    16q/4kv g4 0.95× 1.26× 0.92× 1.08× 1.16×
    16q/2kv g8 1.03× 0.66× 0.89× 1.17× 1.43×
    16q/1kv g16 1.02× 0.72× 0.95× 0.82× 0.63×
    32q/32kv MHA 0.93× 0.51× 0.85× 0.67× 0.72×
    32q/16kv g2 0.94× 0.93× 0.91× 1.15× 1.10×
    32q/8kv g4 0.98× 0.90× 0.98× 1.23× 1.33×
    32q/4kv g8 0.63× 0.91× 0.96× 1.04× 1.22×
    32q/2kv g16 0.96× 0.61× 0.92× 0.99× 1.20×
    32q/1kv g32 1.01× 0.85× FAIL FAIL FAIL
    64q/64kv MHA 0.76× 0.73× 0.55× 0.60× 0.70×
    64q/32kv g2 0.90× 0.61× 0.94× 0.86× 1.09×
    64q/16kv g4 0.87× 0.56× 1.00× 1.03× 1.41×
    64q/8kv g8 0.86× 0.76× 0.96× 1.17× 1.22×
    64q/4kv g16 0.87× 0.72× 0.96× 1.05× 1.19×
    64q/2kv g32 0.83× 0.60× FAIL FAIL FAIL

    105 cells: 23 wins, 24 ties, 52 losses, 6 failures (gqa=32 2-pass). The deep-GQA wins are the same region as the M4 — up to 1.43× at 16K — but shallow contexts flip to fallback territory on this GPU.

    Implications for the selection gate

    The nkv >= 2 && gqa >= 8 gate is not safe cross-device: on M1 it admits 64q/2kv (g32), which crashes, and it routes short-L shapes that regress. Two ways to slice it:

    • Conservative, device-blind: nkv >= 2 && gqa >= 8 && gqa <= 16 && N >= 4096. On this M1 data: worst cell 0.99×, wins to 1.43×. On the M4 data above: worst 0.97×, wins to 1.29×. Zero regressions on both machines, at the cost of the M4's shallow-context wins.
    • Device-adaptive: check kernel->maxTotalThreadsPerThreadgroup() against the required threadgroup size at selection time. 1024-capable devices (M4) keep the full gate; register-limited devices either fall back or restrict to the deep-GQA region. This is also the only robust guard against future register-limited targets.

    Either way, two things seem non-negotiable: the 1-pass dispatch needs the missing check_kernel_threadgroup_size (silent zeros must become at worst a loud error), and gqa=32 needs to be excluded or the 2-pass threadgroup shape reworked before unconditional routing is on the table.

    Raw µs in the fold.

    raw µs (fallback/fused per cell, M1 Max)
    config 256 512 1024 4096 16384
    8q/8kv MHA 338/515 312/587 298/323 508/507 1239/1241
    8q/4kv g2 299/340 368/644 301/286 444/369 1090/780
    8q/2kv g4 310/468 305/561 310/297 414/355 995/708
    8q/1kv g8 348/490 314/648 315/277 361/373 566/578
    16q/16kv MHA 293/427 307/421 304/387 716/786 1843/2224
    16q/8kv g2 306/319 317/369 321/325 573/567 1512/1418
    16q/4kv g4 319/335 462/366 283/308 500/462 1374/1183
    16q/2kv g8 351/342 303/456 278/313 477/407 1225/858
    16q/1kv g16 430/423 314/436 300/315 349/425 537/850
    32q/32kv MHA 313/338 338/668 456/537 1066/1587 3415/4751
    32q/16kv g2 323/342 340/365 358/393 906/789 2510/2281
    32q/8kv g4 311/318 333/370 329/337 740/602 2202/1661
    32q/4kv g8 310/493 330/362 333/347 610/588 1776/1459
    32q/2kv g16 304/318 330/543 317/343 566/570 1665/1386
    32q/1kv g32 316/313 307/363 345/ERR 378/ERR 605/ERR
    64q/64kv MHA 456/602 553/759 660/1199 1863/3112 6727/9613
    64q/32kv g2 354/393 392/647 520/552 1328/1539 5140/4731
    64q/16kv g4 339/389 406/721 453/451 1205/1169 3985/2819
    64q/8kv g8 326/377 361/478 411/428 1020/873 3346/2740
    64q/4kv g16 324/374 360/499 408/427 903/863 3043/2548
    64q/2kv g32 317/381 354/589 410/ERR 876/ERR 2926/ERR
  8. apocryphx commented on Jul 24, 2026

    @apocryphx
    ContributorAuthor

    Validated the M1 findings from the M4 side, with the BN=16 kernel applied:

    Why M4 never hit the bug — pipeline probe on the same metallib:

    device: Apple M4 Max
    sdpa_vector_bfloat16_t_256_256             maxThreads=1024
    sdpa_vector_bfloat16_t_512_512             maxThreads=1024
    sdpa_vector_2pass_1_bfloat16_t_256_256     maxThreads=1024
    sdpa_vector_2pass_1_bfloat16_t_512_512     maxThreads=1024
    

    The 832 cap is M1-generational; the M4's original short-L numbers were genuine computation, not dropped launches. Numerics of the BN=16 variant on M4: clean (bf16-rounding agreement with the fallback across MHA/g4/g8/g32, both 1-pass lengths).

    But BN=16 costs the M4 its short-L wins: re-running the 256/512 columns under the M1-safe kernel flips them from mostly-wins to 7 wins / 13 ties / 22 losses (of 42). So both devices now point the same way: the 1-pass region isn't worth routing at d=512, on any hardware.

    That converges to a PR spec with no device-dependent logic:

    • (512, 512) instantiations; 1-pass carries the BN=16 shape for D >= 512 as correctness insurance (with group_dims to match), though the gate below never routes it;
    • the missing check_kernel_threadgroup_size added to sdpa_vector's dispatch — silent zeros become a loud error on any future register-limited target, for every head dim;
    • routing: query_head_dim == 512 && n_kv >= 2 && gqa >= 8 && gqa <= 16 && kL >= 4096, no env var. On the M4 data: worst covered cell 0.97×, wins to 1.31×. On the M1 data: worst 0.99×, wins to 1.43×. kL >= 4096 only ever selects the unmodified 2-pass kernel on both device classes.

    Deep-context is also where d=512 decode actually happens in practice (Gemma 4 chat sessions sit at 4K+ KV depth routinely), so the gate gives up little. If this spec looks right, I'll open the PR.

  9. apocryphx commented on Jul 24, 2026

    @apocryphx
    ContributorAuthor

    Ran the blocks-heuristic probe on an M4 iPad Pro (16 GB) to chase the deep-context losses there — result: mostly not the heuristic.

    First, the full iPad landscape (same grid and methodology; in-process with cache clears between arms — iOS has no processes; BN=16 kernel throughout): 77 wins / 20 ties / 8 losses of 105 — by far the greenest device, exactly as the bandwidth math predicts (120 GB/s: the fallback's extra traffic is expensive, the fused kernel's occupancy tax is cheap). Short contexts win 1.1–1.4× with the same BN=16 kernel that loses short-L on both Macs. Pipeline probe: all d=512 pipelines report maxThreads=1024 — the 832 cap is confirmed M1-generational, on iPadOS too.

    config 256 512 1024 4096 16384
    8q/8kv MHA 1.19× 1.16× 1.13× 1.06× 1.04×
    8q/4kv g2 1.31× 1.27× 1.22× 1.13× 1.03×
    8q/2kv g4 1.28× 1.27× 1.20× 1.05× 0.99×
    8q/1kv g8 1.31× 1.26× 1.23× 1.13× 1.07×
    16q/16kv MHA 1.16× 1.12× 1.14× 1.04× 1.03×
    16q/8kv g2 1.27× 1.21× 1.15× 1.07× 1.03×
    16q/4kv g4 1.27× 1.25× 1.17× 1.08× 0.95×
    16q/2kv g8 1.21× 1.22× 1.20× 1.08× 0.78×
    16q/1kv g16 1.39× 1.24× 1.18× 0.98× 0.68×
    32q/32kv MHA 1.11× 1.02× 1.03× 1.02× 1.01×
    32q/16kv g2 1.18× 1.14× 1.08× 1.03× 1.12×
    32q/8kv g4 1.21× 1.17× 1.08× 1.11× 1.04×
    32q/4kv g8 1.11× 1.14× 1.10× 1.18× 0.92×
    32q/2kv g16 1.20× 1.15× 1.11× 1.16× 0.75×
    32q/1kv g32 1.14× 1.11× 1.00× 0.71× 0.39×
    64q/64kv MHA 1.11× 1.07× 1.05× 1.03× 1.04×
    64q/32kv g2 1.18× 1.14× 1.07× 1.06× 1.23×
    64q/16kv g4 1.15× 1.15× 1.06× 1.19× 1.13×
    64q/8kv g8 1.15× 1.15× 1.06× 1.25× 0.98×
    64q/4kv g16 1.19× 1.14× 1.06× 1.39× 0.96×
    64q/2kv g32 1.09× 1.15× 1.08× 1.37× 0.86×

    The 8 losses cluster at 16K depth with few KV heads. MTLDevice.architecture.name here is applegpu_g16g — ends in 'g', a third device class that takes the else-branch of the 2-pass blocks heuristic (64 blocks), which no Mac exercises. But sweeping MLX_SDPA_BLOCKS over the losing cells (plus two winning controls) moves almost nothing — only nkv=2@16K improves (0.74× → 0.92× at 512):

    cell fallback fused default b64 b128 b256 b512
    32q/1kv g32 @16k 1074 2691 2713 2725 2696 2677
    16q/1kv g16 @16k 1046 1391 1389 1393 1389 1392
    32q/1kv g32 @4k 515 645 729 706 707 708
    32q/2kv g16 @16k 3041 4103 4108 4107 4127 3316
    16q/2kv g8 @16k 1429 1581 1569 1596 1608 1598
    64q/2kv g32 @16k 7869 6073 5899 7746 7742 5760
    32q/4kv g8 @16k 3447 3737 3697 3750 3729 3766
    16q/4kv g4 @16k 1922 1944 1954 1967 1951 1953
    32q/8kv g4 @16k 4337 4134 4120 4145 4114 4128
    64q/16kv g4 @4k 2126 1864 1805 1780 1755 1754

    The loss severity instead tracks threadgroup size = 32·gqa: g32 → 0.40×, g16 → ~0.75×, g8 → ~0.9×, g4 and below → ties. With d=512's register footprint, large threadgroups leave one resident group per core — on a 10-core GPU there is no latency hiding left at depth. Kernel-occupancy property, not host tuning.

    Revised proposal — still no new selection machinery, and each piece unambiguous on all three devices tested (M4 Max / M1 Max / M4 iPad):

    1. check_kernel_threadgroup_size in sdpa_vector's dispatch, unconditional — the silent-zeros fix; protects every head dim on every register-limited target.
    2. d=512 instantiations with the BN=16 1-pass shape (required for M1-class correctness; the routing below never reaches 1-pass anyway).
    3. Routing gated to Mac-class GPUs via the architecture character this function already branches on for blocks: (devc == 'd' || devc == 's') && n_kv >= 2 && 8 <= gqa <= 16 && kL >= 4096. Zero regressions where it routes (worst 0.97× on M4 Max, 0.99× on M1 Max, wins to 1.43×); phone/tablet-class devices keep today's fallback everywhere.

    Left deliberately on the table: the iPad's broad shallow-context wins can't be routed device-blind (that region is exactly where BN=16 loses on both Macs) — per-device-class tuning could pick them up in a follow-up. The spec above takes only what's unambiguous. Happy to open the PR with this shape.

    raw µs (fallback/fused per cell, M4 iPad Pro, iPadOS 26.5.2)
    config 256 512 1024 4096 16384
    8q/8kv MHA 287/242 374/322 474/418 1019/961 2902/2782
    8q/4kv g2 322/245 344/270 371/303 689/611 1650/1607
    8q/2kv g4 308/240 318/251 330/274 498/476 1056/1062
    8q/1kv g8 314/239 310/246 349/284 482/428 964/898
    16q/16kv MHA 374/323 478/426 729/637 1662/1600 5471/5331
    16q/8kv g2 312/245 398/330 498/434 1053/982 3118/3033
    16q/4kv g4 325/256 345/277 399/340 747/691 1947/2056
    16q/2kv g8 291/240 336/275 362/302 573/531 1427/1830
    16q/1kv g16 340/244 332/268 354/300 460/471 954/1399
    32q/32kv MHA 481/435 687/671 1019/985 2964/2893 10663/10564
    32q/16kv g2 382/323 501/439 737/685 1719/1665 6262/5570
    32q/8kv g4 343/284 402/344 503/464 1184/1066 4327/4153
    32q/4kv g8 311/281 355/311 446/407 983/835 3459/3769
    32q/2kv g16 335/280 353/308 413/372 912/786 3092/4114
    32q/1kv g32 317/277 339/305 370/370 532/750 1090/2775
    64q/64kv MHA 737/666 1051/981 1668/1594 5433/5295 20907/20152
    64q/32kv g2 518/438 744/652 1052/987 3117/2947 13102/10695
    64q/16kv g4 415/361 521/453 754/708 2082/1746 9584/8512
    64q/8kv g8 381/331 461/402 576/543 1702/1361 8155/8304
    64q/4kv g16 376/317 419/369 531/503 1515/1089 7489/7835
    64q/2kv g32 341/313 392/342 493/458 1440/1049 7169/8343
  10. apocryphx commented on Jul 24, 2026

    @apocryphx
    ContributorAuthor

    One correction and one open validation gap, before anyone takes the gate at face value:

    Correction: M4 Max reports applegpu_g16s — so the architecture suffix is 'g' = base chips (and iPads/iPhones), 's' = Pro and Max, 'd' = Ultra. Two implications: base-M-series Macs (e.g. MacBook Air M4, 120 GB/s — bandwidth-identical to the iPad tested above) are correctly excluded by the 's'/'d' condition, which is a nice accident; but the condition includes Pro-class chips (M1/M4 Pro), which none of our three devices represent, and devc cannot distinguish Pro from Max.

    Why Pro is probably fine, and why "probably" isn't enough: Pro chips take the same 's' blocks branch validated on both Maxes (256 blocks at 16K, not the 64 the iPad's 'g' branch picks), with ~2× the iPad's cores and bandwidth — and the only in-gate iPad losses were at kL=16384 (kL=4096 was green on all three devices, 0.97×–1.39×). Interpolation says Max-like. But the iPad result is exactly why interpolation shouldn't be trusted here: the occupancy behavior at 32·gqa-sized threadgroups didn't extrapolate from the Maxes either.

    If anyone has an M1 Pro or M4 Pro handy, the 30-line bench from the earlier comment run over the five gate cells at kL ∈ {4096, 16384} would close this. Failing that, the PR could ship the gate at kL >= 4096 capped below 16K (green on every device tested, including the iPad) and lift the 16K+ range for 's'-class in a follow-up once Pro numbers exist. Ultra ('d') is likewise untested — in the direction of more headroom, but stated for the record.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions