前置讲 P3 · 数值表示与舍入误差 · Number Representation and Rounding Error

Efficient ML Inference at Scale · 预备知识 3。个人学习讲义与配套实验,按主题整理并持续修订。

课程源码 ZIP ↓

个人整理的学习笔记,非官方讲义;课程来源与整理说明见课程介绍。配套示例未逐一运行验证。

本篇目录

定位:为没有数值计算背景的读者补充的前置讲,原课没有对应的课次。它支撑第 3 讲(精度、量化与稀疏)和第 11 讲(FlashAttention 的在线 softmax),也解释了第 1 讲里”FP8 是 BF16 的 2 倍算力""NVFP4 两级缩放”这些说法背后的东西。建议先读 P1。 依据:IEEE 754-2019;OCP 8-bit Floating Point Specification (OFP8) Rev 1.0;OCP Microscaling Formats (MX) Specification v1.0(2023);P. Micikevicius et al., FP8 Formats for Deep Learning(2022);NVIDIA 技术博客 Introducing NVFP4 for Efficient and Accurate Low-Precision Inference(2025-06);DeepSeek-AI, DeepSeek-V3 Technical Report §3.3(FP8 训练的细粒度量化与累加精度);M. Milakov and N. Gimelshein, Online normalizer calculation for softmax(2018);N. Higham, Accuracy and Stability of Numerical Algorithms(误差界);D. Goldberg, What Every Computer Scientist Should Know About Floating-Point Arithmetic(1991)。 配套代码 Code:code/prereq3/(numpy;共享模块 numfmt.py 能逐位编码 / 解码任意小浮点格式,+ 5 个脚本,最慢的约 3 秒) 预计阅读时间:2.5 小时(含运行代码)


0. 本讲导航 · How to read this lecture

0.1 为什么推理课要讲数值表示

第 1 讲里有两个数字值得再看一眼:H100 的 FP8 算力是 BF16 的 2 倍;Blackwell Ultra 的 NVFP4 算力又是 FP8 的 3 倍。厂商宣传的”每代 N 倍”里,有一大半来自换了更窄的数值格式。decode 受限于带宽,每个参数少一个字节,就是少搬一个字节;KV cache 用 FP8 存,同样的显存就能多放一倍的上下文。

但”换一个格式”到底丢掉了什么?4 位的数只有 16 个编码,它怎么能表示一个模型的权重?为什么 FP8 有两种(E4M3 和 E5M2),NVFP4 为什么要”两级缩放”?为什么低精度矩阵乘法要用高精度累加?为什么 softmax 要先减最大值?这一讲从比特讲起,把这些问题一个一个拆开:

本讲的概念后面在哪里用到
补码、溢出、累加器宽度第 3 讲(INT8 量化);第 8 讲(Tensor Core 的输入 / 累加格式)
浮点格式:指数管范围、尾数管精度第 1 讲 §5(NVFP4);第 3 讲(FP8 / FP4 量化);第 7 讲(KV cache 量化)
舍入、误差积累、Kahan / 两两求和第 3 讲;第 10–11 讲(内核里的累加顺序);第 14 讲(为什么”结果对不上”不一定是 bug)
数值稳定的 softmax、在线 softmax第 2 讲(注意力);第 11 讲(FlashAttention、flash-decoding)
缩放因子、零点、粒度、块缩放第 3 讲(LLM.int8、MX、NVFP4);第 9 讲(低精度与能量)

0.2 学习目标 Learning goals

  1. 读懂一个整数的补码;知道溢出时”回绕”和”饱和”的区别;解释 INT8 矩阵乘法为什么要累加到 INT32。
  2. 读懂任何一种二进制浮点格式(符号、指数、尾数、偏置、次正规数、特殊值);对 FP32 / FP16 / BF16 / FP8 / FP6 / FP4 能说出它们的范围、精度和用途。
  3. 理解舍入误差的大小(单位舍入 uu)和方向(偏差),会用误差界比较顺序求和、两两求和、Kahan 求和;解释低精度累加器的问题和”提升到 FP32”的解法。
  4. 识别数值上不稳定的公式并改写:softmax 减最大值、log-sum-exp、在线 softmax 的合并、方差的两遍法 / Welford。
  5. 会做仿射量化(scale 和 zero point),理解截断的权衡、量化粒度的选择,以及 MX / NVFP4 这类块缩放格式为什么有效。

0.3 本讲地图 Map

§1  为什么数值格式是推理效率的核心:字节、算力、能量
§2  整数:补码;回绕与饱和;累加器宽度;定点数的局限                                ← 代码 01
§3  浮点数:格式的解剖;ulp 与相对误差;FP32 / FP16 / BF16;FP8;FP6 / FP4;范围 vs 精度 ← 代码 02
§4  舍入与误差的积累:舍入方式;停滞;求和算法;低精度矩阵乘法的累加器              ← 代码 03
§5  数值稳定性:安全 softmax;log-sum-exp;在线 softmax;1/√d;方差                  ← 代码 04
§6  量化入门:仿射量化;整数矩阵乘法;截断;粒度;块缩放格式;误差怎样传到输出     ← 代码 05
§7  硬件视角:每一代 Tensor Core 支持什么格式,为什么越窄越快
§8–12  小结、常见误区、练习(含答案)、延伸阅读、和正课的联系

0.4 关于数字 · About the numbers

  • 本讲的全部实验数字都来自代码的实际输出,每次运行完全相同(固定随机种子,没有墙钟测量)。
  • numpy 原生只有 FP64 / FP32 / FP16。其它格式(BF16、FP8、FP6、FP4)用 numfmt.py 模拟:它把一个实数舍入到任意”指数 ee 位、尾数 mm 位”的格式。§3.9 会展示它和 numpy、PyTorch 的实现逐位一致,所以模拟出来的数就是真实硬件会得到的数。
  • 数据都是合成的(高斯、Student-t、人为加入的离群通道),用来把机制看清楚;真实模型上的量化效果是第 3 讲的内容。

1. 为什么数值格式是推理效率的核心 · Why number formats matter

把一个数从 16 位换成 8 位,有三重收益:

  1. 字节:每个参数、每个 KV 元素少一半字节。decode 是 memory-bound 的(第 1 讲 §4),时间 ≈ 字节 ÷ 带宽,字节减半,时间几乎减半;容量也一样——同一块显存能放下两倍的 KV cache,批可以开得更大。
  2. 算力:乘法器的面积大致随尾数位数的平方增长(一个 pp 位乘法要处理 p2p^2 个部分积)。同样的芯片面积,8 位乘法器能放的数量是 16 位的好几倍。这就是 H100 的 FP8 算力(1,979 TFLOP/s)是 BF16(989)2 倍的来历。
  3. 能量:第 1 讲 §2 的 Horowitz 能量表(45 nm):一次 FP32 乘法 3.7 pJ,FP16 乘法 1.1 pJ,INT8 乘法 0.2 pJ;8 位整数加法只要 0.03 pJ。更窄的数,每次运算更省电;而第 1 讲说过,功耗固定时,省电就是提速。

代价是精度(相邻两个能表示的数离得多近)和范围(能表示多大、多小的数)。一个 bb 位的格式只有 2b2^b 个编码:8 位只有 256 个,4 位只有 16 个。全讲的核心问题就是:

有限的比特,怎样分配给范围和精度?剩下的误差,怎样不让它积累、不让它爆炸?


2. 整数 · Integers

代码:01_integers_and_fixed_point.py

2.1 补码

一个 bb 位无符号整数的值是 ∑ibiti⋅2i\sum_i \text{bit}_i \cdot 2^i,范围 [0,2b−1][0, 2^b - 1]。负数怎么办?几乎所有硬件都用补码(two’s complement):

v=−bitb−1⋅2b−1+∑i=0b−2biti⋅2i.v = -\text{bit}_{b-1} \cdot 2^{b-1} + \sum_{i=0}^{b-2} \text{bit}_i \cdot 2^i .

