""" Normalization / Residual / Initialization - minimal runnable implementation =========================================================================== Educational PyTorch reference for normalization layers, the Pre-LN vs Post-LN gradient behaviour, and variance-preserving initialization. Standalone script: runs six sanity checks on CPU in a few seconds. Pairs with: docs/tutorials/normalization_init_tutorial.md (concept reference). What it demonstrates: layernorm_from_scratch - LayerNorm matches torch.nn.LayerNorm rmsnorm_from_scratch - RMSNorm matches torch.nn.RMSNorm, and (unlike LayerNorm) is NOT mean-shift invariant (it only re-scales, no re-centering) Sanity checks: [a] LayerNorm from scratch == nn.LayerNorm (population var, eps inside the sqrt) [b] RMSNorm from scratch == nn.RMSNorm; LayerNorm(x+c)==LayerNorm(x) but RMSNorm(x+c)!=RMSNorm(x) [c] BatchNorm train != eval: train uses batch stats (+updates running stats), eval uses running stats [d] Post-LN piles parameter gradients near the OUTPUT (top-heavy, last/first>1, needs warmup); Pre-LN piles them near the INPUT instead (bottom-heavy, opposite skew, comparable magnitude) [e] Kaiming preserves the second moment E[y^2] through Linear+ReLU (factor 2/fan_in); Xavier-for-ReLU halves it to ~0.5 [f] GPT-2 residual scaling 1/sqrt(2N) keeps the residual-stream variance bounded vs linear growth Run: python normalization.py """ import torch import torch.nn as nn import torch.nn.functional as F torch.manual_seed(0) def layernorm_from_scratch(x, weight, bias, eps=1e-5): """LayerNorm over the last dim. x: [..., d]. Population variance (unbiased=False), eps added INSIDE the sqrt, then affine: y = (x-mean)/sqrt(var+eps) * weight + bias.""" mean = x.mean(dim=-1, keepdim=True) var = x.var(dim=-1, unbiased=False, keepdim=True) # population var, matches torch return (x - mean) / torch.sqrt(var + eps) * weight + bias def rmsnorm_from_scratch(x, weight, eps=1e-6): """RMSNorm over the last dim: only re-scale by the RMS, NO mean-centering, NO bias. y = x / sqrt(mean(x^2) + eps) * weight.""" ms = x.pow(2).mean(dim=-1, keepdim=True) # mean of squares return x / torch.sqrt(ms + eps) * weight class ResidualStack(nn.Module): """A deep stack of identical residual blocks, either Pre-LN or Post-LN, plus a final linear head, used to show how parameter-gradient norms distribute across depth at init. Pre-LN block: h = h + Linear(LayerNorm(h)) Post-LN block: h = LayerNorm(h + Linear(h)) The head matters: without it, a Post-LN stack's output is LayerNorm-normalized, so a loss like mean(out^2) is ~scale-invariant and artificially suppresses ALL gradients (a confound). The head makes the loss depend on the actual output, so the only signal left is the genuine cross-depth gradient distribution. """ def __init__(self, depth, d, pre_ln=True): super().__init__() self.pre_ln = pre_ln self.lns = nn.ModuleList(nn.LayerNorm(d) for _ in range(depth)) self.lins = nn.ModuleList(nn.Linear(d, d) for _ in range(depth)) self.head = nn.Linear(d, d) # final projection (un-normalizes the output) def forward(self, h): for ln, lin in zip(self.lns, self.lins): if self.pre_ln: h = h + lin(ln(h)) else: h = ln(h + lin(h)) return self.head(h) def block_grad_topheavy(depth, d, pre_ln): """Build a fresh stack, one forward+backward, return the LAST-block / FIRST-block weight-grad-norm ratio. >1 means the gradient is concentrated near the OUTPUT (top-heavy). Using a relative ratio (rather than an absolute grad norm) is meant to reduce sensitivity to the loss choice / output normalization; this script only runs a single seed with a single quadratic loss, so "robust to loss choice" itself is not exhaustively verified here (would need multi-seed stats and a non-quadratic loss).""" torch.manual_seed(0) # same init for both variants stack = ResidualStack(depth, d, pre_ln=pre_ln) stack(torch.randn(16, d)).pow(2).mean().backward() gn = [lin.weight.grad.norm().item() for lin in stack.lins] return gn[-1] / gn[0] def main(): B, d, eps = 8, 64, 1e-5 # [a] LayerNorm from scratch == nn.LayerNorm x = torch.randn(B, d) ln = nn.LayerNorm(d, eps=eps) # affine: weight=1, bias=0 at init mine = layernorm_from_scratch(x, ln.weight, ln.bias, eps=eps) a_ok = torch.allclose(mine, ln(x), atol=1e-5) print(f"[a] LayerNorm from scratch vs nn.LayerNorm: max|Δ| = {(mine - ln(x)).abs().max():.2e} " f"{'OK' if a_ok else 'FAIL'}") assert a_ok # [b] RMSNorm matches nn.RMSNorm; and RMSNorm is NOT mean-shift invariant (LayerNorm is) rms = nn.RMSNorm(d, eps=1e-6) mine_r = rmsnorm_from_scratch(x, rms.weight, eps=1e-6) b1 = torch.allclose(mine_r, rms(x), atol=1e-5) c = 5.0 # constant shift on every feature ln_shift = (ln(x + c) - ln(x)).abs().max().item() # LayerNorm removes the mean -> ~0 rms_shift = (rmsnorm_from_scratch(x + c, rms.weight) - mine_r).abs().max().item() # RMS keeps it -> >0 b_ok = b1 and ln_shift < 1e-4 and rms_shift > 1e-2 print(f"[b] RMSNorm vs nn.RMSNorm: max|Δ| = {(mine_r - rms(x)).abs().max():.2e}; " f"mean-shift LN |Δ|={ln_shift:.2e} (~0, re-centers) vs RMS |Δ|={rms_shift:.2e} (>0, only re-scales) " f"{'OK' if b_ok else 'FAIL'}") assert b_ok # [c] BatchNorm: train uses batch stats (+ updates running stats), eval uses running stats bn = nn.BatchNorm1d(d) # running_mean=0, running_var=1 at init xb = torch.randn(B, d) * 3 + 7 # shifted/scaled so batch stats != running bn.train() y_train = bn(xb) running_moved = bn.running_mean.abs().mean().item() # moved away from 0 toward batch mean bn.eval() y_eval = bn(xb) differ = (y_train - y_eval).abs().max().item() # train output is normalized by the batch -> ~0 mean / ~1 std per feature; eval is not train_mean = y_train.mean(dim=0).abs().mean().item() c_ok = running_moved > 0 and differ > 1e-2 and train_mean < 1e-4 print(f"[c] BatchNorm train!=eval: max|Δ| = {differ:.2e}; running_mean moved {running_moved:.3f} from 0; " f"train per-feature mean = {train_mean:.2e} (~0) {'OK' if c_ok else 'FAIL'}") assert c_ok # [d] Post-LN concentrates parameter gradients near the OUTPUT (top-heavy -> needs warmup); # Pre-LN's clean identity path instead skews slightly toward the INPUT (bottom-heavy), # at a comparable magnitude but opposite direction (Xiong et al. 2020's asymptotic claim) depth = 48 r_pre = block_grad_topheavy(depth, d, pre_ln=True) # ~0.4: bottom-heavy (bottom grad ~2.5x the top) r_post = block_grad_topheavy(depth, d, pre_ln=False) # ~2.3: top-heavy (gradient piled at the top) d_ok = r_post > 1.3 and r_post > 2 * r_pre # Post-LN top-heavy AND more imbalanced than Pre-LN print(f"[d] per-block weight-grad top/bottom ratio (last/first over {depth} blocks): " f"Pre-LN={r_pre:.2f} (bottom-heavy) Post-LN={r_post:.2f} (top-heavy, >1) " f"-> opposite skew, comparable magnitude; Post-LN piles gradient near the output, needs warmup " f"{'OK' if d_ok else 'FAIL'}") assert d_ok # [e] Kaiming preserves the SECOND MOMENT E[y^2] through Linear+ReLU (the quantity He et al. # actually propagate): ReLU halves E[pre^2], so the factor 2/fan_in restores E[y^2]~E[x^2]=1. # Xavier (1/fan_in) lacks the factor 2 -> E[y^2]~0.5, decaying by half every layer. fan_in, fan_out, N = 512, 512, 4096 xin = torch.randn(N, fan_in) # E[x^2] = 1 (unit second moment) W_kaiming = torch.randn(fan_out, fan_in) * (2.0 / fan_in) ** 0.5 # He: std = sqrt(2/fan_in) W_xavier = torch.randn(fan_out, fan_in) * (1.0 / fan_in) ** 0.5 # Xavier: std = sqrt(1/fan_in) ms_kaiming = F.relu(xin @ W_kaiming.t()).pow(2).mean().item() # E[y^2] ~ 1 (preserved) ms_xavier = F.relu(xin @ W_xavier.t()).pow(2).mean().item() # E[y^2] ~ 0.5 (halved -> decays) e_ok = abs(ms_kaiming - 1.0) < 0.1 and abs(ms_xavier - 0.5) < 0.1 print(f"[e] post-ReLU second moment E[y^2] (input E[x^2]=1): Kaiming = {ms_kaiming:.3f} (~1, preserved) " f"Xavier = {ms_xavier:.3f} (~0.5, halves per layer) {'OK' if e_ok else 'FAIL'}") assert e_ok # [f] GPT-2 residual scaling 1/sqrt(2N): residual-stream variance stays bounded vs linear growth. # Nlayers = number of transformer layers; each layer writes to the residual stream TWICE # (attn output-proj + FFN down-proj), so there are 2*Nlayers total writes, each scaled by # 1/sqrt(2*Nlayers) -- matching GPT-2's actual "N layers -> 2N writes" semantics. Nlayers, width = 50, 256 n_writes = 2 * Nlayers h_plain = torch.randn(N, width) h_scaled = h_plain.clone() v0 = h_plain.var().item() for _ in range(n_writes): delta = torch.randn(N, width) # each write adds an O(1)-variance update h_plain = h_plain + delta # no scaling -> Var grows ~linearly with #writes h_scaled = h_scaled + delta * (1.0 / n_writes) ** 0.5 # GPT-2 1/sqrt(2N) residual scaling growth_plain = h_plain.var().item() / v0 # ~ (1 + 2*Nlayers) growth_scaled = h_scaled.var().item() / v0 # ~ O(1), bounded f_ok = growth_plain > 10 * growth_scaled print(f"[f] residual-stream var growth over {Nlayers} layers ({n_writes} writes): " f"unscaled ×{growth_plain:.1f} vs 1/sqrt(2N)-scaled ×{growth_scaled:.2f} " f"{'OK' if f_ok else 'FAIL'}") assert f_ok print("\nall normalization / residual / init sanity checks passed ✓") if __name__ == "__main__": main()