实践中layernormal的计算方式

实践中layernormal的计算方式

LayerNorm(Layer Normalization,层归一化)需要对一组元素计算均值和方差,再完成归一化。数学公式很简单,但在 GPU kernel 中,如何计算统计量会影响运算速度、显存读取次数和数值精度。

实践中常见的三种方法是:Welford 算法、平方和法,以及先求均值再计算中心化平方和的两阶段方法。本文说明它们的计算过程、公式证明和实际取舍。

1. LayerNorm 要计算什么

假设一组需要归一化的数据为 x1,x2,...,xNx_1,x_2,\ldots,x_Nx1,x2,...,xN。实际张量可能沿最后一个或多个维度归一化,这里统一将其视为包含 NNN 个元素的一行。

均值为:

μ=1N∑i=1Nxi \mu=\frac{1}{N}\sum_{i=1}^{N}x_i μ=N1i=1∑Nxi

方差为:

σ2=1N∑i=1N(xi−μ)2 \sigma^2=\frac{1}{N}\sum_{i=1}^{N}(x_i-\mu)^2 σ2=N1i=1∑N(xi−μ)2

最终输出为:

yi=(xi−μ)1σ2+ϵγi+βi y_i=(x_i-\mu)\frac{1}{\sqrt{\sigma^2+\epsilon}}\gamma_i+\beta_i yi=(xi−μ)σ2+ϵ 1γi+βi

这里使用总体方差,分母是 NNN,不是样本方差中的 N−1N-1N−1。ϵ\epsilonϵ 用于避免分母为零,γi\gamma_iγi 和 βi\beta_iβi 是可学习的缩放与偏移参数。

即使输入是 FP16 或 BF16,统计量也通常使用 FP32 累加。

2. 两阶段中心化法:先求均值,再求方差

这是一种直接按定义计算的方法。

第一阶段计算均值:

μ=1N∑ixi \mu=\frac{1}{N}\sum_i x_i μ=N1i∑xi

第二阶段计算相对于均值的平方偏差:

σ2=1N∑i(xi−μ)2 \sigma^2=\frac{1}{N}\sum_i(x_i-\mu)^2 σ2=N1i∑(xi−μ)2

示意代码如下:

cpp 复制代码
float sum = 0.0f;
for (int i = 0; i < N; ++i) {
    sum += float(input[i]);
}
float mean = sum / N;

float m2 = 0.0f;
for (int i = 0; i < N; ++i) {
    float delta = float(input[i]) - mean;
    m2 += delta * delta;
}
float variance = m2 / N;

它的优点是直观,并且避免了两个很大的平方量相减。均值和平方偏差和都可以通过并行归约计算。

它需要两阶段处理数据,但这不等于必须从显存读取输入两次。如果整行输入能够留在寄存器或 shared memory 中,kernel 可以先算均值,再复用缓存的输入计算方差和输出。若无法缓存,则可能需要重新读取输入。

因此,这种方法是否高效,取决于行长度、缓存方式、寄存器占用和归约成本。

3. 平方和法:同时累加 sum 和 sum_sq

平方和法在一次遍历中维护两个量:

S=∑ixi,Q=∑ixi2 S=\sum_i x_i,\qquad Q=\sum_i x_i^2 S=i∑xi,Q=i∑xi2

然后计算:

μ=SN,σ2=QN−μ2 \mu=\frac{S}{N},\qquad \sigma^2=\frac{Q}{N}-\mu^2 μ=NS,σ2=NQ−μ2

3.1 平方和公式的证明

从方差定义展开:

σ2=1N∑i(xi−μ)2=1N∑i(xi2−2μxi+μ2)=1N∑ixi2−2μN∑ixi+1N∑iμ2. \begin{aligned} \sigma^2 &=\frac{1}{N}\sum_i(x_i-\mu)^2\\ &=\frac{1}{N}\sum_i(x_i^2-2\mu x_i+\mu^2)\\ &=\frac{1}{N}\sum_i x_i^2 -\frac{2\mu}{N}\sum_i x_i +\frac{1}{N}\sum_i\mu^2. \end{aligned} σ2=N1i∑(xi−μ)2=N1i∑(xi2−2μxi+μ2)=N1i∑xi2−N2μi∑xi+N1i∑μ2.