也就是说,最高位的”权重”是负的。8 位补码的范围是 [−128,127][-128, 127]。代码 01 演示 1 列出了几个例子:−1-1 是全 1(1 1111111),−128-128 是 1 0000000。

为什么是补码,而不是”一位符号 + 绝对值”?

  • 同一个加法器:把两个补码当作无符号数相加、丢掉进位,结果正好是正确的补码。演示 1 里 (−3)+5(-3) + 5:编码 253 + 5 = 258,丢掉第 9 位得 2。有符号、无符号整数共用一套电路。
  • 0 只有一个编码(“符号 + 绝对值”有 +0 和 −0)。
  • 取负数 = 按位取反再加 1,硬件很便宜。

一个怪现象:−128-128 的相反数 +128+128 表示不了,取反加一后还是它自己。所以对称量化通常只用 [−127,127][-127, 127],OCP 的 MX 规范也明确允许空着 −128-128,以保持正负对称、避免负偏差。

2.2 溢出:回绕与饱和

结果超出范围时,有两种处理方式(演示 2):

运算(8 位)数学结果回绕(wrap)饱和(saturate)
127 + 1128−128127
100 + 100200−56127
−100 × 2−20056−128
  • 回绕是模 2b2^b 的运算,C 语言和 numpy 的整数类型都这样做,而且不报错:numpy 的 int8 数组 [127] + [1] 静默地得到 [-128],np.abs(int8(-128)) 返回 −128-128。
  • 饱和把结果截到最大 / 最小值。它的结果离真实答案最近,所以量化时用饱和:超范围的权重截到 127,误差有限;回绕成 −128-128 就是灾难。

2.3 累加器要多宽

INT8 矩阵乘法的每个输出是 KK 个乘积之和。一个乘积最大 127×127=16,129127 \times 127 = 16{,}129,所以 int16 最多放下 2 个”最坏”的乘积,int32 能放下 133,144 个。演示 3 真的算了 1000 个长度 4096 的点积:

输入分布结果的平均绝对值int16 回绕:错int16 饱和:错int32:错
均匀分布 [−127,127][-127, 127]278,97991.8%100.0%0.0%
量化过的高斯(σ≈32\sigma \approx 32)50,44759.3%98.3%0.0%

输入可以省,累加器不能省:GPU Tensor Core 的 INT8 运算是 s8 × s8 → s32,TPU 的 MXU 也累加到 32 位。一个有趣的细节:回绕的 int16 比饱和的错得少。因为模运算有个性质——中间的部分和溢出了也没关系,只要最终结果落回范围内,回绕的答案就是对的;饱和一旦截断就回不来了。

2.4 定点数的局限

能不能用整数表示小数?可以:约定”小数点在第 8 位”,整数 qq 代表 q/256q / 256。这叫定点数(fixed point),例如 Q7.8:1 位符号、7 位整数、8 位小数,间隔 2−8≈0.00392^{-8} \approx 0.0039,最大约 128。

定点数的绝对误差是常数,于是相对误差随数值大小剧烈变化。演示 4 比较了同样 16 位的 Q7.8、FP16、BF16 在每个数量级上的最大相对误差:

数量级定点 Q7.8FP16BF16
[10−3,10−2)[10^{-3}, 10^{-2})1.0e+004.8e-043.8e-03
[10−1,1)[10^{-1}, 1)1.9e-024.9e-043.9e-03
[10,100)[10, 100)1.9e-044.8e-043.9e-03
[103,104)[10^{3}, 10^{4})9.9e-014.9e-043.9e-03
[104,105)[10^{4}, 10^{5})1.0e+00inf3.8e-03

定点数只在 1–100 附近好用。神经网络的权重、激活、梯度跨越很多个数量级,需要一种相对精度处处相同的表示——让小数点跟着数值大小”浮动”。这就是浮点数。整数会在 §6 重新登场:先用一个缩放因子把数”挪”到已知范围,整数就又好用了。


3. 浮点数 · Floating point

代码:02_floating_point_formats.py

3.1 二进制的科学计数法

十进制的科学计数法 −6.02×1023-6.02 \times 10^{23} 有三部分:符号、有效数字、指数。二进制浮点数一样:

v=(−1)S×2E−bias×(1+M2m).v = (-1)^S \times 2^{E - \text{bias}} \times \left(1 + \frac{M}{2^m}\right) .
  • SS:1 位符号;
  • EE:ee 位指数字段,当作无符号整数,减去偏置(bias)才是真正的指数——这样指数字段不用补码,比较大小更简单;
  • MM:mm 位尾数字段(mantissa / significand),前面有一个隐含的 1(不用存,因为非零数的二进制科学计数法第一位总是 1),所以有效数字一共 m+1m + 1 位。

一个格式记作 EeeMmm:FP32 是 E8M23,FP16 是 E5M10,FP8 有 E4M3 和 E5M2,FP4 是 E2M1。

3.2 拆开 FP32

代码 02 演示 1 把几个数编码成 FP32(符号 | 8 位指数 | 23 位尾数):

输入比特实际存下来的值
1.00 01111111 000…01
−2.51 10000000 0100…0−2.5
0.10 01111011 100110011001100110011010.100000001490116119384765625
224+12^{24} + 10 10010111 000…016777216
103910^{39}0 11111111 000…0Inf
  • 1.0=20×1.01.0 = 2^0 \times 1.0:指数字段是 127(就是偏置),尾数全 0。
  • 0.1 在二进制里是无限循环小数(就像 1/3 在十进制里),存下来的只是最近的那个 FP32 数。所以浮点运算”几乎总是”有舍入误差,这不是 bug。
  • 224+12^{24} + 1 存不下:FP32 只有 24 位有效数字,2242^{24} 以上相邻两个数相差 2。

3.3 次正规数、Inf 和 NaN

指数字段的两个极端值被留作特殊用途:

  • E=0E = 0:次正规数(subnormal)。值是 (−1)S×21−bias×(0+M/2m)(-1)^S \times 2^{1 - \text{bias}} \times (0 + M / 2^m)——没有隐含的 1。它们在 0 和最小正规数 21−bias2^{1-\text{bias}} 之间等间隔地填满空隙,让数”逐渐”下溢到 0,而不是突然跳到 0。代价是精度逐渐降低(有效位数越来越少)。
  • EE 全 1:Inf 和 NaN(IEEE 格式)。M=0M = 0 是 ±Inf(溢出、除以 0),M≠0M \neq 0 是 NaN(0/0、Inf − Inf)。

3.4 ulp、机器精度和相对误差

在区间 [2k,2k+1)[2^k, 2^{k+1}) 里,能表示的数是等间隔的 2m2^m 个,间隔是

ulp=2k−m(unit in the last place).\text{ulp} = 2^{k - m} \quad (\text{unit in the last place}).

数越大,间隔越大,但间隔 / 数值大致不变。最重要的两个量:

  • 机器精度 ε=2−m\varepsilon = 2^{-m}:1 和”比 1 大的下一个数”的距离;
  • 单位舍入误差 u=ε/2=2−(m+1)u = \varepsilon / 2 = 2^{-(m+1)}:就近舍入时,任何(不溢出、不下溢的)实数 xx 的舍入结果满足
fl(x)=x(1+δ),∣δ∣≤u.\mathrm{fl}(x) = x (1 + \delta), \quad |\delta| \le u .

这一行是整个数值分析的出发点:每次舍入引入一个不超过 uu 的相对误差。FP32 的 u≈6×10−8u \approx 6 \times 10^{-8},FP16 是 2−11≈4.9×10−42^{-11} \approx 4.9 \times 10^{-4}(§2.4 表里 FP16 那一列正是这个数),BF16 是 2−8≈3.9×10−32^{-8} \approx 3.9 \times 10^{-3}。

3.5 格式一览

代码 02 演示 2 由定义算出每种格式的参数(和 IEEE 754、OCP 规范的表格一致):

