ML Interview Notes
33 min read11 sections
Part 3 · Attention kernels · 03-02

FlashAttention 1 → 2 → 3

Status
SOURCE PINNED
Primary sources
  • vllm/v1/attention/backends/flash_attn.py
  • vllm/vllm_flash_attn/
  • python/sglang/srt/layers/attention/
Edition pins
vllm a556f3f · sglang 7d89325

FlashAttention performs strictly more arithmetic than the textbook attention it replaces — it rescales an accumulator on every inner iteration, and in the backward pass it recomputes the entire score matrix from scratch rather than reading the one it already had. It is also several times faster. That is not a paradox once you put attention on the roofline: the operation lives so far left of the ridge point that FLOPs are effectively free and bytes are the only currency that matters.

§1

The problem

Take one attention head of Llama-3-8B at 8192 tokens of context: $d_h = 128$, bf16, no batching, just one head. §3.1 counted the HBM traffic of the textbook implementation; here is the same count summarised, because the whole chapter hangs off it.

The arithmetic is two matmuls: $QK^\top$ and $PV$, each $2T^2 d_h$ FLOPs, so $4 T^2 d_h = 4 \times 8192^2 \times 128 = 34.36$ GFLOP. The traffic is dominated by one object that does not appear in the arithmetic at all — the $T \times T$ score matrix, $8192^2 \times 2 = 134.2$ MB. A textbook implementation writes $S$, reads it back to softmax it, writes $P$, and reads $P$ back for the second matmul: four passes, 536.9 MB. Add $Q$, $K$, $V$ in and $O$ out (8.4 MB) and the head moves 545.3 MB to do 34.36 GFLOP of work.

63 FLOP/B
naive attention intensity (derived)
295 FLOP/B
H100 SXM bf16 ridge point (§0.4)
4.7×
bandwidth floor over compute floor

At 3.35 TB/s that traffic takes at minimum 162.8 µs. The arithmetic, at 989.4 TFLOP/s, takes 34.7 µs. The kernel is 4.7× slower than its own arithmetic requires, and no amount of tuning the matmuls changes that, because the matmuls were never the problem. Every microsecond of the gap is spent moving a matrix that exists only because someone wrote softmax(Q @ K.T / sqrt(d)) @ V as three statements instead of one.

So the optimisation is not "make attention do less work". It is "make attention never write $S$", and you are allowed to spend arithmetic to buy that. This chapter derives how much, walks the three generations of increasingly sophisticated spending, and then reads the exact call that vLLM and SGLang make into the kernel.

§2

Mental model

Attention is a matmul, a row-wise nonlinearity, and a second matmul. The nonlinearity in the middle is what forces the intermediate to be materialised: softmax over a row needs the whole row before it can normalise any element of it. §3.1 broke that dependency — the online softmax recurrence lets you consume the row in chunks, carrying a running maximum and a running sum, and fix up the partial result as you go. That recurrence is the mathematical precondition for everything below; this chapter is about what you build on top of it.

What you build is a loop that keeps a tile of $Q$, a tile of $K$, a tile of $V$, and a running output accumulator resident in on-chip SRAM and registers, and streams the rest past them. $S$ and $P$ are born inside the tensor-core accumulators, consumed by the softmax, consumed again by the second matmul, and die there. They are never given an address in HBM. The $T \times T$ matrix still conceptually exists; it is just never simultaneously alive.

Figure 1 — the FlashAttention-2 tiling pattern for one head at $T=8192$, $d_h=128$, bf16. Tile dimensions are derived in §3 from the H100 shared-memory and register-file budgets. Shaded cells are the only part of the $T \times T$ score matrix alive at any instant.

FlashAttention tiling of the score matrix A grid representing the 8192 by 8192 score matrix, divided into row blocks of 128 queries and column blocks of 64 keys. One row block is highlighted; within it a single column block is shaded as the live tile. Side panels show the SRAM-resident working set: a 32 KB Q tile, double-buffered 16 KB K and V tiles, a 32 KB register-resident S accumulator and a 64 KB register-resident O accumulator. Score matrix S = QK⊤ — 8192 × 8192, never materialised row block i B_r = 128 q j B_c = 64 key axis → inner loop, 128 KV blocks, streamed query axis → outer loop, 64 row blocks, all parallel Shared memory — 96 KB of 228 KB Q tile 128 × 128 × 2 B32 KB K tile 64 × 128 × 2 B × 2 stages32 KB V tile 64 × 128 × 2 B × 2 stages32 KB Q loaded once per row block. K and V streamed by TMA (FA3) or cp.async (FA2) into a circular buffer. Register file — 96 KB of 256 KB S acc 128 × 64 × 4 B fp3232 KB O acc 128 × 128 × 4 B fp3264 KB m, ℓ 2 × 128 × 4 B1 KB O accumulator lives across the whole inner loop — this is what caps B_r.

Two numbers from that figure carry the chapter. The row block $B_r = 128$ is capped by the fp32 output accumulator, which must stay in registers for the entire inner loop. The column block $B_c = 64$ is what the shared-memory budget and the score accumulator jointly allow. §3 derives both.

§3

First principles: bytes bought with FLOPs

The intensity identity

Write $T$ for sequence length and $d_h$ for head dimension. For one head, forward only, in a 2-byte dtype:

$$ \text{FLOPs} = 4 T^2 d_h, \qquad \text{bytes}_{\text{naive}} \approx 4 \cdot 2 T^2, \qquad \text{bytes}_{\text{compulsory}} = 4 \cdot 2 T d_h $$

