跳转至
分布式训练手册 精度与策略 下载 notebook

混合精度与 FP8 训练

几乎所有大模型都用低精度训练:bf16 让矩阵乘的算力翻倍、显存减半,FP8 再翻一倍。但低精度的数有两个问题:能表示的范围有限(太小的梯度会变成 0),精度有限(小的更新会被舍掉)。混合精度训练就是一组"哪些地方必须用高精度"的规则。这一章用几个小实验把每条规则的原因跑出来,最后看 FP8 训练里最关键的问题:缩放因子的粒度。

自测:能答上来就可以跳过本章

  1. fp16 和 bf16 各有几位指数、几位尾数?为什么 fp16 训练需要损失缩放而 bf16 通常不需要?
  2. 为什么优化器里要保存一份 fp32 的主权重?
  3. 混合精度训练中,哪些运算仍然要用 fp32?
  4. FP8 有哪两种格式?各用在哪里?
  5. 为什么 FP8 训练要用细粒度(按块)的缩放因子?
自测参考答案(先自己答,再展开对照)
  1. fp16:5 位指数、10 位尾数;bf16:8 位指数、7 位尾数。fp16 能表示的最小正数约 \(6 \times 10^{-8}\),更小的梯度直接变成 0,要先把损失放大再反向;bf16 的范围和 fp32 一样,梯度不会下溢,所以通常不需要损失缩放。
  2. bf16 的机器精度约 0.0078,权重是 1.0 时,\(10^{-3}\) 的更新小于最小可表示的变化,会被直接舍掉,更新多少次都纹丝不动。在 fp32 的主权重上做更新,每步再转成 bf16 给前向用。
  3. 矩阵乘的累加(Tensor Core 用 fp32 累加器)、softmax、归一化(LayerNorm / RMSNorm)的统计量、损失的计算、优化器的更新(主权重和两个矩);大规模训练的梯度规约也常用 fp32。
  4. E4M3(4 位指数、3 位尾数,精度高、范围小,用于前向的权重和激活)和 E5M2(5 位指数、2 位尾数,范围大,用于反向的梯度)。
  5. FP8 的范围很窄,一个离群值会让整个张量的缩放因子变大,其余数值被压进非规格化数甚至 0。按 1×128(激活)、128×128(权重)分块缩放,离群值只影响它所在的那一块。

范围与精度

随便输入一个数,看它在 FP32 / FP16 / BF16 / FP8 里被拆成哪几位、存下来差了多少(大模型手册里的同一个工具):

precision.py
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 块一个。离群值只影响它所在的那一小块。
激活 x(bf16) 权重 W(bf16 副本) × 缩放 → E4M3 × 缩放 → E4M3 FP8 GEMMTensor Core累加在 fp32 反量化 输出 y(bf16) 反向:梯度用 E5M2(范围大、精度低) 主权重 + 优化器状态fp32,从不量化 缩放因子怎么定:逐张量(延迟缩放,用上几步的 amax)简单但一个离群值毁一整张;细粒度(激活 1×128、权重 128×128,DeepSeek-V3)把影响局限在一小块 E4M3 最大 448、相对精度 1/8:数值必须先缩放进范围;E5M2 范围到 57344,给动态范围更大的梯度用 能省的是矩阵乘的时间和激活显存(存 FP8 版本);归一化、softmax、优化器步骤仍在 fp32 / bf16,所以端到端加速通常 1.2~1.5 倍 FP4 训练再往下走:缩放更细、随机舍入、Hadamard 变换把离群值摊开——每一步都在和"少数几个大数"作斗争
图:FP8 训练的数据流——矩阵乘的输入量化成 FP8,累加和主权重仍在高精度
fp8_scaling.py
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)缩放加高精度累加是标配。

练习

  1. 用 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, 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)缩放是标配,并配合高精度累加。
  • 激活用重计算(全量或选择性)和卸载来省。

本章练习题(1 道)

在浏览器里直接写代码、跑测试;也可以在本地用 python practice/judge.py 判题。

发现错误或有更好的写法?在 GitHub 上编辑此页提交问题