因为 ∑ixi=Nμ\sum_i x_i=N\mu∑ixi=Nμ,而 μ\muμ 对所有元素都是同一个常数,所以 ∑iμ2=Nμ2\sum_i\mu^2=N\mu^2∑iμ2=Nμ2。代入得到:

σ2=1N∑ixi2−μ2 \boxed{\sigma^2=\frac{1}{N}\sum_i x_i^2-\mu^2} σ2=N1i∑xi2−μ2

这个等式在精确算术下完全成立。

3.2 kernel 中如何计算

cpp 复制代码
float sum = 0.0f;
float sum_sq = 0.0f;

for (int i = 0; i < N; ++i) {
    float x = float(input[i]);
    sum += x;
    sum_sq += x * x;
}

float mean = sum / N;
float variance = sum_sq / N - mean * mean;

GPU 上可以让各线程处理一部分元素,得到局部的 sum 和 sum_sq,然后分别求和归约。它的统计量简单,合并成本较低,适合并行计算。

3.3 为什么可能算出负方差

数学上的等价,不意味着浮点运算的结果也完全等价。

记:

A=QN,B=μ2. A=\frac{Q}{N},\qquad B=\mu^2. A=NQ,B=μ2.

浮点计算得到的两个量带有误差:

A^=A+eA,B^=B+eB. \widehat A=A+e_A,\qquad \widehat B=B+e_B. A =A+eA,B =B+eB.

暂时忽略最后一次减法本身的舍入,算出的方差为:

σ2^=σ2+eA−eB. \widehat{\sigma^2}=\sigma^2+e_A-e_B. σ2 =σ2+eA−eB.

当真实方差很小,而 eB−eA>σ2e_B-e_A>\sigma^2eB−eA>σ2 时,结果就可能为负数。浮点计算并不保证分别算出的 A^\widehat AA 和 B^\widehat BB 仍满足 A^≥B^\widehat A\ge\widehat BA ≥B 。

例如:

x1=10000,x2=10002. x_1=10000,\qquad x_2=10002. x1=10000,x2=10002.

精确计算得到:

μ=10001,σ2=1. \mu=10001,\qquad \sigma^2=1. μ=10001,σ2=1.

但平方和公式需要计算:

QN=100020002,μ2=100020001. \frac{Q}{N}=100020002,\qquad \mu^2=100020001. NQ=100020002,μ2=100020001.

两个约为 10810^8108 的数相减,才能得到 111。FP32 在这个数量级上相邻可表示数的间隔为 888,无法保留这样的个位差异。按上述普通 FP32 计算顺序,这个例子可以得到 000;其他输入和累加顺序还可能产生负值。

关键在于:平方、累加、除法等步骤已经引入误差。最后一次减法即使本身精确,也无法恢复此前丢失的信息。两个接近的大数相减后,共同的大部分被抵消,留下的误差相对于真实方差可能非常大。这就是灾难性消减。

若将输入写成 xi=C+ϵix_i=C+\epsilon_ixi=C+ϵi,其中 CCC 很大而 ϵi\epsilon_iϵi 的波动很小,则真实方差只与 ϵi\epsilon_iϵi 有关。平方和法却先计算包含 C2C^2C2 的大数,再通过相减得到小方差,因此尤其容易丢失精度。

使用 FP32 累加、树形归约或融合乘加可以改善部分误差,但不能根除这个问题。将结果截断为 max(variance, 0) 可以避免负方差引发后续异常,却无法恢复丢失的方差。加上 ϵ\epsilonϵ 同样不能修复统计量的计算误差。

4. Welford:一次遍历更新均值和平方偏差和

