LLM Inference | For You
Lecture 7 of 9

The Kernel Level: Attention and MoE

The main question of this lecture: we proved decode wastes bandwidth on paper. What must a kernel never write to HBM, and what must it never wait for, to actually reach the roofline?

Picture yourself profiling a 70B model on an H100 for the first time. You open the trace, and something is off: attention at long context takes seconds, yet your FLOP-counting from Lecture 1 says the math is milliseconds. Then you look at a decode step and find hundreds of tiny kernels separated by... nothing. Gaps. Idle silicon. Two leaks stand between your paper derivation and the metal: bytes that should never have left the chip, and time lost between kernels. This lecture is the hunt for both.

Lectures 1–6 decided what to compute and where; today is how one GPU actually computes it: which bytes live at which level of the memory hierarchy at which instant, and how hundreds of kernel launches are stitched into one decode step. Two kernel families carry almost all the load. The attention main line is FlashAttention → FlashInfer → FlashMLA → the sparse DSA chain; the MoE main line is grouped GEMM → DeepGEMM → DeepEP. Running through both is an execution-style evolution: manual launches → CUDA graphs → Mega MoE's communication–compute fusion. Every step is a response to a derivable number; deriving those numbers is our business today.

Main idea: a kernel that reaches the roofline is a data-residency policy, not a math trick: it never writes to HBM anything it can keep on chip, and it never lets the chip wait between kernels.

The Design Space: Four Levels of Memory

An H100 streaming multiprocessor (SM) presents you with a four-level hierarchy. Only two levels are under your exact control: registers and shared memory (SMEM). That is why kernel design is residency policy: which tiles are pinned in SMEM, which are streamed, and which are never materialized at all. Every byte crossing HBM costs \(1/\beta\) seconds; bytes that stay on chip are, to first order, free.

registers 256 KB/SM (64K × 32-bit) · ~1 clk SMEM (shared with L1) 228 KB/SM · ~30 clk · explicitly managed by you ↑ exact programmer control ends here L2 (partitioned) ~50 MB/GPU · ~200 clk · hardware cache on chip ↑ · off chip ↓ · the β = 3.35 TB/s wall HBM 80 GB · ~500 clk · model weights & KV cache live here naive attention's S, P: the thing we forbid faster, smaller, costlier per byte ↑
How to: read top to bottom as a price list. Everything above the red line is ~free once placed; every byte that crosses it pays \(1/\beta\). The dashed red arrow is naive attention writing its \(L\times L\) matrices into the most expensive memory on the machine. That arrow is the lecture's whole problem.

