RMSNorm 与 GPU 算子优化
一行公式,一个 kernel:从数学推导到跑满 HBM 带宽
按 → 或点击推进动画 · 页面底部的进度条可以拖动回看
目录
- RMSNorm 的公式与几何意义
- 和 LayerNorm 的区别:为什么可以不减均值
- 反向传播:\(\partial L/\partial x\) 与 \(\partial L/\partial g\)
- 数值精度:为什么一定要 FP32 累加
- memory-bound:roofline 上的位置
- eager PyTorch 的 8 个 kernel
- 融合 kernel:行到 block、128-bit 读、warp shuffle、shared memory、寄存器缓存
- 与残差相加、FP8 量化的融合
- Triton 实现
- 反向 kernel 与 dg 的两阶段归约
- 实际能达到的带宽
归一化 hidden 维,gamma 在 token 间共享
R = batch × sequence,D = hidden size
前向按行归约
每个 token 独立计算均方值。γ 是每个通道的可训练增益,广播到所有行;它不是每行一个数。
反向有两个方向
dx 按行归约,因为同一行共享均方根;dγ 沿 token 维求和,因为所有 token 使用同一组 γ。
默认只归一化最后一个维度。多维 normalized_shape 的接口需要先明确归约轴,不能直接沿用一行一个 hidden vector 的地址公式。
RMSNorm:只按均方根缩放
输入是一个 token 的 hidden state:一行 \(d\) 个数(这里画 16 个,数值示意),大小参差不齐。
先逐个平方,再求平均:这就是 mean(x²),均方。
开根号得到 RMS(均方根),它刻画了这一行"整体有多大"。
每个数除以 RMS:忽略很小的 ε 时,这一行的 RMS 被拉回 1,方向不变,只改长度。\(\varepsilon\)(常取 1e-5 或 1e-6)防止除零。
最后逐元素乘可学习的增益 \(g\)(长度 \(d\)),让每个通道自己决定尺度。没有偏置 \(b\)。
平方、求均值、取倒数,再缩放
取 x = [1, 2, 3, 4],γ = [1, 1, 1, 1]。为方便手算,这一页令 ε = 0;实际实现用模型配置里的 ε。
平方和 30,均方值 7.5,r = 1/√7.5 ≈ 0.365148。最终 y ≈ [0.3651, 0.7303, 1.0954, 1.4606]。
LayerNorm 做两件事,RMSNorm 只做一件
同一组数(示意)分别送进 LayerNorm 和 RMSNorm。
LayerNorm 先算均值 \(\mu\);RMSNorm 只算 RMS,不需要均值。
LayerNorm 减掉均值,把这一行挪到 0 附近(re-centering);RMSNorm 跳过这一步。
两边都做缩放(re-scaling):LayerNorm 除以标准差,RMSNorm 除以 RMS。均值恰好为 0 时,两者完全相同。
少一次归约、少一个偏置,效果却与 LayerNorm 相当,运行时间少 7%–64%;LLaMA、Qwen、DeepSeek 等模型都用 RMSNorm。
保留缩放不变性,放弃平移不变性
左边是输入,右边两栏分别是 LayerNorm 与 RMSNorm 的输出(数值示意)。
把输入整体乘 2:分子分母同时翻倍,两种输出都不变。这是缩放不变性:权重或激活整体变大,不会改变归一化后的结果。
把输入整体加 1.5:LayerNorm 减均值时把它抵消了;RMSNorm 的输出跟着变。
实验表明,归一化稳定训练的作用主要来自缩放不变性;平移由前后的线性层自己就能吸收。所以去掉均值几乎不损失效果,却省下一次归约。
Pre-norm Transformer 里的 RMSNorm
残差流(下方横线)一路往前;每个子层先对它做 RMSNorm,再把结果加回残差。
每层两次(attention 前、MLP 前),最后再加一次:Llama 3 8B 有 32 层,一次前向调用 65 次。
Qwen3 这类模型还在每个头的 q、k 上做 RMSNorm(QK-Norm),每层再多两次。
每一次调用都要把整个 [tokens × hidden] 读一遍、写一遍;算子本身很简单,成本几乎全在搬数据。
梯度沿计算图流回来
前向:x 一路直接走到乘法,另一路经过平方、求均值、rsqrt 得到 \(r\);\(\hat x = x\,r\),\(y = \hat x \odot g\)。
梯度 \(dy\) 先到 \(\times g\):一份给 \(g\),要对所有 token 求和;一份给 \(\hat x\)。
\(d\hat x\) 再分两路:直接一路乘 \(r\);另一路经过 \(r\)——\(r\) 依赖整行,所以要先做一次行内归约 \(\sum_j d\hat x_j x_j\)。
两路在 \(x\) 处相加。前向一次行内归约,反向也是一次行内归约,外加一个跨行的 dg 归约。
推导
记 \(r = \big(\tfrac1d\sum_j x_j^2+\varepsilon\big)^{-1/2}\),\(\hat x_i = x_i r\),\(y_i = g_i \hat x_i\)。先求 \(r\) 对 \(x_j\) 的导数:
$$\frac{\partial r}{\partial x_j} = -\tfrac12\Big(\tfrac1d\textstyle\sum x^2+\varepsilon\Big)^{-3/2}\cdot\frac{2x_j}{d} = -\frac{r^3}{d}\,x_j$$
链式法则,记 \(\hat g = dy \odot g\):
$$dx_j = \sum_i \hat g_i\Big(\delta_{ij}\,r + x_i\frac{\partial r}{\partial x_j}\Big) = r\,\hat g_j - \frac{r^3}{d}\,x_j\sum_i \hat g_i x_i = r\Big(\hat g_j - \hat x_j\cdot\tfrac1d\textstyle\sum_i \hat g_i\hat x_i\Big)$$
$$dg_i = \sum_{\text{tokens}} dy_i\,\hat x_i$$
前向把每行的 \(r\)(rstd,一个 FP32)存下来,反向就不必重算那次归约;反向需要的输入是 \(x,\ dy,\ g,\ r\)。
归约项会修正整行的梯度
x = [1, 2],γ = [1, 2],dy = [3, −1],ε = 0。r = 1/√2.5,u = dy⊙γ = [3, −2]。
a = mean(u⊙x) = −0.5;dx = r u − r³ x a ≈ [2.023858, −1.011929]。dγ = dy⊙x r ≈ [1.897367, −1.264911]。
小输入时,ε 会明显改变结果
mean(x²) = 10⁻⁸;y ≈ [0.031607, 0.031607]
把输入乘 10,均方值变成 10⁻⁶,输出约 [0.301511, 0.301511],并没有保持不变。ε 主导时,RMSNorm 更接近一次线性缩放。
写参考实现时
ε 加在均方值里面:rsqrt(mean(x²) + ε)。移到 sqrt 外面、或改用另一套默认值,都会变成不同运算。
检查零输入
x = 0 时 y = 0,但 dx = dy⊙γ/√ε 一般不为零。用“输出为零所以梯度为零”会漏掉真正的导数。
平方和必须用 FP32 累加
往累加器里连加 4096 个 1.0:FP32 得到 4096,BF16 卡在 256 不动了。
BF16 只有 8 位有效数字:在 [256, 512) 里相邻两个可表示数相差 2。256 + 1 = 257 落在正中间,就近取偶回到 256。
真实场景:4096 个标准正态数的平方和约 4140,BF16 顺序累加只得到 2048(一次模拟)。RMS 被低估 √2 倍,输出整体放大 1.42 倍。
FP16 还有溢出:激活里出现 300 这样的值并不罕见,300² = 90000 超过 FP16 上限 65504,直接变成 inf。所以 kernel 一律读入后转 FP32、在 FP32 里累加和缩放,写回时再转回 BF16。
H100:算力上限要看用哪种计算单元
H100 SXM 有 132 个 SM,FP32 CUDA core 峰值约 67 TFLOPS,HBM3 带宽 3.35 TB/s:两者相除约 20 FLOP/字节。RMSNorm 的平方、归约和缩放主要走这条 FP32 路径。
每个 SM 有寄存器与 shared memory,32 个线程组成一个 warp。大矩阵乘走 Tensor Core,BF16 稠密峰值可达 989 TFLOPS;评估归约 kernel,则看 FP32 CUDA core 的算力上限。
RMSNorm 在屋顶的斜坡最底下
Roofline:横轴是算术强度(每字节做多少 FLOP),纵轴是能达到的算力。拐点左边受带宽限制,右边受算力限制。
RMSNorm 每个元素约 4 次运算(平方、累加、乘 r、乘 g),却要读 2 字节、写 2 字节:强度约 1,位于 FP32 拐点 20 左侧。图中屋顶采用 FP32 CUDA core 峰值;Tensor Core 矩阵乘有另一条更高的屋顶。
算一笔账:8192 个 token、d = 4096 时,按峰值估算,搬数据下限约 40 µs,FP32 运算下限约 2 µs。优化 RMSNorm 只有两件事:少搬字节,以及让搬运接近峰值带宽。
4096 × 8192 的矩阵,至少要搬多少?
输入、输出与 γ 为 BF16;γ 足够小且在缓存里复用;每行 r 用 FP32 写出。假设一次读 X、一次写 Y,无 spill。
r = 4R = 16 KiB;γ 冷读约 16 KiB
| 计算项 | 结果 |
|---|---|
| 按约 4RD FLOP 粗估 | 约 134.2 MFLOP |
| FP32 67 TFLOP/s 计算下界 | 约 2.00 μs |
| HBM 3.35 TB/s 传输下界 | 约 40.1 μs |
这不是预测实测延迟;launch、归约、缓存、访存效率都可能抬高耗时。若耗时 50 μs,按 128 MiB 口径的有效带宽约 2.68 TB/s。
逐行执行:8 个 kernel,数据来回跑
# Hugging Face LlamaRMSNorm.forward,拆开写 x32 = x.to(torch.float32) # R2 W4 sq = x32.pow(2) # R4 W4 var = sq.mean(-1, keepdim=True) # R4 var = var + eps # 每行 1 个数 r = torch.rsqrt(var) # 每行 1 个数 xn = x32 * r # R4 W4 xn16 = xn.to(torch.bfloat16) # R4 W2 y = weight * xn16 # R2 W2 # 注释:每个元素读/写的字节数
eager 模式下每个算子是一个独立的 kernel:从 HBM 读输入,算完写回 HBM,下一个 kernel 再读回来。
中间结果都是 FP32,体积是输入的两倍;方差、rsqrt 这些每行一个数的小 kernel 几乎没活,却各占一次 launch。
合计 8 次 launch,每个元素来回搬 36 字节;而这个算子本质上只需要读 x、写 y:4 字节。
一个 kernel:读一次,写一次
融合 kernel 把整行读进 SM,平方累加、归约、乘 \(r\) 和 \(g\) 全在片上完成,中间结果不落 HBM。
每个元素 4 字节(g 只有 d 个数,被所有行共享,几乎全在 L2 命中),流量是 eager 的 1/9,launch 从 8 次变成 1 次。torch.compile 也能生成这样的融合 kernel。
一行交给一个 thread block
输入是 [M, d]:M 个 token,每个 d 维。行与行之间完全独立,所以 grid 就开 M 个 block,一个 block 负责一行,由硬件分派到各个 SM。
block 内部:比如 256 个线程 = 8 个 warp,d = 4096 时每个 warp 分到 512 个元素,每个线程 16 个。
d 较小(比如 ≤ 1024)时,一个 warp 处理一行就够了:归约只在 warp 内做,连 shared memory 都不需要。
128-bit 读:一条指令搬 8 个 bf16
标量读:每个 lane 每条指令拿 1 个 bf16(2 字节)。32 个 lane 相邻,访问是合并的,但 512 字节要发 8 条指令。
向量读(uint4 / LDG.E.128):每个 lane 一次拿连续 16 字节 = 8 个 bf16,一条指令就搬完,访存指令数降到 1/8。
整行 4096 个元素就是 16 次这样的 warp 读:8 个 warp 各读 2 次,每个线程手里 16 个数。要求行首地址 16 字节对齐、d 是 8 的倍数。
128-bit 读写也要处理尾部与对齐
合法的快速路径
每次读 8 个 BF16,对应 16 字节。基址和行起点需满足相应对齐,最后一维连续;按实际 stride 计算每行起始地址。
剩下的元素
D 不是 8 的倍数时,只对完整向量使用宽加载;尾部改用带界限检查的标量/较窄加载,不能越界读后再用 mask 掩盖。
若用 Triton 填到 BLOCK = 2,048,越界 lane 对平方和贡献 0,但均方值必须除以 D = 1,025,不能除以 BLOCK。
蝶形交换:__shfl_xor_sync
每个 lane 先把自己那 16 个数的平方和算好(数值示意)。现在要把 32 个部分和加起来。
第 1 轮 offset = 16:lane \(i\) 读 lane \(i \oplus 16\) 的寄存器并相加。数据直接在寄存器间交换,不经过内存。
第 2 轮 offset = 8:每个 lane 已经代表 2 个 lane 的和,现在合成 4 个。
offset = 4、2:每轮翻倍覆盖范围。
第 5 轮 offset = 1 之后,32 个 lane 全都拿到总和:log₂32 = 5 步,for (o = 16; o > 0; o >>= 1) v += __shfl_xor_sync(0xffffffff, v, o);
跨 warp:经 shared memory 中转
shuffle 只能在 warp 内交换。每个 warp 的 lane 0 把自己的和写进 shared memory 的一个槽位,然后 __syncthreads() 等所有 warp 写完。
warp 0 把 8 个槽位读进自己的 lane,再做一次(更短的)shuffle 归约,得到整行的平方和(数值示意)。
lane 0 算出 \(r = \mathrm{rsqrt}(\Sigma/d + \varepsilon)\),写回 shared memory,再同步一次,所有线程读到同一个 \(r\)。
x 留在寄存器里,只从 HBM 读一次
朴素写法分两遍:第一遍读 x 求平方和,第二遍再读一次 x 去乘 \(r\)。每个元素读 4 字节、写 2 字节。
更好的做法:第一遍读进来的 x 就留在寄存器里,等 \(r\) 算出来直接用。每个元素只读 2 字节、写 2 字节。
代价是寄存器:d = 4096、256 线程时每线程 16 个 FP32,很宽松;d 更大时就得增加线程数、把行暂存在 shared memory,或像 Hopper 上那样用 thread block cluster 把一行分给多个 SM。
每行变宽以后,检查是否 spill
一个 block 的线程要保留自己的 x 分片,还要持有地址、gamma、累加器等状态。线程数固定时,D 翻倍通常会提高每线程寄存器需求。
如果寄存器不够
部分数据 spill 到 local memory,最终又走设备内存路径;本来想避免的第二次读取,可能以更差的访存形式回来。
对比两种策略
A:保留所有 x;B:归约后重新加载 x。B 多读一次,但若显著提高 occupancy、消除 spill,也可能更快。
同时记录 registers/thread、local-memory load/store、活跃 warp 和耗时。只看到源代码“读一次”,并不能证明机器码只访问一次 HBM。
把这几步拼成一个 kernel
__global__ void rmsnorm_fwd(const bf16* x, const bf16* g, bf16* y, float* rstd, int d, float eps) { const int row = blockIdx.x; // 一个 block 一行 const uint4* xr = (const uint4*)(x + (size_t)row * d); float v[ITERS * 8]; // 这一行在寄存器里的份 float ss = 0.f; for (int i = 0; i < ITERS; ++i) { uint4 pk = xr[i * blockDim.x + threadIdx.x]; // 128-bit: 8 个 bf16 unpack8(pk, v + i * 8); // 转 FP32 for (int k = 0; k < 8; ++k) ss += v[i*8+k] * v[i*8+k]; } ss = block_reduce_sum(ss); // shuffle + shared memory const float r = rsqrtf(ss / d + eps); if (threadIdx.x == 0) rstd[row] = r; // 留给反向 for (int i = 0; i < ITERS; ++i) // 用寄存器里的 x store8(y, g, v + i * 8, r, row, i); // y = bf16(x·r·g) }
示意代码(省略了尾部处理和类型细节)。ITERS = d / (8 × blockDim.x),编译期常量,这样 v[] 才能全部放进寄存器。
读:每次 128-bit,转成 FP32 存进寄存器,顺手累加平方。
归约 → rsqrt → 把 \(r\) 存一份给反向,然后用寄存器里的 x 算 \(y\) 写回。整条路径只碰 HBM 两次。
把残差相加和 FP8 量化也并进来
pre-norm 里 RMSNorm 前面总跟着残差相加:h = h + o; x = RMSNorm(h)。分开做要 10 字节/元素,融合后读 h、o,写回 h 和 x:8 字节。
推理时下一层的 GEMM 若用 FP8,还要再量化一次。融合进来后输出直接写 1 字节的 FP8:13 → 7 字节,3 次 launch 变 1 次。
推理引擎里都有这些融合算子,比如 vLLM 的 fused_add_rms_norm、fused_add_rms_norm_static_fp8_quant、rms_norm_dynamic_per_token_quant,以及按 128 元素分块量化的 rms_norm_per_block_quant。
动态 per-token 量化:再做一次行内归约
RMSNorm 的一行输出 \(y\)(数值示意),要转成 FP8 E4M3,它能表示的最大值是 448。
动态 per-token 量化需要这一行的 \(\max|y|\):又一次行内归约(把加法换成 max),整行此时还在寄存器里,几乎不花额外的访存。
\(s = \max|y|/448\),每个数除以 \(s\) 后舍入到 E4M3 网格;网格是非均匀的,越大的数间隔越宽([256, 448] 内间隔 32)。\(s\) 每行一个 FP32,随输出一起写回。
静态量化用离线校准好的 \(s\),省掉这次 max 归约;分块量化则每 128 个元素一个 \(s\),在 block 内分组做 max。
同一个 kernel 的 Triton 写法
@triton.jit def rmsnorm_fwd(X, W, Y, RSTD, stride, N, eps, BLOCK: tl.constexpr): row = tl.program_id(0) # 一个 program 一行 cols = tl.arange(0, BLOCK) # BLOCK = next_pow2(N) mask = cols < N x = tl.load(X + row * stride + cols, mask=mask, other=0.).to(tl.float32) rstd = 1 / tl.sqrt(tl.sum(x * x, axis=0) / N + eps) tl.store(RSTD + row, rstd) w = tl.load(W + cols, mask=mask).to(tl.float32) y = x * rstd * w tl.store(Y + row * stride + cols, y.to(Y.dtype.element_ty), mask=mask) # 启动:M 行就是 M 个 program rmsnorm_fwd[(M,)](x, w, y, rstd, x.stride(0), N, eps, BLOCK=triton.next_power_of_2(N), num_warps=8)
Triton 以"一个 program 处理一块数据"来写:program_id 就是行号,tl.arange 一次覆盖整行。N = 5120 时 BLOCK = 8192,多出来的 3072 个位置被 mask 掉。
tl.load 由编译器向量化;tl.sum 被编译成 warp shuffle + shared memory 归约;整行 x 作为一个张量留在寄存器里。
需要手动选的只剩 num_warps 等少数参数。CUDA 版本的那几页优化,Triton 编译器大都替你做了。
dx:每行再归约一次
反向也是一行一个 program:读 x、dy、g 和前向存下的 \(r\),算 \(\hat x = x r\)、\(\hat g = dy\odot g\),行内归约得到 \(c = \mathrm{mean}(\hat x\odot\hat g)\),写出 \(dx = r(\hat g - \hat x c)\)。
每个元素读 4 字节、写 2 字节。同一个 program 往下处理更多行,\(dy\odot\hat x\) 一直累加在 FP32 寄存器里——这就是 dg 的部分和,最后写回一次。
两阶段:先按 program 求部分和,再按列加起来
dg 是对所有行求和,和行内归约方向正交。让 P 个 program 各自跨步处理行(program p 负责第 p、p+P、p+2P… 行),各自在寄存器里累加。
阶段 1 结束,写出 [P, d] 的 FP32 部分和;第二个小 kernel 按列求和得到 dg。P 一般取 SM 数(H100 上 132),d = 4096 时缓冲区只有 2 MB 左右。
为什么不让每行直接 atomicAdd 到 dg:M 行抢同 d 个地址,冲突严重,而且浮点加法顺序不固定,结果不可复现。另一种做法是让几组行共享一个缓冲区、用锁串行累加,同样是两阶段。
能跑到多少带宽
衡量 memory-bound kernel 用有效带宽:必须搬的字节数 ÷ 耗时。H100 SXM 峰值 3.35 TB/s,实际拷贝也达不到 100%。
精心调优的归约类 kernel(softmax、RMSNorm 等)在 H100 上约 3 TB/s,接近峰值的 90%;编译器生成的版本在超宽的行上会掉到 2 TB/s 左右。eager 实现即使每个 kernel 都跑满带宽,也只有 4/36 的字节是有用的。
token 数少的时候,带宽也跑不满
示意模型:每个 kernel 固定开销 3 µs(launch、启动与收尾),其余时间按 3.0 TB/s 持续带宽搬字节;融合 4 B/元素、1 个 kernel,eager 36 B/元素、8 个 kernel。decode 阶段 M 很小,固定开销占大头,这时把多个算子融合(或用 CUDA Graph)比优化带宽更重要。
用 FP64 验证导数,再比较低精度 kernel
| 输入集合 | 要暴露的问题 |
|---|---|
| D = 1、7、1,025 | 归约宽度、padding、尾部加载 |
| 全零、极小值、300 与 −300 | ε、FP16 平方溢出、有限性 |
| 非连续行 / 非标准 stride | 地址计算与接口约束 |
| 不同 gamma、多个 token | 增益广播、dγ 沿 token 求和 |
| 固定 dy 的中心差分 | dx 的归约项、dγ 的维度 |
先用 float64、h≈10⁻⁶;低精度差分常被舍入淹没。再以同样的转换与 ε 约定比较 BF16/FP16,分别记录最大绝对误差和相对误差。
有效带宽只是一个带假设的指标
# x/y: BF16;gamma 在缓存复用,不计其每行重复读取
bytes_estimate = 4 * rows * hidden
for _ in range(20): y = rms_kernel(x, gamma, eps)
a = torch.cuda.Event(enable_timing=True)
b = torch.cuda.Event(enable_timing=True)
a.record()
for _ in range(100): y = rms_kernel(x, gamma, eps)
b.record(); b.synchronize()
ms = a.elapsed_time(b) / 100
print(ms, "ms", bytes_estimate / (ms * 1e9), "TB/s")rms_kernel 为待测实现;以上需要 CUDA。逐项扫 rows 与 hidden,并记录 warm-cache 或冷缓存策略;数据驻留 L2 时,不能把有效带宽直接当成 HBM 带宽。
先判断是 launch 不够快,还是数据搬不动
| 教学测量 | 推理 | 下一步 |
|---|---|---|
| R=1,D=8192,8 μs | 只搬约 32 KiB,工作量极少 | 查 launch / 调度;考虑融合邻接算子 |
| R=4096,D=8192,50 μs | 约 2.68 TB/s,有效带宽较高 | 改算式的收益有限,评估减少流量 |
| 同形状扩大 D 后突降 | 可能触及寄存器或 block 资源边界 | 查 spill,比较 block/warp 配置 |
| D=1024 正确,1025 结果偏大 | 可能把归约除数错写为 BLOCK | 核对真实 D 与 padding mask |
这些数字是诊断练习的假设观测。评价融合时,把整个 residual → norm → quantize 路径计时,避免仅看到某个 kernel 变快。
用两维输入手算前向与反向
展开参考解答
① r = 1/√2.5 ≈ 0.632456;y = [r, 4r] ≈ [0.632456, 2.529822]。
② u = [3, −2],a = (3−4)/2 = −0.5;dx = ru−r³xa ≈ [2.023858, −1.011929]。
③ dγ = [3r, −2r] ≈ [1.897367, −1.264911];x·dx = 0,对应 ε=0 时的正比例缩放不变性。
先独立计算,再展开解答。
padding 的一个除数,会带来多大误差?
展开参考解答
① 有效元素的平方和是 5;正确均方值为 5/5 = 1,每个有效输出应为 1。
② 错误均方值为 5/8,r = √(8/5) ≈ 1.264911;所有有效输出被放大约 26.49%。
③ padding mask 只负责让无效 lane 贡献 0;归约分母仍须用真实 hidden size。
先独立计算,再展开解答。
50 μs 的结果还有多少带宽空间?
展开参考解答
① 128 MiB = 134,217,728 B;带宽 = 134,217,728 / (50×10⁻⁶) ≈ 2.684 TB/s,约峰值的 80.1%。
② 理想下界为 40.1 μs;在相同流量口径下,50/40.1≈1.25×,对应延迟再降约 19.9%。
③ 若数据命中 L2、gamma 被重复读、出现 spill,账本就会变。要先核对流量口径,再解释百分比。
先独立计算,再展开解答。
一次运行,检查 dx 与 dgamma
python3 lab.py --width 7 --eps 1e-5
# 再试:--width 1、--width 13、--eps 0.01脚本实现前后向,用中心差分验证每个输入和 gamma 的导数,还检查手算结果、全零输入、非 2 次幂的 padding 与 FP32 平方。
通过后打印 PASS
修改题:把 mean(u*x) 改成 sum(u*x),或让 padding 的归约除以 BLOCK。先预测错误如何随 D 变化,再运行检查。
下载 lab.py · NumPy / CPU小结
- RMSNorm = 除以均方根再乘增益;不减均值,通常不加偏置;忽略 ε 时,对输入的正比例缩放保持不变。
- 反向:\(dx = r(\hat g - \hat x\cdot\mathrm{mean}(\hat g\odot\hat x))\),\(dg = \sum_{\text{tokens}} dy\odot\hat x\);前向存下每行的 \(r\)。
- 平方和一定在 FP32 里累加:BF16 顺序连加 1 会在 256 停住,FP16 平方可能溢出。
- 大张量时算术强度约 1 FLOP/B,受带宽限制:目标是每元素只读 2 字节、写 2 字节,并接近峰值带宽。
- 做法:一行一个 block(或 warp)、128-bit 读、warp shuffle + shared memory 归约、x 留在寄存器里。
- 再往前一步是和残差相加、FP8 量化融合:13 → 7 字节/元素,3 次 launch 变 1 次。
- 反向的 dg 是跨行归约:P 个 program 各求部分和,第二个 kernel 按列相加。
参考与延伸阅读
- Zhang & Sennrich, Root Mean Square Layer Normalization(NeurIPS 2019):RMSNorm 的提出、不变性分析与 7%–64% 的耗时节省
- Ba et al., Layer Normalization(2016)
- Triton 官方教程 Layer Normalization:前向、反向与带锁的 dw 部分和缓冲区
- vLLM layernorm_kernels.cu 与 layernorm_quant_kernels.cu:残差融合与 FP8 量化融合
- Guo, Zadouri & Dao, Getting Memory-bound Kernels to Speed-of-Light 与 QuACK:H100 上约 3 TB/s 的归约 kernel、cluster 归约
- NVIDIA CUDA C++ Programming Guide:Warp Shuffle Functions;H100 规格见 NVIDIA H100
- Liger Kernel:训练用的 Triton RMSNorm 前向 / 反向
- 本系列:Roofline:算力、带宽与算术强度,GPU 内核与 FlashAttention,FlashAttention 1、2、3
下一篇:Gradient checkpointing
训练时激活值占掉的显存,能不能用算力换回来