上一篇讲 fp16 的动态损失缩放,结论是:fp16 的动态范围太小,梯度要人为抬上去才能存活。bf16(brain floating point,Google 在 TPU 上首用)用另一种思路解决同一个问题——不改训练流程,改格式本身。两者都是 16 bit,差别只在字段划分:
$$ \text{fp16}: 1+5+10, \qquad \text{bf16}: 1+8+7. $$
一个数在二进制浮点下的值为
$$ x = (-1)^s \times 2^{e-\text{bias}} \times (1 + f), \qquad f = \sum_{i=1}^{p} b_i\, 2^{-i}, $$
其中 $p$ 是尾数位数。决定"能表示多大范围"的是指数位,决定"相邻两个数隔多远"的是尾数位。把位宽固定为 16,这就是一个零和分配。
数值边界的推导
fp16 的 5 位指数偏置为 15,最大规格数 $2^{15}\times(2-2^{-10}) = 65504$,最小正规格数 $2^{-14} \approx 6.1\times 10^{-5}$。尾数 10 位意味着 1.0 附近的相邻间隔是 $2^{-10} \approx 9.8\times 10^{-4}$——相对舍入误差上限 $2^{-11}$。
bf16 的指数字段与 fp32 完全相同(8 位、偏置 127),所以范围与 fp32 一致:$\approx 1.2\times10^{-38}$ 到 $3.4\times10^{38}$。代价是尾数只剩 7 位,1.0 附近间隔 $2^{-7}\approx 7.8\times10^{-3}$,相对舍入误差上限 $2^{-8}$,比 fp16 差 8 倍,比 fp32 差 256 倍。
训练里的误差传导
各张量对精度的敏感度不一样,可以按"这个数要精确到相对误差多少"来分配格式:
- 梯度:值域极宽,且大量落在 $10^{-6}$ 以下(深层的梯度本来就是小量)。fp16 最小正规格数是 $6.1\times10^{-5}$,低于这个直接 flush 成次正规或零——这正是 loss scaling 存在的原因。bf16 的范围和 fp32 一样,$10^{-30}$ 的梯度照样是正规格数,这套机制整个可以删掉。这是 bf16 最大的工程红利:少一个会出 bug 的超参(scale 初始值、倍增窗口、溢出跳步的判断)。
- 权重(master copy):无论哪种 16 位格式,主权重都应保持 fp32。理由:参数更新量 $\Delta\theta$ 通常比 $\theta$ 本身小几个数量级,16 位加法 $w + \Delta$ 会触发吞并(swamping)——当 $|\Delta| / |w| < 2^{-p}$ 时,加法结果等于 $w$,更新被静默丢弃。fp16 下 $p=10$,bf16 下 $p=7$,bf16 的吞并阈值更差。如果权重只存 16 位,训练几千步后大量参数的更新实际上从未生效。
- 矩阵乘累加:Tensor Core 做 $16\times16\times16$ 的乘加链时内部累加器是 fp32,这是硬性的——如果累加也用 16 位,一次 $K=4096$ 的点积累积误差按 $\sqrt{K}\times$ 单步误差增长,bf16 累加的结果根本没法看。fp16 输入 + fp32 累加(FFMA 进 fp32)是 Ampere 之后训练芯片的标配,这也让 bf16 的粗精度在矩阵乘内部被掩盖了大半:单次舍入误差发生在输入端,累加过程无损。
PyTorch 的 autocast 就是按这张敏感度表实现的白名单:matmul/conv 自动转 bf16,optimizer、softmax 归约、layer norm 的统计量留在 fp32。
精度的代价在哪里可见
bf16 的 $2^{-8}$ 舍入误差不是免费的。实测里能观察到两类影响:
- 权重更新的精度:主权重虽是 fp32,但每次前向要把权重 cast 到 bf16,这个 cast 是单向舍入。如果权重更新频率极高且更新量恰好在 $2^{-8}$ 相对量级附近(比如 RLHF 后期 $10^{-6}$ 学习率下的 LoRA B 矩阵),cast 会引入系统性偏差。缓解办法是权重摆动(weight stashing)或对敏感层保留 fp16。
- 推理部署的量化余量:从 fp32 直接量化到 int8 时,校准看到的权重已经是 bf16 量化过的,误差叠加。DeepSeek-V3 把大多数矩阵乘压到 FP8,前提是权重的 bf16 表示误差远小于 FP8 的量化步长——尾数从 7 位砍到 3 位时,bf16 的既有误差占比就不可忽略了,所以他们做了细粒度的 scale 分组。
选型决策
- 硬件约束在前:bf16 需要 Ampere(A100/3090)或更新的 NVIDIA 卡,V100 只有 fp16,TPU v2 开始支持 bf16。老卡没的选。
- 训练新手默认 bf16:
torch.autocast(dtype=torch.bfloat16)一行,没有 loss scale 状态机,发散排查清单短一截。 - 极长训练、极小更新量的场景(长 RL、小学习率微调)留意 cast 误差的系统累积,必要时对敏感参数组单独处理。
- 推理端两者都少用:推理链路的主流是 fp16(activation 范围可控时精度更好)或 int8/FP8 直接量化,bf16 主要留在训练侧和极端动态范围的激活上。
一句话总结这笔交换:fp16 用维护成本(loss scaling、溢出处理)买尾数精度,bf16 用 8 倍的舍入误差买"永远不用管下溢"。训练框架的发展史就是把前者变成默认配置、把后者交给硬件累加器去掩盖的过程。