The naive count is the four passes over the score matrix; the flash count is $Q$, $K$, $V$ in and $O$ out, each $T \times d_h$. Divide, and the $T^2$ and $d_h$ terms collapse into something startlingly clean:

$$ I_{\text{naive}} \;\approx\; \frac{4T^2 d_h}{8T^2} = \frac{d_h}{2}, \qquad I_{\text{compulsory}} \;=\; \frac{4T^2 d_h}{8 T d_h} = \frac{T}{2} $$

With materialized scores, intensity approaches $d_h/2$ for bf16 as T grows; finite Q/K/V/O traffic makes this an asymptote. The $T/2$ FlashAttention figure counts only compulsory Q/K/V reads and O writes, not actual finite-SRAM tile rereads. Original FlashAttention analyzes HBM access complexity $\Theta(T^2d_h^2/M)$ for SRAM capacity M in its stated regime. Therefore a compulsory-byte crossing at T=590 does not prove the actual kernel becomes compute-bound there. Measure HBM traffic and achieved throughput for the real tile schedule.

One Llama-3-8B head, $T = 8192$, $d_h = 128$, bf16, forward pass, non-causal. All figures derived from the formulas above plus the H100 SXM peaks fixed in §0.4. Nothing measured.
QuantityNaiveFlashAttentionRatio
Matmul FLOPs34.36 G34.36 G1.00×
Extra non-matmul FLOPs0134.2 M
HBM bytes545.3 M8.39 M0.015×
Arithmetic intensity63.0409665×
Bandwidth-bound floor162.8 µs2.5 µs
Compute-bound floor34.7 µs34.7 µs
Achievable floor162.8 µs34.7 µs4.7×

What the extra FLOPs actually are

The forward pass pays for the online softmax. After each KV block $j$, the running maximum may have increased, so the accumulator $\tilde O_i$ (shape $B_r \times d_h$) must be rescaled by $\exp(m^{(j-1)} - m^{(j)})$ elementwise. That is $B_r \cdot d_h$ multiplies per (row block, KV block) pair, and there are $(T/B_r)(T/B_c)$ pairs:

$$ \text{rescale FLOPs} \;=\; \frac{T}{B_r}\cdot\frac{T}{B_c}\cdot B_r d_h \;=\; \frac{T^2 d_h}{B_c} $$

At $T = 8192$, $d_h = 128$, $B_c = 64$ that is 134.2 MFLOP — 0.39% more arithmetic, and the compulsory-traffic comparison suggests up to 98.5% less traffic; actual savings depend on finite-SRAM rereads and caching. That is the trade, quantified, for one head. Note that the $T^2$ exponentials are not extra: naive attention computes exactly the same ones. Only the rescale is new.

The backward pass pays much more, and this is where "more FLOPs" stops being a rounding error. A standard attention backward needs four $T\times T\times d_h$ matmuls ($dV$, $dP$, $dQ$, $dK$) and reads the saved $P$. FlashAttention does not save $P$; it recomputes $S = QK^\top$ and re-softmaxes it from the saved per-row logsumexp, adding a fifth matmul — +25% matmul FLOPs — to avoid storing and reloading $O(T^2)$ bytes per head per layer. The original paper measured that trade as a net win end to end (arXiv:2205.14135, §4: 3× end-to-end speedup training GPT-2 at sequence length 1K, and a 15% wall-clock improvement on BERT-large at sequence length 512 over the MLPerf 1.1 record, on A100 40GB). Inference never runs the backward pass, but the logic is the same one and it is the cleanest statement of the book's central bargain: left of the ridge, recomputation is free.

Deriving the tile sizes from an H100

A thread block owns one row block: $B_r$ queries, all $d_h$ channels, for the whole inner loop over KV. What must be resident?

Shared memory. The $Q$ tile is $2 d_h B_r$ bytes. The $K$ and $V$ tiles are $2 d_h B_c$ each, times $s$ pipeline stages for double buffering. So

$$ \text{SMEM} \;=\; 2 d_h \left( B_r + 2 s B_c \right) \;\le\; 228\ \text{KB} $$

With $d_h = 128$, $s = 2$, and $B_r = B_c = B$: $256(B + 4B) = 1280 B \le 233472$, so $B \le 182$. Tiles must be multiples of the MMA tile granularity (16 rows, and in practice 64 for the wgmma N dimension), so the largest legal square tile is 128. At $B_r = 128$, $B_c = 64$, $s = 2$ the block uses $256 \cdot 128 + 512 \cdot 2 \cdot 64 = 98{,}304$ bytes — 96 KB of the 228 KB an H100 SM offers (§0.3). Shared memory is not the binding constraint at $d_h = 128$.

Registers. This is what actually binds. An H100 SM has 65,536 32-bit registers (256 KB) and a hard 255-register-per-thread ceiling. Take a block of 8 warps (256 threads, two Hopper warpgroups). The fp32 output accumulator is $4 B_r d_h = 65{,}536$ bytes, i.e. 256 bytes or 64 registers per thread. The fp32 score accumulator is $4 B_r B_c = 32{,}768$ bytes, another 32 registers. That is 96 before a single address, operand descriptor, or softmax temporary. Doubling $B_r$ to 256 would put the output accumulator at 128 registers per thread, leaving fewer registers for scores and temporaries but not exhausting the 255-register per-thread ceiling by itself; doubling $B_c$ to 128 costs another 32 and is done only at $d_h = 64$, where the output accumulator halves. The rule of thumb falls straight out:

$$ \frac{4 B_r d_h + 4 B_r B_c}{\text{threads}} \;\lesssim\; 4 \times 200\ \text{bytes} \quad\Longrightarrow\quad B_r (d_h + B_c) \lesssim 51{,}200 $$