格式结构最大值最小正规数最小正数ε\varepsilon十进制位数有限值个数
FP32E8M233.403e+381.175e-381.401e-451.19e-077.24,278,190,079
TF32E8M103.401e+381.175e-381.148e-410.0009773.3522,239
FP16E5M1065,5046.104e-055.96e-080.0009773.363,487
BF16E8M73.39e+381.175e-389.184e-410.007812.465,279
FP8 E4M3E4M34480.015620.0019530.1251.2253
FP8 E5M2E5M257,3446.104e-051.526e-050.250.9247
FP6 E3M2E3M2280.250.06250.250.963
FP6 E2M3E2M37.510.1250.1251.263
FP4 E2M1E2M1610.50.50.615

几个要点:

  • FP16 vs BF16:都是 16 位,分法相反。FP16 精度高(3.3 位十进制)、最大只有 65,504;BF16(“brain float”,Google Brain 为 TPU 设计)就是 FP32 的高 16 位,范围和 FP32 一样,精度只有 2.4 位十进制。训练从 FP16 转向 BF16,因为范围比精度更难对付(§3.8)。
  • TF32 不是存储格式,而是 NVIDIA Tensor Core 内部用的输入格式:FP32 的 8 位指数 + FP16 的 10 位尾数,让”FP32 的矩阵乘法”也能跑在 Tensor Core 上。
  • 越往下,格式越”可怜”:FP8 只有 253 / 247 个不同的有限值,FP4 只有 15 个。

3.6 FP8:E4M3 与 E5M2

FP8 有两种编码(Micikevicius et al. 2022,后来成为 OCP OFP8 规范):

  • E4M3:精度高、范围小,建议用于权重和激活。它做了一个改动:不要 Inf,只保留一个 NaN 编码(S.1111.111),把指数全 1 的其余 7 个编码拿来表示 256、288、…、448。如果照搬 IEEE 的约定,最大值只有 240。
  • E5M2:范围大、精度低,和 FP16 的指数一样(可以直接截断 FP16 得到),保留 IEEE 的 Inf / NaN,建议用于梯度。

演示 3 列出了 E4M3 每个”2 的幂区间”里的数:每个区间都是 8 个(3 位尾数),间隔从次正规区的 2−92^{-9} 一路翻倍到最后一个区间的 32,但间隔 / 数值始终在 6.7%–12.5% 之间——这就是”相对精度均匀”。最后一个区间 [256,512)[256, 512) 只有 7 个数(第 8 个编码是 NaN),所以最大值是 448。

DeepSeek-V3 是一个反例:它在所有张量(包括梯度)上都用 E4M3,“尾数优先于指数”,靠细粒度的缩放因子(§6.5)弥补范围。

3.7 FP6 和 FP4:小到可以列出全部的数

OCP 的 MX 规范定义了两种 FP6(E3M2、E2M3)和一种 FP4(E2M1),它们没有 Inf 和 NaN,所有编码都是数,溢出时只能饱和。FP4 E2M1 的全部非负值是:

0, 0.5, 1, 1.5, 2, 3, 4, 6.0,\ 0.5,\ 1,\ 1.5,\ 2,\ 3,\ 4,\ 6 .

1 和 2 之间间隔 0.5,2 到 4 之间间隔 1,4 到 6 之间间隔 2。一个最大值只有 6、一共 15 个数的格式,不缩放根本没法用:权重通常在 ±0.1\pm 0.1 量级,直接舍入几乎全变成 0。缩放因子的作用,就是把一组数整体乘以一个因子,让它们落在格式”最密”、又不溢出的那段数轴上(§6)。

3.8 范围 vs 精度:loss scaling 的故事

演示 5 把几个数放进不同的格式(不做任何缩放):

数FP16BF16FP8 E4M3FP8 E5M2
1e−90(下溢)9.96806e-10(0.32%)0(下溢)0(下溢)
1 + 1/3001.00293(0.04%)1(0.33%)1(0.33%)1(0.33%)
300.5精确300(0.17%)288(4.2%)320(6.5%)
70,000Inf(溢出)70144(0.21%)NaN(溢出)Inf(溢出)

同一个数的命运完全不同。BF16 分辨不出 1 和 1 + 1/300,FP16 却装不下 70,000。

这就引出了混合精度训练早期的一个问题(Micikevicius et al. 2018):梯度通常非常小。演示 5 生成了 20 万个”像梯度”的数(绝对值的中位数 1e−6),在 FP16 里有 8.0% 下溢成 0、87.0% 掉进精度很差的次正规区。解决办法叫 loss scaling:把 loss 乘以 SS,反向传播出来的梯度也都放大 SS 倍,在 FP16 里就不会下溢;更新权重前再除以 SS。

FP16 前乘以下溢成 0落在次正规区上溢成 Inf
202^08.0%87.0%0.00%
282^{8}0.0%28.3%0.00%
2162^{16}0.0%0.3%0.00%
2242^{24}0.0%0.0%0.04%
2322^{32}0.0%0.0%13.77%

缩放因子要”刚刚好”:太小救不了小数,太大让大数溢出。BF16 根本不需要这一步(范围和 FP32 一样),这是今天训练普遍用 BF16 的原因。

请记住这个故事:loss scaling 是”用一个缩放因子把数挪进格式的表示范围”的第一个大规模应用。之后的发展只是让缩放因子越来越细:每张量一个(FP8 训练)→ 每 128 个元素一个(DeepSeek-V3)→ 每 32 个(MX)→ 每 16 个(NVFP4)。

3.9 我们的模拟器可信吗

numfmt 用 numpy 实现了通用的舍入:找到 xx 所在的 2 的幂区间(用 np.frexp,精确)、算出 ulp、把 x/ulpx / \text{ulp} 舍入成整数、再处理溢出。演示 4 把它和独立的实现逐位比较:

格式对照的独立实现比较的数不一致
FP16numpy astype(float16)100,0070
FP32numpy astype(float32)100,0000
BF16位运算 (u + 0x7FFF + lsb) >> 16100,0000
FP8 E4M3PyTorch float8_e4m3fn100,0060
FP8 E5M2PyTorch float8_e5m2100,0060

一个细节:同样是”转成 FP8”,PyTorch 对 E4M3 饱和(超过 448 的变成 448),对 E5M2 却溢出成 Inf。OCP 规范两种方式(SAT / OVF)都允许。用低精度时,要查清楚溢出是怎么处理的。


4. 舍入与误差的积累 · Rounding and error accumulation

代码:03_rounding_and_accumulation.py

4.1 四种舍入方式

一个数落在两个可表示的数之间时,选哪一个?

  • 就近,平局取偶(round to nearest, ties to even,RNE):IEEE 默认,numpy、GPU 的默认。平局时选尾数末位为 0 的那个。
  • 截断(toward zero,RTZ):直接丢掉多余的位。硬件最便宜。
  • 就近,平局远离 0(ties away):小学的”四舍五入”。
  • 随机舍入(stochastic rounding,SR):以”xx 离下面那个数的距离 / ulp”为概率向上取,期望值正好是 xx。

演示 1 把 [1,2)[1, 2) 里 20 万个均匀分布的数舍入到 BF16(单位:ulp):

方式平均误差(偏差)均方根误差最大误差
就近取偶+0.00050.2880.500
截断−0.49960.5771.000
四舍五入+0.00050.2880.500
随机舍入−0.00120.4080.998

比误差大小更重要的是偏差。截断每次都偏小半个 ulp,这是系统性的,累加几千次就线性积累;无偏的误差有正有负,互相抵消,只按 n\sqrt{n} 增长。随机舍入单次误差更大,但无偏,而且对”比半个 ulp 还小”的变化也有反应(§4.3)。

为什么”平局取偶”而不是”四舍五入”?均匀分布的数几乎不会正好平局,两者没区别;但在低精度格式和整数里平局很常见(FP4 里 2.5 正好在 2 和 3 之间),“远离 0”会把所有平局都往外推,产生偏差;“取偶”一半向上、一半向下。

