---
title: "第 5 课:LayerNorm:均值、方差与数值稳定"
jupyter: python3
---
## 本课目标与完成标准
学完后你应能:实现 LayerNorm 统计量并识别 E[x²]-E[x]² 的消减风险。
通过必须同时满足:
- 独立补齐本课唯一的代码填空题,并通过给定检查;
- 三个问答题均说明因果链,而不是只报术语;
- 能指出至少一个正确性边界和一个性能取舍;
- 总分不低于 8/10,且没有一票否决级概念错误。
## 前置关系
- 课程前置:C/C++、线性代数、基本并行编程
- 本课在路线中的作用:LayerNorm 对一行计算均值和方差,再做仿射变换;不同 token 的行互相独立。
## 核心心智模型
### 1. 它是什么,解决什么问题
**LayerNorm (层归一化)** 是 Transformer 架构中最核心的归一化算子之一。与 BatchNorm 跨样本(Batch 维度)归约不同,LayerNorm 在单个样本的单个 Token 内部,沿隐藏特征维度(Hidden Dimension $N$)独立计算均值与方差,并进行仿射重缩放:
$$y_i = \frac{x_i - \mu}{\sqrt{\sigma^2 + \epsilon}} \cdot \gamma_i + \beta_i$$
其中 $\mu = \frac{1}{N}\sum_{i=1}^N x_i$,$\sigma^2 = \frac{1}{N}\sum_{i=1}^N (x_i - \mu)^2$,$\gamma$ 和 $\beta$ 为可学习的通道缩放与平移参数,$\epsilon$ 为防止除以零的微小正数(通常取 $10^{-5}$ 或 $10^{-6}$)。
**解决的核心痛点**:
- **消除 Batch 维度依赖**:NLP 与 LLM 推理场景中 Batch Size 经常动态变化(甚至为 1),BatchNorm 无法稳定统计全局矩,而 LayerNorm 保证不同 Token 的行处理完全正交独立;
- **稳定深层梯度传播**:将激活分布强制拉回到零均值、单位方差,抑制深层残差网络激活值随网络层数加深而发生的数值爆炸(Exploding)或弥散(Vanishing)。
### 2. 它如何工作(结合图 5-1 与图 5-2)
在 GPU 上,典型的算子映射策略为 **One Block per Row(一个 ThreadBlock 负责一行,即一个 Token 的 $N$ 个通道)**:
1. **第一阶段:合并读入与两矩局部累加**
- Block 内所有线程以 Grid-Stride / Stride 方式(`col = tid; col < N; col += BLOCK_SIZE`)遍历整行,相邻线程读取连续内存地址,确保 Global Memory 读取完全合并(Coalesced 128-byte transactions);
- 每个线程在寄存器中维护局部累加标量 `local_sum += x` 与 `local_sq_sum += x * x`。
2. **第二阶段:双层层次化归约与广播(图 5-2)**
```{=html}
<div class="fd fd-seq" role="img"><div class="sd-grid" style="--n:3"><div class="sd-life" style="grid-column:1;grid-row:1/8"></div><div class="sd-life" style="grid-column:2;grid-row:1/8"></div><div class="sd-life" style="grid-column:3;grid-row:1/8"></div><div class="sd-actor" style="grid-column:1;grid-row:1">Block 线程组</div><div class="sd-actor" style="grid-column:2;grid-row:1">Shared Memory (32 槽双标量)</div><div class="sd-actor" style="grid-column:3;grid-row:1">全局显存 (HBM)</div><div class="sd-note sd-note-over" style="grid-column:1/2;grid-row:2;--k:1">线程先在局部寄存器累加 (sum, sq_sum)</div><div class="sd-msg sd-self" style="grid-column:1/span 2;grid-row:3;--k:2"><div class="sd-text"><span class="sd-num">1</span>Warp 内树状归约 sum 与 sq_sum (__shfl_down_sync)</div><div class="sd-loop"></div></div><div class="sd-msg sd-r sd-h-arrow" style="grid-column:1/3;grid-row:4;--k:2"><div class="sd-text"><span class="sd-num">2</span>各 Warp Lane 0 写入局部 (sum, sq_sum)</div><div class="sd-line"></div></div><div class="sd-note sd-note-over" style="grid-column:2/3;grid-row:5;--k:1">__syncthreads() 块内同步</div><div class="sd-msg sd-l sd-h-arrow" style="grid-column:1/3;grid-row:6;--k:2"><div class="sd-text"><span class="sd-num">3</span>Warp 0 聚合整行 (sum, sq_sum),Thread 0 算 mean 与 var = E[x²] - mean² 并广播</div><div class="sd-line"></div></div><div class="sd-msg sd-r sd-h-arrow" style="grid-column:1/4;grid-row:7;--k:3"><div class="sd-text"><span class="sd-num">4</span>二次遍历写回归一化值 y = (x - mean) / sqrt(var + eps) * gamma + beta</div><div class="sd-line"></div></div></div></div>
```
<p class="caption" align="center"><em>图 5-2:LayerNorm 线程级统计量归约与输出写回流程</em></p>
本课代码采用两矩形式(只传递 `sum` 与 `sq_sum`)。数值上更稳妥的做法是 Welford:每个线程维护 $(\text{count}, \text{mean}, M_2)$,Warp/Block 归约时用 Chan 公式合并,代价见第 4 节。
- **Warp 级归约**:利用 `__shfl_down_sync` 寄存器洗牌指令,每个 32 线程 Warp 用 5 步 shuffle 完成树状归约,无任何 Shared Memory 开销;
- **Block 级归约**:各 Warp 的 Lane 0 将局部结果存入 Shared Memory,再由 Warp 0 进行二次归约,得到整行全局 $\sum x_i$ 和 $\sum x_i^2$;
- **统计量发布**:Thread 0 计算标量 $\mu = \frac{\text{sum}}{N}$ 与 $\text{var} = \frac{\text{sq\_sum}}{N} - \mu^2$,计算倒数标准差 $\text{rstd} = \frac{1}{\sqrt{\max(\text{var}, 0) + \epsilon}}$,并写回 Shared Memory。调用 `__syncthreads()` 同步栅障,确保全 Block 线程均可读到最新统计量。
3. **第三阶段:归一化变换与合并写回**
- 线程再次按 Stride 访问该行输入 $x$,从 Shared Memory 广播读取 $\mu$ 和 $\text{rstd}$,读取对应的权重 $\gamma[\text{col}]$ 和偏置 $\beta[\text{col}]$;
- 计算 $y = (x - \mu) \cdot \text{rstd} \cdot \gamma + \beta$,并将最终结果写回 Global Memory。
### 3. 正确性条件与常见误区
1. **灾难性数值抵消(Catastrophic Cancellation)**:
- 朴素两矩法利用公式 $\text{var} = \mathbb{E}[x^2] - (\mathbb{E}[x])^2$。当输入数据具有“大均值、小方差”特征(例如 $x_i \approx 10000$ 且波动仅为 $0.01$)时,$\mathbb{E}[x^2] \approx 10^8$,$(\mathbb{E}[x])^2 \approx 10^8$。在 IEEE 754 单精度浮点数(24 位有效尾数,约 7 位十进制精度)下,两个相近的大数相减会导致有效数字严重丢失,甚至因舍入误差出现 $\text{var} = -10^{-7}$。
- **强制不变量**:若采用朴素两矩法,计算方差时**必须使用 `fmaxf(var, 0.0f)` 截断负数**。否则若 $\text{var} < -\epsilon$,根号下为负数将导致 `rsqrtf` 产生 `NaN`,污染后续所有网络计算。
2. **Shared Memory 重复使用的死锁与数据冒险**:
- `block_reduce_sum` 函数中在写入 `smem` 和读取 `smem` 之间,以及函数返回前必须有严格的 `__syncthreads()`。如果连续两次调用 `block_reduce_sum`(先算 sum,后算 sq_sum)而中间缺少栅障,后一个调用的写入可能覆盖前一个调用尚未读完的数据,形成 WAR(Write-After-Read)竞争,结果错误。
### 4. 性能与工程取舍
1. **朴素两矩法 vs. Welford 在线归约(图 5-1)**:
```{=html}
<div class="fd fd-flow" role="img"><div class="fd-stack"><div class="fd-comp fd-lr"><div class="fd-sg" data-nid="Welford_Flow" data-depth="0"><div class="fd-sg-title">Welford 在线单遍递推</div><div class="fd-stack"><div class="fd-comp fd-td"><div class="fd-rank"><div class="fd-node fd-box" data-nid="X">新样本 x_n</div></div><div class="fd-gap" style="--gl:44px;--gt:38px"></div><div class="fd-rank"><div class="fd-node fd-box" data-nid="Delta">delta = x_n - mean_{n-1}</div></div><div class="fd-gap" style="--gl:44px;--gt:38px"></div><div class="fd-rank"><div class="fd-node fd-box" data-nid="Mean">mean_n = mean_{n-1} + delta / n</div></div><div class="fd-gap" style="--gl:44px;--gt:38px"></div><div class="fd-rank"><div class="fd-node fd-box" data-nid="M2">M2_n = M2_{n-1}<br>+ delta * (x_n - mean_n)</div></div><div class="fd-gap" style="--gl:44px;--gt:38px"></div><div class="fd-rank"><div class="fd-node fd-box" data-nid="Var">方差: var = M2_n / n<br>(避免相近大数相减)</div></div></div></div></div></div></div><i class="fd-e" data-s="X" data-t="Delta" data-k="solid" data-h="1" data-b="0"></i><i class="fd-e" data-s="Delta" data-t="Mean" data-k="solid" data-h="1" data-b="0"></i><i class="fd-e" data-s="Mean" data-t="M2" data-k="solid" data-h="1" data-b="0"></i><i class="fd-e" data-s="M2" data-t="Var" data-k="solid" data-h="1" data-b="0"></i></div>
```
<p class="caption" align="center"><em>图 5-1:Welford 在线数值稳定递推均值与方差架构</em></p>
- **朴素法**:只需维护两个浮点累加器,Warp 洗牌只需 2 次 `float` 传递,指令吞吐极高,但数值稳定性脆弱;
- **Welford 算法**:维护三元组 $(\text{count}, \mu, M_2)$,通过 Chan 结合律算法($\delta = \mu_B - \mu_A, \dots$)在 Warp 间传递合并。计算增量差,避免了相近大数相减,极其适合 FP16/BF16 混合精度;但每次归约需要传递 3 个标量并执行多次乘除法,寄存器压力和指令数增加约 2~3 倍。
2. **两遍访存 vs. 寄存器暂存(SRAM Staging)**:
- 当前基础版为 Two-Pass 访问 Global Memory(第一 pass 读数求均方差,第二 pass 再读数写输出);
- 若隐藏维度 $N \le 1024$ 且寄存器充裕,可在第一 pass 读入时将数据缓存在每个线程的私有寄存器数组中,避免第二 pass 重新打满 HBM 显存带宽,但会牺牲 Occupancy。
## 具体演示:灾难性数值抵消的微观实证
考虑极小向量 $x = [10000.0, 10001.0]$,维度 $N = 2$:
- 真实数学计算:
$$\mu = 10000.5, \quad \sigma^2 = \frac{(10000 - 10000.5)^2 + (10001 - 10000.5)^2}{2} = 0.25$$
- 朴素两矩法(FP32 模拟):
$$x_1^2 = 1.0\times 10^8, \quad x_2^2 = 1.00020001\times 10^8$$
$$\mathbb{E}[x^2] = 100010000.5$$
$$\mu^2 = (10000.5)^2 = 100010000.25$$
两数相减 $\mathbb{E}[x^2] - \mu^2 = 0.25$。在 FP32(尾数精度仅能精确表示约 7 位十进制数)下,$100010000$ 已经超出单精度完全精确表示的极限(单精度最大连续整数为 $2^{24} = 16,777,216$),因此末尾数字发生截断舍入,相减结果极易沦为 0 甚至是负数。这也是 PyTorch、Apex 等库的 LayerNorm 采用 Welford 算法的主要原因。
## 实践任务:唯一代码填空题
补齐方差表达式,并解释为何随后要 fmaxf。
规则:只能修改 `TODO`/`______` 所在位置;不要删除断言或放宽误差。代码注释说明了每个边界条件。
```{python}
#| course_role: exercise
%%writefile /tmp/05_layernorm.cu
#include <cuda_runtime.h>
namespace {
// 面试重点 1:warp 内 reduce 不需要 shared memory。
// 一个 warp 内 32 个线程可以通过 shuffle 指令直接交换寄存器里的值,
// 比写 shared memory 再同步更轻。
template<int BLOCK_SIZE>
__device__ __forceinline__ float warp_reduce_sum(float val) {
#pragma unroll
for (int offset = 16; offset > 0; offset >>= 1) {
val += __shfl_down_sync(0xffffffff, val, offset);
}
return val;
}
// 面试重点 2:block reduce 通常分两层:
// 1. 每个 warp 先在寄存器里 reduce 出一个 partial sum。
// 2. 每个 warp 的 lane 0 把 partial sum 写入 shared memory。
// 3. warp 0 再把这些 warp partial sum reduce 成 block 级结果。
template<int BLOCK_SIZE>
__device__ __forceinline__ float block_reduce_sum(float val) {
constexpr int NUM_WARPS = (BLOCK_SIZE + 31) / 32;
__shared__ float smem[NUM_WARPS];
int lane = threadIdx.x & 31;
int warp = threadIdx.x >> 5;
val = warp_reduce_sum<BLOCK_SIZE>(val);
if (lane == 0) {
smem[warp] = val;
}
__syncthreads();
// 只有前 NUM_WARPS 个线程需要读取 shared memory。
// 这些线程都在 warp 0 里,因此后续仍然可以用 warp reduce。
val = (threadIdx.x < NUM_WARPS) ? smem[lane] : 0.0f;
if (warp == 0) {
val = warp_reduce_sum<BLOCK_SIZE>(val);
}
// 这个同步保证同一个 kernel 里连续调用 block_reduce_sum 时,
// 下一次调用不会覆盖上一次还没读完的 smem。
__syncthreads();
return val;
}
// LayerNorm: 对每一行独立做归一化。
// input/output: [M, N] row-major
// gamma/beta: [N]
//
// 数学公式:
// mean = sum(x_i) / N
// var = sum(x_i^2) / N - mean^2
// y_i = (x_i - mean) / sqrt(var + eps) * gamma_i + beta_i
//
// 优化思路:
// 一个 block 负责一整行,block 内线程并行扫 N 个元素。
// 每个线程先做局部累加,再通过 warp reduce + smem 得到整行统计量。
template<int BLOCK_SIZE>
__global__ void layernorm_kernel(
const float* __restrict__ input,
const float* __restrict__ gamma,
const float* __restrict__ beta,
float* __restrict__ output,
int M,
int N,
float eps
) {
int row = blockIdx.x;
int tid = threadIdx.x;
if (row >= M) return;
const float* row_in = input + row * N;
float* row_out = output + row * N;
float local_sum = 0.0f;
float local_sq_sum = 0.0f;
// 每个线程用 stride 方式处理多个列。
// col 连续分配给 threadIdx.x,能保证同一轮访问时线程读连续地址,
// 也就是 coalesced global memory load。
for (int col = tid; col < N; col += BLOCK_SIZE) {
float x = row_in[col];
local_sum += x;
local_sq_sum += x * x;
}
// 两个 reduce 分别得到 sum(x) 和 sum(x^2)。
float sum = block_reduce_sum<BLOCK_SIZE>(local_sum);
float sq_sum = block_reduce_sum<BLOCK_SIZE>(local_sq_sum);
// mean 和 inv_std 是整行共享的标量。
// 只让 tid 0 计算一次,然后放到 shared memory 广播给全 block。
__shared__ float smem_mean;
__shared__ float smem_inv_std;
if (tid == 0) {
float mean = sum / static_cast<float>(N);
float var = ______; // TODO: E[x^2] - E[x]^2
// fmaxf 防止浮点舍入导致 var 变成很小的负数。
smem_mean = mean;
smem_inv_std = rsqrtf(fmaxf(var, 0.0f) + eps);
}
__syncthreads();
float mean = smem_mean;
float inv_std = smem_inv_std;
// 第二次扫这一行,写最终归一化结果。
// LayerNorm 至少需要先知道整行 mean/var,所以写输出通常是第二 pass。
for (int col = tid; col < N; col += BLOCK_SIZE) {
float y = (row_in[col] - mean) * inv_std;
row_out[col] = y * gamma[col] + beta[col];
}
}
} // namespace
// 简洁 launch wrapper:面试时可以先固定 BLOCK_SIZE=256,
// 后续再讨论如何根据 N 和 occupancy 调参。
void launch_layernorm(
const float* input,
const float* gamma,
const float* beta,
float* output,
int M,
int N,
float eps,
cudaStream_t stream
) {
constexpr int BLOCK_SIZE = 256;
layernorm_kernel<BLOCK_SIZE><<<M, BLOCK_SIZE, 0, stream>>>(
input, gamma, beta, output, M, N, eps
);
}
```
### 检查方法
有 CUDA 环境时执行 `nvcc -std=c++17 -c /tmp/05_layernorm.cu -o /tmp/05_layernorm.cu.o`;无 CUDA 环境时可运行下方纯 Python/NumPy 仿真单元进行算法与数值边界验证。
### 无 GPU 环境纯 Python/NumPy 仿真验证(即时可运行)
若本地暂无 NVIDIA GPU 或 CUDA 工具链,可直接运行以下纯 Python 代码,模拟 GPU 两矩归约流与 `fmaxf` 边界保护:
```{python}
import numpy as np
def layernorm_two_pass_sim(x, gamma, beta, eps=1e-5):
"""模拟 GPU Block 两矩扫描、fmaxf 截断与仿射变换"""
M, N = x.shape
out = np.zeros_like(x)
for i in range(M):
row = x[i]
# Pass 1: 两矩求和
mean = np.sum(row) / N
sq_mean = np.sum(row * row) / N
var = sq_mean - mean * mean
# 边界防护: 必须使用 fmaxf(var, 0.0f) 拦截灾难性抵消产生的微小负数
var_safe = max(var, 0.0)
inv_std = 1.0 / np.sqrt(var_safe + eps)
# Pass 2: 仿射写回
out[i] = (row - mean) * inv_std * gamma + beta
return out
# 仿真测试: 模拟可能诱发精度截断的大偏移输入
np.random.seed(42)
mock_x = np.random.randn(4, 256).astype(np.float32) + 1000.0
mock_g = np.ones(256, dtype=np.float32)
mock_b = np.zeros(256, dtype=np.float32)
sim_out = layernorm_two_pass_sim(mock_x, mock_g, mock_b)
assert np.allclose(np.mean(sim_out, axis=1), 0.0, atol=1e-4), "均值归零校验失败!"
assert np.allclose(np.var(sim_out, axis=1), 1.0, atol=1e-3), "方差归一校验失败!"
print("LayerNorm 跨平台纯 Python 仿真验证 100% 通过!")
```
提交时请给出:补齐后的代码、实际运行输出(环境不可用时注明“仅静态审查”)以及对失败用例的解释。
### Q1
不要背定义:请从输入、状态变化和输出三个阶段解释“LayerNorm:均值、方差与数值稳定”的工作机制。
**你的答案:**
### Q2
方差出现 -1e-7 时直接 rsqrt 会发生什么?
**你的答案:**
### Q3
何时应把两矩统计改成 Welford,代价是什么?
**你的答案:**
## 评分与通过规则
- 代码 4 分:正常输入 2 分,边界输入 1 分,解释实现 1 分;
- Q1~Q3 各 2 分;
- 一票否决:结果碰巧正确但核心因果链错误、删除边界检查、把未运行结果说成实测。
需要提示时按四级机制请求:概念区域 → 具体方向 → 关键局部 → 完整答案。
::: {.callout-tip collapse="true" title="💡 点击展开参考答案与详细代码实现" icon="false"}
## 参考答案(仅 answer 分支)
先完成题目再核对。即使代码一致,也要能解释关键步骤,并尝试更换一个输入规模。
```{python}
#| course_role: solution
%%writefile /tmp/05_layernorm.cu
#include <cuda_runtime.h>
namespace {
// 面试重点 1:warp 内 reduce 不需要 shared memory。
// 一个 warp 内 32 个线程可以通过 shuffle 指令直接交换寄存器里的值,
// 比写 shared memory 再同步更轻。
template<int BLOCK_SIZE>
__device__ __forceinline__ float warp_reduce_sum(float val) {
#pragma unroll
for (int offset = 16; offset > 0; offset >>= 1) {
val += __shfl_down_sync(0xffffffff, val, offset);
}
return val;
}
// 面试重点 2:block reduce 通常分两层:
// 1. 每个 warp 先在寄存器里 reduce 出一个 partial sum。
// 2. 每个 warp 的 lane 0 把 partial sum 写入 shared memory。
// 3. warp 0 再把这些 warp partial sum reduce 成 block 级结果。
template<int BLOCK_SIZE>
__device__ __forceinline__ float block_reduce_sum(float val) {
constexpr int NUM_WARPS = (BLOCK_SIZE + 31) / 32;
__shared__ float smem[NUM_WARPS];
int lane = threadIdx.x & 31;
int warp = threadIdx.x >> 5;
val = warp_reduce_sum<BLOCK_SIZE>(val);
if (lane == 0) {
smem[warp] = val;
}
__syncthreads();
// 只有前 NUM_WARPS 个线程需要读取 shared memory。
// 这些线程都在 warp 0 里,因此后续仍然可以用 warp reduce。
val = (threadIdx.x < NUM_WARPS) ? smem[lane] : 0.0f;
if (warp == 0) {
val = warp_reduce_sum<BLOCK_SIZE>(val);
}
// 这个同步保证同一个 kernel 里连续调用 block_reduce_sum 时,
// 下一次调用不会覆盖上一次还没读完的 smem。
__syncthreads();
return val;
}
// LayerNorm: 对每一行独立做归一化。
// input/output: [M, N] row-major
// gamma/beta: [N]
//
// 数学公式:
// mean = sum(x_i) / N
// var = sum(x_i^2) / N - mean^2
// y_i = (x_i - mean) / sqrt(var + eps) * gamma_i + beta_i
//
// 优化思路:
// 一个 block 负责一整行,block 内线程并行扫 N 个元素。
// 每个线程先做局部累加,再通过 warp reduce + smem 得到整行统计量。
template<int BLOCK_SIZE>
__global__ void layernorm_kernel(
const float* __restrict__ input,
const float* __restrict__ gamma,
const float* __restrict__ beta,
float* __restrict__ output,
int M,
int N,
float eps
) {
int row = blockIdx.x;
int tid = threadIdx.x;
if (row >= M) return;
const float* row_in = input + row * N;
float* row_out = output + row * N;
float local_sum = 0.0f;
float local_sq_sum = 0.0f;
// 每个线程用 stride 方式处理多个列。
// col 连续分配给 threadIdx.x,能保证同一轮访问时线程读连续地址,
// 也就是 coalesced global memory load。
for (int col = tid; col < N; col += BLOCK_SIZE) {
float x = row_in[col];
local_sum += x;
local_sq_sum += x * x;
}
// 两个 reduce 分别得到 sum(x) 和 sum(x^2)。
float sum = block_reduce_sum<BLOCK_SIZE>(local_sum);
float sq_sum = block_reduce_sum<BLOCK_SIZE>(local_sq_sum);
// mean 和 inv_std 是整行共享的标量。
// 只让 tid 0 计算一次,然后放到 shared memory 广播给全 block。
__shared__ float smem_mean;
__shared__ float smem_inv_std;
if (tid == 0) {
float mean = sum / static_cast<float>(N);
float var = sq_sum / static_cast<float>(N) - mean * mean;
// fmaxf 防止浮点舍入导致 var 变成很小的负数。
smem_mean = mean;
smem_inv_std = rsqrtf(fmaxf(var, 0.0f) + eps);
}
__syncthreads();
float mean = smem_mean;
float inv_std = smem_inv_std;
// 第二次扫这一行,写最终归一化结果。
// LayerNorm 至少需要先知道整行 mean/var,所以写输出通常是第二 pass。
for (int col = tid; col < N; col += BLOCK_SIZE) {
float y = (row_in[col] - mean) * inv_std;
row_out[col] = y * gamma[col] + beta[col];
}
}
} // namespace
// 简洁 launch wrapper:面试时可以先固定 BLOCK_SIZE=256,
// 后续再讨论如何根据 N 和 occupancy 调参。
void launch_layernorm(
const float* input,
const float* gamma,
const float* beta,
float* output,
int M,
int N,
float eps,
cudaStream_t stream
) {
constexpr int BLOCK_SIZE = 256;
layernorm_kernel<BLOCK_SIZE><<<M, BLOCK_SIZE, 0, stream>>>(
input, gamma, beta, output, M, N, eps
);
}
```
### Q1 参考答案
- **输入**:行优先矩阵 $[M, N]$,一个 Block 处理一行;线程按 `col = tid; col < N; col += BLOCK_SIZE` 合并读取,在寄存器中累加 `local_sum` 与 `local_sq_sum`。
- **状态变化**:Warp 内 5 步 `__shfl_down_sync` 加上跨 Warp 的 Shared Memory 归约得到整行 $\sum x$ 与 $\sum x^2$;Thread 0 计算 $\mu$、$\sigma^2 = rac{\sum x^2}{N} - \mu^2$,用 `fmaxf(var, 0.0f)` 截断负数后求 `inv_std`,写入 Shared Memory 并 `__syncthreads()`(详见核心概念第 2 节)。
- **输出**:线程再次读取 $x_i$,计算 $y_i = (x_i - \mu) \cdot ext{inv\_std} \cdot \gamma_i + eta_i$ 并合并写回。
### Q2 参考答案
若方差计算结果为 $-10^{-7}$,直接调用 `rsqrtf(var + eps)` 会根据 $\epsilon$ 的取值导致以下致命问题:
1. **若 $\epsilon \le 10^{-7}$(例如某些算子库中取 $\epsilon = 10^{-8}$ 或被省略)**:
- 根号内的输入参数 $\text{var} + \epsilon < 0$;
- 依据 IEEE 754 浮点运算标准,对负实数开平方或求平方根倒数直接触发“Domain Error”,硬件直接返回 `NaN`(Not a Number);
- 一旦该 Token 的归一化尺度变为 `NaN`,该行所有输出元素 $y_i$ 全部变为 `NaN`。在前向传播的下一层矩阵乘法中,`NaN` 会像病毒一样蔓延扩散至整个 Batch 和整个激活张量;而在反向传播中会导致梯度张量全为 `NaN`,导致模型权重瞬间崩塌崩溃。
2. **正确防线**:必须通过 `fmaxf(var, 0.0f)` 或 `fmaxf(var, 0.0f) + eps`,用一条 max 指令截断所有因舍入误差造成的微小负数,确保被开方数恒大于等于 $\epsilon$。
### Q3 参考答案
1. **何时应将两矩统计换为 Welford 算法**:
- **大均值小方差分布**:当输入张量的基准均值绝对值远大于其特征波动(例如激活值基准偏移高达几千,而各通道之间的方差仅为 $0.01$ 时),两矩法中 $\mathbb{E}[x^2]$ 与 $(\mathbb{E}[x])^2$ 相差微小,在有限浮点有效位下发生灾难性抵消,丢失几乎所有精度;
- **低精度量化与训练(FP16/BF16)**:FP16 只有 11 位有效精度(尾数 10 位),BF16 只有 8 位有效精度。低精度下两矩平方项极易发生溢出(Overflow,超过 FP16 最大值 65504)或大数吃小数。此时必须采用 Welford 递推或在线增量法。
2. **改换为 Welford 的代价**:
- **状态传输与寄存器开销翻倍**:朴素两矩法在 Warp 洗牌时只需要传递 2 个 `float`;而 Welford 结合律(Chan's parallel merge)需要同时维护并传递三元组 $(\text{count}, \mu, M_2)$,寄存器占用和洗牌通信量增加 50%~100%;
- **计算指令复杂度增加**:合并两组统计量时需要计算 $\delta = \mu_B - \mu_A$、除法权重比值 $\frac{n_B}{n_A + n_B}$ 以及二阶矩修正项 $\delta^2 \frac{n_A n_B}{n_A + n_B}$,运算指令数远多于朴素累加,在某些计算受限场景下可能会轻微增加 Kernel 执行延迟。工程上需要根据隐藏层宽度与数值精度严密权衡。
## 参考资料
- [CUDA Programming Guide](https://docs.nvidia.com/cuda/cuda-programming-guide/)
- [CUDA Best Practices Guide](https://docs.nvidia.com/cuda/cuda-c-best-practices-guide/)
资料用于建立事实基线;面试回答仍需用自己的语言组织。
:::
::: {.course-pagination}
[**← 上一课**<br>第 4 课:RMSNorm](lesson04_rmsnorm.qmd){.pagination-link .pagination-prev}
[**下一课 →**<br>第 6 课:稳定 Softmax 与朴素复杂...](lesson06_softmax_naive.qmd){.pagination-link .pagination-next}
:::