At $d_h = 128$, $B_c = 64$: $B_r \le 266$, and after operands and pipeline bookkeeping, 128 is the practical answer. The output accumulator is the reason $B_r$ is 128 and not 512, and the reason it must stay in registers at all is the FA2 loop-order change in §4.

§4

FlashAttention 1, 2, 3

FlashAttention-1: tiling and recomputation

FA1 (arXiv:2205.14135) established the shape. The outer loop runs over KV blocks $j$; the inner loop over row blocks $i$. For each $(i,j)$ the kernel loads $Q_i$ and the current $O_i$, $m_i$, $\ell_i$ from HBM, computes $S_{ij} = Q_i K_j^\top$, applies the online softmax update, and writes $O_i$, $m_i$, $\ell_i$ back to HBM. Parallelism is one thread block per (batch, head) pair.

Both of those choices turned out to be wrong, and FA2 fixed both.

FlashAttention-2: three changes, each with a named bottleneck

(a) Fewer non-matmul FLOPs. FA1 divided by the running sum on every iteration:

$$O_i \leftarrow \mathrm{diag}(\ell^{(j)})^{-1}\left(\mathrm{diag}(\ell^{(j-1)})e^{m^{(j-1)}-m^{(j)}} O_i + e^{S-m^{(j)}}V_j\right).$$

FA2 keeps the accumulator unnormalised — $\tilde O_i \leftarrow e^{m^{(j-1)}-m^{(j)}}\tilde O_i + \tilde P_{ij}V_j$ — and divides by $\ell$ exactly once, after the inner loop ends. It also stores only the logsumexp $L = m + \log \ell$ instead of $m$ and $\ell$ separately. Small-looking, but these are non-matmul FLOPs, and the FA2 paper (arXiv:2307.08691, §3.1) prices them at the A100 ratio: 312 TFLOP/s of bf16 matmul against 19.5 TFLOP/s of FP32 non-matmul, so each non-matmul FLOP costs 16 matmul FLOPs of time. At 0.39% of the arithmetic and a 16× price, the rescale is worth about 6% of the matmul time — worth attacking, and worth overlapping entirely, which is what FA3 does.

(b) Parallelise over sequence length. FA1's grid is $\text{batch} \times \text{heads}$. For Llama-3-8B ($h = 32$) at batch 1 that is 32 thread blocks on a 132-SM H100 — 100 SMs idle, 24% of the machine. FA2 swaps the loops so the row-block loop is outermost, and makes it a grid dimension: $\text{batch} \times \text{heads} \times \lceil T/B_r \rceil$. At $T = 8192$, $B_r = 128$ that is $1 \times 32 \times 64 = 2048$ blocks, 15.5 full waves. This is the change that made long-context, small-batch attention actually use the GPU.

(c) Better warp partitioning. Inside a block, FA1 split $K$ and $V$ across the four warps and shared $Q$ ("split-K"). Every warp then held a partial slab of $S$ covering a subset of the key axis for all the rows — which means no warp owns a complete softmax row. To normalise, all four warps must write their partials to shared memory, `__syncthreads`, and reduce. FA2 splits $Q$ across warps and shares $K$ and $V$ ("split-Q"). Each warp now owns a disjoint set of query rows end to end: its own $S$ rows, its own $m$ and $\ell$, its own $O$ rows. No cross-warp softmax reduction is needed for those owned query rows. Shared-memory tile staging and pipeline synchronization can still require barriers.

Figure 2 — the FA1 → FA2 restructuring. Loop order, grid shape, and warp partitioning all change together; each fixes a different bottleneck. Ctrl/Cmd + wheel to zoom · drag to pan · double-click to fit

Loading…

The cited result: FA2 is roughly 2× FA1, reaching 50–73% of theoretical peak on A100 80GB SXM4 and up to 225 TFLOP/s of model FLOPs utilisation on GPT-style training (arXiv:2307.08691, abstract and §4).

FlashAttention-3: why Hopper changes the problem

FA2 on an H100 leaves most of the machine on the table, and the reason is the softmax. Count it. Exponentials execute on the SM's special-function units, not the tensor cores. Assuming the standard 16 MUFU lanes per SM at 1.755 GHz across 132 SMs, the whole GPU sustains $132 \times 16 \times 1.755 \times 10^9 = 3.71 \times 10^{12}$ exponentials per second — 267× below the 989.4 TFLOP/s bf16 matmul rate. Our head needs $T^2 = 67.1$M exponentials, which is $67.1\times10^6 / 3.71\times10^{12} = 18.1$ µs against 34.7 µs of matmul.

Derived

18.1 µs of softmax against 34.7 µs of matmul, for one head at $T=8192$. Run them back to back and the kernel takes 52.8 µs; overlap them perfectly and it takes 34.7 µs. That upper bound is 1.52×, which is the same order as the 1.5–2.0× over FA2 that the FA3 paper reports measuring on H100 SXM5 (arXiv:2407.08608). The SFU lane count is an assumption, not something I read out of a vendor table — treat the 267× as an order of magnitude, not a spec.

Overlapping them requires hardware that FA2's programming model does not have. Hopper supplies four pieces, and FA3 uses all four:

wgmma

Asynchronous warpgroup MMA

A warpgroup (4 warps, 128 threads) issues one wgmma.mma_async that reads operands directly from shared memory and accumulates into registers. Because it is asynchronous, the issuing threads keep running while the tensor core works. Without async MMA there is nothing to overlap the softmax with.

TMA

Tensor Memory Accelerator