Three Hopper mechanisms changed what a good residency policy even looks like: TMA (async block-wise global↔shared copies; one thread issues, the hardware does the address arithmetic, think block-table lookup for paged KV, and signals an SMEM mbarrier); WGMMA (matrix multiply issued asynchronously per warpgroup, operands straight from SMEM); and thread-block clusters with distributed shared memory (blocks read each other's SMEM: a tile-exchange pool larger than 228 KB without touching L2/HBM). With copies and MMAs both asynchronous, the natural post-Hopper kernel is no longer "all warps do the same thing": producer warps run TMA for tile \(t+1\), consumer warpgroups run WGMMA on tile \(t\), epilogue warps drain tile \(t-1\), all handing off through mbarriers. This producer/consumer/epilogue skeleton is shared by FlashAttention-3, DeepGEMM, and Mega MoE. It keeps the tensor cores fed across the ~500-cycle HBM latency by decoupling "move bytes" from "multiply bytes". Two cousins to remember: Blackwell (SM100) adds tcgen05 with TMEM and native FP4 datapaths (they return below), and persistent kernels fix the grid at the SM count and pull tiles from a task queue: no re-launch cost, no wave quantization, and the freedom to interleave tiles of different subproblems onto free SMs.

Naive Attention: the \(O(L^2)\) You Should Never Write

Here is the easiest attention kernel you can write. If you have used eager PyTorch, you have written it: compute the full score matrix \(S = QK^\top\), softmax it into \(P\), multiply by \(V\). You can probably guess this is not the best way. But let us count exactly why not, because the number is spectacular. Assumptions: prefill of a length-\(L\) sequence, one layer, \(H_q = d_{model}/d_h\) query heads, \(n_{kv}\) KV heads, BF16 (\(b_{dtype} = 2\) B). We count per query head: GQA shares \(K, V\), but every query head owns its own \(S\) and \(P\).

? Count the HBM bytes yourself before opening: the naive sequence is QKᵀ GEMM → softmax → PV GEMM. Which matrices cross HBM, and how many times each?

The count, per query head

The QKᵀ GEMM reads \(Q, K\) (\(2Ld_h b\)) and writes \(S\) (\(L^2 b\)). Softmax reads \(S\), writes \(P\) (\(2L^2 b\)). The PV GEMM reads \(P\) (\(L^2 b\)), reads \(V\) and writes \(O\) (\(2Ld_h b\)). The quadratic term totals \(4L^2 b_{dtype}\) bytes per query head: four crossings of an \(L \times L\) matrix. Everything linear in \(L\) is negligible next to it. ∎

Summing over heads, traffic and arithmetic intensity of the naive implementation are \[ T_{naive} = H_q\left(4L^2 b_{dtype} + 4Ld_h b_{dtype}\right) \approx \frac{4L^2 d_{model}\, b_{dtype}}{d_h}, \qquad I_{naive} \approx \frac{d_h}{b_{dtype}} = \frac{128}{2} = 64\ \text{FLOP/byte}. \] (The FLOPs, for contrast, are \(\approx 4L^2 d_{model}\) per layer: Lecture 1's attention term.) And 64 sits far left of the H100 ridge \(\pi/\beta \approx 295\): attention executed naively is bandwidth-bound even at prefill sizes. The \(O(L^2)\) traffic, not the \(O(L^2)\) FLOPs, is the binding constraint. This is the single most important sentence about attention kernels.

Worked example (do it with me): attention traffic at \(L = 128\)K, Llama-3.3-70B. Constants: \(n_{layers} = 80\), \(d_{model} = 8192\), \(d_h = 128 \Rightarrow H_q = 64\), GQA \(n_{kv} = 8\), BF16.

Naive, per query head: \(L^2 = 1.718\times 10^{10}\) elements; the four \(S/P\) crossings cost \(4L^2 \times 2\,\text{B} = 1.374\times 10^{11}\) B ≈ 128 GB. Per head. Per layer: \(64 \times 128\) GB ≈ 8.8 TB; per pass: ≈ 704 TB.

FlashAttention, per layer: read \(Q, K, V\) once, write \(O\) once: \[ T_{FA} = L\, b_{dtype}\left(d_{model} + 2 n_{kv} d_h + d_{model}\right) = 131072 \times 2 \times (8192 + 2\cdot 8 \cdot 128 + 8192) \approx \text{4.83 GB}, \] per pass: \(80 \times 4.83 \approx\) 386 GB. Per query head the ratio is \(4L^2 b / (4 L d_h b) = L/d_h\). At 128K that is a factor of 1024, before GQA sharing.

Contrast: 8.8 TB → 4.83 GB per layer is ≈ 1820×. At 3.35 TB/s the naive traffic alone takes ≈ 210 s per pass. Three and a half minutes, for one forward pass! FA's takes ≈ 115 ms, overlapped with compute. The FLOPs never changed. Only the residency of \(S\) and \(P\) did. Sanity check: 386 GB per pass is the same order as reading the weights twice (\(2P \times 2\,\text{B} = 280\) GB), consistent with attention being a secondary term at 128K prefill once \(S, P\) are gone.
Engineering Takeaway: do not profile attention as "the \(O(L^2)\) compute part". Profile it as an \(O(L^2)\) memory part that FlashAttention already reduced to \(O(Ld)\). If your kernel still materializes \(S\) or \(P\) (watch for torch.matmul + softmax in eager mode), you are leaving a factor \(L/d_h \approx 10^3\) of HBM traffic, and up to ~10× wall-clock at long context, on the table before any scheduling discussion begins.

But Softmax Wants the Whole Row...

Fine. Never write \(S, P\) to HBM. Just one small obstacle, and you may have spotted it: softmax needs the whole row before any output is final, because \(\mathrm{softmax}(s)_j = e^{s_j} / \sum_t e^{s_t}\) couples all KV positions through the denominator. Tiles are small; the row is long. How do you normalize a sum you haven't finished reading? Think first (is a running denominator even possible?), then read on.

The trick is online softmax: per query row, keep a running max \(m\) and running normalization \(\ell\), and correct past partial results by exponential rescaling whenever \(m\) grows. Process the score row \(s = qK^\top \in \mathbb{R}^L\) in blocks \(s^{(1)}, \dots, s^{(T)}\) of length \(B_c\) (with \(V\) blocked conformably); from \(m_0 = -\infty,\ \ell_0 = 0,\ O_0 = 0\): \[ m_i = \max\big(m_{i-1},\ \mathrm{rowmax}(s^{(i)})\big), \qquad \ell_i = \ell_{i-1}\, e^{m_{i-1} - m_i} + \sum_j e^{s^{(i)}_j - m_i}, \qquad O_i = O_{i-1}\, e^{m_{i-1} - m_i} + e^{s^{(i)} - m_i} V^{(i)}, \] with final output \(O_T / \ell_T\). This unnormalized form defers all division to the end (a normalized variant divides each step; the two are identical up to carrying \(1/\ell_i\) inside \(O\)).

? Exact or approximate? Before opening, try to state the induction hypothesis: what are \(m_i, \ell_i, O_i\) after \(i\) blocks?

The exactness theorem (and its one-line engine)

Claim: with \(s^{[i]}\) the concatenation of the first \(i\) blocks, \[ m_i = \max_j s^{[i]}_j, \qquad \ell_i = \sum_j e^{s^{[i]}_j - m_i}, \qquad O_i = \sum_j e^{s^{[i]}_j - m_i} v_j, \] hence \(O_T/\ell_T = \sum_j e^{s_j} v_j \big/ \sum_j e^{s_j} = \mathrm{softmax}(s)\,V\): exactly the full-row softmax. Proof sketch (induction on \(i\)). Base \(i=1\) is immediate. Step: the whole proof is the identity \(e^{s_j - m_{i-1}} \cdot e^{m_{i-1} - m_i} = e^{s_j - m_i}\). Substituting the hypothesis collapses \(\ell_i\) into the sum over all \(j \in [i]\), and the same algebra column-wise gives \(O_i\); at \(i = T\) the common factor \(e^{-m_T}\) cancels in the ratio. ∎ Numerically: max abs error ≈ \(4\times 10^{-16}\) in float64, both forms.

Three remarks to pocket. (1) Safety for free: \(m_T\) is the true row max, so every exponent is ≤ 0; the running max doubles as stabilization. (2) The factors \(e^{m_{i-1}-m_i}\) are the idea: when a later block raises the max, all previous contributions were denominated in too-large units and must be deflated. (3) Unlike top-k or windowed attention (Lecture 3), online softmax changes when operations happen, not which.

Worked example (the merge, with real numbers): one block produced \((m_1, \ell_1, O_1) = (2.0,\ 8.0,\ (4.0, 12.0))\); a new block scores higher: \(m^{(2)} = 3.0\), \(\sum_j e^{s^{(2)}_j - 3.0} = 6.0\), \(\sum_j e^{s^{(2)}_j - 3.0} v_j = (18.0, 6.0)\). Merge: \[ m_2 = \max(2,3) = 3, \quad e^{m_1 - m_2} = e^{-1} \approx 0.3679, \quad \ell_2 = 8e^{-1} + 6 = 8.943, \] \[ O_2 = (4,12)e^{-1} + (18,6) = (19.472,\ 10.414), \qquad \text{output} = O_2/\ell_2 = (2.177,\ 1.164). \] The merge-weight reading: the old block weighs \(\ell_1 e^{m_1-m_2}/\ell_2 = 2.943/8.943 = 0.329\); the new one \(6/8.943 = 0.671\). Check: \(0.329 \times (0.5,1.5) + 0.671 \times (3,1) = (2.177, 1.164)\) ✓. The merge is an LSE-weighted average of block-normalized outputs, with the old block deflated by \(e^{-1}\) for its stale max. More of this in Have Fun!.

Tiling turns the recurrence into a traffic theorem: per KV block the kernel touches \(2 B_c d_h\) bytes of HBM (the \(K_j, V_j\) tile) and zero bytes for \(S_j, P_j\), which exist only in registers/SMEM. Summed: \(Q, K, V\) read once, \(O\) written once. Exactly the \(O(Ld)\) traffic we counted.

HBM: paged KV K₁V₁ K₂V₂ … each block streamed exactly once TMA copy (async, one block) SMEM K_j, V_j tile Q_i tile: loaded once, never leaves registers / SMEM only: S_j = Q_i K_jᵀ → P_j = e^(S_j − m) never written to HBM ✗ running state: (m, ℓ, O) rescale past work by e^(m_old − m_new) each block O stays unnormalized until the end next KV block j+1 → output O = O_T / ℓ_T to HBM, once
How to: start at the paged KV on the left and walk clockwise. A KV block enters SMEM, meets the pinned Q tile, produces \(S_j, P_j\) in registers (note the red ✗: these matrices have no HBM address, ever), folds into the running triple, loop. The only things crossing Fig. 1's red wall are \(Q, K, V\) (read once) and \(O\) (written once).

On Hopper, FA3 runs this loop on the warp-specialized pipeline: producer TMA warps stream the next \(K_j, V_j\) while consumers run WGMMA, and element-wise softmax overlaps the async MMA (the "GEMM–softmax overlap" pushing FA3 toward the compute ceiling; FA3 also adds FP8). The family history in one line: FA1 established online softmax + tiling; FA2 re-parallelized along the Q sequence (each block owns a Q tile and scans all KV, filling all 132 SMs even at long \(L\) and small batch); FA3 rebuilt the kernel for Hopper. Same mathematics every time. What evolves is the residency and overlap policy.

Note: online softmax cut attention's state from \(O(L)\) to \(O(B_c)\) and its traffic from \(O(L^2)\) to \(O(Ld)\) without changing a single FLOP of the result. Let us remember the main idea: a kernel that reaches the roofline is a data-residency policy, not a math trick. FA is the policy "keep \(S, P\) on chip"; the rest of the lecture is the same sentence applied to decode, to MoE, and to the spaces between kernels.

Decode Starves the SMs: Split-KV, the LSE Merge, and One Latent for 128 Heads

Now decode arrives and breaks the map. At decode \(q_{len} = 1\): exactly one query row per request, so FA2's "split along Q" yields only batch-size-many blocks, often far fewer than 132 SMs. You can probably guess where the parallelism must come from: the only long axis left is the KV length itself.

Split-KV (a.k.a. flash-decoding) divides the \(L\) KV positions into \(S\) segments; each segment runs the ordinary online-softmax recurrence in its own block and produces a partial triple \[ m_s = \max_{j \in s} s_j, \qquad \ell_s = \sum_{j \in s} e^{s_j - m_s}, \qquad O_s = \sum_{j \in s} e^{s_j - m_s} v_j. \] A small reduction kernel then merges the \(S\) partials. Let \(m^* = \max_s m_s\); every partial is deflated to the common scale exactly as in the online recurrence: \[ \ell^* = \sum_s \ell_s\, e^{m_s - m^*}, \qquad O^* = \frac{\sum_s \ell_s e^{m_s - m^*}\, O_s^{norm}}{\ell^*}, \qquad O_s^{norm} = O_s/\ell_s. \] Substitution collapses the merge into the global sum: the segments partition \([L]\), so this equals full-KV softmax by the same cancellation. Two equivalent forms of one correction: define each segment's log-sum-exp \(\mathrm{LSE}_s = m_s + \log \ell_s\); the merge weight of segment \(s\) becomes \(e^{\mathrm{LSE}_s - \mathrm{LSE}^*}\), a softmax over segment LSEs applied to partial outputs (verified numerically to \(6\times 10^{-16}\)).

Note: this merge is also Lecture 6's DCP: each GPU computes its KV shard's partial \((m_s, \ell_s, O_s)\) locally; a small reduction over LSEs produces the global result. Split-KV is DCP within one GPU; DCP is split-KV across GPUs. Same mathematics, two scales, same reason for existing: the KV axis is the only decode parallelism left.

A kernel alone is not a serving system. FlashAttention assumes dense contiguous Q/K/V and one shape per call; an engine needs FlashInfer's contract: native paged-KV support (the kernel indexes KV through Lecture 2's block table, no contiguous copy needed), ragged batches carried as metadata, a unified prefill/decode/append interface, JIT-compiled variants (head dim, RoPE, soft-capping, sliding window), and plan/run separation, where a host-side plan inspects the batch's length distribution and decides tile partitioning and load balance, keeping variable-length batches schedulable without device-side sync. Both vLLM and SGLang use FlashInfer as an attention backend: it is where Lecture 2's paging meets this lecture's tiling.

Engineering Takeaway: the kernel–engine interface is a contract: block-table addressing, ragged lengths, plan/run separation. When swapping attention backends, verify the contract (page size, layout, head dims) before benchmark numbers. A kernel that violates the page-size contract forces an upstream copy that can cost more than it saves. FlashMLA's fixed page size of 64 is exactly such a contract term.

And then there is MLA, whose decode shape breaks standard kernel assumptions. Recall Lecture 2: in absorbed mode, per-head projections fold into \(Q\), so attention runs directly against the compressed cache: each token contributes one 576-element latent (512-dim latent + 64-dim RoPE key) shared by all 128 query heads. The decode GEMM is \[ \underbrace{O}_{128 \times 576} = \mathrm{softmax}\Big( \underbrace{Q_{lat}}_{128 \times 576}\, \underbrace{K_{lat}^{\top}}_{576 \times L}\Big)\, \underbrace{V_{lat}}_{L \times 576}, \] attention with 128 heads packed as GEMM rows against a single K-dim-576 cache. The naive treatment (128 independent heads) re-reads the same latent cache 128 times: 72 KB per token instead of 576 B. That forfeits exactly the bandwidth MLA was designed to buy. FlashMLA loads each block of latent KV from HBM once and lets all 128 packed heads consume it from on-chip memory.

Worked example: FlashMLA bytes per decode step (DeepSeek-V3). Per token per layer: \(576 \times 1\,\text{B}\) (FP8) = 576 B (naive per-head: \(128 \times 576 = 72\) KB, a factor of 128). At \(L = 128\)K, per layer: \(131072 \times 576\) B ≈ 75.5 MB; over V3's 61 layers ≈ 4.6 GB per step, matching the ≈ 34 KB/token figure (\(576 \times 61 \approx 34.3\) KB). Sustaining ≈ 3000 GB/s on H800 bounds single-request decode at \(3000/4.6 \approx 650\) steps/s and, more importantly, MBU \(= 3000/3350 \approx 90\%\). Published FlashMLA figures: decode shapes reach the 3000 GB/s regime (≈ 90% MBU, near the ceiling for a bandwidth-bound kernel); compute-bound shapes reach 660 TFLOPS (≈ 33% of the ≈ 1979 TFLOPS FP8 peak, respectable for a softmax-interleaved kernel); FP8 KV cache; page size fixed at 64 (36 KB of latent per page).

TA says: burn this into your decode intuition: 90% MBU is what success looks like. A bandwidth-bound kernel that saturates the memory system has nothing left to give, and nobody needs FLOPs at decode. Also note the shape trick: head_dim 576 is 4.5× a standard head, so K-tile SMEM pressure is huge. FlashMLA absorbs it through aggressive tiling plus the producer/consumer pipeline. When an architecture changes the arithmetic (MLA's shared latent), the kernel must change its residency policy to match, or the architecture's savings evaporate in the memory system.

Last stop on the attention line: sparsity. FlashMLA additionally accepts an indices tensor (batch, qlen, topk) and gathers the selected tokens from paged KV directly into SMEM by index, materializing nothing intermediate. This is the execution endpoint of DSA (Lecture 3). The full per-step chain in DeepSeek V3.2/V4 is three kernels:

1 · indexer scoring DeepGEMM, MQA shape, FP8, paged scores all L tokens (132 B/token keys) scores (L) 2 · fused top-k selects k = 2048 indices (TileLang reference impl.) indices (k) 3 · FlashMLA sparse attention over the k selected tokens, gathered from paged KV straight into SMEM by index per-step attention traffic: O(L) → O(k), a ×64 cut at L = 128K, k = 2048 fine print: the indexer still reads 132 B × L ≈ 17 MB per step (thin: small keys, FP8)
How to: follow the shrinking payload on the arrows: scores of all \(L\) tokens, then \(k\) indices, then attention over only \(k\) gathered tokens. The dominant traffic term dropped from \(O(L)\) to \(O(k)\); the cost you bought is two extra kernels on the launch chain and one materialized indices tensor.
Important: do not read "sparse attention" as "the \(O(L)\) term is gone". The chain moves the \(O(L)\) work into indexer scoring (thin: FP8, 132 B/token) and pays two extra kernels plus indices materialization. The attention drops to \(O(k)\); the chain does not.

MoE: 256 Experts, 8 Tokens Each

Enough attention. The other half of the decode step is the MoE FFN. Under expert parallelism (Lecture 6) each GPU owns \(E_{local}\) experts and, every step, must run one GEMM per local expert. Two facts conspire against you. First, the per-expert token counts \(M_i\) are unequal (routing is data-dependent) and known only at runtime. Second, and this is the killer: at decode they are tiny. With \(B = 256\) requests and top-8 routing over 256 experts, the mean is \(M_i = 8\).

Why not one launch per expert? Two failures. Launch overhead multiplies by the expert count: \(E_{local} \times\) (Linear1 + SwiGLU + Linear2) at a few microseconds each (we will price a launch very soon). More fundamentally, tile quantization: tensor-core GEMMs compute in tiles of, say, 128 rows, so a GEMM with \(M_i = 8\) useful rows still pays for full tiles: \[ \eta_{tile} = \frac{\sum_i M_i}{\sum_i \lceil M_i / T_M \rceil\, T_M} = \frac{\text{useful FLOPs}}{\text{executed tile FLOPs}}, \qquad M_i = 8,\ T_M = 128 \Rightarrow \eta_{tile} = 6.25\%. \] The tensor cores execute 16× more work than is useful (random routing barely helps: a multinomial draw of 2048 assignments over 256 experts still gives \(\eta \approx 6.25\%\)). At prefill the same shape is fine: 16K tokens × top-8 over 256 experts gives \(M_i \approx 512 = 4 \times 128\), so \(\eta \approx 100\%\). The MoE GEMM efficiency problem is a decode-phase, small-\(M\) problem. That is why its solutions (persistent kernels, masked layouts, fusion) all target the decode path.

? Quick drill: a decode step routes tokens to 8 local experts with counts \(M = [5, 12, 3, 27, 16, 9, 1, 31]\), row tiles \(T_M = 16\). Compute \(\eta_{tile}\) yourself before opening, and ask where a second, non-tile waste is hiding.

Answer

Useful rows: \(5+12+3+27+16+9+1+31 = 104\). Executed rows: \(16+16+16+32+16+16+16+32 = 160\). So \(\eta_{tile} = 104/160 = 65\%\). Better than 6.25%, because \(T_M\) is small, but a third of the tensor-core work is still padding. The hidden second waste: 8 separate launches also pay 7 extra launch costs and strand SMs at each expert's wave boundary. Both are gone under one grouped launch, before tile efficiency even enters the picture.

Grouped GEMM is the fix: one kernel schedules all subproblems \(\{(M_i, N, K)\}_{i=1}^{E_{local}}\). A routine builds a tile-level task list (every (expert, row-tile, col-tile) triple) and a persistent kernel pulls tiles from the queue. Tiles of different experts interleave freely across SMs, so small-\(M_i\) GEMMs no longer strand SMs at per-expert wave boundaries: the SM pool sees one long queue of same-shaped tile tasks. Tile quantization within each expert remains (pooling does not shrink tiles), but wave quantization and launch multiplicity are gone. One further design choice is a phase decision:

Engineering Takeaway: never launch per-expert GEMMs on the decode path: you pay \(E_{local}\) launches and run each at ~6% tile efficiency. Route through a masked-layout grouped GEMM (static shape, one launch, CUDA-graphable) and accept capacity padding as the price of graph compatibility. Choose \(C\) from a measured quantile of \(\max_i M_i\) at your batch size, not the average.

The library executing this is DeepGEMM: FP8/FP4/BF16 GEMMs, grouped GEMM in both layouts, the DSA indexer, and the Mega MoE kernels, all JIT-compiled. Two of its choices deserve a close look. Fine-grained FP8 scaling: naive FP8 keeps one scale per tensor, and with ~4 mantissa bits (E4M3) the dynamic range is poor; DeepGEMM gives every \(1 \times 128\) activation strip and every \(128 \times 128\) weight block its own scale, applied at dequantization, plus two-level accumulation: FP8 tensor-core accumulators are not precise over long \(K\) reductions, so partial sums are periodically promoted to higher precision on CUDA cores. Bolted-on FP8 with per-tensor scales never reaches usable accuracy: "just cast to FP8" is not a strategy for V3-class models. JIT as philosophy: no precompiled template zoo. Each concrete \((M, N, K)\), layout, and precision gets a specialized, cached kernel with tiles, unrolls, and pipeline depth matched to it. The cost is first-call compile latency: profile a JIT kernel cold and you measure the compiler, not the kernel. Published: ≈ 1550 TFLOPS FP8 on H800 (≈ 78% of the ≈ 1979 TFLOPS FP8 peak, a high MFU for a fine-grained-scaled GEMM), built on TMA + warp specialization on Hopper, tcgen05/TMEM + native FP4 on Blackwell.

Completing the MoE main line, DeepEP is the communication library behind Lecture 6's all-to-alls, in two phase-specific modes. Normal mode (prefill) aggregates tokens intra-node over NVLink (≈ 900 GB/s), then forwards cross-node over RDMA (≈ 50 GB/s per GPU), minimizing expensive inter-node bytes. Low-latency mode (decode) skips the aggregation hop and sends pure RDMA point-to-point, on the order of a hundred microseconds, with a hook mechanism: dispatch/combine return right after initiation, reception runs in the background, and overlap interleaves at micro-batch granularity (while micro-batch 1's tokens are in flight, micro-batch 2's experts compute). Everything is GPU-initiated RDMA (the IBGDA/NVSHMEM line: GPU threads ring the doorbells, no CPU proxy), and precision follows Lecture 6's contract: dispatch FP8, combine BF16.

Between the Kernels: Launch Overhead, CUDA Graphs, and Fusion

So far we optimized inside kernels. Zoom out one level: a forward pass of a 60–80-layer model runs a dozen-plus kernels per layer, hundreds per step. In prefill, kernels run hundreds of microseconds and launch cost is noise; in decode, kernels run microseconds, and now the stuff between kernels is the leak. Let a decode step execute \(n_k\) kernels with total GPU execution time \(T_{exec}\), each launch costing \(t_{launch}\) of CPU submission/queueing plus \(t_{gap}\) of inter-kernel GPU idle. If launches don't fully overlap execution (small batches: the GPU drains faster than the CPU enqueues), \[ T_{step} = T_{exec} + n_k\,(t_{launch} + t_{gap}). \] Substitute a 70B-class model: \(80 \times \approx 12\) kernels per layer gives \(n_k \approx 960 \approx 10^3\), and with \(t_{launch} + t_{gap} \approx 2\text{–}5\,\mu s\): \[ n_k (t_{launch} + t_{gap}) \approx 1000 \times (2\text{–}5\,\mu s) = 2\text{–}5\ \text{ms}, \] against a small-batch decode step of ≈ 10 ms. 20–50% of the step is launch machinery, not math. No kernel-level optimization can reclaim it; the overhead lives between kernels.

Worked example (launch-overhead percentage): \(n_k = 960\), \(t_{launch} + t_{gap} = 3.5\,\mu s\), \(T_{step} = 10\) ms eager. Overhead: \(960 \times 3.5\,\mu s = 3.4\) ms ⇒ 34%, leaving \(T_{exec} = 6.6\) ms. A CUDA-graph replay collapses the second term to one launch: new step ≈ 6.6 ms, a 1.5× faster decode with zero kernels rewritten. (At 2 µs the share is 19%; at 5 µs, 48%. Hence "double-digit percentage" as the conservative claim.)
eager: CPU submits ~960 kernels, 3.5 µs each … CPU still queueing while the GPU idles … GPU work (6.6 ms total) red gaps: launch machinery, 3.4 ms = 34% one eager step: 10 ms CUDA graph: capture once, replay in one launch the same ~960 kernels, back-to-back, no gaps ≈ 6.6 ms → 1.5× faster, zero kernels rewritten
How to: compare the two bars. Eager alternates work with red launch gaps: at decode sizes the GPU drains each kernel faster than the CPU can feed the next. The graph replay runs the same kernels in the same order with the gaps squeezed out; the 34% you lost reappears as speedup.

CUDA graphs capture the whole step's kernel sequence (parameters, dependencies, stream structure) into a replayable object; each subsequent step is one graph launch, and both overhead terms die together. The catch is static shapes and addresses: inside a captured graph every buffer pointer and shape is frozen. Three engine mechanisms jointly satisfy the freeze:

  1. Batch-size buckets. Capture several graphs for different batch sizes, pad to the nearest bucket at runtime. With bucket width \(w\) and batch size uniform on \([1, B_{max}]\), mean padding is \((w-1)/2\) tokens per step: \[ \mathbb{E}[\text{pad}]/\mathbb{E}[B] \approx \frac{w}{2\,\mathbb{E}[B]}, \qquad w = B_{max}/K\ \text{for}\ K\ \text{buckets.} \] At \(B_{max} = 256\): \(K = 4\) buckets (\(w = 64\)) waste 24.5% of compute on average; \(K = 16\) wastes 5.8%; \(K = 32\) wastes 2.7%. Graph count buys back padding at a \(1/K\) rate, at the price of capturing and storing \(K\) graphs.
  2. Paged indirection. The KV cache grows every step: an inherently dynamic address. The graph freezes not the KV pages but the block-table pointer; the pager updates the table each step and kernels dereference pages through it at fixed addresses. Paging is not only memory management; it is what makes growing KV graph-capturable.
  3. Masked grouped GEMM. MoE's data-dependent \(M_i\) would change shapes every step; the fixed-capacity masked layout keeps the shape static while the count varies inside it.
Engineering Takeaway: treat "is it graph-capturable?" as a design constraint on every kernel you add to the decode path: static shapes, static buffer addresses, no host-side branches on tensor values. Indirection (block tables, counts, fixed capacities) is the standard tool for hiding dynamism from the graph. And size your bucket set with the formula above: batches concentrated near \(B_{max}\) need few wide buckets; a flat distribution pays for more graphs.

But do not celebrate yet: graphs kill launch overhead; the kernel boundaries remain, and every boundary is a global sync. The expert GEMM cannot start until dispatch has fully finished, and combine cannot start until the GEMM finishes. During communication the SMs idle; during compute the network idles. DeepEP's hooks mitigate this at micro-batch granularity, but overlap can never cross a kernel boundary.

Important: graphs remove the launch term of \(T_{step} = T_{exec} + n_k(t_{launch} + t_{gap})\), not inter-kernel synchronization. dispatch→GEMM→combine still serialize inside a captured graph. Bubbles inside the graph call for fusion (Mega MoE) or hook overlap (DeepEP), not "more graphs".

Mega MoE (in DeepGEMM) takes the logical endpoint: fuse the entire MoE layer (dispatch, Linear1, SwiGLU including re-quantization, Linear2, combine) into one kernel, via two enablers. SymmBuffer (symmetric memory): buffers with an identical address layout on every rank of an NVLink domain, so remote GPU memory is read and written with ordinary load/store instructions. Communication becomes a memory instruction inside the compute kernel. Warp specialization, generalized: dispatch warps pull this rank's tokens out of the symmetric buffer and track arrivals; MMA warps run the two GEMMs; epilogue warps do SwiGLU, re-quantization to FP8, and combine write-back, handing off at tile granularity, tokens joining the GEMM as soon as they arrive. No "all dispatch complete" sync point exists. (Precision: FP4 weights + FP8 activations, SM100 primarily, with an SM90 adaptation.)

DeepEP (separate)Mega MoE (fused)
Communicationstandalone comm kernel, GPU-initiated RDMA (IBGDA/NVSHMEM) in-kernel NVLink load/store on symmetric memory
Scopecross-node (RDMA) + intra-nodewithin the NVLink symmetric-memory domain
Overlap granularitymicro-batch (hooks)tile
Synchronizationglobal sync at every kernel boundarynone; compute-on-arrival
Positioninggeneral-purpose; overlap via hooksend-to-end optimal within its domain

Which route wins is a hardware question: the fused route's scope is the NVLink-domain size. An 8-GPU domain covers only small EP degrees; NVL72 puts 72 GPUs into one symmetric-memory domain, so decode-scale EP fits entirely inside the fused route's scope. That is the hardware background of its current prominence. And the phase asymmetry returns: decode is IO-bound with a high launch/sync share, so fusion pays off most there; prefill's large GEMMs already run near the compute ceiling.

The 2026 Line: When the Kernel Problem Inverts

The 2026 frontier models changed the kernel workload itself: Kimi K3 and GLM-5.3-Flash make gated delta-rule linear attention (KDA) the dominant layer type, run routed experts in native MXFP4, and draft with block diffusion instead of autoregressive heads. KDA keeps a matrix-valued recurrent state \(S \in \mathbb{R}^{128 \times 128}\) per head (96 heads), updated as \(S_t = (I - \beta k k^{\top})\, \mathrm{Diag}(\alpha)\, S_{t-1} + \beta k v^{\top}\). There is no \(L \times L\) score matrix at all. The kernel problem inverts FlashAttention's: not "avoid materializing \(S, P\)" but "read, update, and write a per-head matrix state efficiently".

Note (notation collision): in that update, \(\beta\) and \(k\) are the delta-rule step and key, not this course's bandwidth \(\beta\) or draft length \(k\). Blame the Greek alphabet's finite size.

On the prefill side the recurrence goes chunkwise: a within-chunk parallel pass plus cross-chunk state passing (chunk size 64, 16-token secondary tiles), the state carried across chunk boundaries exactly like the \((m, \ell, O)\) triple. FlashKDA implements this in CUTLASS for SM90+ (the FLA library auto-dispatches to it). It is the first linear-attention kernel line with FlashAttention-class maturity. At decode, each KDA layer reads its full state, applies one rank-one-ish update, writes it back. Per layer per step: \[ 96\ \text{heads} \times 128 \times 128\ \text{elem} \times 2\ \text{B (BF16)} = \text{3.0 MiB} \quad \text{(fixed, independent of } L\text{)}, \] so K3's 69 KDA layers cost \(69 \times 3.0\) MiB = 207 MiB ≈ 0.2 GiB of state traffic per token-step. And the fused KDA decode kernel does it in one launch (short causal conv of kernel size 4, recurrent update, gated output RMSNorm; all states updated in place, no intermediate tensors, no per-op launches).

Worked example: decode-step attention traffic, K3 hybrid vs. its own MLA layers at \(L = 1\)M. KDA layers (69): \(3.0\ \text{MiB} \times 69 = 207\) MiB, constant in \(L\). Gated-MLA layers (24): \(1{,}152\ \text{B/token} \times 2^{20}\ \text{tokens} \times 24\) = 27.0 GiB (BF16; FP8 halves it). Total ≈ 27.2 GiB; the KDA share is \(207\ \text{MiB} / 27.2\ \text{GiB} \approx 0.7\%\). Had all 93 layers been MLA, the read would be ≈ 105 GiB/step. 74% of K3's layers pay \(O(1)\) decode traffic instead of \(O(L)\). That is the kernel-level reason hybrid decode is fast; the residual cost concentrates in the 24 MLA layers, exactly where FlashMLA-style absorbed decode and Lecture 6's DCP apply.

MXFP4 MoE in production. K3's routed experts ship native MXFP4 (QAT from SFT onward; 17.5 MB per expert). Executing them is not "FP8 with fewer bits": the SiTU-GLU activation must be fused into the grouped-GEMM epilogue, and Blackwell's native FP4 datapath is what makes 4-bit weights a bandwidth win rather than a dequantization tax. vLLM's recommended K3 backend is deep_gemm_mega_moe (the fused kernel above, in FP4 production), with flashinfer_trtllm for TP > 1.

TA says: two more 2026 numbers worth memorizing: kernel stories wearing system clothes. DSpark (block-diffusion drafting, Lecture 8 teaser): its kernels are MLA-native. Draft and target share the latent-cache layout, so verification reuses the absorbed FlashMLA shapes. Measured: 118 → 370 tok/s per user on 16× GB300 NVL72 in vLLM (3.14×); SGLang reports 113 → 423. And GLM-5.3-Flash's IndexPool: the DSA chain with index_kpool = 4 pools 4 indexer keys into 1, shrinking the scorer's key traffic 4× before top-2048 selection. (vLLM's sparse-MLA indexer kernels require Hopper+, since gather-by-indices needs the async-copy machinery to hit bandwidth.) The moral of all three: sparse attention, drafting, and quantization only become real when a kernel with the right residency policy exists.

Engineering Takeaway: budget decode-step attention traffic layer-type by layer-type: 3.0 MiB/layer for KDA (fixed) vs. 1,152 B × \(L\)/layer for MLA. Hybrid models make most layers context-independent, so FP8-ing just the MLA cache halves the dominant cost. And treat MXFP4 expert execution as a kernel-stack commitment (SiTU-fused epilogues, deep_gemm_mega_moe), not a dtype flag: the 17.5 MB/expert figure converts into TPOT only if the GEMM consumes FP4 natively.

Common Pitfalls

Important: the six traps this lecture exists to disarm:

Research Thinking

How to: read the starting point; read the question and think, for a minute, a day, a week... and only then open the answers. You are not supposed to reinvent several months of someone's research; each paper took its authors exactly that long. It is the habit of thinking that counts.

? Card 1, "invent" FlashAttention. Starting point: naive attention moves \(4L^2 b_{dtype}\) bytes per query head at \(I \approx 64\), far below the 295 ridge, while FLOPs are a non-issue. The only thing stopping you from never writing \(S, P\) is the softmax denominator, which "needs the whole row". Question: can you reorganize the computation so the row is processed in pieces yet the final result is exactly softmax? What state must you carry between pieces, and what goes wrong with past partial results whenever a new piece contains a bigger score?

Existing solutions

Carry a running max \(m\) and running sum \(\ell\), and deflate all past partials by \(e^{m_{old} - m_{new}}\) when the max grows. Then \(S_j, P_j\) never need HBM at all. That is the online-softmax recurrence; tiled with the \(Q\)-outer/KV-inner loop it is FlashAttention (FA1), later re-parallelized along Q (FA2) and rebuilt around Hopper's async machinery (FA3). Notice the shape of the discovery: the math was old; the contribution was seeing that traffic, not FLOPs, was the constraint: a roofline argument made into a kernel.

? Card 2, where is decode's parallelism? Starting point: FA2 fills 132 SMs by splitting the grid along Q; at decode \(q_{len} = 1\) per request, so a small batch owns a handful of blocks and most of the GPU idles. Question: which axis is left to split, what does each piece produce, and how do you recombine pieces that each normalized against a different local maximum?

Where this leads

Split the KV axis: each segment produces \((m_s, \ell_s, O_s)\), and a small reduction merges them with LSE-rescaled weights \(e^{\mathrm{LSE}_s - \mathrm{LSE}^*}\). This is exact, since the segments partition \([L]\). This is split-KV / flash-decoding inside a GPU, and the same merge across GPUs is Lecture 6's DCP. The 2026-era line adds the absorbed-cache twist (FlashMLA: 128 heads consume one shared 576-dim latent) and the sparse twist (DSA: gather only \(k\) selected tokens). It is still the same recurrence, with different machinery deciding which bytes arrive.

? Card 3, after the graph, what? Starting point: CUDA graphs collapsed \(n_k(t_{launch} + t_{gap})\) to one launch, yet the MoE decode step still shows bubbles: SMs idle during dispatch, the network idles during the GEMM. Question: name the two structurally different fixes. What does each assume about your interconnect, and which wins as the NVLink domain grows from 8 to 72 GPUs?

Existing designs

(i) Shrink the bubbles at the boundaries: DeepEP's low-latency hooks return after initiation, receive in the background, and overlap at micro-batch granularity. Works anywhere, even cross-node; overlap stays coarse. (ii) Erase the boundaries: Mega MoE fuses dispatch, both GEMMs, activation, and combine into one kernel over symmetric memory, tile-granularity hand-offs, no global sync. But this works only inside the NVLink domain. As domains grow to 72 GPUs, decode-scale EP fits entirely in route (ii); cross-node prefill keeps route (i). One more design exercise in the same spirit: your batch-size histogram has mass concentrated under 64 with a hard wall at 512. The \(w/(2\mathbb{E}[B])\) formula assumed a uniform batch distribution, so choose buckets from measured quantiles of the real histogram, dense where the mass is. The same quantile logic sizes the masked layout's capacity \(C\): every "static shape" mechanism converts a distribution question into a constant, so pick constants from measurement.

Have Fun! Squash the Running Max

Enough theory. Now you be the reduction kernel. Below are two KV blocks, each with its local stats. Move the sliders and watch what happens to the old block when the new block's max exceeds it. Can you make the old block's merge weight drop below 0.1? Below 0.01? (Defaults are the first component of our worked example: \(m_1 {=} 2, \ell_1 {=} 8, O_1 {=} 4\) vs \(m^{(2)} {=} 3, \sum e^{s-3} {=} 6, \sum e^{s-3}v {=} 18\).)

Block A (old)
m₁ 2
ℓ₁ 8
O₁ 4
Block B (new)
m₂ 3
ℓ₂ 6
O₂ 18
How to: raise Block B's m₂ and watch the old block's weight collapse exponentially. That em_old − m_new is the entire FlashAttention correction, and it is also the DSA/DCP cross-GPU merge weight. Never be afraid of a formula you can push to zero with a slider.

Seminar & Homework

Take Lab 7: Kernels (FlashAttention, Launch Overhead, CUDA Graphs) from the hands-on pack. Three experiments, one GPU:

  1. Make the traffic argument visible (NGC PyTorch container): one attention layer, 32 heads, \(d_h = 128\), BF16. Time naive (\(QK^\top\) + softmax + \(PV\), materializing \(S, P\)) vs. F.scaled_dot_product_attention at \(L \in \{4K, 16K, 64K\}\) (warm up 20 iterations, time 100 with torch.cuda.Event) and convert to achieved GB/s. The expected picture:
    \(L\)naive trafficnaive time @ βflash trafficflash time @ βratio
    4K3.0 GiB0.96 ms128 MiB0.04 ms24×
    16K48 GiB15.4 ms512 MiB0.16 ms96×
    64K768 GiB246 ms2.0 GiB0.64 ms384×
    Both kernels achieve a similar fraction of β, so the traffic ratio, not the FLOPs, explains the gap, and the ratio grows linearly in \(L\) (why? connect it to Lecture 3's \(L^*\) crossover).
  2. Count the kernels (vLLM container): nsys profile -o decode_step vllm serve ... at \(B{=}1\), drive 200 single-stream decode requests, open the trace, count kernels between two sampled tokens. Expect ~300+ per 8B decode step (32 layers × 8–12 kernels + glue); at 3–5 µs of CPU launch work each, ~1.0–1.7 ms of launch path against a ~5–12 ms step.
  3. Flip the graph switch: repeat with --enforce-eager; compare step time and CPU-side launch gaps. Expect eager to hurt far more at \(B{=}1\) than at \(B{=}64\): fat kernels hide launch latency.
Note (counting convention): the lab's quick estimate for naive traffic counts ≈ \(3L^2 n_h b_{dtype}\) per layer, while this lecture's full count is \(4L^2\) per query head; the lab folds one softmax crossing differently. Both give the same conclusion and the same table trend; just know which count you used before comparing absolute numbers.

Lab pitfalls, verbatim-worthy: no warm-up (first-call JIT/capture contaminates the trace); timing with time.time() instead of CUDA events (async launches lie); profiling at \(B{=}1\) only and generalizing to serving; counting host time as kernel time. The analysis questions you will answer there: which kernels dominate decode step time (GEMMs, attention, or elementwise), and does it match the \(2P/\beta\) floor?; what exactly do CUDA graphs trade (capture cost, memory, batch-shape rigidity; why does vLLM capture only specific batch sizes)?; at what batch size does --enforce-eager stop mattering, and why?; and where would DeepGEMM-style JIT show up in your trace, and which benchmarking pitfall does that motivate?

Summary

← Lecture 6: Parallelism Lecture 8: MTP and Speculative Decoding →