AI/ML Attention Systems ML FlashAttention PyTorch

Attention Engineering: From MHA to GQA, MLA & FlashAttention


When Vaswani et al. introduced Multi-Head Attention (MHA) in 2017, sequence lengths were 512 tokens and GPU compute was the primary bottleneck. At 128K+ context windows on modern Hopper/Blackwell GPUs, the physics of transformer scaling have inverted: attention is overwhelmingly bottlenecked by High-Bandwidth Memory (HBM) traffic, not FLOPs.

Every major architectural leap in frontier LLMs over the past four years (including Rotary Position Embeddings (RoPE), Grouped-Query Attention (GQA), DeepSeek's Multi-Head Latent Attention (MLA), and FlashAttention-2/3) is a direct mathematical response to two hardware constraints: quadratic $\mathcal{O}(S^2)$ activation I/O during training and linear $\mathcal{O}(S)$ KV-cache memory bandwidth during autoregressive decoding. Here is the complete first-principles derivation and pure-PyTorch implementation of the modern attention stack.


0. The Hardware Bottleneck: Arithmetic Intensity & KV-Cache Scaling

To understand why standard Multi-Head Attention fails at scale, we must separate the two regimes of LLM execution: Prefill / Training (parallel over sequence length $S$) and Autoregressive Decoding (sequential, generating 1 token at a time against $S$ cached tokens).

KV-Cache Memory Footprint & Arithmetic Intensity
$$M_{\text{KV}} = 2 \cdot B \cdot S \cdot L \cdot (n_{\text{kv}} d_h) \cdot b_{\text{bytes}}, \qquad \mathcal{I} = \frac{\text{FLOPs}}{\text{Bytes Transferred (HBM} \leftrightarrow \text{SRAM)}}$$

where $B$ is batch size, $S$ is sequence length, $L$ is layer count, $n_{\text{kv}}$ is KV head count, $d_h$ is head dimension, and $b_{\text{bytes}} \in \{1, 2\}$ (FP8 vs. BF16). On an NVIDIA H100 SXM5 ($989\text{ TFLOP/s}$ BF16 dense, $3.35\text{ TB/s}$ HBM3), the ridge point is $\mathcal{I}_{\text{ridge}} \approx 295\text{ FLOPs/byte}$. Any kernel with arithmetic intensity $\mathcal{I} < 295$ is strictly memory-bandwidth bound.

During autoregressive decoding of a single query token ($S_q = 1$) attending to $S$ cached keys and values in BF16 ($2\text{ bytes/elem}$), computing $q K^\top$ requires $2 \cdot n_q \cdot d_h \cdot S$ FLOPs while streaming $2 \cdot n_{\text{kv}} \cdot d_h \cdot S$ bytes of $K$ from HBM. Adding the attention-value contraction $P V$ adds another $2 \cdot n_q \cdot d_h \cdot S$ FLOPs while streaming $2 \cdot n_{\text{kv}} \cdot d_h \cdot S$ bytes of $V$. Thus, the arithmetic intensity of decoding attention is:

Decoding Attention Arithmetic Intensity
$$\mathcal{I}_{\text{decode}} \approx \frac{2 \cdot (n_q + n_q) \cdot d_h \cdot S}{2 \cdot (2 \cdot n_{\text{kv}} \cdot d_h \cdot S)} = \frac{n_q}{n_{\text{kv}}} \;\text{FLOPs/byte}$$

In standard MHA ($n_q = n_{\text{kv}}$), $\mathcal{I}_{\text{decode}} \approx 1\text{ FLOP/byte}$, nearly 300× below the H100 compute ridge point. The GPU tensor cores sit idle > 99% of the time waiting for KV-cache bytes to arrive from HBM. Every architectural innovation below attacks either the KV-cache footprint ($n_{\text{kv}} d_h$) or the HBM traffic of the $S \times S$ attention matrix.

1. Standard MHA nq = nkv = 8 (1:1 Ratio) Query Heads (Q₁..Q₄) Cached K, V (2 · nh · dh) KV Cache: 100% Baseline Idecode ≈ 1 FLOP/byte 2. Multi-Query (MQA) nkv = 1 (Shared Single Head) Query Heads (Q₁..Q₄) Single K₁, V₁ (2 · dh) KV Cache: 1/nh (Max Drop) Quality loss at high scale 3. Grouped-Query (GQA) G = nq / nkv Groups Query Heads (Q₁..Q₄) Group K, V (2 · nkv · dh) KV Cache: 4×–8× Smaller Idecode = G FLOPs/byte 4. DeepSeek MLA Low-Rank Latent Compression Absorbed Queries qi · WUK Latent ctKV ktR Cached (dc + dR) ≪ 2·nh·dh KV Cache: ~93% Reduction Outperforms MHA quality
Figure 1: Evolution of attention KV caching from Standard MHA (100% cache) to MQA, GQA, and DeepSeek's Multi-Head Latent Attention (MLA) with decoupled RoPE key $k_t^R$ and absorbed latent vector $c_t^{KV}$.

1. Rotary Position Embeddings (RoPE) & Long-Context Scaling

Absolute positional embeddings ($x_m + p_m$) break translation invariance, while additive relative biases ($q_m^\top k_n + b_{m-n}$) prevent linear-time KV caching or FlashAttention fusion. Su et al. (2021) solved this by asking: what mapping $f(x, m)$ guarantees that the inner product $\langle f(q, m), f(k, n) \rangle$ depends strictly on $q$, $k$, and their relative distance $(m - n)$?

Complex-Plane Derivation

Partitioning a head dimension $d_h$ into $d_h/2$ orthogonal 2D subspaces, we represent each pair $(x_{2j}, x_{2j+1})$ as a complex number $z_j = x_{2j} + i x_{2j+1} \in \mathbb{C}$. Rotating $z_j$ at token position $m$ by angle $m \theta_j$ via Euler's identity $e^{i m \theta_j}$ yields:

RoPE Relative-Distance Invariance Identity
$$\langle \text{RoPE}(q, m)_j,\; \text{RoPE}(k, n)_j \rangle = \text{Re}\!\left( (q_j e^{i m \theta_j}) (k_j e^{i n \theta_j})^* \right) = \text{Re}\!\left( q_j k_j^* e^{i (m - n) \theta_j} \right)$$

where the base frequencies follow a geometric progression $\theta_j = \text{base}^{-2j / d_h}$ for $j \in \{0, 1, \dots, d_h/2 - 1\}$ with $\text{base} = 10{,}000$ (or $500{,}000$ in Llama-3). In real matrix form, each 2D block applies $\begin{pmatrix}\cos m\theta_j & -\sin m\theta_j \\ \sin m\theta_j & \cos m\theta_j\end{pmatrix}$.

Why Naive Context Extrapolation Fails (and How YaRN Fixes It)

Each subspace $j$ has wavelength $\lambda_j = \frac{2\pi}{\theta_j} = 2\pi \cdot \text{base}^{2j/d_h}$. When stretching context from pre-training length $L_{\text{train}}$ to $L_{\text{ext}} = s \cdot L_{\text{train}}$:

  • Linear Position Interpolation (PI) scales all frequencies uniformly ($\theta_j' = \theta_j / s$), crowding high-frequency local token distinctions together and blurring local syntax.
  • NTK-Aware & YaRN Scaling (Peng et al., 2023) partitions dimensions by wavelength ratio $r_j = L_{\text{train}} / \lambda_j$: high-frequency dimensions ($r_j > \beta_{\text{fast}}$, many full rotations seen during training) are not interpolated at all, low-frequency dimensions ($r_j < \alpha_{\text{slow}}$) are linearly interpolated by $1/s$, and intermediate dimensions are smoothly blended via a ramp function $\gamma(r_j)$, paired with an attention temperature correction $\sqrt{1/t} = 0.1 \ln(s) + 1$.

2. Grouped-Query Attention (GQA)

Grouped-Query Attention (Ainslie et al., 2023) partitions $n_q$ query heads into $n_{\text{kv}}$ groups of size $G = n_q / n_{\text{kv}}$. Every query head $q_{g, i}$ ($i \in \{1,\dots,G\}$) within group $g \in \{1,\dots,n_{\text{kv}}\}$ shares the exact same key $K_g \in \mathbb{R}^{S \times d_h}$ and value $V_g \in \mathbb{R}^{S \times d_h}$:

Grouped-Query Attention Formulation
$$\text{Attention}_{g, i}(Q_{g,i}, K_g, V_g) = \text{softmax}\!\left(\frac{\text{RoPE}(Q_{g,i})\,\text{RoPE}(K_g)^\top}{\sqrt{d_h}} + M\right) V_g$$
Memory Trap: Never Materialize repeat_interleave in Production: Naive implementations call K.repeat_interleave(G, dim=1) before matrix multiplication, duplicating the KV tensor $G$ times in HBM. Instead, reshape $Q$ to [B, n_kv, G, S_q, d_h] and broadcast against $K$ of shape [B, n_kv, 1, S_kv, d_h] (or use F.scaled_dot_product_attention(..., enable_gqa=True) in PyTorch 2.5+) so the broadcast happens in registers inside the fused kernel.
💻 PyTorch Implementation: Zero-Copy Broadcast GQA with Complex/Real RoPEPure PyTorch
import math
import torch
import torch.nn as nn
import torch.nn.functional as F

def precompute_rope_cache(seq_len: int, head_dim: int, base: float = 500000.0, device=None):
    """Precomputes cos and sin tables of shape [1, 1, seq_len, head_dim // 2]."""
    theta = 1.0 / (base ** (torch.arange(0, head_dim, 2, device=device).float() / head_dim))
    positions = torch.arange(seq_len, device=device).float()
    freqs = torch.outer(positions, theta)  # [seq_len, head_dim // 2]
    return freqs.cos()[None, None, :, :], freqs.sin()[None, None, :, :]

def apply_rope(x: torch.Tensor, cos: torch.Tensor, sin: torch.Tensor) -> torch.Tensor:
    """Applies Rotary Position Embedding to x of shape [B, H, S, D]."""
    x_even = x[..., 0::2]
    x_odd = x[..., 1::2]
    rot_even = x_even * cos - x_odd * sin
    rot_odd = x_even * sin + x_odd * cos
    return torch.stack([rot_even, rot_odd], dim=-1).flatten(-2)

class GroupedQueryAttention(nn.Module):
    def __init__(self, d_model: int, n_q: int = 32, n_kv: int = 8, head_dim: int = 128):
        super().__init__()
        assert n_q % n_kv == 0, "n_q must be divisible by n_kv"
        self.n_q = n_q
        self.n_kv = n_kv
        self.group_size = n_q // n_kv
        self.head_dim = head_dim
        self.scale = 1.0 / math.sqrt(head_dim)

        self.wq = nn.Linear(d_model, n_q * head_dim, bias=False)
        self.wk = nn.Linear(d_model, n_kv * head_dim, bias=False)
        self.wv = nn.Linear(d_model, n_kv * head_dim, bias=False)
        self.wo = nn.Linear(n_q * head_dim, d_model, bias=False)

    def forward(self, x: torch.Tensor, cos: torch.Tensor, sin: torch.Tensor) -> torch.Tensor:
        B, S, _ = x.shape
        q = self.wq(x).view(B, S, self.n_q, self.head_dim).transpose(1, 2)   # [B, n_q, S, D]
        k = self.wk(x).view(B, S, self.n_kv, self.head_dim).transpose(1, 2)  # [B, n_kv, S, D]
        v = self.wv(x).view(B, S, self.n_kv, self.head_dim).transpose(1, 2)  # [B, n_kv, S, D]

        q = apply_rope(q, cos[:, :, :S, :], sin[:, :, :S, :])
        k = apply_rope(k, cos[:, :, :S, :], sin[:, :, :S, :])

        # Zero-copy grouped broadcast: reshape Q -> [B, n_kv, G, S, D], K/V -> [B, n_kv, 1, S, D]
        q_grouped = q.view(B, self.n_kv, self.group_size, S, self.head_dim)
        k_grouped = k.unsqueeze(2)
        v_grouped = v.unsqueeze(2)

        scores = torch.matmul(q_grouped, k_grouped.transpose(-2, -1)) * self.scale
        causal_mask = torch.triu(torch.full((S, S), float("-inf"), device=x.device), diagonal=1)
        attn = F.softmax(scores + causal_mask, dim=-1)

        out = torch.matmul(attn, v_grouped)  # [B, n_kv, G, S, D]
        out = out.reshape(B, self.n_q, S, self.head_dim).transpose(1, 2).reshape(B, S, -1)
        return self.wo(out)

3. Multi-Head Latent Attention (MLA): Low-Rank KV Compression & Weight Absorption

While GQA shrinks the KV cache by tying heads together, it restricts every head in a group to identical key/value subspaces. Introduced in DeepSeek-V2 and DeepSeek-V3 (Liu et al., 2024), Multi-Head Latent Attention (MLA) achieves a smaller KV cache than GQA while preserving distinct per-head keys and values.

Step 1: Joint Low-Rank KV Compression

Instead of caching full per-head keys and values, MLA projects the hidden state $h_t \in \mathbb{R}^{d}$ into a compressed latent vector $c_t^{KV} \in \mathbb{R}^{d_c}$ (where $d_c = 512 \ll n_h d_h = 16{,}384$ in DeepSeek-V3), and reconstructs per-head content keys $k_{t,i}^C$ and values $v_{t,i}^C$ via up-projections $W_i^{UK}, W_i^{UV} \in \mathbb{R}^{d_h \times d_c}$:

MLA Latent Down-Projection & Up-Projection
$$c_t^{KV} = W^{DKV} h_t \in \mathbb{R}^{d_c}, \qquad k_{t,i}^C = W_i^{UK} c_t^{KV}, \qquad v_{t,i}^C = W_i^{UV} c_t^{KV}$$

Step 2: The Matrix Absorption Trick (Why $W^{UK}$ and $W^{UV}$ Vanish at Decode Time)

If we had to materialize $k_{t,i}^C$ and $v_{t,i}^C$ during decoding, we would blow up HBM bandwidth again. Observe what happens when we compute the content attention score between query $q_{t,i}^C \in \mathbb{R}^{d_h}$ and historical key $k_{s,i}^C$:

Associative Weight Absorption Identity
$$(q_{t,i}^C)^\top k_{s,i}^C = (q_{t,i}^C)^\top \!\left(W_i^{UK} c_s^{KV}\right) = \underbrace{\left((W_i^{UK})^\top q_{t,i}^C\right)^\top}_{\tilde{q}_{t,i}^C \in \mathbb{R}^{d_c}} c_s^{KV}$$

Similarly, the attention output $\sum_{s} \alpha_{t,s,i} v_{s,i}^C = \sum_s \alpha_{t,s,i} (W_i^{UV} c_s^{KV}) = W_i^{UV} \!\left(\sum_s \alpha_{t,s,i} c_s^{KV}\right)$ absorbs $W_i^{UV}$ into the output projection $W^O$. Thus, we only ever cache $c_s^{KV} \in \mathbb{R}^{d_c}$ in the KV cache!

Step 3: Why RoPE Breaks Absorption (and How Decoupled RoPE Fixes It)

Wait: why can't we apply RoPE directly to $k_{s,i}^C$? If we insert a position-dependent rotation matrix $\mathcal{R}_{t-s}$ between $q_{t,i}$ and $k_{s,i}$, the inner product becomes $(q_{t,i})^\top \mathcal{R}_{t-s} W_i^{UK} c_s^{KV}$. Because $\mathcal{R}_{t-s}$ depends on the relative position $(t-s)$ and does not commute with the rectangular matrix $W_i^{UK}$, we cannot pre-multiply $(W_i^{UK})^\top \mathcal{R}_{t-s}^\top q_{t,i}$ without recomputing it for every historical position $s$!

DeepSeek solves this via Decoupled RoPE: each head splits into a non-positional content vector ($q_{t,i}^C \in \mathbb{R}^{d_h}, k_{t,i}^C \in \mathbb{R}^{d_h}$) and a small positional RoPE vector ($q_{t,i}^R \in \mathbb{R}^{d_R}, k_t^R \in \mathbb{R}^{d_R}$ where $d_R = 64$, with $k_t^R$ shared across all heads like MQA):

Complete MLA Attention Score (Absorbed Content + Shared Decoupled RoPE)
$$s_{t,s,i} = \frac{\overbrace{\left((W_i^{UK})^\top q_{t,i}^C\right)^\top c_s^{KV}}^{\text{Absorbed Latent Dot Product } (\mathbb{R}^{d_c})} + \overbrace{\left(\text{RoPE}(q_{t,i}^R, t)\right)^\top \text{RoPE}(k_s^R, s)}^{\text{Shared Positional Dot Product } (\mathbb{R}^{d_R})}}{\sqrt{d_h + d_R}}$$

Total KV cache per token is just $d_c + d_R = 512 + 64 = 576$ scalars per layer, equivalent to GQA with 2.25 KV heads while maintaining 128 independent query-key-value subspaces.

ht [B, 1, dmodel] Query Split qiC [dh] | qiR [dR] Absorb (WiUK)ᵀ q̃iC = (WiUK)ᵀ qiC Shape: [B, nh, 1, dc] ctKV = WDKV ht Cached Latent [B, S, dc=512] ktR = RoPE(WKR ht) Cached Shared [B, S, dR=64] Fused MLA Score & Out α = softmax((q̃C·cKV + qR·kR) / √D) ut = WO (WiUV (α · cKV))
Figure 2: DeepSeek Multi-Head Latent Attention (MLA) inference dataflow. Up-projection matrices $W_i^{UK}$ and $W_i^{UV}$ are absorbed into query and output projections so only $c_t^{KV}$ ($d_c=512$) and shared $k_t^R$ ($d_R=64$) reside in the HBM KV cache.
💻 PyTorch Implementation: DeepSeek MLA with Unabsorbed Training & Absorbed DecodePure PyTorch
import math
import torch
import torch.nn as nn
import torch.nn.functional as F

class MultiHeadLatentAttention(nn.Module):
    """
    DeepSeek-V2/V3 Multi-Head Latent Attention (MLA) demonstrating exact numerical
    equivalence between standard unabsorbed forward pass and weight-absorbed KV-cache inference.
    """
    def __init__(self, d_model: int = 256, n_heads: int = 8, d_c: int = 64, d_head: int = 32, d_rope: int = 16):
        super().__init__()
        self.n_heads = n_heads
        self.d_c = d_c
        self.d_head = d_head
        self.d_rope = d_rope
        self.scale = 1.0 / math.sqrt(d_head + d_rope)

        # Query projections (content + decoupled RoPE)
        self.wq_c = nn.Linear(d_model, n_heads * d_head, bias=False)
        self.wq_r = nn.Linear(d_model, n_heads * d_rope, bias=False)

        # Joint KV latent down-projection + shared RoPE key projection
        self.w_dkv = nn.Linear(d_model, d_c, bias=False)
        self.wk_r = nn.Linear(d_model, d_rope, bias=False)

        # Per-head KV up-projections stored as [n_heads, d_head, d_c]
        self.w_uk = nn.Parameter(torch.randn(n_heads, d_head, d_c) / math.sqrt(d_c))
        self.w_uv = nn.Parameter(torch.randn(n_heads, d_head, d_c) / math.sqrt(d_c))
        self.wo = nn.Linear(n_heads * d_head, d_model, bias=False)

    def forward_unabsorbed(self, x: torch.Tensor, cos: torch.Tensor, sin: torch.Tensor) -> torch.Tensor:
        """Training / Prefill formulation: explicitly materializes per-head K^C and V^C."""
        B, S, _ = x.shape
        q_c = self.wq_c(x).view(B, S, self.n_heads, self.d_head).transpose(1, 2)  # [B, H, S, d_h]
        q_r = self.wq_r(x).view(B, S, self.n_heads, self.d_rope).transpose(1, 2)  # [B, H, S, d_r]
        q_r = apply_rope(q_r, cos[:, :, :S, :], sin[:, :, :S, :])

        c_kv = self.w_dkv(x)                                                      # [B, S, d_c]
        k_r = self.wk_r(x).unsqueeze(1)                                           # [B, 1, S, d_r]
        k_r = apply_rope(k_r, cos[:, :, :S, :], sin[:, :, :S, :])

        # Up-project latent c_kv -> k_c, v_c of shape [B, H, S, d_h]
        k_c = torch.einsum("bsd,hcd->bhsc", c_kv, self.w_uk)
        v_c = torch.einsum("bsd,hcd->bhsc", c_kv, self.w_uv)

        scores = (torch.matmul(q_c, k_c.transpose(-2, -1)) +
                  torch.matmul(q_r, k_r.transpose(-2, -1))) * self.scale
        mask = torch.triu(torch.full((S, S), float("-inf"), device=x.device), diagonal=1)
        attn = F.softmax(scores + mask, dim=-1)

        out = torch.matmul(attn, v_c)                                             # [B, H, S, d_h]
        return self.wo(out.transpose(1, 2).reshape(B, S, -1))

    def forward_absorbed(self, x: torch.Tensor, cos: torch.Tensor, sin: torch.Tensor) -> torch.Tensor:
        """Inference formulation: caches ONLY c_kv [B, S, d_c] and k_r [B, 1, S, d_r]."""
        B, S, _ = x.shape
        q_c = self.wq_c(x).view(B, S, self.n_heads, self.d_head).transpose(1, 2)  # [B, H, S, d_h]
        q_r = self.wq_r(x).view(B, S, self.n_heads, self.d_rope).transpose(1, 2)
        q_r = apply_rope(q_r, cos[:, :, :S, :], sin[:, :, :S, :])

        # Cached tensors in HBM: only c_kv and shared k_r!
        c_kv = self.w_dkv(x)                                                      # [B, S, d_c]
        k_r = apply_rope(self.wk_r(x).unsqueeze(1), cos[:, :, :S, :], sin[:, :, :S, :])

        # Absorb W_UK into query: q_absorbed = (W_UK)^T q_c -> [B, H, S, d_c]
        q_absorbed = torch.einsum("bhsd,hdo->bhso", q_c, self.w_uk)

        # Compute scores directly against compressed latent c_kv [B, 1, S, d_c]
        scores_c = torch.matmul(q_absorbed, c_kv.unsqueeze(1).transpose(-2, -1))
        scores_r = torch.matmul(q_r, k_r.transpose(-2, -1))
        mask = torch.triu(torch.full((S, S), float("-inf"), device=x.device), diagonal=1)
        attn = F.softmax((scores_c + scores_r) * self.scale + mask, dim=-1)

        # Aggregate in latent space first [B, H, S, d_c], then apply W_UV
        latent_context = torch.matmul(attn, c_kv.unsqueeze(1))                    # [B, H, S, d_c]
        out = torch.einsum("bhso,hdo->bhsd", latent_context, self.w_uv)           # [B, H, S, d_h]
        return self.wo(out.transpose(1, 2).reshape(B, S, -1))

4. FlashAttention & Online Softmax Tiling

Even with GQA or MLA compressing the KV cache during decoding, training and prefill over $S = 64\text{K}$ tokens would still crash if we materialized the $S \times S$ attention matrix $P = \text{softmax}(Q K^\top / \sqrt{d_h})$ in HBM (a single head at $S=64\text{K}$ in FP16 takes $8\text{ GB}$!). Dao et al. (2022, 2023) observed that if we tile $Q$ into blocks of $B_r$ rows and $K, V$ into blocks of $B_c$ columns inside on-chip SRAM ($192\text{ KB}$ per SM on A100, $228\text{ KB}$ on H100), we never have to write $S \times S$ intermediate matrices to HBM.

The Online Softmax Recurrence

Standard softmax requires knowing the global row maximum $m = \max_k s_k$ and partition sum $\ell = \sum_k e^{s_k - m}$ across all $S$ keys before computing the weighted sum over $V$. Online Softmax (Milakov & Gimelshein, 2018; FlashAttention-2) maintains running statistics $(m^{(j)}, \ell^{(j)}, \tilde{O}^{(j)})$ as it streams over key/value tiles $j = 1, \dots, T_c$, rescaling previous accumulations on the fly whenever a new tile increases the maximum:

FlashAttention-2 Exact Tiled Rescaling Recurrence
$$\begin{aligned} m^{(j)} &= \max\!\left(m^{(j-1)},\; \text{rowmax}(S^{(j)})\right), \qquad \widetilde{P}^{(j)} = \exp\!\left(S^{(j)} - m^{(j)}\right) \\ \ell^{(j)} &= e^{m^{(j-1)} - m^{(j)}} \ell^{(j-1)} + \text{rowsum}\!\left(\widetilde{P}^{(j)}\right) \\ \widetilde{O}^{(j)} &= \text{diag}\!\left(e^{m^{(j-1)} - m^{(j)}}\right) \widetilde{O}^{(j-1)} + \widetilde{P}^{(j)} V^{(j)} \end{aligned}$$

In FlashAttention-2, division by the partition function $\ell^{(j)}$ is deferred until the very last tile $T_c$: $O = \text{diag}(\ell^{(T_c)})^{-1} \widetilde{O}^{(T_c)}$, cutting non-matmul FLOPs inside the inner loop and storing only the single scalar log-sum-exp $L = m^{(T_c)} + \log \ell^{(T_c)}$ per row for the backward pass.

GPU HBM (40–80 GB) 3.35 TB/s Bandwidth (H100) Qi Tile [Br × dh] Kj, Vj Tiles [Bc × dh] ✗ S × S Attention Matrix NEVER materialized in HBM! Load 1× Stream j=1..Tc On-Chip SRAM & Registers (228 KB / SM, ~33 TB/s) Inner Loop over Key/Value Blocks j = 1 ... Tc 1. Block Matmul & Max S(j) = Qi Kjᵀ / √dh m(j) = max(m(j−1), rowmax) 2. Online Softmax Rescale αscale = exp(m(j−1) − m(j)) Õ(j) = αscale·Õ(j−1) + P̃(j) Vj 3. Epilogue Writeback (After Final Tile Tc) Normalize Oi = Õ(Tc) / ℓ(Tc) and write Oi + logsumexp Li back to HBM once
Figure 3: FlashAttention-2 tiled execution inside on-chip SRAM. Query block $Q_i$ stays resident in SRAM while $K_j, V_j$ tiles stream past, updating running max $m^{(j)}$ and unnormalized accumulator $\widetilde{O}^{(j)}$ without touching HBM.
💻 PyTorch Implementation: FlashAttention-2 Tiled Online Softmax Kernel SimulationPure PyTorch
import math
import torch

def flash_attention_v2_tiled(
    Q: torch.Tensor,          # [B, H, S, D]
    K: torch.Tensor,          # [B, H, S, D]
    V: torch.Tensor,          # [B, H, S, D]
    block_r: int = 32,
    block_c: int = 32,
    causal: bool = True
) -> tuple[torch.Tensor, torch.Tensor]:
    """
    Exact FlashAttention-2 tiled forward pass with Online Softmax recurrence.
    Returns (O, L) where O is [B, H, S, D] and L is logsumexp [B, H, S].
    """
    B, H, S, D = Q.shape
    scale = 1.0 / math.sqrt(D)
    O = torch.empty_like(Q)
    L = torch.empty((B, H, S), device=Q.device, dtype=torch.float32)

    # Outer loop (parallelized across thread blocks / SMs in CUDA/Triton): iterate over Q tiles
    for r_start in range(0, S, block_r):
        r_end = min(r_start + block_r, S)
        q_tile = Q[:, :, r_start:r_end, :]  # [B, H, B_r, D]
        br = r_end - r_start

        # On-chip SRAM running accumulators in FP32
        m_i = torch.full((B, H, br, 1), float("-inf"), device=Q.device, dtype=torch.float32)
        l_i = torch.zeros((B, H, br, 1), device=Q.device, dtype=torch.float32)
        o_acc = torch.zeros((B, H, br, D), device=Q.device, dtype=torch.float32)

        # In causal attention, skip key blocks strictly to the right of the diagonal
        c_limit = r_end if causal else S

        # Inner loop: stream K_j, V_j tiles through SRAM
        for c_start in range(0, c_limit, block_c):
            c_end = min(c_start + block_c, S)
            k_tile = K[:, :, c_start:c_end, :]  # [B, H, B_c, D]
            v_tile = V[:, :, c_start:c_end, :]  # [B, H, B_c, D]

            # 1. Tile scores S_ij = Q_i K_j^T * scale
            s_ij = torch.matmul(q_tile.float(), k_tile.float().transpose(-2, -1)) * scale

            if causal and c_end > r_start:
                row_idx = torch.arange(r_start, r_end, device=Q.device)[:, None]
                col_idx = torch.arange(c_start, c_end, device=Q.device)[None, :]
                s_ij = s_ij.masked_fill(col_idx > row_idx, float("-inf"))

            # 2. Online Softmax running max & exponential rescaling factor
            m_ij = s_ij.max(dim=-1, keepdim=True).values
            m_new = torch.maximum(m_i, m_ij)
            alpha = torch.exp(m_i - m_new)
            p_tilde = torch.exp(s_ij - m_new)

            # 3. Update running partition sum l_i and unnormalized output accumulator o_acc
            l_i = alpha * l_i + p_tilde.sum(dim=-1, keepdim=True)
            o_acc = alpha * o_acc + torch.matmul(p_tilde, v_tile.float())
            m_i = m_new

        # Epilogue: normalize once at the end of the row loop and store logsumexp L_i
        O[:, :, r_start:r_end, :] = (o_acc / l_i).to(Q.dtype)
        L[:, :, r_start:r_end] = (m_i + torch.log(l_i)).squeeze(-1)

    return O, L

5. Failure Modes & Systems Diagnostics Playbook

When scaling attention layers to 100B+ parameters or 128K+ context lengths, three subtle failure modes regularly break training or inference:

1. QK-Logit Explosion at Scale: Above ~10B parameters with high learning rates, the norm of $Q$ and $K$ projections can grow unboundedly during training, pushing pre-softmax logits $q^\top k / \sqrt{d_h} > 88$ (FP32 overflow) or saturating BF16 into one-hot distributions with vanishing gradients.
Fix: Apply QK-Norm (RMSNorm applied per-head to $Q$ and $K$ immediately prior to RoPE, used in Gemma-2, OLMo-2, and ViT-22B), bounding the maximum possible dot product by $\sqrt{d_h} \max_i |\gamma_i|^2$.
2. Attention Sinks in Streaming / Windowed Inference: Softmax requires attention probabilities across every row to sum to $1.0$. Even when a query head has nothing relevant to attend to, it dumps excess probability mass onto the very first tokens ($t = 0, 1, 2, 3$) because they are visible to all subsequent tokens under the causal mask. Evicting token $0$ in a rolling window causes catastrophic perplexity collapse.
Fix: Retain 4 permanent initial "sink tokens" in the KV cache (Xiao et al., 2023) or add a learnable scalar sink logit $s_{\text{sink}}$ in the softmax denominator (used in GPT-OSS / modern sparse attention).
3. FP32 Accumulator Drift in Custom Flash Kernels: Computing the online rescaling factor $\alpha = \exp(m^{(j-1)} - m^{(j)})$ or running partition sum $\ell^{(j)}$ in BF16 causes severe rounding bias across > 128 tiles.
Fix: Keep $Q_i, K_j, V_j$ tiles in BF16/FP8 for Tensor Core mma instructions, but strictly keep $m_i$, $\ell_i$, and $\widetilde{O}_i$ accumulators in FP32 registers until the final epilogue writeback.
MHA vs. GQA-8 KV Cache
For a 70B model ($L=80, n_q=64, d_h=128$) at $S=128\text{K}, B=1$ in BF16: Standard MHA requires 320 GiB (343.6 GB) of KV cache; GQA-8 ($n_{\text{kv}}=8$) requires 40.0 GiB (43.0 GB) (8× reduction).
DeepSeek MLA KV Cache
With $d_c = 512, d_R = 64$, the same 80-layer 128K context requires only $80 \times 131{,}072 \times 576 \times 2\text{ B} \approx \mathbf{11.25\text{ GiB (12.1 GB)}}$ (a 28.4× reduction vs. MHA, and 3.6× smaller than GQA-8).
FlashAttention-2 HBM I/O
Reduces HBM reads/writes from $\Theta(B H S^2 + B H S d_h)$ to $\Theta\!\left(\frac{B H S^2 d_h}{M_{\text{SRAM}}}\right)$, delivering 3–5× wall-clock kernel speedup and $\mathcal{O}(S)$ activation memory.

6. Runnable End-to-End Verification & Equivalence Test

The self-contained script below verifies three fundamental invariants on synthetic tensors: (1) zero-copy broadcast GQA matches explicit repeat_interleave to machine precision (diff = 0.0), (2) DeepSeek MLA absorbed inference matches unabsorbed training to $\lt 10^{-6}$ max absolute error while shrinking cached KV elements by $8\times$ (and 57× on full-scale DeepSeek-V3), and (3) tiled FlashAttention-2 online softmax matches standard PyTorch SDPA on both forward outputs and log-sum-exp statistics.

🧪 Runnable Verification Script: GQA, MLA Absorption & FlashAttention-2Verified PyTorch
import math
import torch
import torch.nn as nn
import torch.nn.functional as F

def precompute_rope_cache(seq_len: int, head_dim: int, base: float = 500000.0, device=None):
    theta = 1.0 / (base ** (torch.arange(0, head_dim, 2, device=device).float() / head_dim))
    positions = torch.arange(seq_len, device=device).float()
    freqs = torch.outer(positions, theta)
    return freqs.cos()[None, None, :, :], freqs.sin()[None, None, :, :]

def apply_rope(x: torch.Tensor, cos: torch.Tensor, sin: torch.Tensor) -> torch.Tensor:
    x_even = x[..., 0::2]
    x_odd = x[..., 1::2]
    rot_even = x_even * cos - x_odd * sin
    rot_odd = x_even * sin + x_odd * cos
    return torch.stack([rot_even, rot_odd], dim=-1).flatten(-2)

class GroupedQueryAttention(nn.Module):
    def __init__(self, d_model: int, n_q: int = 32, n_kv: int = 8, head_dim: int = 128):
        super().__init__()
        self.n_q = n_q
        self.n_kv = n_kv
        self.group_size = n_q // n_kv
        self.head_dim = head_dim
        self.scale = 1.0 / math.sqrt(head_dim)
        self.wq = nn.Linear(d_model, n_q * head_dim, bias=False)
        self.wk = nn.Linear(d_model, n_kv * head_dim, bias=False)
        self.wv = nn.Linear(d_model, n_kv * head_dim, bias=False)
        self.wo = nn.Linear(n_q * head_dim, d_model, bias=False)

    def forward(self, x: torch.Tensor, cos: torch.Tensor, sin: torch.Tensor) -> torch.Tensor:
        B, S, _ = x.shape
        q = apply_rope(self.wq(x).view(B, S, self.n_q, self.head_dim).transpose(1, 2), cos[:, :, :S, :], sin[:, :, :S, :])
        k = apply_rope(self.wk(x).view(B, S, self.n_kv, self.head_dim).transpose(1, 2), cos[:, :, :S, :], sin[:, :, :S, :])
        v = self.wv(x).view(B, S, self.n_kv, self.head_dim).transpose(1, 2)
        q_grouped = q.view(B, self.n_kv, self.group_size, S, self.head_dim)
        scores = torch.matmul(q_grouped, k.unsqueeze(2).transpose(-2, -1)) * self.scale
        mask = torch.triu(torch.full((S, S), float("-inf"), device=x.device), diagonal=1)
        attn = F.softmax(scores + mask, dim=-1)
        out = torch.matmul(attn, v.unsqueeze(2)).reshape(B, self.n_q, S, self.head_dim).transpose(1, 2).reshape(B, S, -1)
        return self.wo(out)

class MultiHeadLatentAttention(nn.Module):
    def __init__(self, d_model: int = 256, n_heads: int = 8, d_c: int = 64, d_head: int = 32, d_rope: int = 16):
        super().__init__()
        self.n_heads = n_heads
        self.d_c, self.d_head, self.d_rope = d_c, d_head, d_rope
        self.scale = 1.0 / math.sqrt(d_head + d_rope)
        self.wq_c = nn.Linear(d_model, n_heads * d_head, bias=False)
        self.wq_r = nn.Linear(d_model, n_heads * d_rope, bias=False)
        self.w_dkv = nn.Linear(d_model, d_c, bias=False)
        self.wk_r = nn.Linear(d_model, d_rope, bias=False)
        self.w_uk = nn.Parameter(torch.randn(n_heads, d_head, d_c) / math.sqrt(d_c))
        self.w_uv = nn.Parameter(torch.randn(n_heads, d_head, d_c) / math.sqrt(d_c))
        self.wo = nn.Linear(n_heads * d_head, d_model, bias=False)

    def forward_unabsorbed(self, x: torch.Tensor, cos: torch.Tensor, sin: torch.Tensor) -> torch.Tensor:
        B, S, _ = x.shape
        q_c = self.wq_c(x).view(B, S, self.n_heads, self.d_head).transpose(1, 2)
        q_r = apply_rope(self.wq_r(x).view(B, S, self.n_heads, self.d_rope).transpose(1, 2), cos[:, :, :S, :], sin[:, :, :S, :])
        c_kv = self.w_dkv(x)
        k_r = apply_rope(self.wk_r(x).unsqueeze(1), cos[:, :, :S, :], sin[:, :, :S, :])
        k_c = torch.einsum("bsd,hcd->bhsc", c_kv, self.w_uk)
        v_c = torch.einsum("bsd,hcd->bhsc", c_kv, self.w_uv)
        scores = (torch.matmul(q_c, k_c.transpose(-2, -1)) + torch.matmul(q_r, k_r.transpose(-2, -1))) * self.scale
        attn = F.softmax(scores + torch.triu(torch.full((S, S), float("-inf"), device=x.device), diagonal=1), dim=-1)
        return self.wo(torch.matmul(attn, v_c).transpose(1, 2).reshape(B, S, -1))

    def forward_absorbed(self, x: torch.Tensor, cos: torch.Tensor, sin: torch.Tensor) -> torch.Tensor:
        B, S, _ = x.shape
        q_c = self.wq_c(x).view(B, S, self.n_heads, self.d_head).transpose(1, 2)
        q_r = apply_rope(self.wq_r(x).view(B, S, self.n_heads, self.d_rope).transpose(1, 2), cos[:, :, :S, :], sin[:, :, :S, :])
        c_kv = self.w_dkv(x)
        k_r = apply_rope(self.wk_r(x).unsqueeze(1), cos[:, :, :S, :], sin[:, :, :S, :])
        q_abs = torch.einsum("bhsd,hdo->bhso", q_c, self.w_uk)
        scores = (torch.matmul(q_abs, c_kv.unsqueeze(1).transpose(-2, -1)) + torch.matmul(q_r, k_r.transpose(-2, -1))) * self.scale
        attn = F.softmax(scores + torch.triu(torch.full((S, S), float("-inf"), device=x.device), diagonal=1), dim=-1)
        latent_ctx = torch.matmul(attn, c_kv.unsqueeze(1))
        return self.wo(torch.einsum("bhso,hdo->bhsd", latent_ctx, self.w_uv).transpose(1, 2).reshape(B, S, -1))

def flash_attention_v2_tiled(Q, K, V, block_r=32, block_c=32, causal=True):
    B, H, S, D = Q.shape
    scale = 1.0 / math.sqrt(D)
    O = torch.empty_like(Q)
    L = torch.empty((B, H, S), device=Q.device, dtype=torch.float32)
    for r_start in range(0, S, block_r):
        r_end = min(r_start + block_r, S)
        q_tile, br = Q[:, :, r_start:r_end, :], r_end - r_start
        m_i = torch.full((B, H, br, 1), float("-inf"), device=Q.device, dtype=torch.float32)
        l_i = torch.zeros((B, H, br, 1), device=Q.device, dtype=torch.float32)
        o_acc = torch.zeros((B, H, br, D), device=Q.device, dtype=torch.float32)
        c_limit = r_end if causal else S
        for c_start in range(0, c_limit, block_c):
            c_end = min(c_start + block_c, S)
            s_ij = torch.matmul(q_tile.float(), K[:, :, c_start:c_end, :].float().transpose(-2, -1)) * scale
            if causal and c_end > r_start:
                s_ij = s_ij.masked_fill(torch.arange(c_start, c_end, device=Q.device)[None, :] > torch.arange(r_start, r_end, device=Q.device)[:, None], float("-inf"))
            m_ij = s_ij.max(dim=-1, keepdim=True).values
            m_new = torch.maximum(m_i, m_ij)
            alpha, p_tilde = torch.exp(m_i - m_new), torch.exp(s_ij - m_new)
            l_i = alpha * l_i + p_tilde.sum(dim=-1, keepdim=True)
            o_acc = alpha * o_acc + torch.matmul(p_tilde, V[:, :, c_start:c_end, :].float())
            m_i = m_new
        O[:, :, r_start:r_end, :] = (o_acc / l_i).to(Q.dtype)
        L[:, :, r_start:r_end] = (m_i + torch.log(l_i)).squeeze(-1)
    return O, L

def verify_attention_stack():
    torch.manual_seed(42)
    B, S, d_model = 2, 64, 256
    x = torch.randn(B, S, d_model)

    # 1. Verify RoPE + Zero-Copy Broadcast GQA against repeat_interleave
    cos, sin = precompute_rope_cache(seq_len=S, head_dim=32)
    gqa = GroupedQueryAttention(d_model=d_model, n_q=8, n_kv=2, head_dim=32).eval()
    with torch.no_grad():
        out_gqa = gqa(x, cos, sin)
        # Compare zero-copy broadcast with explicit repeat_interleave
        q = apply_rope(gqa.wq(x).view(B, S, 8, 32).transpose(1, 2), cos, sin)
        k = apply_rope(gqa.wk(x).view(B, S, 2, 32).transpose(1, 2), cos, sin)
        v = gqa.wv(x).view(B, S, 2, 32).transpose(1, 2)
        k_rep = k.repeat_interleave(4, dim=1)
        v_rep = v.repeat_interleave(4, dim=1)
        scores_rep = torch.matmul(q, k_rep.transpose(-2, -1)) * gqa.scale
        attn_rep = F.softmax(scores_rep + torch.triu(torch.full((S, S), float("-inf")), diagonal=1), dim=-1)
        out_rep = gqa.wo(torch.matmul(attn_rep, v_rep).transpose(1, 2).reshape(B, S, -1))
        gqa_diff = (out_gqa - out_rep).abs().max().item()
    assert gqa_diff < 1e-5, f"GQA broadcast mismatch: {gqa_diff}"

    # 2. Verify DeepSeek MLA Unabsorbed vs. Weight-Absorbed Equivalence
    cos_r, sin_r = precompute_rope_cache(seq_len=S, head_dim=16)
    mla = MultiHeadLatentAttention(d_model=d_model, n_heads=8, d_c=48, d_head=32, d_rope=16).eval()
    with torch.no_grad():
        out_unabsorbed = mla.forward_unabsorbed(x, cos_r, sin_r)
        out_absorbed = mla.forward_absorbed(x, cos_r, sin_r)
    mla_diff = (out_unabsorbed - out_absorbed).abs().max().item()
    mha_kv_scalars = 2 * 8 * 32          # 512 scalars/token
    mla_kv_scalars = 48 + 16             # 64 scalars/token (8x smaller!)
    assert mla_diff < 1e-5, f"MLA absorption mismatch: {mla_diff}"

    # 3. Verify Tiled FlashAttention-2 Online Softmax vs. Reference SDPA
    Q = torch.randn(B, 4, S, 32)
    K = torch.randn(B, 4, S, 32)
    V = torch.randn(B, 4, S, 32)
    out_flash, lse_flash = flash_attention_v2_tiled(Q, K, V, block_r=16, block_c=16, causal=True)
    out_ref = F.scaled_dot_product_attention(Q, K, V, is_causal=True)
    flash_diff = (out_flash - out_ref).abs().max().item()
    assert flash_diff < 1e-5, f"FlashAttention-2 mismatch: {flash_diff}"

    print(f"[PASS] GQA Broadcast vs Repeat Diff: {gqa_diff:.2e} (Output shape: {tuple(out_gqa.shape)})")
    print(f"[PASS] MLA Max Absorption Diff     : {mla_diff:.2e} (KV Cache: {mla_kv_scalars} vs {mha_kv_scalars} scalars/tok, 8x compression)")
    print(f"[PASS] FlashAttn-2 Max Diff        : {flash_diff:.2e} (LSE shape: {tuple(lse_flash.shape)})")

if __name__ == "__main__":
    verify_attention_stack()

7. Head-to-Head Architectural Comparison

Dimension Standard MHA MQA GQA DeepSeek MLA
KV Elements / Token / Layer $2 n_h d_h$ $2 d_h$ $2 n_{\text{kv}} d_h$ $d_c + d_R$
Decode Arithmetic Intensity $\approx 1\text{ FLOP/B}$ $\approx n_h\text{ FLOPs/B}$ $\approx (n_q / n_{\text{kv}})\text{ FLOPs/B}$ $\approx n_h\text{ FLOPs/B}$
Per-Head Key/Value Diversity Full ($n_h$ distinct) None ($1$ shared) Group-tied ($n_{\text{kv}}$ distinct) Full ($n_h$ distinct via $W_i^{UK}, W_i^{UV}$)
RoPE Compatibility Direct on full $Q, K$ Direct on full $Q, K$ Direct on full $Q, K$ Requires Decoupled $q_{t,i}^R, k_t^R$
Representative Models GPT-3, Llama-1/2 (7B) PaLM, Falcon, StarCoder Llama-3/4, Qwen-2.5/3, Mistral DeepSeek-V2, DeepSeek-V3, DeepSeek-R1