A dedicated copy engine. One thread hands it a multi-dimensional tensor descriptor and it moves a whole tile from global memory to shared memory, doing all address generation and bounds handling in hardware. FA2 spent registers and instruction slots on cp.async address arithmetic in every thread; TMA returns those to the compute path.

mbarrier

Async transaction barriers

Shared-memory barriers that count bytes arrived, not just threads. This is what lets a producer warpgroup say "tile j is in buffer 2" and a consumer wait on exactly that, rather than everyone meeting at a __syncthreads.

setmaxnreg

Warpgroup register reallocation

A warpgroup can voluntarily shrink its per-thread register allocation and donate the difference to its peers. The producer warpgroup needs almost no registers — it issues TMA descriptors — so it gives them to the consumers, whose accumulators are the constraint derived in §3.

On top of that machinery FA3 layers three techniques.

Warp specialisation. The thread block is split by role. One producer warpgroup does nothing but issue TMA loads of $K_j$ and $V_j$ into a circular shared-memory buffer and signal an mbarrier. Two consumer warpgroups do nothing but wait on the barrier, issue wgmma, and run the softmax. This is not the same thing as double buffering: the producer runs a different instruction stream, so a stall in the memory path never occupies an issue slot the tensor cores wanted.

Ping-pong scheduling. With two consumer warpgroups, FA3 offsets them by half a stage using named barriers, so that while warpgroup 0 is in its softmax (SFU-bound), warpgroup 1 is in its wgmma (tensor-core-bound), and then they swap. Both units stay busy. Inside a single warpgroup, FA3 additionally pipelines two stages deep: the wgmma for KV block $j+1$ is issued before the softmax of block $j$ has finished, because wgmma is asynchronous and its accumulator is a different register set.

Figure 3 — FA3's producer/consumer pipeline as a timeline. Schematic, not measured: the point is which hardware unit is occupied when. The FA2 row shows the same work serialised, which is what leaves the tensor cores idle during every softmax.

FlashAttention-3 ping-pong warpgroup timeline A timeline with four rows. The FA2 row alternates GEMM and softmax on one warpgroup so the tensor core idles during softmax. Below, the FA3 rows show a producer warpgroup issuing TMA loads continuously, and two consumer warpgroups offset by half a stage so that one runs GEMM while the other runs softmax, keeping both the tensor core and the special function unit busy. FlashAttention-2 — one warpgroup, serialised warpgroup GEMM jsoftmax GEMM j+1softmax GEMM j+2softmax GEMM j+3softmax tensor core idle 37% of the time FlashAttention-3 — warp-specialised, ping-pong producer consumer 0 consumer 1 TMA jTMA j+1TMA j+2 TMA j+3TMA j+4TMA j+5 TMA j+6TMA j+7 copy engine, ~0 registers GEMM jsoftmax GEMM j+2softmax GEMM j+4softmax GEMM j+6 softmaxGEMM j+1 softmaxGEMM j+3 softmaxGEMM j+5 Shaded = tensor core busy (wgmma). Unshaded = special function unit busy (exp2). Consumers are offset by half a stage via named barriers, so at every instant one warpgroup is in a GEMM and the other is in a softmax. Neither unit idles. Producer stays a full circular-buffer depth ahead; mbarrier counts bytes, not threads.

FP8 with in-kernel scaling. The third technique is numerical. FP8 e4m3 (§0.5) doubles tensor-core throughput but has 3 mantissa bits, and attention inputs have outliers. FA3 handles two separate problems. The layout problem: FP8 wgmma requires both operands contiguous along the reduction dimension, which $V$ is not, so the kernel transposes $V$ in shared memory with LDSM/STSM and hides that under the async copies. The accuracy problem: block-level quantisation (a scale per tile rather than per tensor) plus incoherent processing — multiplying $Q$ and $K$ by a random orthogonal matrix, which leaves $QK^\top$ unchanged but spreads outliers across channels. The paper reports 2.6× lower RMS error than a per-tensor FP8 baseline, and up to 1.2 PFLOP/s on H100 SXM5 (arXiv:2407.08608, abstract and §4.3). The paper's per-tile scaling must not be equated with every runtime descale interface: inspect the q_descale/k_descale/v_descale shapes and kernel path, which may use per-sequence/head scales.

The inference caveat

Everything above assumes a long query axis. FA2's central win — parallelising over $\lceil T_q / B_r\rceil$ row blocks — evaporates when $T_q = 1$. In decode the grid collapses back to $\text{batch} \times \text{heads}$, exactly FA1's shape: 32 blocks for Llama-3-8B at batch 1, on 132 SMs. Worse, a $B_r = 128$ row block with one live row wastes 99.2% of its own tile. FlashAttention was designed for training-shaped attention and the decode phase is a different problem, solved by splitting the key axis instead and merging partial softmaxes. That is FlashDecoding and split-K, and it is §3.3. The hook is already visible in the code below: the num_splits argument.

§5

How production systems call it

Neither engine writes attention kernels here. Both call the same family of CUDA kernels through a thin Python wrapper, and the interesting engineering is in what they pass. As of a556f3f, vLLM has deleted its own PagedAttention CUDA kernel (commit d715b3aa1e) and vendors FlashAttention under vllm/vllm_flash_attn/.

The signature, argument by argument

