你知道FP6和BF6的区别吗?一文搞懂AI训练如何选择数值精度

30 阅读3分钟

你知道FP6和BF6的区别吗?一文搞懂AI训练如何选择数值精度

"God made the integers, all else is the work of man." —— Leopold Kronecker

在 Python 里运行这行代码:

>>> 0.1 + 0.2 == 0.3
False
>>> 0.1 + 0.2
0.30000000000000004

大多数人第一次看到这个结果都会觉得是 Python 的 Bug。但实际上这是所有现代编程语言共同的行为,C、Java、JavaScript 一样。

这背后的原因,和大模型为什么从 FP32 切换到 BF16、为什么梯度有时会突然变成 nan、为什么量化会损失精度——都是同一个物理原因。


3.1 浮点数的物理结构(IEEE 754)

计算机只有比特(0 和 1)。要用有限的比特表示无限稠密的实数,必然要做取舍。IEEE 754 标准的设计思路是:用科学计数法的二进制版本

就像十进制的 1.234×1051.234 \times 10^5,一个浮点数被拆成三部分存储:

=(1)符号×(1+尾数)×2指数偏置\text{值} = (-1)^{\text{符号}} \times (1 + \text{尾数}) \times 2^{\text{指数} - \text{偏置}}
  • 符号位(Sign):1 bit,0 正 1 负。
  • 指数位(Exponent):决定数值的量级(能表示多大/多小的数)。
  • 尾数位(Mantissa):决定数值的精度(有效数字有几位)。

三种格式的结构对比一目了然:

图:三行分别是 FP32、FP16、BF16,色块宽度正比于位数。红色=符号位,蓝色=指数位,绿色=尾数位。右侧标注了各格式的动态范围和十进制精度。

核心结论一眼能看出来:BF16 就是把 FP32 直接截断保留前 16 位——指数位位数不变(都是 8 bit),只砍掉了尾数位的精度。这个设计决策的含义下一节详说。

为什么 0.1 + 0.2 ≠ 0.3?

因为 0.10.1 在二进制里是一个无限循环小数

0.110=0.000110011001120.1_{10} = 0.0001100110011\ldots_2

就像 1/31/3 在十进制里无法精确表示(0.3330.333\ldots)一样,FP64 只能存有限位,截断后引入了误差。两次截断误差相加,就出现了 0.300000000000000040.30000000000000004

动手试试

import struct, numpy as np
# 看 0.1 在内存里实际存了什么
b = struct.pack('f', 0.1)
print(bin(int.from_bytes(b, 'little')))
# 0b111101110011001100110011001101  ← 注意尾数不是精确的

浮点数在数轴上的分布是不均匀的

这是理解所有数值问题的关键洞察:

图:上方是 Mini-Float(1+3+2 bit)的数轴分布,蓝线越稀疏代表精度越低;下方柱状图显示相邻浮点数的间隔随数值增大而增大。

数值越大,相邻两个可表示数之间的"间隙"越大。任何落在间隙里的计算结果都会被强制舍入到最近的可表示数。

这直接导致了「大数吃小数」:

import torch
a = torch.tensor(10_000_000.0, dtype=torch.float32)
b = torch.tensor(0.0000001,    dtype=torch.float32)
print(a + b)   # tensor(1.0000e+07)  ← b 消失了

10710^7 附近 FP32 的间隙约为 11,而 0.0000001=1070.0000001 = 10^{-7} 比这个间隙小了 14 个数量级,直接被抹掉。

上溢出与下溢出

两个边界情况:

  • 上溢出(Overflow):计算结果超过格式能表示的最大值,变成 inf(无穷大)。

    • FP16 最大值只有 65504,乘法很容易溢出。
    • 训练中出现 loss = inf 通常是这个原因。
  • 下溢出(Underflow):计算结果太接近 0,比格式能表示的最小正数还小,直接归零。

    • FP16 最小正规数约为 6×1056 \times 10^{-5}
    • 反向传播中梯度经过多层乘法后可能非常小,在 FP16 下直接变成 0.0,参数永远不更新——这就是梯度消失的数值根因之一。

3.2 AI 训练中的精度选择

理解了浮点数的物理结构,现在来看三种格式在 AI 训练中的命运:

图:横轴是最大可表示数值的 log10,越长动态范围越大。注意 FP16 的动态范围远小于 BF16 和 FP32,而 BF16 与 FP32 几乎相同。

为什么 BF16 赢了 FP16?

FP16 的早期混合精度训练(V100 时代)面临一个持续的头痛问题:训练崩溃

原因很直接——FP16 的指数位只有 5 bit,最大值 65504。而大模型中间层的激活值、梯度、权重更新量,随时可能超过这个上限变成 inf,然后整个训练崩掉。

Google 的工程师提出了 BF16(Brain Float 16),做了一个极其简单的设计:直接把 FP32 后 16 bit 截掉

  • 总位数,FP16:16,BF16:16
  • 指数位,FP16:5 bit(最大值 65504),BF16:8 bit(最大值 ~3.4×10³⁸)
  • 尾数位,FP16:10 bit(精度高),BF16:7 bit(精度低)
  • 溢出风险,FP16:高(需要 Loss Scaling),BF16:几乎没有
  • 神经网络适配性,FP16:一般,BF16:(网络对精度鲁棒,对范围敏感)