4.2 浮点加法不满足结合律

由 §3.4,每次加法 fl(a+b)=(a+b)(1+δ)\mathrm{fl}(a + b) = (a + b)(1 + \delta)。所以 (a+b)+c(a + b) + c 和 a+(b+c)a + (b + c) 一般不相等——舍入发生在不同的地方。这有两个直接后果:

  1. 同一个求和,换个顺序,结果就可能不同。GPU 上的并行归约、多卡上的 all-reduce、不同的分块方式,都会改变求和顺序。“换了内核以后结果对不上最后几位”通常不是 bug(第 14 讲讨论 KernelBench 怎样设置容差)。
  2. 顺序的选择会影响误差(§4.4)。

4.3 停滞,以及随机舍入怎样救回来

在 BF16 里从 0 开始一直加 1:加到 256 时,ulp 变成 2,256+1=257256 + 1 = 257 正好是平局,取偶回到 256——加了等于没加。演示 2:

累加器操作精确答案就近舍入随机舍入(200 次的均值 ± 标准差)
BF161,000 次 + 110002561000.1 ± 42.7
BF1610,000 次 + 0.01100.0984100.3 ± 5.1
FP1610,000 次 + 11000020489997.6 ± 177.5
FP1610,000 次 + 0.01100.02132100.0 ± 1.8

这叫停滞(stagnation):累加器大到”增量不足半个 ulp”时,再怎么加都不变。它无处不在:点积的累加、求均值、softmax 的分母、优化器更新权重 w+=ηgw \mathrel{+}= \eta g。于是有混合精度的核心原则:

低精度存、低精度乘,高精度加。

随机舍入把停滞救了回来(平均值接近精确答案),代价是随机波动。这是一些 AI 芯片在低精度权重更新里使用随机舍入的原因(Gupta et al. 2015)。

4.4 求和算法:顺序、两两、Kahan

nn 个数求和,三种算法的最坏情况误差界(Higham 第 4 章;uu 是单位舍入误差):

顺序:∣s^−s∣∑∣xi∣≲(n−1) u,两两:≲⌈log⁡2n⌉ u,Kahan:≲2u+O(nu2).\text{顺序:} \frac{|\hat s - s|}{\sum |x_i|} \lesssim (n-1)\,u, \qquad \text{两两:} \lesssim \lceil \log_2 n \rceil\,u, \qquad \text{Kahan:} \lesssim 2u + O(n u^2).
  • 两两求和(pairwise):先两两相加,再把结果两两相加……每个数只参与 log⁡2n\log_2 n 次加法,而且相加的两个数大小相近。GPU 上的并行归约(P2 §6)天然就是这个结构。
  • Kahan 补偿求和:多用一个变量 cc 记住每次被舍掉的低位,下一次补回来:
y = x_i − c          # 先把上次丢掉的补回来
t = s + y            # 加到和里(这里会丢掉 y 的低位)
c = (t − s) − y      # (t − s) 是"实际加进去的",减去 y 得到"丢掉的"(取负)
s = t

演示 4 用 1 万个 [0,1)[0, 1) 里的正数,在 16 位累加器里比较:

累加器算法结果精确答案相对误差
BF16顺序2564994.179.49e-01
BF16两两49924994.174.34e-04
BF16Kahan49924994.174.34e-04
FP16顺序20485058.15.95e-01
FP16两两50565058.14.16e-04
FP16Kahan50605058.13.75e-04

顺序求和停滞了;两两求和把误差降了三个数量级——BF16 的 4992 已经是 BF16 能表示的、离精确答案最近的数(5000 附近 BF16 的间隔是 32)。Kahan 在 FP16 里给出 5060,正好是”精确答案舍入到 FP16”。并行归约不仅更快,而且更准;Kahan 多花 3 次加减法,在 memory-bound 的内核里几乎免费。

4.5 低精度矩阵乘法的累加器

FP8 矩阵乘法的输入是 FP8,两个 FP8 的乘积在 FP32 里是精确的(两个 4 位有效数字相乘只有 8 位),所以误差几乎全部来自累加。累加器有多准?

DeepSeek-V3 技术报告(§3.3.2)有一个重要的观察:H800 上 FP8 GEMM 的累加”只保留约 14 位”,远低于 FP32;对 K=4096K = 4096 的随机矩阵,“最大相对误差接近 2%“。他们的解决办法是每 NC=128N_C = 128 个元素提升一次:Tensor Core 在低精度里累加 128 个乘积后,把部分和拷到 CUDA core 的 FP32 寄存器里累加(这 128 个元素等于 4 次 WGMMA 指令;两个 warpgroup 交替,一个提升、一个继续做 MMA,掩盖开销)。

报告没有说明累加器具体怎样舍入,所以演示 3 用一个玩具模型:累加器保留 14 位有效数字(FP32 的指数 + 13 位尾数),分别试”就近舍入”和”截断”两种(每个 KK 模拟 256 个输出;误差用”范数相对误差”∥c^−c∥/∥c∥\|\hat c - c\| / \|c\|):

累加器K=512K = 512K=4096K = 4096K=16384K = 16384
FP320%3.93e-06%1.05e-05%
14 位,就近舍入0.0259%0.0877%0.208%
14 位,截断0.317%4.01%17.8%
14 位截断 + 每 128 提升到 FP320.0479%0.0464%0.0436%
14 位就近 + 每 128 提升到 FP320.0107%0.0109%0.0113%
BF162.83%6.94%13.4%
FP160.331%0.903%1.98%

读这张表:

  • 截断的偏差随 KK 积累得很快:KK 从 512 增加到 4096(8 倍),误差涨了约 13 倍;就近舍入的误差有正有负,只涨了约 3.4 倍(接近 8≈2.8\sqrt 8 \approx 2.8)。DeepSeek 报告的”接近 2%“和两个玩具模型给出的量级范围一致。
  • 每 128 个提升一次,误差几乎不再随 KK 增长:低精度累加器里最多只有 128 个数,误差被定期”重置”到 FP32。
  • 16 位累加器只适合很短的累加。这也是为什么”FP8 训练”真正难的部分不在 FP8 本身,而在累加和缩放的细节。

4.6 小结:混合精度的分工

环节典型格式原因
存储(权重、KV、激活)BF16 / FP8 / FP4(加缩放因子)字节决定带宽和容量
乘法和存储一样低乘法器面积 ∝ 位数²,能量也低
累加FP32(或定期提升到 FP32)停滞、偏差、误差积累
归一化、softmax、方差FP32指数、除法、相减对精度敏感(§5)

5. 数值稳定性 · Numerical stability

代码:04_softmax_and_stability.py

一个公式在数学上正确,不代表它在浮点数里能算对。数值稳定的算法,不会把舍入误差、溢出、下溢放大成荒谬的结果。LLM 推理里最常见的两个例子是 softmax 和方差。

5.1 安全 softmax:先减最大值

softmax(x)i=exi∑jexj=exi−m∑jexj−m,m=max⁡jxj.\mathrm{softmax}(x)_i = \frac{e^{x_i}}{\sum_j e^{x_j}} = \frac{e^{x_i - m}}{\sum_j e^{x_j - m}}, \qquad m = \max_j x_j .

两种写法数学上相等(分子分母同乘 e−me^{-m}),数值上天差地别。exe^x 不溢出的最大 xx 是 ln⁡(最大值)\ln(\text{最大值}):FP32 / BF16 是 88.72,FP16 只有 11.09,FP8 E4M3 只有 6.10。演示 1 把一组 logits 整体放大:放大 5 倍(最大 15)时 FP16 的朴素 softmax 已经是 NaN(Inf / Inf),放大 30 倍时 FP32 也 NaN 了;减去最大值的版本在所有情况下都正常——所有指数 ≤0\le 0,exp 的值在 (0,1](0, 1] 里,最大那一项正好是 1,分母 ≥1\ge 1。

