feat(attention): complete Flash Attention VJP implementation#1
feat(attention): complete Flash Attention VJP implementation#1Brooooooklyn wants to merge 1 commit into
Conversation
There was a problem hiding this comment.
Pull request overview
This PR implements Flash Attention VJP (Vector-Jacobian Product) backward pass for scaled dot-product attention, enabling memory-efficient training with automatic differentiation. The implementation includes two kernel approaches: vector VJP for short sequences (≤8) and STEEL VJP for longer sequences, with proper handling of GQA (Grouped Query Attention), causal masking, and numeric stability.
Changes:
- Adds cached logsumexp mechanism to enable VJP access without materializing attention matrices
- Implements two-kernel STEEL VJP approach (dQ and dKV kernels) with K-row ownership model to eliminate atomic operations
- Adds vector VJP kernels with float32 accumulator support for half/bfloat16 dtypes
- Updates forward kernels to output logsumexp when training and removes the training mode fallback to enable Flash Attention during training
Reviewed changes
Copilot reviewed 14 out of 14 changed files in this pull request and generated 4 comments.
Show a summary per file
| File | Description |
|---|---|
| mlx/fast_primitives.h | Adds cached_logsumexp member and accessors for VJP backward pass |
| mlx/fast.cpp | Updates VJP dispatch logic to check mask/sinks and access cached logsumexp |
| mlx/backend/no_gpu/primitives.cpp | Updates use_fallback signature with mask/sinks parameters |
| mlx/backend/metal/scaled_dot_product_attention.cpp | Implements complete VJP eval_gpu with STEEL and vector kernel dispatch |
| mlx/backend/metal/kernels/steel/attn/params.h | Adds AttnVJPParams structure with gradient strides and LSE strides |
| mlx/backend/metal/kernels/steel/attn/kernels/steel_attention_vjp_dq.{h,metal} | Implements dQ gradient kernel (607 lines) |
| mlx/backend/metal/kernels/steel/attn/kernels/steel_attention_vjp_dkv.{h,metal} | Implements dK/dV gradient kernel (817 lines) |
| mlx/backend/metal/kernels/steel/attn/kernels/steel_attention.h | Adds logsumexp output support to forward kernel |
| mlx/backend/metal/kernels/sdpa_vector_vjp.h | Implements vector VJP kernels (507 lines) |
| mlx/backend/metal/kernels/scaled_dot_product_attention.metal | Adds VJP kernel instantiations |
| mlx/backend/metal/kernels/CMakeLists.txt | Updates build rules for new VJP kernels |
| mlx/backend/cuda/scaled_dot_product_attention.cpp | Updates use_fallback signature for consistency |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
PR Review Comment Resolutions✅ Fixed Issues
❌ Not Bugs (False Positives)
📝 Minor/Documentation
|
|
@Brooooooklyn I've opened a new pull request, #2, to work on those changes. Once the pull request is ready, I'll request review from you. |
Additional Bug Fixes (Latest Commits)✅ Fix: Unallocated arrays passed to kernel (c1d5375)Issue: Fix: Replaced ✅ Fix: NAX path ignores logsumexp output (4caea80)Issue: When NAX conditions are met, the function early-returns to Fix: Added Both issues affected bfloat16/half VJP correctness. |
9d7f0de to
9f878a0
Compare
0c747a1 to
7885ff3
Compare
35a7886 to
568ff36
Compare
|
@codex review |
e4ffeda to
295a609
Compare
295a609 to
a9e5133
Compare
a9e5133 to
ce91749
Compare
38f5529 to
f696e1d
Compare
f696e1d to
4636ffd
Compare
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes and found 2 potential issues.
Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes and found 2 potential issues.
Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.
8d0aea8 to
b56d13b
Compare
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes and found 2 potential issues.
Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.
ce4f96e to
6a4522e
Compare
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes and found 2 potential issues.
Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.
6a4522e to
8f1293e
Compare
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes and found 3 potential issues.
Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.
8f1293e to
0952a03
Compare
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes and found 2 potential issues.
Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.
0952a03 to
ab9f0b4
Compare
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes and found 2 potential issues.
Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.
ab9f0b4 to
3e2965b
Compare
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes and found 2 potential issues.
Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.
f2f1380 to
746d28a
Compare
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes and found 1 potential issue.
Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.
Add fused Flash Attention backward pass (VJP) kernels for Apple Silicon
GPUs, implementing the two-kernel architecture from Flash Attention 2
(Dao, 2023). The fused backward eliminates O(L^2) attention matrix
materialization, reducing peak memory by 70-95% with auto-dispatch
routing between fused and unfused paths.
Key additions:
- Two Metal kernels: steel_attention_vjp_dq and steel_attention_vjp_dkv
- JIT compilation with baked constants for dead-code elimination
- Delta precomputation as lazy MLX graph ops
- Threadgroup memory aliasing (23KB -> 14.8KB for D=128 dKV)
- Sparse block mask support via function constant gating
- Auto-dispatch: causal L thresholds + 1GB memory ceiling
- Support for D={64,96,128}, float16/bfloat16, causal, GQA
- 2-pass vector kernel LSE output for VJP logsumexp
Performance (M3 Max, B=1 H=32 causal float16, fused vs unfused):
D=64: 1.22-1.40x faster
D=96: 1.29-1.35x faster
D=128: 0.77-0.81x (memory trade-off, 70-82% savings)
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
746d28a to
985fdd0
Compare
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes and found 2 potential issues.
Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.
| AccumType lse_val = max_score[i] + fast::log2(sum_score[i]); | ||
| lse_out[row_pos * params->LSE_strides[1]] = static_cast<float>(lse_val); | ||
| } | ||
| } |
There was a problem hiding this comment.
LSE written after output normalization corrupts sum_score values
High Severity
The LSE output at line 505 uses sum_score[i] after row_bin_op<DivOp>(sum_score) at line 482 has already divided the output tile by sum_score. The row_bin_op template in STEEL modifies each element of sum_score as part of broadcasting the division — depending on the implementation, the sum_score array values may no longer represent the original row sums after the division operation. The LSE formula max_score + log2(sum_score) requires the original unnormalized sum of exponentials, not values modified by the division broadcast. If row_bin_op leaves sum_score unchanged this is benign, but if it modifies the array (e.g., by computing reciprocals in-place), the LSE output would be incorrect, causing the VJP backward pass to produce wrong gradients.
| auto O_f32 = astype(outputs[0], float32, s); | ||
| auto dO_f32 = astype(cotangents[0], float32, s); | ||
| auto delta = sum(multiply(dO_f32, O_f32, s), std::vector<int>{3}, false, s); | ||
| inputs.push_back(delta); // delta = sum(dO * O, axis=-1), shape [B, H, qL] |
There was a problem hiding this comment.
Unnecessary delta computation wastes FLOPs on CUDA path
Low Severity
The delta = sum(multiply(dO_f32, O_f32), axis=3) computation is unconditionally added to VJP inputs in the shared vjp() function. The CUDA backend's eval_gpu never reads this delta (cuDNN computes it internally), but MLX's lazy evaluation materializes all primitive inputs before eval_gpu runs. This triggers an unnecessary element-wise multiply and reduce of O(B×H×L×D) on CUDA, including two astype casts to float32.