神经网络天然对精度(尾数位)有鲁棒性——加一点噪声(量化误差)通常不影响收敛;但对范围(指数位)非常脆弱——一旦溢出就 nan/inf,训练直接挂。BF16 正好满足了这个需求。

科普:BF16 最早由 Google Brain 提出,用于 TPU 训练。随后 NVIDIA 在 Ampere 架构(A100)开始原生支持 BF16 Tensor Core,让 BF16 成为了大模型训练的默认精度。

Loss Scaling(仅 FP16 需要)

如果你用的是老显卡(V100、T4、2080Ti)只支持 FP16,需要手动处理下溢出问题。

核心思路:人为把 Loss 放大,让梯度"显得更大",避免被下溢出归零

from torch.cuda.amp import autocast, GradScaler

scaler = GradScaler()   # 自动管理 scale 因子

for x, y in dataloader:
    optimizer.zero_grad()
    with autocast():                    # 前向用 FP16
        loss = model(x, y)
    scaler.scale(loss).backward()       # loss × scale → 反向传播梯度也被放大
    scaler.step(optimizer)              # 梯度 ÷ scale 还原,再更新参数
    scaler.update()                     # 动态调整 scale(没有 inf/nan 就加倍)

PyTorch 的 GradScaler 还会自动检测梯度里是否出现了 inf/nan:如果出现,跳过这次参数更新,把 scale 因子减半,再试一次。

常见误区:用了 BF16 还需要 Loss Scaling 吗?不需要。BF16 的动态范围和 FP32 一样,不存在 FP16 的溢出问题,GradScaler 在 BF16 下是 no-op(什么都不做)。


3.3 量化初步

BF16 相比 FP32,显存减半、速度翻倍。那能不能继续压缩?

量化(Quantization) 是下一步:把浮点数映射到整数格点。

最常见的线性量化公式:

xq=round ⁣(xs)+zx_q = \text{round}\!\left(\frac{x}{s}\right) + z

其中 ss(scale)是缩放因子,zz(zero-point)是零点偏移。反量化:xs(xqz)x \approx s \cdot (x_q - z)

  • FP8 E4M3,位宽:8 bit 浮点(4 指数位,3 尾数位),显存相比 FP32:÷4,适用场景:H100 训练前向、权重/激活
  • FP8 E5M2,位宽:8 bit 浮点(5 指数位,2 尾数位),显存相比 FP32:÷4,适用场景:H100 训练反向、梯度
  • INT8,位宽:8 bit 整数,显存相比 FP32:÷4,适用场景:推理加速(Tensor Core 支持)
  • W4A16,位宽:权重 4 bit,激活 16 bit,显存相比 FP32:÷8(权重),适用场景:大模型推理(AWQ/GPTQ)
  • INT4,位宽:4 bit 整数,显存相比 FP32:÷8,适用场景:极限压缩推理

FP8 的两种变体设计有不同取向

  • E4M3(4 指数位,3 尾数位):精度稍高,动态范围略小(最大值 448)。适合前向传播的权重和激活值——数值通常集中在较小范围内,更高精度更重要。
  • E5M2(5 指数位,2 尾数位):动态范围更大(最大值 57344),精度稍低。适合反向传播的梯度——梯度分布较宽,动态范围比精度更关键。

H100 原生支持 FP8 Tensor Core,在 Transformer Engine(NVIDIA 的 FP8 训练库)里实现了自动缩放(per-tensor scaling):每个张量动态计算一个缩放因子,把数值映射到 FP8 的最佳表示范围,然后在 Tensor Core 内用 FP8 计算,累加器用 FP32,结果再转回 BF16 存储。

量化有两种主要方式:

  • PTQ(Post-Training Quantization,训练后量化):模型训练好之后再量化,简单快速,但精度损失相对大。适合推理部署。
  • QAT(Quantization-Aware Training,感知量化训练):训练过程中模拟量化误差(在前向中加入量化-反量化操作),让模型"习惯"量化带来的噪声。精度更好,但训练更复杂。

动手试试:用 bitsandbytes 一行代码量化 HuggingFace 模型:

from transformers import AutoModelForCausalLM, BitsAndBytesConfig
import torch

bnb_config = BitsAndBytesConfig(load_in_4bit=True,
                                 bnb_4bit_compute_dtype=torch.bfloat16)
model = AutoModelForCausalLM.from_pretrained(
    "meta-llama/Llama-3-8B",
    quantization_config=bnb_config,
    device_map="auto"
)
# 原本需要 ~16 GB 显存,量化后只需 ~5 GB

核心结论:浮点数不是精确的实数,它是一把刻度不均匀的尺子——靠近 0 的地方精度高,数值越大精度越低。AI 训练的精度选择本质上是在"动态范围"和"精度"之间做权衡:FP16 精度高但易溢出;BF16 动态范围大但精度低;量化进一步压缩位宽以换取速度和显存。神经网络对范围敏感、对精度鲁棒,这是 BF16 和量化在实践中成功的根本原因。