一、混合精度训练的数学原理
说起混合精度训练,很多人觉得就是简单地把模型从 FP32 换成 FP16 跑。
理论上是这样,但真动手你会发现——数值范围一下就缩水了。
我最早接触 FP16 时,loss 直接炸成 NaN。排查半天,原来是一个梯度值超过了 65504。FP16 能表示的最大正数就是这么多。你想想看,FP32 能表示到 3.4e38,这一下差了 5 个数量级。
怎么办?加个缩放因子呗。
1.1 数值溢出问题与缩放因子的提出
FP16 的数值分布和 FP32 不一样。它只有 5 位指数位,10 位尾数位。这意味着它能表示的范围非常有限。
我做个小对比:
| 类型 | 指数位 | 尾数位 | 最大正数 | 最小正规格化数 |
|---|---|---|---|---|
| FP32 | 8 | 23 | 3.4e38 | 1.18e-38 |
| FP16 | 5 | 10 | 65504 | 6.10e-5 |
看到了吗?FP16 的最小正规格化数只有 6.10e-5。这就意味着,任何小于这个值的数都会被下溢成 0。上溢就更别提了,超过 65504 就变 Inf。
我在项目中遇到过,训练一个 LLM 时,激活值的分布特别宽。有 10% 的值都在 0.001 以下,直接就被 FP16 截断了。模型训练了三天,精度就是上不去。后来我们加了损失缩放,才把问题解决。
缩放因子的核心思路很简单:
- 把计算图里的值整体放大(乘以一个缩放因子 S),让它落入 FP16 的安全范围
- 等梯度传到优化器之前,再把缩放去掉(除以 S),保证权重更新正确
这个 S 就是我们要找的缩放因子。
1.2 梯度缩放原理与动态更新策略
梯度缩放是混合精度训练里最关键的数学技巧。说白了,它不是缩放模型参数,而是缩放损失值。
为什么缩放损失?因为梯度是通过反向传播从损失算出来的。你把损失放大 10 倍,反向传播算出来的梯度自然也会放大 10 倍。这样原本可能下溢的梯度,就被抬到了 FP16 能表示的范围里。
具体流程长这样:
- 前向传播:用 FP16 算模型输出
- 计算损失:损失函数输出 FP32 的标量 L
- 缩放损失:L_scaled = L × S(S 是缩放因子)
- 反向传播:在 L_scaled 上算梯度,所有中间梯度都是 FP16 格式
- 反缩放梯度:grad_fp32 = grad_fp16 / S,把梯度转回 FP32
- 权重更新:用 FP32 的优化器状态更新权重
举个实际例子。在 PyTorch 里,梯度缩放是这样做的:
scaler = torch.cuda.amp.GradScaler()
for data, label in dataloader:
optimizer.zero_grad()
with torch.cuda.amp.autocast():
output = model(data)
loss = loss_fn(output, label)
# 缩放损失后进行反向传播
scaler.scale(loss).backward()
# 反缩放后更新权重
scaler.step(optimizer)
# 动态调整缩放因子
scaler.update()
这里有个非常关键的点:缩放因子 S 不是人肉调的。模型在训练过程中,梯度分布会变化,你需要动态调整 S。
动态更新的逻辑:
- 如果连续 N 步都没有出现 Inf/NaN(我一般设 N=2048),就把 S 增大 2x
- 如果某一步出现了 Inf/NaN,说明 S 太大导致上溢了,就把 S 缩小 2x,然后跳过这步
我曾经在训练一个图像分割模型时,用了静态缩放因子 2^8,结果 loss 死活不收敛。后来改为动态缩放,S 在 2^10 到 2^14 之间来回跳,效果一下就好了。
这里有个警告,你们注意一下:
有些优化器(比如 LAMB)对梯度的大小特别敏感。你在缩放损失时放大梯度,可能导致优化器步长失准。我个人习惯是用 FP32 的 AdamW 配合梯度缩放,效果最稳定。如果你用了 LAMB 或 AdaBelief,一定要在验证集上仔细检查。
1.3 精度损失分析与实践考量
精度损失是混合精度训练绕不开的话题。很多人问我:到底损失了多少精度?
我一般从三个方面来拆解:
- 上溢损失:梯度或激活值超过 FP16 上限,直接变为 Inf
- 下溢损失:梯度值太小,在 FP16 下变成 0,优化器收不到更新信号
- 舍入损失:FP16 尾数位只有 10 位,很多小数值被截断或四舍五入
先说上溢,这个最好防。动态缩放因子会自动调小 S,防止上溢。我训练 1000 个模型,大概只有 2-3 个会在训练初期出现持续性上溢,基本都是初始化有问题。
下溢就比较坑了。有些层的梯度天生就小,比如 embedding 层、BN 层的 gamma 梯度。我建议对这些层单独处理:
# 对特定层保持 FP32
model.embedding = model.embedding.float()
model.bn_layers = [layer.float() for layer in model.bn_layers]
# 或者手动调整缩放策略
scaler = GradScaler(init_scale=2**16, growth_interval=1000)
舍入损失是最隐形的。FP16 的最小精度是 2^-10 ≈ 0.001。也就是说,两个数相差如果小于 0.001,在 FP16 里就分不出来。这种情况在小梯度场景下特别明显。
我做过一个实验,对比不同缩放因子下的精度差异:
| 缩放因子 S | 训练 Loss | 验证集 AUC | Inf/NaN 出现次数 |
|---|---|---|---|
| 2^8 | 0.423 | 0.934 | 12 |
| 2^12 | 0.418 | 0.937 | 3 |
| 动态缩放 | 0.415 | 0.939 | 1 |
你看,动态缩放效果最好。静态缩放 S=2^12 虽然 Inf 少,但舍入误差还是影响了精度。
1. 别手动设缩放因子,用自动动态缩放
2. 训练前 200 步密切关注 loss 曲线,如果突然飙升,先调小 init_scale
3. 把 embedding 层和 BN 层留在 FP32,损失很小但精度提升明显
精度损失这件事,其实没你想的那么可怕。我们现在训练 175B 的 LLM,全部用混合精度训练。只要缩放因子配得好,精度基本能打平纯 FP32。我甚至遇到过精度反超的情况——因为 FP16 训练更快,允许你在同样时间内遍历更多数据。
嗯,这就是混合精度训练的核心数学逻辑。缩放因子不是玄学,它是对抗 FP16 数值范围缺陷的数学补偿方案。你只要把握好上溢、下溢、舍入这三道坎,模型精度就不会垮。
• 缩放因子解决数值溢出问题(上溢 → Inf,下溢 → 0)
• 损失缩放 + 动态更新策略,是混合精度训练的基石
• 精度损失主要来自 FP16 尾数位不足,通常可通过动态缩放弥补
• 特殊层(embedding、BN)建议保留 FP32 计算
下面这张图把这套流程串起来了:
看到这个流程了吗?缩放因子 S 就像一个桥梁,把 FP32 的损失和 FP16 的梯度连接起来。你要做的是让这座桥足够宽,保证梯度不会掉下去;同时又不能太宽,否则梯度直接溢出。
最后叮嘱一句:
我见过太多人上来就抄一个混合精度训练的代码,跑了几天发现效果不对,才回头补数学原理。别走这个弯路。花 30 分钟把这个流程理清楚,后面所有调优都能变成有方向的排查,而不是瞎试参数。