【AIaaS 全栈架构师】第 9 篇:混合精度训练——FP16、BF16 与 FP8 的精度博弈

本系列定位:本系列是一套面向全栈架构师的 AIaaS(AI as a Service)系统化教程,覆盖从概念全景、模型训练/微调底层原理、推理部署优化、Go 平台工程、GPU 集群编排到平台核心服务与运维前沿的完整知识体系。技术栈以 Go 为主,Python/C++ 为辅。


本篇你将学到

  • 理解浮点数表示原理:符号位、指数位、尾数位如何共同决定精度和范围
  • 掌握 FP16 的局限性与 Loss Scaling 机制
  • 理解 BF16 为何成为大模型训练的事实标准
  • 了解 FP8 在 Hopper 架构上的突破及其工程实践

学完本篇,你将能够为不同 GPU 架构和训练场景选择最合适的数值精度,并理解混合精度训练背后的工程权衡。


一、浮点数表示基础

1.1 IEEE 754 浮点数格式

在讨论各种精度格式之前,我们需要理解浮点数在计算机中的表示方式。IEEE 754 标准定义了浮点数的存储格式,一个浮点数由三部分组成:

┌───────┬───────────────┬─────────────────────────────────┐
│ 符号位 │    指数位      │           尾数位                │
│ Sign  │   Exponent    │          Mantissa               │
│ (1位)  │   决定动态范围 │         决定精度                │
└───────┴───────────────┴─────────────────────────────────┘
  • 符号位(Sign):1 位,0 表示正数,1 表示负数
  • 指数位(Exponent):决定数值的动态范围(能表示的最大值和最小值)
  • 尾数位(Mantissa/Fraction):决定数值的精度(有效数字位数)

数值的计算公式为:

value = (-1)^sign × 1.mantissa × 2^(exponent - bias)

1.2 范围与精度的权衡

对于固定的总位宽,指数位和尾数位存在此消彼长的关系:

  • 更多指数位 → 更大的动态范围,但精度降低
  • 更多尾数位 → 更高的精度,但范围缩小

位分配权衡

固定总位宽
例如 16 位

分配给指数位
→ 动态范围 ↑
→ 精度 ↓

分配给尾数位
→ 精度 ↑
→ 动态范围 ↓

这个权衡是理解 FP16 vs BF16 差异的关键。


二、各精度格式详解

2.1 FP32:单精度浮点数(基准)

FP32(Single Precision)是深度学习的传统默认精度。

属性值
总位宽32 位(4 字节)
符号位1 位
指数位8 位
尾数位23 位
最大值约 3.4 × 10³⁸
最小正数约 1.2 × 10⁻³⁸
有效十进制位数约 7 位

FP32 的动态范围和精度都非常充裕,几乎不会遇到数值溢出或下溢问题。但代价是每个数值占用 4 字节,训练大模型时显存消耗巨大。

2.2 FP16:半精度浮点数

FP16(Half Precision)将位宽减半,显存直接节省 50%。

属性值
总位宽16 位(2 字节)
符号位1 位
指数位5 位
尾数位10 位
最大值约 65504
最小正数约 5.96 × 10⁻⁸
有效十进制位数约 3-4 位

FP32 vs FP16 位分配

减半指数位
减半尾数位

FP32: [1|8|23]
范围: ±3.4e38
精度: ~7位

FP16: [1|5|10]
范围: ±65504
精度: ~3-4位

风险:动态范围大幅缩小

FP16 的核心问题:指数位从 8 减到 5,动态范围从 10³⁸ 缩小到 65504。这意味着:

  1. 梯度下溢:深度学习中的梯度值经常小于 10⁻⁵,FP16 的最小正规数约 6×10⁻⁸,很多梯度会变成 0(下溢)
  2. 激活值溢出:某些层的激活值可能超过 65504(上溢),导致 NaN

2.3 BF16:Brain Float 16

