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).
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:
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. 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:
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}$:
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}$:
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$:
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):
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.
💻 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:
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.
💻 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:
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$.
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).
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 |