🏠 总目录📚 本教程 混合精度
📑 本页目录(点开跳转)

06 · 混合精度

26 分钟 | ⭐ 性价比最高的一个优化


🎯 一句话

用一半的位数存数据,速度快一倍、显存省一半。 听起来像作弊,但它能work是因为 —— 深度学习需要的是"动态范围",而不是"小数点后多少位"。


🔢 一、五种数字格式

   FP32   [符号 1][指数  8][尾数 23]     ← 传统标准
   TF32   [符号 1][指数  8][尾数 10]     ← A100 的默认,硬件自动用
   FP16   [符号 1][指数  5][尾数 10]     ⚠️ 范围窄
   BF16   [符号 1][指数  8][尾数  7]     ⭐ 范围同 FP32,精度低
   FP8    [符号 1][指数 4-5][尾数 2-3]   ← H100 起
        ↑ 决定【范围】      ↑ 决定【精度】
格式 最大值 最小正规值 十进制有效位
FP32 ~3.4e38 ~1.2e-38 ~7 位
FP16 65504 ⚠️ ~6e-5 ⚠️ ~3 位
BF16 ~3.4e38 ~1.2e-38 ⭐ ~2 位

🔑 BF16 vs FP16 是这一章最重要的对比BF16 牺牲精度换范围,FP16 牺牲范围换精度。 而深度学习更怕"溢出"(范围不够)而不是"不精确" —— 梯度本来就是噪声估计,差几个百分点无所谓; 但一旦变成 inf0,训练直接崩。

所以现在的默认是 BF16,不是 FP16。


💰 二、为什么快:三个来源

   ① Tensor Core(最主要)⭐
      FP32:  19.5 TFLOPS
      BF16: 312   TFLOPS    ← 16 倍
      → 半精度【才能】用上 Tensor Core(第 2 章)

   ② 带宽减半
      同样的显存带宽,一次能搬两倍的元素
      → 对带宽瓶颈的操作(第 3 章那一大堆)直接快一倍 ⭐

   ③ 显存占用减半
      → 能开更大的 batch → 算术强度上升 → 又快一点

💡 注意第 ② 点即使某个操作没用 Tensor Core,减半的数据量也让它变快。 这就是为什么混合精度对整个模型都有收益,而不只是矩阵乘。


⚙️ 三、"混合"精度:哪些用低精度,哪些不能

   ✅ 用 BF16/FP16:
   ├─ 矩阵乘、卷积(占 90%+ 的计算量)
   ├─ 激活值(占大部分显存)
   └─ 前向和反向的中间结果

   ❌ 必须保持 FP32:
   ├─ ⭐ 主权重副本(master weights)
   ├─ 优化器状态(Adam 的 m、v)
   ├─ Softmax / LayerNorm 的【累加部分】
   └─ 损失的求和归约

⭐ 为什么必须有 FP32 主权重副本

   典型情况:权重 = 1.0,学习率 × 梯度 = 0.0001

   FP16 下:1.0 的最小可表示间隔约 0.001
   → 1.0 + 0.0001 = 1.0     ⭐ 更新【完全丢失】💀

   → 所以:
     · 用 FP32 保存主权重,更新累加在 FP32 上
     · 每步把它转成 BF16 拿去做前向/反向
一圈下来:FP32 负责【存和累加】,BF16 负责【算】 cast cast to FP32 optimizer.step():更新【在 FP32 上累加】 FP32 主权重 BF16 权重 前向 / 反向(BF16) BF16 梯度
FP32 主权重每步都转一份 BF16 出去算,梯度回来后转回 FP32 再累加 —— 小到 BF16 表示不了的更新,就是靠这一步不被丢掉的。

🔑 这就是第 3 章那个 "12 字节/参数" 的来源: FP32 主权重 4 + Adam 的 m 4 + v 4 = 12。 省显存的是激活,不是优化器状态。