BF16(Bfloat16)是 Google Brain 团队设计的 16 位浮点格式,核心思路是保持 FP32 的指数位宽度,牺牲尾数精度。

属性值
总位宽16 位(2 字节)
符号位1 位
指数位8 位(与 FP32 相同!)
尾数位7 位
最大值约 3.4 × 10³⁸(与 FP32 相同!)
最小正数约 1.2 × 10⁻³⁸(与 FP32 相同!)
有效十进制位数约 2-3 位

三种 16 位格式的位分配对比

截断尾数即得 BF16

FP32: |S|EEEEEEEE|MMMMMMMMMMMMMMMMMMMMMMM|
1+8+23 = 32 bit

BF16: |S|EEEEEEEE|MMMMMMM|
1+8+7 = 16 bit

FP16: |S|EEEEE|MMMMMMMMMM|
1+5+10 = 16 bit

BF16 的关键优势:

  1. 动态范围与 FP32 完全相同:指数位都是 8 位,不存在 FP16 的梯度下溢问题
  2. 无需 Loss Scaling:这是 BF16 相比 FP16 最大的工程优势
  3. 转换简单:BF16 ↔ FP32 转换只需截断/填充尾数位,无需重新编码指数
  4. 精度足够:虽然尾数只有 7 位(约 2-3 位有效十进制数字),但对于深度学习的训练已经足够

2.4 FP8:8 位浮点数

FP8 是 NVIDIA Hopper 架构(H100)引入的 8 位浮点格式。FP8 有两种变体,以适应不同的数值分布:

格式符号位指数位尾数位适用场景
E4M3143前向传播(激活值、权重)
E5M2152反向传播(梯度)
  • E4M3:更多尾数位 → 精度更高,适合需要精度的前向传播。最大值约 448
  • E5M2:更多指数位 → 范围更大,适合需要范围的梯度。最大值约 57344

FP8 两种格式

E4M3: |S|EEEE|MMM|
1+4+3 = 8 bit
精度优先,最大448
→ 用于前向传播

E5M2: |S|EEEEE|MM|
1+5+2 = 8 bit
范围优先,最大57344
→ 用于反向传播(梯度)

前向:权重和激活值
用 E4M3

反向:梯度和误差
用 E5M2

FP8 将显存再减半(相比 FP16/BF16),且 H100 的 Tensor Core 原生支持 FP8 矩阵乘法,训练速度可提升 1.5-2 倍。但 FP8 训练的数值稳定性更难控制,需要精细的缩放策略。

2.5 全格式对比表

格式总位宽指数位尾数位最大值最小正规数显存(相对 FP32)硬件支持
FP32328233.4e381.2e-381×全部
FP1616510655046e-80.5×Volta+ (V100)
BF1616873.4e381.2e-380.5×Ampere+ (A100)
FP8 E4M38434482e-20.25×Hopper+ (H100)
FP8 E5M2852573446e-60.25×Hopper+ (H100)

三、混合精度训练原理

3.1 为什么不能全用低精度

直接将整个训练流程切换到 FP16/BF16 会有问题:

  1. 参数更新的精度损失:优化器更新公式 W_new = W_old - lr × grad 中,lr × grad 往往是一个非常小的数,在低精度下可能被截断为 0
  2. 累加误差:矩阵乘法的累加在低精度下误差会不断累积
  3. Loss 计算:损失函数中涉及 exp/log 等运算,需要高精度

因此,现代训练采用**混合精度(Mixed Precision)**策略——不同部分使用不同精度。

3.2 混合精度的经典策略

混合精度训练数据流

前向前转换

FP32 主权重
(优化器维护)

FP16/BF16 权重副本
(用于矩阵乘法)

前向传播
(全低精度计算)

Loss 计算
(建议 FP32)

反向传播
(全低精度计算)

FP16/BF16 梯度

转回 FP32 梯度

优化器更新
(FP32 精度)

