Skip to content

[Performance] Vector SDPA achieves only ~25% of memory bandwidth for GQA (4 KV heads, head_dim 256) at long context on M1 Ultra #3837

Description

@KaiserCaiser

Describe the issue

Single-query (decode-time) mx.fast.scaled_dot_product_attention runs far
below memory-bandwidth speed for a GQA shape with few KV heads and large
head_dim at long context. On an M1 Ultra with mlx 0.32.0:

  • Shape: q=(1, 32, 1, 256), k=v=(1, 4, 100000, 256), bf16, mask=None
  • Measured: 2.2-2.4 ms per call
  • KV bytes touched: 2 × 4 × 100000 × 256 × 2B ≈ 0.41 GB → ~170-190 GB/s achieved
  • The same machine sustains ~680 GB/s on a plain reduction over a large
    contiguous array (benchmark below), so the kernel achieves ~25-28% of
    practically available bandwidth.

For a 16-full-attention-layer model (Qwen3.6-27B hybrid) this makes decode
attention cost ~39 ms/token at 100k context — about 43% of the total token
time — and is the dominant reason decode throughput trails
bandwidth-proportional expectations at long context.

MLX_SDPA_BLOCKS (new in 0.32.0) was swept at this shape: the default
selection is already optimal (8/16/32/64/128 are all equal or worse), so this
does not look like a block-count tuning issue.

q_len=2 costs 5.39 ms (≈2.2×), so batched-verify speculative decoding at long
context is also gated by the same kernel.

To Reproduce

import time
import mlx.core as mx

B, Hq, Hkv, D, S = 1, 32, 4, 256, 100000

# reference: practically achievable bandwidth on this machine
rows = 8 * 1024**3 // (4 * 65536)
a = mx.random.normal((rows, 65536))
mx.eval(a)
mx.eval(a.sum())
t0 = time.time()
for _ in range(5):
    mx.eval(a.sum())
bw = 8 / ((time.time() - t0) / 5)
print(f"reduction bandwidth: {bw:.0f} GB/s")

k = mx.random.normal((B, Hkv, S, D), dtype=mx.bfloat16)
v = mx.random.normal((B, Hkv, S, D), dtype=mx.bfloat16)
q = mx.random.normal((B, Hq, 1, D), dtype=mx.bfloat16)
mx.eval(k, v, q)
scale = D ** -0.5

mx.eval(mx.fast.scaled_dot_product_attention(q, k, v, scale=scale, mask=None))
t0 = time.time()
n = 50
for _ in range(n):
    mx.eval(mx.fast.scaled_dot_product_attention(q, k, v, scale=scale, mask=None))
dt = (time.time() - t0) / n
kv_gb = 2 * Hkv * S * D * 2 / 1e9
print(f"sdpa q_len=1: {dt*1000:.2f} ms  -> {kv_gb/dt:.0f} GB/s "
      f"({kv_gb/dt/bw*100:.0f}% of reduction bandwidth)")

Output on M1 Ultra (128GB), macOS 26.2, mlx 0.32.0 (pip):

reduction bandwidth: 676 GB/s
sdpa q_len=1: 2.17 ms  -> 189 GB/s (28% of reduction bandwidth)

Expected behavior

Decode-time SDPA at long context is memory-bound; something closer to
50-80% of achievable bandwidth would be expected (llama.cpp's Metal FA
achieves ~75%+ on comparable GQA shapes on M-series).

Happy to run patches/benchmarks on this machine (M1 Ultra 128GB, real
workload = 27B hybrid model at 80-160k context).


This investigation (bandwidth baseline, kernel benchmarks, and this report)
was performed by Claude (model: claude-fable-5) via Claude Code, operating
directly on the reporter's hardware.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Fields

    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions