2.10 Modern Architecture Improvements & Design Choices¶
The Transformer described by Vaswani et al. in 2017 was a landmark, but the architecture used in today’s production LLMs — Llama 4, Qwen3, DeepSeek-V3, Gemma 3 — bears only a family resemblance to that original design. A practitioner opening a modern model config file for the first time will encounter terms like rms_norm, silu, rope_theta, num_key_value_heads, qk_norm, and no_bias. Each of these represents a deliberate, empirically validated architectural decision made in pursuit of better training stability, improved compute efficiency, or stronger final model quality.
This chapter is your annotated map of those decisions. We will examine each improvement from first principles — why it was introduced, what it fixes, how it works mathematically, how it appears in code, and finally what its name is in a real config.json and which library ships the fused kernel for it. We conclude with a “modern transformer recipe” summarizing which choices are nearly universal versus still debated. For the transformer block basics, see The Transformer Block: Norms, Residuals, MLPs & Activations; for positional encodings in depth, see Positional Encodings: Sinusoidal, Learned, RoPE & ALiBi; and for GQA/MQA mechanics, see Multi-Head Attention, MQA, GQA & MLA.
Every choice surveyed here is committed to in The Stack-100M Architecture, where the capstone freezes one config — RMSNorm pre-norm, SwiGLU, GQA with 8 query / 2 KV heads, RoPE with interleaved position-free layers, QK-norm, tied embeddings, no biases — and reproduces its parameter count to the last norm vector. Read this chapter for the menu; read that one for the order.
RMSNorm: Cheaper, Equally Stable Normalization¶
The problem with LayerNorm¶
The original Transformer used LayerNorm (Ba et al., “Layer Normalization”, 2016):
where \(\mu = \frac{1}{d}\sum_i x_i\) and \(\sigma^2 = \frac{1}{d}\sum_i (x_i - \mu)^2\). This requires two passes over the feature vector: one to compute the mean, one to compute the variance. More importantly, it requires two learned parameter vectors: \(\gamma\) (scale) and \(\beta\) (shift). The \(\beta\) term encodes an explicit re-centering.
RMSNorm derivation¶
Zhang and Sennrich (“Root Mean Square Layer Normalization”, NeurIPS 2019) asked: is the re-centering operation actually necessary? They ablated LayerNorm into its components and found that the re-scaling (via \(\gamma\)) drives almost all of LayerNorm’s benefit, while mean subtraction contributes little to final performance but accounts for roughly a third of LayerNorm’s compute.
Root Mean Square Normalization (RMSNorm) drops mean subtraction entirely:
There is no \(\beta\) parameter. One division by the RMS, then a learned element-wise scale. This is approximately 10–30% faster than LayerNorm on modern hardware because there is no mean subtraction kernel and no second reduction pass.
import torch
import torch.nn as nn
class RMSNorm(nn.Module):
"""
Root Mean Square Layer Normalization.
Used in Llama, Mistral, Qwen, DeepSeek, Gemma, and most modern LLMs.
Parameters
----------
dim : int
The feature dimension (hidden size d_model).
eps : float
Small constant for numerical stability. 1e-5 is common; some
models (Gemma) use 1e-6.
"""
def __init__(self, dim: int, eps: float = 1e-5):
super().__init__()
self.eps = eps
# gamma: learnable scale, initialized to ones so RMSNorm is
# initially the identity (modulo the normalization step).
self.weight = nn.Parameter(torch.ones(dim))
def _norm(self, x: torch.Tensor) -> torch.Tensor:
# x: (..., dim). Compute RMS over last dimension.
# rsqrt = 1/sqrt for numerical efficiency.
return x * torch.rsqrt(x.pow(2).mean(dim=-1, keepdim=True) + self.eps)
def forward(self, x: torch.Tensor) -> torch.Tensor:
# Cast to float32 for the norm computation for precision,
# then cast back. This pattern is used in Llama reference code.
output = self._norm(x.float()).type_as(x)
return output * self.weight
# --- Quick sanity check ---
torch.manual_seed(42)
x = torch.randn(2, 8, 512) # batch=2, seq=8, d_model=512
norm = RMSNorm(dim=512)
y = norm(x)
# Verify unit-RMS along the feature dimension (before scaling by weight)
raw_normed = x.float() * torch.rsqrt(x.float().pow(2).mean(-1, keepdim=True) + 1e-5)
print(f"RMS of raw_normed row 0: {raw_normed[0,0].pow(2).mean().sqrt():.4f}") # ~1.000
Common pitfall: the (1 + w) convention
Two conventions for the learned scale coexist in the wild. Llama/Qwen/Mistral store \(\gamma\) directly and multiply by self.weight (initialized to ones), exactly as above. Gemma stores an offset and multiplies by (1.0 + self.weight) with the parameter initialized to zeros, so that weight decay pulls the scale toward 1 rather than toward 0. Loading a Gemma checkpoint into a Llama-style RMSNorm therefore multiplies every hidden state by roughly zero and produces fluent-looking garbage with no error message. When you write a checkpoint converter, diff the norm formula before you diff anything else.
Pre-norm vs post-norm¶
The original Transformer (and BERT) used post-norm: normalization applied after the residual addition. GPT-2 already moved the norm to the input of each sub-block — pre-norm — and added a single final norm before the LM head; nearly all modern models kept that placement:
Post-norm (Vaswani 2017, BERT): x → Sublayer(x) + x → Norm → output
Pre-norm (GPT-2 onward): x → Norm(x) → Sublayer → + x → output
Pre-norm yields more stable gradients at large scale — the residual path is always clean, so gradients flow through the skip connection without being divided by a normalization operation. The trade-off is that pre-norm models can exhibit “representation collapse” at depth if not carefully initialized, which motivates other tricks like scaled initialization.
Two further placements are now common at the frontier, both of which keep the clean residual highway but re-bound what each block adds to it. Sandwich norm (Gemma ⅔) keeps the pre-norm and adds a second RMSNorm on the sublayer output before the residual add: x + Norm_post(Sublayer(Norm_pre(x))). Reordered / output norm (OLMo 2) drops the pre-norm and normalizes only the branch output: x + Norm(Sublayer(x)). Both cost one extra cheap reduction per sublayer and both were adopted specifically because they suppress loss spikes in long runs (see Training Stability, Loss Spikes & Debugging Large Runs). A different axis entirely — running the attention and MLP sublayers in parallel off a single norm, as PaLM and GPT-NeoX do — is derived in The Transformer Block: Norms, Residuals, MLPs & Activations.
SwiGLU: A Better MLP Activation¶
From ReLU to GELU to SwiGLU¶
The original Transformer used a plain two-layer MLP with ReLU:
BERT switched to GELU (Gaussian Error Linear Unit), which is smoother and empirically outperforms ReLU. Modern LLMs take this further with Gated Linear Units (GLU) and specifically their SiLU-gated variant — SwiGLU — introduced by Noam Shazeer (“GLU Variants Improve Transformer”, 2020).
SwiGLU mechanics¶
A Gated Linear Unit multiplies two linear projections together, one of which passes through a nonlinearity acting as a soft gate:
where \(\text{SiLU}(z) = z \cdot \sigma(z)\) (also called Swish), and \(\otimes\) denotes element-wise multiplication. The SiLU function is smooth, non-monotone, and has a non-zero gradient for negative inputs, all of which help gradient flow.
The gating mechanism is important: \(xV\) produces a “content” projection and \(\text{SiLU}(xW)\) produces a soft gate. The gate dynamically suppresses or amplifies each dimension of the content, giving the MLP a multiplicative interaction it lacked before.
xW) passes through SiLU to become a soft, per-dimension gate in roughly [0, 1]-ish range; the other (xV) is the raw content -- the gate then dials each dimension of the content up or down before the result is projected back to model width by W2, which is the multiplicative interaction a plain GELU MLP lacks. Because SwiGLU needs three weight matrices instead of two, the intermediate width is shrunk to 8d/3 so total parameters match a standard 4x MLP.Because SwiGLU has three weight matrices (\(W\), \(V\), \(W_2\)) instead of two, to keep parameter count equal to a standard 4x MLP, the hidden dimension is reduced to \(\frac{2}{3} \cdot 4d = \frac{8d}{3}\). In practice most models round to a multiple of 256 for hardware alignment; Llama 2 70B uses an intermediate size of 28,672 for a model dimension of 8,192.
import torch
import torch.nn as nn
import torch.nn.functional as F
class SwiGLUMLP(nn.Module):
"""
SwiGLU feed-forward network used in Llama, Qwen, DeepSeek, etc.
For a model with hidden_dim = d, the intermediate_dim is typically
floor(8*d/3) rounded up to a multiple of 256.
"""
def __init__(self, hidden_dim: int, intermediate_dim: int):
super().__init__()
# Three linear projections; no bias (see section on no-bias later)
self.gate_proj = nn.Linear(hidden_dim, intermediate_dim, bias=False)
self.up_proj = nn.Linear(hidden_dim, intermediate_dim, bias=False)
self.down_proj = nn.Linear(intermediate_dim, hidden_dim, bias=False)
def forward(self, x: torch.Tensor) -> torch.Tensor:
# gate: apply SiLU to create a soft gate
gate = F.silu(self.gate_proj(x))
# up: linear content projection
up = self.up_proj(x)
# Element-wise product (gated content), then project back down
return self.down_proj(gate * up)
# Demonstration: compare SwiGLU vs vanilla FFN parameter counts
d = 4096 # typical hidden size
# Vanilla FFN with 4x expansion: 2 * d * 4d = 8d^2
vanilla_params = 2 * d * (4 * d)
# SwiGLU FFN with 8/3 expansion: 3 * d * (8d/3) = 8d^2 — same!
intermediate = int(8 * d / 3)
swiglu_params = 3 * d * intermediate
print(f"Vanilla 4x FFN params: {vanilla_params:,}")
print(f"SwiGLU 8/3 FFN params: {swiglu_params:,}")
# Both are approximately 8*d^2 — SwiGLU is iso-parameter with better quality.
RoPE: Rotary Position Embeddings¶
Rotary Position Embedding (RoPE), introduced by Su et al. (“RoFormer: Enhanced Transformer with Rotary Position Embedding”, 2021), is now the dominant positional encoding for autoregressive LLMs. See Positional Encodings: Sinusoidal, Learned, RoPE & ALiBi for the full derivation; here we focus on why it was adopted in the modern recipe and its practical configuration.
Why RoPE won¶
RoPE encodes position by rotating query and key vectors in 2D subspaces. The critical insight is that the dot product \(q_m^\top k_n\) naturally becomes a function of the relative offset \((m - n)\), not of absolute positions — this is the relative position property that matters for generalization. Unlike sinusoidal encodings, RoPE requires no separate embedding table and requires no modification to the value vectors. Unlike learned absolute position embeddings, it generalizes beyond training context length (with appropriate scaling, see below).
The rotation for position \(m\) applied to a 2D subspace of the query vector:
For a \(d_k\)-dimensional head, this is repeated for \(d_k/2\) rotation pairs, each with a different base frequency:
The rope_theta hyperparameter (the base) controls the frequency range. GPT-2 had no RoPE. Llama 1 used rope_theta=10000. Llama 3 extended this to rope_theta=500000 to improve long-context behavior by spreading frequencies more broadly, making it easier to extrapolate to new positions at inference time. RoPE’s dominance is now being probed at the long-context frontier: Llama 4 (2025) uses iRoPE, interleaving a minority of position-free “NoPE” attention layers among the RoPE layers to support its 10M-token context window.
import torch
import torch.nn as nn
def precompute_freqs_cis(dim: int, max_seq_len: int, theta: float = 10000.0):
"""
Precompute complex exponentials for RoPE.
Returns a tensor of shape (max_seq_len, dim//2) with dtype=complex64.
The 'cis' notation: cis(x) = e^{ix} = cos(x) + i*sin(x).
"""
# Frequencies: theta^{-2i/dim} for i in [0, dim/2)
freqs = 1.0 / (theta ** (torch.arange(0, dim, 2).float() / dim))
# Positions: [0, 1, 2, ..., max_seq_len-1]
t = torch.arange(max_seq_len)
# Outer product: shape (max_seq_len, dim//2)
freqs = torch.outer(t, freqs)
# Convert to complex: e^{i * freqs}
freqs_cis = torch.polar(torch.ones_like(freqs), freqs)
return freqs_cis
def apply_rotary_emb(xq: torch.Tensor, xk: torch.Tensor,
freqs_cis: torch.Tensor):
"""
Apply RoPE to query and key tensors.
xq, xk: (batch, seq_len, n_heads, head_dim)
freqs_cis: (seq_len, head_dim//2) complex tensor
"""
# Reshape to complex view: treat pairs of reals as complex numbers
xq_ = torch.view_as_complex(xq.float().reshape(*xq.shape[:-1], -1, 2))
xk_ = torch.view_as_complex(xk.float().reshape(*xk.shape[:-1], -1, 2))
# Broadcast freqs_cis over batch and head dimensions
# freqs_cis shape: (seq_len, head_dim//2) -> (1, seq_len, 1, head_dim//2)
freqs_cis = freqs_cis.unsqueeze(0).unsqueeze(2)
# Multiply in complex space = rotation in 2D pairs
xq_out = torch.view_as_real(xq_ * freqs_cis).flatten(3)
xk_out = torch.view_as_real(xk_ * freqs_cis).flatten(3)
return xq_out.type_as(xq), xk_out.type_as(xk)
# Demonstration
torch.manual_seed(0)
batch, seq_len, n_heads, head_dim = 2, 16, 8, 64
xq = torch.randn(batch, seq_len, n_heads, head_dim)
xk = torch.randn(batch, seq_len, n_heads, head_dim)
freqs = precompute_freqs_cis(head_dim, seq_len, theta=500000.0) # Llama 3 theta
xq_rot, xk_rot = apply_rotary_emb(xq, xk, freqs)
print(f"RoPE applied. Output shape: {xq_rot.shape}")
# Verify relative position property: dot product depends only on relative offset
q0 = xq_rot[0, 3, 0] # position 3, head 0
k5 = xk_rot[0, 8, 0] # position 8, head 0
# The dot product q0.k5 encodes offset=5, not absolute positions 3 and 8.
Grouped Query Attention (GQA)¶
Standard Multi-Head Attention (MHA) maintains separate \(K\) and \(V\) projection matrices for each of \(H\) heads. During autoregressive decoding, the key-value (KV) cache grows as \(O(L \cdot H \cdot d_k)\) per layer — for a 70B model with 64 heads and a 128K context, this is on the order of tens of gigabytes. See Multi-Head Attention, MQA, GQA & MLA for the full treatment; here we focus on the design decision and its practical configuration.
Grouped Query Attention (GQA), introduced in Ainslie et al. (“GQA: Training Generalized Multi-Query Transformer Models from Multi-Head Checkpoints”, 2023), is a generalization that interpolates between Multi-Head Attention and Multi-Query Attention (MQA, which has a single KV head for all query heads):
MHA: H_q = H_kv (all heads have own KV)
GQA: H_q = G * H_kv for G > 1 (G query heads share one KV head)
MQA: H_kv = 1 (all query heads share one KV)
The near-universal choice is 8 KV heads, almost regardless of query-head count: Llama 3 8B uses 32 query / 8 KV heads (\(G=4\)), Llama 2 70B and Llama 3 70B use 64 query / 8 KV heads (\(G=8\)), and the Qwen 2.5 / Qwen3 families follow the same pattern. This reduces KV cache memory by a factor of \(G\) while introducing only a small quality degradation versus MHA. Eight is not a coincidence: under tensor parallelism the KV heads are split across ranks, so \(H_{kv} \ge \text{TP degree}\) lets each GPU of a standard 8-GPU node own exactly one KV head with no replication and no extra all-gather (see Distributed Training II: Tensor, Pipeline, Sequence & Expert Parallelism). Picking \(H_{kv} < \text{TP}\) forces the KV cache to be duplicated on every rank and silently gives back the memory you were trying to save.
Worked example: KV cache memory budget
Consider a model with the following configuration (similar to Llama 3 8B): - Layers: 32 - Hidden dim: 4096 - Query heads: 32 (head_dim = 128) - KV heads (GQA): 8
KV cache per token per layer:
With bf16 (2 bytes), this is:
Total KV cache for 32 layers, 8K context:
With full MHA (32 KV heads), this would be 4 GB — four times larger. For a 128K context, GQA brings it from ~64 GB to ~16 GB, making long-context inference feasible on reasonable hardware.
import torch
import torch.nn as nn
import math
class GroupedQueryAttention(nn.Module):
"""
Grouped Query Attention (GQA) as used in Llama 2/3, Qwen, Mistral.
n_heads: number of query heads (H_q)
n_kv_heads: number of key/value heads (H_kv), must divide n_heads evenly
"""
def __init__(self, d_model: int, n_heads: int, n_kv_heads: int):
super().__init__()
assert n_heads % n_kv_heads == 0, "n_heads must be divisible by n_kv_heads"
self.n_heads = n_heads
self.n_kv_heads = n_kv_heads
self.n_rep = n_heads // n_kv_heads # how many Q heads per KV head
self.head_dim = d_model // n_heads
# Query projects to n_heads * head_dim
self.q_proj = nn.Linear(d_model, n_heads * self.head_dim, bias=False)
# Key/Value project to n_kv_heads * head_dim (smaller!)
self.k_proj = nn.Linear(d_model, n_kv_heads * self.head_dim, bias=False)
self.v_proj = nn.Linear(d_model, n_kv_heads * self.head_dim, bias=False)
self.o_proj = nn.Linear(d_model, d_model, bias=False)
def _repeat_kv(self, x: torch.Tensor) -> torch.Tensor:
"""Expand KV from n_kv_heads to n_heads by repeating."""
# x: (batch, seq, n_kv_heads, head_dim)
if self.n_rep == 1:
return x
# Repeat along the head dimension n_rep times
return x.unsqueeze(3).expand(
*x.shape[:2], self.n_kv_heads, self.n_rep, self.head_dim
).reshape(*x.shape[:2], self.n_heads, self.head_dim)
def forward(self, x: torch.Tensor) -> torch.Tensor:
B, T, C = x.shape
head_dim = self.head_dim
# Project to Q, K, V
q = self.q_proj(x).view(B, T, self.n_heads, head_dim)
k = self.k_proj(x).view(B, T, self.n_kv_heads, head_dim)
v = self.v_proj(x).view(B, T, self.n_kv_heads, head_dim)
# Expand K and V to match n_heads
k = self._repeat_kv(k) # (B, T, n_heads, head_dim)
v = self._repeat_kv(v)
# Transpose to (B, n_heads, T, head_dim) for attention
q = q.transpose(1, 2)
k = k.transpose(1, 2)
v = v.transpose(1, 2)
# Scaled dot-product attention
scale = math.sqrt(head_dim)
attn = (q @ k.transpose(-2, -1)) / scale # (B, n_heads, T, T)
attn = torch.softmax(attn, dim=-1)
out = attn @ v # (B, n_heads, T, head_dim)
# Merge heads and project
out = out.transpose(1, 2).contiguous().view(B, T, C)
return self.o_proj(out)
QK-Norm: Stabilizing Attention Logits at Scale¶
The problem: logit explosion¶
In deep, wide models trained for many tokens, the dot products \(q_i \cdot k_j\) can grow to very large values. Once the logits are large in magnitude, the softmax saturates: one token gets weight ~1 and all others get weight ~0. This “attention collapse” degrades the model’s ability to attend to multiple positions, and the large pre-softmax logits create numerical instability, especially in bf16 where the dynamic range is narrow. See also Numerical Computing, Floating Point & Precision for why bf16 overflow is a real concern.
c*tanh(z/c), a curve that tracks the identity near zero and flattens to a fixed asymptote for extreme values -- one constrains the input, the other constrains the output, and Gemma 2 uses both together.QK-norm: normalize Q and K before attention¶
The fix — introduced for ViT-22B (Dehghani et al., “Scaling Vision Transformers to 22 Billion Parameters”, 2023), studied systematically as an instability remedy by Wortsman et al. (“Small-scale proxies for large-scale Transformer training instabilities”, 2023), and now standard in Gemma 3, OLMo 2, Chameleon and Qwen3 — is to apply RMSNorm to the query and key vectors before computing the attention scores:
After normalization each head’s query and key have RMS \(\approx \gamma\), so \(\|q\| \approx \|k\| \approx \sqrt{d_k}\,\gamma\) and by Cauchy–Schwarz \(|q \cdot k| \le d_k \gamma_q \gamma_k\); after the \(1/\sqrt{d_k}\) scale the logits are \(O(\sqrt{d_k}\,\gamma_q\gamma_k)\) — bounded by the learned scales rather than by whatever magnitude the projections happened to drift to over a trillion tokens. Because \(\gamma\) is learned per channel, expressivity is largely retained; only the unbounded growth is removed.
Two implementation details matter. First, order versus RoPE: the convention (and what HuggingFace’s Qwen3Attention and Gemma3Attention do) is project → normalize → rotate. Either order gives the same bound, since rotation preserves vector norms, but the two are not the same function once \(\gamma\) is per-channel — rotation mixes channel pairs — so a converter that swaps them will silently produce a different model. Second, the norm is over head_dim, not d_model: one tiny \(\gamma \in \mathbb{R}^{d_k}\) shared across heads (Qwen3, Gemma 3) or one per head (some variants). The shared version is what the code below implements, and it adds only \(2 d_k\) parameters per layer.
class QKNormAttention(nn.Module):
"""
Attention with per-head QK normalization, as in Gemma 3, OLMo 2 and Qwen3.
(Gemma 2 used logit soft-capping instead; Gemma 3 replaced it with this.)
In HuggingFace these modules are literally named `q_norm` / `k_norm`.
Prevents logit explosion during long training runs.
"""
def __init__(self, d_model: int, n_heads: int):
super().__init__()
self.n_heads = n_heads
self.head_dim = d_model // n_heads
self.q_proj = nn.Linear(d_model, d_model, bias=False)
self.k_proj = nn.Linear(d_model, d_model, bias=False)
self.v_proj = nn.Linear(d_model, d_model, bias=False)
self.o_proj = nn.Linear(d_model, d_model, bias=False)
# One RMSNorm per head for Q and K; per-head head_dim
self.q_norm = RMSNorm(self.head_dim)
self.k_norm = RMSNorm(self.head_dim)
def forward(self, x: torch.Tensor) -> torch.Tensor:
B, T, C = x.shape
q = self.q_proj(x).view(B, T, self.n_heads, self.head_dim)
k = self.k_proj(x).view(B, T, self.n_heads, self.head_dim)
v = self.v_proj(x).view(B, T, self.n_heads, self.head_dim)
# Apply QK-norm per head (RMSNorm operates on head_dim)
q = self.q_norm(q) # normalizes the last dimension
k = self.k_norm(k)
# Standard scaled dot-product attention from here
q = q.transpose(1, 2)
k = k.transpose(1, 2)
v = v.transpose(1, 2)
attn = torch.softmax((q @ k.transpose(-2,-1)) / math.sqrt(self.head_dim), dim=-1)
out = (attn @ v).transpose(1,2).contiguous().view(B, T, C)
return self.o_proj(out)
No Biases, Tied/Untied Embeddings, and Initialization¶
Dropping biases¶
GPT-2 had bias terms in every linear projection and LayerNorm. Modern models like Llama, Qwen, and Mistral remove biases from all linear layers. The motivation is empirical: at large scale biases do not meaningfully improve loss (they represent a negligible fraction of parameters), but they complicate optimizer state memory (Adam maintains a first and second moment for every parameter, so biases add to optimizer memory with little benefit). For a 7B model, removing biases saves a few hundred MB of optimizer state — not huge, but free.
There is also a theoretical argument: when using pre-RMSNorm (which has no bias itself), the preceding biases in linear projections are redundant, as RMSNorm can represent any affine output of a linear layer with a bias by adjusting its scale. A stability argument closed the case: biases are the one part of the model that receives gradient regardless of the input, so they drift monotonically over long runs and are a recurring source of activation outliers that later break INT8/FP8 quantization (see Quantization I: Post-Training Quantization). Qwen2 kept a bias on the Q/K/V projections specifically to help length extrapolation; Qwen3 removed it and added QK-norm instead — the clearest single data point that the field now prefers normalizing over biasing.
Tied vs untied embeddings¶
The embedding table maps token IDs to vectors of dimension \(d\) and has shape \((V, d)\) where \(V\) is the vocabulary size. The output “unembedding” (also called the LM head or logit projection) maps from \(d\) back to \(V\), also shape \((V, d)\).
Tied embeddings (Press & Wolf, 2017) share the same weight matrix for both. This saves \(V \times d\) parameters — for a vocabulary of 32,000 and \(d=4096\), that is ~131M. Untied embeddings use separate matrices. The choice:
- Tied: fewer parameters, simpler to implement, forces the embedding space to be simultaneously useful for input representation and output scoring.
- Untied: more expressive; the input and output embedding spaces can specialize, and the LM head can develop the large-norm “unembedding” directions that tying would push back into the input representation.
The decision is essentially a budget fraction question, and the field splits cleanly by scale rather than by year. Tie when \(V d\) is a large slice of the model — GPT-2, all Gemma models, Llama 3.2 1B/3B, and Qwen3 0.6B–4B tie; untie when it is a rounding error — the Llama ½/3 models at 7B and above, and Qwen3 8B+, untie. A workable rule of thumb: if \(V d\) exceeds roughly 10% of your parameter budget, tie. At \(V=128{,}256\) and \(d=3072\) the table is 394M parameters, over 12% of a 3B model — tie. At \(d=8192\) in a 70B model it is 1.05B, under 2% — untie and buy the expressivity.
Practitioner tip: tying changes the gradient scale on the embedding table
With tied weights the embedding matrix receives gradient from two places every step: a sparse gradient from the input lookup (only the rows for tokens actually in the batch) and a dense gradient from the LM head (every row, every step). The dense term dominates, so the table trains far faster than an untied input embedding would. Gemma compensates by scaling the looked-up embeddings by \(\sqrt{d_{\text{model}}}\) before the first block — a fixed multiplier that decouples the scale the input path wants from the scale the output logits want. If you tie embeddings in a from-scratch model and see the embedding rows blowing up, this multiplier (or a lower LR group for the table) is the standard fix.
Initialization matters more than you think
Modern models use carefully scaled initialization. The standard method (from GPT-2 and widely adopted) scales down the residual projections (the output projection of attention and the down-projection of MLP) by \(1/\sqrt{2L}\) where \(L\) is the number of layers. This controls the variance of the residual stream at initialization, preventing the residual sum from growing as \(O(\sqrt{L})\). Without this, deep models (>60 layers) exhibit loss spikes at the start of training.
Logit Soft-Capping¶
The problem: logit skew in final layer¶
During pretraining, the final linear projection from hidden state to vocabulary logits can develop large-magnitude outputs for a small set of tokens. When the model becomes very confident, the cross-entropy gradient for non-predicted tokens effectively vanishes, slowing learning. At inference time, extreme logits after temperature scaling create numerical issues.
Soft-capping¶
Gemma 2 introduces logit soft-capping: a differentiable function that squashes logits toward a cap value \(c\) while preserving sign and relative ordering for logits well within the cap:
For small \(|z_i| \ll c\), \(\tanh(z_i/c) \approx z_i/c\), so \(\hat{z}_i \approx z_i\) — linear behavior near zero. For large \(|z_i|\), \(\hat{z}_i \to \pm c\) asymptotically. With \(c=30\) (Gemma 2’s setting), logits are prevented from exceeding 30 in magnitude.
Gemma 2 also applies soft-capping to per-layer attention logits (pre-softmax), with \(c=50\):
Currency note: soft-capping lost. Gemma 3 explicitly replaced Gemma 2’s soft-capping with QK-norm, citing Dehghani et al. (2023), Wortsman et al. (2023) and the Chameleon team. The reason is a systems reason rather than a quality one: a \(\tanh\) applied to the full \(T \times T\) logit matrix cannot be expressed inside a FlashAttention kernel, which by construction never materializes that matrix (see FlashAttention I: IO-Awareness & The Online Softmax). Enabling attention soft-capping in HuggingFace therefore forces the eager attention path, which is exactly the throughput you were trying to buy with long context. QK-norm, by contrast, is two cheap elementwise ops on \(Q\) and \(K\) before the kernel, so it composes with FlashAttention for free.
The other standard guard on the final logits is the z-loss, an auxiliary penalty \(\beta_z \cdot \operatorname{mean}\big(\log\sum_v e^{z_v}\big)^2\) with \(\beta_z \approx 10^{-4}\) (PaLM, ST-MoE, OLMo 2). It applies pressure on the log-partition function during training instead of clamping at inference, so it changes no forward-pass math and costs nothing at serving time — which is why most 2025–2026 models use z-loss rather than logit capping. It is derived, fused with cross-entropy, and chunked for memory in Training Stability, Loss Spikes & Debugging Large Runs. Learn soft-capping because you will meet it in Gemma 2 checkpoints and in interviews; reach for QK-norm plus z-loss when you build.
import torch
def soft_cap(logits: torch.Tensor, cap: float) -> torch.Tensor:
"""
Differentiable logit soft-capping (Gemma 2).
Maps logits smoothly toward [-cap, +cap]. For |z| << cap, behavior
is approximately linear; for |z| >> cap, asymptotes to ±cap.
Args
----
logits : (..., vocab_size) raw pre-softmax logits
cap : scalar, e.g. 30.0 for final logits or 50.0 for attention logits
Returns
-------
Capped logits of same shape as input.
"""
return cap * torch.tanh(logits / cap)
# Demonstration
z = torch.tensor([-100.0, -30.0, -10.0, 0.0, 10.0, 30.0, 100.0])
capped = soft_cap(z, cap=30.0)
print("Raw :", z.tolist())
print("Capped :", [f"{v:.2f}" for v in capped.tolist()])
# Output: Capped: [-29.92, -22.85, -9.65, 0.00, 9.65, 22.85, 29.92]
# Note: 100 -> 29.92, 30 -> 22.85 (compressed, not clipped)
Attention Sinks and Sink Tokens¶
The “attention sink” phenomenon¶
Xiao et al. (“Efficient Streaming LLMs with Attention Sinks”, 2023) made an empirical observation: attention maps in trained autoregressive models show that some tokens — almost always the first few tokens in the context (especially BOS, Beginning-Of-Sequence) — receive anomalously high attention weights regardless of their content relevance. These are called attention sinks.
Why does this happen? Attention weights must sum to 1 via softmax. When no other token is relevant, the model needs somewhere to “dump” the probability mass so it can attend to nothing useful without the softmax distribution becoming uniform (which would average together all values and potentially corrupt the output). The initial tokens, having been seen in every context, become the designated garbage-collector for probability mass.
Implications for model design and inference¶
The attention sink phenomenon has two practical consequences:
-
Long-context window extension via StreamingLLM: if you want to process infinite-length streams, you can evict old KV cache entries safely — as long as you keep the first few tokens’ KV cache (the sinks). Dropping the sink tokens causes catastrophic loss spikes.
-
Deliberate sink token design: some models prepend a learnable or fixed “register” token that is never attended to in the output but acts as a sink for attention. This frees the model from overloading the BOS token.
-
Learned sink logits, no token at all: the cleanest 2025 formulation drops the sink token and instead gives each head a learned scalar \(s_h\) that participates in the softmax denominator but has no corresponding value vector — i.e. \(\text{softmax}\) over \([\,q\!\cdot\!k_1, \ldots, q\!\cdot\!k_t, s_h\,]\), with the last slot’s weight simply discarded. The head can now attend to nothing (all real weights near zero) without distorting any value, which is the “off-by-one softmax” idea made trainable. OpenAI’s gpt-oss models (2025) ship this as a per-head
sinksparameter, and it is whatGptOssAttentionimplements in HuggingFacetransformers. Concretely, the softmax denominator becomes \(e^{s_h} + \sum_j e^{q\cdot k_j}\), costing one extra scalar per head and nothing at inference.
Interview Corner
Q: You are designing a 70B parameter language model for production deployment. Walk through the key architectural choices you would make compared to the original Transformer, and explain why for each one.
A: A strong answer covers the following decisions with their motivations:
- RMSNorm over LayerNorm: cheaper (no mean subtraction), equally stable; use pre-norm placement for gradient flow.
- SwiGLU FFN: empirically stronger than ReLU/GELU at the same parameter count; multiplicative gating gives more expressive activations.
- RoPE positional encoding: relative position property generalizes beyond training length; no embedding table overhead; set
rope_thetahigh (500k) for long-context capability. - GQA with ~8 KV heads: dramatically reduces KV cache memory (often 4–8x smaller) with minimal quality loss; critical for deployment economics.
- No biases in linear layers: negligible quality impact, reduces optimizer memory, simplifies distributed checkpointing.
- Untied input/output embeddings: improved model quality at scale; the cost is \(V \times d\) extra parameters (~130M for a 32K vocab at d=4096), which is acceptable.
- QK-norm (RMSNorm on Q and K per head): prevents attention logit explosion during extended training runs, and unlike soft-capping it composes with FlashAttention because it acts before the kernel.
- Z-loss (\(\beta_z \approx 10^{-4}\) on \((\log\sum_v e^{z_v})^2\)) rather than Gemma 2-style logit soft-capping: same guard against output logit blow-up, but training-time only, so inference math and kernel choice are unaffected.
- Scaled residual initialization: scale down output projections by \(1/\sqrt{2L}\) to keep residual variance stable at initialization in deep models.
Depth vs Width Trade-offs¶
Why we go deep¶
Scaling laws (Kaplan et al., “Scaling Laws for Neural Language Models”, 2020; Hoffmann et al., “Training Compute-Optimal Large Language Models” / Chinchilla, 2022) largely treat architecture shape as secondary to total parameter count and compute budget. See Scaling Laws: Kaplan, Chinchilla & Beyond for the full story. But within a fixed parameter budget, how to allocate parameters between depth (number of layers) and width (hidden dimension, number of heads, MLP expansion) matters.
The dominant empirical finding is that depth is worth more than width up to a point. Deeper models can represent functions of exponentially higher complexity (via function composition) than wide-but-shallow ones. However, deeper models are harder to parallelize in pipeline parallel training (more micro-batches needed to fill the pipeline bubble) and have slower sequential KV-cache generation at inference.
Practical aspect ratios¶
The ratio \(L / d_{\text{model}}\) tends to be consistent across generations:
| Model | Layers (\(L\)) | Hidden dim (\(d\)) | \(L/d\) |
|---|---|---|---|
| GPT-2 1.5B | 48 | 1600 | 0.030 |
| Llama 2 7B | 32 | 4096 | 0.0078 |
| Llama 3 70B | 80 | 8192 | 0.0098 |
| Qwen 2.5 72B | 80 | 8192 | 0.0098 |
| DeepSeek-V3 (dense equiv.) | 61 | 7168 | 0.0085 |
Modern 7B-class models favor roughly 32 layers with \(d=4096\), yielding an attention head dimension of 128 (with 32 heads). Larger models scale \(d\) and \(L\) roughly in proportion, holding \(d_k = 128\) fixed and adding heads: \(d_k\) below 64 wastes tensor-core tiles (which want \(\ge 64\) along the contraction dimension) and starves each head of capacity, while \(d_k\) above 256 is unsupported by most FlashAttention builds. So the practical knobs are \(L\), \(H_q\) and \(H_{kv}\), with \(d = H_q \cdot d_k\) falling out.
Below ~1B parameters the trade-off tilts noticeably toward depth: MobileLLM (Liu et al., 2024) ablated shape at fixed parameter count for sub-billion models and found deeper-and-thinner consistently wins, which is why the capstone’s Stack-100M chooses \(d=512\) with 30 layers (\(L/d \approx 0.059\), six times “deeper” by this metric than a 7B model). See The Stack-100M Architecture for that derivation in full.
Worked example: parameter count breakdown for Llama 2 7B
Configuration (the actual Llama 2 7B): \(L=32\), \(d=4096\), \(H_q=32\), \(H_{kv}=32\) (full MHA – Llama 2 predates GQA at the 7B size), \(d_k=128\), intermediate \(=11008\) (SwiGLU), vocab \(=32000\), untied embeddings.
Attention (\(Q, K, V, O\) projections), per layer. With full MHA, \(H_{kv}=H_q=32\), so K and V are full width:
- \(Q\): \(4096 \times 4096 = 16.8\)M
- \(K\): \(4096 \times 4096 = 16.8\)M
- \(V\): \(4096 \times 4096 = 16.8\)M
- \(O\): \(4096 \times 4096 = 16.8\)M
- Attention total: \(\approx 67.1\)M
MLP (SwiGLU, intermediate \(=11008\)), per layer:
- gate_proj + up_proj: \(2 \times 4096 \times 11008 = 90.2\)M
- down_proj: \(11008 \times 4096 = 45.1\)M
- MLP total: \(\approx 135.3\)M
RMSNorm (2 per layer): \(2 \times 4096 \approx 8\)K (negligible).
Per-layer total: \(67.1 + 135.3 \approx 202.4\)M.
32 layers: \(32 \times 202.4\text{M} \approx 6.48\)B.
Embeddings (untied): input \(32000 \times 4096 \approx 131\)M; output (LM head) \(\approx 131\)M; total \(\approx 262\)M.
Grand total: \(6.48\text{B} + 0.26\text{B} \approx 6.74\)B – exactly what “Llama 2 7B” denotes. The round “7B” is marketing rounding of 6.74B, not a vocabulary artifact.
Where does the often-quoted “~5.9B” figure come from? From plugging GQA (\(H_{kv}=8\), Llama 3 8B style) into this same count: K and V shrink to \(4096 \times 1024 = 4.2\)M each, attention drops to \(Q+K+V+O = 16.8+4.2+4.2+16.8 \approx 42\)M/layer, and the model falls to \(32 \times (42+135.3)\text{M} + 262\text{M} \approx 5.94\)B. So the 5.9-vs-6.7B gap is entirely the MHA-vs-GQA choice in the attention block – the vocabulary (\(32000\)) is identical either way. (Llama 3 8B ends up larger than 7B despite GQA because it pairs a 128K-token vocab with a wider \(14336\) intermediate.)
Worked exercise: size a Llama-style model to a 3B budget
This unit’s deliverable is turning a parameter budget into a full config. Build a 3B-parameter dense decoder step by step, then verify the count. (The result is essentially Llama 3.2 3B.)
Step 1 – head dimension. Fix \(d_k = 128\) (the modern convention: good tensor-core shapes, enough per-head capacity).
Step 2 – width and depth via the aspect ratio. From the \(L/d\) table above, target \(L/d \approx 0.009\). Pick \(d = 3072\) (a multiple of both 128 and 256); then \(L \approx 0.009 \times 3072 \approx 28\) layers.
Step 3 – query heads. \(H_q = d / d_k = 3072 / 128 = 24\).
Step 4 – KV heads (GQA). Choose \(H_{kv} = 8\) (grouping \(G = H_q/H_{kv} = 3\)), the usual quality/memory sweet spot; \(24\) is divisible by \(8\). checks out.
Step 5 – FFN width. SwiGLU iso-parameter rule: \(d_{ff} = \tfrac{8}{3} d = \tfrac{8}{3}\times 3072 = 8192\), already a multiple of 256 – no rounding needed.
Step 6 – vocabulary and embedding tying. Use the Llama 3 tokenizer, \(V = 128256\). At \(d=3072\) the embedding table is \(128256 \times 3072 \approx 394\)M – over 12% of a 3B budget – so tie input and output embeddings (share one matrix) as Llama 3.2 1B/3B do; untying would add another 394M and blow the budget.
Step 7 – verify. Per layer: attention \(Q(3072^2)+K(3072\cdot1024)+V(3072\cdot1024)+O(3072^2) \approx 25.2\)M; MLP \(2(3072\cdot8192)+8192\cdot3072 \approx 75.5\)M; total \(\approx 100.7\)M. Times \(L=28\) gives \(2.82\)B, plus the (tied) \(0.39\)B embedding \(\Rightarrow \approx 3.21\)B. On budget.
def llama_param_count(d, L, n_heads, n_kv, d_ff, vocab, head_dim=128, tied=True):
# Q + O are full width; K + V are GQA-width
attn = 2 * d * (n_heads * head_dim) + 2 * d * (n_kv * head_dim)
mlp = 3 * d * d_ff # gate, up, down (SwiGLU)
norms = 2 * d # 2 RMSNorm per layer (gamma only)
per_layer = attn + mlp + norms
emb = vocab * d if tied else 2 * vocab * d
return per_layer * L + emb + d # + final RMSNorm
n = llama_param_count(d=3072, L=28, n_heads=24, n_kv=8,
d_ff=8192, vocab=128256, tied=True)
print(f"{n:,}") # 3,212,749,824 -> ~3.21B, matches Llama 3.2 3B
Checklist to apply every time: (1) \(d = H_q \cdot d_k\) exactly; (2) \(H_q \bmod H_{kv} = 0\); (3) \(d_{ff}\) a multiple of 256; (4) for models under ~4B, tie embeddings or they dominate the budget; (5) recompute the total and confirm it lands within ~10% of target before committing to a training run.
The Modern Transformer Recipe¶
We now have enough pieces to assemble a complete reference. The table below summarizes the consensus choices circa 2024–2026, distinguishing “near-universal” (adopted by nearly all recent models) from “model-specific” (used by some, absent in others).
| Component | GPT-2 (2019) | Modern Consensus | HF config field | Notes |
|---|---|---|---|---|
| Normalization | LayerNorm (pre) | RMSNorm (pre; +post in Gemma/OLMo 2) | rms_norm_eps |
Zhang & Sennrich 2019 |
| MLP activation | GeLU | SwiGLU / GeGLU | hidden_act: "silu" |
Shazeer 2020 |
| MLP width | \(4d\) | \(\approx \tfrac{8}{3}d\), multiple of 256 | intermediate_size |
iso-parameter with 3 matrices |
| Position encoding | Learned absolute | RoPE (+ NoPE layers at long ctx) | rope_theta, rope_scaling |
Su et al. 2021 |
| Multi-head variant | MHA | GQA, \(H_{kv}=8\) | num_key_value_heads |
Ainslie et al. 2023 |
| Bias in Linear | Yes | No | attention_bias: false |
Qwen3 dropped the last holdout |
| Tied embeddings | Yes | Tied under ~4B, untied above | tie_word_embeddings |
budget-fraction decision |
| QK-norm | No | Yes (increasingly standard) | q_norm/k_norm modules |
Gemma 3, OLMo 2, Qwen3 |
| Logit soft-cap | No | Rare — superseded | attn_logit_softcapping |
Gemma 2 only; Gemma 3 dropped it |
| Logit z-loss | No | Common (training-time only) | (loss-side, not in config) | PaLM, ST-MoE, OLMo 2 |
| Attention sink | Ignored | Keep BOS in KV cache; learned sink logits | sinks (gpt-oss) |
StreamingLLM 2023; gpt-oss 2025 |
"""
Putting it all together: a from-scratch ModernTransformerBlock.
This implements the full recipe: pre-RMSNorm, SwiGLU, GQA with RoPE,
no biases, QK-norm optional. Runnable as a standalone unit test.
"""
import torch
import torch.nn as nn
import torch.nn.functional as F
import math
class RMSNorm(nn.Module):
def __init__(self, dim: int, eps: float = 1e-5):
super().__init__()
self.eps = eps
self.weight = nn.Parameter(torch.ones(dim))
def forward(self, x):
return x * torch.rsqrt(x.float().pow(2).mean(-1, keepdim=True) + self.eps
).type_as(x) * self.weight
class SwiGLUFFN(nn.Module):
def __init__(self, d_model: int, intermediate: int):
super().__init__()
self.gate = nn.Linear(d_model, intermediate, bias=False)
self.up = nn.Linear(d_model, intermediate, bias=False)
self.down = nn.Linear(intermediate, d_model, bias=False)
def forward(self, x):
return self.down(F.silu(self.gate(x)) * self.up(x))
class ModernTransformerBlock(nn.Module):
"""
A single Transformer block implementing the modern (2024-era) recipe:
- Pre-RMSNorm on both attention and MLP sublayers
- GQA-style attention (n_kv_heads <= n_heads)
- SwiGLU FFN
- No biases anywhere
- Optional QK-norm
Does NOT include RoPE application here for brevity; in a full model
RoPE would be applied to Q and K after projection.
"""
def __init__(
self,
d_model: int,
n_heads: int,
n_kv_heads: int,
intermediate: int,
qk_norm: bool = False,
eps: float = 1e-5,
):
super().__init__()
assert d_model % n_heads == 0
assert n_heads % n_kv_heads == 0
self.n_heads = n_heads
self.n_kv_heads = n_kv_heads
self.n_rep = n_heads // n_kv_heads
self.head_dim = d_model // n_heads
# Pre-norm before attention
self.attn_norm = RMSNorm(d_model, eps)
# Attention projections — no bias
self.q_proj = nn.Linear(d_model, n_heads * self.head_dim, bias=False)
self.k_proj = nn.Linear(d_model, n_kv_heads * self.head_dim, bias=False)
self.v_proj = nn.Linear(d_model, n_kv_heads * self.head_dim, bias=False)
self.o_proj = nn.Linear(d_model, d_model, bias=False)
# Optional QK-norm
self.qk_norm = qk_norm
if qk_norm:
self.q_norm = RMSNorm(self.head_dim, eps)
self.k_norm = RMSNorm(self.head_dim, eps)
# Pre-norm before MLP
self.ffn_norm = RMSNorm(d_model, eps)
self.ffn = SwiGLUFFN(d_model, intermediate)
# Scaled residual initialization for stability in deep models
# Scale output projections by 1/sqrt(2*n_layers); caller should
# set this post-construction, e.g.:
# for block in blocks:
# block.o_proj.weight.data.mul_(1.0 / math.sqrt(2 * n_layers))
# block.ffn.down.weight.data.mul_(1.0 / math.sqrt(2 * n_layers))
def _repeat_kv(self, x: torch.Tensor) -> torch.Tensor:
if self.n_rep == 1:
return x
return x.unsqueeze(3).expand(
*x.shape[:2], self.n_kv_heads, self.n_rep, self.head_dim
).reshape(*x.shape[:2], self.n_heads, self.head_dim)
def forward(self, x: torch.Tensor) -> torch.Tensor:
B, T, C = x.shape
# --- Attention sublayer (pre-norm) ---
h = self.attn_norm(x)
q = self.q_proj(h).view(B, T, self.n_heads, self.head_dim)
k = self.k_proj(h).view(B, T, self.n_kv_heads, self.head_dim)
v = self.v_proj(h).view(B, T, self.n_kv_heads, self.head_dim)
if self.qk_norm:
q = self.q_norm(q)
k = self.k_norm(k)
k = self._repeat_kv(k)
v = self._repeat_kv(v)
# (B, H, T, d_k)
q = q.transpose(1, 2)
k = k.transpose(1, 2)
v = v.transpose(1, 2)
# Causal mask for autoregressive decoding
mask = torch.tril(torch.ones(T, T, device=x.device)).bool()
attn_logits = (q @ k.transpose(-2, -1)) / math.sqrt(self.head_dim)
attn_logits = attn_logits.masked_fill(~mask, float('-inf'))
attn_w = torch.softmax(attn_logits, dim=-1)
out = (attn_w @ v).transpose(1, 2).contiguous().view(B, T, C)
x = x + self.o_proj(out) # residual connection
# --- MLP sublayer (pre-norm) ---
x = x + self.ffn(self.ffn_norm(x)) # residual connection
return x
# --- Integration test ---
torch.manual_seed(0)
block = ModernTransformerBlock(
d_model=512, n_heads=8, n_kv_heads=2, intermediate=1365, # 8/3 * 512 ≈ 1365
qk_norm=True
)
x = torch.randn(2, 16, 512) # batch=2, seq=16, d_model=512
y = block(x)
print(f"Block output shape: {y.shape}") # (2, 16, 512)
print(f"Output norm (should be finite): {y.norm().item():.3f}")
param_count = sum(p.numel() for p in block.parameters())
print(f"Block parameter count: {param_count:,}") # 2,753,152 -> ~2.75M
# Check by hand: attention = 512*512 (Q) + 2*512*128 (K,V, GQA) + 512*512 (O)
# = 655,360; SwiGLU = 3*512*1365 = 2,096,640;
# norms = 2*512 + 2*64 (qk_norm) = 1,152. Total 2,753,152.
The Recipe in Practice: Config Files, Fused Kernels & Reference Repos¶
We opened by promising that a modern config.json would stop being alphabet soup. Here is the actual configuration of Llama 3.2 3B — the model we sized by hand earlier — with every field now decodable:
{
"architectures": ["LlamaForCausalLM"],
"hidden_size": 3072,
"intermediate_size": 8192,
"num_hidden_layers": 28,
"num_attention_heads": 24,
"num_key_value_heads": 8,
"hidden_act": "silu",
"rms_norm_eps": 1e-05,
"rope_theta": 500000.0,
"rope_scaling": {
"rope_type": "llama3",
"factor": 32.0,
"low_freq_factor": 1.0,
"high_freq_factor": 4.0,
"original_max_position_embeddings": 8192
},
"max_position_embeddings": 131072,
"attention_bias": false,
"tie_word_embeddings": true,
"vocab_size": 128256,
"torch_dtype": "bfloat16"
}
Read it against this chapter: rms_norm_eps is the \(\epsilon\) of our RMSNorm (pre-norm placement is implied by the architecture class, not by a flag); hidden_act: "silu" with intermediate_size \(\ne 4d\) tells you it is SwiGLU, since a plain FFN would have no third matrix and would sit at \(4 \times 3072 = 12288\); num_key_value_heads: 8 against 24 query heads is GQA with \(G=3\); attention_bias: false is the no-bias decision; tie_word_embeddings: true is the small-model budget call (\(128256 \times 3072 = 394\)M would otherwise be paid twice); rope_theta: 500000 plus rope_scaling is long-context RoPE, trained at 8192 and extended 32× by frequency-dependent interpolation (see Long-Context Pretraining & Context Extension). There is no q_norm field because QK-norm in transformers is expressed as submodules in the attention class (Qwen3, Gemma 3, OLMo 2) rather than a boolean.
Instantiating that recipe — no download required — is three lines:
from transformers import LlamaConfig, LlamaForCausalLM, AutoModelForCausalLM
# Route 1: build the architecture from the recipe, randomly initialized.
cfg = LlamaConfig(
hidden_size=3072, intermediate_size=8192, num_hidden_layers=28,
num_attention_heads=24, num_key_value_heads=8, # GQA, G=3
hidden_act="silu", rms_norm_eps=1e-5, # SwiGLU + RMSNorm
rope_theta=500000.0, attention_bias=False, # RoPE, no biases
tie_word_embeddings=True, vocab_size=128256,
)
model = LlamaForCausalLM(cfg)
print(f"{sum(p.numel() for p in model.parameters()):,}") # ~3.21B, matching our hand count
# Route 2: load real weights and read the same knobs back off the config.
# model = AutoModelForCausalLM.from_pretrained(
# "meta-llama/Llama-3.2-3B",
# attn_implementation="flash_attention_2", # or "sdpa" (default) / "eager"
# dtype="bfloat16",
# )
That attn_implementation argument is where this chapter meets Part IV. "eager" is the hand-rolled softmax we wrote above; "sdpa" dispatches to torch.nn.functional.scaled_dot_product_attention; "flash_attention_2" calls the flash-attn package’s kernel directly. Only the first can express logit soft-capping — which is precisely why Gemma 3 dropped it.
Where the fused kernels live¶
The from-scratch modules in this chapter are for understanding. In a real training loop every one of them has a fused counterpart, and using them is not optional at scale:
| Layer | From scratch (this chapter) | What you actually call | Package |
|---|---|---|---|
| GQA attention | manual _repeat_kv + softmax |
F.scaled_dot_product_attention(..., enable_gqa=True) |
PyTorch (2.5+) |
| Attention (train) | — | flash_attn_func, flash_attn_varlen_func |
flash-attn |
| RMSNorm | x * rsqrt(mean(x²)) |
LigerRMSNorm, apex.normalization.FusedRMSNorm |
Liger-Kernel, apex |
| SwiGLU MLP | three nn.Linear + F.silu |
LigerSwiGLUMLP |
Liger-Kernel |
| RoPE | complex-valued rotation | LigerRopeFunction, apply_rotary_emb |
Liger-Kernel, flash-attn |
| LM head + CE (+ z-loss) | lm_head then cross_entropy |
LigerFusedLinearCrossEntropy, cut-cross-entropy |
Liger-Kernel |
The last row matters more than it looks: it never materializes the \((B, T, V)\) logit tensor, which at \(BT=16384\) and \(V=128256\) would be 8.4 GB in fp32 — often the single largest tensor in a small-model training step. enable_gqa=True similarly avoids materializing the repeated K and V, which our teaching code does materialize. Liger-Kernel is written in Triton (see Writing GPU Kernels with Triton); torch.compile will fuse the simpler ones (RMSNorm, SwiGLU) for you without any extra dependency.
import torch
import torch.nn.functional as F
# The production form of the GQA forward pass: no _repeat_kv materialization,
# causal masking inside the kernel, and an IO-aware fused backend under the hood.
B, T, Hq, Hkv, dk = 2, 128, 24, 8, 128
q = torch.randn(B, Hq, T, dk) # (B, H_q, T, d_k)
k = torch.randn(B, Hkv, T, dk) # (B, H_kv, T, d_k) <-- 3x smaller
v = torch.randn(B, Hkv, T, dk)
out = F.scaled_dot_product_attention(q, k, v, is_causal=True, enable_gqa=True)
print(out.shape) # (2, 24, 128, 128) -- broadcast over the group
Reference implementations worth reading¶
When you need a correct, complete, trainable version of this recipe rather than a teaching one, these are the canonical open-source starting points, in rough order of increasing machinery: nanoGPT (Karpathy — the minimal GPT-2 training loop, the ancestor of Building a GPT From Scratch); litgpt (Lightning AI — clean single-file implementations of ~20 modern architectures); torchtitan (PyTorch’s own reference for pretraining Llama-family models with FSDP2 + tensor/pipeline parallelism); torchtune (PyTorch’s fine-tuning library, with the architectures expressed as composable builder functions); nanotron (HuggingFace’s minimal 3D-parallel pretrainer); and Megatron-LM / DeepSpeed for the industrial path, covered in Megatron-LM, DeepSpeed & Parallelism in Practice. All six implement exactly the components of this chapter; the differences between them are parallelism and ergonomics, not architecture.
Key Takeaways
- RMSNorm (pre-norm placement) replaces LayerNorm in virtually all modern LLMs. It is ~10–30% faster, equally stable, and requires no bias parameters.
- SwiGLU provides multiplicative gating that consistently outperforms ReLU/GELU FFNs at equal parameter cost; the three-weight design requires scaling the intermediate dimension to \(\frac{8}{3}d\) to stay iso-parameter.
- RoPE enables relative positional encoding without a separate embedding table, generalizes beyond training context length, and is the universal choice for decoder-only models. The
rope_thetabase should be set high (100k–500k) for long-context models. - GQA (Grouped Query Attention) reduces KV cache memory by a factor equal to the grouping ratio (commonly 4–8x) with minimal quality degradation; this is the key architectural enabler for long-context inference.
- QK-norm (normalizing Q and K per head before computing attention scores) prevents attention logit explosion in large or long-training models; originating in ViT-22B and now used in Gemma 3, OLMo 2 and Qwen3, it is increasingly a default rather than a large-scale-only trick.
- No biases in linear layers is almost universal at the frontier; biases add optimizer memory overhead and negligible quality benefit, especially with pre-RMSNorm, and they drive activation outliers that later hurt quantization. Qwen3 dropped the last common holdout (QKV bias) in favor of QK-norm.
- Logit soft-capping (\(z \to c \cdot \tanh(z/c)\)) is a differentiable alternative to hard clipping, but it cannot live inside a FlashAttention kernel; Gemma 3 replaced it with QK-norm, and the training-time z-loss is the standard guard on final logits.
- Attention sinks (typically the BOS token) must be preserved in the KV cache for streaming/long-context inference; evicting them causes catastrophic attention pattern collapse. The 2025 refinement is a learned per-head sink logit with no token and no value vector (gpt-oss).
- Know the config field for every knob.
num_key_value_heads,rope_theta,hidden_act,attention_bias,tie_word_embeddings,rms_norm_epsare the interface;flash-attn, PyTorch SDPA (enable_gqa=True), and Liger-Kernel are the implementations you actually run. - Scaled residual initialization (\(\times 1/\sqrt{2L}\) on output projections) is essential for stable training of very deep models; without it, the residual stream variance grows with depth.
State of the Art & Resources (2026)
The modern transformer recipe — RMSNorm, SwiGLU, RoPE, GQA, and QK-norm — has become near-universal across frontier open-source models (Llama 4, Qwen3, DeepSeek-V3, Gemma 3) since 2023, with z-loss and attention-sink awareness rounding out the toolkit for stable long-context training (logit soft-capping was a Gemma 2-only detour that Gemma 3 itself abandoned, because a \(\tanh\) on the score matrix is incompatible with FlashAttention). The 2025 generation pushed on two fronts: sparse Mixture-of-Experts backbones (DeepSeek-V3, Llama 4, Qwen3) and long-context attention variants (Gemma 3’s 5:1 local-to-global sliding-window interleave; Llama 4’s position-free “NoPE” layers).
Foundational work
- Zhang & Sennrich, Root Mean Square Layer Normalization (2019) — establishes that re-scaling (not re-centering) drives LayerNorm’s benefit, motivating the lighter RMSNorm used in virtually all modern LLMs.
- Shazeer, GLU Variants Improve Transformer (2020) — introduces SwiGLU and GeGLU gated feed-forward networks that consistently outperform ReLU/GELU at equal parameter cost.
- Su et al., RoFormer: Enhanced Transformer with Rotary Position Embedding (2021) — proposes RoPE, encoding relative position via rotation matrices in the attention QK dot-product; now the dominant positional scheme for decoder-only models.
- Ainslie et al., GQA: Training Generalized Multi-Query Transformer Models from Multi-Head Checkpoints (2023) — shows grouped-query attention achieves MHA quality at MQA memory cost and provides an uptraining recipe for existing checkpoints.
Recent advances (2023–2026)
- The Gemma Team, Gemma 2: Improving Open Language Models at a Practical Size (2024) — introduces logit soft-capping (\(c \cdot \tanh(z/c)\)) on both attention and final logits, sandwich (pre + post) normalization, and interleaved sliding-window attention.
- Dehghani et al., Scaling Vision Transformers to 22 Billion Parameters (2023) and Wortsman et al., Small-scale proxies for large-scale Transformer training instabilities (2023) — the origin and the systematic study of QK-norm; the second is the best single reference on diagnosing attention-logit growth from small-scale proxies.
- DeepSeek-AI, DeepSeek-V3 Technical Report (2024) — comprehensive recipe for a 671B MoE model using MLA, SwiGLU, RMSNorm, and auxiliary-loss-free load balancing; shows the modern stack scales to frontier quality.
- Meta AI, The Llama 3 Herd of Models (2024) — documents the full open-source recipe at 8B–405B scale: GQA, RoPE with
theta=500000, untied embeddings, and a 128K context window. Its 2025 successor, Llama 4, moves to a Mixture-of-Experts backbone with iRoPE — interleaving position-free “NoPE” layers among the RoPE layers to reach a 10M-token context. - The Gemma Team, Gemma 3 Technical Report (2025) — explicitly replaces Gemma 2’s soft-capping with QK-norm and adopts a 5:1 local-to-global sliding-window attention ratio (1024-token local windows) to cut long-context KV-cache cost; the cleanest published statement of the current norm/stability consensus.
- Qwen Team, Qwen3 Technical Report (2025) — a 0.6B–235B family spanning dense and MoE variants that folds fast/”thinking” reasoning modes into a single checkpoint atop the standard RMSNorm/SwiGLU/RoPE/GQA stack.
Open-source & tools
- huggingface/transformers — canonical implementations of every major modern architecture (Llama, Qwen, Gemma, DeepSeek, gpt-oss) with config files exposing every design knob discussed in this chapter;
attn_implementationselects between eager / SDPA / FlashAttention-2 backends. - pytorch/torchtitan and pytorch/torchtune — PyTorch’s own reference pretraining and fine-tuning stacks; the cleanest reading of this recipe wired to FSDP2 and tensor/pipeline parallelism.
- linkedin/Liger-Kernel — Triton kernels fusing exactly the modules in this chapter (RMSNorm, SwiGLU, RoPE, and a fused linear + cross-entropy that never materializes the \((B,T,V)\) logit tensor).
- Dao-AILab/flash-attention — the attention kernel every serious training run uses, plus fused RoPE and layer-norm utilities.
- Lightning-AI/litgpt and karpathy/nanoGPT — readable single-file implementations for checking your own from-scratch model against a known-good reference.
Go deeper
- Lilian Weng, The Transformer Family Version 2.0 (2023) — authoritative survey of architectural improvements (efficient attention, positional encoding variants, long-context techniques) with clear diagrams and equations.
Further Reading¶
- Zhang and Sennrich, “Root Mean Square Layer Normalization,” NeurIPS 2019 — the RMSNorm paper.
- Noam Shazeer, “GLU Variants Improve Transformer,” arXiv 2020 — SwiGLU and GeGLU.
- Su et al., “RoFormer: Enhanced Transformer with Rotary Position Embedding,” arXiv 2021 — original RoPE paper.
- Ainslie et al., “GQA: Training Generalized Multi-Query Transformer Models from Multi-Head Checkpoints,” EMNLP 2023 — GQA, including a method for uptraining existing MHA checkpoints.
- Touvron et al., “Llama 2: Open Foundation and Fine-Tuned Chat Models,” arXiv 2023 — a comprehensive blueprint of the modern recipe in an openly released model.
- Team Gemma, “Gemma 2: Improving Open Language Models at a Practical Size,” Google DeepMind 2024 — logit soft-capping, sandwich normalization, and interleaved sliding-window attention; and “Gemma 3 Technical Report,” 2025, which swaps soft-capping for QK-norm.
- Wortsman et al., “Small-scale proxies for large-scale Transformer training instabilities,” 2023 — QK-norm, z-loss and logit-growth diagnostics studied at a scale you can actually reproduce.
- Press and Wolf, “Using the Output Embedding to Improve Language Models,” EACL 2017 — the original weight-tying argument.
- Liu et al., “MobileLLM: Optimizing Sub-billion Parameter Language Models for On-Device Use Cases,” ICML 2024 — the deep-and-thin aspect-ratio result for small models.
- Xiao et al., “Efficient Streaming LLMs with Attention Sinks,” ICLR 2024 — attention sink phenomenon and StreamingLLM.
- Kaplan et al., “Scaling Laws for Neural Language Models,” arXiv 2020 — depth vs width trade-offs in the context of scaling.
- Meta AI, Llama 3 model card and technical report, 2024 — updated RoPE theta and architectural decisions at 70B/405B scale.
Exercises¶
1. (Conceptual) LayerNorm applies \(\frac{x-\mu}{\sqrt{\sigma^2+\epsilon}}\cdot\gamma + \beta\), whereas RMSNorm applies \(\frac{x}{\text{RMS}(x)}\cdot\gamma\) with no \(\beta\). (a) Which two operations present in LayerNorm does RMSNorm discard, and which one does it keep? (b) Zhang & Sennrich justified the discard empirically — what did their ablation find? © In the chapter’s RMSNorm.forward, why is the input cast to float32 before computing the norm and cast back afterward?
Solution
(a) RMSNorm discards mean subtraction (the \(-\mu\) re-centering) and the learned shift \(\beta\). It keeps the re-scaling: division by a per-vector magnitude statistic followed by the learned element-wise scale \(\gamma\). The magnitude statistic changes from the standard deviation \(\sigma\) (computed around the mean) to the root-mean-square \(\text{RMS}(x)=\sqrt{\frac{1}{d}\sum_i x_i^2 + \epsilon}\) (computed around zero), so no mean is ever needed.
(b) Their ablation of LayerNorm into its components found that the re-scaling via \(\gamma\) drives almost all of LayerNorm’s benefit, while mean subtraction contributes little to final performance yet costs roughly a third of LayerNorm’s compute (a second reduction pass plus a subtraction kernel). Dropping it is therefore near-free in quality but ~10-30% faster.
© The normalization involves squaring every element, summing, and taking a reciprocal square root. In low-precision formats like bf16 the squared sum can lose precision or overflow the narrow dynamic range, and the reciprocal-square-root is sensitive to rounding. Computing x.float().pow(2).mean(...) in float32 keeps the reduction accurate; the result is then cast back with .type_as(x) so the rest of the network still runs in the model’s working precision. This is exactly the pattern used in the Llama reference code.
2. (Quantitative) You are configuring a SwiGLU FFN for a model with hidden dimension \(d = 4096\). A vanilla two-matrix FFN would use a 4x expansion (intermediate \(= 4d\)). (a) Compute the parameter count of that vanilla FFN. (b) Using the iso-parameter rule for SwiGLU’s three matrices, compute the target intermediate dimension, then round it to the nearest multiple of 256. © Compute the SwiGLU FFN’s parameter count at that rounded dimension and confirm it is close to the vanilla count.
Solution
(a) Vanilla FFN has two matrices, \(W_1\in\mathbb{R}^{d\times 4d}\) and \(W_2\in\mathbb{R}^{4d\times d}\): $\(P_{\text{vanilla}} = 2 \cdot d \cdot 4d = 8d^2 = 8 \times 4096^2 = 134{,}217{,}728 \approx 134.2\text{M}.\)$
(b) SwiGLU has three matrices (\(W\), \(V\), \(W_2\)), so its cost is \(3\,d\,d_{ff}\). Setting this equal to \(8d^2\) gives the iso-parameter rule: $\(d_{ff} = \frac{8d^2}{3d} = \frac{8d}{3} = \frac{8 \times 4096}{3} = 10922.67.\)$ Rounding to the nearest multiple of 256: \(10922.67 / 256 = 42.67 \to 43\), and \(43 \times 256 = 11008\). (This is exactly the intermediate size Llama 2 7B uses.)
© At \(d_{ff}=11008\): $\(P_{\text{SwiGLU}} = 3 \cdot 4096 \cdot 11008 = 135{,}266{,}304 \approx 135.3\text{M}.\)$ This is within about 0.8% of the vanilla \(134.2\)M — the small excess comes from rounding \(10922.67\) up to \(11008\). SwiGLU is thus iso-parameter with the vanilla 4x FFN while adding multiplicative gating.
3. (Quantitative) A decoder-only model has \(L = 48\) layers, \(H_q = 40\) query heads, head dimension \(d_k = 128\), and uses GQA with \(H_{kv} = 8\) KV heads. It runs in bf16 (2 bytes/element). (a) Compute the KV-cache size per token per layer. (b) Compute the total KV cache for a context of 16,384 tokens across all layers, in GiB. © What would the same total be with full MHA (\(H_{kv} = H_q = 40\)), and what is the reduction factor?
Solution
(a) The cache stores both K and V for each KV head: $\(\text{bytes/token/layer} = 2\,(\text{K and V}) \times H_{kv} \times d_k \times 2\,(\text{bytes}) = 2 \times 8 \times 128 \times 2 = 4096\ \text{bytes} = 4\ \text{KiB}.\)$
(b) Total over all layers and all context positions: $\(4096 \times 48 \times 16384 = 3{,}221{,}225{,}472\ \text{bytes} = 3 \times 2^{30}\ \text{bytes} = 3\ \text{GiB}.\)$
© With full MHA, \(H_{kv}=40\) so bytes/token/layer \(= 2 \times 40 \times 128 \times 2 = 20480\) bytes \(= 20\) KiB. Total: $\(20480 \times 48 \times 16384 = 16{,}106{,}127{,}360\ \text{bytes} = 15\ \text{GiB}.\)$ The reduction factor is \(15 / 3 = 5\), exactly \(H_q / H_{kv} = 40/8 = 5\) — GQA shrinks the KV cache by the grouping ratio \(G\), here \(G=5\).
4. (Conceptual) Llama 1 used rope_theta = 10000; Llama 3 raised it to rope_theta = 500000 specifically to improve long-context behavior. (a) In the frequency formula \(\theta_i = \text{base}^{-2i/d_k}\), what happens to the set of rotation frequencies as the base increases from 10,000 to 500,000? (b) Why does that change help a model extrapolate to positions beyond its training length? © RoPE is described as giving a “relative position” property — state precisely what quantity the attention dot product \(q_m^\top k_n\) ends up depending on, and why that property is desirable.
Solution
(a) Each frequency is \(\theta_i = \text{base}^{-2i/d_k}\), a value in \((0,1]\) that shrinks as \(i\) grows. Raising the base makes the exponential decay steeper, so the frequencies (especially at larger \(i\)) become smaller — the rotations turn more slowly and the corresponding wavelengths become longer. The overall frequency spectrum is stretched/spread out: the fastest pair still rotates at \(\theta_0 = 1\), but the slow pairs rotate far more slowly than with base 10,000.
(b) With longer wavelengths, a given absolute position \(m\) produces a smaller total rotation angle \(m\theta_i\) on the low-frequency pairs. This means positions farther apart than anything seen in training still fall within the first, unambiguous part of each sinusoid rather than “wrapping around” and aliasing onto angles the model saw for nearby positions. Spreading the frequencies more broadly therefore keeps distant positions distinguishable and makes extrapolation to longer contexts at inference time behave more gracefully.
© Because position \(m\) rotates the query and position \(n\) rotates the key by angles proportional to \(m\) and \(n\) in each 2D subspace, the inner product \(q_m^\top k_n\) depends only on the relative offset \((m-n)\), not on the absolute positions \(m\) and \(n\) individually. This is desirable because linguistic relationships are typically relative (“the previous word”, “three tokens back”): a model whose attention scores are a function of distance generalizes across the sequence and to unseen absolute positions, and needs no separate learned position-embedding table.
5. (Implementation) Gemma 2 applies logit soft-capping in two places: on the attention logits (pre-softmax, \(c=50\)) and on the final vocabulary logits (\(c=30\)), using \(\hat z = c\cdot\tanh(z/c)\). Starting from the chapter’s ModernTransformerBlock, modify its forward so that attention logits are soft-capped at \(c=50\). Then (a) explain why the cap must be applied before the causal -inf mask, and (b) verify by hand what an uncapped logit of \(75\) becomes after soft-capping at \(c=50\).
Solution
Add a soft_cap helper and insert one line into the attention path, capping the scaled logits before the causal mask and softmax:
import torch
import math
def soft_cap(x: torch.Tensor, cap: float) -> torch.Tensor:
# Smoothly squashes x toward [-cap, +cap]; ~linear for |x| << cap.
return cap * torch.tanh(x / cap)
def forward(self, x: torch.Tensor, attn_logit_cap: float = 50.0) -> torch.Tensor:
B, T, C = x.shape
# --- Attention sublayer (pre-norm) ---
h = self.attn_norm(x)
q = self.q_proj(h).view(B, T, self.n_heads, self.head_dim)
k = self.k_proj(h).view(B, T, self.n_kv_heads, self.head_dim)
v = self.v_proj(h).view(B, T, self.n_kv_heads, self.head_dim)
if self.qk_norm:
q = self.q_norm(q)
k = self.k_norm(k)
k = self._repeat_kv(k)
v = self._repeat_kv(v)
q = q.transpose(1, 2)
k = k.transpose(1, 2)
v = v.transpose(1, 2)
attn_logits = (q @ k.transpose(-2, -1)) / math.sqrt(self.head_dim)
# >>> soft-cap the attention logits (Gemma 2, c=50) BEFORE masking <<<
attn_logits = soft_cap(attn_logits, attn_logit_cap)
mask = torch.tril(torch.ones(T, T, device=x.device)).bool()
attn_logits = attn_logits.masked_fill(~mask, float('-inf'))
attn_w = torch.softmax(attn_logits, dim=-1)
out = (attn_w @ v).transpose(1, 2).contiguous().view(B, T, C)
x = x + self.o_proj(out)
# --- MLP sublayer (pre-norm) ---
x = x + self.ffn(self.ffn_norm(x))
return x
(a) The mask fills the disallowed (future) positions with \(-\infty\) so that softmax assigns them exactly zero weight. If we soft-capped after masking, every masked entry \(-\infty\) would be sent to \(50\cdot\tanh(-\infty/50) = -50\), a finite value — those future tokens would then receive non-zero attention weight and the causal property would be broken. Capping must therefore act only on the real, finite logits, i.e. before the -inf mask is applied. (This mirrors QK-norm and soft-capping being complementary front-end guards on the logits, with masking as the final step.)
(b) With \(z = 75\) and \(c = 50\): \(z/c = 1.5\), and \(\tanh(1.5) \approx 0.9051\). So $\(\hat z = 50 \times 0.9051 \approx 45.26.\)$ The logit is compressed from \(75\) toward the cap of \(50\) (not hard-clipped): it stays below \(50\), preserves sign and ordering, and remains differentiable, which is precisely the point of using \(\tanh\) rather than a hard clamp.