混合精度与 FP8 训练¶
几乎所有大模型都用低精度训练:bf16 让矩阵乘的算力翻倍、显存减半,FP8 再翻一倍。但低精度的数有两个问题:能表示的范围有限(太小的梯度会变成 0),精度有限(小的更新会被舍掉)。混合精度训练就是一组"哪些地方必须用高精度"的规则。这一章用几个小实验把每条规则的原因跑出来,最后看 FP8 训练里最关键的问题:缩放因子的粒度。
自测:能答上来就可以跳过本章
- fp16 和 bf16 各有几位指数、几位尾数?为什么 fp16 训练需要损失缩放而 bf16 通常不需要?
- 为什么优化器里要保存一份 fp32 的主权重?
- 混合精度训练中,哪些运算仍然要用 fp32?
- FP8 有哪两种格式?各用在哪里?
- 为什么 FP8 训练要用细粒度(按块)的缩放因子?
自测参考答案(先自己答,再展开对照)
- fp16:5 位指数、10 位尾数;bf16:8 位指数、7 位尾数。fp16 能表示的最小正数约 \(6 \times 10^{-8}\),更小的梯度直接变成 0,要先把损失放大再反向;bf16 的范围和 fp32 一样,梯度不会下溢,所以通常不需要损失缩放。
- bf16 的机器精度约 0.0078,权重是 1.0 时,\(10^{-3}\) 的更新小于最小可表示的变化,会被直接舍掉,更新多少次都纹丝不动。在 fp32 的主权重上做更新,每步再转成 bf16 给前向用。
- 矩阵乘的累加(Tensor Core 用 fp32 累加器)、softmax、归一化(LayerNorm / RMSNorm)的统计量、损失的计算、优化器的更新(主权重和两个矩);大规模训练的梯度规约也常用 fp32。
- E4M3(4 位指数、3 位尾数,精度高、范围小,用于前向的权重和激活)和 E5M2(5 位指数、2 位尾数,范围大,用于反向的梯度)。
- FP8 的范围很窄,一个离群值会让整个张量的缩放因子变大,其余数值被压进非规格化数甚至 0。按 1×128(激活)、128×128(权重)分块缩放,离群值只影响它所在的那一块。
范围与精度¶
随便输入一个数,看它在 FP32 / FP16 / BF16 / FP8 里被拆成哪几位、存下来差了多少(大模型手册里的同一个工具):
import torch
print("各格式能表示的最小正规数与机器精度:")
for dt in (torch.float32, torch.float16, torch.bfloat16):
fi = torch.finfo(dt)
print(f" {str(dt):<15} 最大 {fi.max:.3g},最小正规数 {fi.tiny:.3g},eps {fi.eps:.3g}")
g = torch.tensor([1e-8, 1e-6, 2e-5]) # 很小的梯度(深层网络里很常见)
print("fp16 直接存:", g.half().tolist())
scale = 2.0 ** 16
print("先乘 2^16 再存 fp16、用时再除回:", (g * scale).half().float().div(scale).tolist())
print("bf16 直接存:", g.bfloat16().float().tolist())
w16, w32 = torch.tensor(1.0, dtype=torch.bfloat16), torch.tensor(1.0)
for _ in range(1000): # 学习率 × 梯度 = 1e-3 的小更新,做 1000 次
w16 += 1e-3
w32 += 1e-3
print(f"bf16 权重直接更新 1000 次:{w16.item():.4f};fp32 主权重:{w32.item():.4f}")
各格式能表示的最小正规数与机器精度:
torch.float32 最大 3.4e+38,最小正规数 1.18e-38,eps 1.19e-07
torch.float16 最大 6.55e+04,最小正规数 6.1e-05,eps 0.000977
torch.bfloat16 最大 3.39e+38,最小正规数 1.18e-38,eps 0.00781
fp16 直接存: [0.0, 1.0132789611816406e-06, 2.002716064453125e-05]
先乘 2^16 再存 fp16、用时再除回: [9.997165761888027e-09, 1.000240445137024e-06, 1.9997358322143555e-05]
bf16 直接存: [1.0011717677116394e-08, 9.98377799987793e-07, 2.002716064453125e-05]
bf16 权重直接更新 1000 次:1.0000;fp32 主权重:2.0000
逐条看:
- fp16 只有 5 位指数,能表示的最小正数约 \(6 \times 10^{-8}\)(非规格化数),更小的梯度直接变成 0(第一个值),接近下限的精度也很差。损失缩放:把损失乘以一个大数(比如 \(2^{16}\))再反向,梯度随之放大到 fp16 能表示的范围,更新前再除回来。缩放因子太大会溢出成
inf,所以实际中动态调整:出现inf就跳过这一步并减小缩放因子(torch.amp.GradScaler); - bf16 有 8 位指数,范围和 fp32 一样,梯度不会下溢,所以通常不需要损失缩放——这是它成为大模型训练默认格式的原因;代价是只有 7 位尾数,精度比 fp16 还低;
- 主权重:bf16 的机器精度约 \(0.0078\),权重是 1.0 时,\(10^{-3}\) 的更新小于最小可表示的变化,直接被舍掉——更新 1000 次,权重纹丝不动。所以优化器用一份 fp32 的主权重做更新,每步再把它转成 bf16 给前向用;Adam 的两个矩也用 fp32。这正是显存账本里"每参数 16 字节"的来源。
哪些运算用什么精度¶
| 运算 | 精度 | 原因 |
|---|---|---|
| 矩阵乘(前向、反向) | bf16 / FP8 输入,fp32 累加 | 算力的主要来源;累加器用高精度,Tensor Core 默认如此 |
| softmax、LayerNorm / RMSNorm、损失 | fp32 | 求和、指数、除法对精度敏感 |
| 优化器更新 | fp32 主权重和矩 | 见上面的实验 |
| 梯度的 all-reduce | bf16 或 fp32 | bf16 省一半通信,但很多卡上累加时误差会增大,大规模训练常用 fp32 规约 |
PyTorch 里 torch.autocast(device_type="cuda", dtype=torch.bfloat16) 会自动按这张表选择精度(矩阵乘用 bf16,归一化和 softmax 升到 fp32);自己写的 kernel 要自己遵守这些规则。
FP8 训练¶
FP8 有两种格式:E4M3(4 位指数、3 位尾数,最大 448)精度高、范围小,一般用于前向的权重和激活;E5M2(5 位指数、2 位尾数)范围大、精度低,传统上用于反向的梯度(DeepSeek-V3 全程使用 E4M3)。
范围只有几百,所以必须先乘一个缩放因子把数值映射进来。缩放的粒度是关键:
- 逐张量:整个张量一个缩放因子。只要有一个离群值,缩放因子就被它决定,其余的普通值被挤到很小的范围里,落进非规格化区甚至下溢,精度大幅下降;
- 细粒度(DeepSeek-V3):激活每 1×128 个元素一个缩放因子,权重每 128×128 块一个。离群值只影响它所在的那一小块。
import torch
torch.manual_seed(0)
FP8 = torch.float8_e4m3fn
MAX = torch.finfo(FP8).max # 448
def quant(x, scale):
return (x / scale).clamp(-MAX, MAX).to(FP8).float() * scale # 缩放 → 量化成 FP8 → 反量化
def per_tensor(x):
return quant(x, x.abs().max() / MAX)
def per_block(x, rows, cols):
"""按 rows × cols 的块各自缩放(DeepSeek-V3:激活 1×128,权重 128×128)"""
R, C = x.shape
b = x.reshape(R // rows, rows, C // cols, cols)
s = b.abs().amax(dim=(1, 3), keepdim=True).clamp(min=1e-12) / MAX
return quant(b, s).reshape(R, C)
def rel_err(q, x):
return ((q - x).norm() / x.norm()).item()
print(f"FP8 E4M3:最大值 {MAX:.0f},最小正规数 {torch.finfo(FP8).tiny}")
for outlier in (1e2, 1e4, 3e4):
a = torch.randn(256, 1024)
a[:, :4] *= outlier # 少数几个通道有离群值(LLM 的激活里很常见)
normal = a[:, 128:] # 与离群通道不在同一个 128 块里的普通通道
t = rel_err(per_tensor(a)[:, 128:], normal)
b = rel_err(per_block(a, 1, 128)[:, 128:], normal)
print(f"离群值放大 {outlier:>7.0f} 倍:普通通道的相对误差,逐张量缩放 {t:5.1%},每 1×128 块缩放 {b:5.1%}")
FP8 E4M3:最大值 448,最小正规数 0.015625
离群值放大 100 倍:普通通道的相对误差,逐张量缩放 2.6%,每 1×128 块缩放 2.6%
离群值放大 10000 倍:普通通道的相对误差,逐张量缩放 4.9%,每 1×128 块缩放 2.6%
离群值放大 30000 倍:普通通道的相对误差,逐张量缩放 14.1%,每 1×128 块缩放 2.6%
离群值越大,逐张量缩放下普通通道的误差越大;每 1×128 块缩放的误差始终稳定在 FP8 本身的精度水平(3 位尾数,约 2~3%)。LLM 的激活里恰恰存在少数幅值极大的通道,这是细粒度缩放成为 FP8 训练标配的原因。
硬件上还有两个配套措施:
- 高精度累加:H800 的 FP8 Tensor Core 的累加器精度有限,DeepSeek-V3 每累加 128 个元素就把部分和提升到 CUDA 核上用 fp32 累加(DeepGEMM 的实现);
- Blackwell 的 MX 格式(MXFP8、MXFP4)在硬件层面原生支持"每 32 个元素共享一个缩放因子",把细粒度缩放变成了标准。
省激活的其他手段¶
混合精度省的是模型状态和算力,激活还要靠:
- 全量重计算:只保存每层的输入,反向时重算整层,激活降到每层 \(2\,sbh\),多约 33% 的计算;
- 选择性重计算(Megatron):只重算注意力里占显存大、计算便宜的部分(softmax、dropout 的中间结果),用很少的额外计算省下大部分激活;有了 FlashAttention,这部分本来就不保存了;
- 激活卸载:把激活拷到 CPU 内存,反向时再拷回来,用 PCIe 带宽换显存,要和计算重叠才划算。
怎么讲清楚
讲混合精度:fp16 只有 5 位指数,小梯度会下溢,需要动态损失缩放(溢出就跳过这一步并减半缩放因子);bf16 的指数位和 fp32 一样多,通常不需要,是大模型训练的默认格式,代价是只有 7 位尾数,所以优化器要保存 fp32 的主权重(bf16 下 1.0 + 0.001 会被舍掉)。矩阵乘用低精度输入、fp32 累加,softmax、归一化、损失和优化器更新用 fp32。FP8 分 E4M3(前向)和 E5M2(梯度),逐张量缩放会被离群值拖垮,细粒度(1×128、128×128)缩放加高精度累加是标配。
练习¶
- 用
precision.py的思路验证:fp16 的主权重也会有同样的问题吗?把w16换成 fp16 试一试,并解释结果。
参考答案
fp16 在 \([1, 2)\) 之间的间隔是 \(2^{-10} \approx 0.000977\),\(1.0 + 10^{-3}\) 舍入到最近的 \(1.000977\),所以每一步实际只加了 \(0.000977\):1000 次之后是 \(1.9766\) 而不是 \(2.0\)(每一步都有系统性的舍入误差)。 到 \([4, 8)\) 之间,间隔变成 \(0.0039\),\(10^{-3}\) 小于间隔的一半,更新被完全舍掉——实测从 1.0 开始加 5000 次,权重停在 \(4.0\)。结论一样:主权重必须是 fp32。
- 一个张量的数值大多在 \([-1, 1]\),但有一个值是 5000。用 E4M3 逐张量量化时,缩放因子是多少?\(0.01\) 这样的值量化后还剩多少精度?
参考答案
缩放因子 \(= 5000 / 448 \approx 11.2\),\(0.01\) 除以缩放因子约为 \(0.0009\),小于 E4M3 最小的非规格化数(\(2^{-9} \approx 0.00195\))的一半,直接被量化成 0。\([-1, 1]\) 里的大部分值只剩一两位有效数字甚至变成 0——这就是细粒度缩放要解决的问题。
小结¶
- fp16 范围小、需要损失缩放;bf16 范围与 fp32 相同、通常不需要,是大模型训练的默认格式。
- 矩阵乘用低精度输入、fp32 累加;softmax、归一化、损失、优化器更新用 fp32;优化器保存 fp32 主权重。
- FP8 分 E4M3 和 E5M2;逐张量缩放会被离群值拖垮,细粒度(1×128、128×128)缩放是标配,并配合高精度累加。
- 激活用重计算(全量或选择性)和卸载来省。
发现错误或有更好的写法?在 GitHub 上编辑此页提交问题