5.2 log-sum-exp:在对数空间里算

对数概率 log⁡pi=xi−LSE(x)\log p_i = x_i - \mathrm{LSE}(x),其中

LSE(x)=log⁡∑jexj=m+log⁡∑jexj−m.\mathrm{LSE}(x) = \log \sum_j e^{x_j} = m + \log \sum_j e^{x_j - m} .

如果先算 softmax 再取 log,概率下溢成 0 时就得到 −∞-\infty。演示 2:logits 差距到 120 时,e−120≈7.7×10−53e^{-120} \approx 7.7 \times 10^{-53} 小于 FP32 的最小正数,log(softmax) 给出 −inf,而 x−LSE(x)x - \mathrm{LSE}(x) 给出精确的 −120。推理引擎返回 logprobs、投机解码计算接受率(第 6 讲)、计算困惑度,都要这样算。

5.3 在线 softmax 与它的合并运算

安全 softmax 要读三遍输入:一遍求最大值,一遍求分母,一遍算输出。Milakov 和 Gimelshein(2018)发现前两遍可以合成一遍:维护”目前为止的最大值 mm“和”以 mm 为基准的指数和 dd“,每来一个 xjx_j:

m′=max⁡(m,xj),d′=d⋅em−m′+exj−m′.m' = \max(m, x_j), \qquad d' = d \cdot e^{m - m'} + e^{x_j - m'} .

