混合精度与 FP8 训练

在 H100 上,FP8 矩阵乘的吞吐是 BF16 的 2 倍,BF16 又是 FP32 非矩阵运算的十几倍。把数字表示得更短,可以同时省显存、省带宽、提算力。代价是范围和精度都变小了,训练可能不稳定甚至发散。

问题归结为两个。第一,一个数用更少的位表示时,会在哪里出错:太大(溢出)、太小(下溢成 0)、还是不够细(舍入把小的变化吃掉)?第二,训练里哪些量对这些错误敏感,要用什么办法保护它们?今天大模型预训练实际用的 BF16 混合精度、DeepSeek-V3 的 FP8 方案和 Blackwell 上的微缩放格式,都是对这两个问题的回答。

1. 为什么要降精度

PyTorch 张量默认是 FP32,每个数 4 字节。降到 16 位或 8 位有三重好处:

但简单地把所有东西都换成 FP16 行不通,训练通常会发散。混合精度的意思是:大部分计算(尤其是矩阵乘)用低精度做,少数对数值敏感的地方保留高精度。要知道哪些地方敏感、为什么,得先看浮点数是怎么表示的。

2. 浮点数怎么编码

2.1 三个字段

浮点数就是二进制的科学计数法。一个数被拆成三部分:

对普通(规格化,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 之后的间距)
FP32328 / 231273.40e381.18e−381.40e−452⁻²³ ≈ 1.19e−7
BF16168 / 71273.39e381.18e−389.18e−412⁻⁷ ≈ 7.8e−3
FP16165 / 1015655046.10e−55.96e−82⁻¹⁰ ≈ 9.8e−4
FP8 E4M384 / 374482⁻⁶ ≈ 1.6e−22⁻⁹ ≈ 2.0e−32⁻³ = 0.125
FP8 E5M285 / 215573442⁻¹⁴ ≈ 6.1e−52⁻¹⁶ ≈ 1.5e−52⁻² = 0.25
FP4 E2M142 / 11610.50.5

BF16 的「B」来自 Google Brain。它就是 FP32 截掉低 16 位:指数一样 8 位,所以范围和 FP32 几乎相同;尾数只剩 7 位。FP16 则反过来,尾数多(10 位)、指数少(5 位),范围窄得多。

FP8 有两种格式(2022 年由 NVIDIA、Arm、Intel 联合提出,后来成为 OCP 标准),取舍也是范围和精度:

通常的建议是权重和激活用 E4M3、梯度用 E5M2(梯度的动态范围更大)。DeepSeek-V3 则在细粒度缩放的帮助下所有张量都用了 E4M3。

2.3 一个例子:0.1

\(0.1 = 1.6\times2^{-4}\)。在各个格式里:

输入任意数,看它在各格式中的编码:

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。

各格式正数的可表示范围,横轴是 \(\log_2|x|\)。深色是规格化范围,浅色是次正规范围。BF16 与 FP32 几乎重合,FP8 只覆盖很窄的一段。
浮点格式浏览器
舍入方式
格式位模式(符号 指数 尾数)舍入后的值相对误差该处 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 累加(防止长求和出错)。一步训练的流程如下:

主权重FP32 cast 权重副本FP16 / BF16 前向(低精度矩阵乘,FP32 累加)→ loss loss × S损失缩放 反向(低精度)得到 S·∇ 的梯度 ÷ S,查 inf/NaN再做裁剪等(FP32) 优化器更新FP32 主权重 + 状态 有溢出:跳过这一步,S 减半;连续若干步无溢出:S 加倍
FP16 混合精度训练的一步(含动态损失缩放)。BF16 训练去掉「损失缩放」这一格即可。

5.1 FP32 主权重

权重在优化器里保存一份 FP32 的「主副本」(master weights),每步把它转成 FP16 用于前向和反向;优化器用梯度更新的是 FP32 主副本。原因有两个:

第二点对 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 的有:

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 怎么判断是精度出了问题

实际训练里,损失发散的原因很多,精度只是其中之一。几个比较直接的检查:

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 → 再读一遍做转换」,对带宽受限的部分很不划算。两种策略:

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}}}\)。训练时有三个矩阵乘:

这三个名字在 FP8 的讨论里会反复出现:Fprop 是前向(forward propagation),Dgrad 算的是传给上一层的激活梯度(data gradient),Wgrad 算的是权重梯度。三个都在 FP8 里做,理论上比 BF16 快一倍。而且 Wgrad 用 FP8 的 \(X\),意味着前向存给反向的激活本身就可以是 FP8,激活显存也减半。注意同一个 \(X\) 在 Fprop 里沿行(\(d_{\text{in}}\))归约,在 Wgrad 里沿列(\(T\))归约,这在下面讲分组缩放时会成为麻烦。

输入 XBF16 主权重 WFP32 → FP8 量化 → FP8 量化 Fprop GEMMFP8 × FP8 → FP32 累加 输出 YBF16 Wgrad GEMMXᵀ(FP8) · dY(FP8) Dgrad GEMMdY(FP8) · Wᵀ(FP8) dYBF16→FP8 → dW(FP32,进优化器) → dX(BF16,往前传) 缓存 FP8 的 X 给反向
FP8 线性层的数据流。三个 GEMM 都吃 FP8、在高精度里累加;主权重、权重梯度留在 FP32。

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、列是通道,数值是固定种子的标准高斯随机数),在其中注入离群值,再用不同的格式和分组做量化-反量化,计算误差。数据为示意。

离群值与缩放粒度:量化误差
离群值类型
元素格式
热图显示的分组
相对误差 ≤ 0.1%1%10%≥ 100%(含被量化成 0)
缩放分组整体 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 的张量级缩放)。开销按缩放因子的位数平摊到每个元素计。

拖动滑块能看到几件事:

另一类办法是在量化前把离群值「摊开」。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,什么不用

10.2 细粒度量化

分组方式正是第 9 节演示里的两种:

为什么两者不一样?激活每个 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 节)。

激活 X(T × K):每行每 128 列一个缩放 权重 W(K × N):每 128×128 一个缩放 累加:Tensor Core 内每 128 个 K(4 次 WGMMA)→ 拷到 CUDA core,乘 sX·sW,加进 FP32
DeepSeek-V3 的细粒度量化与分组累加。上:分组方式;下:沿 K 方向每 128 个元素一组,组内在 Tensor Core 里累加,组间在 FP32 里累加。

10.3 全部用 E4M3、在线量化

此前的工作(Transformer Engine、FP8-LM 等)前向用 E4M3、反向用 E5M2。DeepSeek-V3 所有张量都用 E4M3,理由是细粒度分组相当于让一组元素共享指数位,弥补了 E4M3 范围小的缺点,于是可以把位数花在尾数上。缩放因子用在线(即时)量化:每个 1×128 分组或 128×128 块现算 amax,而不是用历史估计。

10.4 低精度存储与通信

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 混合精度基线BF16FP32FP32BF16BF16FP32 + FP324+4+2+2+4+4 = 20 B
上面去掉 FP32 梯度累加BF16FP32—BF16BF16FP32 + FP3216 B
Transformer EngineFP8——FP32FP32FP32 + FP3216 B
FP8-LM(O3 级)FP8FP16FP16FP8FP8FP8 + FP169 B
DeepSeek-V3FP8FP32FP32FP8BF16BF16 + BF1615 B
Nanotron FP8FP8BF16FP32FP8FP8FP8 + FP810 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 的全部指数):

格式块大小缩放因子元素平均位宽
MXFP832E8M0FP8(E4M3 或 E5M2)8.25
MXFP632E8M0FP6(E2M3 或 E3M2)6.25
MXFP432E8M0FP4(E2M1)4.25
MXINT832E8M0INT88.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 的区别:

平均每个元素 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 基线。配方几乎就是上面这些手段的组合:

FP4 训练还很新,但它说明了一个趋势:位宽越低,越依赖这些「配套设施」——分组缩放、离群值处理、高精度累加、选择性地保留高精度、无偏舍入。

14. 小结

问题原因对策
小梯度变 0FP16 指数范围窄损失缩放(动态);或换 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 节。