ML Interview Notes
38 min read10 sections
Part 3 · Attention kernels · 03-01

Naive attention and the online softmax

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

Write attention the way the paper writes it and a single Llama-3-8B layer at 32k context asks the allocator for 64 GiB. The fix is not a smaller matrix — it is never building the matrix at all, which requires computing a softmax before you have seen all of its inputs. This chapter derives the recurrence that makes that legal, proves it exact, and then finds it sitting in production CUDA in both engines.

§1

The problem

Here is textbook scaled dot-product attention, for one layer of Llama-3-8B, with exact shapes. $T$ is the number of tokens in the sequence, $h = 32$ query heads, $h_{kv} = 8$ key/value heads (grouped-query, so each KV head serves four query heads), $d_h = 128$ the head dimension, all tensors fp16 ($b = 2$ bytes).

$$S = \frac{QK^{\top}}{\sqrt{d_h}} \in \mathbb{R}^{h \times T \times T}, \qquad P = \mathrm{softmax}_{\text{row}}(S) \in \mathbb{R}^{h \times T \times T}, \qquad O = PV \in \mathbb{R}^{h \times T \times d_h}.$$

$Q$ is $[h, T, d_h]$; $K$ and $V$ are $[h_{kv}, T, d_h]$ and are broadcast four ways. The offending object is $S$. It is $[h, T, T]$ — quadratic in sequence length and linear in head count, and it does not depend on $d_h$ at all, so it does not shrink when the model gets narrower. At $T = 8192$:

$$|S| = h \cdot T^{2} \cdot b = 32 \times 8192^{2} \times 2 = 4{,}294{,}967{,}296 \text{ bytes} = 4.00 \text{ GiB.}$$

Derived. Four gibibytes, for one layer, one sequence, one intermediate tensor that is thrown away microseconds later. And PyTorch's softmax is not in-place, so $P$ is a second 4.00 GiB allocation living at the same time. Double the context to 32,768 and the quadratic bites: $32 \times 32768^{2} \times 2 = 64.0$ GiB for $S$ alone. On an 80 GB H100 that already holds 15 GB of Llama-3-8B weights, a single 32k-token sequence cannot be prefilled by the textbook algorithm at all — not slowly, not at all.

4.00 GiB
S per layer, T=8192, fp16 (derived)
64.0 GiB
S per layer at T=32768 (derived)
63.4
FLOP/byte if S is materialised (derived)
6554
FLOP/byte if it is not (derived)

Capacity is the loud failure. The quiet one is worse. Count the HBM traffic of the three kernels a naive implementation launches, at $T = 8192$, one layer, fp16. Compulsory traffic only — each distinct byte crossing the HBM boundary once, the convention fixed in §0.4.

Derived — HBM traffic of textbook attention, Llama-3-8B, one layer, T = 8192, fp16, no causal masking
KernelReadsWritesBytes
1. S = Q @ K.TQ (64 MiB), K (16 MiB)S (4.00 GiB)4.078 GiB
2. P = softmax(S)S (4.00 GiB)P (4.00 GiB)8.000 GiB
3. O = P @ VP (4.00 GiB), V (16 MiB)O (64 MiB)4.078 GiB
Total16.156 GiB
Of which is Q, K, V, O160 MiB (0.97%)

Ninety-nine percent of the bytes are an intermediate. The FLOPs are unchanged by any of this: $F = 2hT^{2}d_h$ for $QK^{\top}$ plus the same again for $PV$, so $F = 4 \times 32 \times 8192^{2} \times 128 = 1.0995 \times 10^{12}$ FLOP per layer. Divide:

$$I_{\text{materialised}} = \frac{1.0995\times10^{12}}{1.7348\times10^{10}} = 63.4 \ \text{FLOP/byte}, \qquad I_{\text{compulsory}} = \frac{1.0995\times10^{12}}{1.678\times10^{8}} = 6553.6 \ \text{FLOP/byte}.$$

Two remarks on how these are counted, so the numbers reconcile with the ones §3.2 quotes per head. First, $I_{\text{materialised}}$ is $T d_h / \big(b(T + d_h)\big)$ once the $4|S|$ term dominates — the head count cancels, so the per-head figure (34.36 GFLOP over 545.3 MB) and the per-layer figure are the same 63. Second, $I_{\text{compulsory}} = 2hT / \big(b(h + h_{kv})\big)$ does depend on the GQA ratio, because K and V are read once and reused across four query heads; count per head without that sharing and you get $T/b = 4096$ instead of 6553.6. The GQA-aware number is the honest one for a whole layer.

Against the H100 SXM ridge point of $I^{*} = 295$ FLOP/byte fixed in §0.4: the second number is 22× past the ridge — attention is intrinsically one of the most compute-dense things a transformer does. The first is 4.7× left of it. Materialising $S$ costs a factor of 103 in arithmetic intensity and takes a comfortably compute-bound operation and makes it memory-bound.

In time: at 3.35 TB/s, 16.156 GiB per layer is 5.18 ms, or 165.7 ms for all 32 layers. The compute-bound floor is $F/\pi = 1.0995\times10^{12} \times 32 / 989.4\times10^{12} = 35.6$ ms. Derived, both. Textbook attention is 4.7× slower than the same arithmetic has any right to be, and that ratio grows with $T$, because $S$'s bytes grow quadratically while Q/K/V/O's grow linearly.

Causality makes it worse

A causal mask halves the useful FLOPs — only $\approx T^2/2$ query-key pairs matter — but the textbook implementation still allocates the full $[h, T, T]$ and writes $-\infty$ into half of it. Traffic is unchanged, FLOPs halve, so $I$ halves to 31.7 FLOP/byte, 9.3× left of the ridge. Derived. The naive algorithm is the only formulation of attention where adding a mask makes the roofline position worse.

Keep this in proportion. §0.2 established that attention is only about 7% of decode FLOPs for Llama-3-8B at 2.5k context, and §0.4 gave decode attention a fixed intensity of $I = 2h/(b\,h_{kv}) = 4$ FLOP/byte, invariant in both context length and batch size. So this entire part of the book optimises a small share of the FLOPs and a large share of the latency. That is not a contradiction; it is the definition of a memory-bound bottleneck, and it is why attention kernels get more engineering attention than the GEMMs that do fourteen times the arithmetic.

§2

Mental model