Welford 在处理数据时维护三个量:

  • 已处理元素数量 nnn。
  • 当前均值 μn\mu_nμn。
  • 当前平方偏差和 M2,n=∑i=1n(xi−μn)2M_{2,n}=\sum_{i=1}^{n}(x_i-\mu_n)^2M2,n=∑i=1n(xi−μn)2。

加入新元素 xxx 时,令 δ=x−μn\delta=x-\mu_nδ=x−μn,更新公式为:

μn+1=μn+δn+1 \boxed{\mu_{n+1}=\mu_n+\frac{\delta}{n+1}} μn+1=μn+n+1δ

M2,n+1=M2,n+δ(x−μn+1) \boxed{M_{2,n+1}=M_{2,n}+\delta(x-\mu_{n+1})} M2,n+1=M2,n+δ(x−μn+1)

处理完所有元素后:

σ2=M2,NN. \sigma^2=\frac{M_{2,N}}{N}. σ2=NM2,N.

4.1 均值更新的证明

根据均值定义:

μn+1=nμn+xn+1=μn+x−μnn+1=μn+δn+1. \begin{aligned} \mu_{n+1} &=\frac{n\mu_n+x}{n+1}\\ &=\mu_n+\frac{x-\mu_n}{n+1}\\ &=\mu_n+\frac{\delta}{n+1}. \end{aligned} μn+1=n+1nμn+x=μn+n+1x−μn=μn+n+1δ.

记均值移动量为:

a=μn+1−μn=δn+1. a=\mu_{n+1}-\mu_n=\frac{\delta}{n+1}. a=μn+1−μn=n+1δ.

4.2 旧元素的平方偏差和如何变化

均值改变后,每个旧元素相对于新均值的偏差变成:

xi−μn+1=(xi−μn)−a. x_i-\mu_{n+1}=(x_i-\mu_n)-a. xi−μn+1=(xi−μn)−a.

展开平方并求和:

∑i=1n(xi−μn+1)2=∑i=1n(xi−μn)−a2=M2,n−2a∑i=1n(xi−μn)+na2. \begin{aligned} \sum_{i=1}^{n}(x_i-\mu_{n+1})^2 &=\sum_{i=1}^{n}(x_i-\\mu_n)-a^2\\ &=M_{2,n}-2a\sum_{i=1}^{n}(x_i-\mu_n)+na^2. \end{aligned} i=1∑n(xi−μn+1)2=i=1∑n(xi−μn)−a2=M2,n−2ai=1∑n(xi−μn)+na2.

旧元素相对于旧均值的偏差之和为零:

∑i=1n(xi−μn)=∑i=1nxi−nμn=0. \sum_{i=1}^{n}(x_i-\mu_n)=\sum_{i=1}^{n}x_i-n\mu_n=0. i=1∑n(xi−μn)=i=1∑nxi−nμn=0.

因此,旧元素相对于新均值的平方偏差和为:

M2,n+na2. M_{2,n}+na^2. M2,n+na2.

这解释了为什么不必重新遍历旧元素:均值移动对它们造成的总变化可以直接用 na2na^2na2 表示。

4.3 加入新元素的贡献

新元素相对于新均值的偏差为:

x−μn+1=δ−a=nδn+1. x-\mu_{n+1}=\delta-a=\frac{n\delta}{n+1}. x−μn+1=δ−a=n+1nδ.

所以新的平方偏差和为:

M2,n+1=M2,n+na2+(x−μn+1)2=M2,n+nδ2(n+1)2+n2δ2(n+1)2=M2,n+n(n+1)δ2(n+1)2=M2,n+nn+1δ2. \begin{aligned} M_{2,n+1} &=M_{2,n}+na^2+(x-\mu_{n+1})^2\\ &=M_{2,n}+\frac{n\delta^2}{(n+1)^2} +\frac{n^2\delta^2}{(n+1)^2}\\ &=M_{2,n}+\frac{n(n+1)\delta^2}{(n+1)^2}\\ &=M_{2,n}+\frac{n}{n+1}\delta^2. \end{aligned} M2,n+1=M2,n+na2+(x−μn+1)2=M2,n+(n+1)2nδ2+(n+1)2n2δ2=M2,n+(n+1)2n(n+1)δ2=M2,n+n+1nδ2.