vllm/vllm_flash_attn/flash_attn_interface.py:L176-L207 vLLM
def flash_attn_varlen_func(
    q,
    k,
    v,
    max_seqlen_q,
    cu_seqlens_q,
    max_seqlen_k,
    cu_seqlens_k=None,  # only used for non-paged prefill
    seqused_k=None,
    q_v=None,
    dropout_p=0.0,
    softmax_scale=None,
    causal=False,
    window_size: list[int] | None = None,
    softcap=0.0,  # 0.0 means deactivated
    alibi_slopes=None,
    deterministic=False,
    return_attn_probs=False,
    block_table=None,
    return_softmax_lse=False,
    out=None,
    # FA3 Only
    scheduler_metadata=None,
    q_descale=None,
    k_descale=None,
    v_descale=None,
    num_splits: int = 0,
    # FA4 Only
    output_scale=None,
    # Version selector
    fa_version: int = DEFAULT_FA_VERSION,
    s_aux=None,

Map that onto §3, one argument at a time.

Every argument of flash_attn_varlen_func against the mathematics it implements. Shapes quoted from the docstring at vllm/vllm_flash_attn/flash_attn_interface.py:L240-L268.
ArgumentShape / typeWhat it is in §3
q(total_q, nheads, headdim)All query tokens of the batch, flattened into one ragged axis. The kernel's row blocks $B_r$ tile this axis.
k, v(total_k, nheads_k, headdim), or the paged cacheStreamed in $B_c$-sized column blocks. nheads_k < nheads is GQA: "head 0, 1, 2 of Q will attention to head 0 of K, V".
cu_seqlens_q(batch+1,) int32Prefix sums of per-request query lengths. This is what makes the call varlen: instead of a padded [B, T, H, D] tensor, requests are concatenated and the kernel derives each request's row range from the offsets. No padding means no wasted row blocks.
cu_seqlens_k(batch+1,) int32Same for keys — used only when K/V are contiguous, i.e. non-paged prefill.
seqused_k(batch,) int32Per-request KV length. Replaces cu_seqlens_k when the KV is paged, because paged KV has no contiguous offsets to prefix-sum.
block_table(batch, max_blocks) int32The PagedAttention indirection (§2.2), absorbed into FlashAttention's inner loop. Instead of walking $K$ linearly, KV block $j$ is fetched from physical page block_table[req, j].
softmax_scalefloatThe $1/\sqrt{d_h}$ in $\mathrm{softmax}(QK^\top/\sqrt{d_h})$. Folded into the score tile before the max is taken, so it costs nothing extra.
causalboolSkips whole KV blocks past the diagonal — for a row block this halves the inner loop. Alignment is bottom-right, which is the only correct choice for cached prefixes (see below).
window_size(left, right)Sliding-window attention: bounds the inner loop on both sides instead of one. §3.6.
num_splitsintSplit-K over the key axis. 0 means let the kernel's heuristic decide. This is the decode-phase escape hatch — §3.3.
scheduler_metadataopaque tensorFA3's ahead-of-time tile schedule, computed once per step on the host side rather than by the kernel's persistent scheduler.
q_descale, k_descale, v_descale(num_seqs, num_kv_heads)FA3's in-kernel FP8 scales from §4. Applied inside the kernel so the FP8 tensors go straight into wgmma.
return_softmax_lseboolReturns $L = m + \log\ell$ per row — the object that lets two partial attention outputs be merged. Cascade attention and split-K both need it.

The causal alignment detail is worth pausing on, because getting it wrong is a silent correctness bug in any prefix-caching engine. From the same docstring:

vllm/vllm_flash_attn/flash_attn_interface.py:L224-L234 vLLM
    If causal=True, the causal mask is aligned to the bottom right corner of the attention matrix.
    For example, if seqlen_q = 2 and seqlen_k = 5, the causal mask (1 = keep, 0 = masked out) is:
        1 1 1 1 0
        1 1 1 1 1
    If seqlen_q = 5 and seqlen_k = 2, the causal mask is:
        0 0
        0 0
        0 0
        1 0
        1 1
    If the row of the mask is all zero, the output will be zero.

Chunked prefill and prefix caching both produce exactly the first case: $q$ new tokens against $C + q$ cached-plus-new keys. Bottom-right alignment gives each new token its full cached context plus the new tokens up to itself. Top-left alignment would silently truncate every request that ever hit the prefix cache.

vLLM's call site

vllm/v1/attention/backends/flash_attn.py:L1123-L1145 vLLM
                flash_attn_varlen_func(
                    q=query[:num_actual_tokens],
                    k=key_cache,
                    v=value_cache,
                    out=output[:num_actual_tokens],
                    cu_seqlens_q=cu_seqlens_q,
                    max_seqlen_q=max_seqlen_q,
                    seqused_k=seqused_k,
                    max_seqlen_k=max_seqlen_k,
                    softmax_scale=self.scale,
                    causal=causal,
                    alibi_slopes=self.alibi_slopes,
                    window_size=sliding_window_size,
                    block_table=block_table,
                    softcap=self.logits_soft_cap,
                    scheduler_metadata=scheduler_metadata,
                    fa_version=self.vllm_flash_attn_version,
                    q_descale=q_descale,
                    k_descale=k_descale,
                    v_descale=v_descale,
                    dynamic_causal=dynamic_causal,
                    num_splits=attn_metadata.max_num_splits,
                    s_aux=self.sinks,

Note what is absent: cu_seqlens_k. vLLM always passes the paged cache, so it supplies seqused_k instead — and the wrapper enforces exactly that pairing at vllm/vllm_flash_attn/flash_attn_interface.py:L276-L278: assert block_table is None or seqused_k is not None.

Every one of those arguments is read out of a metadata struct built once per forward pass:

vllm/v1/attention/backends/flash_attn.py:L256-L265 vLLM
    num_actual_tokens: int  # Number of tokens excluding padding.
    max_query_len: int
    query_start_loc: torch.Tensor
    max_seq_len: int
    seq_lens: torch.Tensor
    block_table: torch.Tensor
    slot_mapping: torch.Tensor

query_start_loc becomes cu_seqlens_q; seq_lens becomes seqused_k. And self.scale traces back to the model definition — vllm/model_executor/models/llama.py:L159 is self.scaling = self.head_dim**-0.5, which for Llama-3-8B is $128^{-1/2} = 0.0884$.

Version selection, and where the engines diverge

vllm/v1/attention/backends/fa_utils.py:L91-L100 vLLM
        # 1. default version depending on platform
        if device_capability.major == 9 and is_fa_version_supported(3):
            # Hopper (SM90): prefer FA3
            fa_version = 3
        elif device_capability.major == 10 and is_fa_version_supported(4):
            # Blackwell (SM100+, restrict to SM100 for now): prefer FA4
            fa_version = 4
        else:
            # Fallback to FA2
            fa_version = 2

FA3 is gated hard to SM90 — vllm/vllm_flash_attn/flash_attn_interface.py:L62-L69 returns "FA3 is only supported on devices with compute capability 9.x". Everything in §4 about warp specialisation, TMA, and ping-pong exists only on Hopper, and on an A100 vLLM silently runs FA2. The rest of get_flash_attn_version is a list of demotions: ALiBi forces FA2, batch-invariant mode can demote FA4 to FA2 but does not universally demote FA3, head sizes over 128 on Blackwell force FA2.

SGLang reaches the same kernels but with a structurally different policy. Its FlashAttentionBackend has no FA2 path at all:

python/sglang/srt/layers/attention/flashattention_backend.py:L260-L300 SGLang
        # Select version
        self.fa_impl_ver = fa_impl_ver
        device_capability = get_device_capability()
        if self.fa_impl_ver == 3:
            from sgl_kernel.flash_attn import (
                flash_attn_varlen_func,
                flash_attn_with_kvcache,
                get_scheduler_metadata,
            )
# ...
        elif self.fa_impl_ver == 4:
# ...
        else:
            raise ValueError(f"Invalid version: {self.fa_impl_ver=}")

The backend is registered twice, as "fa3" and "fa4" (python/sglang/srt/layers/attention/attention_registry.py:L209-L241), and the default chooser picks it only on Hopper:

python/sglang/srt/server_args.py:L5925-L5934 SGLang
        if not use_mla_backend:
            # MHA architecture

            if is_hopper_with_cuda_12_3() and is_no_spec_infer_or_topk_one(
                resolved_view(self)
            ):
                # Note: flashinfer 0.6.1 caused performance regression on Hopper attention kernel
                # Before the kernel is fixed, we choose fa3 as the default backend on Hopper MHA
                # ref: https://github.com/sgl-project/sglang/issues/17411
                return "fa3"
The divergence

Same kernels, opposite defaults off Hopper. vLLM treats FlashAttention as the backend and degrades the version (FA3 → FA2) when hardware or features demand it. SGLang treats FA3/FA4 as Hopper/Blackwell specialists and, on Ampere, routes to FlashInfer or Triton instead — see the else arms of the same function. Neither is wrong: vLLM optimises for one code path that runs everywhere, SGLang for the best kernel per architecture at the cost of more backends to maintain. Backend selection proper is §3.4.

They also use different entry points. vLLM funnels everything — prefill, decode, chunked prefill — through flash_attn_varlen_func with a block_table. SGLang calls flash_attn_varlen_func only when the KV is not paged (python/sglang/srt/layers/attention/flashattention_backend.py:L1479-L1494), and otherwise calls flash_attn_with_kvcache with page_table= and cache_seqlens=:

python/sglang/srt/layers/attention/flashattention_backend.py:L1496-L1514 SGLang
                result = flash_attn_with_kvcache(
                    q=q.contiguous().view(-1, layer.tp_q_head_num, layer.head_dim),
                    k_cache=key_cache,
                    v_cache=value_cache,
                    page_table=page_table,
                    cache_seqlens=cache_seqlens,
                    cu_seqlens_q=cu_seqlens_q,
                    cu_seqlens_k_new=cu_seqlens_k if not use_local_attn else None,
                    max_seqlen_q=max_seqlen_q,
                    softmax_scale=layer.scaling,
                    causal=False if use_cascade_attn else causal,
                    window_size=window_size,
                    softcap=layer.logit_cap,
                    return_softmax_lse=use_cascade_attn,
                    num_splits=self.num_splits,
                    out=_fa_out,
                    ver=self.fa_impl_ver,
                    **kwargs,
                )

page_table/cache_seqlens are the same two tensors as vLLM's block_table/seqused_k under different names, and ver= is the same selector as fa_version=. The kernels underneath are the same Dao-AILab CUDA.

§6

Worked trace: one prefill through the stack

One Llama-3-8B request, 8192 prompt tokens, no prefix cache hit, on an H100, layer 0, through vLLM.

  1. FlashAttentionMetadataBuilder.build() (vllm/v1/attention/backends/flash_attn.py:L538-L740) runs once per forward pass. It sets query_start_loc = [0, 8192], seq_lens = [8192], max_query_len = 8192, and block_table to the 8192/16 = 512 physical pages the block manager allocated.
  2. Because get_flash_attn_version() == 3 on H100, self.aot_schedule is True (vllm/v1/attention/backends/flash_attn.py:L447), so build() calls _get_scheduler_metadata(), which calls into torch.ops._vllm_fa3_C.get_scheduler_metadata (vllm/vllm_flash_attn/flash_attn_interface.py:L147-L172) with the batch shape, head counts, head dim, page size, and causal flag. FA3's persistent tile scheduler is thereby computed on the host and handed to the kernel as a tensor, instead of being derived per-block on device — which is what makes the kernel CUDA-graph capturable.
  3. FlashAttentionImpl.forward() unpacks the KV cache. The cache is stored as (num_blocks, num_kv_heads, block_size, 2 * head_size) (vllm/v1/attention/backends/flash_attn.py:L141-L151) — K and V packed into one contiguous content dimension — and split at flash_attn.py:L983-L984: key_cache, value_cache = kv_cache.transpose(1, 2).split(self.head_size, dim=-1).
  4. Descale tensors are built with .expand() rather than materialised: descale_shape = (cu_seqlens_q.shape[0] - 1, self.num_kv_heads) at flash_attn.py:L1016. For bf16 KV they are the scalar 1.0 broadcast to $(1, 8)$; for FP8 KV they are the per-sequence per-head scales that §4's in-kernel scaling consumes.
  5. flash_attn_varlen_func(..., fa_version=3) dispatches to torch.ops._vllm_fa3_C.fwd (vllm/vllm_flash_attn/flash_attn_interface.py:L349-L385). Note the positional None, # pack_gqa — FA3 can pack the 4 query heads that share each KV head into one row block, filling $B_r$ with (position, head) pairs rather than positions alone; passing None lets the kernel heuristic decide.
  6. On device: the logical query tiling has $1\times32\times\lceil8192/128\rceil=2048$ tiles; persistent launch scheduling and GQA packing can change the actual grid. Each loads its 32 KB $Q$ tile, then runs 128 iterations of the inner loop over 64-key blocks — halved by causal=True for the average row block, so about 65 iterations for 128-row query tiles against 64-key tiles (ends cover 2,4,...,128 key tiles). The coarse half-of-128 estimate is 64, not 32. Each iteration: producer TMA-loads $K_j, V_j$; consumers wgmma $Q_i K_j^\top$, exp2, rescale the register-resident $\tilde O_i$, wgmma $\tilde P_{ij} V_j$. Nothing of size $T \times T$ is ever addressed in HBM.
  7. After the loop, divide by $\ell$ once, write $O_i$ (32 KB) back through out=, which vLLM pre-allocated so the kernel writes in place and the attention layer does no extra copy.
§7

Pitfalls and war stories

Passing both cu_seqlens_k and seqused_k. The wrapper asserts: "cu_seqlens_k and seqused_k cannot be provided at the same time" (vllm/vllm_flash_attn/flash_attn_interface.py:L269-L275). Writing a new backend, the natural instinct is to fill in everything you know. The two arguments describe mutually exclusive KV layouts and the kernel picks its addressing path from which one is present.

Silently getting FA2 when you expected FA3. get_flash_attn_version demotes on ALiBi, on applicable version-specific batch-invariant restrictions, and on unsupported head sizes. The only signal is a logger.info_once("Using FlashAttention version %s", ...) at vllm/v1/attention/backends/flash_attn.py:L868-L871. Grep the log for that line before profiling anything.

Degenerate strides break TMA. A real one, and a good illustration of how §4's hardware leaks into Python. From vllm/v1/attention/backends/flash_attn.py:L985-L987: "Fix degenerate strides on size-1 dims (e.g. num_kv_heads=1 with TP). FA3/4 on H100+ uses TMA, which requires ≥16-byte stride alignment." When tensor parallelism reduces a GQA model to one KV head per rank, PyTorch reports an arbitrary stride for that size-1 dimension, and TMA's descriptor validation rejects it. vLLM canonicalises the strides before the call.

FP8 KV requires FA3 or FA4, and never e5m2. flash_attn_supports_kv_cache_dtype (vllm/v1/attention/backends/fa_utils.py:L223-L243) returns False immediately for fp8_e5m2, and otherwise requires FA3-on-SM90 or FA4-on-SM100. Setting --kv-cache-dtype fp8_e5m2 on Hopper does not get you a faster kernel; it gets you a different backend.

Expecting FA2's parallelism at decode. Covered in §4 — if you benchmark decode-phase attention and find the GPU 24% occupied, that is not a bug, that is $T_q = 1$. §3.3.

§8

Hands-on

Confirm which version your deployment actually runs, then force the other one and compare:

shell shell
# What version did vLLM pick? Look for "Using FlashAttention version N".
VLLM_LOGGING_LEVEL=INFO vllm serve meta-llama/Llama-3.1-8B-Instruct \
  --max-model-len 8192 2>&1 | grep -i "FlashAttention version"

# Request FA2 explicitly; confirm acceptance and selected version in the log.
# The config field read at vllm/v1/attention/backends/fa_utils.py:L102-L110 is attention_config.flash_attn_version.
vllm bench latency --model meta-llama/Llama-3.1-8B-Instruct \
  --input-len 8192 --output-len 1 --batch-size 1 \
  --attention-config '{"flash_attn_version": 2}'

# SGLang: name the backend explicitly rather than relying on the Hopper default.
python -m sglang.launch_server --model-path meta-llama/Llama-3.1-8B-Instruct \
  --attention-backend fa3

Read vllm/v1/attention/backends/fa_utils.py top to bottom while you do it — 305 lines, and the single most useful file in the repo for understanding why your kernel is the kernel it is. The --input-len 8192 --output-len 1 shape isolates prefill, the regime this chapter describes; flip to --input-len 128 --output-len 512 and you are measuring §3.3 instead.

§9

Exercises

  1. Redo the §3 intensity identity for MLA-shaped attention with $d_h = 576$ for Q/K and $d_h^V = 512$. Does naive attention's intensity still sit left of the H100 ridge?
  2. Using the shared-memory constraint $2 d_h (B_r + 2 s B_c) \le 228$ KB, find the largest square tile for $d_h = 64$ with 3 pipeline stages. Then check it against the register constraint and say which binds.
  3. Read vllm/v1/attention/backends/fa_utils.py and list every condition under which a Hopper H100 running a standard GQA model would not get FA3.
  4. Predict: you serve Llama-3-8B on an H100 at batch 1 with a 128k context. FA2's grid is $\text{batch}\times\text{heads}\times\lceil T/B_r\rceil$ during prefill. How many waves is that over 132 SMs, and does adding a second concurrent request help prefill throughput? Then verify by reasoning about what query_start_loc looks like for a two-request batch.
  5. SGLang's flash_attn_with_kvcache takes cu_seqlens_k_new where vLLM's flash_attn_varlen_func takes seqused_k. Read python/sglang/kernels/ops/attention/flash_attention.py and explain what cu_seqlens_k_new is for and why vLLM does not need it.
Answer 1

$I_{\text{naive}} \approx d_h/2$ uses a single $d_h$; with asymmetric head dims the score matrix is still $T \times T$ and the FLOPs become $2T^2 d_h^{QK} + 2T^2 d_h^{V} = 2T^2(576+512)$, so $I \approx 2T^2 \cdot 1088 / (8T^2) = 272$ FLOP/byte. That is still below the 295 ridge, though only just — which is a good illustration of why MLA's real win is KV footprint (§3.5) rather than attention arithmetic intensity.

Answer 2

$2\cdot 64\cdot(B + 6B) = 128 \cdot 7B = 896B \le 233472 \Rightarrow B \le 260$, so shared memory permits 256. Registers: $4 B_r d_h + 4 B_r B_c = 4\cdot256\cdot64 + 4\cdot256\cdot256 = 65{,}536 + 262{,}144 = 327{,}680$ bytes, which exceeds the SM's entire 256 KB register file. Registers bind, hard. Even $B_r = B_c = 128$ costs $4\cdot128\cdot64 + 4\cdot128\cdot128 = 98{,}304$ bytes = 96 registers per thread at 256 threads, which is the practical answer.

Answer 3

From get_flash_attn_version: FA3 not built into the wheel (FA3_AVAILABLE False); an explicit attention_config.flash_attn_version override; ALiBi slopes present; and the FA3→FA4 upgrade paths, which take you off FA3 for head sizes above 256 on SM90, for diff-KV with sinks, and for diffusion models needing per-sequence causal. Batch-invariant mode demotes FA4 to FA2 but not FA3.

Answer 4

$1 \times 32 \times \lceil 131072/128\rceil = 32{,}768$ blocks, i.e. 248 waves over 132 SMs. Prefill at that length is already thoroughly compute-bound and fully occupied; a second request doubles the work and roughly doubles the time, so per-request prefill throughput does not improve. query_start_loc would be [0, 131072, 262144] and the grid would simply double — there is no idle capacity for it to fill. This is precisely why chunked prefill exists: the win is latency scheduling, not occupancy.

Answer 5

flash_attn_with_kvcache can append new K/V into the cache inside the kernel — the docstring says "k_cache and v_cache will be updated inplace with the new values from k and v" — so cu_seqlens_k_new describes the ragged batch of newly arriving keys. vLLM does the cache write in a separate step (reshape_and_cache_flash, imported at fa_utils.py:L19-L24) before calling attention, so by the time the kernel runs, all keys are already in the cache and only seqused_k is needed.

§10

Key takeaways

  • Naive materialized-attention intensity approaches d_h/2 in bf16. FlashAttention avoids the quadratic score intermediate, but its actual IO still depends on SRAM, tile rereads, and caching; T/2 is a compulsory-traffic ideal, not a guaranteed achieved intensity or a universal 590-token compute-bound threshold.
  • The extra arithmetic is real but tiny in the forward ($T^2 d_h / B_c$ rescale multiplies, 0.39% at $T=8192$) and substantial in the backward (+25% matmul FLOPs to recompute $S$). Both are the right trade because the operation is bandwidth-bound in the first place.
  • Tile sizes are not tuned constants, they are the solution to two inequalities. Shared memory gives $2d_h(B_r + 2sB_c) \le 228$ KB; the register file gives $B_r(d_h + B_c) \lesssim 51{,}200$ for a 256-thread block. At $d_h=128$ the fp32 output accumulator binds, and that is why $B_r = 128$.
  • FA2's three changes each name a bottleneck: deferred normalisation (non-matmul FLOPs cost 16× on tensor-core hardware), sequence-length parallelism (32 blocks on 132 SMs becomes 2048), and split-Q warp partitioning (removes a shared-memory reduction from the inner loop).
  • FA3 is not an algorithm change, it is a hardware-utilisation change. Softmax exponentials run on the SFU at roughly 1/267th the tensor-core rate, so on one $T=8192$ head they cost 18.1 µs against 34.7 µs of matmul. Warp specialisation, TMA, and ping-pong scheduling exist to make those two numbers overlap instead of add.
  • Every symbol in the derivation has a name in the call: $B_r$ tiles cu_seqlens_q's axis, $B_c$ walks block_table, $1/\sqrt{d_h}$ is softmax_scale, the FP8 tile scales are {q,k,v}_descale, and the split-K escape hatch for decode is num_splits.
§11

Further reading

The Inference Engineering Course · code read at vllm@a556f3fccb and sglang@7d893255c3, pinned 2026-08-21. See SOURCES for the full provenance record.

Explore the library

Reading preferences

Appearance
18 px