2、数值计算基础:浮点数表示(FP32、FP16、BF16)、精度损失来源、舍入误差与累积误差

混合精度训练之所以会出现“精度异常”,根源并不在框架本身,而在于浮点数这一底层数值表示方式。要理解为什么把 FP32 换成 FP16 后 loss 会突然变成 NaN,或者为什么 BF16 更“稳”但收敛更慢,必须先回到 IEEE 754 浮点数的位布局上。本节从位结构出发,梳理 FP32、FP16、BF16 的差异,再逐层分析精度损失的来源,最后落到舍入误差与累积误差的机制上。

一、浮点数的位布局:符号、指数、尾数

一个浮点数由三部分组成:符号位 S、指数位 E、尾数位 M。其数值可近似表示为:

value = (-1)^S × 1.M × 2^(E - bias)

FP32: 1 位符号 + 8 位指数 + 23 位尾数 = 32 bit
FP16: 1 位符号 + 5 位指数 + 10 位尾数 = 16 bit
BF16: 1 位符号 + 8 位指数 +  7 位尾数 = 16 bit

关键结论是:指数位决定动态范围,尾数位决定有效精度。FP16 只有 5 位指数,动态范围约 6×10⁻⁵ 到 65504;BF16 保留了 FP32 的 8 位指数,动态范围与 FP32 一致,但尾数只有 7 位,精度明显低于 FP16。这就是“BF16 不易溢出、FP16 精度更高”这一常见说法的数值根源。

FP32 / FP16 / BF16 位布局与动态范围对比 FP32 S 指数 8 位 尾数 23 位 范围 ~1e-38 ~ 3e38 FP16 S 指数 5 位 尾数 10 位 范围 ~6e-5 ~ 65504 BF16 S 指数 8 位 尾数 7 位 范围同 FP32,精度较低 精度低 精度高 BF16 FP16 FP32 指数位 → 动态范围;尾数位 → 有效精度

二、精度损失的三大来源

在混合精度训练中,精度损失并非单一原因,而是三类机制叠加的结果:

  • 表示精度不足(量化损失):FP16 尾数仅 10 位,能表示约 3~4 位十进制有效数字。当参数更新量小于当前数值的“最小可分辨间隔”时,更新会被直接吞掉。
  • 动态范围溢出(上溢/下溢):FP16 最大约 65504,梯度平方、attention 分数、大 batch 的 loss 累加极易上溢为 Inf;而小于 6×10⁻⁵ 的小梯度会下溢为 0,导致参数“停止学习”。
  • 舍入模式引入的偏差:IEEE 754 默认使用“就近舍入到偶数”(round-to-nearest-even),每次运算都会引入不超过半个 ULP 的误差,单次可忽略,但会随迭代累积。

核心公式:对于规格化浮点数,相邻两个可表示数之间的间隔(ULP)为 2^(E-23)(FP32)或 2^(E-10)(FP16)。当更新量 Δw 小于 ULP/2 时,w + Δw 舍入后仍等于 w,更新被“吸收”。

三、舍入误差与累积误差

舍入误差是单次运算的局部误差,累积误差则是它在成千上万次迭代、上亿次矩阵乘加中的统计放大。二者关系可以用一个直观的类比理解:每次舍入像往天平上放一粒几乎无重量的沙,单粒无感,但持续单向偏移就会让天平倾斜。

在训练中,累积误差主要通过三条路径放大:

  1. 梯度累加路径:小梯度在 FP16 下被舍入为 0,长期不更新,等效于学习率被悄悄调低。
  2. 权重更新路径:w = w - lr·g,当 lr·g 远小于 w 的 ULP 时,更新丢失,模型“卡住”。
  3. 归约路径:all-reduce 中大量数值相加,若用 FP16 累加,误差随参与节点数线性增长。

下面是一段典型的混合精度训练片段,可以看到 FP16 前向、FP32 主权重与 loss scaling 的配合:

# 混合精度训练核心逻辑(伪代码)
master_weight = fp32_weight.clone()      # FP32 主副本
scaler = GradScaler(init_scale=2**16)    # 动态 loss scaling

for x, y in loader:
    with autocast(dtype=torch.float16):
        logits = model(x.half())         # FP16 前向
        loss = criterion(logits, y)

    scaler.scale(loss).backward()        # 放大 loss,避免梯度下溢
    scaler.unscale_(optimizer)           # 还原梯度
    torch.nn.utils.clip_grad_norm_(model.parameters(), 1.0)
    scaler.step(optimizer)               # 在 FP32 主权重上更新
    scaler.update()                      # 动态调整 scale

四、三种格式的适用场景与对比

不同格式并非“越新越好”,而是要在动态范围与精度之间做取舍。下表给出工程实践中的典型判断依据:

格式 指数位 尾数位 动态范围 典型用途 主要风险
FP32 8 23 ~1e-38 ~ 3e38 主权重、优化器状态、loss 累加 显存与带宽开销大
FP16 5 10 ~6e-5 ~ 65504 前向/反向计算、推理加速 梯度下溢、激活上溢
BF16 8 7 同 FP32 大模型训练、长序列场景 尾数精度低,收敛略慢

五、常见误区与复盘

误区一:“用了 BF16 就不会有精度问题。” BF16 只是不溢出,但 7 位尾数在累加大量小量时仍会丢精度,尤其是 LayerNorm、Softmax 的归约。

误区二:“loss scaling 越大越好。” scale 过大会让梯度上溢为 Inf,反而触发 skip step;scale 过小则起不到防下溢作用。

误区三:“精度异常一定是框架 bug。” 多数 NaN/Inf 可回溯到某层激活值超出 FP16 范围,或某次除法分母下溢为 0。

复盘时建议按“定位—量化—验证”三步走:先用 torch.autograd.set_detect_anomaly(True) 定位首个异常算子;再统计该算子输入输出的 min/max/abs-mean,判断是上溢还是下溢;最后用 FP32 复跑同一 batch,对比数值差异,确认是表示精度问题还是算法本身不稳定。只有把浮点数的位布局、动态范围与舍入机制真正吃透,后续的 loss scaling、主权重副本、梯度裁剪等校准手段才有据可依。