Problem
Stack many layers and two things go wrong. First, the size of the activations drifts: each layer multiplies by weights, so the numbers shrink toward zero or blow up, and every layer must keep re-adapting to the shifting scale of the one below. Second, gradients travel back through every layer by the chain rule, a long product of factors, and a product of many numbers below 1 vanishes while one above 1 explodes (see backpropagation). Networks beyond a handful of layers simply fail to train.
Intuition
Residual connection: instead of a block computing y = F(x), let it compute y = x + F(x). The block now only learns a correction to its input, and the identity path gives the gradient a direct route: the derivative of x + F(x) with respect to x is 1 + F′(x), so even when F′ is tiny the gradient still flows. Stack blocks and you get a residual stream: one running vector per position that every block reads from and adds to.
Normalization: before (or after) a block, rescale each vector to a standard size: subtract a mean and divide by a standard deviation, then let two learned parameters (a scale γ and shift β) restore whatever range the next layer wants. The variants differ in which numbers share a mean and variance: all features of one example (LayerNorm), one feature across the whole batch (BatchNorm), or a group of channels in one example (GroupNorm).
In a transformer the "example" is one token position, and LayerNorm normalizes the E features of that position alone, so it never looks at other tokens or other sequences in the batch.
Mechanics
The residual stream
y = x + F(x) shapes: x, F(x), y all [N, L, E]
F must return the same shape as x so the two can be added (when widths differ, a projection on the skip path fixes it). In a transformer block F is either attention or the feed-forward network; each adds its output into the stream. Reading the model this way, the stream is the model's working memory and each block writes a small update into it. Dropout is applied to F(x) before the addition, so a dropped block output leaves the identity path intact.
LayerNorm
y = (x − E[x]) / √(Var[x] + ε) · γ + β statistics over the last dimension(s), x: [.., E], γ, β: [E]
The mean and (biased) variance are computed over the E features of each position independently, so each row ends up with zero mean and unit variance, then γ (initialized to 1) and β (initialized to 0) rescale it. ε (default 1e-5) keeps the division finite. Because nothing depends on other examples, LayerNorm behaves identically in training and evaluation, works with any batch size (even 1) and any sequence length, which is why transformers use it. It also makes the output invariant to scaling or shifting its input: multiply a row by 10 or add 100 to every entry and the result is unchanged.
RMSNorm
y = x / √(mean(x²) + ε) · γ
RMSNorm drops the mean subtraction (and β): it only divides by the root mean square. It is a
little cheaper and, empirically, as good for many language models, so recent open-weight models
commonly use it. Unlike LayerNorm it does not remove a constant offset, so the output keeps the
direction of x exactly. If you leave eps=None PyTorch uses the machine epsilon of the
computation type.
BatchNorm and the train/eval split
BatchNorm normalizes each feature (channel) over the batch (and spatial positions, in images): statistics are per column in the diagram above. In training it uses the current mini-batch's mean and variance and also updates two stored buffers by an exponential moving average:
running ← (1 − momentum) · running + momentum · batch statistic momentum = 0.1 by default
(the running variance uses the unbiased batch variance, dividing by n − 1). In
evaluation mode it does not look at the batch at all: it normalizes with the stored running mean
and variance, so one example gives the same answer whatever else is in the batch. This is why
model.eval() matters, why BatchNorm struggles with tiny batches (noisy statistics),
and why it is awkward for variable-length sequences. The module's state is
weight (γ), bias (β), and buffers running_mean,
running_var, num_batches_tracked; the buffers are saved in the state dict
but are not trained by gradient descent.
GroupNorm and others
GroupNorm splits the C channels of one example into G groups and normalizes each
group on its own, so it needs no batch and no running statistics. With G = 1 it is a LayerNorm
over all channels; with G = C every channel is normalized alone (instance norm). It requires
num_channels % num_groups == 0. It is the usual choice for convolutional models with
small batches.
Pre-norm versus post-norm
Where the normalization sits relative to the residual addition changes training behavior:
| Post-norm (original Transformer) | Pre-norm | |
|---|---|---|
| Block | x = norm(x + F(x)) | x = x + F(norm(x)) |
| Identity path | passes through a norm at every block | untouched from input to output |
| Training | needs learning-rate warmup; harder to train deep | stable at depth, warmup matters less |
| Stream size | reset to a fixed scale after every block | grows with depth (hence a final norm before the output layer) |
In TransformerEncoderLayer the switch is the norm_first argument
(default False, i.e. post-norm). The code's own comment points to Fig. 1 of the
"On Layer Normalization in the Transformer Architecture" paper for the difference.
Green is the skip path. In pre-norm it carries x from the input to the output without passing through any normalization, which is what keeps gradients healthy at depth.
The feed-forward block and dropout
The other half of a transformer block is a position-wise two-layer network applied to each
position separately: linear2(dropout(activation(linear1(x)))), expanding E to
dim_feedforward (commonly 4E) and back, with ReLU by default (GELU or SwiGLU in many
newer models). Dropout zeroes each activation with probability p during training and rescales the
rest by 1/(1 − p); in evaluation mode it does nothing. In TransformerEncoderLayer it
is applied inside the feed-forward block, to the attention output (dropout1) and to
the feed-forward output (dropout2), each before its residual addition.
| Component | Shape/Params | Meaning | Source |
|---|---|---|---|
nn.LayerNorm |
normalized_shape, eps=1e-5, elementwise_affine=True, bias=True; weight γ and bias β of shape normalized_shape, initialised to 1 and 0 |
Normalizes the last dimension(s) of each example, then scales and shifts. Same in train and eval. | LayerNorm, layer_norm |
nn.RMSNorm |
normalized_shape, eps=None, elementwise_affine=True; weight only |
Divides by the root mean square; no mean subtraction, no bias. | RMSNorm, rms_norm |
nn.BatchNorm1d/2d/3d |
num_features, eps=1e-5, momentum=0.1, affine=True, track_running_stats=True; γ, β of shape [C]; buffers running_mean, running_var [C], num_batches_tracked |
Normalizes each channel over the batch; updates running statistics in training, uses them in eval. | _NormBase, _BatchNorm.forward, batch_norm |
nn.GroupNorm |
num_groups, num_channels, eps=1e-5, affine=True; γ, β of shape [C] |
Normalizes each group of channels within one example; no batch dependence. | GroupNorm |
nn.Dropout |
p=0.5; no parameters |
Zeroes entries with probability p in training and scales the rest by 1/(1−p); identity in eval. | Dropout |
nn.TransformerEncoderLayer |
d_model, nhead, dim_feedforward=2048, dropout=0.1, activation=relu, layer_norm_eps=1e-5, norm_first=False |
Self-attention and feed-forward sublayers, each with dropout, a residual addition and a LayerNorm; norm_first selects pre-norm. |
TransformerEncoderLayer, norm order |
_sa_block, _ff_block |
[N, L, E] → [N, L, E] | The two sublayers, each ending in a dropout that runs before the residual add. | transformer.py |
Worked example
Three examples (the, cat, sat), four features each:
[2, 4, 6, 8], [1, 1, 1, 5], [-3, 0, 3, 6].
LayerNorm (γ = 1, β = 0, ε = 10−5) works on each row.
For the first row the mean is 5 and the variance 5, so each entry becomes
(x − 5) / √5.00001: [-1.34, -0.45, 0.45, 1.34].
| row | input | LayerNorm | RMSNorm |
|---|---|---|---|
the | [2, 4, 6, 8] | [-1.34, -0.45, 0.45, 1.34] | [0.37, 0.73, 1.10, 1.46] |
cat | [1, 1, 1, 5] | [-0.58, -0.58, -0.58, 1.73] | [0.38, 0.38, 0.38, 1.89] |
sat | [-3, 0, 3, 6] | [-1.34, -0.45, 0.45, 1.34] | [-0.82, 0, 0.82, 1.63] |
The first and third rows, [2, 4, 6, 8] and [−3, 0, 3, 6], are the same straight line with a different spacing (2 versus 3) and offset, and LayerNorm gives them the identical output [-1.34, -0.45, 0.45, 1.34]: it forgets scale and offset. The second row, [1, 1, 1, 5], has one large value; it becomes [-0.58, -0.58, -0.58, 1.73]. RMSNorm keeps every entry's sign and ratio (the first row becomes [0.37, 0.73, 1.10, 1.46], still all positive and still in the ratio 1 : 2 : 3 : 4), where LayerNorm centres it.
BatchNorm on the same 3 × 4 matrix normalizes each column across the three examples instead: [0.93, 1.37, 1.30, 1.34], [0.46, -0.39, -1.14, -1.07], [-1.39, -0.98, -0.16, -0.27]. Change a different row in the batch and these numbers change; that is the dependence LayerNorm avoids. GroupNorm with two groups on the first row normalizes [2, 4] and [6, 8] separately: [-1.00, 1.00, -1.00, 1.00].
Running statistics. Start from running mean 0 and running variance 1 and feed a training batch [1, 3] for one feature (mean 2, unbiased variance 2). With momentum 0.1 the buffers become 0.20 and 1.10. In evaluation mode a value of 5 with running mean 3 and running variance 4 is normalized with those stored numbers to (5 − 3) / √4 = 1 (before γ, β and ε), whatever the batch contains.
Why pre-norm and post-norm differ in scale. Push a 4-vector through six
identical residual blocks. The vector's length after each block, post-norm:
[3.87, 2.00, 2.00, 2.00, 2.00, 2.00, 2.00]; pre-norm: [3.87, 5.07, 6.33, 7.66, 9.04, 10.48, 11.97]. Post-norm snaps back to a fixed size after
every block; pre-norm lets the stream grow, which is why pre-norm models add one last norm
before the output layer. All numbers come from stdlib helpers in ml_models_docs.py.
The input bars are drawn at the same scale as the outputs, so the first chart is simply tall.
Implementation
import torch
import torch.nn as nn
x = torch.randn(8, 16, 64) # [batch, positions, features]
ln = nn.LayerNorm(64) # weight [64] = 1, bias [64] = 0
y = ln(x) # per position: mean 0, variance 1
rms = nn.RMSNorm(64) # weight only
bn = nn.BatchNorm1d(64) # expects [N, C] or [N, C, L]
y = bn(x.transpose(1, 2)) # statistics per feature over batch and positions
bn.running_mean, bn.running_var, bn.num_batches_tracked
bn.eval() # now uses the stored statistics
gn = nn.GroupNorm(num_groups=8, num_channels=64) # 64 % 8 == 0
# a transformer sublayer, both styles
class Block(nn.Module):
def __init__(self, dim, pre_norm=True, p=0.1):
super().__init__()
self.norm1, self.norm2 = nn.LayerNorm(dim), nn.LayerNorm(dim)
self.attn = nn.MultiheadAttention(dim, 4, batch_first=True)
self.ff = nn.Sequential(nn.Linear(dim, 4 * dim), nn.GELU(), nn.Linear(4 * dim, dim))
self.drop = nn.Dropout(p)
self.pre_norm = pre_norm
def forward(self, x):
if self.pre_norm: # x = x + F(norm(x))
h = self.norm1(x)
x = x + self.drop(self.attn(h, h, h, need_weights=False)[0])
return x + self.drop(self.ff(self.norm2(x)))
x = self.norm1(x + self.drop(self.attn(x, x, x, need_weights=False)[0]))
return self.norm2(x + self.drop(self.ff(x)))
layer = nn.TransformerEncoderLayer(64, 4, batch_first=True, norm_first=True) # the built-in
LayerNorm and a residual block in plain Python:
import math
def layer_norm(row, gamma, beta, eps=1e-5):
mean = sum(row) / len(row)
var = sum((v - mean) ** 2 for v in row) / len(row) # biased variance
return [(v - mean) / math.sqrt(var + eps) * g + b for v, g, b in zip(row, gamma, beta)]
def rms_norm(row, gamma, eps=1e-5):
scale = 1 / math.sqrt(sum(v * v for v in row) / len(row) + eps)
return [v * scale * g for v, g in zip(row, gamma)]
def block(x, sublayer, norm, pre_norm):
if pre_norm:
return [a + b for a, b in zip(x, sublayer(norm(x)))]
return norm([a + b for a, b in zip(x, sublayer(x))])
Tradeoffs
- BatchNorm needs a decent batch. With a handful of examples the batch statistics are noisy; with variable-length sequences the padding corrupts them. It works well for image convolutions at moderate batch sizes and badly for most language models.
-
Train/eval mismatch. Forgetting
model.eval()leaves BatchNorm on batch statistics and Dropout on; predictions then vary with the batch and with chance. LayerNorm and RMSNorm have no such mode switch. - Pre-norm trains more easily, post-norm can end slightly better. Pre-norm's clean skip path stabilises deep stacks, but the stream grows and later layers contribute relatively less; many large models therefore use pre-norm plus a final norm.
- RMSNorm saves a little. No mean, no bias: slightly fewer operations and parameters (4096 versus 8192 for E = 4096), with similar quality in practice, but it cannot remove a shared offset.
-
Pitfalls.
nn.LayerNorm(64)normalizes only the last dimension, so giving it an image tensor [N, C, H, W] with64normalizes over W. Using BatchNorm1d on a [N, L, E] tensor without transposing normalizes the wrong axis (it expects the feature dimension second). Epsilon matters in half precision. Frozen BatchNorm layers during fine-tuning need.eval()on those layers specifically. - When not to use it. A shallow network with a well-scaled input often needs neither a residual path nor normalization; add them when depth or instability calls for it.
Check yourself
Why is the gradient through y = x + F(x) healthier than through y = F(x)?
The derivative is 1 + F′(x) rather than F′(x). Across many blocks the product of (1 + F′) terms does not collapse toward zero the way a product of small F′ values does, because the identity term always carries gradient straight back.
You evaluate with a batch of one and your BatchNorm model returns nonsense in train() mode but fine in eval(). Why?
In training mode BatchNorm uses the statistics of the current batch; with one example (and one value per feature) the variance is zero, so everything normalizes to β. In eval mode it uses the stored running mean and variance, which do not depend on the batch.
How many parameters does nn.LayerNorm(512) have? nn.RMSNorm(512)? nn.BatchNorm1d(512), including buffers?
LayerNorm: 1024 (γ and β). RMSNorm: 512 (γ only). BatchNorm1d: 1024 trainable parameters (γ and β), plus the buffers running_mean and running_var (512 each) and the scalar num_batches_tracked, which are state but not trained.
Which of x + F(norm(x)) and norm(x + F(x)) needs a final norm before the output layer, and why?
The pre-norm form, x + F(norm(x)). Its stream is never normalized, so it grows with depth, and the last block's output needs one norm to bring it back to a standard scale before the output projection.
Connections
Read first
- Backpropagation core
Read next
- The Transformer core
References
- He, Zhang, Ren and Sun. Deep Residual Learning for Image Recognition. CVPR, 2016 (arXiv 1512.03385).
- Ioffe and Szegedy. Batch Normalization: Accelerating Deep Network Training by Reducing Internal Covariate Shift. ICML, 2015 (arXiv 1502.03167).
- Ba, Kiros and Hinton. Layer Normalization. 2016 (arXiv 1607.06450).
- Zhang and Sennrich. Root Mean Square Layer Normalization. NeurIPS, 2019 (arXiv 1910.07467).
- Wu and He. Group Normalization. ECCV, 2018 (arXiv 1803.08494).
- Xiong et al. On Layer Normalization in the Transformer Architecture. ICML, 2020 (arXiv 2002.04745).
- Srivastava et al. Dropout: A Simple Way to Prevent Neural Networks from Overfitting. JMLR, 2014.
- PyTorch v2.15.0:
normalization.py,batchnorm.pyandTransformerEncoderLayer.