由于 x−μn+1=nδ/(n+1)x-\mu_{n+1}=n\delta/(n+1)x−μn+1=nδ/(n+1),最终得到:

M2,n+1=M2,n+δ(x−μn+1). \boxed{M_{2,n+1}=M_{2,n}+\delta(x-\mu_{n+1})}. M2,n+1=M2,n+δ(x−μn+1).

从第一个元素的 μ1=x1\mu_1=x_1μ1=x1、M2,1=0M_{2,1}=0M2,1=0 开始,这个递推在精确算术下始终保持 M2M_2M2 等于已处理元素的平方偏差和。

4.4 实现示例

cpp 复制代码
int count = 0;
float mean = 0.0f;
float m2 = 0.0f;

for (int i = 0; i < N; ++i) {
    float x = float(input[i]);
    ++count;
    float delta = x - mean;
    mean += delta / count;
    float delta2 = x - mean;
    m2 += delta * delta2;
}

float variance = m2 / N;

这里的 delta 相对于旧均值,delta2 相对于新均值。二者的乘积同时计入了旧元素因均值移动产生的变化,以及新元素的贡献。

Welford 避免了最后用两个很大的平方量相减,因此通常比平方和法稳定。它仍然存在舍入误差,也无法恢复输入量化时已经丢失的差异。

5. Welford 如何在 GPU 上并行

上面的代码是串行示例。实际 GPU kernel 可以让各线程处理一部分元素,再合并局部统计量。

假设两组数据的统计量分别为 (nA,μA,M2,A)(n_A,\mu_A,M_{2,A})(nA,μA,M2,A) 和 (nB,μB,M2,B)(n_B,\mu_B,M_{2,B})(nB,μB,M2,B)。令:

n=nA+nB,Δ=μB−μA. n=n_A+n_B,\qquad \Delta=\mu_B-\mu_A. n=nA+nB,Δ=μB−μA.

合并公式为:

μ=μA+ΔnBn \mu=\mu_A+\Delta\frac{n_B}{n} μ=μA+ΔnnB

M2=M2,A+M2,B+Δ2nAnBn. \boxed{M_2=M_{2,A}+M_{2,B}+\Delta^2\frac{n_A n_B}{n}}. M2=M2,A+M2,B+Δ2nnAnB.

这个公式也可以用"均值移动"证明。A 组均值移动量为 μ−μA=ΔnB/n\mu-\mu_A=\Delta n_B/nμ−μA=ΔnB/n,B 组为 μ−μB=−ΔnA/n\mu-\mu_B=-\Delta n_A/nμ−μB=−ΔnA/n。各组内部偏差之和为零,因此合并后的平方偏差和是:

M2=M2,A+nA(μA−μ)2+M2,B+nB(μB−μ)2=M2,A+M2,B+Δ2nAnB2+nBnA2n2=M2,A+M2,B+Δ2nAnBn. \begin{aligned} M_2 &=M_{2,A}+n_A(\mu_A-\mu)^2 +M_{2,B}+n_B(\mu_B-\mu)^2\\ &=M_{2,A}+M_{2,B} +\Delta^2\frac{n_A n_B^2+n_B n_A^2}{n^2}\\ &=M_{2,A}+M_{2,B}+\Delta^2\frac{n_A n_B}{n}. \end{aligned} M2=M2,A+nA(μA−μ)2+M2,B+nB(μB−μ)2=M2,A+M2,B+Δ2n2nAnB2+nBnA2=M2,A+M2,B+Δ2nnAnB.

