第4章:混合精度训练(AMP):FP16与FP32原理

显存优化这件事,说白了就是跟精度做交易。你想想看,FP32占4个字节,FP16只占2个字节,直接砍半。但问题来了——精度降了,模型还能收敛吗?

我个人习惯把混合精度训练比作「开车过弯」。FP32是四平八稳的SUV,FP16是轻量跑车。跑直线时跑车更快,但过急弯容易翻车。混合精度就是让跑车在直道飙,过弯时自动减速——嗯,这就是AMP干的事。

4.1 FP16与FP32:精度与范围的博弈

先看一组数据,我当年第一次看到时也挺惊讶的:

精度类型 位宽 指数位 尾数位 最大值 最小值
FP32 32 bit 8 bit 23 bit ~3.4e38 ~1.4e-45
FP16 16 bit 5 bit 10 bit ~65504 ~6.0e-8

看到问题了吗?FP16的最大值才65504。这意味着什么?我举个例子——你在做Transformer训练时,注意力分数稍微大一点,直接溢出变成inf。我在项目中遇到过这种情况,当时排查了半天,最后发现是softmax前的数值超过了65504。

⚠️ 避坑指南: 我曾经在训练BERT-large时,FP16下loss直接变成NaN。查了两天才发现是LayerNorm的gamma参数在反向传播时溢出了。后来加了GradScaler才解决。

4.2 AMP自动混合精度:让框架替你操心

AMP的核心思想很简单:该用FP16的地方用FP16,该保留FP32的地方保留FP32。具体来说:

  • 计算密集型操作(卷积、矩阵乘法)→ FP16
  • 精度敏感操作(BatchNorm、LayerNorm、softmax)→ FP32
  • 权重更新(优化器步骤)→ FP32

PyTorch的AMP实现起来就几行代码:

import torch
from torch.cuda.amp import autocast, GradScaler

model = MyModel().cuda()
optimizer = torch.optim.Adam(model.parameters(), lr=1e-3)
scaler = GradScaler()  # 关键组件

for data, target in dataloader:
    optimizer.zero_grad()
    
    # 自动混合精度上下文
    with autocast():
        output = model(data)
        loss = loss_fn(output, target)
    
    # 用scaler缩放梯度
    scaler.scale(loss).backward()
    scaler.step(optimizer)
    scaler.update()

你看,就多了三行代码:autocast、scaler.scale、scaler.update。但效果立竿见影。

💡 个人经验: 我习惯把scaler定义在训练循环外面,这样每个epoch都能复用。另外,记得在验证/推理时也要用with autocast():,否则推理速度会慢一截。

4.3 GradScaler:为什么需要它?

你可能会问:既然用了FP16,为什么还要缩放梯度?

原因很简单——FP16的表示范围太窄了。梯度在反向传播时,很多值会小于FP16的最小表示范围(6.0e-8),直接变成0。这就是所谓的「梯度下溢」问题。

GradScaler的做法是:

  1. 前向传播时,loss乘以一个缩放因子(默认2^16=65536)
  2. 反向传播时,梯度也相应放大
  3. 更新权重前,再把梯度缩回来

这样,原本会下溢的梯度就被「抬」到了FP16的可表示范围内。

核心机制:

  • 初始缩放因子:2^16
  • 如果出现inf/NaN → 缩放因子减半,跳过当前batch
  • 连续N个batch无异常 → 缩放因子加倍(N默认2000)

我记得有一次训练GAN,生成器和判别器交替更新,梯度波动特别大。如果不加GradScaler,生成器的梯度几乎全为0,模型根本学不动。加上之后,训练曲线立马正常了。

4.4 显存节省效果分析

直接上数据,这是我用ResNet-50在ImageNet上做的实测:

配置 显存占用 训练速度 准确率
FP32(基线) 12.4 GB 1.0x 76.1%
AMP(FP16) 7.8 GB 1.8x 76.0%
AMP + GradScaler 7.8 GB 1.7x 76.1%

看到没?显存直接省了37%,速度提升了70%。准确率几乎没变。这就是为什么我强烈建议——只要你的GPU支持FP16(Volta架构及以上),AMP就是必选项。

但要注意,不是所有模型都适合AMP。我踩过的一个坑是:训练BERT-large时,AMP导致某些层的梯度一直为0。后来发现是LayerNorm的精度太敏感,需要手动指定某些操作用FP32。

⚠️ 避坑指南: 如果你发现AMP训练时loss曲线异常(比如突然跳变、长时间不下降),先检查两点:
  1. 是否有inf/NaN出现 → 调低GradScaler的初始缩放因子
  2. 某些层是否精度敏感 → 用torch.cuda.amp.custom_fwd手动指定FP32

4.5 知识体系总览

下面这张图是我自己整理的AMP核心逻辑,你看一遍就能记住:

混合精度训练(AMP)核心流程 输入数据 (FP32) 精度敏感? 是 FP32计算 否 FP16计算 GradScaler缩放 权重更新 (FP32) 图例: 输入 决策 FP32路径 FP16路径 GradScaler 输出

这张图把AMP的决策逻辑讲得很清楚。你只要记住:精度敏感走FP32,其他走FP16,最后统一用GradScaler兜底。就这么简单。

最后说一句,AMP不是银弹。如果你的模型本身就很小(比如显存占用不到2GB),加AMP的意义不大。但如果你在训大模型,或者想在同一张卡上塞更大的batch size——AMP就是你工具箱里最趁手的那个扳手。


想要更多资料,联系可可