3、混合精度训练(AMP):FP16与FP32的区别、自动混合精度原理、GradScaler的使用、实际收益与陷阱

聊到显存优化,混合精度训练绝对是个绕不开的话题。我刚开始接触这玩意儿的时候,心里也犯嘀咕:把精度砍一半,模型还能收敛吗?后来踩过几次坑,才真正摸透了它的脾气。

说白了,混合精度训练就是让模型在训练时,该用 FP16 的地方用 FP16,该用 FP32 的地方用 FP32。不是一刀切,而是有策略地混着来。这样既能省显存、提速度,又能保住模型的收敛质量。

FP16 与 FP32 的区别

先看一张表,直观感受下两者的差异:

特性 FP32(单精度) FP16(半精度)
位宽 32 位 16 位
指数位 8 位 5 位
尾数位 23 位 10 位
表示范围 ~1.4e-45 到 3.4e38 ~6e-8 到 65504
显存占用 4 字节 2 字节
计算速度 基准 约 2-8 倍(看硬件)

FP16 的显存占用直接砍半,计算速度还能翻倍。但代价是什么?精度丢失和数值溢出。FP16 能表示的最小正数是 6e-8,最大是 65504。你想想看,如果梯度值小于 6e-8,直接变成 0——这就是所谓的「下溢出」。反过来,大于 65504 就变成无穷大——「上溢出」。

我在项目中遇到过一个大模型训练,loss 突然变成 NaN。排查了半天,发现就是某个层的梯度值超过了 FP16 的上限。嗯,这就是典型的溢出问题。

自动混合精度原理

自动混合精度(AMP)的核心思路,就是把 FP16 和 FP32 结合起来用。具体来说:

  • 前向传播:用 FP16 计算,省显存、提速度
  • 反向传播:用 FP16 计算梯度,同样省显存
  • 权重更新:用 FP32 维护一份权重副本,保证精度
  • 损失缩放:对 loss 进行缩放,防止梯度下溢出

为什么会这样设计?因为权重更新这一步对精度最敏感。你想想看,如果权重本身精度不够,模型参数就会慢慢漂移,最终收敛不了。所以 AMP 的做法是:计算用 FP16,存储用 FP32。

我个人习惯用 PyTorch 的 torch.cuda.amp 模块。它提供了两个核心组件:autocast 和 GradScaler。

核心逻辑:autocast 自动选择精度,GradScaler 防止梯度下溢出。两者配合,才能发挥 AMP 的最大威力。

下面这张图展示了 AMP 的整体流程:

AMP 自动混合精度训练流程 输入数据 (FP32) autocast 自动选择精度 前向传播用 FP16,敏感层用 FP32 计算损失 (FP16) GradScaler 损失缩放 放大 loss → 防止梯度下溢出 权重更新 (FP32) 关键点 • 权重副本用 FP32 存储 • 梯度用 FP16 计算 • 损失缩放防止下溢出 • 敏感层自动回退 FP32 • 显存占用减少约 40% • 训练速度提升 1.5-3 倍

GradScaler 的使用

GradScaler 是 AMP 中防止梯度下溢出的关键。它的工作原理很简单:在反向传播前,把 loss 放大一个倍数。这样梯度值也跟着放大,就不会掉到 FP16 的表示范围之外了。更新权重时,再把梯度缩回来。

看一段实际代码:

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

# 初始化 GradScaler
scaler = GradScaler()

for data, target in dataloader:
    optimizer.zero_grad()
    
    # 使用 autocast 自动管理精度
    with autocast():
        output = model(data)
        loss = loss_fn(output, target)
    
    # 关键步骤:用 scaler 放大 loss
    scaler.scale(loss).backward()
    
    # 更新权重前,先缩放梯度
    scaler.step(optimizer)
    
    # 更新 scaler 的缩放因子
    scaler.update()

这段代码里,scaler.scale(loss).backward() 做了两件事:放大 loss,然后反向传播。放大后的梯度就不会下溢出了。scaler.step(optimizer) 在更新权重前,会自动把梯度缩回来。scaler.update() 会根据梯度情况动态调整缩放因子。

小技巧:我习惯把 scaler.update() 放在每个 batch 的最后。如果某个 batch 的梯度出现 NaN,scaler 会自动增大缩放因子,保证后续训练稳定。

实际收益与陷阱

AMP 带来的收益是实打实的。我测试过一个 7B 参数的模型:

  • 显存节省:从 24GB 降到 14GB,省了约 40%
  • 训练速度:从 3.2 it/s 提升到 5.8 it/s,快了 80%
  • 模型精度:验证集准确率从 72.3% 降到 72.1%,几乎没损失

但陷阱也不少。我曾经踩过几个坑,分享给你:

陷阱一:梯度爆炸

如果模型本身不稳定,AMP 会放大梯度爆炸的问题。因为 FP16 的表示范围小,一旦梯度值超过 65504,直接变成无穷大。我建议先跑几个 batch 看看梯度范数,如果太大,先调小学习率。

陷阱二:某些层不适合 FP16

BatchNorm、LayerNorm 这些层对精度很敏感。AMP 会自动把它们回退到 FP32,但如果你手动改了设置,可能会出问题。我曾经手贱把 BatchNorm 强制设为 FP16,结果 loss 直接飞了。

陷阱三:分布式训练时的同步问题

多卡训练时,梯度同步用的是 FP16。如果某个卡的梯度下溢出了,同步后所有卡的梯度都变成 0。我建议在 scaler.step 之前,检查一下梯度是否有效。

嗯,这里要注意一点:AMP 不是万能的。对于小模型(参数量小于 100M),AMP 的收益不明显,反而可能因为精度损失导致收敛变慢。我一般只在模型超过 1B 参数时才用 AMP。

最后说一句:AMP 的配置其实很简单,但调优需要经验。我建议你先在 1-2 个 epoch 上跑通,观察 loss 曲线和梯度范数。如果一切正常,再全量跑。这样能避免浪费算力。

想要更多资料,联系可可