在 H100 上,FP8 矩阵乘的吞吐是 BF16 的 2 倍,BF16 又是 FP32 非矩阵运算的十几倍。把数字表示得更短,可以同时省显存、省带宽、提算力。代价是范围和精度都变小了,训练可能不稳定甚至发散。
问题归结为两个。第一,一个数用更少的位表示时,会在哪里出错:太大(溢出)、太小(下溢成 0)、还是不够细(舍入把小的变化吃掉)?第二,训练里哪些量对这些错误敏感,要用什么办法保护它们?今天大模型预训练实际用的 BF16 混合精度、DeepSeek-V3 的 FP8 方案和 Blackwell 上的微缩放格式,都是对这两个问题的回答。
1. 为什么要降精度
PyTorch 张量默认是 FP32,每个数 4 字节。降到 16 位或 8 位有三重好处:
- 算力:Tensor Core 的吞吐按位宽成倍提高。H100 上 BF16/FP16 矩阵乘约 990 TFLOPs/s,FP8 约 1979 TFLOPs/s;B200 还支持 FP4,三者的稠密峰值分别约为 BF16 2.25e15、FP8 4.5e15、FP4 9.0e15 FLOPs/s,每减半一次位宽吞吐翻一倍。
- 带宽:带宽受限的算子(逐元素、归一化)时间和字节数成正比,位宽减半时间减半。通信也一样:梯度 all-reduce、MoE 的 all-to-all 都按字节计费。
- 显存:激活、权重副本、优化器状态都可以更小。
但简单地把所有东西都换成 FP16 行不通,训练通常会发散。混合精度的意思是:大部分计算(尤其是矩阵乘)用低精度做,少数对数值敏感的地方保留高精度。要知道哪些地方敏感、为什么,得先看浮点数是怎么表示的。
2. 浮点数怎么编码
2.1 三个字段
浮点数就是二进制的科学计数法。一个数被拆成三部分:
- 符号位 \(s\):1 位,0 正 1 负。
- 指数 \(E\):\(e\) 位无符号整数,存的是加了偏置(bias)的指数。偏置通常是 \(2^{e-1}-1\),这样实际指数可正可负。例如 FP16 有 5 位指数,偏置是 15,字段值 1 到 30 对应实际指数 \(-14\) 到 \(15\)。
- 尾数 \(M\):\(m\) 位,存小数点后的部分(IEEE 754 叫 trailing significand)。
对普通(规格化,normal)数,值是
$$x = (-1)^s\cdot 2^{E-\text{bias}}\cdot\Big(1+\frac{M}{2^m}\Big),\qquad 1\le E\le E_{\max}.$$开头的 1 是隐含的,不占存储——规格化的二进制数小数点前总是 1。指数字段全 0 时是次正规数(subnormal),隐含位变成 0,指数固定为最小值:
$$x = (-1)^s\cdot 2^{1-\text{bias}}\cdot\frac{M}{2^m},\qquad E=0.$$次正规数让数值能「逐渐下溢」到 0,而不是在最小规格化数以下突然变成 0。指数字段全 1 在 IEEE 格式里留给 \(\pm\infty\)(尾数为 0)和 NaN(尾数非 0)。
2.2 常用格式
| 格式 | 位数 | 指数/尾数 | 偏置 | 最大值 | 最小规格化 | 最小次正规 | ε(1 之后的间距) |
|---|---|---|---|---|---|---|---|
| FP32 | 32 | 8 / 23 | 127 | 3.40e38 | 1.18e−38 | 1.40e−45 | 2⁻²³ ≈ 1.19e−7 |
| BF16 | 16 | 8 / 7 | 127 | 3.39e38 | 1.18e−38 | 9.18e−41 | 2⁻⁷ ≈ 7.8e−3 |
| FP16 | 16 | 5 / 10 | 15 | 65504 | 6.10e−5 | 5.96e−8 | 2⁻¹⁰ ≈ 9.8e−4 |
| FP8 E4M3 | 8 | 4 / 3 | 7 | 448 | 2⁻⁶ ≈ 1.6e−2 | 2⁻⁹ ≈ 2.0e−3 | 2⁻³ = 0.125 |
| FP8 E5M2 | 8 | 5 / 2 | 15 | 57344 | 2⁻¹⁴ ≈ 6.1e−5 | 2⁻¹⁶ ≈ 1.5e−5 | 2⁻² = 0.25 |
| FP4 E2M1 | 4 | 2 / 1 | 1 | 6 | 1 | 0.5 | 0.5 |
BF16 的「B」来自 Google Brain。它就是 FP32 截掉低 16 位:指数一样 8 位,所以范围和 FP32 几乎相同;尾数只剩 7 位。FP16 则反过来,尾数多(10 位)、指数少(5 位),范围窄得多。
FP8 有两种格式(2022 年由 NVIDIA、Arm、Intel 联合提出,后来成为 OCP 标准),取舍也是范围和精度:
- E5M2 完全遵循 IEEE 惯例(有 \(\pm\infty\),多种 NaN),可以看作尾数更少的 FP16,范围和 FP16 相同。
- E4M3 为了扩大范围不表示无穷,NaN 只保留一个位模式 S.1111.111。这样指数全 1 的那一档也能存普通数,多出 256、288、…、448 这 7 个值,最大值从 240 扩到 448,动态范围从 17 个二进制数量级(binade)扩到 18 个。
通常的建议是权重和激活用 E4M3、梯度用 E5M2(梯度的动态范围更大)。DeepSeek-V3 则在细粒度缩放的帮助下所有张量都用了 E4M3。
2.3 一个例子:0.1
\(0.1 = 1.6\times2^{-4}\)。在各个格式里:
- BF16:指数字段 \(-4+127=123\);尾数 \(0.6\times2^7=76.8\),就近舍入成 77。值是 \((1+77/128)\times2^{-4}=0.10009765625\),相对误差约 \(9.8\times10^{-4}\)。
- FP16:指数字段 \(-4+15=11\);尾数 \(0.6\times2^{10}=614.4\to614\)。值 \(0.0999755859375\),相对误差约 \(2.4\times10^{-4}\)。
- E4M3:指数字段 \(-4+7=3\);尾数 \(0.6\times8=4.8\to5\)。值 \(0.1015625\),相对误差 1.6%。
- E5M2:尾数 \(0.6\times4=2.4\to2\)。值 \(0.09375\),相对误差 6.25%。
输入任意数,看它在各格式中的编码:
2.4 TF32:只在 Tensor Core 里出现的格式
还有一个常被忽略的格式。从 A100 开始,Tensor Core 支持 TF32:输入保留 FP32 的 8 位指数、只取 10 位尾数(和 FP16 一样),乘积在 FP32 中累加。它不是存储格式,张量在显存里仍然是 FP32,只是矩阵乘内部把尾数截短了。PyTorch 用 torch.backends.cuda.matmul.allow_tf32(或 torch.set_float32_matmul_precision("high"))控制 FP32 矩阵乘是否走 TF32。所以「我用的是 FP32 训练」在 A100/H100 上未必是真正的 23 位尾数。
3. 范围、精度与 ULP
浮点数的可表示值不是均匀分布的。在每个区间 \([2^k, 2^{k+1})\)(一个 binade)里,规格化数恰好有 \(2^m\) 个,等间距排列,间距是
$$\mathrm{ulp}(x) = 2^{k-m},\qquad 2^k\le |x|\lt 2^{k+1}.$$ULP(unit in the last place)是「最后一位变 1 对应的数值」。所以绝对精度随数值变大而变粗,相对精度大致不变:就近舍入的相对误差不超过 \(2^{-(m+1)}\)。举个数:BF16 在 1 附近的间距是 \(2^{-7}\approx0.008\),在 1000 附近(\([512,1024)\))的间距是 \(2^{9-7}=4\)——1000 和 1001 在 BF16 里是同一个数,但相对来说误差都不到 0.4%。这就是为什么常说 FP32 有约 7 位十进制有效数字(\(2^{-24}\approx6\times10^{-8}\)),FP16 约 3 位,BF16 约 2 到 3 位,E4M3 约 1 位。
可以做个直观的实验:在 \([1,2]\) 之间撒 10000 个点,舍入到各格式,E4M3 只能落到其中 7 个内部值上(1.125, 1.25, …, 1.875),E5M2 只有 3 个。分辨率常用 ε(1 之后的下一个可表示数与 1 的差)来衡量:FP32 是 \(2^{-23}\approx1.19\times10^{-7}\),FP16 约 \(10^{-3}\),BF16 再大 8 倍。
动态范围则由指数位数决定。用「最大值 / 最小次正规数」的以 2 为底的对数衡量:FP32 约 277 个 binade,BF16 约 261,FP16 约 40,E5M2 约 32,E4M3 约 18。
| 格式 | 位模式(符号 指数 尾数) | 舍入后的值 | 相对误差 | 该处 ULP | 状态 |
|---|
可表示值网格:一个 binade [2k, 2k+1) 里各格式有哪些点
E4M3 越界时按常用的饱和转换钳到 ±448(非饱和模式会得到 NaN);FP16、BF16、E5M2 越界变成 ±∞。所有数值都是按位精确计算的,FP32 用浏览器的 Math.fround。
4. 舍入:就近偶数与随机舍入
4.1 就近偶数
IEEE 默认的舍入方式是就近舍入、平局取偶(round to nearest, ties to even,RNE):取离 \(x\) 最近的可表示数;如果恰好在两个数中间,取尾数最后一位是 0 的那个。平局取偶是为了让平局时向上和向下的次数大致相等,避免系统性偏差。
RNE 对单次运算是最优的,但有一个在训练里很要命的性质:如果 \(|\delta|\) 小于 \(x\) 处 ULP 的一半,\(x+\delta\) 舍入后还是 \(x\)。不管加多少次,都加不上去。一个最直观的例子:在 BF16 里从 0 开始一次加 1。到 256 时,BF16 在 \([256,512)\) 的间距是 \(2^{8-7}=2\),\(256+1=257\) 恰好落在 256 和 258 的中间,平局取偶得到 256(尾数全 0 是偶数)。于是累加永远停在 256。FP16 同理停在 2048。这叫吞没(swamping)。
4.2 随机舍入
设 \(x\) 落在相邻可表示数 \(a\lt x\lt b\) 之间。随机舍入(stochastic rounding,SR)以与距离成反比的概率选择:
$$\mathrm{SR}(x) = \begin{cases} b & \text{概率 } p=\dfrac{x-a}{b-a}\\[4pt] a & \text{概率 } 1-p\end{cases}$$它的关键性质是无偏:
$$\mathbb E[\mathrm{SR}(x)] = a(1-p) + bp = a + (b-a)\cdot\frac{x-a}{b-a} = x.$$方差是 \((b-a)^2p(1-p)\le \mathrm{ulp}^2/4\)。于是即使每次更新都小于半个 ULP,平均下来也能正确累积:在上面「加 1」的例子里,SR 下每次有 1/2 的概率加 2,期望正好加 1。Gupta 等人 2015 年就发现,用 16 位定点数训练神经网络时舍入方式起决定性作用,配合随机舍入可以几乎无损地训练。NVFP4 预训练也对梯度使用随机舍入(第 13 节)。代价是要生成随机数,以及单次结果有噪声。
两种情形:一个权重被反复加上很小的更新;一串乘积在不同精度的累加器里求和。
5. FP16 混合精度的三个技巧
知道了 FP16 会在哪里出错,就可以对症下药。让 FP16 训练达到 FP32 精度的经典做法由三个技巧组成:FP32 主权重(防止更新被吞没)、损失缩放(防止梯度下溢)、FP32 累加(防止长求和出错)。一步训练的流程如下:
5.1 FP32 主权重
权重在优化器里保存一份 FP32 的「主副本」(master weights),每步把它转成 FP16 用于前向和反向;优化器用梯度更新的是 FP32 主副本。原因有两个:
- 更新本身下溢:FP16 能表示的最小正数是 \(2^{-24}\approx6\times10^{-8}\),更小的值变成 0。学习率乘梯度后常常落在这个范围以下。在一个普通话语音识别模型上统计,约 5% 的权重梯度指数小于 \(-24\),放进 FP16 就直接变成 0。
- 更新被吞没:即使更新本身可表示,加到权重上时也可能被舍掉,也就是吞没。设 \(w\in[2^k,2^{k+1})\),ULP 是 \(2^{k-m}\),更新 \(|\Delta|\lt\frac12\mathrm{ulp}=2^{k-m-1}\) 时 \(w+\Delta\) 舍入回 \(w\)。所以当 $$\frac{|w|}{|\Delta|}\gtrsim 2^{m+1}$$ 时更新会丢失。FP16(\(m=10\))是 2048,BF16(\(m=7\))只有 256。也就是说,在 FP16 里,只要权重比更新大 2000 倍以上,这次更新就白做了。
第二点对 BF16 更加严重。举个数:一个量级 0.02 的权重,Adam 的更新幅度约等于学习率。学习率 \(3\times10^{-4}\) 时比值约 67,BF16 还能加上;退火到 \(3\times10^{-5}\) 时比值约 670,超过 256,在 BF16 里用 RNE 直接更新,这些更新全部丢失。所以 BF16 训练同样需要 FP32 主权重(或者在 BF16 权重上用随机舍入/Kahan 补偿求和等替代办法)。在前面那个语音模型上做过对照:不用 FP32 主副本、直接更新 FP16 权重,准确率相对损失了 80%。
FP32 主副本让权重显存多了 50%(相对 FP32 训练),但激活也变成了 FP16,总显存反而大约减半。在大模型里,主副本和优化器状态会被 ZeRO 切到各个数据并行 rank 上(见数据并行与 ZeRO)。
5.2 损失缩放
第二个问题出在反向传播里的激活梯度。它们通常远小于 1。FP16 规格化数的指数范围是 \([-14,15]\),但如果画出一个网络(比如 SSD 检测网络)的激活梯度直方图,会发现大部分 FP16 的可表示范围(尤其是靠上的部分)根本没被用到,很多值却落在最小可表示值以下变成了 0。范围是够的,只是用错了位置。
办法是把梯度整体「往上挪」。由链式法则,如果把损失乘以常数 \(S\),反向传播得到的每个梯度都会乘以 \(S\):
$$\frac{\partial (S\cdot L)}{\partial \theta} = S\cdot\frac{\partial L}{\partial\theta}.$$这不需要在反向里加任何额外运算。更新前把权重梯度除以 \(S\) 还原。还原要在梯度裁剪、权重衰减等任何梯度处理之前做,这样这些超参数不用跟着改。
\(S\) 怎么选?不同网络需要的常数从 8 到 32K 不等(SSD 用 8 就够了,很多网络不需要缩放)。只要不溢出,\(S\) 越大越好;可以选使 \(S\cdot\max|g|\lt65504\) 的值。溢出会产生 inf/NaN,一旦进入权重就不可逆,所以要检测:动态损失缩放在反缩放梯度时检查 inf/NaN,发现就跳过这一步并把 \(S\) 减小;连续若干步没有溢出就把 \(S\) 增大。PyTorch 的 GradScaler 默认初始 \(S=2^{16}\),溢出时乘 0.5,连续 2000 步无溢出时乘 2。
scaler = torch.amp.GradScaler("cuda") # init_scale=2**16, backoff 0.5, growth 2.0 / 2000 步
for x, y in loader:
with torch.autocast("cuda", dtype=torch.float16):
loss = loss_fn(model(x), y)
scaler.scale(loss).backward() # 反向得到 S 倍的梯度
scaler.unscale_(opt) # 先除以 S,再裁剪
torch.nn.utils.clip_grad_norm_(model.parameters(), 1.0)
scaler.step(opt) # 有 inf/NaN 就跳过这一步
scaler.update() # 调整 S
opt.zero_grad(set_to_none=True)
5.2.1 一个数值例子
设某层的激活梯度典型量级是 \(10^{-6}\)、小的到 \(10^{-9}\)。FP16 最小次正规数是 \(5.96\times10^{-8}\),所以 \(10^{-9}\) 量级的值直接变成 0;\(10^{-6}\) 也在次正规区(小于 \(6.1\times10^{-5}\)),只剩 \(10^{-6}/5.96\times10^{-8}\approx17\) 个刻度,相对误差高达几个百分点。取 \(S=2^{16}=65536\):\(10^{-6}\) 变成 \(0.066\),回到规格化区,相对精度恢复到 \(2^{-11}\);\(10^{-9}\) 变成 \(6.6\times10^{-5}\),刚好进入规格化区。只要最大的梯度乘以 \(S\) 后不超过 65504(原始值小于约 1),就没有溢出。这也说明了为什么动态缩放从很大的 \(2^{16}\) 开始:先尽量往上推,溢出了再退。
5.3 累加精度
第三个技巧是让低精度的乘积在高精度里累加。矩阵乘的每个输出是 \(K\) 个乘积的和,\(K\) 动辄几千,如果每次部分和都舍入到 FP16,吞没和舍入误差会一路积累。Volta 开始的 Tensor Core 支持 FP16 输入、FP32 累加,最后再把结果转回 FP16 写出。同样的道理推广开:大的归约(求和)应该在 FP32 里做,逐元素运算则用 FP16 还是 FP32 都行——它们是带宽受限的,算术精度不影响速度。
5.4 主权重的替代:补偿求和
如果连 FP32 主权重的 4 字节都想省掉,可以用 Kahan 补偿求和:除了 BF16 权重 \(w\),再存一个 BF16 的补偿量 \(c\),记录上次加法被舍掉的部分:
$$y = \Delta - c,\qquad t = \mathrm{bf16}(w + y),\qquad c = \mathrm{bf16}\big((t - w) - y\big),\qquad w = t.$$\((t-w)\) 是这次实际加上的量,减去本想加的 \(y\) 就是误差;下一步先把它补回来。这样被吞没的小更新不会丢,只是延迟到累积够大时才体现在 \(w\) 上。它和随机舍入一样,都是在「不存高精度副本」的前提下保住小更新,代价是额外的 2 字节状态和几次运算。
6. 哪些运算要留在 FP32
上面的原则落到 Transformer 上,就是一份「哪些算子不能降精度」的清单。通常保留 FP32 的有:
- softmax:\(e^x\) 增长极快,FP16 下 \(x\gt\ln65504\approx11.1\) 就溢出;先减最大值可以避免上溢,但分母是一长串正数的求和,正是吞没的典型场景。FlashAttention 内部的行最大值、行和、输出累加器都是 FP32。
- 归一化(LayerNorm、RMSNorm):要算 \(\frac1d\sum x_i^2\)。激活里有几百量级的值时,平方后在 FP16 里会溢出(\(300^2=9\times10^4\gt65504\));对 \(d\) 个元素求和又有吞没问题。
- 损失:交叉熵要对整个词表算 logsumexp,词表十几万。
- 各种大规模归约:求和、均值、范数(包括梯度裁剪用的全局范数)。
- 优化器:主权重、Adam 的一阶二阶矩、更新计算本身。
PyTorch 的 autocast 就是按这个思路列的表:在 CUDA 上,matmul、linear、bmm、卷积等会被转成 FP16/BF16;softmax、log_softmax、layer_norm、group_norm、cross_entropy、sum、cumsum、exp、log、pow、norm 等会被转成 FP32;没列出的算子按输入类型运行。注意「转成 FP32 计算」不一定意味着「FP32 存储」:融合内核常常读写 BF16、在寄存器里用 FP32 算,这样既不损失精度也不多花带宽。
7. BF16:为什么不需要损失缩放
损失缩放解决的是范围问题:FP16 的最小规格化数是 \(6.1\times10^{-5}\),梯度掉到这以下就开始丢精度、再往下变成 0。BF16 的指数和 FP32 一样是 8 位,最小规格化数 \(1.18\times10^{-38}\),实际训练中梯度几乎不可能小到这个程度,也几乎不可能大到 \(3.4\times10^{38}\)。所以 BF16 不需要损失缩放,也就没有跳步、没有 \(S\) 的调节——这正是它在 TPU 和 A100 之后成为大模型训练默认格式的原因。
BF16 付出的代价是精度:7 位尾数,相对误差可达 \(2^{-8}\approx0.4\%\)。这使得「FP32 主权重」和「FP32 累加」在 BF16 下比在 FP16 下更重要(更新的吞没阈值从 2048 降到 256)。于是 BF16 混合精度的标准显存账是每个参数 20 字节:BF16 参数 2 + BF16 梯度 2 + FP32 主权重 4 + FP32 梯度累加 4 + Adam 两个 FP32 状态 4+4。
7.1 梯度累加为什么要 FP32
上面这份账里「FP32 梯度累加」单独占了 4 字节。原因还是吞没:用梯度累加(或者流水线并行的多个 micro-batch)时,一个参数的梯度是 \(n\) 个 micro-batch 梯度之和。若在 BF16 里累加,部分和变大后每个新加进来的 micro-batch 梯度只保留约 2 到 3 位有效数字,\(n\) 越大误差越大,而且「全为正」的那部分分量会系统性偏小——第 4 节演示里的 BF16 累加曲线就是这个现象。数据并行的 all-reduce 也是一个求和,所以很多框架用 FP32 做梯度的 reduce,或者至少在 reduce 内部用 FP32 累加。也有框架去掉这份 FP32 累加缓冲(第 12 节表格第二行),省下 4 字节,代价就是这里说的精度风险。
7.2 怎么判断是精度出了问题
实际训练里,损失发散的原因很多,精度只是其中之一。几个比较直接的检查:
- 记录每层激活、梯度的 amax 和下溢(变成 0)的比例。FP16 下大量梯度为 0、或者 FP8 下大量值饱和,都是明确的信号。
- 做一次小规模的对照:同样的种子和数据,把可疑的部分(某一类 GEMM、某个算子、优化器状态)切回高精度,看损失曲线是否分开。DeepSeek-V3 对 Dgrad 块量化的结论就是用这种消融得到的。
- 看权重更新量与权重的比值(\(|\Delta w|/|w|\))。如果它接近或低于 \(2^{-(m+1)}\),而权重又以低精度存储、没有主副本或随机舍入,更新就会被吞没——表现往往不是发散,而是学习「悄悄变慢」。
8. FP8:格式与缩放
8.1 为什么 FP8 必须缩放
E4M3 的正规范围只有 \([2^{-6}, 448]\),加上次正规也只到 \(2^{-9}\),约 18 个 binade;而且在每个 binade 里只有 8 个点。一个张量的数值如果分布在 \(10^{-3}\) 附近,直接转成 E4M3 大部分会落进次正规区、精度极差;分布在 \(10^3\) 附近又会溢出。所以 FP8 张量几乎总是带着一个缩放因子:
$$S=\frac{\text{FP8}_{\max}}{\mathrm{amax}(x)},\qquad q=\mathrm{FP8}(x\cdot S),\qquad \hat x = q/S.$$\(\mathrm{amax}\) 是张量(或某个分组)的绝对值最大值。这样最大的元素恰好映射到格式的最大值附近,充分利用顶部的几个 binade。
看一个具体的数。设某个张量的 amax 是 0.02,其中有一个值 0.001。不缩放直接转 E4M3:0.001 小于最小规格化数 \(2^{-6}\approx0.0156\),落在次正规区,那里的间距是 \(2^{-9}\approx0.00195\),0.001 约是半个间距,舍入成 0.00195,误差 95%。缩放后:\(S=448/0.02=22400\),\(0.001\times S=22.4\),在 \([16,32)\) 里间距是 2,舍入成 22,反量化得 \(22/22400\approx0.000982\),误差 1.8%。同样 8 位,差别全在于数被放到了格式的哪一段。这和 FP16 的损失缩放是同一个思路,只是 FP8 的范围太窄,一个全局的 \(S\) 不够,需要每个张量(甚至每个分组)各一个。
矩阵乘时缩放因子可以提出去:若 \(X\) 和 \(W\) 各有一个标量缩放 \(S_X,S_W\),则
$$XW = \frac{1}{S_XS_W}\,(q_X\,q_W),$$FP8 Tensor Core 算 \(q_Xq_W\)(FP32 累加),最后乘一个标量就还原了。每个输出元素只多一次乘法。还要注意一点:FP8 下溢出比 FP16 更容易发生,像 FP16 那样遇到溢出就跳步不合适(会跳太多步),溢出的值应该饱和到最大可表示值。
8.2 延迟缩放与即时缩放
计算 \(\mathrm{amax}(x)\) 要先完整扫一遍 \(x\),这意味着「先写出高精度张量 → 读一遍求 amax → 再读一遍做转换」,对带宽受限的部分很不划算。两种策略:
- 延迟缩放(delayed scaling):用过去若干步记录的 amax 来估计这一步的缩放因子,转换和 amax 统计可以融合在产生这个张量的内核里一次完成。NVIDIA Transformer Engine 的
DelayedScaling默认保留 1024 步的 amax 历史,取其中最大值,缩放因子是 \((\text{FP8}_{\max}/\mathrm{amax})/2^{\text{margin}}\)(margin 默认 0)。缺点是缩放因子是「过时」的:如果这一步突然出现比历史更大的值,就会饱和。 - 即时缩放(current scaling,DeepSeek-V3 称为 online quantization):用当前张量(或当前分组)的 amax。更准确,代价是多一次归约。分组越小,这次归约越容易融合进内核(一个 1×128 的分组在一个线程块里就能求完)。
Transformer Engine 的默认格式组合是 HYBRID:前向(权重、激活)用 E4M3,反向(梯度)用 E5M2。
8.2.1 延迟缩放会在哪里出问题
设某个激活在前 1000 步的 amax 一直在 20 左右,E4M3 下缩放因子 \(S=448/20=22.4\)。某一步出现一个 50 的值(比如一次 loss spike 的前兆):\(50\times22.4=1120\gt448\),被饱和成 448,反量化后是 20——这个最需要被准确表示的大值被截掉了 60%。下一步 amax 历史更新为 50,\(S\) 降到 8.96,但已经晚了一步。反过来,如果历史里曾经出现过一个很大的 amax,取窗口最大值的策略会让后面很多步的 \(S\) 偏小,普通值被压向次正规区,直到那个值滑出 1024 步的窗口。即时缩放没有这两个问题。DeepSeek-V3 选择它的理由正是这两点:缩放因子更准确,框架也更简单(不用维护 amax 历史)。
8.3 一个线性层里的三个 FP8 矩阵乘
线性层 \(Y=XW\),\(X\in\mathbb R^{T\times d_{\text{in}}}\),\(W\in\mathbb R^{d_{\text{in}}\times d_{\text{out}}}\)。训练时有三个矩阵乘:
- Fprop:\(Y = XW\),归约维度是 \(d_{\text{in}}\)。
- Dgrad:\(dX = dY\,W^\top\),归约维度是 \(d_{\text{out}}\)。
- Wgrad:\(dW = X^\top dY\),归约维度是 token 数 \(T\)。
这三个名字在 FP8 的讨论里会反复出现:Fprop 是前向(forward propagation),Dgrad 算的是传给上一层的激活梯度(data gradient),Wgrad 算的是权重梯度。三个都在 FP8 里做,理论上比 BF16 快一倍。而且 Wgrad 用 FP8 的 \(X\),意味着前向存给反向的激活本身就可以是 FP8,激活显存也减半。注意同一个 \(X\) 在 Fprop 里沿行(\(d_{\text{in}}\))归约,在 Wgrad 里沿列(\(T\))归约,这在下面讲分组缩放时会成为麻烦。
8.4 FP8 能省多少时间
FP8 只加速矩阵乘,而且通常只是线性层的矩阵乘。按 DeepSeek-V3 的方案,注意力、归一化、embedding、输出头、MoE 门控都留在高精度;逐元素算子的时间按字节算,激活从 BF16 变 FP8 只在那些被量化的张量上省带宽;量化本身(求 amax、缩放、转换、转置后重新量化)还要额外的内核或融合工作。所以即使线性层 GEMM 占了一步的 70%,理想加速也只是 \(1/(0.3+0.7/2)\approx1.54\) 倍,实际更少。这也是为什么第 12 节里各家方案都花了很多心思在显存和通信上——FP8 的收益是算力、显存、通信三处加起来的。
9. 离群值与缩放粒度
大模型的激活有一个众所周知的特点:少数通道的数值比其他通道大一两个数量级(离群值,outlier),而且这些通道在不同 token 上比较固定。按张量缩放时,\(\mathrm{amax}\) 由离群值决定,其他所有值都被压到格式的底部:如果离群值是普通值的 1000 倍,普通值在 E4M3 里会落到 \(448/1000\approx0.45\) 附近甚至更低,有效精度变差,更小的值直接掉进次正规区或变成 0。
所以「按张量把 amax 缩放到 FP8 最大值」的标准做法,会让低精度训练对激活离群值高度敏感。解决思路是缩小共享缩放因子的分组,让一个离群值只影响它所在的那一小组。
演示生成一个 128×256 的「激活」矩阵(行是 token、列是通道,数值是固定种子的标准高斯随机数),在其中注入离群值,再用不同的格式和分组做量化-反量化,计算误差。数据为示意。
| 缩放分组 | 整体 L2 误差 | 非离群 L2 误差 | 非离群逐元素平均误差 | 非离群被量化成 0 | 缩放因子开销 |
|---|
L2 误差 = \(\lVert\hat x-x\rVert_2/\lVert x\rVert_2\)(矩阵乘看到的主要是它);逐元素平均误差是 \(|\hat x_i-x_i|/|x_i|\) 的平均。「1×128」对应 DeepSeek-V3 的激活分组(每 token 每 128 通道),「128×128」对应它的权重分组;「1×32,E8M0」是 MX 格式(缩放因子只能是 2 的幂);「1×16,E4M3」是 NVFP4 的两级缩放(块缩放因子用 E4M3 存、再乘一个 FP32 的张量级缩放)。开销按缩放因子的位数平摊到每个元素计。
拖动滑块能看到几件事:
- 浮点格式比整数格式耐离群值。FP8 在每个 binade 里的相对精度相同,按张量缩放时离群值只是把普通值整体往下挪几个 binade,精度不变;直到普通值被挪进次正规区(E4M3 只有约 18 个 binade 可挪),误差才明显上升、开始大量变成 0。切到 INT8 对照:均匀刻度下,离群值一大,普通值只剩几个量化级,按张量缩放的误差立刻变大。这是 FP8 能比 INT8 更直接地用于训练的原因之一。
- 分组只保护不含离群值的组。演示里两个离群通道都在前 128 列。通道离群时,所有包含这两列的分组(每一行的第一个 1×128 分组、左边的 128×128 块)都被拉大了缩放因子,右半边则完全不受影响;分组越小(1×32、1×16),受牵连的普通元素越少。真实模型的隐藏维度是几千,一个离群通道只污染它所在的那 1/几十 的分组。
- token 离群时 1×128 明显优于 128×128。离群值沿行分布,按 token 分组时离群 token 自成一组,其他 token 不受影响;128×128 的块却把块内所有 token 都拖下水。DeepSeek-V3 的训练中发现,激活梯度有明显的 token 相关离群值,对它们用 128×128 块量化会导致发散。
- E4M3 最依赖细粒度缩放。E5M2 范围大,按张量缩放时很少把小值压成 0,但每个值的精度更低;E4M3 精度高、范围窄。分组缩放补上了范围,于是可以把位数留给尾数——这就是 DeepSeek-V3「有了细粒度缩放就可以全部用 E4M3」的道理。FP4 只有约 3.6 个 binade、8 个非负值,任何粗粒度缩放都不够用。
另一类办法是在量化前把离群值「摊开」。FlashAttention-3 和 NVFP4 训练都用了随机 Hadamard 变换:对 \(x\) 乘一个正交矩阵 \(H\)(Hadamard 矩阵乘随机 \(\pm1\) 对角阵),每个输出是输入的随机 \(\pm\) 组合,一个大值被均摊到所有位置上,\(\mathrm{amax}\) 和其余值的比例大幅下降。矩阵乘的另一边乘同一个 \(H\),由 \(HH^\top=I\) 结果不变。
一个四维的小例子:\(x=(10,1,1,1)\),最大值是其余值的 10 倍。乘以归一化的 4 阶 Hadamard 矩阵(元素是 \(\pm\tfrac12\))后得到 \((6.5,4.5,4.5,4.5)\):向量的长度不变,但最大值只是其余值的约 1.4 倍,按 amax 缩放时其余元素不再被压到格式底部。
10. DeepSeek-V3 的 FP8 框架
DeepSeek-V3 是第一个公开报告在超大规模模型(671B 总参数的 MoE)上用 FP8 混合精度完成预训练的工作,它把上面这些零件拼成了一个完整的系统,可以分成四块。
10.1 什么用 FP8,什么不用
- 线性层的三个 GEMM(Fprop、Dgrad、Wgrad)全部用 FP8,输入 FP8,输出 BF16 或 FP32。
- 保持原精度(BF16 或 FP32)的组件:embedding、输出头、MoE 门控、归一化算子、注意力算子。理由是这些算子对低精度敏感,或者计算量小、用高精度几乎不增加成本。
- 主权重、权重梯度、优化器状态用更高精度存储;它们的显存开销靠在数据并行 rank 之间分片来摊薄。
10.2 细粒度量化
分组方式正是第 9 节演示里的两种:
- 激活按 1×128 分组(每个 token 的每 128 个通道一个缩放因子)。
- 权重按 128×128 分块(每 128 个输入通道 × 128 个输出通道一个缩放因子)。
为什么两者不一样?激活每个 token 一行,离群值常集中在个别 token 或通道上,1×128 能把它们隔离得最细。权重则要在两个 GEMM 里用:Fprop 沿 \(d_{\text{in}}\) 归约,Dgrad 用 \(W^\top\)、沿 \(d_{\text{out}}\) 归约。128×128 的块在两个方向上都按 128 对齐,转置之后仍然是合法的分组,同一份 FP8 权重两边都能直接用,不用重新量化。
关键的一点是:这些分组是沿着 GEMM 的归约维度 K 划分的。以 Fprop 为例,\(Y_{ij} = \sum_k X_{ik}W_{kj}\)。把 \(k\) 按 128 分成若干组 \(b\),每组的激活缩放 \(s^X_{i,b}\) 和权重缩放 \(s^W_{b,j'}\)(\(j'\) 是 \(j\) 所在的 128 块)各不相同:
$$Y_{ij} = \sum_b s^X_{i,b}\, s^W_{b,j'}\sum_{k\in b} q^X_{ik}\,q^W_{kj}.$$缩放因子随 \(b\) 变化,不能像按张量缩放那样提到整个求和外面,只能在每组的部分和算完之后乘上去。标准的 FP8 GEMM 不支持这种「沿 K 分组的缩放」。DeepSeek 的实现把它和下面的「提升到 CUDA core 累加」结合在一起:每 128 个元素的部分和从 Tensor Core 拷到 FP32 寄存器时,顺便乘上这一组的两个缩放因子。
这种分组和微缩放格式的思路一致,最好由 Tensor Core 直接接收分组缩放因子,而不是在软件里补——Blackwell 的 MX 格式正是这样做的(第 13 节)。
10.3 全部用 E4M3、在线量化
此前的工作(Transformer Engine、FP8-LM 等)前向用 E4M3、反向用 E5M2。DeepSeek-V3 所有张量都用 E4M3,理由是细粒度分组相当于让一组元素共享指数位,弥补了 E4M3 范围小的缺点,于是可以把位数花在尾数上。缩放因子用在线(即时)量化:每个 1×128 分组或 128×128 块现算 amax,而不是用历史估计。
10.4 低精度存储与通信
- 优化器状态:AdamW 的一阶、二阶矩用 BF16 存,没有观察到性能下降;主权重和用于梯度累加的梯度仍是 FP32。
- 激活缓存:给 Wgrad 用的激活以 FP8 缓存。两个例外:注意力之后那个线性层的输入对精度更敏感(它在注意力的反向里也要用),用一种自定义的 E5M6 格式;并且它在反向里要从 1×128 分组转成 128×1 分组,为避免额外的量化误差,缩放因子都取 2 的整数次幂。MoE 里 SwiGLU 的输入以 FP8 缓存,输出在反向时重算。
- 通信:MoE 上投影之前的激活先量化成 FP8 再做 dispatch(缩放因子同样是 2 的幂),下投影之前的激活梯度也类似;combine 阶段保持 BF16。
10.5 转置的麻烦和一个失败的尝试
同一个激活在 Fprop 里沿行归约、在 Wgrad 里沿列归约。按 1×128 分组的量化结果不能直接转置成 128×1 分组,只能「读出来、反量化、转置、重新量化成 128×1、写回」。一个省事的替代是对激活梯度也像权重一样用 128×128 块量化,这样转置只需交换块的下标。DeepSeek 试过这条路:对 Dgrad 相关的张量都用块量化,在约 16B 参数的 MoE 上训练约 300B token 后发散了。原因是激活梯度在 token 之间极不均衡,存在 token 相关的离群值,块量化管不住。
10.6 效果
DeepSeek 在两个规模上和 BF16 做了对比:约 16B 总参数的 MoE 训练 1.33T token,约 230B 总参数的 MoE 训练约 0.9T token。FP8 相对 BF16 的损失相对误差始终低于 0.25%,在训练随机性的范围内。
11. 累加精度
低精度乘积要在高精度里累加。FP8 GEMM 名义上是「FP32 累加」,但 DeepSeek 发现 H800 上 FP8 GEMM 的累加实际只保留了大约 14 位,远低于 FP32 的 24 位有效位。机制是这样的:对齐 32 个尾数乘积时按最大指数右移,只用每个乘积最高的 14 位相加,超出的位截断;加到寄存器里的累加也是 14 位精度。实测 \(K=4096\) 的两个随机矩阵相乘,最大相对误差接近 2%。\(K\) 越大问题越严重,而大模型训练中 \(K\)(隐藏维度,或者 Wgrad 里的 token 数)恰恰很大。
为什么 14 位不够?原因还是吞没:累加器里的部分和越来越大,而每次加进来的只是一个乘积。14 位有效位意味着部分和比新加的项大 \(2^{14}\approx1.6\times10^4\) 倍以上时,新项就被整个截掉;没到这个倍数时也会被截掉低位。\(K=4096\) 个同号的项相加,后面的项和部分和之比轻易就降到几千分之一,截断误差一路单向积累。如果只在 Tensor Core 里累加 128 项就把结果交出去,部分和始终和单项同一量级,截断损失就小得多;真正的长求和交给 FP32 做。
这就是 DeepSeek 的办法,叫提升到 CUDA core(promotion):Tensor Core 里只累加 \(N_C\) 个元素,然后把部分和拷到 FP32 寄存器,在 CUDA core 上做全精度累加(同时乘上分组缩放因子)。\(N_C=128\),对应 4 次 WGMMA 指令,是他们实验中能明显提升精度、又不带来太大开销的最小间隔。这会降低单个 warpgroup 发射 WGMMA 的速率,但 H800 上通常有两个 warpgroup 的 WGMMA 并存:一个在做提升时另一个可以继续做 MMA,Tensor Core 的利用率仍然很高。
这是对硬件限制的妥协,DeepSeek 也因此建议未来的芯片提高 Tensor Core 内的累加精度、原生支持分组缩放、把 FP8 转换和 TMA 融合以支持在线量化、支持转置读取。Transformer Engine 后来的 FP8 块缩放方案也注明它需要「分段累加」(split accumulation)来保证精度。
上面「舍入与累加」演示的第二个模式用一个简化模型复现了这种现象:累加器每次加法后截断到 14 位有效位;可以对比 FP32 累加、每 128 个元素提升一次到 FP32、以及 BF16 累加。
12. 各种方案的显存账
几种已知方案每个参数的显存(不含激活)。每一行的「每参数」就是左边各列字节数之和:
| 方案 | GEMM 精度 | 主权重 | 梯度累加 | 模型权重 | 梯度 | 优化器状态 | 每参数 |
|---|---|---|---|---|---|---|---|
| BF16 + FP32 混合精度基线 | BF16 | FP32 | FP32 | BF16 | BF16 | FP32 + FP32 | 4+4+2+2+4+4 = 20 B |
| 上面去掉 FP32 梯度累加 | BF16 | FP32 | — | BF16 | BF16 | FP32 + FP32 | 16 B |
| Transformer Engine | FP8 | — | — | FP32 | FP32 | FP32 + FP32 | 16 B |
| FP8-LM(O3 级) | FP8 | FP16 | FP16 | FP8 | FP8 | FP8 + FP16 | 9 B |
| DeepSeek-V3 | FP8 | FP32 | FP32 | FP8 | BF16 | BF16 + BF16 | 15 B |
| Nanotron FP8 | FP8 | BF16 | FP32 | FP8 | FP8 | FP8 + FP8 | 10 B |
Transformer Engine 那一行看起来奇怪:它没有单独的主权重,因为参数本身就以 FP32 存着,每次 GEMM 前现场量化成 FP8,FP32 参数同时扮演了主副本的角色。FP8-LM 和 Nanotron 则更激进,把梯度和优化器状态也压到了 8 位或 16 位。
可以看到 FP8 的主要收益不在参数相关的显存(DeepSeek-V3 只比基线省 25%),而在算力翻倍、激活减半和通信减半。到 2025 年,FP8 预训练仍然算不上完全成熟,方法还在演进,但已经有了 DeepSeek-V3 这样的大规模先例,很可能会像当年的 BF16 一样成为默认选项。
FP8 预训练最主要的风险是稳定性。固定模型规模时,学习率越高越不稳定;FP8 训练常见的失败形态是损失曲线一直正常,到某一步之后突然发散。
13. 微缩放:MXFP8 与 NVFP4
13.1 MX 格式
DeepSeek-V3 的分组缩放在 Hopper 上要靠软件(CUDA core 上的乘法)实现。开放计算项目(OCP)的微缩放(Microscaling,MX)规范把这件事标准化、交给硬件:一个 MX 块由 \(k\) 个低精度元素和一个共享缩放因子组成。所有具体的 MX 格式都用 \(k=32\),缩放因子格式是 E8M0(8 位指数、0 位尾数,只能表示 2 的幂,范围覆盖 FP32 的全部指数):
| 格式 | 块大小 | 缩放因子 | 元素 | 平均位宽 |
|---|---|---|---|---|
| MXFP8 | 32 | E8M0 | FP8(E4M3 或 E5M2) | 8.25 |
| MXFP6 | 32 | E8M0 | FP6(E2M3 或 E3M2) | 6.25 |
| MXFP4 | 32 | E8M0 | FP4(E2M1) | 4.25 |
| MXINT8 | 32 | E8M0 | INT8 | 8.25 |
转换算法是这样的:先求块内 \(\mathrm{amax}\),共享指数 \(\lfloor\log_2\mathrm{amax}\rfloor - e_{\max}\),其中 \(e_{\max}\) 是元素格式最大规格化数的指数(E4M3 是 8,E2M1 是 2);每个元素除以 \(2^{\text{共享指数}}\) 后量化,超出范围的饱和。因为缩放因子是 2 的幂,乘缩放因子只是改指数,硬件代价很小;但 amax 被向下取到 2 的幂后,块内最大值未必能用满元素格式的顶部。
举个例子:一个块的 amax 是 5.3,元素格式 E4M3。\(\lfloor\log_2 5.3\rfloor=2\),共享指数 \(2-8=-6\),缩放因子 \(2^{-6}\),即每个元素乘 64。最大元素变成 \(5.3\times64\approx339\),没有越过 448,但也没用满。如果元素格式是 E2M1(\(e_{\max}=2\)),共享指数是 0、缩放因子是 1,5.3 只能舍入到 6,误差 13%。这正是下面 NVFP4 要改进的地方。
分组是沿某一个轴的(通常是矩阵乘的归约维度),所以 MX 量化和转置不可交换:转置后分组的方向变了。Transformer Engine 的 MXFP8BlockScaling 方案的处理方法是:需要一个张量和它的转置时(前向和反向各用一个),两份都直接从高精度输入量化,避免二次量化误差。它的默认格式是全 E4M3。
13.2 NVFP4
NVIDIA Blackwell 支持 FP4、MXFP4 和自家的 NVFP4。元素都是 E2M1(可表示 0、0.5、1、1.5、2、3、4、6 及其相反数)。NVFP4 和 MXFP4 的区别:
- 块更小:每 16 个元素一个缩放因子(MXFP4 是 32 个)。
- 缩放因子用 E4M3 而不是 E8M0,可以是非 2 的幂的小数,能把块内最大值更准确地对到 6 上。
- E4M3 缩放因子的范围不够,再加一个 FP32 的张量级缩放因子,构成两级缩放。
平均每个元素 4.5 位(4 位元素 + 8 位缩放 / 16),外加每个张量一个 FP32。还是 amax 为 5.3 的那个块:NVFP4 的块缩放因子取 \(5.3/6\approx0.883\),存成 E4M3 是 0.875;\(5.3/0.875\approx6.06\),舍入到 6,反量化得 5.25,误差不到 1%。缩放因子能取非 2 的幂,块内最大值就能被准确地对到 E2M1 的顶端。
13.3 用 NVFP4 预训练
NVIDIA 在 2025 年用 NVFP4 训练了一个 12B 参数的 Mamba-Transformer 混合模型,10T token,损失曲线和下游精度都接近 FP8 基线。配方几乎就是上面这些手段的组合:
- 敏感层保留高精度:12B 模型保守地把前 2 个和最后 8 个块的线性层留在 BF16,约占线性层的 16%。
- 随机 Hadamard 变换:对 Wgrad 的输入做 16×16 的随机 Hadamard 变换(RHT),压制块内离群值。Fprop 和 Dgrad 不做:在它们上面做变换没有带来收益,小模型上反而略差。
- 二维缩放:权重按 16×16 的块缩放,这样前向和反向(转置后)看到的是同一个量化结果;激活和梯度按 1×16 缩放。
- 随机舍入:梯度用随机舍入,权重和激活用就近偶数。Blackwell 的 FP4 转换指令原生支持这两种舍入。
FP4 训练还很新,但它说明了一个趋势:位宽越低,越依赖这些「配套设施」——分组缩放、离群值处理、高精度累加、选择性地保留高精度、无偏舍入。
14. 小结
| 问题 | 原因 | 对策 |
|---|---|---|
| 小梯度变 0 | FP16 指数范围窄 | 损失缩放(动态);或换 BF16 |
| 小更新加不到权重上 | RNE 下 \(|\Delta|\lt\frac12\)ulp 被吞没(FP16 阈值 2048,BF16 256) | FP32 主权重;随机舍入;补偿求和 |
| 长求和误差大 | 部分和变大后吞没小项 | FP32 累加;softmax/norm/loss/归约在 FP32 |
| FP8 范围太窄 | E4M3 只有约 18 个 binade | 按张量缩放:延迟或即时 amax |
| 离群值毁掉精度 | amax 被少数大值主导 | 细粒度分组(1×128、128×128、MX 32、NVFP4 16);Hadamard 旋转 |
| Tensor Core 累加位数不够 | Hopper FP8 约 14 位 | 每 128 个元素提升到 FP32 |
| 转置后分组方向不对 | 分组沿归约维度 | 从高精度重新量化两份;或权重用二维块 |
所有这些技巧都在回答同一个问题——有限的位数该花在哪里。指数位买范围,尾数位买精度;缩放因子把一组数的公共指数提出去,等于给这组数免费多了几位指数;高精度累加和主权重则保证误差不会在长链条里累积。
参考与延伸阅读
想看各种格式在真实训练中的损失曲线和吞吐对比,推荐读 Ultra-Scale Playbook 的混合精度一节和 DeepSeek-V3 报告的第 3.3 节。
- Nouamane Tazi et al., The Ultra-Scale Playbook,「Mixed precision training」一节(FP16 and BF16 training、FP8 pretraining)。
- Paulius Micikevicius et al., Mixed Precision Training(2017),第 3 节。
- Paulius Micikevicius et al., FP8 Formats for Deep Learning(2022),第 2–3 节、表 1。
- DeepSeek-AI, DeepSeek-V3 Technical Report(2024),第 3.3 节(FP8 Training)、3.5.2 节(Compute Hardware)、附录 B。
- Bita Darvish Rouhani et al., Microscaling Data Formats for Deep Learning(2023),表 1 与算法 1;OCP Microscaling Formats (MX) v1.0 规范。
- NVIDIA, Introducing NVFP4 for Efficient and Accurate Low-Precision Inference(技术博客)。
- NVIDIA, Pretraining Large Language Models with NVFP4(2025),第 4 节。
- Jay Shah et al., FlashAttention-3(2024),第 3.3 节(块量化与非相干处理)。
- Suyog Gupta et al., Deep Learning with Limited Numerical Precision(2015)。
- NVIDIA Transformer Engine,recipe 定义(DelayedScaling、Float8CurrentScaling、MXFP8BlockScaling、Float8BlockScaling)。
- PyTorch,Automatic Mixed Precision 文档(autocast 的算子列表、GradScaler)。
- Jacob Austin et al., How to Think About GPUs(各代 GPU 的 FLOPs 规格)。