2. 浮点数基础:FP16、BF16、FP32、TF32 的数据格式与精度对比

大伙儿做深度学习训练,每天都在跟浮点数打交道。但说真的,很多人对 FP32、FP16、BF16、TF32 这些格式的理解,也就停留在“位数不同”的层面。我之前在调一个超大模型时,就因为 FP16 的精度范围踩了个大坑——模型直接炸了。后来我才彻底搞明白,这些格式的差异,远不只是几个二进制位那么简单。

这一节,咱们就掰开揉碎了看。我会从位结构讲起,再对比它们的数值范围、精度、适用场景。你理解透之后,再做混合精度训练或者 GPU 调优,心里就有底了。

2.1 为什么要有多种浮点格式?

先回答一个基础问题:为什么不用统一的 FP32?

原因很简单:快和省。FP16 的带宽和计算吞吐比 FP32 高一倍,BF16 在保持大动态范围的同时也省显存。但天下没有免费的午餐——低位宽必然损失精度或者范围。你想想看,我们用混合精度训练,本质就是在精度和性能之间做权衡。

我在早期做 ResNet 训练时,觉得 FP16 省显存就无脑用,结果 loss 降不下去。后来才发现是 FP16 的指数位数太少,导致梯度下溢。这个坑,你们一定要避开。

我曾经遇到过:用 FP16 训练一个 BERT-large,跑了一上午 loss 都不动。排查了一天,发现是权重更新太小,在 FP16 下直接变零。后来换成混合精度(主权重用 FP32 副本)才解决。

所以,理解每种格式的位结构,才能知道它适合做什么。

2.2 四种格式的位排布对比

这里我直接给出一张结构对比表。注意看指数的位数——它决定了能表示的数值范围;尾数的位数决定了精度(相对误差)。

格式 总位数 符号位 (bit) 指数位 (bit) 尾数位 (bit) 偏置值 最大可表示值 最小正规值
FP32 32 1 8 23 127 ~3.4e38 ~1.18e-38
FP16 16 1 5 10 15 65504 ~6.1e-5
BF16 16 1 8 7 127 ~3.4e38 ~1.18e-38
TF32 19 1 8 10 127 ~3.4e38 ~1.18e-38

你看,FP16 的指数只有 5 位,所以最大只能到 65504,而 BF16 保留了 FP32 的 8 位指数,范围跟 FP32 一样大。但 BF16 的尾数只有 7 位,精度比 FP16 的 10 位尾数还低。所以 BF16 适合那些对数值范围敏感、但对精度容忍度高的任务(比如梯度累积)。

TF32 是 NVIDIA Ampere 架构推出的“混合”格式。它内部用 19 位存储(8 位指数 + 10 位尾数),但计算时自动处理。我的实测经验是:TF32 的精度跟 FP32 几乎没差别,但速度能快 1.5–2 倍。说白了,它是 NVIDIA 给不想改代码又想白嫖加速的人准备的。

2.3 一张图看懂四种格式的位结构

下面这张 SVG 把四种格式的位宽分布画出来了。注意看指数和尾数的长度对比——这是精度的根本区别。

四种浮点格式的位结构 图例: 符号位 指数位 尾数位 FP32 S 8-bit exponent 23-bit mantissa FP16 S 5-bit exp 10-bit mantissa BF16 S 8-bit exponent 7-bit mant TF32 S 8-bit exponent 10-bit mantissa 指数位越多 → 数值范围越大 尾数位越多 → 精度越高 单精度 (FP32) 半精度 (FP16) 脑浮点 (BF16) 张量浮点 (TF32)

从图上能很直观地看到:FP16 的指数段最短,所以能表示的最大值最小;BF16 的指数段长度跟 FP32 一样,但是尾数砍掉了一大截,精度损失严重。TF32 则是在 FP32 基础上压缩了尾数,保留了完整的指数范围。

2.4 精度与范围的数学对比

接下来我用量化的方式对比一下。你注意看 FP16 的最小正规值——只有 6.1e-5,也就是说比这个还小的正数都会变成零(下溢)。我之前训练一个 Transformer 模型,梯度里有很多元素在 1e-6 量级,FP16 直接把它们清零了。你想想看,这模型还能收敛才怪。

BF16 的最小正规值跟 FP32 一样,都是 1.18e-38,所以它对梯度下溢的容忍度极高。但它的相对精度(尾数的有效位数)只有约 2–3 位有效十进制数字。我建议在不需要高精度中间结果的地方用 BF16,比如 LayerNorm 的输入输出。

我习惯用下面这个表格来快速决定选哪种格式:

场景 推荐格式 原因
主权重存储 FP32 避免累积误差
前向传播(大多数层) FP16 / BF16 加速计算
梯度 FP32 副本 + 混合精度 防止下溢
损失函数 / 归一化层 FP32 精度敏感
大批量训练(显存吃紧) BF16 大动态范围 + 省显存
NVIDIA Ampere 以上 GPU TF32 (默认开启) 无需改代码即可加速
一个小技巧:如果你用 PyTorch,可以设置 torch.set_autocast_dtype(torch.bfloat16),它会自动在精度敏感层使用 FP32,其他地方用 BF16。我在训练 70B 模型时,用这个策略省了 30% 显存,收敛曲线跟全 FP32 几乎一致。

2.5 实际使用中的注意事项

  • FP16 的最大值只有 65504。如果你的激活值或权重超过这个数,就会变成 inf。我在做对比学习(SimCLR)时,温度系数设得太小导致 logits 很大,结果 FP16 全炸了。解决方法是做梯度裁剪或者换 BF16。
  • BF16 精度低但不丢范围。它特别适合梯度通信的场景。因为梯度通常很小但需要范围覆盖,用 BF16 做 allreduce 可以减少一半带宽,且几乎不影响最终精度。
  • TF32 只在 Ampere/Ada 架构上生效。你在 RTX 3090、A100、H100 上默认就能用。老架构(Volta、Turing)不支持,会回退到 FP32。可以用 torch.backends.cuda.matmul.allow_tf32 = True 确保开启。
  • 混合精度训练时,必须维护一份 FP32 的权重副本。大多数框架(AMP / bfloat16 autocast)自动帮你做了。但我见过有人自己写手撸训练循环,忘了做这一步,结果精度一路下跌。切记:master_weights 必须保留 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()  # 动态 loss scaling,防止 FP16 下溢

for data, target in dataloader:
    optimizer.zero_grad()
    with autocast(dtype=torch.float16):  # 或 torch.bfloat16
        output = model(data)
        loss = loss_fn(output, target)
    scaler.scale(loss).backward()
    scaler.step(optimizer)
    scaler.update()
核心要点:
  • FP32:通用标准,精度高,速度慢。
  • FP16:速度快,但范围窄、精度低,容易下溢/上溢。
  • BF16:范围与 FP32 一致,精度略低于 FP16,适合梯度通信和大模型训练。
  • TF32:Ampere 架构专属,零代码改动加速,精度几乎无损。

我个人建议:如果你用的是新 GPU(A100/H100 或同等),优先尝试 BF16+TF32 的组合。BF16 做前向和反向,TF32 做矩阵乘法,效果非常棒。如果模型较大且梯度容易消失,一定要用动态 loss scaling 或者直接上 BF16。