Personalize this lesson
Adapt explanations and teaching visuals to your background and preferred voice.
Positional encoding showed how attention knows where tokens sit. Layer normalization controls a different part of the transformer: the scale of the residual stream that carries token features through dozens of blocks.
A transformer block repeatedly updates one token's hidden-state vector. Each layer adds attention and MLP outputs to the same representation. After fifty layers, poorly scaled updates can compound. Some features may become far larger than others; a downstream softmax may become sharply saturated and gradients may weaken. Deep networks face this scale-control risk. Layer Normalization helps manage it, and its placement inside a transformer block changes the optimization behavior.
Assume you've seen residual connections before. If you need a quick refresher: a residual block computes output = input + sublayer(input). The input travels along a skip path, and the sublayer (attention or a feed-forward network) adds a small change. Layer Normalization decides whether you normalize the input before the sublayer, or normalize the sum after it.
Why scale drift becomes a training problem
Every residual block adds a change to a hidden state. Residual updates aren't literal multipliers, but a toy multiplicative drift makes the risk easy to see: a scale factor of 1.1 repeated across fifty blocks gives 1.1^50, roughly 117. During backpropagation, gradients face a related issue because they multiply block Jacobians; their product can become too large or too small.
Layer Normalization helps by re-centering and rescaling each token's hidden state. Before learned scale and shift are applied, the normalized vector sits near zero with a spread close to one. The model then learns scale parameters to stretch those values back into the range it needs.
LayerNorm by hand
Before the formula, start with one concrete token hidden state:
1x = [3.0, 1.0, -1.0, 5.0]Step 1: compute the mean:
(3 + 1 + (-1) + 5) / 4 = 2.0
Step 2: subtract the mean from every entry:
[1.0, -1.0, -3.0, 3.0]
Step 3: compute the variance:
(1^2 + (-1)^2 + (-3)^2 + 3^2) / 4 = (1 + 1 + 9 + 9) / 4 = 5.0
Step 4: divide by the standard deviation:
sqrt(5.0) ≈ 2.236
[0.45, -0.45, -1.34, 1.34]
Step 5: scale and shift with learned parameters:
If γ = [1.0, 1.0, 1.0, 1.0] and β = [0.0, 0.0, 0.0, 0.0], the output is the same normalized vector. In practice, the model learns the γ and β that help it predict the next token.
That's the entire operation. It happens independently for every token in the sequence. Other tokens in the batch have no role in its statistics, so the same rule applies during training and during autoregressive generation with batch size one.
If you feed 16 request-latency feature vectors through LayerNorm, which numbers determine the mean and variance for one vector?
Answer
Only that vector's own feature values determine its mean and variance. The other 15 vectors in the batch don't affect it, which is why LayerNorm behaves the same during real-time batch-size-1 prediction and during larger-batch training.
1from math import isclose, sqrt
2
3def layer_norm(values, gamma=None, beta=None, eps=1e-5):
4 if gamma is None:
5 gamma = [1.0] * len(values)
6 if beta is None:
7 beta = [0.0] * len(values)
8 mean = sum(values) / len(values)
9 variance = sum((value - mean) ** 2 for value in values) / len(values)
10 scale = sqrt(variance + eps)
11 return [
12 gamma_i * ((value - mean) / scale) + beta_i
13 for value, gamma_i, beta_i in zip(values, gamma, beta)
14 ]
15
16normalized = layer_norm([3.0, 1.0, -1.0, 5.0])
17rounded = [round(value, 2) for value in normalized]
18assert rounded == [0.45, -0.45, -1.34, 1.34]
19assert isclose(sum(normalized), 0.0, abs_tol=1e-12)
20assert layer_norm([3.0, 1.0], gamma=[0.0, 0.0]) == [0.0, 0.0]The general formula
For a vector x ∈ ℝ^d (a single token's hidden state), LayerNorm computes:[1]
where:
μ = (1/d) Σ x_i: mean over the hidden dimensionσ² = (1/d) Σ (x_i - μ)²: variance over the hidden dimensionγ, β ∈ ℝ^d: learnable scale and shift parametersε: tiny positive constant to prevent division by zero (10^-5in this example; defaults vary by implementation). In mixed precision (FP16 / BF16), keep ε large enough that the variance floor doesn't underflow to zero; common production floors sit around1e-5to1e-6rather than aggressive1e-12values that can look fine in FP32 and blow up in half precision.
Subtracting the mean centers the values around zero. Dividing by the standard deviation squeezes them to a similar scale. The learned γ (scale) and β (shift) let the model decide whether the normalized values should be stretched, compressed, or offset for the next layer.
Key distinction from BatchNorm
LayerNorm normalizes across the feature dimension for each token independently. Batch Normalization (BatchNorm) instead computes each feature coordinate's statistics across examples in a mini-batch (and, for some data layouts, additional positions). This makes LayerNorm independent of batch size, which is useful for variable-length sequences and autoregressive generation.
| Property | BatchNorm | LayerNorm |
|---|---|---|
| Normalizes across | Mini-batch examples per feature coordinate | Feature values within one token |
| Depends on batch size | Yes | No |
| Running statistics | Yes (train/eval mismatch) | No |
| Works for variable-length sequences | Awkward | Natural |
| Works for autoregressive decoding | Awkward | Natural |
| Used in modern LLMs | Rare | Routine |
Why is BatchNorm awkward for autoregressive decoding while LayerNorm is natural?
Answer
BatchNorm depends on batch-level statistics or stored running statistics, so behavior can change between training and inference. Autoregressive decoding often uses batch size 1 and variable sequence lengths. LayerNorm normalizes each token independently across its feature dimension, so population statistics are unnecessary.
This small program changes a neighboring token while holding one token fixed. Its LayerNorm result stays fixed; a batch-normalized feature coordinate changes.
1from math import sqrt
2
3def layer_norm(values, eps=1e-5):
4 mean = sum(values) / len(values)
5 variance = sum((value - mean) ** 2 for value in values) / len(values)
6 return [(value - mean) / sqrt(variance + eps) for value in values]
7
8def batch_norm_one_feature(values, eps=1e-5):
9 mean = sum(values) / len(values)
10 variance = sum((value - mean) ** 2 for value in values) / len(values)
11 return [(value - mean) / sqrt(variance + eps) for value in values]
12
13token = [1.0, 3.0, 5.0, 7.0]
14ln_before = [layer_norm(row) for row in [token, [2.0, 4.0, 6.0, 8.0]]][0]
15ln_after = [layer_norm(row) for row in [token, [100.0, 100.0, 100.0, 100.0]]][0]
16batch_a = batch_norm_one_feature([token[0], 2.0, 3.0])
17batch_b = batch_norm_one_feature([token[0], 2.0, 100.0])
18
19print("LayerNorm token:", [round(value, 3) for value in ln_before])
20print("Batch feature with peers=2,3:", round(batch_a[0], 3))
21print("Batch feature with peers=2,100:", round(batch_b[0], 3))
22assert ln_before == ln_after
23assert round(batch_a[0], 3) != round(batch_b[0], 3)1LayerNorm token: [-1.342, -0.447, 0.447, 1.342]
2Batch feature with peers=2,3: -1.225
3Batch feature with peers=2,100: -0.718PyTorch implementation
This PyTorch implementation takes an input tensor of shape (batch, seq_len, d_model), calculates the mean and variance across the final feature dimension, then applies the learnable scale and shift.
1import torch
2import torch.nn as nn
3
4class LayerNorm(nn.Module):
5 """Layer Normalization from scratch."""
6 def __init__(self, d_model: int, eps: float = 1e-5):
7 super().__init__()
8 self.gamma = nn.Parameter(torch.ones(d_model))
9 self.beta = nn.Parameter(torch.zeros(d_model))
10 self.eps = eps
11
12 def forward(self, x: torch.Tensor) -> torch.Tensor:
13 # x shape: (batch, seq_len, d_model)
14 mean = x.mean(dim=-1, keepdim=True)
15 var = x.var(dim=-1, keepdim=True, unbiased=False)
16 x_norm = (x - mean) / torch.sqrt(var + self.eps)
17 return self.gamma * x_norm + self.beta
18
19# Quick check with the hand-worked example
20x = torch.tensor([[[3.0, 1.0, -1.0, 5.0]]]) # shape (1, 1, 4)
21ln = LayerNorm(d_model=4)
22# Fix gamma=1, beta=0 to match the hand calculation
23nn.init.constant_(ln.gamma, 1.0)
24nn.init.constant_(ln.beta, 0.0)
25out = ln(x)
26print(out.round(decimals=2).detach())
27expected = torch.tensor([[[0.45, -0.45, -1.34, 1.34]]])
28assert torch.allclose(out.round(decimals=2), expected)1tensor([[[ 0.4500, -0.4500, -1.3400, 1.3400]]])
The colored paths preserve each feature's identity across both operations. Centering translates the whole vector; scaling changes its spread. Learned γ and β can then reshape that standardized vector for the next layer.
Where you put LayerNorm matters
Now that you know what LayerNorm does, the next question is where to place it inside a transformer block. There are two choices, and the difference is whether the normalization happens before or after the residual addition.
The placement difference
- Post-LN normalizes after the residual update has been added. Its residual path therefore crosses a normalization operation in every block.
- Pre-LN normalizes before the sublayer update. Its residual path retains an identity contribution to the backward Jacobian.
Post-LN: the original transformer
In this arrangement (used by the original 2017 Transformer and BERT-style encoders), normalization happens after the residual addition:[2][3]
In plain terms: compute the sublayer output (attention or FFN), add it to the original input along the skip connection, then normalize the sum. Normalization now sits directly on the main highway, so gradients through the residual path pass through a LayerNorm Jacobian in each Post-LN block.
Pre-LN: the GPT-2 layout
In this arrangement, used by GPT-2, normalization happens before the sublayer:[4]
Normalize the input first, feed it into the sublayer, then add the result to a direct copy of the input. The residual path from x_l to x_{l+1} keeps an identity contribution to the backward pass.
At the full-model level, GPT-2 and the Pre-LN architecture analyzed by Xiong et al. apply a final normalization before prediction.[4][5] That final operation normalizes the accumulated residual stream before the output projection. Removing it produces a different architecture whose output scale needs evaluation.

The diagram on the left shows Pre-LN: LayerNorm cleans the sublayer input first, and the raw input still jumps straight over to the addition. The diagram on the right shows Post-LN: the sublayer and the skip path meet first, then LayerNorm cleans up the result.
In one equation, what is the architectural difference between Post-LN and Pre-LN?
Answer
Post-LN is . Pre-LN is . The important difference is whether normalization sits on the residual highway after the add, or only on the sublayer input before the add.
Holding a sublayer update fixed isolates the placement difference. Post-LN centers and rescales the updated stream immediately; Pre-LN lets the updated stream continue along the residual path.
1from math import sqrt
2
3def layer_norm(values, eps=1e-5):
4 mean = sum(values) / len(values)
5 variance = sum((value - mean) ** 2 for value in values) / len(values)
6 return [(value - mean) / sqrt(variance + eps) for value in values]
7
8x = [1.0, 2.0, 4.0, 8.0]
9fixed_update = [0.5, -0.5, 1.0, -1.0]
10pre_ln_output = [value + update for value, update in zip(x, fixed_update)]
11post_ln_output = layer_norm(pre_ln_output)
12
13print("Pre-LN stream mean:", round(sum(pre_ln_output) / len(pre_ln_output), 3))
14print("Post-LN stream mean:", round(sum(post_ln_output) / len(post_ln_output), 3))
15print("Post-LN output:", [round(value, 3) for value in post_ln_output])
16assert abs(sum(post_ln_output) / len(post_ln_output)) < 1e-12
17assert sum(pre_ln_output) / len(pre_ln_output) != 0.01Pre-LN stream mean: 3.75
2Post-LN stream mean: 0.0
3Post-LN output: [-0.954, -0.954, 0.53, 1.378]A two-layer walkthrough
To see how the placement handles a uniform offset, trace a deliberately simplified stream through two blocks. Assume each block's sublayer produces the same update, 0.5, for every feature. Also set LayerNorm's learned scale to γ = 1 and shift to β = 0. A trained attention or feed-forward sublayer would usually produce nonuniform updates.
Post-LN stack
- Start:
x_0 = [1.0, 1.0, 1.0, 1.0] - After block 1:
x_1 = LayerNorm(x_0 + 0.5) = LayerNorm([1.5, 1.5, 1.5, 1.5]) = [0.0, 0.0, 0.0, 0.0](mean-centered, then scaled) - After block 2:
x_2 = LayerNorm(x_1 + 0.5) = LayerNorm([0.5, 0.5, 0.5, 0.5]) = [0.0, 0.0, 0.0, 0.0]
The constant offset is reset at every layer. This toy is intentionally stark: LayerNorm removes a uniform shift, so the residual stream doesn't preserve that offset.
Pre-LN stack
- Start:
x_0 = [1.0, 1.0, 1.0, 1.0] - After block 1:
x_1 = x_0 + Sublayer(LayerNorm(x_0)) = [1.0, 1.0, 1.0, 1.0] + 0.5 = [1.5, 1.5, 1.5, 1.5] - After block 2:
x_2 = x_1 + Sublayer(LayerNorm(x_1)) = [1.5, 1.5, 1.5, 1.5] + 0.5 = [2.0, 2.0, 2.0, 2.0]
The running sum flows through the network because Pre-LN leaves the residual path outside the block normalization. The backward equation below shows why this placement includes an identity gradient route.
Why does the Pre-LN architecture analyzed by Xiong et al. include a final normalization before the output head?
Answer
Inside each Pre-LN block, the residual stream bypasses block-output normalization and accumulates updates. A final norm sets its scale before the vocabulary projection. Removing that norm changes the architecture and requires rechecking output-scale and training behavior.
1from math import sqrt
2
3def layer_norm(values, eps=1e-5):
4 mean = sum(values) / len(values)
5 variance = sum((value - mean) ** 2 for value in values) / len(values)
6 scale = sqrt(variance + eps)
7 return [(value - mean) / scale for value in values]
8
9def sublayer(values):
10 # Tiny deterministic stand-in for attention or an FFN update.
11 return [0.2 * value + 0.1 for value in values]
12
13def pre_ln_step(values):
14 return [value + update for value, update in zip(values, sublayer(layer_norm(values)))]
15
16def post_ln_step(values):
17 raw = [value + update for value, update in zip(values, sublayer(values))]
18 return layer_norm(raw)
19
20start = [1.0, 2.0, 4.0, 8.0]
21pre_after_two = pre_ln_step(pre_ln_step(start))
22post_after_two = post_ln_step(post_ln_step(start))
23
24assert sum(abs(value) for value in pre_after_two) > sum(abs(value) for value in start)
25assert abs(sum(post_after_two) / len(post_after_two)) < 1e-12Why Pre-LN can train more stably
Post-LN gradient flow is like passing an error signal through a long chain of adapters where every block rescales it before sending it backward. Each rescaling step slightly changes the signal. After eighty blocks, the original direction is hard to track. Pre-LN keeps a cleaner residual path beside the block updates, so useful gradients can travel backward without being repeatedly disrupted.
Post-LN gradient problem
The key result from Xiong et al. is that Post-LN produces especially large expected gradients near the output layers at initialization under their analysis.[5] For one block, the backward Jacobian is:
where J_LN,l is the LayerNorm Jacobian and J_F,l is the sublayer Jacobian. Across many layers, the backward pass multiplies many such terms together. In practice that means:
- Gradient scale becomes uneven across depth. Early layers may receive tiny updates while late layers receive huge ones.
- Top layers can receive very large updates at initialization. A single step with a moderately high learning rate can push weights far from a good basin.
- Learning-rate warmup addresses this risk in the evaluated Post-LN setups. Start the learning rate near zero and ramp it up so early updates are smaller.
Pre-LN gradient advantage
For Pre-LN, the Jacobian is:
That leading identity term I is the key difference. Every block includes a direct residual contribution to the gradient, so the backward signal avoids relying entirely on repeated normalization Jacobians. Xiong et al. report that, in their evaluated tasks:
- Pre-LN trains without learning-rate warmup in experiments where Post-LN needed it for stable optimization.[5]
- Its initialization-time gradients are better behaved in their analysis.
This scalar toy isn't a training experiment. It only shows how putting a normalization factor on the identity path changes a product across blocks.
1depth = 12
2j_layer_norm = 0.75
3j_sublayer = 0.10
4
5post_block = j_layer_norm * (1.0 + j_sublayer)
6pre_block = 1.0 + j_sublayer * j_layer_norm
7post_path = post_block ** depth
8pre_path = pre_block ** depth
9
10print("one block: post=", round(post_block, 3), "pre=", round(pre_block, 3))
11print("twelve-block product: post=", round(post_path, 3), "pre=", round(pre_path, 3))
12assert post_path < 1.0
13assert pre_path > 1.01one block: post= 0.825 pre= 1.075
2twelve-block product: post= 0.099 pre= 2.382A reported Pre-LN risk: representation collapse
Pre-LN isn't free of trade-offs. Analyses including ResiDual report that its residual stream can dominate newer sublayer updates as depth increases. Under that behavior, late layers make smaller relative changes to the hidden representation.[6]
The ResiDual authors call this reported behavior representation collapse.[6] Treat it as a design risk to measure, not a guarantee for every Pre-LN model or training run.
Practical summary:
| Property | Post-LN | Pre-LN |
|---|---|---|
| Warmup result in Xiong et al. | Needed in evaluated stable runs | Removed in evaluated stable runs |
| Initialization gradients in Xiong et al. | Large near output layers | Better behaved |
| Reported deep-layer risk | Gradient flow can become difficult | Residual stream can dominate later updates |
| Representative architecture | Original Transformer, BERT | GPT-2 |
There is no universal winner. Xiong et al. demonstrate the Pre-LN optimization advantage in their evaluated tasks, while DeepNorm later scales a Post-LN-derived design to a 1,000-layer machine-translation experiment using residual scaling and matching initialization.[5][7]
What is the representation-collapse risk reported for Pre-LN?
Answer
The residual stream can dominate newer sublayer updates as depth grows. Late blocks still run, but their relative changes may become small compared with the accumulated stream. This is an observed and analyzed risk, rather than an inevitable outcome for every Pre-LN run.
Residual magnitude growth across depth
A short numeric toy makes the scale story concrete. Treat each block as adding a fixed update vector (size 1 along one axis). Pre-LN lets the stream accumulate without a reset. Post-LN re-normalizes after each add. DeepNorm-style residual scaling multiplies the incoming stream by a constant α before the add, then normalizes (toy form of the paper's update).
1from math import sqrt
2
3def l2(values: list[float]) -> float:
4 return sqrt(sum(v * v for v in values))
5
6def layer_norm(values: list[float], eps: float = 1e-5) -> list[float]:
7 mean = sum(values) / len(values)
8 var = sum((v - mean) ** 2 for v in values) / len(values)
9 scale = sqrt(var + eps)
10 return [(v - mean) / scale for v in values]
11
12def pre_step(x: list[float], update: list[float]) -> list[float]:
13 # Pre-LN: residual path carries x unchanged; update is "normalized-input" write.
14 return [a + b for a, b in zip(x, update)]
15
16def post_step(x: list[float], update: list[float]) -> list[float]:
17 return layer_norm([a + b for a, b in zip(x, update)])
18
19def deep_step(x: list[float], update: list[float], alpha: float) -> list[float]:
20 # Toy DeepNorm-style Post-LN residual scale: LN(alpha * x + G(x)).
21 return layer_norm([alpha * a + b for a, b in zip(x, update)])
22
23depth = 8
24update = [1.0, 0.0, 0.0, 0.0]
25x_pre = [1.0, 0.0, 0.0, 0.0]
26x_post = [1.0, 0.0, 0.0, 0.0]
27x_deep = [1.0, 0.0, 0.0, 0.0]
28alpha = (2 * depth) ** 0.25 # encoder-style depth constant from DeepNet sketch
29
30print("layer | Pre-LN ||x|| | Post-LN ||x|| | DeepNorm-scale ||x||")
31for layer in range(1, depth + 1):
32 x_pre = pre_step(x_pre, update)
33 x_post = post_step(x_post, update)
34 x_deep = deep_step(x_deep, update, alpha)
35 print(
36 f"{layer:5d} | {l2(x_pre):12.3f} | {l2(x_post):13.3f} | {l2(x_deep):18.3f}"
37 )
38
39assert l2(x_pre) > l2(x_post)
40assert l2(x_deep) > 01layer | Pre-LN ||x|| | Post-LN ||x|| | DeepNorm-scale ||x||
2 1 | 2.000 | 2.000 | 2.000
3 2 | 3.000 | 2.000 | 2.000
4 3 | 4.000 | 2.000 | 2.000
5 4 | 5.000 | 2.000 | 2.000
6 5 | 6.000 | 2.000 | 2.000
7 6 | 7.000 | 2.000 | 2.000
8 7 | 8.000 | 2.000 | 2.000
9 8 | 9.000 | 2.000 | 2.000In this toy, Pre-LN stream magnitude grows with depth while Post-LN and DeepNorm-scale both re-normalize after the add so ||x|| stays controlled. DeepNorm's distinguishing mechanism is the depth-dependent residual scale α plus matched init on the update branch, which keeps optimization of a Post-LN-shaped stack stable when depth is extreme. Real models use learned sublayers rather than a fixed update, so treat the table as geometry intuition, then measure ||x_l|| on your own stack.
DeepNorm: scaled residual + matched init
DeepNorm (DeepNet) keeps a Post-LN-shaped block but multiplies the residual branch by a depth-dependent constant α and down-scales residual-branch initialization so expected update magnitudes stay controlled at large depth.[7] The paper's encoder update has the form:
Here is the sublayer stack (attention and/or FFN writes). α grows slowly with depth so the residual highway still carries signal, while initialization of is reduced (the paper pairs α with a gain bound β on residual-branch weights). Architecture-specific constants from the paper include encoder-only for layers, decoder-only for layers, and encoder-decoder pairs with different α on each stack. Copy constants only from the table matching your architecture.
DeepNorm is therefore not "plain Post-LN with no residual scaling." The residual scale and matched init are the design, and the 1,000-layer machine-translation experiment is evidence under that recipe, not a free upgrade for an arbitrary Post-LN codebase.
What two mechanisms does DeepNorm add on top of a Post-LN-shaped block?
Answer
A depth-dependent residual scale α in the update , and matched down-scaling of residual-branch initialization so expected update size stays controlled as depth grows.
Common mistakes and how to debug them
| Symptom | Likely cause | Fix |
|---|---|---|
Model diverges at step 1 with loss = NaN | For a Post-LN run, an overly large early update is one hypothesis; Xiong et al. connect Post-LN initialization gradients to warmup sensitivity. | Inspect layerwise gradient norms and learning-rate schedule. Compare a warmer start, smaller initial rate, or a Pre-LN variant under the same setup. |
| One normalization placement underperforms another | Placement interacts with depth, initialization, optimizer, data, and objective. | Treat placement as an ablation, then compare training loss, gradient profile, and downstream quality under matched conditions. |
| A Pre-LN implementation differs sharply from a reference model | A final normalization used by the target Pre-LN architecture may be absent. | Check the architecture definition and restore its final LayerNorm or RMSNorm before the output projection when applicable.[5] |
| Attention becomes nearly one-hot during unstable training | Query-key dot products may have grown enough to saturate softmax. | Inspect attention-logit statistics; QK-Norm or softmax capping are targeted candidates to evaluate in such runs. |
RMSNorm: scale-only normalization
RMSNorm (Root Mean Square Layer Normalization) replaces LayerNorm's centered standard deviation with a root-mean-square scale. Zhang and Sennrich propose it as a cheaper normalization that retains rescaling invariance while removing mean-centering; their evaluated models achieve comparable quality to LayerNorm with run-time reductions that vary by setup.[8] Qwen2.5 and Gemma 2 reports provide later architecture examples using RMSNorm.[9][10]
Instead of centering (subtracting the mean) and then scaling (dividing by standard deviation), RMSNorm just divides by the root-mean-square. It doesn't subtract the mean, and many implementations also omit a learned bias term.
| Step | LayerNorm | RMSNorm |
|---|---|---|
| Compute mean | Yes | No |
| Subtract mean | Yes | No |
| Scale statistic | Centered standard deviation | Root mean square |
| Divide by scale statistic | Yes | Yes |
| Learned scale γ | Yes | Yes |
| Learned shift β | Yes | Often omitted |

The original RMSNorm paper reports comparable quality to LayerNorm on its evaluated tasks and observed run-time reductions from 7% to 64% across different models and implementations.[8] Fused kernels, hardware, and the fraction of time spent in normalization determine the result in another stack, so those measurements are evidence from the paper rather than a promised speedup.
What does RMSNorm remove from LayerNorm, and what does it keep?
Answer
RMSNorm removes mean-centering and often removes the learned bias. It keeps per-token rescaling by a feature-wise magnitude estimate and keeps a learned scale vector.
1from math import sqrt
2
3def rms_norm(values, weight=None, eps=1e-6):
4 if weight is None:
5 weight = [1.0] * len(values)
6 rms = sqrt(sum(value * value for value in values) / len(values) + eps)
7 return [value * scale / rms for value, scale in zip(values, weight)]
8
9values = [3.0, 1.0, -1.0, 5.0]
10normalized = rms_norm(values)
11
12assert round(sqrt(sum(value * value for value in normalized) / len(normalized)), 6) == 1.0
13assert round(sum(normalized) / len(normalized), 6) != 0.0
14assert rms_norm([3.0, 1.0], weight=[0.0, 0.0]) == [0.0, 0.0]1import torch
2import torch.nn as nn
3
4class RMSNorm(nn.Module):
5 """Root Mean Square Layer Normalization."""
6 def __init__(self, d_model: int, eps: float = 1e-6):
7 super().__init__()
8 self.weight = nn.Parameter(torch.ones(d_model))
9 self.eps = eps
10
11 def forward(self, x: torch.Tensor) -> torch.Tensor:
12 # x shape: (batch, seq_len, d_model)
13 rms = torch.sqrt(x.pow(2).mean(dim=-1, keepdim=True) + self.eps)
14 return self.weight * (x / rms)
15
16# Quick sanity check
17x = torch.tensor([[[3.0, 1.0, -1.0, 5.0]]])
18rms = RMSNorm(d_model=4)
19out = rms(x)
20print(out.round(decimals=4).detach())
21feature_rms = torch.sqrt(out.pow(2).mean(dim=-1))
22assert torch.allclose(feature_rms, torch.ones_like(feature_rms), atol=1e-5)
23assert not torch.allclose(out.mean(dim=-1), torch.zeros_like(out.mean(dim=-1)))1tensor([[[ 1.0000, 0.3333, -0.3333, 1.6667]]])Reported normalization layouts
Once you understand Pre-LN and Post-LN, advanced variants become easier to read. These entries summarize specific reports; their experiments differ in model size, objective, and optimization setup.
| Recipe | Reported example | What changes |
|---|---|---|
| Post-LN | BERT | Normalize after the residual addition |
| Pre-LN | GPT-2 | Normalize before attention and FFN, retaining an identity residual contribution |
| Pre + Post norm | Gemma 2 | Normalize both input and output of each sublayer with RMSNorm[10] |
| DeepNorm | DeepNet study | Post-LN-shaped update with depth-dependent α and matched residual-branch init; evaluated up to 1,000 layers[7] |
| QK-Norm (Query-Key Normalization) | Rybakov et al. study | Normalize query and key vectors before their dot product; evaluated alone and with softmax capping[11] |
No row establishes a globally superior recipe. Peri-LN names an input-and-output normalization layout and reports experiments up to 3.2B parameters; Rybakov et al. evaluate attention-specific normalization and capping on an 830M-parameter model driven into instability with high learning rates.[12][11]
QK-Norm targets attention logits, not every residual-stream dynamic.
How does QK-Norm reduce attention instability?
Answer
QK-Norm normalizes query and key vectors before their dot product, limiting the contribution from their vector magnitude to attention logits. Rybakov et al. study a LayerNorm-based form and report that combining QK-Norm with softmax capping increases the stable learning-rate range in their evaluated setup. The normalizer and its exact placement relative to positional operations remain architecture choices.
This small calculation uses RMS rescaling to expose the mechanism: multiplying a query by twenty changes the raw dot-product logit, but rescaling both query and key by their RMS removes that scale-only change.
1from math import isclose, sqrt
2
3def rms_rescale(values):
4 rms = sqrt(sum(value * value for value in values) / len(values))
5 return [value / rms for value in values]
6
7def attention_logit(query, key):
8 return sum(q * k for q, k in zip(query, key)) / sqrt(len(query))
9
10query = [6.0, -3.0, 2.0, 1.0]
11key = [4.0, -2.0, 1.0, 3.0]
12large_query = [20.0 * value for value in query]
13
14raw = attention_logit(query, key)
15raw_large = attention_logit(large_query, key)
16controlled = attention_logit(rms_rescale(query), rms_rescale(key))
17controlled_large = attention_logit(rms_rescale(large_query), rms_rescale(key))
18
19print("raw logits:", round(raw, 3), round(raw_large, 3))
20print("RMS-rescaled logits:", round(controlled, 3), round(controlled_large, 3))
21assert raw_large == 20.0 * raw
22assert isclose(controlled, controlled_large, rel_tol=1e-12)1raw logits: 17.5 350.0
2RMS-rescaled logits: 1.807 1.807Why do hybrid layouts such as Peri-LN or Pre + Post norm exist if Pre-LN is already stable?
Answer
Pre-LN includes a direct identity gradient route, but residual-stream scale can still become a concern. Hybrid layouts add output-side normalization while preserving an input-side norm, then test whether that changes activation and optimization behavior.
Peri-LN and hybrids
Kim et al. use Peri-LN for normalization placed both before and after each sublayer, and report more balanced variance growth and steadier gradients than their compared layouts in experiments up to 3.2B parameters.[12] Gemma 2's technical report states that it applies RMSNorm to both the input and output of each transformer sublayer; this matches an input-and-output normalization layout, although that report uses no Peri-LN label.[10]
Practice: build a toggle-switch transformer block
A practical way to internalize the difference is to write one block that can switch between Post-LN and Pre-LN. The minimal PyTorch module below lets you initialize stacks of each type and compare the gradient norms across blocks at initialization.
1import torch
2import torch.nn as nn
3
4class ToggleBlock(nn.Module):
5 """Transformer block with style='pre' or style='post'."""
6 def __init__(self, d_model: int, style: str = "pre"):
7 super().__init__()
8 assert style in ("pre", "post")
9 self.style = style
10 self.norm = nn.LayerNorm(d_model)
11 # Simplified sublayer: single linear + ReLU
12 self.sublayer = nn.Sequential(
13 nn.Linear(d_model, d_model * 4),
14 nn.ReLU(),
15 nn.Linear(d_model * 4, d_model),
16 )
17
18 def forward(self, x: torch.Tensor) -> torch.Tensor:
19 if self.style == "pre":
20 # x_l + Sublayer(LayerNorm(x_l))
21 return x + self.sublayer(self.norm(x))
22 else:
23 # LayerNorm(x_l + Sublayer(x_l))
24 return self.norm(x + self.sublayer(x))
25
26class TinyStack(nn.Module):
27 def __init__(self, d_model: int = 64, depth: int = 6, style: str = "pre"):
28 super().__init__()
29 self.blocks = nn.ModuleList([ToggleBlock(d_model, style) for _ in range(depth)])
30 self.final_norm = nn.LayerNorm(d_model) if style == "pre" else nn.Identity()
31 self.head = nn.Linear(d_model, 16)
32
33 def forward(self, x: torch.Tensor) -> torch.Tensor:
34 for block in self.blocks:
35 x = block(x)
36 return self.head(self.final_norm(x))
37
38def inspect_grad(style: str, d_model: int = 64, depth: int = 6, seed: int = 0):
39 torch.manual_seed(seed)
40 model = TinyStack(d_model=d_model, depth=depth, style=style)
41 x = torch.randn(2, 8, d_model)
42 target = torch.randn(2, 8, 16)
43 out = model(x)
44 loss = nn.functional.mse_loss(out, target)
45 loss.backward()
46 grad_norms = [block.sublayer[0].weight.grad.norm().item() for block in model.blocks]
47 rounded_norms = [round(value, 4) for value in grad_norms]
48 print(f"{style:4} | loss={loss.item():.4f} | block grad norms={rounded_norms}")
49 return grad_norms
50
51pre_grad_norms = inspect_grad("pre")
52post_grad_norms = inspect_grad("post")
53
54assert len(pre_grad_norms) == len(post_grad_norms) == 6
55assert all(value > 0 for value in pre_grad_norms + post_grad_norms)1pre | loss=1.3247 | block grad norms=[0.2664, 0.2565, 0.2494, 0.2392, 0.2472, 0.2415]
2post | loss=1.3183 | block grad norms=[0.2663, 0.2614, 0.2619, 0.2555, 0.2684, 0.2698]The profiles differ across depth and seed. This toy is a diagnostic, not a proof of the Xiong et al. result. The real lesson is how to inspect layerwise gradients instead of treating a diverging model as a mystery.
Mini exercise
- Change
depthfrom 6 to 12. How do the per-block gradient profiles change? - Replace
final_normwithnn.Identity()in Pre-LN mode and inspect output scale and loss under several seeds. - Try replacing
nn.LayerNormwith theRMSNormclass from earlier. Does the gradient norm change meaningfully?
What to check before moving on
- Compute LayerNorm by hand, including mean, population variance, epsilon, learned scale, and learned shift.
- Explain why LayerNorm uses one token's feature values while BatchNorm uses statistics across examples or positions.
- Identify Pre-LN and Post-LN from code, then derive versus .
- Connect Post-LN's initialization-time gradient profile to learning-rate warmup without claiming the result applies to every training setup.
- Explain the reported deep Pre-LN representation-collapse risk and name measurements that would reveal it.
- Implement RMSNorm and state exactly which LayerNorm operations it removes.
- Separate residual-stream normalization, QK-Norm, DeepNorm, and input-output hybrid layouts by the mechanism each changes.
Practice checkpoints
Your 48-layer decoder diverges during first few hundred steps, but the same optimizer works on a 12-layer version. What should you inspect first?
Answer
Start with norm placement and residual-path statistics. If the stack is Post-LN, inspect initialization-time layerwise gradients and check whether warmup or initial learning rate differs from a stable setup. If the model follows a Pre-LN architecture with a final norm, confirm that norm remains present; then inspect attention-logit statistics and candidate controls such as QK-Norm or softmax capping.
Why should you avoid treating Pre-LN as a universal replacement for Post-LN?
Answer
Published comparisons are tied to architecture, depth, initialization, schedule, and task. Xiong et al. establish a Pre-LN initialization and warmup advantage in evaluated setups, while DeepNorm demonstrates a stable Post-LN-derived alternative under a different design. Measure the candidate layout under matched conditions.
When does RMSNorm change the model's behavior less than replacing Pre-LN with Post-LN?
Answer
RMSNorm changes the normalization formula inside a broadly similar residual layout. Switching Pre-LN to Post-LN changes where normalization sits relative to the skip path, which directly changes gradient flow and optimization stability. So RMSNorm is a smaller architectural change than moving the norm across the residual addition.
Common failures and fixes
- Symptom: A reference Pre-LN implementation has unexpected output scale. Cause: Its final normalization was removed because each block already has an input-side norm. Fix: Match the architecture first; GPT-2 and the Pre-LN setup analyzed by Xiong et al. normalize the accumulated stream before prediction.
- Symptom: RMSNorm is described as a faster spelling of LayerNorm. Cause: Mean-centering and RMS rescaling were collapsed into one operation. Fix: State the formulas: LayerNorm centers and rescales; RMSNorm rescales without subtracting the mean.
- Symptom: A Post-LN run diverges and depth alone gets blamed. Cause: Initialization-time gradient scale and early learning rate weren't inspected. Fix: Log layerwise gradient norms, then compare warmup, initial rate, and norm placement under matched conditions.
- Symptom: Attention logits are controlled but another instability remains. Cause: QK-Norm was treated as a replacement for residual normalization. Fix: Treat query-key scale and residual-stream dynamics as separate measurements and interventions.