令 B 组只有新元素 xxx,即 nB=1n_B=1nB=1、μB=x\mu_B=xμB=x、M2,B=0M_{2,B}=0M2,B=0,就得到前面的逐元素更新公式。

并行归约的过程可以理解为:

text 复制代码
各线程处理自己的元素
        ↓
得到局部 (count, mean, M2)
        ↓
在 warp 或 block 内树形合并
        ↓
得到整行均值和方差
        ↓
计算归一化输出

浮点合并顺序会影响末尾精度,因此不同并行布局的结果可能存在微小差异。

6. 一次遍历统计,不等于一次读取完成 LayerNorm

Welford 和平方和法都能在一次遍历中计算统计量,但每个元素的最终输出依赖整行的均值和方差。统计完成前,通常无法直接确定输出。

如果输入保存在寄存器或 shared memory 中,统计完成后可以复用;如果没有缓存,就需要再次读取输入来计算输出。两阶段中心化法也可以通过缓存实现一次显存读取。

因此,比较 kernel 时,应分别看统计阶段的处理次数、输入的显存读取次数,以及缓存带来的寄存器或 shared memory 成本。

7. 实践中的选择与总结

方法 统计过程 数值稳定性 主要成本与限制
两阶段中心化法 先求均值,再求平方偏差和 通常较好 两阶段归约;能否复用输入取决于缓存
平方和法 同时累加和与平方和 大均值、小方差时较差 运算与合并简单,但有消减风险
Welford 更新并合并均值与平方偏差和 通常较好 状态和合并运算更复杂,逐元素更新有依赖

实践中并非所有 LayerNorm 都使用 Welford。同一个库也可能根据行长度、数据类型和目标硬件选择不同 kernel。

精度优先、需要处理大均值和小方差的输入时,Welford 是常见选择。如果整行输入可以高效保存在寄存器中,两阶段中心化法也值得考虑。平方和法结构简单、可能具有性能优势,但应针对实际输入分布验证误差,不能仅凭代数等价判断它可靠。

测试时,除了普通随机输入,还应覆盖常量输入、大均值与小波动、不同长度和不同数据类型,并与更高精度的参考结果比较。

三种方法的核心区别是:两阶段中心化法直接按定义计算;平方和法通过大数相减得到方差;Welford 通过递推与合并维护平方偏差和。选择时需要同时衡量速度、数值误差和内存成本,没有对所有场景都最优的方法。

相关推荐
Guarding and trust3 小时前
项目拆解(二):模型输出是‘野‘的——三层兜底+重分析,把AI变成可靠零件
java·人工智能·python·大模型·fastapi·ollama·错误兜底
星核0penstarry5 小时前
企业接入多家 AI API,聚合平台能解决什么、不能解决什么
人工智能·大模型·ai编程·合规·api聚合平台·大模型网关·接入层
for_ever_love__5 小时前
LangChain Text-to-SQL:让不会写 SQL 的人查数据库
数据库·sql·langchain·大模型·agent·rag·问答
问天_观心6 小时前
Mistral-7B QLoRA 实战(二):官方权重转换、4-bit NF4 加载与 Base Model 基线测试
人工智能·深度学习·学习·大模型·transformer
倔强的石头1066 小时前
Gemini架构解析_多模态大模型的设计思路
人工智能·大模型
前端大斗师16 小时前
「大于 1000」把 1000 元那单也算进去了:我在 Vue3 订单页对了 10 句话
前端·人工智能·typescript·大模型·原力计划
遇码1 天前
system prompt 里拼了时间和知识大纲,每轮缓存都被击穿:静态/动态分离的提示词装配方法
人工智能·ai·大模型
寻道模式1 天前
【开发心得】大模型背后的“隐形毒药”
大模型·标签·数据·注入·投毒
北京地铁1号线1 天前
为大模型创建一个简单的skill:在12306网站查询车次(仅作查询,不提供购票功能,仅作学习使用)
自然语言处理·大模型·agent·技能·skill