Add a fused full-attention path for head_dim 256 on NAX devices#3842
Open
wyanzhao wants to merge 2 commits into
Open
Add a fused full-attention path for head_dim 256 on NAX devices#3842wyanzhao wants to merge 2 commits into
wyanzhao wants to merge 2 commits into
Conversation
use_fallback currently allows the fused full-attention path only for
head_dim in {64, 80, 128}. Models with head_dim 256 (e.g. the Qwen3.5/3.6
family's full-attention layers) fall back to the unfused graph, which
materializes the [n_heads, qL, kL] score tensor and computes the full
rectangle before masking.
A naive bd=256 instantiation of steel_attention_nax runs at half the
efficiency of the bd=128 kernel on every shape (22 vs 48 TF on M5 Max):
the per-simdgroup accumulator working set doubles (16 + 4 f32 output and
score fragments), and accumulator bytes are the resource that gates
tensor-unit throughput at this width (a probe with fp16 accumulators
recovers +51%; static register pressure, scheduling fences, accumulation
chain order, instruction footprint and Q reload traffic were each ruled
out by measurement).
This adds a head-dim-split configuration instead (bq=64, bk=32, wm=4,
wn=2): the second warp dimension splits D, so each simdgroup computes
Q@K.T partials over one half of D (exchanged pair-by-pair through a 16KB
threadgroup buffer in per-lane-linear layout, which needs no coordinate
math because both halves share the fragment-to-lane map), runs softmax
redundantly on the full score tile, and accumulates P@V for its half of
Dv. That puts the accumulator set (2 S + 8 O fragments, 320B/lane) at
exact bd=128 parity, and throughput follows: 42.8 TF at 8192^2 causal,
90% of the bd=128 kernel. Q fragments stay register-resident across the
KV loop, and mma chains keep a single accumulator (alternating
accumulators measures -25%).
Routing and raggedness (all numbers M5 Max, 16 Q / 2 KV heads, bf16,
causal):
- The fused path is taken for causal shapes with qL >= 1024 and no array
mask, where it beats the unfused graph everywhere measured, offset
rectangles included: 2048x8192 5.77 vs 8.92 ms, 4096^2 3.45 vs
8.87 ms, 1024x8192 3.58 vs 4.65 ms, 2048^2 1.19 vs 2.39 ms. Below
~1k queries against a long cache there are too few query blocks to
fill the machine (512x8192 measured 1.08x on an earlier variant), so
those keep the fallback.
- Ragged lengths are padded up to the tile sizes at dispatch and the
padded query rows sliced off on the way out: the unaligned
(align_Q/align_K = false) pipelines slow every block down ~3x at this
width, and real decoder prefills are essentially never tile-aligned.
With qL_off computed from the true lengths the causal bound
c <= r + qL_off never reaches a padded key column; pads are
zero-filled so padded value rows cannot poison P @ V; cost is ~0.6 ms
against a ~49 ms cliff.
- float32 with tf32 disabled keeps the fallback, since only the NAX
kernel is instantiated at this width.
Transient memory at 4096^2 drops 663 MB -> 75 MB (no score
materialization), and the fused output is closer to an f32 reference
than the unfused bf16 graph (relative L2 1.6e-3 vs 2.3e-3).
End to end (Qwen3.6-35B-A3B 4-bit, 8k ragged prompt):
default 2048-token chunked prefill: 4039 -> 4193 tok/s (+3.8%)
single-chunk prefill: 4209 -> 4822 tok/s (+14.6%)
Tests: fallback shapes and fused shapes (aligned and ragged squares, a
boundary rectangle and a ragged rectangle) against the primitives
reference in float32 and bfloat16, plus a subprocess case with
MLX_ENABLE_TF32=0 covering the float32 fallback predicate (a mismatch
there fails as a kernel-load error).
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Proposed changes
Add a fused NAX full-attention path for
head_dim == 256withoutmaterializing the
[n_heads, qL, kL]score tensor.bq=64,bk=32,wm=4,wn=2DandDvacross two simdgroupsqL >= 1024, NAX-compatible dtype/TF32 modefloat32with TF32 disabledMotivation
use_fallbackcurrently allows fused full attention only forhead_dimin{64, 80, 128}. Full-attention layers in the Qwen3.5/3.6family use
head_dim == 256, so they fall back to an unfused graph that:A direct
bd=256instantiation ofsteel_attention_naxreaches only abouthalf the efficiency of
bd=128on M5 Max (22vs48 TF). The limitingresource is the per-simdgroup accumulator byte budget: the
bd=256outputand score accumulator set is twice as large. A half-precision-accumulator
probe recovered
+51%, while register pressure, scheduling fences,accumulation-chain order, instruction footprint, and Q reload traffic were
ruled out by measurement.
Design
Head-dimension split
The
wn=2configuration assigns each simdgroup one half ofDandDv:Q @ K.Tscores.16 KBthreadgroup buffer.
P @ Vfor its owned half ofDv.Both halves use the same fragment-to-lane mapping, so the exchange can use a
per-lane-linear layout without coordinate conversion. Q fragments remain
register-resident across the KV loop.
This reduces the per-simdgroup accumulator set to
2 S + 8 Ofragments(
320 B/lane), matching the working set of the existingbd=128kernel.Ragged causal inputs
For
head_dim == 256, the unaligned pipelines (align_Q/align_K = false)slow every block down by roughly
3x. Ragged causal inputs are thereforepadded to the tile sizes at dispatch:
qL_offis computed from the true sequence lengths, preserving thecausal diagonal;
The padding/copy cost is approximately
0.6 ms, avoiding an observedapproximately
49 msunaligned-path cliff.Routing and fallback behavior
head_dim == value_head_dim == 256,qL >= 1024wn=2pathqL < 1024float32withMLX_ENABLE_TF32=0The
qL >= 1024threshold is based on measured M5 Max behavior. Below thatpoint, long-cache rectangles do not provide enough query blocks to fill the
machine (
512 x 8192measured approximately1.08xthe unfused time on anearlier variant).
Performance
Unless noted otherwise: M5 Max, 16 Q / 2 KV heads, bf16, causal.
Kernel benchmarks
8192 x 819224.92 ms/22.1 TF12.84 ms/42.8 TF1.94xthroughput4096 x 40968.87 ms3.45 ms2.57xfaster2048 x 81928.92 ms5.77 ms1.55xfaster1024 x 81924.65 ms3.58 ms1.30xfaster2048 x 20482.39 ms1.19 ms2.01xfasterAt
8192 x 8192, the new kernel reaches approximately90%of thebd=128kernel's throughput.End-to-end prefill
Qwen3.6-35B-A3B 4-bit, ragged prompts:
4039 tok/s4193 tok/s+3.8%4209 tok/s4822 tok/s+14.6%3457 tok/s4180 tok/s+20.9%29.9 -> 26.1 GB2833 tok/s3598 tok/s+27.0%34.6 -> 26.5 GB1771 tok/s2873 tok/s+62.2%72.9 -> 41.8 GBTransient memory for the
4096 x 4096kernel case drops from663 MBto75 MBbecause the fused path no longer materializes the score tensor.Numerical behavior
Against an f32 reference, the fused bf16 output is more accurate than the
previous unfused graph:
2.3e-31.6e-3Validation
float32andbfloat16.rectangles.
1031and2049, plusthe exact
qL == 1024routing boundary.MLX_ENABLE_TF32=0verifies thatfloat32 + head_dim 256remains on the fallback path.test_fast_sdpa.pyresult: 18 tests, 1 platform-dependentskip, otherwise passing.
Checklist
pre-commit run --all-files.