Kernel Dev 训练营/ 提示与讲解
T3 提示与讲解 · Track T · Triton · 对照 solutions/t3_layernorm.py 逐行读

T3 讲解:LayerNorm / RMSNorm 的 forward

和 T2 一样一行一个 program,但多了两个陷阱:方差用哪个公式算,以及 padding 位置减完均值之后不再是 0。逐行读参考解,附带 fp32 下“负方差”的具体例子、为什么要把 mean 和 rstd 存下来,以及 RMSNorm 少做了什么。

怎么读这一页

对照 drillst3_layernorm.py 读。结构和 T2 几乎一样(一行一个 program、整行在寄存器里、两次行内归约),如果 T2 还没看,先看 T2 讲解。

① 先看全貌:两个公式

LayerNorm(GPT-2、BERT) RMSNorm(LLaMA、Qwen、DeepSeek)
公式 y = (x − μ) · rstd · w + b y = x · rstd · w
rstd 1 / √(σ² + ε),σ² 是有偏方差(÷N) 1 / √(mean(x²) + ε)
行内归约 2 次:Σx 求 μ,Σ(x−μ)² 求 σ² 1 次:Σx²
参数 w、b 各 N 个 只有 w
额外输出 每行的 mean、rstd(backward 要用) 无
layer_norm_kernel 里一行的数据流悬停节点查看连接
整行只从显存读一次 x、写一次 y,两次归约都在片上完成。w 和 b 每个 program 都读一遍,但它们只有 N 个数,所有 program 共享,基本都命中 L2。
类比 · 统一考试的标准分

LayerNorm 就是把一个班的成绩换算成标准分:先算平均分(mean),再算每个人偏离平均分多少、偏离的幅度有多大(方差),最后用“偏离 ÷ 标准差”让每个班的成绩都在同一个尺度上,再乘上学校规定的权重(w)、加上基础分(b)。RMSNorm 偷了个懒:不减平均分,直接用“成绩的均方根”做尺度。实践证明对大模型来说效果差不多,还更省事。

② kernel 逐行讲

drillslayer_norm_kernel(16–38 行)

读和均值

row  = tl.program_id(0).to(tl.int64)
cols = tl.arange(0, BLOCK_N)
mask = cols < N
x    = tl.load(x_ptr + row * x_row_stride + cols, mask=mask, other=0.0).to(tl.float32)
mean = tl.sum(x, axis=0) / N

前四行和 T2 一样。这里 padding 填的是 0(T2 填 -inf),因为下一步是求和:0 不影响 Σx。注意除以的是真实的 N,不是 BLOCK_N。

陷阱 1:padding 减完均值就不是 0 了

drills27–29 行

xc  = tl.where(mask, x - mean, 0.0)
var = tl.sum(xc * xc, axis=0) / N

padding 位置的 x 是 0,但 x − mean = −mean,平方之后是 mean²,会被加进方差。N = 5、BLOCK_N = 8 时:

x
2
4
4
5
5
0
0
0
x − mean(mean = 4)
−2
0
0
1
1
−4
−4
−4
tl.where(mask, …, 0)
−2
0
0
1
1
0
0
0

正确的方差 = (4 + 0 + 0 + 1 + 1) / 5 = 1.2。不用 tl.where 的话,三个 padding 各贡献 16,方差变成 (6 + 48) / 5 = 10.8,错了 9 倍。而且 N 恰好是 2 的幂时没有 padding,测试发现不了这个 bug。

tl.where(cond, a, b) 是逐元素的三元选择:cond 为 True 取 a,否则取 b。Triton 里没有逐元素的 if,都用它。

陷阱 2:方差用哪个公式

参考解用的是两遍法:先求 mean,再求 Σ(x − mean)²。教科书上还有一个“一遍法”:var = E[x²] − E[x]²,只需要同时累加 Σx 和 Σx²。它在数学上等价,在浮点上可能灾难性地出错:

x = [10001, 10002, 10003],fp32
真实方差 2/3 ≈ 0.667
E[x²] (100020001 + 100040004 + 100060009) / 3 = 100040004.67
E[x]² 10002² = 100040004
问题 fp32 在 1 亿附近的间隔是 8,两个数都只能被存成 8 的倍数(例如 100040008 和 100040000)
一遍法的结果 只能是 0、±8 这样的值:可能是 8(错 12 倍),可能是 0(rstd 爆炸),也可能是负数(√ 之后是 NaN)
两遍法 x − mean = [−1, 0, 1],Σ = 2,÷3 = 0.667 ✓

