一文搞懂归一化技术
前言
本文要讨论的是归一化(Normalization)。它是让深层网络能够稳定训练的基础技术之一,也是现代 LLM 中每个 Transformer Block 都不可或缺的组件。我们将从它要解决的问题出发,逐步梳理从 BatchNorm 到 LayerNorm、再到 RMSNorm 的演进脉络,最后讨论归一化在 Transformer 中的摆放位置,以及注意力内部的 QK-Norm。
为什么需要归一化
深层网络的训练难题
在深度神经网络中,每一层的输出都会成为下一层的输入。当网络只有几层时,各层激活值的尺度差异还可以接受;但随着层数加深,激活值的尺度会随层数逐层放大或收缩。如果某一层让信号平均放大一点,几十层之后这个效应就会指数累积,最终导致激活值爆炸;反过来,信号也可能逐层衰减直至消失。梯度反向传播时面临同样的问题,这就是经典的梯度爆炸与梯度消失。
早期的解释来自 Batch Normalization 原论文提出的“内部协变量偏移(Internal Covariate Shift)”:由于前面层的参数在训练中不断更新,后面每一层看到的输入分布也随之持续变化,因此每一层都必须不断适应一个“移动的输入分布”,导致训练困难。
不过,后续工作 How Does Batch Normalization Help Optimization? 通过实验表明,内部协变量偏移不足以解释 BN 的全部优化收益:即使人为向归一化后的激活注入噪声、加剧分布偏移,带归一化的网络依然训练得更快更稳。目前更被广泛接受的解释是,归一化让损失曲面变得更加平滑,参数更新方向更稳定,从而允许使用更大的学习率。至于“归一化为什么有效”这个问题本身,到今天仍没有完全统一的结论,但这并不妨碍它成为深度学习中最重要的工程手段之一。
归一化与残差流
在 Transformer 中,问题还有一个更具体的来源:残差连接。对于输入 与某个子层 ,例如 Attention 或 FFN,残差连接的计算为:
这意味着每一层的输出都会在原有表示上不断累加。如果各子层输出近似不相关、方差大致恒定,那么残差流(residual stream)的方差会随层数近似线性增长。一个简单的实验就能观察到这个现象:
x = torch.randn(8, 128, 512) # [N, L, C]stds = [x.std().item()]for _ in range(64): x = x + torch.randn(8, 128, 512) * 0.1 # 模拟各子层的输出 stds.append(x.std().item())# stds 大致随 sqrt(层数) 增长,64 层后尺度已明显漂移归一化的作用,就是在每个子层读取残差流之前,把输入重新拉回一个固定的尺度,使各层看到的输入尺度保持稳定:
图中的平稳曲线表示子层读取的 Norm 输出,残差流本身仍可持续增长。这个“在残差流上进行尺度校正”的视角,是后面理解 Pre-Norm 与 Post-Norm 差异的基础。
归一化是什么
统一形式
BatchNorm、LayerNorm、InstanceNorm 和 GroupNorm 可以写成下面的标准化形式;稍后介绍的 RMSNorm 则省略减均值。对输入元素 ,先减去均值、再除以标准差,最后做一层可学习的仿射变换:
其中 与 是统计得到的均值与方差, 与 是可学习参数。整个计算可以拆成三件事:
- 去中心化:减去 ,把分布中心移到原点;
- 缩放:除以标准差,把尺度统一为 1;
- 仿射:乘以 、加上 ,提供可学习的输出尺度与偏移。它们是共享参数,不能逐输入恢复被归一化丢弃的均值与尺度;只有固定统计量时,才可以取 、 恢复恒等变换。
公式中的 是一个很小的正数,通常为 或 ,放在根号内防止方差为零时出现除零。另外,在 bf16/fp16 训练中,、 这类统计量通常会在 fp32 精度下累加,再转回原精度参与逐元素运算,以降低舍入误差。
统计的维度
那么,各种归一化方法的区别在哪里?对于下面这四种中心化方法,一个关键区别是:
和 沿哪些维度统计。
设输入张量为 ,其中 是 batch 大小, 是序列长度, 是特征维度。几种经典方法的区别可以列成一张表:
| 方法 | 统计维度 | 含义 |
|---|---|---|
| BatchNorm | 同一通道跨 batch、跨位置共享统计量 | |
| LayerNorm | 单个 token 的全部特征做统计 | |
| InstanceNorm | 单个样本的单个通道做统计 | |
| GroupNorm | 单个样本内的一组通道做统计 |
画成图会更直观;同样一个 张量,四种方法覆盖的区域各不相同:
可以看到,它们的公式完全一致,只是统计量的“覆盖范围”不同。接下来我们分别看看这些方法在 LLM 场景下的取舍。
BatchNorm
BatchNorm 沿 batch 维做统计:对第 个通道,统计量为
也就是说,同一个通道上的所有 token、所有样本共享同一组 与 。此外,BatchNorm 在训练和推理时的行为并不一致:训练时用当前 batch 的统计量,推理时则切换为训练过程中滑动平均得到的 running mean/var。这一设计在 CNN 时代非常成功,但它也带来了后来的一系列问题。
BatchNorm 的局限性
将 BatchNorm 搬到 LLM 上,会遇到几个绕不开的问题:
- 统计量被 padding 污染。语言模型的序列长度不一,batch 内需要 padding 对齐,而 padding 位置的值会进入 、 的统计,使统计量随 batch 的 padding 比例漂移;
- 小 batch 下统计噪声大。统计量依赖整个 batch,batch 越小噪声越大,而大模型训练的 per-device batch 往往很小;
- 训练与推理不一致。推理依赖 running mean/var,但语言模型面对的输入长度、领域分布差异极大,固定的 running 统计量很难代表推理时的真实分布;
- 逐 token 解码难以沿用训练时的统计方式。单请求每步只有一个新位置,直接重算 batch 统计量会很不稳定;标准 BN 在推理时可以使用 running 统计量,并非无法执行;
- 跨卡同步有额外开销。普通 BN 可以使用各卡局部统计量;若使用 SyncBatchNorm 聚合跨卡统计量,则需额外通信。
代码实现
先实现一个 BatchNorm 教学版本,对 [N, L, C] 输入沿 (N, L) 统计,并复现 running mean/var 的更新逻辑:
class BatchNorm(nn.Module): def __init__(self, num_features: int, eps: float = 1e-5, momentum: float = 0.1) -> None: super().__init__() self.eps = eps self.momentum = momentum self.weight = nn.Parameter(torch.ones(num_features)) self.bias = nn.Parameter(torch.zeros(num_features)) self.register_buffer("running_mean", torch.zeros(num_features)) self.register_buffer("running_var", torch.ones(num_features))
def forward(self, x: torch.Tensor) -> torch.Tensor: # x: [N, L, C] if self.training: mean = x.mean(dim=(0, 1)) # [C] var = x.var(dim=(0, 1), unbiased=False) # [C] with torch.no_grad(): self.running_mean.mul_(1 - self.momentum).add_(self.momentum * mean) # PyTorch 惯例:running_var 用无偏估计 self.running_var.mul_(1 - self.momentum).add_( self.momentum * x.var(dim=(0, 1), unbiased=True) ) else: mean, var = self.running_mean, self.running_var return (x - mean) / torch.sqrt(var + self.eps) * self.weight + self.bias注意两处方差的口径不一样。归一化当前 batch 用有偏方差,分母是 ,这样归一化后的方差恰好约为 1;如果错用无偏估计,分母变成 ,归一化后方差就只剩 。更新 running_var 时则反过来用无偏方差:它是推理阶段对总体方差的长期估计,样本量小时有偏估计会系统性偏低,这也是 PyTorch BatchNorm 的做法。另外 时无偏估计会除零,得到 NaN。
而 padding 污染的问题可以直接观察到:给序列补零之后,统计量会明显偏移。
x = torch.randn(4, 16, 8) + 2 # [N, L, C]x_padded = torch.cat([x, torch.zeros(4, 16, 8)], dim=1) # [N, 2L, C],补长一倍
x.mean(dim=(0, 1)) # ≈ 2x_padded.mean(dim=(0, 1)) # ≈ 1,均值被拉走,方差也从 ≈1 变成 ≈1.5batch 内容一变,统计量就变。归根结底,BatchNorm 的统计依赖于 batch 与序列维度,而语言模型的 batch 构成和序列长度都在不断变化;Transformer 需要一种只依赖单个 token 自身特征的归一化方法,这正是 LayerNorm。
LayerNorm
Layer Normalization 的思路非常直接:对每个 token 的隐藏状态 做统计:
统计量只来自当前 token 的特征维度,与 batch 大小、序列长度、序列位置都无关。这让它天然适配语言模型:训练和推理行为一致,单 token 解码时行为也不变,padding 更不会影响其他 token 的统计量。
性质
LayerNorm 有几个值得注意的数学性质:
- 平移不变性:,所有分量同时加上常数,归一化结果不变;
- 缩放不变性:忽略 且方差非零时,对 有 ;固定的正 会破坏严格等式,仅当其相对方差很小时近似成立。
这意味着 LayerNorm 丢失了输入的整体平移信息,并在常见数值范围内弱化整体尺度差异。可学习的 、 可以调整输出,却不能恢复每个输入原有的均值与尺度。从反向传播的角度看,由于均值与方差同样参与计算,LN 的梯度中会出现“减去均值方向的梯度分量”这一项,这使得它同时约束了梯度的尺度,是其稳定训练的原因之一。
代价方面,LayerNorm 需要先统计均值和方差,再做逐元素归一化,属于典型的访存受限(memory-bound)算子:计算量不大,但要完整读写几遍激活值。在 LLM 中归一化层极多,这部分开销累积起来也不可忽视;这也是后来 RMSNorm 出现的一个动机。
代码实现
class LayerNorm(nn.Module): def __init__(self, d_model: int, eps: float = 1e-5) -> None: super().__init__() self.eps = eps self.weight = nn.Parameter(torch.ones(d_model)) self.bias = nn.Parameter(torch.zeros(d_model))
def forward(self, x: torch.Tensor) -> torch.Tensor: # x: [N, L, C] mean = x.mean(dim=-1, keepdim=True) # [N, L, 1] var = x.var(dim=-1, unbiased=False, keepdim=True) # [N, L, 1] return (x - mean) / torch.sqrt(var + self.eps) * self.weight + self.bias与 nn.LayerNorm 数值对拍,使用默认参数与零初始化 bias,输出应在浮点误差内一致:
x = torch.randn(4, 16, 768) # [N, L, C]assert torch.allclose(LayerNorm(768)(x), nn.LayerNorm(768)(x), atol=1e-6)RMSNorm
LayerNorm 把统计范围缩到了单个 token,但它仍然包含去中心化和仿射偏移两部分。一个自然的问题是:
LayerNorm 里哪些成分真正起作用?
RMSNorm 的作者给出的假设是:LayerNorm 的收益主要来自 re-scaling 不变性,即对输入整体缩放近似不敏感,而 re-centering,也就是减均值,在其所测任务中未必必要;Understanding and Improving Layer Normalization 的消融实验也表明,可学习的 、 在多数任务中并不带来收益。RMSNorm 据此把均值与 一起去掉,只保留均方根缩放:
少算一个统计量、少一组参数,换来的却是与 LayerNorm 基本持平的训练效果。因此自 LLaMA 起,RMSNorm 逐渐成为现代 LLM 的默认选择。
工程细节
实现层面有几个值得注意的点:
- 统计量在 fp32 下计算。bf16 激活先 upcast 到 fp32 求均方根,再转回原精度做逐元素乘法,避免累加误差;
- 用
rsqrt表达倒数平方根。这有利于内核优化;具体速度与误差可以通过基准测试比较; - fused kernel。Liger Kernel、FlashInfer 等提供将统计、缩放与乘权重融合的实现;
nn.RMSNorm是否走融合内核取决于 PyTorch 版本、设备和编译路径; - Gemma 的小差别。Gemma 系列的 RMSNorm 权重形式是 而不是 ,参数初始化为 0 而不是 1。数学上等价,但读代码时容易困惑,值得留意。
代码实现
class RMSNorm(nn.Module): def __init__(self, d_model: int, eps: float = 1e-6) -> None: super().__init__() self.eps = eps self.weight = nn.Parameter(torch.ones(d_model))
def forward(self, x: torch.Tensor) -> torch.Tensor: # x: [N, L, C] dtype = x.dtype x = x.float() # 统计量用 fp32 x = x * torch.rsqrt(x.pow(2).mean(dim=-1, keepdim=True) + self.eps) # [N,L,C] × [N,L,1] return self.weight * x.to(dtype) # 转回原精度再乘权重,沿用 LLaMA 顺序与 nn.RMSNorm 对拍时注意把 eps 显式对齐,两者的默认值不同:
x = torch.randn(4, 16, 768) # [N, L, C]assert torch.allclose( RMSNorm(768, eps=1e-6)(x), nn.RMSNorm(768, eps=1e-6)(x), atol=1e-6,)至于“RMSNorm 到底快多少”,可以直接写个基准对比:同样 [T, d] 的输入,分别跑 nn.LayerNorm 与 nn.RMSNorm 各千次取平均。由于两者都是访存受限算子,实际收益主要来自少维护一组 参数、少一次均值规约,具体加速比例值得用实测数字说话。GPU 基准还应先预热,并同步设备或使用设备计时器。
归一化放在哪
前面讨论的都是归一化“怎么算”,还有一个同样重要的问题是归一化“放在哪”。
Post-Norm
原始 Transformer 采用的是 Post-Norm,即先残差相加、再归一化:
Post-Norm 的每一层输出都被强制拉回固定尺度,表达能力较强;但代价是残差路径上每一层都要穿过一次归一化。On Layer Normalization in the Transformer Architecture 的分析表明,Post-Norm 在训练初期靠近输出层的梯度会异常放大,因此深层时通常需要配合 warmup 才能稳定收敛。
Pre-Norm
现代 LLM 几乎全部使用 Pre-Norm,即先归一化、再进入子层、最后残差相加:
这样残差流本身就是一条没有归一化打断的“高速公路”,梯度可以直达每一层,训练非常稳定,对 warmup 也不敏感。代价则来自前面提到的方差累加:残差流的尺度随层数不断增长,深层子层的输出相对残差流越来越小,相当于深层模块的贡献被稀释。此外还有一个容易遗漏的细节:因为残差流上不再有归一化,所以模型最后通常还需要一个 final norm,在 lm_head 之前把输出拉回固定尺度。
两种接线方式的对比一目了然:
折中方案
Post-Norm 与 Pre-Norm 各有代价,因此也出现了一些折中设计:
- DeepNorm:DeepNet 在 Post-Norm 的残差通路上给 乘一个常数 ,即 ,并配套缩小初始化幅度,让 Post-Norm 结构也能稳定堆叠到上千层;
- Sandwich Norm:在子层前后各放一个归一化,同时约束输入与输出幅度,CogView 曾采用;Gemma 2 中每个子层前后各一个 RMSNorm 的设计也是同样的思路。
| 方案 | 稳定性 | 效果上限 | 实现复杂度 |
|---|---|---|---|
| Post-Norm | 差,依赖 warmup | 高 | 低 |
| Pre-Norm | 好 | 略低,深层贡献被稀释 | 低 |
| DeepNorm / Sandwich | 好 | 兼顾二者 | 需调参或多一层 Norm |
代码实现
两种接线方式的差别只在 forward 里一行代码的顺序:
class PostNormBlock(nn.Module): def __init__(self, d_model: int, sublayer: nn.Module) -> None: super().__init__() self.sublayer = sublayer self.norm = nn.RMSNorm(d_model)
def forward(self, x: torch.Tensor) -> torch.Tensor: # x: [N, L, C] return self.norm(x + self.sublayer(x)) # 残差之后归一化
class PreNormBlock(nn.Module): def __init__(self, d_model: int, sublayer: nn.Module) -> None: super().__init__() self.sublayer = sublayer self.norm = nn.RMSNorm(d_model)
def forward(self, x: torch.Tensor) -> torch.Tensor: # x: [N, L, C] return x + self.sublayer(self.norm(x)) # 归一化后进子层如果想直观感受两者的差异,可以堆一个几十层的 mini Transformer,固定学习率、去掉 warmup 分别训练:观察 Post-Norm 更容易出现的 loss 震荡,以及 Pre-Norm 更平稳的梯度;这个实验比单看公式更直观。
QK-Norm
归一化不只作用于残差流,注意力内部也有需要校正尺度的地方。随着模型规模增大,attention logits 的数值会不断增长,导致 softmax 饱和、梯度退化,甚至在训练中后期引发 loss 尖刺。ViT-22B 在把视觉 Transformer 扩到 220 亿参数时报告了这类不稳定,并采用了直接的解决方案:对 Q、K 分别做归一化,再计算内积,即 QK-Norm。更早的 Query-Key Normalization for Transformers 已在 2020 年提出沿头维度做 归一化并配合可学习缩放。
设某个注意力头的查询与键为 ,一种常见做法是在 head_dim 上对二者分别归一化:
不带仿射参数并忽略 时,归一化会固定 Q、K 的模长;加入可学习的逐维缩放后模长不再严格固定,但仍有助于控制 logits 的尺度:
有两个实现细节值得注意:
- 归一化与 RoPE 的先后顺序。采用 QK-Norm 的模型,如 Gemma 3、Qwen3 和 OLMo 2,通常先对线性投影得到的 Q、K 做 RMSNorm,再应用 RoPE;
- Norm 的选择。ViT-22B 用的是 LayerNorm,而 LLM 中普遍使用 RMSNorm,且 Q、K 各有一组独立的归一化参数。
这里出现的 RoPE 是作用在 Q、K 上的一种旋转式位置编码,也是下一篇文章的主角。暂时不熟悉它的读者,可以先把它看成一个给 Q、K 注入位置信息的操作;至于归一化为什么要放在 RoPE 之前而不是之后,则与 RoPE 的数学形式有关,答案留到下一篇揭晓。
代码实现
在 attention 中插入 qk norm 只需在投影之后、RoPE 之前加两行:
self.q_norm = RMSNorm(head_dim)self.k_norm = RMSNorm(head_dim)
def forward(self, x): # x: [B, L, d_model] q = self.q_norm(self.q_proj(x).view(B, L, H, D)) # [B,L,H·D] -> [B,L,H,D] k = self.k_norm(self.k_proj(x).view(B, L, H, D)) # [B,L,H,D] q = apply_rope(q, cos, sin) # norm 在 RoPE 之前 k = apply_rope(k, cos, sin) # apply_rope 见 EP.3 ...结语
回到最初的问题,LLM 中的归一化可以概括为一句话:只看单个 token 的缩放校正,放在残差分支之前。代表性的现代方案是 Pre-RMSNorm 加最后的 final norm,注意力内部再辅以 QK-Norm 约束 logits 的尺度。
梳理下来,记住三个层次就够了:
- 统计范围:从 BatchNorm 的跨 batch 统计,收缩到 LayerNorm/RMSNorm 的单 token 统计,这是语言模型对归一化的根本要求;
- 统计内容:从均值加方差,简化到只保留均方根,依据是 re-scaling 不变性起主要作用;
- 摆放位置:从 Post-Norm 换到 Pre-Norm,用一点效果上限换来深层训练的稳定性。
下一篇文章我们将讨论 Transformer 的另一个基础组件:位置编码。
参考资料
- Batch Normalization: Accelerating Deep Network Training by Reducing Internal Covariate Shift
- How Does Batch Normalization Help Optimization?
- Layer Normalization
- Understanding and Improving Layer Normalization
- Root Mean Square Layer Normalization
- On Layer Normalization in the Transformer Architecture
- DeepNet: Scaling Transformers to 1,000 Layers
- Query-Key Normalization for Transformers
- Scaling Vision Transformers to 22 Billion Parameters
- LLaMA: Open and Efficient Foundation Language Models