旧的和要乘以 em−m′e^{m - m'} 来”换基准”。例:x=[1,3,2]x = [1, 3, 2],(m,d)(m, d) 依次是 (1,1)(1, 1)、(3, e−2+1)(3,\ e^{-2} + 1)、(3, e−2+1+e−1)=(3,1.5032)(3,\ e^{-2} + 1 + e^{-1}) = (3, 1.5032)。

更重要的是,两段的统计量可以合并:

(ma,da)⊕(mb,db)=(m, daema−m+dbemb−m),m=max⁡(ma,mb).(m_a, d_a) \oplus (m_b, d_b) = \left(m,\ d_a e^{m_a - m} + d_b e^{m_b - m}\right), \quad m = \max(m_a, m_b) .

⊕\oplus 满足结合律,所以一行可以切成很多段、交给不同的线程块各算一段,最后再合并。FlashAttention(按 KV 分块)和 flash-decoding(把很长的 KV 切成几段并行)都建立在这个运算上(第 11 讲)。

演示 3 在 32,000 个 FP32 logits 上比较(分母 dd 对 FP64 的相对误差):

方法dd 的相对误差
两遍:先 max,再用 numpy 的 sum4.7e-08
两遍:先 max,再逐个累加2.7e-05
在线一遍(逐个累加)2.8e-05
8 段各自在线 + ⊕\oplus 合并1.0e-06

这张表有一个写代码时没想到的结果:在线算法的误差比”两遍法”大了几百倍——但原因不是”在线”,而是求和顺序。numpy 的 sum 内部用两两求和;把两遍法的第二遍也改成逐个累加,误差就和在线算法一样了(§4.4 的结论在这里重演)。分成 8 段再合并,误差降到 1e−6。真实的 GPU 内核里每个线程只累加几个元素,再用 warp shuffle 树形合并,所以误差很小。

访存上,在线 softmax 把每个元素的访存从 4 次(读、读、读、写)降到 3 次,对 memory-bound 的 softmax 是约 1.33 倍的理论加速;论文在 V≥4000V \ge 4000 时实测最多约 1.3 倍。再把 softmax 和 top-k 融合,论文报告最多 5 倍——这是”算子融合”的预告(第 11–13 讲)。

5.4 注意力为什么要除以 d\sqrt{d}

q,kq, k 的分量是方差为 1 的随机数时,q⋅kq \cdot k 的方差是 dd。演示 4(d=128d = 128,4,096 个 key):

分数标准差最大值最大注意力权重熵 / 均匀分布的熵FP16 朴素 softmax
不缩放 q⋅kq \cdot k10.9046.10.9750.12 / 8.32NaN
除以 d\sqrt d0.964.10.0097.83 / 8.32正常

不缩放时,softmax 几乎变成 one-hot(梯度很小,这是 Attention Is All You Need 给的理由),而且分数的绝对值大,低精度下更容易溢出。FlashAttention 等内核通常把 1/d1/\sqrt d 和 log⁡2e\log_2 e 合成一个常数,乘到分数上再用 exp2 计算——exp2 在硬件上比 exp 便宜,而第 1 讲 §5.5 说过,exp 正在成为 Blackwell 上的瓶颈。

5.5 方差:灾难性抵消

方差有一个”一遍公式”Var(x)=E[x2]−(E[x])2\mathrm{Var}(x) = E[x^2] - (E[x])^2,读一遍数据就能同时累加 ∑x\sum x 和 ∑x2\sum x^2。但当均值远大于标准差时,两个很大、很接近的数相减,有效数字全被抵消,剩下的主要是舍入误差——灾难性抵消(catastrophic cancellation)。演示 5(n=4096n = 4096,标准差 1,FP32):

均值精确方差(FP64)一遍公式两遍法Welford
00.9951180.9951180.9951180.995117
10000.9830140.9375000.9830140.983018
100000.982780−8.0000000.9827810.982971

均值 10410^4 时,一遍公式算出的方差是负数。两遍法(先求均值,再求 ∑(x−xˉ)2\sum (x - \bar x)^2)几乎和 FP64 一样,但要读两遍数据。Welford 算法一遍扫描、增量更新:

δ=xn−μn−1,μn=μn−1+δ/n,Mn=Mn−1+δ (xn−μn),Var=Mn/n.\delta = x_n - \mu_{n-1}, \quad \mu_n = \mu_{n-1} + \delta / n, \quad M_n = M_{n-1} + \delta\,(x_n - \mu_n), \quad \mathrm{Var} = M_n / n .

它的误差比两遍法略大(均值 10410^4 时从小数点后第 4 位开始不同),但远好于一遍公式,而且像 ⊕\oplus 一样可以分块合并。融合的 LayerNorm 内核想只读一遍激活,用的就是这类算法。RMSNorm 只需要 E[x2]E[x^2],没有抵消问题——这也是它更受欢迎的原因之一。


6. 量化入门 · Quantization basics

代码:05_quantization_basics.py

量化(quantization)= 选一个缩放因子,把一组实数映射到低精度格式能表示的范围,再舍入。§3.8 的 loss scaling 是最早的例子;这一节把它系统化。

6.1 仿射量化:scale 和 zero point

把实数 xx 映射到 bb 位整数 qq:

q=clamp(round(xs)+z, qmin⁡, qmax⁡),x^=s (q−z).q = \mathrm{clamp}\left(\mathrm{round}\left(\frac{x}{s}\right) + z,\ q_{\min},\ q_{\max}\right), \qquad \hat x = s\,(q - z) .
  • 对称(symmetric):z=0z = 0,q∈[−(2b−1−1), 2b−1−1]q \in [-(2^{b-1} - 1),\ 2^{b-1} - 1],s=max⁡∣x∣/(2b−1−1)s = \max|x| / (2^{b-1} - 1)。适合以 0 为中心的数据(权重)。
  • 非对称(asymmetric):q∈[0,2b−1]q \in [0, 2^b - 1],s=(max⁡x−min⁡x)/(2b−1)s = (\max x - \min x) / (2^b - 1),z=round(−min⁡x/s)z = \mathrm{round}(-\min x / s)。适合偏向一侧的数据(ReLU、SiLU 之后的激活)。

演示 1 用 ReLU 之后的激活(一半是 0、一半是正数):对称 int8 只能用 0…127(实际用到 107 个编码),相对误差 1.077%;非对称 uint8 能用 0…255(实际用到 203 个),scale 减半,误差 0.535%——相当于多了 1 位。

6.2 只用整数的矩阵乘法

量化的真正好处是乘加可以全在整数里做。设激活非对称(sa,zas_a, z_a)、权重对称(每个输出通道 sw,js_{w,j}):

yij=∑ksa(qika−za)⋅sw,j qjkw=sasw,j[ ∑kqikaqjkw  −  za∑kqjkw ].y_{ij} = \sum_k s_a (q^a_{ik} - z_a) \cdot s_{w,j}\, q^w_{jk} = s_a s_{w,j} \left[\ \sum_k q^a_{ik} q^w_{jk} \;-\; z_a \sum_k q^w_{jk}\ \right] .

方括号里全是整数:第一项是整数矩阵乘法(累加到 int32,§2.3),第二项只和权重有关,提前算好。最后乘一次浮点的 sasw,js_a s_{w,j}。演示 1 验证:整数流水线和”先反量化再用浮点乘”的最大差只有 7.11e-15(浮点舍入),和原始浮点结果的相对误差 0.943%。这就是 INT8 推理的标准做法。

6.3 截断:两种误差的拉锯

ss 按 max⁡∣x∣\max|x| 来定,意味着一个离群值就能把所有数的间隔撑大。另一个选择是截断(clipping):把范围缩到 c⋅max⁡∣x∣c \cdot \max|x|,超出的数截到边界。这时误差有两部分:舍入误差 ∝(c⋅max⁡)2\propto (c \cdot \max)^2(越小越好),截断误差(cc 越小,被截的数越多、截得越狠)。演示 2(INT4 对称):

分布max/σ不截断(c=1c = 1)最优 cc最优误差
高斯4.719.5%0.5(≈ 2.4σ)11.5%
拉普拉斯8.132.4%0.4(≈ 3.3σ)16.8%
Student-t(ν=5\nu = 5)29.384.6%0.1(≈ 2.9σ)21.4%

尾巴越重,最大值离”大多数数”越远,截断的收益越大(Student-t 从 84.6% 降到 21.4%)。这条 U 形曲线是所有”选缩放因子”方法(MSE 搜索、百分位数校准、GPTQ / AWQ 的缩放搜索)背后的共同权衡。但截断扔掉的恰恰是最大的那些数——如果它们很重要(LLM 激活里的离群特征),截断就会出事。另一条路是缩小缩放的粒度。

6.4 粒度:一个缩放因子管多少个数

演示 3 构造了两个合成矩阵:权重 WW 每一行(输出通道)有自己的尺度;激活 AA 有 8 个”离群通道”,在每个 token 上都放大 60 倍(这是 LLM.int8 论文的观察,第 3 讲)。

矩阵位数整张量每行每 128 个一组每 32 个一组
WW:每行尺度不同INT452.61%15.72%11.72%9.70%
AA:8 个离群通道INT816.25%8.86%2.34%1.20%
AA:8 个离群通道INT438.00%36.28%15.25%8.34%
  • 权重按行(per-channel)缩放就解决了大部分问题,而且几乎没有开销(每行一个缩放因子),是权重量化的默认做法。
  • 激活按 token(每行)缩放几乎没用(INT4 38% → 36%):离群通道在每个 token 上都在,每一行的最大值都被它决定。必须沿通道方向分组,离群通道才只”连累”同组的通道。

麻烦在于:矩阵乘法 AW⊤A W^\top 恰恰沿通道方向(内维 kk)求和。缩放因子沿 kk 变化时,不能在累加完以后统一乘一次缩放因子,必须在累加过程中处理。这正是 DeepSeek-V3”每 128 个提升一次”、MX / NVFP4 “硬件块缩放”要解决的问题;LLM.int8 走的是另一条路——把离群通道拆出去用 FP16 单独算(第 3 讲)。

组越小越准,但缩放因子的开销越大:每 32 个数一个 FP16 缩放因子,相当于每个数多 0.5 bit。

6.5 块缩放浮点格式:MX 与 NVFP4

把”每组一个缩放因子”标准化、并做进硬件,就是块缩放格式:

  • MX(OCP Microscaling,2023,AMD、Arm、Intel、Meta、Microsoft、NVIDIA、Qualcomm 联合制定):每 k=32k = 32 个元素共享一个 E8M0 缩放因子(8 位,只能表示 2 的幂,2−127…21272^{-127} \ldots 2^{127})。元素可以是 FP8(E4M3 / E5M2)、FP6(E3M2 / E2M3)、FP4(E2M1)或 INT8。规范给出的转换算法:
X=2⌊log⁡2max⁡i∣Vi∣⌋2emax⁡elem,Pi=round(Vi/X) (超过元素最大值的截断).X = \frac{2^{\lfloor \log_2 \max_i |V_i| \rfloor}}{2^{e_{\max}^{\text{elem}}}}, \qquad P_i = \mathrm{round}(V_i / X)\ \text{(超过元素最大值的截断)}.

对 FP4 来说,emax⁡=2e_{\max} = 2,块内最大值除完以后落在 [4,8)[4, 8),超过 6 的会被截断。

  • NVFP4(NVIDIA Blackwell):每 16 个元素一个 E4M3 缩放因子(有 3 位尾数,不必是 2 的幂),外加整个张量一个 FP32 缩放因子 g=max⁡∣x∣/(6×448)g = \max|x| / (6 \times 448),把块缩放因子挪进 E4M3 的表示范围。平均每元素 4+8/16=4.54 + 8/16 = 4.5 bit。

演示 4 在第 1 讲同样的数据(100 万个 Student-t 随机数,同一个种子,所以 MXFP4 / NVFP4 的数字和第 1 讲完全一样)上比较:

格式每元素比特相对误差SQNR (dB)
FP8 E4M3,整张量缩放82.65%31.5
INT8,整张量(对称)86.68%23.5
MXFP8(E4M3)8.253.16%30.0
MXFP6(E2M3)6.253.05%30.3
MXFP6(E3M2)6.255.49%25.2
INT4,每 32 个一组(FP16 缩放)4.512.25%18.2
MXFP4(E2M1)4.2512.65%18.0
NVFP44.59.23%20.7
FP4 E2M1,整张量缩放464.44%3.8

再把 NVFP4 和 MXFP4 的两个差别拆开(元素都是 FP4 E2M1):

块大小E8M0 缩放(2 的幂)E4M3 缩放精确缩放(理想)
每 32 个12.65%10.28%10.19%
每 16 个12.42%9.23%9.08%

几点结论:

  1. 缩放因子的格式比块大小更重要:同样每 32 个,E8M0 → E4M3 把误差从 12.65% 降到 10.28%;同样用 E4M3,块从 32 缩到 16 只再降到 9.23%。2 的幂太粗:块内最大值除完以后可能落在 4–8 之间的任何地方,浪费刻度,还要截断超过 6 的数。而 E4M3 缩放已经非常接近”精确缩放”。
  2. 同样 4.5 bit,NVFP4 比 INT4 分组更准:重尾数据里,浮点元素”0 附近密、远处疏”的刻度比整数的均匀刻度更合适。
  3. 块缩放主要是为窄格式准备的:MXFP8(3.16%)反而比整张量缩放的 FP8(2.65%)略差——E4M3 自己的动态范围已经够用,而 2 的幂缩放会截断 0.788% 的元素(块内最大值除完以后落在 [256,512)[256, 512),超过 448 的被截)。
  4. 块缩放之后,尾数比指数重要:MXFP6 的 E2M3(3.05%)比 E3M2(5.49%)准得多,甚至和 MXFP8 差不多。块内的数大小相近,用不上那么大的范围。这和 DeepSeek-V3”细粒度缩放后全部用 E4M3”是同一个道理。

6.6 误差怎样传到输出

第 1 讲 §5.4 留了一个问题:NVFP4 的逐元素误差约 9%,为什么模型精度能和 FP8 相差不到 1%?一个常见的猜想是”误差在点积里正负抵消了”。演示 5 直接检验:

权重格式权重的相对误差输出 Y=WXY = WX 的相对误差
INT8 每行2.01%2.00%
NVFP49.23%9.25%
MXFP412.63%12.65%
INT4 每 128 个一组17.17%17.13%

没有抵消。误差矩阵 E=W^−WE = \hat W - W 和激活不相关时,∥EX∥\|EX\| 和 ∥WX∥\|WX\| 都按 K\sqrt K 增长,比值不变:逐元素误差 9%,每个矩阵乘法的输出也有约 9% 的噪声。答案在别处:模型对噪声有一定的容忍度(最终看的是 argmax 和排序)、不同层和不同张量的敏感度差别很大、量化方法会有针对性地保护重要的部分——这些是第 3 讲的内容。

最后一个经验法则:每多 1 位,SQNR 增加约 6 dB(间隔减半,误差减半,20log⁡102≈6.0220 \log_{10} 2 \approx 6.02)。演示 5 的高斯数据从 INT5 到 INT8,每一位的增量是 +6.6、+6.3、+6.2、+6.1 dB;位数很少时(INT2–INT4)截断和”0 附近的刻度太粗”让增量偏离 6 dB。


7. 硬件视角 · The hardware view

把 §1 的三重收益和 Tensor Core 的发展对照起来看:

代年份Tensor Core 新增的输入格式
Volta(V100)2017FP16
Turing(T4)2018INT8、INT4
Ampere(A100)2020TF32、BF16
Hopper(H100)2022FP8(E4M3 / E5M2)
Blackwell(B200 / B300)2024–25FP6、FP4、MX 块缩放、NVFP4

每一代都在往更窄的格式走,而累加始终至少是 FP32(或像 §4.5 那样定期提升)。还有两点值得注意:

  • 格式越窄,“附加电路”越重要。FP4 本身很便宜,但块缩放要求硬件在点积里处理缩放因子(MX 规范把它写成 C=X(A)X(B)∑iPi(A)Pi(B)C = X^{(A)} X^{(B)} \sum_i P^{(A)}_i P^{(B)}_i:先在元素上累加,再乘两个块的缩放因子)。NVFP4 的 E4M3 缩放因子比 E8M0 更准(§6.5),但乘一个 E4M3 比”加一个指数”(E8M0)贵一点——这是精度和硬件成本的又一个取舍,NVIDIA 博客也承认 E8M0 的优点是”简单”。
  • 低精度改变了 roofline(第 1 讲 §3.7)。如果权重、激活和算力换成同一种更窄的格式,强度和屋脊点同比例变化;Blackwell Ultra 的 NVFP4 算力涨得比字节省得多,临界 batch 从 FP8 的 353 涨到 747。仅权重量化(W4A16)是另一种不对称:权重字节变少,计算仍是 16 位,decode 更快,prefill 不变。

8. 小结 · Summary

  1. 比特是有限的:bb 位只有 2b2^b 个编码。整数用补码,溢出时量化用饱和;INT8 乘积要累加到 INT32。
  2. 浮点数 = 符号 × 2^指数 × 1.尾数:指数位决定范围,尾数位决定精度。每次舍入引入不超过 u=2−(m+1)u = 2^{-(m+1)} 的相对误差。FP16 / BF16、E4M3 / E5M2、E3M2 / E2M3 都是同一个取舍的不同答案。
  3. 误差会积累:舍入的偏差比大小更危险;累加器大到增量不足半个 ulp 就会停滞。低精度存、低精度乘、高精度加;两两求和和 Kahan 求和便宜而有效;FP8 GEMM 要定期把部分和提升到 FP32。
  4. 数值稳定:softmax 先减最大值;对数概率用 log-sum-exp;在线 softmax 的 (m,d)(m, d) 可以分段合并,是 FlashAttention 的基础;方差不要用一遍公式。
  5. 量化:缩放因子把数挪进格式的范围;截断用”大数的误差”换”小数的精度”;粒度越细越准,但缩放因子要沿内维变化时需要硬件支持;块缩放格式(MX、NVFP4)把这一切标准化,缩放因子的精度比块大小更重要。
  6. 误差原样传到输出:矩阵乘法不会让量化噪声的相对大小变小。模型为什么能容忍它,是第 3 讲的问题。

9. 常见误区 · Common pitfalls

  1. “浮点运算有误差,说明代码有 bug。” 0.1 本身就存不准;换一个求和顺序结果就会变。判断对错要用合适的容差(第 14 讲)。
  2. “FP16 和 BF16 差不多,都是 16 位。” 最大值差了约 5×10335 \times 10^{33} 倍,精度差了 8 倍,适用场景完全不同。
  3. “FP8 就是 FP8。” E4M3 和 E5M2 是两种格式;转换时还要看是饱和还是溢出成 Inf / NaN。
  4. “低精度的问题主要在输入的舍入。” 对长的点积,累加器的精度同样重要(§4.5);FP8 GEMM 的误差几乎全部来自累加。
  5. “截断(四舍五入到零)和就近舍入差不多。” 单次误差差 2 倍,但截断有系统性偏差,累加 4096 次后误差差了 40 多倍(§4.5 表)。
  6. “在线 softmax 不如两遍法准。” 在线本身不损失精度;误差来自求和顺序(§5.3)。
  7. “按 token 缩放就能处理激活的离群值。” 离群值集中在固定的通道,要沿通道方向分组(§6.4)。
  8. “块越小越好。” 块小了缩放因子的开销变大;而且缩放因子的格式(2 的幂还是 E4M3)比块大小影响更大(§6.5)。
  9. “量化误差会在矩阵乘法里抵消。” 不会,相对误差原样传递(§6.6)。

10. 练习 · Exercises

带 💻 的题可以用 numfmt 验证。参考答案在每题后面。

练习 1(补码)。写出 −5-5 的 8 位补码。8 位编码 11110110 代表哪个有符号整数?

参考答案

−5 mod 256=251-5 \bmod 256 = 251,即 11111011(也可以由 5 = 00000101 取反得 11111010,再加 1)。11110110 = 246,作为有符号数是 246−256=−10246 - 256 = -10。验证:twos_complement_encode(-5, 8) = 251,twos_complement_decode(0b11110110, 8) = −10。

练习 2(手工编码 FP16)💻。把 1.5 和 −0.375 编码成 FP16(1 | 5 | 10)。

参考答案

1.5=20×1.121.5 = 2^0 \times 1.1_2:符号 0,指数字段 0+15=150 + 15 = 15 = 01111,尾数 1000000000。−0.375=−2−2×1.12-0.375 = -2^{-2} \times 1.1_2:符号 1,指数字段 −2+15=13-2 + 15 = 13 = 01101,尾数 1000000000。验证:bits_str(FP16.encode(-0.375), 16, (1, 5, 10)) 得 1 01101 1000000000。

练习 3(BF16 的舍入)💻。1+2−81 + 2^{-8} 和 1+3×2−91 + 3 \times 2^{-9} 舍入到 BF16 分别是多少?

参考答案

BF16 在 [1,2)[1, 2) 的 ulp 是 2−72^{-7}。1+2−81 + 2^{-8} 正好在 1 和 1+2−71 + 2^{-7} 中间,平局取偶(1 的尾数末位是 0)得 1。1+3×2−9=1+0.75×2−71 + 3 \times 2^{-9} = 1 + 0.75 \times 2^{-7},离 1+2−7=1.00781251 + 2^{-7} = 1.0078125 更近,得 1.0078125。

练习 4(设计一种格式)💻。一个 8 位格式 E3M4、偏置 3。按 IEEE 的约定(指数全 1 留给 Inf / NaN),最大值是多少?如果像 FP6 / FP4 那样不要特殊值呢?最小正数是多少?

参考答案

IEEE 约定:最大指数 =(23−2)−3=3= (2^3 - 2) - 3 = 3,最大值 23×(2−2−4)=15.52^3 \times (2 - 2^{-4}) = 15.5。不要特殊值:最大指数 7−3=47 - 3 = 4,最大值 16×1.9375=3116 \times 1.9375 = 31。最小正数 21−3−4=2−6=0.0156252^{1 - 3 - 4} = 2^{-6} = 0.015625。用 FloatFormat("E3M4", 3, 4, 3, "ieee").max_value 验证。

练习 5(停滞)💻。在 FP16 累加器里从 0 开始反复加 0.5(就近舍入),会停在哪里?为什么?

参考答案

停在 1024。FP16 在 [1024,2048)[1024, 2048) 的 ulp 是 210−10=12^{10 - 10} = 1,1024+0.51024 + 0.5 正好是平局,取偶(1024 的尾数是 0)回到 1024。验证:accumulate(np.full((1, 5000), 0.5), FP16) 得 1024。(对比:加 1 时停在 2048。)

练习 6(求和的误差界)。用 BF16 累加 n=4096n = 4096 个正数。顺序求和、两两求和的最坏情况相对误差界各是多少?这说明了 GPU 树形归约的什么好处?

参考答案

u=2−8≈0.0039u = 2^{-8} \approx 0.0039。顺序:(n−1)u≈16(n - 1) u \approx 16(大于 1,界失去意义,§4.4 的表里顺序求和确实完全失败);两两:12u≈0.04712 u \approx 0.047。树形归约的深度只有 log⁡2n\log_2 n,每个数经历的舍入次数从 nn 降到 log⁡2n\log_2 n。

练习 7(在线 softmax)。对 x=[1,3,2]x = [1, 3, 2] 手算在线 softmax 每一步的 (m,d)(m, d),以及最终的 softmax。再把 [1,3][1, 3] 和 [2][2] 两段分别算出 (m,d)(m, d),用 ⊕\oplus 合并,验证结果相同。

参考答案

(1,1)→(3, e−2+1=1.1353)→(3, 1.1353+e−1=1.5032)(1, 1) \to (3,\ e^{-2} + 1 = 1.1353) \to (3,\ 1.1353 + e^{-1} = 1.5032)。softmax =[e−2,1,e−1]/1.5032=[0.090,0.665,0.245]= [e^{-2}, 1, e^{-1}] / 1.5032 = [0.090, 0.665, 0.245]。分段:[1,3]→(3,1.1353)[1, 3] \to (3, 1.1353),[2]→(2,1)[2] \to (2, 1);合并 m=3m = 3,d=1.1353+1×e2−3=1.5032d = 1.1353 + 1 \times e^{2 - 3} = 1.5032。

练习 8(仿射量化)。把 [−0.5,0.2,1.27][-0.5, 0.2, 1.27] 用 8 位对称量化,scale、编码分别是多少?反量化的结果呢?

参考答案

s=1.27/127=0.01s = 1.27 / 127 = 0.01,q=[−50,20,127]q = [-50, 20, 127],反量化正好是原数(这组数恰好都是 0.01 的倍数)。quantize_int(np.array([-0.5, 0.2, 1.27]), 8) 给出同样的结果。

练习 9(MX 的缩放因子)💻。一个 MXFP4 块的最大值是 5.0,其余元素包括 0.3 和 −1.2。按 OCP 规范的算法,缩放因子 XX 是多少?这三个数量化成多少?NVFP4(假设这 16 个数单独成一个张量)呢?

参考答案

MXFP4:X=2⌊log⁡25⌋−2=20=1X = 2^{\lfloor \log_2 5 \rfloor - 2} = 2^{0} = 1。5.0 正好在 E2M1 的 4 和 6 中间,平局取偶得 4(误差 20%);0.3 → 0.5;−1.2 → −1.0。NVFP4:张量级因子 g=5/(6×448)≈0.00186g = 5 / (6 \times 448) \approx 0.00186,块因子 =5/6/g=448= 5 / 6 / g = 448(E4M3 正好能表示),实际缩放 =448g=5/6= 448 g = 5/6。于是 5.0 → 6 × 5/6 = 5.0(精确),0.3 → 0.4167,−1.2 → −1.25。块最大值被精确表示,正是 E4M3 缩放的优势。

练习 10(显存账)。一个 700 亿参数的模型,用 BF16、FP8、MXFP4、NVFP4、INT4(每 128 个一组、FP16 的 scale 和 zero point)存权重,各要多少 GB?

参考答案

BF16:140 GB;FP8:70 GB;MXFP4:70×109×4.25/8=37.270 \times 10^9 \times 4.25 / 8 = 37.2 GB;NVFP4:4.5 bit → 39.4 GB;INT4 分组:4+32/128=4.254 + 32/128 = 4.25 bit → 37.2 GB。4 位格式把 70B 模型塞进一块 80 GB 的 H100 还剩约 40 GB 给 KV cache。

练习 11(INT32 够不够)。K=8192K = 8192 的 INT8 矩阵乘法,最坏情况下 INT32 累加器会溢出吗?KK 最大到多少才保证不溢出?

参考答案

最坏情况 8192×16,129≈1.32×108<231−1≈2.15×1098192 \times 16{,}129 \approx 1.32 \times 10^8 < 2^{31} - 1 \approx 2.15 \times 10^9,不会。保证不溢出的最大 KK 是 ⌊(231−1)/16,129⌋=133,144\lfloor (2^{31} - 1) / 16{,}129 \rfloor = 133{,}144(对称量化不用 −128 时;用 −128 则乘积最大 16,384)。


11. 延伸阅读 · Further reading

  • D. Goldberg, What Every Computer Scientist Should Know About Floating-Point Arithmetic, ACM Computing Surveys, 1991:浮点数的经典入门。
  • N. Higham, Accuracy and Stability of Numerical Algorithms(第 2 版, SIAM 2002):第 2 章(浮点运算)、第 4 章(求和)。
  • OCP 8-bit Floating Point Specification (OFP8) 与 Microscaling Formats (MX) Specification v1.0:FP8 / FP6 / FP4 / E8M0 的权威定义,只有十几页。
  • P. Micikevicius et al., Mixed Precision Training(ICLR 2018):loss scaling;FP8 Formats for Deep Learning(2022):E4M3 / E5M2 的设计。
  • B. Darvish Rouhani et al., Microscaling Data Formats for Deep Learning(2023):MX 格式的实验。
  • NVIDIA 技术博客 Introducing NVFP4 for Efficient and Accurate Low-Precision Inference(2025)。
  • DeepSeek-AI, DeepSeek-V3 Technical Report §3.3:细粒度量化、累加精度提升、E4M3 全覆盖。
  • M. Milakov and N. Gimelshein, Online normalizer calculation for softmax(2018):FlashAttention 的前身。
  • 本仓库 CS349D 附录 A:从推理系统角度看量化方法(AWQ、GPTQ、SmoothQuant、KV 量化)。

12. 和正课的联系 · Where this shows up

  • 第 1 讲:§5.4 的 NVFP4 实验在本讲 §6.5 被拆开(缩放因子格式 vs 块大小),§6.6 回答了”逐元素误差和任务精度”的第一步。
  • 第 2 讲(Transformer):注意力里的 1/d1/\sqrt d、安全 softmax、RMSNorm(§5)。
  • 第 3 讲(精度、量化与稀疏):在真正的(小)Transformer 上做量化:离群特征、LLM.int8 的混合精度分解、仅权重量化 vs W8A8 vs FP8、MX 与 NVFP4。
  • 第 7 讲(KV cache):KV cache 用 FP8 / INT4 存,容量翻倍;按什么粒度缩放(每 token 还是每通道)又是 §6.4 的问题。
  • 第 8 讲(硬件引擎):Tensor Core 的输入 / 累加格式,脉动阵列里的累加器宽度。
  • 第 11 讲(内核优化):FlashAttention 和 flash-decoding 就是 §5.3 的在线 softmax 加上分块。
  • 第 14 讲(智能体生成内核):判断生成的内核”对不对”,需要理解浮点误差和合理的容差(§4.2)。
← 返回课程目录