The whole part rests on one observation. Every row of $S$ is consumed by exactly one row of $O$, through a softmax and a weighted sum. Nothing outside that row ever reads it. So the row never needs to exist in HBM — it needs to exist in registers or shared memory for as long as it takes to fold it into the output, and then it can be discarded. Hopper gives you 228 KB of shared memory per SM (§0.3); one row of $S$ at $T = 8192$ in fp16 is 16 KB, and a tile of 64 keys against 64 queries is 8 KB. There is room.

The obstacle is the softmax. Its denominator $\sum_j e^{s_j}$ is a reduction over the whole row, so the textbook algorithm cannot emit a single correct element of $P$ until it has seen the last element of $S$ — which is exactly why the row has to be parked somewhere in the meantime. Break that dependency and the round trip disappears.

Figure 1 — the same computation, two traffic patterns. Byte counts are derived for Llama-3-8B, one layer, T = 8192, fp16. Boxes are not area-proportional; 4.00 GiB against 160 MiB would not fit on a page.

Materialised attention versus streamed attention Left panel: three kernels round-trip a 4 GiB score matrix and a 4 GiB probability matrix through HBM, totalling 16.16 GiB of traffic and 63.4 FLOP per byte. Right panel: one kernel reads Q, K and V once, keeps tiles of the score matrix in on-chip SRAM, and writes only O, totalling 160 MiB and 6553.6 FLOP per byte. MATERIALISE — 3 kernels STREAM — 1 kernel HBM (3.35 TB/s) HBM (3.35 TB/s) S [32, 8192, 8192] fp16 4.00 GiB P = softmax(S) fp16 4.00 GiB Q 64 MiB K V O 64 MiB 16 16 MiB total resident + moved: 160 MiB SM / SRAM (228 KB per SM) SM / SRAM (228 KB per SM) 1. QK.T 2. softmax 3. PV S tile [64, 64] m, l, O accumulators in fp32 8 KB, never leaves SRAM rescaled once per KV block 16.156 GiB of HBM traffic I = 63.4 FLOP/byte — 4.7x LEFT of the ridge (295) 0.160 GiB of HBM traffic I = 6553.6 FLOP/byte — 22x PAST the ridge

The right-hand panel is what §3.2 builds. Everything it needs is the box marked m, l, O accumulators: a scheme for carrying a partially-computed softmax forward across KV blocks, correcting it as new information arrives, and arriving at exactly the answer the three-kernel version would have produced. That scheme is the rest of this chapter.

§3

First principles: three softmaxes

Fix one query row. Let $x_1, \dots, x_N \in \mathbb{R}$ be its attention logits — the row of $S$ after the $1/\sqrt{d_h}$ scale and any mask — and let $v_1, \dots, v_N \in \mathbb{R}^{d_h}$ be the corresponding value vectors. The target is

$$y_j = \frac{e^{x_j}}{\sum_{k=1}^{N} e^{x_k}}, \qquad o = \sum_{j=1}^{N} y_j\, v_j \in \mathbb{R}^{d_h}.$$

(1) Naive softmax, and why it is unusable

Evaluate that definition literally and you compute $e^{x_j}$ before dividing. The exponential overflows at

$$x > \ln(\text{max finite value of the dtype}).$$
Derived from IEEE-754 and bfloat16 ranges (the format ranges themselves are tabulated in §0.5)
dtypemax finiteexp overflows aboveexp rounds to 0 below (nearest-even, subnormals)
fp166550411.09−17.33
bf163.39e3888.72−92.88
fp323.40e3888.72−103.97

An fp16 softmax dies at a logit of 11.1. Idealised logits do not reach it: if $q$ and $k$ have independent unit-variance entries, the $1/\sqrt{d_h}$ scale makes $x_j$ roughly $\mathcal{N}(0,1)$, and the expected maximum of an 8192-long row of such samples is only $\approx \sqrt{2\ln N} - \tfrac{\ln\ln N + \ln 4\pi}{2\sqrt{2\ln N}} = 3.7$ — nine sigma of margin. That is exactly why the naive form is dangerous rather than obviously broken: it works on every toy tensor you test it with. Trained models are not $\mathcal{N}(0,1)$ (the callout below is the architectural admission of it), and the failure when it comes is not gradual. Once one entry is $+\infty$, the denominator is $+\infty$, overflowed entries yield $\infty/\infty=\mathrm{NaN}$ while finite exponentials divided by the infinite denominator yield zero. Later weighted reductions can propagate those NaNs throughout the output. And upcasting is no defence: $e^{100} = 2.688\times10^{43}$ overflows fp32 too.

Logits are not always small

Trained models do produce large attention logits; the countermeasure has a flag in both engines. vLLM's FlashAttention backend threads a logits_soft_cap through to the kernel (vllm/v1/attention/backends/flash_attn.py:L854-L857, defaulting to 0 meaning "off"), and SGLang passes softcap=layer.logit_cap at python/sglang/srt/layers/attention/flashattention_backend.py:L1535. Gemma-2 and Grok ship with it set. Softcapping applies $c\tanh(x/c)$ to bound the logits before the softmax — an architectural admission that unbounded logits happen.

(2) Safe softmax, and why it costs three passes

The exponential's translation property rescues it. For any constant $c$,

$$\frac{e^{x_j - c}}{\sum_k e^{x_k - c}} = \frac{e^{-c} e^{x_j}}{e^{-c}\sum_k e^{x_k}} = \frac{e^{x_j}}{\sum_k e^{x_k}}.$$

For finite logits, subtracting their maximum makes exponent arguments nonpositive and prevents exp overflow. Under nearest-even with subnormals, fp16 rounds exp(z) to zero below z=log(2^-25), approximately -17.33; flush-to-zero hardware can differ. Tiny terms are often negligible but many such terms or large associated values can make their aggregate relevant. Check the reduction's error budget rather than declaring every underflow harmless.

The price is passes over the data:

$$\text{pass 1: } m = \max_k x_k, \qquad \text{pass 2: } \ell = \sum_k e^{x_k - m}, \qquad \text{pass 3: } y_j = e^{x_j - m}/\ell .$$

Pass 2 cannot start before pass 1 finishes, and pass 3 cannot start before pass 2 finishes. If the row does not fit in on-chip memory, each pass is a separate trip to HBM — which is precisely the round trip Figure 1's left panel is drawing, and precisely why $S$ has to be materialised.

(3) The online recurrence

Milakov and Gimelshein's observation (arXiv:1805.02867, Online normalizer calculation for softmax, 2018) is that passes 1 and 2 can be fused into one, at the cost of a correction term. Split the row into $n$ consecutive blocks $B_1, \dots, B_n$ — for a kernel, one block is one tile of keys, say 64 of them. Maintain two scalars, initialised to the identity of their respective reductions:

$$m^{(0)} = -\infty, \qquad \ell^{(0)} = 0,$$

and after consuming block $j$:

$$\boxed{\;m^{(j)} = \max\!\Big(m^{(j-1)},\ \max_{k \in B_j} x_k\Big), \qquad \ell^{(j)} = \underbrace{\alpha^{(j)}}_{e^{\,m^{(j-1)} - m^{(j)}}} \cdot\, \ell^{(j-1)} \;+\; \sum_{k \in B_j} e^{x_k - m^{(j)}}\;}$$

Two symbols to be clear about. $m^{(j)}$ is the running maximum over everything seen so far. $\ell^{(j)}$ is the running sum of exponentials, expressed relative to $m^{(j)}$ — that qualifier is the whole idea. And $\alpha^{(j)} = e^{m^{(j-1)} - m^{(j)}}$ is the rescaling factor: when a block arrives containing a logit larger than anything seen before, the old $\ell^{(j-1)}$ was normalised against a stale, too-small maximum, and every term inside it is too large by exactly $e^{m^{(j)} - m^{(j-1)}}$. Multiplying by $\alpha^{(j)}$ retro-normalises all of them at once, in one scalar multiply, without revisiting a single term. Note $\alpha^{(j)} \in (0, 1]$ always, since $m^{(j)} \ge m^{(j-1)}$ — the correction only ever shrinks. It cannot overflow.

The proof

This is the load-bearing claim of the whole part, so it gets a real proof.

Proposition

For every $j \in \{0, 1, \dots, n\}$, with $X_j := B_1 \cup \dots \cup B_j$ the set of indices consumed so far, $$m^{(j)} = \max_{k \in X_j} x_k \qquad\text{and}\qquad \ell^{(j)} = \sum_{k \in X_j} e^{x_k - m^{(j)}}.$$

Proof, by induction on $j$.

Base case, $j = 0$. $X_0 = \varnothing$. The maximum over the empty set is $-\infty$ by convention, matching $m^{(0)}$; the sum over the empty set is $0$, matching $\ell^{(0)}$.

Inductive step. Assume the claim at $j-1$. For the maximum, associativity of $\max$ gives it immediately:

$$m^{(j)} = \max\Big(\max_{k \in X_{j-1}} x_k,\ \max_{k \in B_j} x_k\Big) = \max_{k \in X_{j-1} \cup B_j} x_k = \max_{k \in X_j} x_k .$$

For the sum, substitute the induction hypothesis into the update and use the single algebraic fact the entire construction rests on, $e^{a-b}\,e^{b-c} = e^{a-c}$:

$$\begin{aligned} \ell^{(j)} &= e^{\,m^{(j-1)} - m^{(j)}} \cdot \ell^{(j-1)} + \sum_{k \in B_j} e^{x_k - m^{(j)}} \\[2pt] &= e^{\,m^{(j-1)} - m^{(j)}} \sum_{k \in X_{j-1}} e^{x_k - m^{(j-1)}} + \sum_{k \in B_j} e^{x_k - m^{(j)}} \\[2pt] &= \sum_{k \in X_{j-1}} e^{\,x_k - m^{(j-1)} + m^{(j-1)} - m^{(j)}} + \sum_{k \in B_j} e^{x_k - m^{(j)}} \\[2pt] &= \sum_{k \in X_{j-1}} e^{x_k - m^{(j)}} + \sum_{k \in B_j} e^{x_k - m^{(j)}} \;=\; \sum_{k \in X_j} e^{x_k - m^{(j)}} . \end{aligned}$$

The blocks $B_j$ partition $\{1,\dots,N\}$, so $X_{j-1}$ and $B_j$ are disjoint and the two sums concatenate without double counting. $\square$

Corollary. At the final tile the state is the maximum, shifted exponential sum, and shifted weighted-value sum over all processed entries, so normalization yields the same attention function in exact arithmetic. Floating-point results can differ with tiling and reduction order. Conditioning belongs to the mathematical problem; algorithmic stability is separate. Individual exponentials lie in [0,1], but ell can grow to the number of keys and the weighted output accumulator need not be bounded by 1.

Verify it on numbers

Eight logits, four blocks of two. This is small enough to check by hand and large enough to show a rescale that matters.

Derived — the recurrence run by hand on x = [1, 3, 0, 2, 8, 5, 4, 6], blocks of 2
Block jlogitsblock maxm(j)α(j)(j)
0−∞0
11, 3331.135335
20, 2231.0000001.553002
38, 5880.00673791.060251
44, 6681.0000001.213902

Block 3 is the interesting one: the maximum jumps from 3 to 8, and $\alpha^{(3)} = e^{-5} = 0.0067379$ divides the accumulated state by 148. Everything banked before block 3 was, in hindsight, almost irrelevant — the recurrence discovers this and corrects for it in one multiply. Now the direct computation: $m = 8$, and

$$\ell = e^{-7} + e^{-5} + e^{-8} + e^{-6} + e^{0} + e^{-3} + e^{-4} + e^{-2} = 1.213902 .$$

Identical to $\ell^{(4)}$. Derived.

Extending the recurrence to the output

Softmax alone is not the goal; $o = \sum_j y_j v_j$ is. Keep an unnormalised output accumulator $\tilde{O}^{(j)} \in \mathbb{R}^{d_h}$ alongside $m$ and $\ell$, and read the recurrence off the same argument:

$$\tilde{O}^{(0)} = \mathbf{0}, \qquad \tilde{O}^{(j)} = \alpha^{(j)} \cdot \tilde{O}^{(j-1)} + \sum_{k \in B_j} e^{x_k - m^{(j)}}\, v_k, \qquad o = \tilde{O}^{(n)} / \ell^{(n)}.$$

The claim $\tilde{O}^{(j)} = \sum_{k \in X_j} e^{x_k - m^{(j)}} v_k$ has the identical induction — the only change is that each term carries a vector factor $v_k$ that is untouched by the rescale. That is exactly why $O$ needs the same $\alpha^{(j)}$ and no other: the stale normalisation $e^{-m^{(j-1)}}$ is a common scalar factor of every accumulated term, so correcting it distributes out of the sum. Had the correction depended on $k$, you would have to revisit every term, and the whole scheme would collapse back to storing the row.