混合精度训练的关键设计:

  1. 维护 FP32 主权重副本:优化器在 FP32 下维护和更新权重,保证参数更新的精度
  2. 前向和反向传播使用低精度:矩阵乘法是计算密集型操作,低精度的 Tensor Core 加速效果最明显
  3. Loss 计算使用 FP32:避免指数/对数运算的精度损失

这种设计意味着显存节省不到 50%(因为还保留了 FP32 的权重副本),但计算速度可以提升 2-3 倍。

3.3 混合精度的显存构成

以 7B 模型为例,混合精度训练的显存构成:

存储项精度大小
FP32 主权重FP3228 GB
FP16 计算副本FP1614 GB
FP16 梯度FP1614 GB
FP32 优化器状态(Adam)FP3256 GB
总计112 GB

而纯 FP32 训练需要 28+28+28+56 = 140 GB,混合精度节省了 20% 的显存。结合 ZeRO 分片,才能真正大幅降低单卡显存需求。


四、FP16 与 Loss Scaling

4.1 梯度下溢问题

FP16 的最小正规数约为 6×10⁻⁸,而深度学习中的梯度经常小于 10⁻⁵。这些小梯度在 FP16 中会直接变成 0,导致训练停滞。

4.2 Loss Scaling 机制

Loss Scaling 是解决梯度下溢的标准方法,其思路非常巧妙:

  1. 在前向传播计算出 loss 后,将其乘以一个很大的缩放因子 S(如 2¹⁶ = 65536)
  2. 反向传播时,梯度也自动被放大了 S 倍,从而脱离 FP16 的下溢区
  3. 在优化器更新前,将梯度除以 S 恢复原始值(转为 FP32 再除)

有

无

是

否

前向传播
计算 loss

Loss × S
(S = 缩放因子)

反向传播
梯度自动放大 S 倍
脱离下溢区

梯度中
有 NaN/Inf?

缩放因子减半
S = S / 2
跳过本次更新

梯度 ÷ S
恢复原始值
(FP32 运算)

优化器更新
(FP32 精度)

连续 N 步
无溢出?

缩放因子加倍
S = S × 2

4.3 动态 Loss Scaling

现代框架采用动态 Loss Scaling——自动调整缩放因子 S:

  • 如果连续 N 步(如 2000 步)没有溢出,将 S 翻倍
  • 如果检测到梯度中有 Inf/NaN,将 S 减半,跳过本次更新
# PyTorch 自动混合精度的 Loss Scaling 示例
import torch
from torch.cuda.amp import autocast, GradScaler

model = MyModel().cuda()
optimizer = torch.optim.AdamW(model.parameters(), lr=1e-4)

# GradScaler 自动处理动态 Loss Scaling
scaler = GradScaler(init_scale=2**16)

for batch in dataloader:
    inputs, labels = batch
    inputs, labels = inputs.cuda(), labels.cuda()

    optimizer.zero_grad()

    # autocast 上下文管理器:自动在 FP16/FP32 之间切换
    with autocast(dtype=torch.float16):
        outputs = model(inputs)
        loss = loss_fn(outputs, labels)

    # scaler.scale(loss) 会自动放大 loss
    scaler.scale(loss).backward()

    # scaler.step() 会自动 unscale 梯度并检查溢出
    scaler.step(optimizer)

    # 更新缩放因子
    scaler.update()

五、BF16:大模型训练的事实标准

5.1 为什么 BF16 成为标准

从 2022 年开始,BF16 逐渐取代 FP16 成为大模型训练的首选精度。核心原因:

维度FP16BF16
动态范围±65504±3.4e38(与 FP32 相同)
需要 Loss Scaling是否
精度3-4 位十进制2-3 位十进制
硬件支持V100+(2017)A100+(2020)
训练稳定性需调参(scale factor)开箱即用