数据的均值远大于它的波动时(LLM 的激活值里经常出现个别很大的通道),两个很大的数相减,有效数字全被抵消掉了,这叫灾难性抵消。两遍法先把均值减掉,再平方,就没有这个问题。

整行在寄存器里,所以“两遍”只是对寄存器里的数据算两次,不需要多读显存。如果行太长、必须分段从显存读,又想只读一遍,就用 Welford 算法:边读边更新均值和 M2,数值上也是稳定的。PyTorch 的 CUDA LayerNorm 就用它(见第 ⑤ 节)。

类比 · 量珠穆朗玛峰上的一张纸

你想知道一张纸有多厚,于是量“山顶到海平面的高度(含纸)”和“山顶到海平面的高度(不含纸)”,然后相减。两个数都是 8848 米左右,量具的精度是 1 厘米,纸的厚度 0.1 毫米完全淹没在误差里,甚至可能算出负数。正确做法是先站到山顶上(减去均值),再直接量纸(两遍法)。

rstd、仿射变换和写回

drills30–38 行

rstd = 1.0 / tl.sqrt(var + eps)
w = tl.load(w_ptr + cols, mask=mask, other=0.0).to(tl.float32)
b = tl.load(b_ptr + cols, mask=mask, other=0.0).to(tl.float32)
y = xc * rstd * w + b
tl.store(y_ptr + row * y_row_stride + cols, y.to(y_ptr.dtype.element_ty), mask=mask)
tl.store(mean_ptr + row, mean)     # 标量写:每个 program 一个数
tl.store(rstd_ptr + row, rstd)

③ RMSNorm:少一次归约

drillsrms_norm_kernel(41–49 行)

x    = tl.load(..., other=0.0).to(tl.float32)
rstd = 1.0 / tl.sqrt(tl.sum(x * x, axis=0) / N + eps)
w    = tl.load(w_ptr + cols, mask=mask, other=0.0).to(tl.float32)
tl.store(y_ptr + ..., (x * rstd * w).to(...), mask=mask)

不减均值,所以 padding 填 0 之后平方还是 0,不需要 tl.where。没有 b,也不存统计量(这题只要求 forward)。

题目页的实测里 RMSNorm 和 LayerNorm 一样快(N = 8192 时都是 176 us):两者都要读 M×N 的 x、写 M×N 的 y,少一次寄存器里的归约几乎不省时间。memory-bound 的 kernel 只有减少主要数据的字节数才有用。

④ host 端:reshape 和 num_warps

drills52–73 行

x2 = x.reshape(-1, shape[-1])            # [B, S, N] → [B·S, N]
if x2.stride(-1) != 1: x2 = x2.contiguous()
BLOCK_N = triton.next_power_of_2(N)      # 超过 16384 就报错
num_warps = min(max(BLOCK_N // 256, 1), 8)
N BLOCK_N num_warps 每线程的 fp32 个数
768 1024 4 8
4096 4096 8 16
8192 8192 8 32

⑤ 对照真实实现

⑥ 常见坑与自测

自测 1:x = [1, 1, 1, 1],LayerNorm 的 y 是多少?为什么不会除以 0?

mean = 1,xc = [0, 0, 0, 0],var = 0,rstd = 1/√(0 + 1e-5) ≈ 316。y = 0 · 316 · w + b = b。eps 保证了分母不为 0;xc 全是 0,所以 y 就是 bias。

自测 2:同一个 x = [3, 4],LayerNorm(w=1, b=0)和 RMSNorm(w=1)的结果分别是多少?(忽略 eps)

LayerNorm:mean = 3.5,xc = [−0.5, 0.5],var = 0.25,rstd = 2,y = [−1, 1]。 RMSNorm:mean(x²) = (9 + 16)/2 = 12.5,rstd = 1/√12.5 ≈ 0.283,y ≈ [0.849, 1.131]。 RMSNorm 不减均值,所以输出不以 0 为中心,只是把整体尺度归一化。

源码与资料索引

本仓库:drillssolutions/t3_layernorm.py · drillsmine/t3_layernorm.py · drillstests/test_t3_layernorm.py · drillscontent/t3.md

参考实现:tritonTriton tutorial 05 · flashinferFlashInfer Triton norm · flashinferFlashInfer RMSNormKernel · vllmvLLM rms_norm_kernel · pytorchPyTorch RowwiseMomentsCUDAKernel

API 与背景:tl.where · tl.sqrt · Welford 在线方差算法 · LayerNorm 论文 · RMSNorm 论文