This is also the step where the pass count finally reaches one. Milakov and Gimelshein's result is a two-pass softmax: the recurrence fuses the max sweep and the sum sweep, but you still need a second sweep over the row to divide each $e^{x_j - m}$ by $\ell$ — and that second sweep is what would force the row back into memory. Attention escapes it because the only consumer of $y_j$ is the weighted sum $\sum_j y_j v_j$, and division by a scalar is linear, so it commutes past the sum and can be deferred: accumulate $\tilde{O}$ unnormalised, divide once, at the very end, by one number. Attention gets a one-pass online softmax not because softmax admits one, but because $PV$ absorbs the normalisation. Every element of $S$ is now produced, consumed and discarded in registers, and never written anywhere.

Cost accounting: the rescale is $d_h$ multiplies for $\tilde{O}$ plus one for $\ell$, once per KV block, per query row. Against the $T \cdot d_h$ multiply-adds of the main accumulation that is a relative overhead of $1/B_c$ where $B_c$ is the block width in keys — 1.6% at $B_c = 64$. Derived. You spend under two percent more arithmetic to delete 99% of the memory traffic. That trade is the entire thesis of §3.2.

Figure 2 — the recurrence as a dataflow over KV blocks. Numbers are the worked example above. The bold path is the state that survives between blocks: three quantities, total size $d_h + 2$ floats per query row, independent of T.

Online softmax dataflow over four key-value blocks Four blocks are consumed left to right. Each block computes its own maximum and partial sum, then the running state m, l and O-tilde is rescaled by alpha equal to exp of old m minus new m and the block contribution added. At block 3 the maximum jumps from 3 to 8 and alpha is 0.0067, shrinking the accumulated state by a factor of 148. The final output is O-tilde divided by l. KV blocks in HBM per-block reduction running state rescale factor B1 K,V x = 1, 3 B2 K,V x = 0, 2 B3 K,V x = 8, 5 B4 K,V x = 4, 6 read once read once read once read once blockmax = 3 blockmax = 2 blockmax = 8 blockmax = 6 sum exp(x - m) sum exp(x - m) sum exp(x - m) sum exp(x - m) m = 3 l = 1.135335 O-tilde m = 3 l = 1.553002 O-tilde m = 8 l = 1.060251 O-tilde m = 8 l = 1.213902 O-tilde m = -inf l = 0 O-tilde = 0 alpha = 1 alpha = exp(3-3) = 1 alpha = exp(3-8) = 0.0067 alpha = exp(8-8) = 1 state shrinks 148x Update, per block: m ← max(m, blockmax) · l ← alpha*l + sum exp(x - m) · O-tilde ← alpha*O-tilde + sum exp(x - m)*v o = O-tilde / l = O-tilde / 1.213902

What has to be fp32

§0.5's rule was: low precision is safe for operands, dangerous for reductions. Every state variable here is a reduction, and each fails differently in fp16.

l

Overflows

Each term is in $(0,1]$, so $\ell \le N$. At $N = 131{,}072$ tokens the bound exceeds fp16's 65504 outright, and long before that the accumulation of $N$ same-signed terms in fp16 loses more mantissa bits than fp16 has. Both engines store it as float.

O-tilde

Loses bits

A serial sum of $T$ positive-weighted vectors. §0.5's argument gives roughly $\log_2 T = 13$ bits lost at $T = 8192$ — catastrophic in bf16's 8-bit mantissa, survivable in fp32's 24. The accumulator is fp32 even when $Q$, $K$, $V$ and the stored output are bf16.

alpha

Safe by construction

For finite maxima, alpha is in (0,1] and cannot overflow. Underflowing alpha can still matter: alpha*ell or alpha*O may be representable and significant when ell or O is large, even if alpha alone rounds to zero in a narrow dtype. Keep the rescale and reduction state sufficiently wide and test late extreme maxima; flushing is not unconditionally correct.

§4

How production systems do it

You will not find the recurrence written out as a recurrence in either engine, because inside FlashAttention it is compiled into a CUTLASS mainloop that §3.2 takes apart. But both engines expose the merge form of it as a standalone kernel — the operation that takes two partial attention results over disjoint key ranges and combines them. That kernel is the recurrence with the induction step written for two arbitrary states instead of state-plus-block, and reading it is the fastest way to see the mathematics as code.

The state that gets passed around is the log-sum-exp

Neither engine passes $(m, \ell)$ across a kernel boundary. They pass a single fp32 scalar per (token, head), the log-sum-exp:

$$\mathrm{LSE} \;=\; \log \sum_{k} e^{x_k} \;=\; m + \log \ell .$$

One number instead of two, and it is the natural quantity: $e^{\mathrm{LSE}_A}$ is literally the total unnormalised mass of partition $A$. Given two disjoint ranges with normalised outputs $O_A, O_B$ and their LSEs, the merge is a convex combination weighted by those masses:

$$O = \frac{e^{\mathrm{LSE}_A} O_A + e^{\mathrm{LSE}_B} O_B}{e^{\mathrm{LSE}_A} + e^{\mathrm{LSE}_B}}, \qquad \mathrm{LSE} = \log\!\left(e^{\mathrm{LSE}_A} + e^{\mathrm{LSE}_B}\right).$$

Which overflows, for exactly the reason §(1) gave. So you apply the safe-softmax shift one level up — subtract $M = \max(\mathrm{LSE}_A, \mathrm{LSE}_B)$ from both, which §(2) showed is free because softmax is shift-invariant, and which is legal for any common shift, not just the true maximum of the underlying logits. That gives the algorithm, and the algorithm is the kernel, line for line.

One more reconciliation before the code. The recurrence in §3 is asymmetric — a running state absorbing one new block — while the merge above is symmetric, treating both sides alike. They are the same operator. Take the merge and set side $A$ to the running state $(\tilde{O}^{(j-1)}/\ell^{(j-1)},\ m^{(j-1)} + \log \ell^{(j-1)})$ and side $B$ to a single block's own partial result; expand $e^{\mathrm{LSE}_A - M}$ and the $\alpha^{(j)}$ of §3 falls out, with $M$ playing the role of $m^{(j)}$. The streaming form is the merge specialised to "the second argument is one tile"; the merge is the streaming form with the privilege removed. Which is why the same algebra serves a FlashAttention mainloop, a split-KV reduction and a prefix/suffix combine — though, as §5 shows, those are three different kernels in vLLM, not three callers of one.

vLLM: merge_attn_states.cu