BF16 的核心优势是"无需 Loss Scaling"。这意味着:

  • 不需要调试缩放因子
  • 不会因为 scale factor 过大导致训练中断
  • 不会因为 scale factor 过小导致梯度下溢

这使得 BF16 训练的工程复杂度大幅降低。对于动辄训练数周的大模型来说,稳定性比微小的精度差异重要得多。

5.2 BF16 训练示例

import torch
from torch.amp import autocast

model = MyModel().cuda()
optimizer = torch.optim.AdamW(model.parameters(), lr=1e-4)

# BF16 不需要 GradScaler!
for batch in dataloader:
    inputs, labels = batch
    inputs, labels = inputs.cuda(), labels.cuda()

    optimizer.zero_grad()

    # BF16 混合精度
    with autocast(device_type='cuda', dtype=torch.bfloat16):
        outputs = model(inputs)
        loss = loss_fn(outputs, labels)

    loss.backward()  # 直接反向传播,无需 scaling
    optimizer.step()

对比 FP16 的代码,BF16 去掉了 GradScaler,代码更简洁,也更不容易出错。

5.3 BF16 vs FP16 精度影响

BF16 尾数只有 7 位(比 FP16 少 3 位),精度确实更低。但研究表明:

  • 对于大模型训练(7B+),BF16 和 FP16 的最终模型质量几乎无差异
  • 对于小模型或高精度要求的任务(如数值预测),FP16 的额外精度可能有帮助
  • 训练稳定性方面,BF16 显著优于 FP16

六、FP8:Hopper 架构的新前沿

6.1 Transformer Engine

NVIDIA H100 引入了 Transformer Engine(TE),这是一个专用的硬件单元,支持 FP8 矩阵乘法。TE 能够自动在前向传播中使用 E4M3 格式、在反向传播中使用 E5M2 格式。

FP8 训练流程

FP32 主权重

E4M3 权重
(前向用)

前向传播
E4M3 矩阵乘法
(H100 Tensor Core)

Loss(FP32)

反向传播
E5M2 梯度计算

E5M2 梯度

转 FP32 梯度

FP32 优化器更新

6.2 FP8 的缩放策略

FP8 的动态范围比 FP16 更小,需要更精细的缩放策略。Transformer Engine 采用每张量缩放(per-tensor scaling):

# 使用 NVIDIA Transformer Engine 进行 FP8 训练
import transformer_engine.pytorch as te
import torch

# TE 提供兼容的模块(自动处理 FP8 转换)
model = te.TransformerLayer(
    hidden_size=4096,
    ffn_hidden_size=16384,
    num_attention_heads=32,
    layernorm_eps=1e-5
).cuda()

optimizer = torch.optim.AdamW(model.parameters(), lr=1e-4)

# FP8 自动混合精度上下文
for batch in dataloader:
    inputs, labels = batch
    inputs, labels = inputs.cuda(), labels.cuda()

    optimizer.zero_grad()

    with te.fp8_autocast():
        outputs = model(inputs)
        loss = loss_fn(outputs, labels)

    loss.backward()
    optimizer.step()

TE 内部会自动:

  1. 监控每个张量的数值范围
  2. 为每个张量计算合适的缩放因子
  3. 在 FP8 计算和 FP32 累加之间自动切换

6.3 FP8 的性能收益

在 H100 上,FP8 相比 BF16 的典型收益:

指标BF16FP8提升
矩阵乘法吞吐989 TFLOPS1979 TFLOPS2.0×
显存占用(权重)1×0.5×2× 节省
实际训练速度基准1.5-1.8×~1.7×

注意:实际训练速度提升通常小于理论 2×,因为并非所有操作都能用 FP8 加速(如 LayerNorm、Softmax 仍需 FP32)。


七、精度选择决策矩阵

7.1 按 GPU 架构选择