🩹 四、Loss Scaling:FP16 专属的补丁

   ⚠️ 问题:梯度普遍很小(1e-7 量级很常见)
      FP16 的最小正规值约 6e-5
      → 大量梯度【下溢成 0】💀

   ✅ 解法:
   ① loss × S(比如 S = 65536)再反向
   ② 梯度全部被放大 S 倍 → 落进 FP16 的可表示范围
   ③ optimizer.step() 之前把梯度 ÷ S 还原

   ⭐ 动态 loss scaling:
     · 没溢出 → 每隔 N 步把 S 翻倍(试探更大的范围)
     · 溢出(出现 inf/nan)→ S 减半,【跳过这一步更新】

BF16 不需要 loss scaling —— 它的指数位和 FP32 一样宽, 梯度不会下溢。这是 BF16 在工程上最大的省心之处。

# FP16:需要 GradScaler
import torch
scaler = torch.amp.GradScaler()
with torch.autocast("cuda", dtype=torch.float16):
    loss = model(x)
scaler.scale(loss).backward()
scaler.unscale_(opt)                              # ⭐ 裁剪前必须先还原
torch.nn.utils.clip_grad_norm_(model.parameters(), 1.0)
scaler.step(opt); scaler.update()

# BF16:干净得多 ⭐
with torch.autocast("cuda", dtype=torch.bfloat16):
    loss = model(x)
loss.backward()
torch.nn.utils.clip_grad_norm_(model.parameters(), 1.0)
opt.step()

⚠️ scaler.unscale_() 这一步经常被漏掉: 不还原就做梯度裁剪,等于按放大后的值裁剪,裁剪阈值完全失效


⚠️ 五、五个真实的坑

症状 解法
维度没对齐 精度改了但速度没变 维度补到 8 的倍数(FP16)/ 16 的倍数(INT8)⭐
在 autocast 里手动 .half() 类型混乱、报错或静默降速 让 autocast 自己管,别手动转
LayerNorm/Softmax 用低精度累加 loss 抖动、NaN PyTorch 的 autocast 默认已把它们留在 FP32
梯度裁剪前没 unscale 裁剪失效,训练发散 见上面代码
FP16 训练中期突然 NaN 某个激活溢出 65504 换 BF16(首选)或降低 loss scale ⭐

💥 一个高频的困惑:"我开了 AMP,为什么没变快?" 排查顺序:

① 矩阵维度是 8 的倍数吗?(4095 → 4096)
② 模型是不是太小?(小 kernel 的启动开销占主导)
③ 瓶颈是不是根本不在 GPU?(第 4 章的 dataloader 实验)
④ 用 profiler 确认 Tensor Core kernel 真的被调用了

🚀 六、FP8:H100 之后的下一步

   两种格式:
   E4M3(指数4尾数3)→ 精度稍好,用于【前向的激活和权重】
   E5M2(指数5尾数2)→ 范围稍大,用于【反向的梯度】

   ⭐ 收益:算力再翻一倍(H100: 1979 TFLOPS),带宽再减半

   ⚠️ 代价:动态范围极窄,必须【per-tensor scaling】
      → 每个张量维护一个缩放因子,实时统计并调整
      → 框架(Transformer Engine)帮你做,但要理解它在做什么

💡 FP8 目前的实际状态推理上已经相当成熟(尤其是权重和 KV Cache); 训练上仍需谨慎 —— 大模型预训练用 FP8 需要仔细的逐层策略, 通常敏感层(第一层、最后一层、LayerNorm)仍保持更高精度。


🔗 和站内其他章的关系

相关的地方 这里的位置
第 2 章 Tensor Core 混合精度快的真正原因
第 3 章 带宽瓶颈 数据减半 → 带宽压力减半
第 3 章 12 字节/参数 FP32 主权重的来源
第 18 章 量化 更激进的同一思路(INT8/INT4)
ML 基础第 11 章 NaN 排查 FP16 溢出是常见原因
《机器学习与深度学习基础》09 优化器与学习率 lr 常在 1e-4 量级 lr × 梯度 比权重小几个数量级 —— 这才是必须留 FP32 主权重的原因

✅ 检查点

  1. BF16 和 FP16 的区别是什么?为什么现在默认用 BF16?
  2. 混合精度快的三个来源是什么?哪个最主要?
  3. 为什么即使不用 Tensor Core 的操作也会变快?
  4. 为什么必须保留 FP32 主权重副本?举个具体数字。
  5. 混合精度省的是哪部分显存?不省哪部分?
  6. Loss scaling 解决什么问题?动态版本怎么工作?
  7. 为什么 BF16 不需要 loss scaling?
  8. "开了 AMP 但没变快"的四步排查顺序?
  9. FP8 的两种格式各用在哪?它的主要代价是什么?
