让数字 变轻
Day 11 我们从 allocator 视角节省显存;今天换一个更底层的杠杆:
把每个数字本身变小。
混合精度训练的目标不是"全部换成半精度",而是让不同算子在不同 dtype 里各司其职:
GEMM / Conv 用 Tensor Core 跑低精度,归约 / Softmax / Loss 留在高精度,
反向传播用 loss scaling 避免梯度下溢。
今天你要看清 FP32 / TF32 / FP16 / BF16 / FP8 的数值边界,
掌握 torch.amp.autocast 与
GradScaler 的真实分工。
思维导图
先看数字格式 — 范围、精度与溢出
半精度训练的第一个坑是把"位数少"理解成"一定不准"。 真正要分清的是两件事: 指数位决定能表达多大/多小的数,也就是溢出和下溢; 尾数位决定相邻两个数之间的间隔,也就是精度。 FP16 和 BF16 都是 16 位,但性格完全不同。
| 格式 | 位宽结构 | 最大有限值 | 精度特征 | AI Infra 里的角色 |
|---|---|---|---|---|
| FP32 | 1 sign · 8 exp · 23 mantissa | ~3.4e38 | 范围大、精度高,最稳但慢/占显存 | 基线;optimizer master weights;敏感归约 |
| TF32 | 8 exp · 10 mantissa | ~3.4e38 | FP32 范围 + 接近 FP16 尾数 | Ampere+ 默认 GEMM 加速路径,通常不改模型代码 |
| FP16 | 1 sign · 5 exp · 10 mantissa | 65504 | 尾数还行,但指数太短,非常容易 overflow / underflow | V100/T4/A10 上常用;通常需要 GradScaler |
| BF16 | 1 sign · 8 exp · 7 mantissa | ~3.4e38 | 范围与 FP32 相同,尾数较粗 | A100/H100 训练默认推荐;通常不需要 GradScaler |
| FP8 E4M3 | 1 sign · 4 exp · 3 mantissa | ~448 | 精度相对更好,范围小 | TransformerEngine 常用于 activation / weight 前向 |
| FP8 E5M2 | 1 sign · 5 exp · 2 mantissa | ~57344 | 范围更大,精度更粗 | TransformerEngine 常用于 gradient 反向 |
一张图理解 FP16 vs BF16
为什么 FP16 会炸
import torch x = torch.tensor([1e4], device="cuda", dtype=torch.float16) print(x * x) # tensor([inf], device='cuda:0', dtype=torch.float16) g = torch.tensor([1e-8], device="cuda", dtype=torch.float16) print(g) # tensor([0.], device='cuda:0', dtype=torch.float16) ← 梯度下溢 b = torch.tensor([1e4], device="cuda", dtype=torch.bfloat16) print(b * b) # 仍然能表达大数,但尾数更粗
前向 / 反向变成 inf
FP16 最大有限值只有 65504。attention score、loss、梯度范数、指数运算一旦超过边界,结果就是 inf,接着传播成 nan。这就是为什么 softmax、exp、log、norm 通常不能莽撞降到 FP16。
小梯度直接变成 0
深层网络反向传播时很多梯度本来就在 1e-8 附近。FP16 的可表示小数范围不够,这些梯度会被 flush 到 0。GradScaler 的本质就是先把 loss 放大,让梯度落进 FP16 能表达的区间。
为什么 LLM 喜欢 BF16
LLM 训练的主要麻烦是梯度范围和 activation 范围大,而不是每个数都需要很细的尾数。BF16 保留 FP32 的 8 位指数,所以稳定性接近 FP32;尾数少一点通常可以靠大 batch 和优化器噪声消化。
不要忘记默认加速
在 Ampere+ GPU 上,很多 FP32 matmul 会走 TF32 Tensor Core:输入按 TF32 乘,累加仍是 FP32。它不是显式 dtype,更像 CUDA/cuBLAS 的计算模式。想要严格可复现时需要关掉,想要吞吐时通常保留。
autocast — 不是全局转 dtype,而是算子级决策
autocast 不是把模型参数永久改成半精度。
它只是在一个上下文里拦截算子调用,根据算子的数值风险选择合适 dtype:
大矩阵乘和卷积适合降精度,归约和 softmax 更保守,有些算子会主动提升到 FP32。
这就是 AMP 的核心: Automatic Mixed Precision,自动混合,不是自动半精度。
现代 PyTorch 写法
import torch device = "cuda" model = MyModel().to(device) optimizer = torch.optim.AdamW(model.parameters(), lr=3e-4) # CUDA + fp16 训练:需要 GradScaler scaler = torch.amp.GradScaler(device) for x, y in loader: x, y = x.to(device), y.to(device) optimizer.zero_grad(set_to_none=True) # 只包 forward + loss with torch.amp.autocast(device_type=device, dtype=torch.float16): logits = model(x) loss = torch.nn.functional.cross_entropy(logits, y) # scale 后 backward,避免 fp16 梯度下溢 scaler.scale(loss).backward() # 如需 gradient clipping,必须先 unscale scaler.unscale_(optimizer) torch.nn.utils.clip_grad_norm_(model.parameters(), max_norm=1.0) scaler.step(optimizer) # 若发现 inf/nan,自动跳过 optimizer.step() scaler.update() # 动态调整 scale
BF16 写法更简单
# A100 / H100 上训练 LLM 的常见默认 for x, y in loader: optimizer.zero_grad(set_to_none=True) with torch.amp.autocast("cuda", dtype=torch.bfloat16): loss = model(x).loss loss.backward() optimizer.step() # BF16 的指数范围和 FP32 一样,通常不需要 GradScaler
需要手动接管的场景
敏感算子强制 FP32
如果某段计算在 AMP 下不稳定,局部退出 autocast:with torch.amp.autocast("cuda", enabled=False):
在里面把输入显式转回 float()。常见对象:自定义 loss、复杂归约、指数/对数链路。
别让 autocast 猜
自定义 autograd.Function 可以用 torch.amp.custom_fwd / custom_bwd 标注输入如何 cast。写 CUDA extension 时也要明确 accumulator dtype,否则 forward 看似加速,backward 悄悄不稳。
不要手动 model.half()
初学者常犯错:直接 model.half() 然后训练。这样 LayerNorm、embedding、optimizer 状态都可能进入低精度,稳定性大幅下降。AMP 推荐参数保持 FP32/BF16 策略,由 autocast 决定每个 op 的输入 dtype。
永远和 FP32 baseline 比
AMP 不是"开了就算成功"。至少比较三件事:loss 曲线前 100 step 是否贴近、最终指标是否在误差范围、是否出现 inf/nan/scale 回退。生产里还要记录吞吐、显存峰值和数值回归。
GradScaler — 动态 Loss Scaling 的状态机
FP16 最危险的是梯度下溢。解决思路非常朴素:
loss 乘一个很大的 scale,反向传播得到的梯度也跟着变大,
optimizer.step 前再除回去。
关键是 scale 不能太大,否则梯度又会 overflow。
GradScaler 做的就是这个动态平衡。
关键 API 顺序
| 步骤 | API | 为什么顺序不能错 |
|---|---|---|
| 1 | scaler.scale(loss).backward() | 放大 loss 后再反传,让小梯度不会被 FP16 吃掉 |
| 2 | scaler.unscale_(optimizer) | 如果要梯度裁剪/检查,必须先把梯度除回原尺度 |
| 3 | clip_grad_norm_ | 在真实梯度尺度上裁剪,否则 max_norm 没意义 |
| 4 | scaler.step(optimizer) | 内部检查 inf/nan;发现溢出会跳过这次 step,避免污染参数 |
| 5 | scaler.update() | 根据这一步是否溢出动态调 scale |
常见错误
clip 前忘记 unscale
如果梯度还乘着 65536 就做 norm clipping,几乎每步都会被错误裁掉,训练会变慢甚至不收敛。固定顺序: backward → unscale_ → clip → step → update。
多个 optimizer 混用
GAN、MoE、RLHF 里可能有多个 optimizer。每个 optimizer 都要单独 unscale_ 和 step;但一般共用一个 scaler。跳过某个 optimizer step 时要知道参数更新不同步的影响。
把 scaler 用在 BF16 上
BF16 通常不需要 loss scaling。不是绝对不能用,而是收益很小且会让调试更复杂。A100/H100 上优先 BF16 + no scaler;旧卡 FP16 才用 scaler。
看 scale 曲线
如果 scaler.get_scale() 一直下降,说明频繁 overflow:学习率过大、loss 本身不稳、某个算子不该进 FP16。如果 scale 长期稳定或缓慢上升,AMP 状态基本健康。
动手实践 — 跑一个 AMP benchmark
今天的练习目标不是把某个模型训到高分,而是建立一套固定的 AMP 验证手感: 同一个模型分别跑 FP32、FP16 AMP、BF16 AMP, 记录吞吐、显存峰值、loss 是否贴近。 以后你给任何训练脚本开 AMP,都照这个流程验。
# amp_bench.py — FP32 / FP16 AMP / BF16 AMP 对比骨架 import argparse, time, torch import torch.nn as nn import torch.nn.functional as F class TinyMLP(nn.Module): def __init__(self, d=4096, layers=8): super().__init__() self.net = nn.Sequential(*[ layer for _ in range(layers) for layer in (nn.Linear(d, d), nn.GELU()) ], nn.Linear(d, 1000)) def forward(self, x): return self.net(x) def run(mode): device = "cuda" model = TinyMLP().to(device) opt = torch.optim.AdamW(model.parameters(), lr=1e-3) scaler = torch.amp.GradScaler(device, enabled=(mode == "fp16")) dtype = {"fp32": None, "fp16": torch.float16, "bf16": torch.bfloat16}[mode] torch.cuda.reset_peak_memory_stats() torch.cuda.synchronize() t0 = time.perf_counter() losses = [] for step in range(80): x = torch.randn(32, 4096, device=device) y = torch.randint(0, 1000, (32,), device=device) opt.zero_grad(set_to_none=True) enabled = (dtype is not None) with torch.amp.autocast(device_type=device, dtype=dtype, enabled=enabled): loss = F.cross_entropy(model(x), y) if mode == "fp16": scaler.scale(loss).backward() scaler.step(opt) scaler.update() else: loss.backward() opt.step() losses.append(float(loss.detach())) torch.cuda.synchronize() dt = time.perf_counter() - t0 mem = torch.cuda.max_memory_allocated() / 2**30 print(f"{mode:4s} time={dt:.2f}s peak={mem:.2f}GiB last_loss={losses[-1]:.4f} scale={scaler.get_scale():.0f}") if __name__ == "__main__": parser = argparse.ArgumentParser() parser.add_argument("--mode", choices=["fp32", "fp16", "bf16"], required=True) run(parser.parse_args().mode)
运行方式
python amp_bench.py --mode fp32 python amp_bench.py --mode fp16 python amp_bench.py --mode bf16 # 记录这三列: # 1) time:吞吐收益 # 2) peak:显存峰值 # 3) last_loss / loss 曲线:数值是否明显偏离
你应该看到什么
| 模式 | 速度 | 显存 | 稳定性 | 解释 |
|---|---|---|---|---|
| FP32 | 最慢 | 最高 | 最稳 | 基线,用于对照 loss 与最终指标 |
| FP16 AMP | 通常最快 | 低 | 依赖 scaler | Tensor Core 吞吐高,但需要处理 overflow / underflow |
| BF16 AMP | 接近 FP16 | 低 | 接近 FP32 | LLM 训练默认优先项,前提是 GPU 支持 BF16 Tensor Core |
生产检查项
不要只看 wall time
记录 samples/s 或 tokens/s,并确保 batch size、gradient accumulation、dataloader 都一致。AMP 加速如果被数据加载拖住,你看到的 wall time 可能没有明显变化。
看 peak allocated
用 torch.cuda.max_memory_allocated() 对比峰值。AMP 会减少 activation / gradient 的字节数,但 optimizer state 仍可能是 FP32,所以整体不一定正好减半。
先短跑再长跑
先比前 100 step loss,再跑完整 epoch 指标。短跑偏离代表 dtype 策略或 scale 有问题;长跑偏离可能是随机性、学习率或优化器超参需要重调。
把 dtype 写进实验元数据
日志里固定记录:目标 dtype、是否开 TF32、GradScaler 初始值、scale 最小值、是否出现 inf/nan、GPU 型号。否则你以后复现实验会很痛苦。
FP8 — H100 时代的下一层混合精度
FP8 不是"把 AMP 的 dtype 从 FP16 改成 FP8"这么简单。
8 位太小,必须额外维护 scale / amax history,
把真实数值范围映射进 FP8 可表示区间。
这也是为什么 FP8 在生产里通常通过 NVIDIA TransformerEngine 这类库使用,
而不是手写一堆 tensor.to(torch.float8_*)。
TransformerEngine 的使用形状
# 概念示例:真实项目需按 TransformerEngine 版本与硬件配置调整 import torch import transformer_engine.pytorch as te from transformer_engine.common import recipe fp8_recipe = recipe.DelayedScaling( margin=0, fp8_format=recipe.Format.HYBRID, # forward E4M3, backward E5M2 amax_history_len=16, amax_compute_algo="max", ) model = te.Linear(4096, 4096).cuda() with te.fp8_autocast(enabled=True, fp8_recipe=fp8_recipe): y = model(x) loss = y.float().pow(2).mean()
大 Transformer 的 Linear/GEMM
FP8 收益主要来自 Linear、Attention projection、MLP projection 这些大 GEMM。模型越大、GEMM 占比越高,收益越明显。小模型或数据瓶颈任务,FP8 可能只增加复杂度。
敏感归约与小算子
LayerNorm、Softmax、loss、复杂 mask、采样逻辑通常继续使用 FP16/BF16/FP32。FP8 的正确用法是把高吞吐 GEMM 降下去,不是让全模型所有数都变成 8 位。
精度回归比 FP16 更严格
FP8 需要额外比较收敛曲线、per-layer amax、scale 是否饱和、是否出现大量 clamping。生产上通常先在小规模预训练/微调验证,再扩到大集群。
别脱离 GPU 世代谈 FP8
FP8 的现实价值建立在 H100/H200/B200 这类硬件的 Transformer Engine 与 Tensor Core 上。没有对应硬件时,模拟 FP8 往往只会更慢,适合研究不适合提吞吐。
常见疑问
Q1 FP16 和 BF16 都是 16 位,为什么 BF16 通常更稳定? +
因为稳定性首先看指数范围。FP16 只有 5 位 exponent,最大有限值约 65504;BF16 有 8 位 exponent,范围和 FP32 基本一致。训练里最怕的是 activation、loss、gradient norm 突然变大或变小,这些都由指数位决定。
BF16 的代价是尾数只有 7 位,相邻数间隔更粗。但深度学习训练本来就带噪声,很多模型能接受这点尾数损失。大模型训练里,不 overflow 往往比多几位尾数更重要。
Q2 开 AMP 后 loss 偶尔变 nan,应该先查哪里? +
按顺序查四件事:第一,看 scaler.get_scale() 是否频繁下降,如果是 FP16 overflow;第二,把可疑 loss、softmax、normalization 局部禁用 autocast 强制 FP32;第三,检查学习率和梯度裁剪顺序,特别是 clip 前是否 unscale_;第四,短跑 FP32 baseline,确认不是模型本身就不稳定。
不要一上来就把整个 AMP 关掉。更好的调试方式是二分定位哪一段 op 在低精度下不稳,然后只把那一段升回 FP32。
Q3 为什么不直接把模型参数保存成 FP16/BF16 来省一半显存? +
推理可以这么做,训练要小心。训练不仅有参数,还有梯度、optimizer state、master weights。AdamW 的 m/v 状态通常仍需要 FP32 或至少更高精度;如果把所有状态都降到 FP16,长跑很容易积累误差。
AMP 的设计是"计算用低精度,关键状态保高精度"。这也是为什么显存不会刚好减半:activation 和 gradient 省了,但 optimizer state 仍然很重。真正想切 optimizer state,要看 ZeRO/FSDP/8-bit optimizer,那是 Day 18 之后的内容。
Q4 TF32、FP16 AMP、BF16 AMP 应该怎么选? +
如果你在 Ampere+ 上跑 FP32 baseline,默认就可能已经吃到 TF32 加速,这是最少改动的选项。想进一步省显存和提吞吐,优先看 BF16 AMP,前提是 GPU 支持 BF16 Tensor Core。旧卡或部署环境常见 FP16,就用 FP16 AMP + GradScaler。
粗略规则:A100/H100 训练 LLM 用 BF16;V100/T4/A10 用 FP16 AMP;严格数值对齐或科学计算先关 TF32/AMP 做 baseline。
Q5 FP8 会不会很快取代 BF16? +
不会是简单替代,更像是再加一层。BF16 仍然是稳定、通用、易调的训练默认;FP8 更适合大模型里占主导的 GEMM 路径,并且依赖硬件、库和 scale 策略。生产系统很可能是 FP8 GEMM + BF16/FP16 activation 辅助 + FP32 optimizer state 的多层混合。
所以学习顺序也应该是:先懂 BF16/FP16 AMP,再懂 FP8 的 scale 系统。没有前者,直接上 FP8 会很难判断问题来自 dtype、scale、kernel 还是模型本身。
复盘问题
- 画出 FP32 / FP16 / BF16 的 sign、exponent、mantissa 位宽,解释为什么 FP16 需要 GradScaler 而 BF16 通常不需要。
- 说明
autocast为什么不能理解成"把所有 op 都转成 FP16",并举出三类算子的 dtype 策略:降精度、升 FP32、跟随最宽输入。 - 写出 AMP 训练循环的 5 个关键步骤:scale、backward、unscale、step、update,并说明 gradient clipping 应该插在哪一步。
- 如果 AMP 训练第 20 step 开始出现 nan,设计一个二分排查流程:看哪些日志、禁用哪些局部 autocast、如何和 FP32 baseline 比。
- 解释 FP8 E4M3 / E5M2 的差异,以及为什么 FP8 必须配合 scale / amax history 才能训练。
今日检查清单
- 能解释指数位和尾数位分别控制什么,并用这个视角比较 FP16 / BF16
- 能写出新版
torch.amp.autocast与torch.amp.GradScaler的 AMP 训练模板 - 知道 backward 不需要单独包 autocast,只包 forward 和 loss
- 知道 FP16 下 gradient clipping 前必须先
scaler.unscale_(optimizer) - 能用
torch.cuda.max_memory_allocated()对比 FP32 / FP16 / BF16 的显存峰值 - 能用 loss 曲线和
scaler.get_scale()判断 AMP 是否健康 - 知道 BF16 是 A100/H100 上训练 LLM 的默认优先项,FP16 更依赖 GradScaler
- 能解释 FP8 为什么需要 scale / amax history,以及 TransformerEngine 大致做了什么
推荐阅读
PyTorch AMP 官方文档
重点看 torch.amp.autocast、torch.amp.GradScaler、op eligibility 三块。注意新版 API 已统一到 torch.amp,旧的 torch.cuda.amp 只应在维护老代码时出现。
PyTorch Automatic Mixed Precision Examples
官方例子覆盖典型训练循环、gradient clipping、gradient accumulation、多 loss / 多 optimizer 场景。今天的实践模板就是从这些模式抽象出来的。
Mixed Precision Training Guide
NVIDIA 对 Tensor Core、FP16 accumulation、loss scaling 的解释最直接。适合补硬件视角:为什么矩阵维度对齐、GEMM 大小、累加 dtype 会影响真实吞吐。
NVIDIA TransformerEngine 文档
学习 FP8 的入口。重点看 delayed scaling、E4M3/E5M2 hybrid format、FP8 autocast、支持的 layer。不要先读论文,先把工程 API 的形状看懂。
FP8 Formats for Deep Learning
理解 E4M3 / E5M2 为什么这样设计,以及前向和反向为何选择不同格式。读完你会更清楚 FP8 的难点不是位宽,而是动态范围校准。
Day 13 预告
torch.compile / TorchDynamo / Inductor — 从 eager 到图编译
今天我们让单个 op 在更合适的 dtype 上跑得更快;明天继续往上看一层:PyTorch 如何把 Python eager 执行捕获成 FX graph,用 guards 保证语义,再交给 Inductor 做融合与代码生成。你会 benchmark 同一个小模型的 eager vs compile,第一次真正看到"图捕获 + 算子融合"带来的端到端收益。