6.2 FlashAttention V2 in depth
A deep dive into FlashAttention V2's core improvements over V1
This article unpacks FlashAttention V2’s core improvements over V1: swapping the outer/inner loop order, better work partitioning inside thread blocks, fewer non-matmul ops, and reaching 50–73% of peak FLOPS on A100.
📑 Contents
- 1. Recap: V1 performance bottlenecks
- 2. Improvement 1: swap the loop order
- 3. Improvement 2: fewer non-matmul ops
- 4. Improvement 3: warp-level work partitioning
- 5. Causal Mask optimization
- 6. Full forward algorithm
- 7. Backward-pass optimizations
- 8. Performance analysis and comparison
- Summary
- Self-check list
- References
1. Recap: V1 performance bottlenecks
1.1 V1’s real utilization
FlashAttention V1 only hit 25–40% of peak FLOPS on A100. For an operator that should be compute-bound after IO is cut down, that utilization is weak.
The bottlenecks fall into three buckets (largest impact first):
| 📊 Issue | 📝 Cause | 💡 Impact |
|---|---|---|
| Poor loop order | Outer loop over K/V → O tiles repeatedly read/written, Q tiles reloaded | Extra HBM traffic; no parallelism across Q |
| High non-GEMM share | Softmax rescaling has many elementwise ops | Low Tensor Core utilization |
| Bad warp split axis | V1 splits along K/V across warps; several warps update the same O row and must merge partial Softmax | Frequent inter-warp communication and sync; low parallel efficiency |
1.2 Hardware context
Key A100 numbers:
- FP16 Tensor Core peak: 312 TFLOPS
- HBM bandwidth: 2 TB/s
- SRAM capacity: 192 KB/SM (Shared Memory; up to 164 KB configurable for the user)
- Warp count: each Thread Block can hold multiple Warps (often 4–8)
To keep Tensor Cores busy, the compute pipeline must not stall on non-GEMM work.
2. Improvement 1: swap the loop order
2.1 Problems with V1’s loop structure
In V1 the outer loop walks K/V tiles and the inner loop walks Q tiles. That causes two issues:
- Repeated Q and O traffic: every new K/V tile forces all Q tiles and their O outputs to reload from and write back to HBM
- Limited parallelism: outer K/V tiles are serially dependent through online softmax’s running max/sum, so V1 can only parallelize over batch × heads. When batch and heads are small and sequences are long (long-context settings), you cannot use sequence-dimension parallelism and SM utilization suffers
2.2 V2’s new loop structure
V2 swaps the order to outer over Q tiles, inner over K/V tiles:
Outer loop: for i = 1 to T_r (over Q tiles) — mapped to different Thread Blocks
Load Q_i from HBM into SRAM (once)
Init O_i = 0, m_i = -inf, l_i = 0
Inner loop: for j = 1 to T_c (over K/V tiles)
Load K_j, V_j from HBM into SRAM
Compute S_ij, update stats and O_i (all in SRAM)
Write final O_i back to HBM (once)
2.3 What you gain
That seemingly small swap buys three things:
Gain 1: O is written once at the end
In V1, every K/V tile reads and writes O — HBM accesses ( reads + writes). In V2, O is written once after the inner loop.
Gain 2: the outer loop parallelizes naturally
Different Q tiles are fully independent (each computes its own output rows), so they map straight onto different Thread Blocks for GPU-level parallelism.
Gain 3: Q is loaded once
Each Thread Block owns one Q tile; Q stays in Shared Memory for the whole inner loop.
📌 Key point: V1 put K/V outside because of online softmax’s accumulation (update O’s normalization after each K/V column tile). The cost is repeated O read+write. V2’s finding: loading Q once is better, because cutting O traffic pays more than cutting K/V reads.
3. Improvement 2: fewer non-matmul ops
3.1 The problem: non-GEMM instructions
Besides the two matmuls ( and ), tiled Attention has many elementwise ops:
- Subtract max:
- Exp:
- Row sum:
- Rescaling: multiply by correction factors
These cannot use Tensor Cores — only CUDA Cores — and stall the compute pipeline.
3.2 V2’s fix: delay rescaling
V2 delays Softmax rescaling until after the inner loop and does it once:
V1 (rescale every K/V tile):
V2 (keep an unnormalized accumulator):
Normalize once at the end:
3.3 Quantifying the win
That removes three elementwise ops per inner-loop iteration:
- Denormalize old O: multiply by
- Renormalize old O: divide by
- Normalize the new contribution: divide by
V2 strips those 「2 divides + 1 multiply」 from the inner loop and does one divide at the end. When is large, the savings add up.
4. Improvement 3: warp-level work partitioning
4.1 V1’s plan: split along K/V
In V1, warps inside a Thread Block split the same tile along the K/V axis.
Suppose 4 warps and :
- Warp 0 owns K/V columns 0–31
- Warp 1 owns columns 32–63
- Warp 2 owns columns 64–95
- Warp 3 owns columns 96–127
Each warp only sees part of the columns of , so:
- Row-max / row-sum need a cross-warp merge of local results
- For , several warps accumulate into the same O row and must sync again
That split-K style forces frequent inter-warp sync and shared-memory traffic.
4.2 V2’s plan: split along Q
V2 further splits the Q tile across warps:
Suppose 4 warps and :
- Warp 0 owns Q rows 0–31
- Warp 1 owns rows 32–63
- Warp 2 owns rows 64–95
- Warp 3 owns rows 96–127
Each warp independently computes attention between its Q rows and the full K/V tile. No interference.
4.3 Comparing the two strategies
| ❌ Split warps on K/V (V1) | ✅ Split warps on Q (V2) |
|---|---|
| Warps must merge partial Softmax | Each warp finishes Softmax alone |
| Needs shared memory or shuffle | No inter-warp sync |
| Same O row updated by many warps | Different O rows owned by different warps |
💡 Tip: If you split on K/V, each warp sees only part of the Softmax input and must communicate max and sum across warps — one reason V1 underperformed.
5. Causal Mask optimization
5.1 What Causal Mask does
In autoregressive models (GPT and the like), token may only “see” itself and earlier tokens when generating — position can attend only to . That causality constraint is central to autoregressive LM training: the model must predict the next token from history, not peek at the answer.
In practice this is a lower-triangular mask added to attention scores before Softmax: . Masked positions get , become 0 after , and contribute nothing to Softmax output.
Visually, the attention matrix is only valid on the lower triangle (including the diagonal); the upper triangle is zero. Roughly half the compute is wasted in theory — exactly the structure V2’s block-skip strategy exploits.
5.2 Waste in a naive implementation
Without special handling, masked-out positions still run full GEMM and Softmax — about half the FLOPs wasted.
5.3 V2’s block-level skip
V2 adds block-level Causal Mask optimization:
For Q tile (rows ) and K tile (columns ):
- Fully below the mask ( and , i.e. Q min row ≥ K max col): every position has row ≥ col, fully visible, compute normally
- Fully above the mask (, i.e. Q max row < K min col): every position has row < col, fully invisible, skip the block
- Crossing the diagonal: mixed visible and invisible positions — apply the mask elementwise
Below diagonal (all visible) Diagonal block (partial mask) Above diagonal (all skip)
┌─────────────┐ ┌─────────────┐ ┌─────────────┐
│ ■ ■ ■ ■ ■ ■ │ │ ■ ■ ■ □ □ □ │ │ □ □ □ □ □ □ │
│ ■ ■ ■ ■ ■ ■ │ │ ■ ■ ■ ■ □ □ │ │ □ □ □ □ □ □ │
│ ■ ■ ■ ■ ■ ■ │ │ ■ ■ ■ ■ ■ □ │ │ □ □ □ □ □ □ │
└─────────────┘ └─────────────┘ └─────────────┘
5.4 Speedup
For Causal Attention, block skip cuts about 50% of useless work. Combined with outer-loop Q assignment, each Thread Block only walks K/V up to the diagonal and can exit the inner loop early.
6. Full forward algorithm
6.1 Pseudocode
def flash_attention_v2_forward(Q, K, V, B_r, B_c, causal=False):
N, d = Q.shape
O = zeros(N, d)
L = zeros(N) # logsumexp, for backward
# Outer loop (parallelized across Thread Blocks)
parallel for i in range(0, N, B_r):
Q_i = Q[i:i+B_r] # load into SRAM; stays for whole inner loop
# Init accumulators (SRAM/registers)
m_i = full(B_r, -inf)
l_i = zeros(B_r)
O_tilde = zeros(B_r, d) # unnormalized accumulated output
# Inner loop: walk K/V tiles
kv_end = min(N, (i + B_r)) if causal else N
for j in range(0, kv_end, B_c):
K_j = K[j:j+B_c] # load from HBM
V_j = V[j:j+B_c] # load from HBM
# Local attention scores
S_ij = Q_i @ K_j.T / sqrt(d)
# Apply causal mask (only needed on diagonal-crossing tiles)
# i, j are absolute start coords; when K max col (j+B_c-1) > Q min row (i), this tile crosses the diagonal
if causal and i < j + B_c - 1:
apply_causal_mask(S_ij, i, j, B_r, B_c)
# Update statistics
m_new = max(m_i, rowmax(S_ij))
P_ij = exp(S_ij - m_new[:, None])
l_new = exp(m_i - m_new) * l_i + rowsum(P_ij)
# Update unnormalized output
O_tilde = exp(m_i - m_new)[:, None] * O_tilde + P_ij @ V_j
m_i = m_new
l_i = l_new
# Final normalize and write back
O[i:i+B_r] = O_tilde / l_i[:, None]
L[i:i+B_r] = m_i + log(l_i) # save logsumexp for backward
return O, L
6.2 Key differences from V1
| 📊 Aspect | V1 | V2 |
|---|---|---|
| Outer loop | K/V tiles | Q tiles (parallel) |
| O HBM accesses | ( read + write) | 1 (final write only) |
| Inner-loop normalize | Per-step denorm + renormalize (2 div + 1 mul) | Exp scaling only; one divide at the end |
| Warp split axis | Along K/V (split-K, needs inter-warp comm) | Along Q (each warp owns rows, no comm) |
| Causal opt | None | Block-level skip |
| Saved stats | and | (merged logsumexp) |
7. Backward-pass optimizations
7.1 Save logsumexp instead of separate m and l
V2 saves (row-wise logsumexp) in the forward pass instead of separate and . Two wins:
- Half the statistic storage
- Backward can recover directly:
7.2 Backward parallelism
V2’s backward uses an outer loop over K/V tiles (same as V1 backward, opposite of V2 forward’s Q-outer loop), so and updates need no atomics:
- and are accumulated exclusively by one Thread Block (that block walks all Q tiles)
- must accumulate across Thread Blocks (atomic add or a two-pass scan)
7.3 The D vector in backward
Softmax gradients need , where
That rewrite already appears in the V1 paper (Appendix B.3): the left side reduces a full length- row, and , may not fit in SRAM; the right side only needs a length- dot product, independent of sequence length.
V2’s engineering change: compute in a separate preprocess kernel once for the whole vector, then read it in the main backward loop; V1 computed it tile-by-tile inside that loop.
8. Performance analysis and comparison
8.1 Numbers on A100
| 📊 Seq length | V1 TFLOPS | V2 TFLOPS | V2 util |
|---|---|---|---|
| 1024 | 124 | 196 | 63% |
| 2048 | 136 | 218 | 70% |
| 4096 | 141 | 227 | 73% |
| 8192 | 138 | 222 | 71% |
V2 is about 1.6× faster than V1.
8.2 Versus other implementations
A100-80GB, sequence length 2048, head dim 128:
| 📊 Implementation | Forward | Backward |
|---|---|---|
| PyTorch standard | 1.0x | 1.0x |
| FlashAttention V1 | 2.8x | 2.5x |
| FlashAttention V2 | 4.3x | 3.9x |
| xFormers (cutlass) | 3.1x | 2.8x |
8.3 Memory advantage on long sequences (training activation estimate)
The table estimates total attention-related activation memory in training (intermediate tensors and gradient buffers, rough multi-head config) to show vs scaling — not the cost of a single matrix:
| 📊 Seq length | Standard Attention mem | FlashAttention V2 mem |
|---|---|---|
| 4K | 128 MB | 4 MB |
| 16K | 2 GB | 16 MB |
| 64K | 32 GB (OOM) | 64 MB |
📝 Summary
FlashAttention V2 lifts A100 efficiency from 25–40% to 50–73% with three core changes plus one extra opt:
- Swap loop order: outer over Q tiles (parallel), inner over K/V — less O HBM traffic and natural parallelism
- Delayed normalize (fewer non-GEMM ops): inner loop only does exp scaling, one unified normalize at the end
- Warp-level opt: each warp owns different Q rows — no inter-warp sync or communication
- Extra: Causal block skip: use the triangular mask to skip dead blocks — ~50% less work for Causal Attention
🎯 Self-check list
- Explain why V2 moves the outer loop from K/V to Q
- Explain why O’s HBM accesses drop from to 1
- Describe V2’s warp partitioning and its edge over V1
- Derive correctness of delayed normalize (unnormalized accumulate then divide by equals per-step normalize)
- Sketch the three block-skip cases under Causal Mask
- Explain how logsumexp simplifies backward
- Compare V1 vs V2 TFLOPS utilization on A100