The unglamorous plumbing — where you put the norm, whether you subtract the mean, and how you gate the MLP — is what actually lets a 100-layer transformer train without diverging.
Attention gets the headlines, but the reason a modern transformer with 80+ layers trains at all is the boring scaffolding around it: normalization, residuals, and the FFN. A transformer block is x -> x + Attn(Norm(x)) -> x + FFN(Norm(x)). The residual gives gradients a clean highway from the loss all the way back to layer 0. Pre-norm (normalize inside the residual branch, never on the highway itself) is what keeps that highway un-distorted so you can stack depth. RMSNorm is LayerNorm with the mean-subtraction deleted — same stabilization, fewer ops. And the FFN does two-thirds of the parameter work in the model; the modern version is a gated MLP (SwiGLU) that multiplies two projections together for a learned, input-dependent gate. Get these four pieces right and the model converges; get them wrong and a 70-layer model NaNs in the first thousand steps.
d_model -> 4·d_model -> d_model?The words first.
x + f(x)) so information and gradients can bypass the layer.Step by step.
x.x (the residual).Remember this: normalize inside the residual branch (pre-norm), keep the skip path clean, and let the gated FFN do the heavy lifting.
LayerNorm normalizes across the feature dimension of a single token (not across the batch — that's BatchNorm, which is brittle for variable-length sequences). For a token vector :
Symbols: is feature of the token; is the mean over the features; is the variance over those features; (e.g. ) prevents divide-by-zero; are learned scale and shift; is element-wise multiply. The job: keep each token's activations at a controlled scale so the next sublayer never sees exploding or vanishing inputs, which stabilizes training and lets you use higher learning rates.
RMSNorm (Zhang & Sennrich, 2019) makes one observation: most of LayerNorm's benefit comes from the rescaling (dividing by a measure of magnitude), not the re-centering (subtracting the mean). So drop entirely:
Here the denominator is the root-mean-square of the features. No , no (typically). Why is this safe? A mechanistic finding is that models trained with RMSNorm spontaneously learn representations roughly orthogonal to the uniform (all-ones) vector — and LayerNorm's mean-subtraction is exactly the projection that removes the component along that uniform vector. If the model already avoids putting signal there, subtracting the mean is removing something close to zero: redundant work. Empirically RMSNorm is 7–64% faster than LayerNorm (the range depends on shape and hardware) with no meaningful quality loss — verified across LLaMA, OLMo, and others. That's why it's ubiquitous in 2024–2026 LLMs.
The savings: you skip computing , skip the subtraction broadcast, and skip the add. On a memory-bandwidth-bound norm kernel, fewer passes over the activation tensor matters more than the FLOP count.
Take a tiny token vector x = [2, -2, 4, 0], so d = 4. Set gamma = [1,1,1,1], eps ≈ 0.
LayerNorm path:
mu = (2 - 2 + 4 + 0)/4 = 1x - mu = [1, -3, 3, -1]sigma^2 = (1 + 9 + 9 + 1)/4 = 5, so sqrt = 2.236= [1, -3, 3, -1]/2.236 = [0.447, -1.342, 1.342, -0.447]RMSNorm path:
= (4 + 4 + 16 + 0)/4 = 6, so rms = sqrt(6) = 2.449= [2, -2, 4, 0]/2.449 = [0.816, -0.816, 1.633, 0]What it did: both rescaled the vector to a unit-ish magnitude. LayerNorm first shifted everything so the mean became 0 (note its outputs sum to 0); RMSNorm skipped that shift, did one fewer pass over the data, and produced a slightly different but equally well-scaled vector. The model learns gamma to compensate for the difference.
The residual (He et al., 2015) is x_{l+1} = x_l + f(x_l). The magic is in the backward pass. Differentiate:
That +1 is everything. In a plain deep network, the gradient at layer is a long product of Jacobians, ; if each factor has magnitude < 1, the product decays exponentially with depth and early layers get no signal (vanishing gradients). The residual replaces each factor with (1 + ∂f/∂x_l). Even if the sublayer's Jacobian , the gradient still flows through the +1 term — an identity highway from the loss back to layer 0. This is the single reason you can stack 80 transformer blocks instead of 6.
A second view: a residual stack computes an additive refinement. Each block reads the residual stream, writes a small update, and adds it. The stream is a shared scratchpad; layers compose by accumulation, not by repeated multiplication. This framing (the "residual stream") is also how mechanistic interpretability reasons about transformers.
The original transformer (Vaswani et al., 2017) used post-norm: x_{l+1} = LN(x_l + Sublayer(x_l)). The norm sits on the residual highway, after the add. This is a problem: the clean identity path is now wrapped in a normalization at every layer, so the gradient highway is repeatedly rescaled and the effective signal can blow up or shrink as depth grows. Post-norm transformers need careful learning-rate warmup and are finicky past a couple dozen layers.
Pre-norm (Xiong et al., 2020) moves the norm inside the residual branch:
Now the skip path x_l + ... is a pure identity — nothing touches it. The norm only conditions the input that flows into the sublayer. Gradient flow analysis shows pre-norm keeps gradient magnitudes well-behaved across depth without warmup, which is why every large modern LLM (LLaMA, Qwen, Gemma, GPT-style) is pre-norm. The tradeoff: pre-norm lets the residual stream grow in magnitude with depth (each block adds to it, and nothing renormalizes the stream itself), so the final layers' relative contributions shrink and you typically add one final norm before the output head. Post-norm, by contrast, has been reported to give slightly better results in some narrow regimes (e.g. a notable BLEU gain in certain machine-translation setups) precisely because it does renormalize the stream — but it pays for that with training instability you have to fight. At staff scale the verdict is settled: pre-norm, plus a final norm, plus sometimes tricks like scaling residual branches by to tame the growing stream.
The FFN's nonlinearity has drifted over time:
The bigger leap is gating (GLU variants, Shazeer 2020). Instead of one projection through a nonlinearity, split into two projections and multiply:
produces the gate, produces the value; the gate (squashed by SiLU) decides, per dimension and per token, how much of the value passes. GeGLU is identical with GELU instead of SiLU. Because gating consumes the hidden dimension across two matrices, implementations shrink the hidden width to to keep parameter count matched to a standard FFN. The payoff is consistently lower perplexity and better downstream scores at equal parameters — small but reliable, which is why SwiGLU/GeGLU is standard in LLaMA, OLMo, Gemma. The cost is one extra matmul (three weight matrices instead of two: ).
The FFN (a.k.a. MLP) is applied independently to every token position — no mixing across positions (attention already did that). A standard FFN:
It expands , applies the nonlinearity, contracts back. This is where roughly two-thirds of a transformer's parameters live (the expansion dominates attention's projections). Interpretively, the FFN acts as a per-token key-value memory: rows detect features, columns write associated outputs into the residual stream. The gated version replaces with the SwiGLU expression above.
A complete, runnable pre-norm transformer block with RMSNorm and a SwiGLU FFN — production-shaped, the way LLaMA-family models are built.
import torch
import torch.nn as nn
import torch.nn.functional as F
class RMSNorm(nn.Module):
def __init__(self, dim: int, eps: float = 1e-6):
super().__init__()
self.eps = eps
self.gamma = nn.Parameter(torch.ones(dim)) # learned scale; no beta, no mean
def forward(self, x: torch.Tensor) -> torch.Tensor:
# rms over the feature dim; upcast to fp32 for numerical stability
norm = x.float().pow(2).mean(dim=-1, keepdim=True).add(self.eps).rsqrt()
return (x.float() * norm).type_as(x) * self.gamma
class SwiGLU(nn.Module):
"""Gated FFN. Hidden width shrunk to ~8/3*d to match a standard 4*d FFN's params."""
def __init__(self, dim: int, hidden: int | None = None):
super().__init__()
hidden = hidden or int(8 * dim / 3)
hidden = 256 * ((hidden + 255) // 256) # round to a hardware-friendly multiple
self.w_gate = nn.Linear(dim, hidden, bias=False)
self.w_value = nn.Linear(dim, hidden, bias=False)
self.w_out = nn.Linear(hidden, dim, bias=False)
def forward(self, x: torch.Tensor) -> torch.Tensor:
return self.w_out(F.silu(self.w_gate(x)) * self.w_value(x))
class Block(nn.Module):
def __init__(self, dim: int, n_heads: int):
super().__init__()
self.attn_norm = RMSNorm(dim)
self.ffn_norm = RMSNorm(dim)
self.attn = nn.MultiheadAttention(dim, n_heads, batch_first=True)
self.ffn = SwiGLU(dim)
def forward(self, x: torch.Tensor, attn_mask=None) -> torch.Tensor:
# PRE-NORM: norm is inside the residual branch; the skip path is a clean identity.
h = self.attn_norm(x)
x = x + self.attn(h, h, h, attn_mask=attn_mask, need_weights=False)[0]
x = x + self.ffn(self.ffn_norm(x))
return x
if __name__ == "__main__":
blk = Block(dim=512, n_heads=8)
tok = torch.randn(2, 16, 512) # (batch, seq, dim)
out = blk(tok)
print(out.shape) # torch.Size([2, 16, 512])
# sanity: a fresh block is near-identity, so output ≈ input (residual highway)
print((out - tok).abs().mean().item()) # small numberThree things to notice. First, the upcast to fp32 inside RMSNorm — the squaring can overflow or lose precision in bf16, so production code computes the norm in fp32 then casts back; getting this wrong is a real source of training instability. Second, the clean residual (x = x + sublayer(norm(x))) — the norm never touches the x on the left of the +. Third, rounding the hidden dim to a multiple of 256 so the matmuls hit fast tensor-core paths; the "8/3·d" is theoretical, hardware wants alignment.
| Choice | Cost / latency | Quality | Failure mode | What changes at scale |
|---|---|---|---|---|
| LayerNorm | Baseline; 2 passes over activations + mean/var | Baseline | Generally safe | Bandwidth-bound; mean term is wasted ops |
| RMSNorm | 7–64% faster norm; fewer memory passes | Parity with LN | fp32 upcast needed or bf16 instability | Free win; default everywhere |
| Post-norm | Same FLOPs as pre-norm | Slightly better in narrow regimes | Diverges past ~24 layers without warmup | Unusable at 80+ layers unmodified |
| Pre-norm | Same FLOPs; +1 final norm | Slightly softer final activations | Residual stream magnitude grows with depth | Standard; pair with final norm |
| ReLU/GELU FFN | 2 matmuls | Baseline | None | Simple, but leaves quality on the table |
| SwiGLU/GeGLU | 3 matmuls (+~50% FFN compute) | Reliably lower perplexity | Param accounting if you forget the 2/3 shrink | Standard in modern LLMs |
Cost. The FFN is ~two-thirds of the model's parameters and FLOPs, so the SwiGLU "third matmul" is not free — it's the dominant compute in the block. You recover the parameter count by shrinking hidden width to ≈8/3·d, but you still pay one extra matmul of activation read/writes. At inference this shows up as FFN being the throughput bottleneck, which is why MoE (sparse FFNs) is the next lever: route each token to a few experts so you scale parameters without scaling per-token FFN FLOPs.
Latency. Norm kernels are memory-bandwidth bound, not compute bound — they read and write the whole activation tensor and do little arithmetic. That's why RMSNorm's win is about fewer passes over memory, and why production stacks fuse norm + the following matmul into one kernel to avoid a round-trip to HBM.
Quality and failure modes. The catastrophic failures are at training time: post-norm + no warmup at depth -> divergence; RMSNorm in bf16 without fp32 upcast -> slow instability; forgetting the final norm in a pre-norm model -> a poorly-calibrated output head. None of these show up in a 12-layer toy model — they emerge at scale, which is exactly why interviewers ask.
What changes at scale. Deep pre-norm models grow the residual stream's magnitude with depth; large training runs counter this with branch-scaling (e.g. dividing each sublayer output by for layers) or with careful initialization so the stream stays in range for the final norm.
x / sqrt(mean(x^2) + eps) * gamma) and skips two things LayerNorm does: subtracting the mean and adding the learned bias beta. The saving is fewer passes over the activation tensor on a bandwidth-bound kernel, giving 7–64% faster norm with no quality loss — because models learn representations roughly orthogonal to the uniform vector, so subtracting the mean removes a near-zero component. You must compute the squared mean in fp32 to avoid bf16 instability.x_{l+1} = x_l + f(x_l) makes the backward pass ∂L/∂x_l = ∂L/∂x_{l+1} · (1 + ∂f/∂x_l). The +1 is an identity term that survives even when the sublayer's Jacobian goes to zero, so the gradient flows back through every layer via a clean highway instead of being a long product of sub-1 factors that decays exponentially with depth. That's what lets you train 80 layers instead of 6.d -> 4d -> d expansion gives it capacity to act as a key-value memory: the first matrix detects features, the second writes associated outputs into the residual stream. It holds roughly two-thirds of the model's parameters, so it dominates compute.LN(x + sublayer(x))), so it renormalizes the stream every layer — which can help final quality (a documented BLEU gain in some MT setups) but repeatedly distorts the gradient highway, causing divergence at depth without heavy warmup. Pre-norm normalizes inside the branch (x + sublayer(LN(x))), keeping the skip a pure identity and gradients well-behaved, at the cost of a residual stream whose magnitude grows with depth. At 100 layers I'd use pre-norm with a final norm before the head, plus residual branch-scaling (e.g. 1/√(2N)) or careful init to keep the growing stream in range — getting the stability for free and recovering most of post-norm's calibration benefit.SiLU(xW_g) ⊗ xW_v), giving an input-dependent, per-dimension gate instead of a fixed pointwise nonlinearity — empirically a reliable perplexity/downstream improvement. It uses three weight matrices instead of two, so to match a standard 4d FFN's parameter count you shrink the hidden width to ≈8/3·d (then round to a hardware-friendly multiple like 256). The cost is one extra matmul's worth of activation traffic, paid in the block's dominant compute, which is why it's a deliberate quality-for-compute trade that the field decided is worth it.x^2 sum loses precision; compute the norm in fp32, cast back. Silent training instability otherwise.Flashcard. Pre-norm =
x + Sublayer(Norm(x))(clean skip, trains deep). Post-norm =Norm(x + Sublayer(x))(renormalizes stream, diverges deep). RMSNorm = LayerNorm minus the mean and beta. Residual's gradient+1is why depth works. FFN = ~2/3 of params; SwiGLU gates it for reliable perplexity gains at ~3 matmuls.
+1 came from (https://arxiv.org/abs/1512.03385).Next: Positional encodings — RoPE, ALiBi, and length extrapolation.