Fused Flash Attention Backward (VJP) Kernels for Metal
Summary
This PR adds fused Flash Attention backward pass (VJP) kernels for Apple Silicon GPUs, implementing the two-kernel architecture from Flash Attention 2 (Dao, 2023) using MLX's STEEL tiling framework. The fused backward eliminates the O(L^2) attention matrix materialization, reducing peak memory by 70-95% at the cost of additional recomputation. An auto-dispatch policy routes between fused and unfused paths based on sequence length and memory pressure.
Key additions:
steel_attention_vjp_dq(dQ gradients) andsteel_attention_vjp_dkv(dK/dV gradients)gqa_factor,scale,scale_log2, alignment flags, causal, block mask) for dead-code eliminationred_smemoverQ_smem+dO_smem(temporally disjoint), reducing D=128 dKV from 23 KB to 14.8 KB (enables 2 TGs/core)Background
The Flash Attention Backward Recomputation Trade-off
Flash Attention (Dao et al., NeurIPS 2022) achieves O(N) memory for attention by never materializing the full N x N attention matrix. The forward pass tiles the computation with no extra FLOPs — it's the same work, just blocked differently. But the backward pass fundamentally changes the compute profile.
Forward pass (2 matmuls per block-pair):
Backward pass (5 matmuls per block-pair):
The key insight from Flash Attention 2 (Dao, 2023, Section 3.1): the backward pass requires ~2.5x the FLOPs of the forward pass because the attention matrix S must be recomputed from saved LSE in both the dQ kernel and the dKV kernel. This is the fundamental cost of O(N) memory.
Published backward/forward ratios
These ratios are well-documented across implementations:
The backward is inherently more expensive than forward in Flash Attention. This is not a bug — it's the price of O(N) memory.
Reference implementation: Metal Flash Attention (MFA)
Our implementation follows the architectural patterns from Metal Flash Attention by Philip Turner, the only prior fused attention backward for Apple Silicon:
delta = rowsum(dO * O)computed once outside kernels (MFA pattern)P = exp2(S * scale_log2 - LSE)instead ofexp(S * scale - LSE)for Metalexp2efficiency (MFA pattern)The main difference from MFA: we use MLX's JIT compilation to bake runtime constants (
gqa_factor,scale, alignment, causal) as#defineliterals, enabling the Metal compiler to eliminate dead code paths. MFA achieves this via pre-compiled metallib with#defineconstants baked at build time.Architecture
Two-Kernel Design
The backward is split into two independent kernels that run concurrently:
Per-Head-Dimension Configuration
D=128 dKV is the most register-constrained configuration. At BQ=32 WM=2, it would need ~338 regs/thread (spills at >256). BQ=16 reduces TQ from 2 to 1 per simdgroup, cutting register usage to ~202 at the cost of 2x more Q-tile iterations.
Threadgroup Memory Aliasing
The dKV kernel uses four smem buffers:
Q_smem,dO_smem(iteration phase),KV_smem(iteration phase), andred_smem(reduction phase, WM>1 only). Since Q/dO and red are temporally disjoint (iterations end before reduction begins),red_smemis aliased over theQ_smem+dO_smemregion:A
static_assertverifies the Q+dO region is large enough to alias red, and an explicitthreadgroup_barrierbefore the reduction phase ensures correctness.Sparse Block Masks
Both kernels support an optional
block_maskbuffer (uint8_t[NQ_tiles * NK_tiles]) for skipping tile pairs in sparse attention patterns (sliding window, local attention, block-sparse). The mask is gated by a function constant (has_block_mask, index 302) / JIT define (VJP_HAS_BLOCK_MASK):has_block_mask = false(default): all mask checks are dead-code eliminated — zero overheadhas_block_mask = true: tile-skip check at the top of each loop iteration, before any smem loadsblock_mask[qb * NK_tiles + kb] == 0block_mask[qb * NK_tiles + kb] == 0JIT Constant Baking
Both kernels are JIT-compiled with
#defineconstants:For metallib (non-JIT) builds, macros fall back to
params->runtime reads and Metal function constants, ensuring zero behavior change.Why Fused Backward Is Not Always Faster
The fundamental trade-off
The fused path pays two penalties:
When fused wins: causal masking
With causal attention, the attention matrix is lower-triangular. Fused kernels skip entire tile blocks in the upper triangle, eliminating ~50% of compute. Unfused still materializes the full N x N matrix then masks it:
This is why our benchmarks show fused causal at 1.18-1.36x speedup (fused faster) but fused dense at 0.39-0.70x (fused slower).
When fused wins: long sequences
At long sequence lengths, the O(L^2) attention matrix becomes a memory bandwidth bottleneck. At L=4096 with 32 heads, unfused needs 3.4 GB just for the backward intermediate. Even when fused is slower in raw compute, it avoids thrashing the memory system.
How we deal with it: auto-dispatch
Rather than always using fused (slow for short dense sequences) or always unfused (OOM risk for long sequences), we route per-configuration:
Users can override with
MLX_SDPA_VJP_MODE={fused|unfused}.Benchmark Results
All benchmarks on Apple M3 Max, B=1, H=32, float16. Speedup > 1.0x means fused is faster.
Performance: Fused vs Unfused (MLX_SDPA_VJP_MODE=fused)
Causal attention — fused is competitive or faster due to block skipping:
Dense attention — fused pays the full recomputation penalty:
Pattern analysis:
Why D=128 causal is slower despite block skipping
D=64/96 causal fused is 1.18-1.36x faster than unfused, but D=128 causal fused is only 0.76-0.80x. Both get the same ~50% causal tile-skip benefit, yet D=128 cannot overcome the recomputation penalty. This is caused by five compounding factors:
1. Occupancy collapse (dominant factor). Apple M3 Max has 32 KB threadgroup memory per GPU core. D=64 dKV uses ~7 KB smem, allowing 4 concurrent threadgroups per core — when one threadgroup stalls on a memory load, others keep the ALU busy. D=128 dKV originally used ~22.5 KB smem (Q: 4,352B + dO: 4,352B + KV: 6,144B + reduction: 8,192B), limiting occupancy to 1 threadgroup per core. After smem aliasing (red over Q+dO), this drops to 14.8 KB enabling 2 TGs/core — improving D=128 causal from 0.73x to 0.80x, but still below D=64's 4 TGs/core.
2. BQ=16 doubles iteration overhead. D=128 dKV uses BQ=16 tiles (half of D=64's BQ=32) to keep register pressure under 256 regs/thread. This means 2x more Q-tile iterations in the inner loop, each paying fixed costs: 2 global memory tile loads, 5 threadgroup barriers, and LSE/delta scalar reads. Total useful compute is the same, but the overhead-to-compute ratio doubles — from ~30% at D=64 to ~60% at D=128.
3. WM=2 threadgroup reduction. D=128 requires WM=2 (2 simdgroups, 64 threads) to split register pressure across simdgroups (~202 regs/thread vs ~364 at WM=1). This adds a threadgroup reduction with 4 extra barriers per Q-block iteration for dK/dV partial sum accumulation — overhead that D=64 (WM=1, single simdgroup) never pays.
4. Fewer MMAs per iteration. Each Q-tile iteration performs:
Less compute per iteration means the fixed overhead (barriers, loads) dominates a larger fraction of wall time.
5. Unfused baseline is stronger at D=128. The unfused path uses NAX-optimized matmuls with large tiles (BM=128, BN=128). At D=128, matmul shapes like
[L, L] @ [L, 128]fill NAX tiles perfectly (N=128 = BN). At D=64, N=64 wastes half the tile width. The unfused baseline is proportionally faster at D=128, widening the gap.Combined effect: The effective fused/unfused throughput ratio is ~18% at D=64 vs ~12% at D=128 (after smem aliasing, up from ~10%). Causal tile-skipping saves ~50% of work for both: 50% of 18% = 36% effective throughput (enough to beat unfused → 1.36x), but 50% of 12% = 24% (still slower → 0.80x). Even with 2 TGs/core after aliasing, D=128's combination of BQ=16, WM=2 reduction, and stronger NAX baseline keeps it below parity.
Memory: Peak Usage During VJP
The primary motivation for fused backward is memory efficiency at scale:
At L=4096, unfused requires 3.4 GB for backward alone — this is where fused becomes essential. With model weights, optimizer states, and activations competing for memory during training, the 95% reduction determines whether a training run fits in memory or not.
Auto-Dispatch Validation
The auto-dispatch correctly routes short sequences to unfused and long sequences to fused. At the L=1024 threshold boundary:
The 1.00x ratios for L <= 1024 confirm that auto mode is running unfused (both columns execute the same code path). The speedup drop at L=2048 confirms the switch to fused.
References
Note
High Risk
Adds new Metal GPU backward kernels and changes SDPA forward kernels/dispatch to produce and consume logsumexp for fused VJP, which can affect correctness/performance and memory across many attention shapes. Also introduces an environment-controlled auto-dispatch policy, making runtime behavior configuration-dependent.
Overview
Implements a fused FlashAttention backward (VJP) path on Metal by adding new STEEL kernels for
dQanddK/dV, wiring them intoScaledDotProductAttentionVJP::eval_gpu, and running the two kernels concurrently while feeding precomputeddeltaand forwardlogsumexp.Updates SDPA forward kernels to optionally emit
logsumexp(needed for fused backward) and aligns the vector path to STEEL’s log2/exp2domain; adds JIT plumbing/caching for baked constants (e.g.,scale, GQA factor, alignment/causal flags) plus new build targets/headers for the VJP kernels.Introduces an auto-dispatch policy (with
MLX_SDPA_VJP_MODE,MLX_SDPA_VJP_LONG_L_THRESHOLD,MLX_SDPA_VJP_ATTENTION_BYTES_THRESHOLD) to choose fused vs unfused backward based on causal/dense, sequence length, GQA, and an attention-matrix memory ceiling, and expands Python tests + a new benchmark script to validate correctness, unaligned shapes, and memory behavior.Written by Cursor Bugbot for commit 985fdd0. This will update automatically on new commits. Configure here.