Skip to content

mx.fast.scaled_dot_product_attention: 2.3–2.8× latency jump from L=8 to L=12 queries (kernel-routing gap), flat until L≥48 #3826

Description

@pierre427

While benchmarking speculative-decoding verify shapes we found a sharp cliff in
fp16 mx.fast.scaled_dot_product_attention when the number of query rows
crosses from 8 to 12. Latency then stays flat until L≈48 and only recovers
efficiency at L≥64 — so the L∈[12, 48] window pays ~L=48 cost regardless of L.

GQA 32 q-heads / 8 kv-heads, head_dim 128, fp16, no mask, M5 Max:

S (kv len) L=8 L=12 L=16 L=32 L=48 L=64
16384 0.813 ms 1.906 ms 1.903 ms 1.904 ms 1.904 ms 1.588 ms
32768 1.319 ms 3.633 ms 3.646 ms 3.643 ms 3.630 ms 2.992 ms
  • L=8→12 is a 2.34× (16k) / 2.75× (32k) jump for 1.5× the work.
  • Per-query-row cost at L=12 is ~1.6–1.9× the L=8 cost and worse than L=1.
  • The flat plateau L=12..48 suggests the vector kernel's small-L path caps at
    L≤8 and everything above falls to a path tuned for much larger L (steel /
    full attention), with nothing covering the 12–48 window.

Why it matters: multi-token verify steps in speculative decoding (MTP,
prompt-lookup / n-gram, draft-model) live exactly in L≈4–16, and batched short
continuations hit 12–48. The window between "decode-shaped" and "prefill-shaped"
is becoming the hot path for spec-decode workloads.

Observed on the affine-quantized side too: the decomposed quantized attention
(qmm→softmax→qmm) beats fp16 SDPA at L=16 at these S (0.35–0.48× its
latency), which is only possible because of this cliff.

Repro (self-contained):

import time, mlx.core as mx
NQ, NKV, D = 32, 8, 128
for S in (16384, 32768):
    k = mx.random.normal((1, NKV, S, D)).astype(mx.float16)
    v = mx.random.normal((1, NKV, S, D)).astype(mx.float16)
    mx.eval(k, v)
    for L in (1, 2, 4, 8, 12, 16, 24, 32, 48, 64):
        q = mx.random.normal((1, NQ, L, D)).astype(mx.float16); mx.eval(q)
        f = lambda: mx.fast.scaled_dot_product_attention(q, k, v, scale=D**-0.5)
        for _ in range(10): mx.eval(f())
        mx.synchronize(); t0 = time.perf_counter()
        for _ in range(40): mx.eval(f())
        mx.synchronize()
        print(S, L, f"{(time.perf_counter()-t0)/40*1e3:.3f} ms")

Happy to run additional shapes (head dims, GQA ratios, masks) if useful.

Metadata

Metadata

Assignees

No one assigned

    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