vLLM's CUDA implementation lives at csrc/libtorch_stable/attention/merge_attn_states.cu (362 lines at a556f3f). Its own header comment names the reference: // Implements section 2.2 of https://www.arxiv.org/pdf/2501.01005 (csrc/libtorch_stable/attention/merge_attn_states.cu:L15). Here is the arithmetic, verbatim:

csrc/libtorch_stable/attention/merge_attn_states.cu:L148-L171 vLLM
  p_lse = p_lse - max_lse;
  s_lse = s_lse - max_lse;
  const float p_se = expf(p_lse);
  const float s_se = expf(s_lse);
  const float out_se = p_se + s_se;
  const float p_scale = p_se / out_se;
  const float s_scale = s_se / out_se;

  if (pack_offset < head_size) {
    input_pack_t p_out_pack = reinterpret_cast<const input_pack_t*>(
        prefix_head_ptr)[pack_offset / pack_size];
    input_pack_t s_out_pack = reinterpret_cast<const input_pack_t*>(
        suffix_head_ptr)[pack_offset / pack_size];

    // Compute merged values in float32
    float o_out_f[pack_size];
#pragma unroll
    for (uint i = 0; i < pack_size; ++i) {
      const float p_out_f =
          vllm::to_float(reinterpret_cast<const scalar_t*>(&p_out_pack)[i]);
      const float s_out_f =
          vllm::to_float(reinterpret_cast<const scalar_t*>(&s_out_pack)[i]);
      o_out_f[i] = p_out_f * p_scale + (s_out_f * s_scale);
    }

Map it onto the equations. max_lse (set at csrc/libtorch_stable/attention/merge_attn_states.cu:L106 as fmaxf(p_lse, s_lse)) is M=max(LSE_A,LSE_B), not generally the maximum raw logit m. The two expf results are $e^{\mathrm{LSE}_A - M}$ and $e^{\mathrm{LSE}_B - M}$ — the rescaling factors $\alpha$, in their symmetric form where neither side is privileged. out_se is the merged $\ell$ relative to $M$; p_scale and s_scale are the normalised mixing weights, which sum to exactly 1 by construction, making o_out_f a convex combination — it cannot leave the range of its inputs. And the accumulation is fp32 even though scalar_t may be __nv_bfloat16: vllm::to_float on the way in, vllm::from_float on the way out, exactly the operand-vs-accumulator split of §0.5. The LSE pointers in the kernel signature are const float* unconditionally (csrc/libtorch_stable/attention/merge_attn_states.cu:L21-L22) — the running sum is never allowed to be narrow, whatever the tensors are.

Then the state for the next merge is written back in log space:

csrc/libtorch_stable/attention/merge_attn_states.cu:L195-L200 vLLM
  // We only need to write to output_lse once per head.
  if (output_lse != nullptr && pack_idx == 0) {
    float out_lse = logf(out_se) + max_lse;
    output_lse[head_idx * output_lse_head_stride +
               token_idx * output_lse_token_stride] = out_lse;
  }

logf(out_se) + max_lse is $\log \ell + m$ — the LSE definition, undoing the shift. Because the output has the same type as the inputs, merges compose: this is what makes the operator associative, and associativity is what licenses everything built on it downstream. Formally, states $(O, \mathrm{LSE})$ under this merge form a commutative monoid with identity $(\mathbf{0}, -\infty)$. Streaming blocks left-to-right, reducing split-KV partials in a tree, or combining a cached prefix with a fresh suffix are all the same fold in different orders, and all give the same answer.

What this kernel is actually called for

Associativity licenses all three folds; it does not mean this file performs all three. At a556f3f merge_attn_states takes exactly two operands (prefix_output/prefix_lse and suffix_output/suffix_lse), and every call site is a two-way combine across separate launches: cascade attention's shared prefix plus per-request suffix (vllm/v1/attention/backends/flash_attn.py:L1794), decode context parallelism's context-versus-query merge (flash_attn.py:L1384), and FlashInfer's context-plus-new-tokens merge (vllm/v1/attention/backends/flashinfer.py:L384). The N-way split-K reduction inside one launch is somewhere else entirely: inside FlashAttention's own combine when num_splits > 1, and in the Triton path a separate kernel, reduce_segments (vllm/v1/attention/ops/triton_unified_attention.py:L685-L697), which carries per-segment $(m, \ell)$ arrays rather than a single LSE. §3.3 reads that one.

SGLang: the same thing, in Triton

SGLang dispatches through python/sglang/srt/layers/attention/merge_state.py (46 lines), preferring a compiled kernel and falling back to Triton:

python/sglang/srt/layers/attention/merge_state.py:L34-L46 SGLang
    if (
        _is_cuda
        and _supported_dtypes(prefix_output)
        and _supported_headdim(prefix_output)
    ):
        return merge_state_v2(
            prefix_output, prefix_lse, suffix_output, suffix_lse, output, output_lse
        )
    else:
        # Fallback to Triton kernel
        return merge_state_triton(
            prefix_output, prefix_lse, suffix_output, suffix_lse, output, output_lse
        )

The Triton fallback is the clearest statement of the recurrence anywhere in either codebase — no packing, no dtype dispatch, just the algebra:

python/sglang/kernels/ops/attention/merge_state.py:L25-L37 SGLang
    p_lse = tl.load(prefix_lse + token_idx * num_heads + head_idx)
    s_lse = tl.load(suffix_lse + token_idx * num_heads + head_idx)
    p_lse = float("-inf") if p_lse == float("inf") else p_lse
    s_lse = float("-inf") if s_lse == float("inf") else s_lse

    max_lse = tl.maximum(p_lse, s_lse)
    p_lse = p_lse - max_lse
    s_lse = s_lse - max_lse
    out_se = tl.exp(p_lse) + tl.exp(s_lse)

    if OUTPUT_LSE:
        out_lse = tl.log(out_se) + max_lse
        tl.store(output_lse + token_idx * num_heads + head_idx, out_lse)
python/sglang/kernels/ops/attention/merge_state.py:L56-L63 SGLang
    p_scale = tl.exp(p_lse) / out_se
    s_scale = tl.exp(s_lse) / out_se
    out = p_out * p_scale + s_out * s_scale
    tl.store(
        output + token_idx * num_heads * HEAD_SIZE + head_idx * HEAD_SIZE + head_arange,
        out,
        mask=head_mask,
    )

Eleven lines of arithmetic, and every one of them appears in the CUDA kernel above. The engines differ only in packaging: vLLM keeps its CUDA kernel in-tree under csrc/ with a Triton fallback at vllm/v1/attention/ops/triton_merge_attn_states.py selected by vllm/v1/attention/ops/merge_attn_states.py:L80-L100; SGLang's compiled merge_state_v2 comes from the separately-built sgl_kernel package, with the in-tree Triton kernel as the AMD / odd-head-dim / fp8 path. The dispatch predicates are almost identical — head dimension a multiple of 8 for half types, 4 for fp32, because both CUDA kernels issue 128-bit vector loads (python/sglang/srt/layers/attention/merge_state.py:L19-L23, vllm/v1/attention/ops/merge_attn_states.py:L70-L78).

Unverified

SGLang's compiled merge_state_v2 is imported from sgl_kernel, which is not vendored into the SGLang checkout at 7d89325 — there is no sgl-kernel/ directory in the tree. I could therefore read only the Triton fallback and the dispatch wrapper, not the CUDA source. The reader should check the sgl-kernel package separately if the exact CUDA arithmetic matters; the Python signature and the Triton reference implementation are what is verified here.

For the record on where this operation is not: at a556f3f vLLM has deleted its bespoke PagedAttention CUDA kernel (commit d715b3aa1e, Delete PagedAttention (#47361)). csrc/attention/ now contains only dtype headers. CUDA attention runs through FlashAttention, FlashInfer and Triton backends, which is where §3.2 and §3.4 pick the thread up.

§5

Worked trace: one token through the merge

vLLM's cascade-attention path is the shortest route from Python to the equations. It runs when a batch shares a long common prefix: attend to the shared prefix once for all queries, and to each query's private suffix separately, then merge. Trace it.

Figure 3 — the call path from a Python attention backend to the rescale. Shapes are per call, for a batch of n tokens with h query heads and head size d. Ctrl/Cmd + wheel to zoom · drag to pan · double-click to fit

Loading…
  1. Two partial attentions. cascade_attention calls flash_attn_varlen_func twice, both with return_softmax_lse=True. The second call is quoted at vllm/v1/attention/backends/flash_attn.py:L1771-L1791 — note causal=True and block_table=block_table[:, num_common_kv_blocks:], the suffix being everything past the shared prefix. Each call returns $(O_A, \mathrm{LSE}_A)$: an already-normalised output over its own key range, plus the scalar that lets it be un-normalised later.
  2. The merge call.
    vllm/v1/attention/backends/flash_attn.py:L1793-L1794 vLLM
        # Merge prefix and suffix outputs, and store the result in output.
        merge_attn_states(output, prefix_output, prefix_lse, suffix_output, suffix_lse)
  3. Dispatch. vllm/v1/attention/ops/merge_attn_states.py:L80-L100 checks platform, dtype and head-dim divisibility, then calls either vllm._custom_ops.merge_attn_states (the CUDA kernel) or the Triton one. For Llama-3-8B, $d_h = 128$ is a multiple of 8 and bf16 is supported, so the CUDA path wins.
  4. One thread's work. The kernel maps global_idx -> token_idx + head_idx + pack_idx (csrc/libtorch_stable/attention/merge_attn_states.cu:L44-L49) with threads_per_head = head_size / pack_size. For bf16, pack_size = 8, so $d_h = 128$ is covered by 16 threads, each handling 16 bytes — one 128-bit vector load. Every one of those 16 threads reloads the same p_lse and s_lse and recomputes the same two scales; only pack_idx == 0 writes the merged LSE back. Recomputing a scalar in 16 threads is cheaper than a shared-memory broadcast and a barrier.
  5. The output. output[token, head, :] is written once, in the requested dtype, and output_lse (if requested) carries the merged state forward for a further merge.

Total HBM traffic for the merge, per token-head: two $d_h$-vectors and two fp32 scalars in, one $d_h$-vector out. At $d_h = 128$ in bf16 that is 776 bytes moved for roughly 390 FLOPs — $I \approx 0.5$ FLOP/byte, about as memory-bound as a kernel can be. Derived. It is tolerable only because it runs once per split, not once per key: the whole point of the design is that the expensive fold happened inside the attention kernel, in SRAM, and only the $(O, \mathrm{LSE})$ summary crossed the HBM boundary.

§6

Pitfalls and war stories

The base case is a NaN factory

The proof's base case sets $m^{(0)} = -\infty$ and $\ell^{(0)} = 0$, and the induction step computes $\alpha = e^{m^{(0)} - m^{(1)}}$. If $m^{(1)}$ is also $-\infty$ — a query row with no unmasked keys at all — then $\alpha = e^{-\infty + \infty} = e^{\mathrm{NaN}} = \mathrm{NaN}$, and the NaN propagates to every element of the output. In real arithmetic the empty case is harmless; in IEEE-754 it is a landmine, and both engines have stepped on it. vLLM's kernel carries the incident report inline:

csrc/libtorch_stable/attention/merge_attn_states.cu:L103-L116 vLLM
  p_lse = std::isinf(p_lse) ? -std::numeric_limits<float>::infinity() : p_lse;
  s_lse = std::isinf(s_lse) ? -std::numeric_limits<float>::infinity() : s_lse;

  const float max_lse = fmaxf(p_lse, s_lse);

  /* In certain edge cases, MLA can produce p_lse = s_lse = -inf;
     continuing the pipeline then yields NaN. Root cause: with chunked prefill
     a batch may be split into two chunks; if a request in that batch has no
     prefix hit, every LSE entry for that request's position is -inf, and at
     this moment we merge cross-attention at first. For now we simply emit
     prefix_output (expected to be all zeros) and prefix_lse (-inf) to fix
     this problem.
  */
  if (std::isinf(max_lse)) {

Two separate defences in four lines. The first maps $+\infty$ to $-\infty$, because FA2 signals "empty range" with the wrong sign — vLLM's Triton kernel spells out the reasoning at vllm/v1/attention/ops/triton_merge_attn_states.py:L160-L164: "If we see an inf assume FA2 and convert inf to -inf for consistency and correctness." SGLang's Triton kernel carries the identical guard at python/sglang/kernels/ops/attention/merge_state.py:L27-L28. The second is the early-out for the both-empty case. vLLM's Triton version guards the same case with tl.where instead of a branch, at vllm/v1/attention/ops/triton_merge_attn_states.py:L176-L178 and :L207-L209, with the comment "If both sides are empty (max_lse == -inf) the scales are 0/0 = NaN; emit zeros rather than NaN."

The debugging signature: a NaN that appears only under chunked prefill, only for requests that missed the prefix cache, only in the first layer's output, and vanishes if you disable cascade or chunked prefill. If you write your own merge, write the empty-state guard first.

Scale first, then multiply

The order of operations in the merge is load-bearing, and vLLM's Triton kernel says so:

vllm/v1/attention/ops/triton_merge_attn_states.py:L201-L206 vLLM
    # NOTE(woosuk): Be careful with the numerical stability.
    # We should compute the scale first, and then multiply it with the output.
    # Do not multiply the output with tl.exp(p_lse) or tl.exp(s_lse) directly.
    p_scale = p_se / out_se
    s_scale = s_se / out_se
    out = p_out * p_scale + s_out * s_scale

Both forms are algebraically identical. Numerically they are not: $p\_scale$ and $s\_scale$ are non-negative and sum to exactly 1, so out is a convex combination bounded by $\max(|p\_out|, |s\_out|)$ and cannot overflow whatever dtype it is stored in. Multiply by the raw $e^{\mathrm{LSE}-M}$ first and divide afterwards and you pass through an unbounded intermediate. The same instinct is why the running $\ell$ is carried normalised against $m$ rather than raw.

Bitwise reproducibility is a casualty

The merge is associative in real arithmetic and only approximately so in floating point. Change the number of KV splits — which both engines do adaptively, based on batch size and sequence length — and you change the fold order, and the last bits of the output move. vLLM has an explicit escape hatch for this: at vllm/v1/attention/backends/flash_attn.py:L1790 the split count is pinned when batch-invariant mode is on:

vllm/v1/attention/backends/flash_attn.py:L1790 vLLM
        num_splits=1 if envs.VLLM_BATCH_INVARIANT else max_num_splits,

num_splits=1 means one partial, so no merge, so no order-dependence. It also means no split-KV parallelism, which is the throughput lever §3.3 is built on. Determinism and decode throughput are in direct tension here, and the flag is where you choose.

§7

Hands-on

Reproduce the proof numerically before trusting any kernel that claims it. No GPU required — this is CPU numpy and takes a second.

PSEUDOCODE — a self-contained check of the proposition in §3 pseudocode
import numpy as np
rng = np.random.default_rng(0)
N, D, BC = 8192, 128, 64                 # keys, head dim, KV block width
x = rng.normal(0, 3, N).astype(np.float64)   # logits, sigma=3 so exp(x) overflows fp16
x[4000] = 40.0                           # a late outlier: forces a big rescale
v = rng.normal(0, 1, (N, D))

m, l, O = -np.inf, 0.0, np.zeros(D)
for j in range(0, N, BC):
    xb, vb = x[j:j+BC], v[j:j+BC]
    m_new = max(m, xb.max())
    a = np.exp(m - m_new) if np.isfinite(m) else 0.0   # the base-case guard
    p = np.exp(xb - m_new)
    l = a * l + p.sum()
    O = a * O + p @ vb
    m = m_new
online = O / l

ref = (np.exp(x - x.max()) / np.exp(x - x.max()).sum()) @ v   # safe 3-pass
np.testing.assert_allclose(online, ref, rtol=1e-12, atol=1e-12)
print(np.max(np.abs(online - ref)))      # rounding-scale difference

Then, on a machine with a GPU, exercise the real kernels. vLLM's merge has a dedicated benchmark and test:

exercise the real merge kernels shell
# exercise the CUDA and Triton merge kernels against a torch reference
pytest -q tests/kernels/attention/test_merge_attn_states.py

# force the Triton fallback path by asking for an unsupported head dim,
# then diff the two implementations at your own shapes
python -c "import vllm.v1.attention.ops.merge_attn_states as m; print(m.__file__)"

# turn cascade attention off and on and watch the merge disappear from the profile
VLLM_BATCH_INVARIANT=1 vllm serve meta-llama/Meta-Llama-3-8B-Instruct
Not measured

Nothing in this chapter was run on a GPU. Every byte count, intensity and time estimate is arithmetic from published shapes and the H100 SXM datasheet figures fixed in §0.4. Treat the 165.7 ms and 35.6 ms as bounds the hardware cannot beat, not as measurements.

§8

Exercises

  1. Recompute the motivating number for a different model. Llama-3-70B has $h = 64$, $h_{kv} = 8$, $d_h = 128$, $L = 80$. At $T = 8192$ in bf16, what is $|S|$ per layer, what is the total HBM traffic of the three-kernel implementation per layer, and what is the arithmetic intensity? Does the intensity go up or down relative to the 8B model, and why?
  2. Read the file and answer. Open csrc/libtorch_stable/attention/merge_attn_states.cu. For bf16 inputs with $d_h = 128$, how many threads cooperate on one (token, head) pair, and how many of them execute expf? Find the line that decides which one writes output_lse. Why is redundant computation preferred to a shared-memory broadcast here?
  3. Predict, then verify. In the numpy script above, replace float64 with float16 for x, v, l and O, and set x[4000] = 40.0. Predict which of the three state variables fails first and what the failure looks like. Then run it. Now keep l and O in fp32 and only the operands in fp16 — predict the error and check.
  4. Break the base case. Construct a call to merge_state_triton (python/sglang/kernels/ops/attention/merge_state.py) where both prefix_lse and suffix_lse are $-\infty$. Predict the output. Then do the same against vllm/v1/attention/ops/triton_merge_attn_states.py and explain why the two disagree.
  5. Prove associativity. Show that the merge operator $(O_A, \mathrm{LSE}_A) \oplus (O_B, \mathrm{LSE}_B)$ defined in §4 is associative and commutative, and that $(\mathbf{0}, -\infty)$ is a two-sided identity. Then state precisely which of those three properties FlashAttention's sequential mainloop actually needs, and which additional one split-KV reduction needs.
Answers

1. $|S| = 64 \times 8192^2 \times 2 = 8.00$ GiB per layer. Traffic: $4|S| = 32$ GiB, plus Q (128 MiB) + K (16 MiB) + V (16 MiB) + O (128 MiB) = 288 MiB; total 32.28 GiB. FLOPs $= 4 \times 64 \times 8192^2 \times 128 = 2.199\times10^{12}$. $I = 2.199\times10^{12} / 3.466\times10^{10} = 63.4$ FLOP/byte — close to the 8B model, not exactly identical once the different GQA Q/K/V/O traffic is retained. Both $F$ and the dominant part of $Q$ scale with $h$, so $h$ cancels; $I_{\text{materialised}} = T d_h / (b(T + d_h))$ depends only on $T$, $d_h$ and the dtype. That is why materialisation is a structural problem and not a big-model problem.

2. pack_size = 16 / sizeof(scalar_t) = 8 for bf16, so threads_per_head = 128 / 8 = 16. All 16 load p_lse/s_lse and call expf twice (lines L150-L151); the scales are computed redundantly 16 times. Only pack_idx == 0 writes the LSE — the guard is at merge_attn_states.cu:L196. Two expf instructions per thread are a handful of cycles; a __shared__ staging plus __syncthreads() costs a barrier that serialises the whole block, and shared memory is the scarce resource that limits occupancy. Recompute-over-communicate is the default instinct on a GPU.

3. After subtracting the tile maximum, every exponent is nonpositive, so exp itself cannot overflow for finite inputs. This code's p@vb reduces 64 terms per tile, not 8192; the running state then combines 128 tiles. Narrow storage/accumulation can lose small updates, underflow rescale factors, or overflow an unnormalized weighted sum for sufficiently large values. The first failure depends on data and reduction implementation; there is no universal 13-bit loss or fixed relative-error guarantee. Compare against FP64 and record absolute error as well as relative error near zero.

4. SGLang's Triton kernel has no both-empty guard: max_lse is $-\infty$, p_lse - max_lse is NaN, and the output is NaN. vLLM's has the tl.where(max_lse == float("-inf"), 0.0, out) guard at triton_merge_attn_states.py:L207-L209 and emits zeros, with the LSE held at $-\infty$ so downstream merges keep treating the token as empty. The divergence is real and matters only on a path SGLang does not currently take with that kernel; it is the kind of thing to check before reusing either file.

5. Write $\sigma_A = e^{\mathrm{LSE}_A}$. The merge is $\big((\sigma_A O_A + \sigma_B O_B)/(\sigma_A+\sigma_B),\ \log(\sigma_A+\sigma_B)\big)$, i.e. a weighted mean with weights $\sigma$ and a sum of weights. Both operations are associative and commutative on $(\sigma O, \sigma)$ pairs — the merge is just componentwise addition in those coordinates, re-normalised at read time — and $\sigma = 0$ (that is, $\mathrm{LSE} = -\infty$) is the additive identity. FlashAttention's mainloop folds blocks in a fixed left-to-right order, so it needs only associativity plus the identity for its base case. Split-KV reduction combines partials whose order depends on scheduling, so it additionally needs commutativity to be well-defined — which is why changing num_splits changes the last bits but not the answer.

§9

Key takeaways

  • The cost of textbook attention is the $[h, T, T]$ intermediate, not the FLOPs. At $T = 8192$ it is 4.00 GiB per Llama-3-8B layer and 99% of the HBM traffic; the compulsory-traffic ideal is 6553.6 while materialization yields about 63.4 FLOP/byte; actual tiled traffic includes SRAM-driven rereads, moving attention from 22× past the H100 ridge to 4.7× left of it. Derived.
  • Softmax is invariant to any common shift of its inputs. The maximum is chosen only because it makes every exponent non-positive, converting a fatal overflow into a benign underflow. That freedom is what lets the merge kernels shift by $\max(\mathrm{LSE}_A, \mathrm{LSE}_B)$ instead of the true logit maximum, which they no longer have.
  • The online recurrence is exact, not approximate, and the proof is four lines resting on $e^{a-b}e^{b-c} = e^{a-c}$. The rescale $\alpha = e^{m_{\text{old}} - m_{\text{new}}}$ works on the output accumulator for one reason: the stale normalisation is a scalar factor common to every accumulated term, so it distributes out of the sum and costs $d_h$ multiplies instead of a re-read.
  • $(O, \mathrm{LSE})$ under the merge is a commutative monoid with identity $(\mathbf{0}, -\infty)$. Streaming blocks, tree-reducing split-KV partials, and combining a cached prefix with a fresh suffix are the same fold in different orders — which is why the same 36 lines of algebra reappear in three unrelated kernels, and why changing the split count perturbs the last bits. merge_attn_states is only the two-way one.
  • The identity element is where floating point bites: $-\infty$ minus $-\infty$ is NaN, and vLLM's quoted merge paths guard it, while the quoted SGLang Triton fallback lacks a both-empty guard. Distinguish that fallback from other paths. Write the empty-state branch before the arithmetic.
  • Attention is ~7% of Llama-3-8B's decode FLOPs but a large share of its latency, because its intensity is 4 FLOP/byte against a ridge of 295. Attention kernel work is latency work, not FLOP work — hold that distinction through the rest of this part.
§10

Further reading

  • Milakov & Gimelshein, Online normalizer calculation for softmax, arXiv:1805.02867 (2018). The two-pass softmax and the running-max recurrence, three years before FlashAttention used them. Short, and worth reading in full.
  • Rabe & Staats, Self-attention Does Not Need $O(n^2)$ Memory, arXiv:2112.05682 (2021). The same recurrence applied to attention, with the memory argument made explicitly.
  • arXiv:2501.01005, section 2.2 — the reference cited by name in vLLM's own source at csrc/libtorch_stable/attention/merge_attn_states.cu:L15, vllm/v1/attention/ops/merge_attn_states.py:L22 and csrc/cpu/mla_decode.cpp:L129. Read it as the engines' chosen statement of the merge formulation.
  • vLLM PR #16173, [Kernel] support merge_attn_states CUDA kernel, 3x speedup — the commit that introduced the kernel dissected in §4, with the author's benchmark discussion.
  • vLLM PR #47361, Delete PagedAttention (commit d715b3aa1e). Context for why there is no bespoke vLLM attention CUDA kernel to read at this SHA.
  • The backend set this part will work through, enumerated for §3.4: SGLang's python/sglang/srt/layers/attention/ holds flashattention_backend.py, flashinfer_backend.py, flashinfer_mla_backend.py, triton_backend.py, torch_native_backend.py, torch_flex_backend.py, trtllm_mha_backend.py, trtllm_mla_backend.py, flashmla_backend.py, cutlass_mla_backend.py, cutedsl_mla_backend.py, aiter_backend.py, wave_backend.py, xpu_backend.py, intel_amx_backend.py, nsa_backend.py, dsa_backend.py, hybrid_attn_backend.py, hybrid_linear_attn_backend.py and tbo_backend.py, selected through python/sglang/srt/layers/attention/attention_registry.py.

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