GPU 架构代表型号最佳训练精度备注
PascalP100FP32不支持 Tensor Core
VoltaV100FP16首代 Tensor Core,需 Loss Scaling
TuringT4FP16同 V100
AmpereA100BF16首次支持 BF16,推荐 BF16
HopperH100BF16 / FP8支持 FP8,TE 自动优化
BlackwellB200FP8 / FP4支持 FP4(更激进)

7.2 按训练场景选择

V100 或更早

A100

H100

是

否

否

是

选择训练精度

GPU 型号?

使用 FP16
必须配 Loss Scaling

推荐 BF16
无需 Loss Scaling

追求极限性能?

使用 FP8 + Transformer Engine
需调试缩放策略

使用 BF16
简单稳定

训练稳定?

FP8 训练

7.3 经验总结

场景推荐精度理由
预训练(A100)BF16稳定,无需调参
预训练(H100,追求速度)FP81.5-2× 加速
微调(LoRA/QLoRA)BF16微调数据量小,精度足够
对齐训练(RLHF/DPO)BF16需要高稳定性
小模型(< 1B)FP16 或 FP32BF16 精度可能不够
学术实验复现FP32可复现性最高

八、常见问题与排查

8.1 训练出现 NaN

混合精度训练中出现 NaN 的常见原因和解决方案:

原因排查方法解决方案
FP16 梯度溢出检查 scale factor 是否频繁减半增大初始 scale 或切换 BF16
学习率过大检查 loss 曲线是否突然飙升降低学习率,增加 warmup
某层激活值溢出逐层检查激活值范围对该层使用 FP32 或添加 LayerNorm
数据中含异常值检查输入数据范围数据清洗,clip 异常值

8.2 BF16 训练 loss 不收敛

虽然 BF16 的动态范围大,但尾数精度低。如果训练不收敛,可能原因:

  1. 学习率不匹配:BF16 的低精度可能需要稍微不同的学习率。通常 BF16 的学习率可以与 FP32 相同
  2. 某些操作需要高精度:如 LayerNorm 的方差计算、Softmax 的指数运算,这些操作应保持 FP32。autocast 会自动处理,但自定义操作可能遗漏
  3. 梯度累加精度:如果使用梯度累加,累加操作应在 FP32 下进行
# 正确的梯度累加实现
with autocast(dtype=torch.bfloat16):
    output = model(input)
    loss = loss_fn(output, target) / accumulation_steps

# loss.backward() 在 FP32 下累加梯度
loss.backward()  # 梯度会自动转为 FP32 累加到 .grad

if (step + 1) % accumulation_steps == 0:
    optimizer.step()
    optimizer.zero_grad()

本篇小结

知识点核心内容
浮点数格式符号位+指数位(范围)+尾数位(精度),三者权衡
FP324 字节,8 位指数,23 位尾数,训练基准精度
FP162 字节,5 位指数(范围小),需 Loss Scaling 防梯度下溢
BF162 字节,8 位指数(范围=FP32),7 位尾数,无需 Loss Scaling
FP81 字节,E4M3(前向)/E5M2(反向),Hopper 原生支持
Loss ScalingFP16 训练必备,放大梯度脱离下溢区,动态调整
混合精度策略维护 FP32 主权重,前向/反向用低精度,优化器用 FP32
精度选择A100→BF16,H100→BF16/FP8,V100→FP16+Loss Scaling

下篇预告

第 10 篇:参数高效微调 PEFT——LoRA 与 QLoRA 原理详解

前三篇我们讨论了如何高效训练大模型。但在很多场景下,我们并不需要训练全部参数——只需要微调。下一篇将深入 LoRA(低秩适配)和 QLoRA(量化+LoRA)的数学原理与实践方法,看它们如何实现"单卡微调 65B 模型"的奇迹。


如果本篇内容对你有帮助,欢迎点赞收藏!有任何疑问,欢迎在评论区交流。

Logo

北京人形旗下天工造物具身智能开源社区,聚焦具身天工与慧思开物两大平台

更多推荐