👀 答案
  1. BF16 指数 8 位(范围同 FP32)尾数 7 位;FP16 指数 5 位(最大 65504)尾数 10 位。BF16 牺牲精度换范围。默认用 BF16 是因为深度学习更怕溢出而不是不精确——梯度本来就是噪声估计,但变成 inf/0 训练直接崩。
  2. Tensor Core(最主要,16 倍)②带宽减半 ③显存减半能开更大 batch。
  3. 因为数据量减半,同样的带宽一次能搬两倍元素——对带宽瓶颈的操作(逐元素、LayerNorm、Softmax 等)直接快一倍。
  4. 因为低精度下小更新会丢失。例:权重 1.0,更新量 0.0001,FP16 在 1.0 附近的最小间隔约 0.001,所以 1.0 + 0.0001 = 1.0,更新完全丢失
  5. 省的是激活值(占大部分显存)。不省优化器状态——FP32 主权重 4 + m 4 + v 4 = 12 字节/参数照样要。
  6. 解决 FP16 下梯度普遍太小(1e-7)会下溢成 0 的问题。动态版:没溢出就每隔 N 步把 S 翻倍试探;出现 inf/nan 就 S 减半并跳过这一步更新
  7. 因为 BF16 的指数位和 FP32 一样宽(8 位),梯度不会下溢。这是它工程上最省心之处。
  8. ①矩阵维度是 8 的倍数吗(4095→4096)②模型是不是太小(kernel 启动开销占主导)③瓶颈是不是根本不在 GPU(dataloader 实验)④profiler 确认 Tensor Core kernel 真被调用。
  9. E4M3 用于前向的激活和权重(精度稍好),E5M2 用于反向的梯度(范围稍大)。代价:动态范围极窄,必须 per-tensor scaling

🛑 可以停在这里

走神救援

用一半位数存数据:快一倍、省一半显存。能work是因为深度学习需要的是动态范围而不是小数点后多少位。⭐⭐BF16 vs FP16 是核心对比BF16 指数 8 位(范围同 FP32)尾数 7 位,FP16 指数 5 位(最大仅 65504)尾数 10 位 → ⭐深度学习更怕溢出而不是不精确(梯度本就是噪声估计,但变 inf/0 就崩)→ 所以默认是 BF16快的三个来源:①⭐Tensor Core(FP32 19.5 vs BF16 312 TFLOPS,半精度才能用上)②带宽减半(所以即使不用 Tensor Core 的操作也变快)③显存减半能开大 batch。"混合"的含义:矩阵乘/激活用 BF16,但⭐主权重副本、Adam 的 m/v、LayerNorm/Softmax 的累加必须 FP32——因为权重 1.0 加更新 0.0001 时,FP16 的最小间隔约 0.001,更新完全丢失;⭐这就是第 3 章"12 字节/参数"的来源,所以混合精度省的是激活不是优化器状态Loss scaling(FP16 专属):梯度普遍 1e-7 会下溢,先 loss×65536 再反向、step 前还原;动态版没溢出就翻倍、溢出就减半并跳过这步。⭐BF16 不需要 loss scaling(指数位和 FP32 一样宽)——工程上最省心之处。⚠️五个坑维度没对齐到 8 的倍数(改了精度速度没变)、在 autocast 里手动 .half()、梯度裁剪前忘了 unscale_()(裁剪阈值完全失效)、FP16 中期 NaN(换 BF16)。"开了 AMP 没变快"排查:维度对齐 → 模型太小 → 瓶颈不在 GPU → profiler 确认 Tensor Core 被调用。FP8(H100):E4M3 前向、E5M2 反向,算力再翻倍但动态范围极窄需 per-tensor scaling推理已成熟,训练仍需谨慎(敏感层保持高精度)。

下一节 👉 07-算子融合与编译.md

打卡记录保存在你的浏览器里,首页能看到总进度