八位尾数怎么训出大模型
上一章为了钱,往更精确的格子搬。这一章反过来:AI 训练主动搬进一个粗糙得多的格子。bf16 的尾数只有 7 位,能表示的十进制有效数字连两位都不到——比上一章嫌弃的 double 粗糙了十四个数量级。它却是今天训练几乎所有大模型的默认格式。这背后是一笔非常清晰的交易。
2018 年前后,混合精度训练刚普及时,业界的默认选择是 fp16(IEEE 半精度,5 位指数 + 10 位尾数)。后来几乎所有人都换成了 bf16(8 位指数 + 7 位尾数)。
注意这个交换:bf16 把尾数从 10 位砍到 7 位——精度降低了 8 倍——换来指数从 5 位加到 8 位。
问:为什么这笔交易划算?
顺便一个数:在 fp16 里,2048 + 1 等于多少?
把格子摊开看
| 格式 | 指数 | 尾数 | eps | 十进制位数 | 最大值 | 最小正规数 |
|---|---|---|---|---|---|---|
| fp64 double | 11 | 52 | 2.22 × 10⁻¹⁶ | 15.65 | 1.798 × 10³⁰⁸ | 2.225 × 10⁻³⁰⁸ |
| fp32 float | 8 | 23 | 1.19 × 10⁻⁷ | 6.92 | 3.403 × 10³⁸ | 1.175 × 10⁻³⁸ |
| tf32(NVIDIA) | 8 | 10 | 9.77 × 10⁻⁴ | 3.01 | 3.401 × 10³⁸ | 1.175 × 10⁻³⁸ |
| bf16 | 8 | 7 | 7.81 × 10⁻³ | 2.11 | 3.390 × 10³⁸ | 1.175 × 10⁻³⁸ |
| fp16 half | 5 | 10 | 9.77 × 10⁻⁴ | 3.01 | 6.550 × 10⁴ | 6.104 × 10⁻⁵ |
| fp8 E4M3 | 4 | 3 | 0.125 | 0.90 | 2.400 × 10² | 1.563 × 10⁻² |
| fp8 E5M2 | 5 | 2 | 0.250 | 0.60 | 5.734 × 10⁴ | 6.104 × 10⁻⁵ |
把 bf16 那一行和 fp32 那一行并排看,一件事会跳出来:
bf16 就是把 fp32 的尾数砍掉后 16 位。指数位一个没动。
后果是:bf16 的最大值、最小正规数、动态范围,和 fp32 完全相同。任何一个 fp32 能表示的数,bf16 都能表示(只是粗一点);任何一个 fp32 不溢出的计算,bf16 也不溢出。
换算也因此变得几乎免费:fp32 → bf16 就是把低 16 位截掉,bf16 → fp32 就是补 16 个 0。硬件上这是最便宜的一种转换。
而 fp16 的指数只有 5 位,它的世界一头顶在 65504,另一头卡在 6.1 × 10⁻⁵。训练里的梯度,天天在这两条线外面。
fp16 的两条线
下溢那一头。训练到中后期,很多梯度分量会掉到 10⁻⁸ 甚至更小。在 fp16 里:
梯度 = 1e−8
fp32:正常(最小正规数 1.175e−38)
bf16:正常(最小正规数 1.175e−38,和 fp32 一样)
fp16:1e−8 < 6.104e−5 ⇒ 掉进次正规区,精度骤降
小于 5.96e−8 ⇒ ⟨直接变成 0⟩
★ 梯度变 0 = 这个参数不再更新 = 这部分网络停止学习。
而且它不报错。
这就是 loss scaling 存在的全部理由:反向传播之前把 loss 乘上一个大数(比如 1024),让所有梯度整体抬高到 fp16 的安全区,反传完再除回去。它不是在提高精度,它是在把数搬进格子里。而 bf16 完全不需要这一步——它的下限和 fp32 一样。
溢出那一头。fp16 的最大值是 65504。注意力分数、残差累加、某些激活值超过这个数的时候,得到 Infinity,然后 Infinity - Infinity = NaN,然后整个 batch 的梯度变成 NaN。「训着训着 loss 变 NaN」这个经典现象,一大半的成因在这里。
那 7 位尾数为什么够
回到最初的疑问:bf16 的 eps 是 7.81 × 10⁻³——相对误差接近 1%。用误差接近 1% 的数去做几千亿次乘加,为什么不会崩?
三个原因,每个都值得单独记:
- 误差是随机游走的(第 4 章)。几百万次乘加里,抹零的方向近似随机,误差按 √n 长而不是按 n 长。再加上梯度下降本身对噪声极其宽容——它每一步都在用一个 mini-batch 的采样噪声估计梯度,而那个噪声比 1% 大得多。数值噪声躲在采样噪声的阴影里。
- 累加器不用 bf16。这是最关键、也最容易被忽略的一条。Tensor Core 做矩阵乘法时,输入是 bf16,累加器是 fp32。也就是第 8 章那件事:长长的求和一定要在高精度里做。低精度只用在「存」和「乘」上,不用在「累加」上。
- 优化器状态留在 fp32(master weights)。因为权重更新是「大加小」:
w = 1.0,学习率 × 梯度 = 1e−4 bf16 的 eps = 0.0078125,半格 = 0.00390625 1e−4 < 0.00390625 ⇒ ⟨1.0 + 1e−4 在 bf16 里就是 1.0⟩ ★ 更新被整个吃掉,参数永远不动。第 2 章那件事,在训练循环里每一步都在发生。
所以混合精度训练的标准结构是:前向和反向用 bf16(省显存、省带宽、Tensor Core 快),而权重和优化器动量用 fp32 保存一份,更新在 fp32 里做,做完再转成 bf16 拿去算下一步。这份 fp32 副本就叫 master weights。
回答开头的第二问:fp16 里 2048 + 1 = 2048。因为 ulp(2048) = 2,1 比半格还小。2049 这个数在 fp16 里根本不存在。(而 1024 + 1 = 1025 是对的,因为 ulp(1024) = 1。)
再往下:int8 量化,和离群值的代价
推理阶段还能更狠——直接换成 8 位整数。做法是找出这批权重的绝对值最大值,把 [−absmax, absmax] 均匀分成 255 格:
# 4096 个权重,标准差 0.02,对称 int8 量化 absmax = 0.071435 scale = 5.6248e-4 量化后信噪比 = 41.78 dB 相对 L2 误差 = 0.815% ← 完全可用 # 现在混进一个离群值 1.0(就一个,占 0.02%) scale = 7.8740e-3 ← 放大了 14 倍 信噪比 = 20.73 dB ← ★ 掉了 21 dB 原来那 4096 个权重里,有 637 个被量化成了 0(占 15.6%)
一个离群值,让六百多个正常权重变成零。这就是大模型量化里最难的那个问题。Transformer 的激活值天生有强离群通道(某几个特征维度的数值比其他大两三个数量级),于是量化研究里一整支——LLM.int8()、SmoothQuant、AWQ、GPTQ——都在处理同一件事:怎么不让离群值把 scale 拖走。
它们的手段本质上只有三种,而且这本书前面都出现过:① 把离群通道单独留在高精度(分而治之);② 把离群值的量级搬到别处去(SmoothQuant 把激活的尺度挪进权重里——就是第 10 章的「平移法」的乘法版);③ 分组量化,每 64 或 128 个权重一个 scale,让离群值只污染自己那一组。
混合精度(mixed precision)这个词容易让人以为是「有些层用低精度,有些层用高精度」。实际含义更细:同一个运算内部,不同角色用不同精度。
- 存储(权重、激活、梯度):bf16 / fp8 —— 省显存和带宽,而带宽才是瓶颈。
- 乘法的输入:bf16 —— Tensor Core 吃这个。
- 累加:fp32 —— 第 8 章,长求和必须高精度。
- 参数更新与优化器状态:fp32 —— 「大加小」必须高精度。
- 归一化、softmax、loss:fp32 —— 这些里面有求和、有 exp、有除法,全是这本书前十六章的重灾区。
这张表可以直接当成前十七章的应用索引:每一条都对应一个前面讲过的坑。
numpy 自带 float16,可以直接玩:
import numpy as np
for t in (np.float16, np.float32, np.float64):
i = np.finfo(t)
print(f'{t.__name__:>9} eps={i.eps:<12} max={i.max:<12} tiny={i.tiny}')
h = np.float16
print('fp16: 2048 + 1 =', h(2048) + h(1)) # 2.048e+03 ★ 加了等于没加
print('fp16: 1024 + 1 =', h(1024) + h(1)) # 1.025e+03 这个对
print('fp16: 65505 →', h(65505)) # 6.55e+04(顶到 65504)
print('fp16: 70000 →', h(70000)) # inf
print('fp16: 1e-8 →', h(1e-8)) # 0.0 ★ 下溢
print('fp16: 1e-8 × 1024(loss scaling)→', h(1e-8 * 1024)) # 1.025e-05,活过来了
# bf16 手工构造:把 fp32 的低 16 位截掉
def to_bf16(x):
b = np.float32(x).view(np.uint32) >> 16
return (np.uint32(b) << 16).view(np.float32)
print('bf16(1.0 + 1e-4) =', to_bf16(np.float32(1.0) + np.float32(1e-4))) # 1.0 ★
print('bf16 的 eps =', 2.0 ** -7) # 0.0078125
print('bf16(1e-8) =', to_bf16(1e-8)) # 9.95351e-09,活着(范围和 fp32 一样)
倒数第二行是 master weights 存在的理由:在 bf16 里,1.0 加上 1e-4 还是 1.0。
python3 lowprec.py
在线:Google Colab。装了 PyTorch 的话,torch.bfloat16 可以直接构造:torch.tensor(1.0, dtype=torch.bfloat16) + torch.tensor(1e-4, dtype=torch.bfloat16)。
bf16 是 Google 为 TPU 发明的(brain float,名字来自 Google Brain),后来 NVIDIA 从 Ampere 架构开始支持,现在是训练的事实标准。它是一个纯粹由数值分析驱动的硬件设计决策——有人算清楚了「范围比精度值钱」,然后把这个结论刻进了芯片。
tf32 是 NVIDIA 的另一个折中:8 位指数(和 fp32 一样)+ 10 位尾数,存储时仍占 32 位,只在 Tensor Core 内部按 19 位算。它的意义是「不改代码就提速」——PyTorch 默认对矩阵乘法开 tf32,很多人不知道自己的 fp32 训练其实跑在 10 位尾数上。torch.backends.cuda.matmul.allow_tf32 就是这个开关。
fp8(H100 起)有两种:E4M3 用于前向(精度优先,尾数 3 位),E5M2 用于反向(范围优先,尾数 2 位)。连「前向和反向该用不同格式」这件事,都是从这一章的取舍推出来的——梯度的动态范围比激活大。
这不只是 AI 的事。图形学早就在做同一笔交易:顶点坐标用 fp32、颜色用 8 位、HDR 用 fp16(R11G11B10 这种格式甚至给每个通道分配不同位数)。音频、传感器、通信里的定点数设计也是同一件事:先想清楚动态范围要多大,剩下的位才给精度。
「精度越高越好。有资源的话,当然应该用 fp32 甚至 fp64 训练。」
精度是一种可以交易的资源,不是一个越多越好的属性。64 位里怎么切分符号、指数、尾数,是一个设计选择——而不同的任务需要的切分完全不同。
训练要的是范围:梯度跨越十几个数量级,而每个梯度分量只要「方向大致对」就够,因为随机梯度下降本身就在用噪声估计。钱要的是精度(上一章):范围只有十几个数量级,但每一分都必须精确。同样是 16 位,给钱和给梯度应该切得完全不同。
还有一层实际考虑:今天的瓶颈是内存带宽,不是算力。把权重从 fp32 换成 bf16,等于把可用带宽翻倍、把能装下的模型变大一倍。「用一半的位换两倍的模型」这笔账,在几乎所有场景下都是赚的——因为模型大小对效果的影响,远远大于每个数多两位小数。
正确答案是 C:fp16 的最小正规数是 6.104 × 10⁻⁵,而训练里的梯度经常小于它,会掉进次正规区甚至直接归零。bf16 的最小正规数和 fp32 一样是 1.175 × 10⁻³⁸。第二问:fp16 里 2048 + 1 = 2048。
A 「bf16 更快」——两者都是 16 位,Tensor Core 上吞吐相同。bf16 在转换上确实更快(和 fp32 的互转就是截断和补零,fp16 要真的做格式转换),但这是次要好处,不是换格式的理由。 B 「更省显存」——两者都是 16 位,一个字节都不差。这个答案把「16 位 vs 32 位」的好处,安到了「bf16 vs fp16」这个选择上。省显存是「用 16 位」带来的,和选哪种 16 位无关。 D 「bf16 精度更高」——正相反,bf16 的精度比 fp16 低八倍(7 位尾数 vs 10 位尾数,eps 从 9.77×10⁻⁴ 涨到 7.81×10⁻³)。这正是这一章要说的那件事的全部反直觉之处:业界主动选择了精度更差的那个,因为丢掉的精度不值钱,换来的范围很值钱。这一章的「改写」发生在配置层,不在代码层:
全 fp32 训练 bf16 混合精度 + fp32 master weights。不需要 loss scaling(那是 fp16 的补丁)。PyTorch:torch.autocast(dtype=torch.bfloat16)。
fp16 + loss scaling,然后花两天调 scale、处理 NaN
换 bf16。训练里 90% 的 NaN 问题在换格式的那一刻就消失了——因为它们本来就是 fp16 那两条线造成的。
自己写归约(求和、mean、softmax、layer norm)时沿用输入的低精度
累加器提到 fp32。第 8 章那一整章讲的就是这件事。框架的内置算子已经这么做了,自己写 CUDA kernel 时要记得。
量化时全模型一个 scale
分组量化(每 64/128 个一个 scale),或者把离群通道单独留在高精度。一个离群值能让 15.6% 的权重归零。
最后一条通则,也是这一章真正想留下的:选数值格式的时候,先问「我的量跨多少个数量级」,再问「每个量要几位有效数字」。先定范围,再定精度——顺序反了,就会选出 fp16。
这一章的一句话
位数是要分配的预算,指数管范围、尾数管精度;训练之所以能用 7 位尾数活下来,是因为它的噪声本来就比抹零大,而它的量跨了十几个数量级。
下一章是卷 V 的最后一个「换格」,而且换的不是格式——是环境。同一份 C 源代码,同一台机器,同一个编译器,只加一个 -ffast-math:累加一千万个 0.1,答案从 999999.99983897537 变成 1000000.0000223046。而 a*b + c 这一行,加一个 -ffp-contract=off,答案从 −4.93 × 10⁻³² 变成 0。下一章会在这台机器上把这些全部跑出来。