音乐
暂未播放
浮点数与数值稳定性:从 IEEE 754 到 FP8 的 LLM 精度世界

背景:为什么推理系统天天在和数值问题打交道#
先做一个小实验。在 Python 里输入 0.1 + 0.2,你会得到 0.30000000000000004,而不是 0.3。再试试 (1e8 + 1) - 1e8,结果不是 1,而是 0。如果你用 CUDA 的 __fadd_rn 做 1.0f + 1e-8f,结果就是 1.0f——那个 1e-8 被”吃掉”了。
这不是 bug,而是计算机表示实数的基本方式决定的。我们在之前拆解 FlashAttention-4 时看到过它的影子(在线 softmax 的”条件重缩放”),在 XFP 那篇里也看到过(量化格式的选择本质上是数值格式的取舍),但一直没把最底层的机制系统讲一遍:浮点数到底是什么、误差从哪里来、为什么 LLM 推理系统的每一个精度决策背后都有一套相同的数值逻辑。
本文的目标是把这个地基补上。读完你会明白:
- 为什么
FP32下1 + 2^{-24} = 1,误差的”门槛”到底在哪; - 为什么 BF16 丢了 16 位尾数还能训练大模型,而 FP16 范围更”精密”却经常溢出;
- 为什么 FlashAttention 一定要用”在线 softmax”这套重缩放机制,而不是先算出最大值再统一归一化;
- 为什么 FP8 推理必须配缩放因子(scaling factor),以及 DeepSeek-V3 那种 671B 参数的大模型为什么敢用 FP8 训练。
二进制的科学计数法:IEEE 754 的表示方案#
从十进制科学计数法说起#
你早就熟悉十进制科学计数法:12345.6=1.23456×104。它把一个数拆成两部分:有效数字(1.23456)和指数(4),有效数字固定在 [1,10) 区间内,叫做”规格化”(normalized)形式。
二进制完全同理:任何非零实数 x 都可以唯一写成
x=±1.f×2e其中 f 是小数部分(二进制),e 是整数指数。比如 5.510=101.12=1.0112×22。关键差异只有一个:指数部分是 2 的幂而不是 10 的幂。
那么存储一个数需要哪些信息?三个:符号(正负)、指数 e、小数 f。IEEE 754 标准(1985 年首次发布,现行版本 IEEE 754-2019)就用固定位数把这三个部分装进一个字节串。以单精度 FP32 为例,它占 32 位:
- 最高位 1 位:符号位 S,S=1 表示负数;
- 中间 8 位:指数位,存放 e 加一个偏移量(bias);
- 低 23 位:尾数位,存放规格化形式里小数部分 f 的二进制位。
于是数值为
x=(−1)S×(1.f1f2⋯f23)2×2E−bias其中 E 是指数位组成的无符号整数。IEEE 754 对位布局的每一个细节都值得单独解释,因为每个细节都对应一个数值特性。
为什么指数要加偏移量(bias)而不是用补码#
FP32 的 8 位指数可以表示 0∼255 的无符号整数。如果我们直接把 e 存成带符号数,比较两个浮点数大小时还得先处理符号位和指数的符号,硬件设计变复杂。IEEE 754 的做法是存 E=e+127,即偏移量为 127。这样指数部分 E 在内存里就是”越大代表数越大”的无符号数,配合符号位和尾数,整个浮点数可以用无符号整数比较来完成大小比较(这叫 monotonic ordering,是 float 排序和 radix sort 的基础)。这也意味着最小的正规格化数是 e=−126(E=1),最大的指数是 e=127(E=254)。
为什么尾数不用存开头的那个 1#
规格化形式里 x=1.f×2e 的整数部分永远是 1(对正规格化数而言),那这一位就不必存储——这叫”隐式前导位”(implicit leading bit)。23 位尾数字段因此白赚了 1 位精度:FP32 实际有效数字是 24 位。同理 FP16 有 11 位有效数字、BF16 只有 8 位。
特殊值:指数和尾数全 0 / 全 1 时#
指数位全 0 或全 1 被 IEEE 754 保留给特殊值:
| 指数位 E | 尾数位 | 含义 |
|---|---|---|
| 全 0 | 全 0 | ±0(有正负零,因为符号位独立) |
| 全 0 | 非全 0 | 次正规数(subnormal/denormal),x=±0.f×2emin+1,用于填补零附近的空隙 |
| 全 1 | 全 0 | ±∞(溢出时产生) |
| 全 1 | 非全 0 | NaN(Not a Number,非法运算结果) |
次正规数值得单独说明:最小规格化 FP32 数是 2−126≈1.18×10−38。如果比它还小的正数全部变成 0,那么在 0 和 2−126 之间就出现”断层”,减法 a - b 在 a≈b≈10−40 时可能直接得 0,破坏”减法结果平滑过渡”的性质。次正规数用”尾数前导位为 0”的方式把最小可表示数扩展到 2−149≈1.4×10−45,代价是这些数的精度随指数下降而下降。GPU 上次正规数运算通常有额外开销(CUDA 里可以用 --ftz=true 把次正规数 flush 成 0 换性能,代价是精度)。
浮点数在数轴上是”疏密不均”的#
一个重要的直觉:浮点数不是均匀分布在数轴上,而是越靠近 0 越密集,越往两边越稀疏。在 [1,2) 区间内,FP32 的间距是 2−23≈1.19×10−7;在 [2,4) 间距翻倍成 2−22;到 [220,221) 间距已经是 2−3=0.125。也就是说,浮点数的相对精度(有效数字位数)是恒定的,绝对精度随数值大小线性增长。这是浮点表示与定点/整数表示的本质区别,也是后文一切讨论的出发点:浮点数的误差是”相对误差”,不是”绝对误差”。
表示误差:0.1 在二进制里是无理数#
0.1 + 0.2 ≠ 0.3 的原因现在就清楚了:0.110 的二进制展开是无限循环小数 0.0001100110011⋯2,任何有限位数的浮点格式都无法精确表示它,只能存最接近的一个值。这个”表示误差”(representation error)约等于该数附近的间距的一半。0.1 与 0.2 各自都有约 10−17 量级的表示误差,相加后误差没被抵消,于是结果和”0.3 的最接近表示”差了一个最小单位(ULP,unit in the last place),打印出来就是 0.30000000000000004。
这种误差本身无害(相对误差只有 10−16 量级),但它揭示了浮点运算的第一定律:每个浮点数都只是真实值的近似,每次运算都可能引入新的近似误差。数值分析(numerical analysis)这门学科研究的正是这些误差如何传播、何时会毁掉结果。
机器精度、舍入与大数加小数#
机器精度 ϵ:1 和下一个数的距离#
最常用的误差尺度叫机器精度(machine epsilon)ϵ:数值 1 与下一个可表示浮点数之间的间距。
ϵFP32=2−23≈1.19×10−7,ϵFP16=2−10≈9.77×10−4,ϵBF16=2−7≈7.81×10−3(严格说这是”spacing at 1”,有的教材把”半间距”也叫 epsilon;本文统一用间距。)ϵ 告诉你”相对精度大概是 10−7“意味着你的数有约 7 位十进制有效数字。FP16 有约 3 位,BF16 约 2 位,FP8 的 E4M3 只有 1 位左右。每次浮点运算的相对误差上界大约是 ϵ/2(半间距,round-to-nearest 下)。
舍入模式:默认是”最近偶数”#
浮点运算结果往往不是可表示的数,必须舍入。IEEE 754 默认模式是 round-to-nearest, ties-to-even:舍入到最近的可表示数,恰好在中点(tie)时选尾数为偶数的那个。选”偶数”而不是”向上”是为了避免统计偏差——如果总是向上舍入,一万次运算的误差会系统性偏正。随机误差可以互相抵消,系统性偏差则会累积成可观的错误。
大数加小数:1 + 2^{-24} = 1 的完整推导#
现在来精确分析那个经典的例子。在 FP32 里:
- 1 的可表示形式是 1.000⋯02×20,下一个数是 1.000⋯12×20(尾数最低位加 1),间距 ϵ=2−23。
- 要加上的 2−24 是多少?它是 2−24,恰好等于半间距 ϵ/2=2−24。
- 结果 1+2−24 正好落在 1 和 1+2−23 的正中间——tie。
- ties-to-even 规则:1.0 的尾数全 0(偶数),1+2−23 的尾数最低位是 1(奇数),所以舍入到 1.0。
结论:FP32 中 1.0f + 1e-8f(约 2−26.6,比半间距还小)必然等于 1.0f。更一般的规律:当两个数 a>b 的指数之差超过有效数字位数时,a+b 直接舍入为 a。FP32 有 24 位有效数字,所以比值超过 224≈1.7×107 的小数会被完全吞掉。FP16 的 11 位有效数字意味着比值超过 211=2048 就吞掉,BF16 是 28=256。
这解释了一个常见的困惑:(1e8 + 1) - 1e8 = 0。在 108≈226.6 附近,FP32 的间距是 226.6−23≈23.6≈12,1 远小于半间距 6,所以 1e8 + 1 先被舍入回 1e8,减法后自然得 0。注意这里每一步都”正确”地舍入了,错误来自信息在那一步已经被丢弃——这就是数值误差的本质:信息一旦被舍入抹掉,后面任何操作都找不回来。
消去误差(catastrophic cancellation):相近数相减#
比”大数加小数”更隐蔽的是相近数相减。假设 a=1.00000019,b=1.00000004,精确差是 1.5×10−7。但 FP32 里 a 和 b 各自的表示误差约 5×10−8(半间距),a−b 的误差上界是两者误差之和 10−7——和结果本身 1.5×10−7 同一个量级,相对误差可能高达 50% 以上。
这不是舍入出了错,而是”减法把两个数共有的高位全部抵消,只剩误差”:a 和 b 都有 7 位有效数字,但它们相同的部分(1.000000)在差里消失了,差的相对精度取决于 a−b 的绝对误差,而绝对误差由 a、b 的舍入误差决定。公式化地说:
分子是”输入误差”,分母是”结果大小”。当 a≈b 时分母趋近 0,相对误差爆炸。数值分析里给这个放大倍数一个名字叫条件数(condition number),后面还会遇到。
在 LLM 里消去误差最常见的藏身处是求和的先后:注意力分数、梯度、统计量全都涉及大量求和,而浮点加法不满足结合律——这是下一节的内容。
浮点加法不结合:(a+b)+c ≠ a+(b+c)#
取 a=108,b=−108,c=1:
(a+b)+c=0+1=1a+(b+c)=108+(−108+1)=108+(−108)=0两条路径结果不同!原因就是 -1e8 + 1 在 FP32 里等于 -1e8(前面分析过,间距 12 吞掉了 1)。浮点加法是可交换的,但不是可结合的——结合律失效直接导致”求和顺序影响结果”,这也是 GPU 上归约(reduction)为什么必须小心设计的原因。
假设要计算 S=∑i=1nxi。用朴素顺序求和,误差上界大约为:
∣S−S^∣≲n⋅2ϵ⋅i=1∑n∣xi∣如果所有 xi 同号,这个上界基本是紧的——误差随 n 线性增长。用分层求和(tree reduction,两两相加再两两相加,GPU 硬件上正是这么做的)可以把上界降到 ϵlog2n⋅∑∣xi∣,从线性变成对数。如果 xi 有正有负,误差呈随机游走,约按 n⋅ϵ 增长,比最坏情况好得多——这也是为什么”不要排序后把所有正数先加完再加负数”。
更进一步的技巧是 Kahan 求和(1965):用一个补偿变量记录每一步丢掉的低阶误差,下次加法时补回去,误差上界降到 ∼2ϵ∑∣xi∣,与 n 无关。CUDA 的 cub::WarpReduce、PyTorch 的 torch.sum 内部都实现了分层求和,大部分情况下你不需要手写 Kahan,但要理解”顺序求和精度差、分层求和精度好”这个事实——比如你手写注意力 kernel 时,把 softmax 分母的求和按 block 累加,就要意识到误差随块数增长,最终项全部同号,上界是线性的,长序列下必须用 FP32 甚至 Kahan 来保精度(FlashAttention 的官方实现里,在线 softmax 的求和恰好就是这种情况)。
条件数:从”误差放大”到线性代数#
把误差放大倍率形式化就得到条件数。对线性方程组 Ax=b,解的相对误差满足:
∥x∥∥Δx∥≤κ(A)(∥b∥∥Δb∥+∥A∥∥ΔA∥),κ(A)=∥A∥⋅∥A−1∥其中条件数 κ(A) 衡量”输入扰动被放大的倍数”。它与 SVD(奇异值分解)直接相关——SVD 的分解式、最优截断定理(Eckart–Young)等完整讲解见《答疑特辑:秩与 SVD、RoPE 旋转矩阵、投机解码原理与 AI 芯片全景》:
κ(A)=σminσmax即最大奇异值与最小奇异值之比——这正好回答”奇异值大小对应矩阵的什么特性”:奇异值刻画矩阵在每个方向上的拉伸倍数,最大与最小的比值就是这个矩阵”有多接近退化”的度量。κ 大意味着矩阵把某个方向拉伸得极长、另一个方向压得极扁(几何上接近”退化”),这样的矩阵求逆时,微小的输入误差会被放大 κ 倍。κ=1014 的矩阵在 FP32(ϵ≈10−7)下求逆,解的相对误差理论上可达 107——结果基本是噪声。实际数值算法里通常用 cond() 预估误差,或者干脆避免显式求逆。这个”输入误差 × 条件数 = 输出误差”的框架,是理解数值稳定性问题的统一语言:一个算法数值稳定,指的就是它的输出对输入的相对误差放大倍数(有效条件数)不失控。
数字格式动物园:FP16 / BF16 / TF32 / FP8#
一张表看全所有格式#
| 格式 | 符号+指数+尾数(位) | 有效数字 | 最大正常值 | 最小正常值 | 机器精度 ϵ | 用途 |
|---|---|---|---|---|---|---|
| FP64 (double) | 1+11+52 | 53 | ≈1.8×10308 | 2−1022 | 2−52≈2.2×10−16 | 科学计算、极少数的 LLM 累积 |
| FP32 (float) | 1+8+23 | 24 | ≈3.4×1038 | 2−126≈1.2×10−38 | 2−23≈1.2×10−7 | 默认精度、softmax/累积 |
| TF32 | 1+8+10 | 11 | 同 FP32 | 同 FP32 | 2−10≈9.8×10−4 | Ampere+ 张量核心矩阵乘 |
| FP16 (half) | 1+5+10 | 11 | 65504 | 2−14≈6.1×10−5 | 2−10≈9.8×10−4 | 旧式训练/推理存储 |
| BF16 | 1+8+7 | 8 | 同 FP32 | 同 FP32 | 2−7≈7.8×10−3 | 现代训练/推理的主流 |
| FP8 E4M3 | 1+4+3 | 4 | 448 | 2−6=0.015625 | 2−3=0.125 | FP8 前向(权重/激活) |
| FP8 E5M2 | 1+5+2 | 3 | 57344 | 2−14≈6.1×10−5 | 2−2=0.25 | FP8 反向(梯度/误差) |
(E4M3 的 448 这个”不整齐”的数字来自它砍掉 Infinity 的做法,后面细讲;表格里”有效数字”包含隐式前导位。)
BF16 为什么只有 8 位有效数字却统治了训练#
BF16 的位布局是 1+8+7:指数位数与 FP32 完全相同(8 位),只把尾数砍到 7 位。它由 Google 在 2019 年的论文 A Study of Bfloat16 for Deep Learning Training 中系统提出(TPU 早在 2016 年就开始用了)。论文的核心实验结论是:在 DeepSpeech2(语音)、GNMT(翻译)、SRGAN(图像)等覆盖 CNN/RNN/Transformer 的工作负载上,BF16 训练与 FP32 训练收敛到几乎相同的精度,而内存占用减半。
为什么砍尾数没事、砍指数会出事?因为深度学习数值的”危险模式”是溢出/下溢,不是精度不足:中间值(尤其是梯度)的动态范围跨越几十个数量级,FP16 的最大值只有 65504,梯度一放大就溢出成 Infinity,训练直接炸掉;而 BF16 的范围和 FP32 一模一样,永远不会因为范围问题翻车。牺牲的只是 2-3 位十进制精度,而深度学习对权重/激活的精度需求恰好没那么苛刻(大多数情况下 10−2 量级的相对误差都扛得住)。这就是”范围 > 精度”的取舍逻辑,它后来成了低精度计算的第一原则。
FP16 的悲剧:更”精密”却更难用#
FP16 有 5 位指数、10 位尾数,有效数字比 BF16 多 3 位,但动态范围只有 6.1×10−5∼65504——上界比 FP32 小 33 个数量级,下界又不够小。训练时 FP16 要么溢出(大梯度)要么下溢(小梯度),所以必须配合 loss scaling 技巧(把 loss 乘一个大常数,把梯度”抬”进可表示范围)。混合精度训练(mixed precision,FP16 计算 + FP32 主权重 + loss scaling)比 BF16 方案复杂得多,这也是后来 NVIDIA 在 Hopper 之后主推 BF16、FP16 逐渐退居存储格式的原因。推理侧 FP16 相对更常见(权重幅度已知,不会突然爆炸),但激活仍然有风险——RMSNorm 之后的值如果掉到 6×10−5 以下就直接变成 0,某些小方差层会”莫名其妙丢精度”。
TF32:A100 张量核心上的”截尾 FP32”#
TF32 是 NVIDIA 随 Ampere A100 引入的格式:指数 8 位(和 FP32/BF16 相同)、尾数只有 10 位(和 FP16 相同),总共 19 位。它本质上是把 FP32 的 23 位尾数截断成 10 位的”精度打折版 FP32”,专门在张量核心上跑矩阵乘。好处是硬件上直接复用 FP32 的指数通路,范围与 FP32 完全一致,输入输出还是 FP32(无需额外转换),坏处是每次乘法的相对误差放大到 2−11 左右,相当于 FP16 的精度。A100 上 TF32 矩阵乘吞吐是 FP32 的 8 倍,NVIDIA 官方博客 TensorFloat-32 in the A100 GPU Accelerates AI Training, HPC up to 20x 给出的定位就是”用可接受的精度损失换 8-20 倍吞吐”。PyTorch 在 Ampere 上默认把矩阵乘切到 TF32(可手动关闭);到了 Hopper 时代,BF16 成为张量核心的主打格式,TF32 的使用场景进一步收缩。
FP8:E4M3 与 E5M2 的明确分工#
FP8 是 2022 年由 NVIDIA、ARM、Intel 联合提出的 8 位浮点格式(论文 FP8 Formats for Deep Learning),包含两种编码:E4M3(4 位指数、3 位尾数)和 E5M2(5 位指数、2 位尾数)。它们的设计哲学是把 FP16 的那 11 位”一半给指数、一半给尾数”的两种折中再压缩一次:
- E4M3:范围 [2−9,448],有效数字 4 位。精度优先,用于前向传播(权重和激活)。为了挤范围,它放弃了 Infinity 表示——指数位全 1 时不再表示无穷,而是让最大正常值扩展到 1.75×28=448(尾数 110),把全 1 尾数 111 留给 NaN。这样动态范围比”按 IEEE 754 规则”多出 1 档。
- E5M2:范围 [2−16,57344],有效数字 3 位。范围优先,完全遵循 IEEE 754 特殊值约定(有 ±∞、NaN),用于反向传播(梯度、误差)——因为梯度动态范围大、对精度相对不敏感。
这种分工和 BF16/FP16 的分工逻辑完全一致:计算路径上谁更怕溢出,谁就多拿指数位。
论文的 GPT-3 实验直接验证了这一点:训练损失曲线(perplexity)在不同规模(350M 到 175B 参数)下,FP8 与 FP16 基线几乎完全重合,且超参数一行未改。

有趣的是”哪一个数用 E4M3、指数偏置取多少”本身是个需要实验的数值问题。论文对 1.3B GPT-3 做了系统的指数偏置扫描——把训练好的 BF16 模型权重和激活 cast 成 E4M3,偏置从 0 到 15 逐个试,困惑度差异肉眼可见:

这张图传达的信息是:8 位格式里每一比特都值钱,格式的细微设计(偏置、特殊值)能带来可测的精度差,所以量化工具链都把它们做成可调参数。
三种格式布局放一起看#
Google 的 BF16 论文里有一张经典的格式对比图,把 FP32、BF16、FP16 的位布局和混合精度数据流画在一起:主权重存 FP32,前向/反向计算用 BF16,中间结果在哪一步转换都标得清清楚楚——这正是推理/训练框架里”精度分层”的标准结构(主副本高精度、计算副本低精度、累积再回到高精度):

注意力里的数值稳定性:在线 softmax 的完整推导#
softmax 的经典溢出问题#
注意力里的 softmax 是 softmax(x)i=exi/∑jexj。问题出在指数上:e88≈1.65×1038 已经逼近 FP32 最大值 3.4×1038,e89 直接溢出成 +Inf。而注意力分数 QK⊤/d 的取值动辄几十上百(点积方差是 d,除以 d 后方差为 1,但长序列、高维下最大 logit 经常到 30-60,极端情况轻松超过 88)。如果某一行里有 logit xi=100,那 e100 溢出,整行 softmax 变成 NaN,训练直接崩。
标准解法:减去最大值#
教科书解法是减最大值:令 m=maxjxj,则
softmax(x)i=∑jexj−mexi−m因为分子分母同乘 e−m,数学上完全等价,数值上每个指数项 exj−m∈(0,1],绝不会溢出。这是你之前就理解的部分,这里把它作为”数值稳定性重构”的第一个例子:同样的数学对象,不同的计算顺序,数值行为天差地别。
但这个标准做法要求先看到整行数据才能算出 m,也就要求先完整算出 S=QK⊤ 的一整行、存下来,再扫第二遍。对注意力来说这意味着 N×N 的中间矩阵要落进 HBM——这正是 FlashAttention 想要消灭的 IO。
分块场景:为什么不能”等最大值”#
FlashAttention 的思路是分块流式计算:一次只把 Q 的一个 block 和 K、V 的各个 block 拿进 SRAM,算完 P×V 的部分和就扔,全程不落盘。问题是:处理第 t 个 block 时,后面 block 里的更大 logit 还没出现——你拿不到全局最大值 m,而如果每个 block 各自用”自己的局部最大值”归一化,各部分和的尺度就不同,直接相加是错的。
举个具体例子:x=[1,2,100,3] 分成两块 [1,2] 和 [100,3]。第一块归一化后 [e−1,e0]/Z1,第二块是 [e0,e−97]/Z2,两者的 Z 一个是 1.37、一个是 1.0000⋯——不能相加。必须有一套机制在流式处理过程中持续维护归一化状态,这就是在线 softmax(online softmax)。
在线 softmax:running max + 重缩放#
在线 softmax 算法最早由 NVIDIA 的 Milakov 和 Gimelshein 在 2018 年给出(论文 Online normalizer calculation for softmax,后来成为 FlashAttention 系列的标准组件)。它维护两个累计量:运行最大值 mt 和运行归一化常数 lt=∑j≤texj−mt(注意 lt 是以”当前最大值 mt“为基准的,这个细节是算法核心)。
处理第 t 个 block Bt 时:
- 更新最大值:mt=max(mt−1, mBt),其中 mBt 是本 block 内的最大值;
- 重缩放旧的归一化常数:因为基准变了,旧项 exj−mt−1 要乘上 emt−1−mt 才能变成以 mt 为基准:
- 重缩放旧输出:输出累加器 O(未归一化的加权和)同样整体乘 emt−1−mt,再加上新 block 的部分和:
全部 block 处理完后,最终答案 OT/lT。用归纳法可以证明每一步都维持不变式”Ot/lt = 前 t 个 block 的精确 softmax 加权和”:当 m 不变时只是普通累加,当 m 增大时全体旧项被同一个标量重缩放,比例关系不变。这个算法在数学上是精确的(不引入任何近似),它做的只是把”除法”推迟到最后一步——中间所有项都控制在 [0,1] 量级,这正是它数值稳定的原因。
一个完整的数值例子#
还是 x=[1,2,100,3],v=[v1,v2,v3,v4],块 1 是 [1,2],块 2 是 [100,3]。
第 1 块:m1=2,l1=e−1+e0≈1.3679,O1=e−1v1+e0v2。
第 2 块:mB2=100>m1=2,于是 m2=100。重缩放:
l2=1.3679⋅e2−100+(e0+e−97)≈0+1.0000=1.0000O2=O1⋅e−98+(e0v3+e−97v4)≈O1⋅3.7×10−43+v3最终结果 O2/l2=e−99v1+e−98v2+v3+e−97v4(约等于),与一次性算全局最大值的结果逐位一致——e−98 之后那个 3.7×10−43 的旧项在 FP32 里不是零,只是小得看不见,而它在真实 softmax 里本来就只有 10−43 量级,丢弃它对最终结果的影响小于一个 ULP。
注意第 2 步里 l1⋅e2−100 在 FP32 中下溢风险:e−98≈3.7×10−43≪1.4×10−45?不,3.7×10−43 大于 FP32 次正规数下限 1.4×10−45,乘上 1.3679 后约 5×10−43,仍然可表示(次正规数)。但如果序列再长、旧项再多,这个量级会继续往下掉,最终在 FP32 里下溢为 0——而它在数学上本来就可忽略,所以无害。这里值得强调:在线 softmax 的下溢只会发生在”本来就该被忽略”的项上,不会发生在”正在累积的重要项”上,这正是”把中间值控制在安全区间”策略的威力。
为什么归一化常数 l 必须用 FP32 累积#
一个常被忽视的细节:lt=∑exj−mt 的每一项都在 (0,1],所以 1≤lt≤N(序列长度)。当 N>65504 时,l 在 FP16 里必然溢出(65504 是 FP16 最大值)——而 65504 token 的上下文在今天已经是家常便饭。即使没溢出,l 的误差也会随项数线性累积(所有项同号,前面分析过这是最坏情形)。所以 FlashAttention 的 softmax 统计量(m、l)一律用 FP32 维护,只有 QK⊤ 的矩阵乘本身可以用低精度。这是”哪里用低精度、哪里必须保精度”的一个教科书式案例。
与 FlashAttention 的衔接#
你已经在 FlashAttention-4 那篇文章里见过”条件 softmax 重缩放”(conditional rescaling)——那就是这套在线 softmax 机制在 Blackwell 上的工程化:多数 block 处理时 m 根本不更新(重缩放因子 emt−1−mt=1),于是 FA4 把”是否真的需要重缩放”做成运行时条件,大多数情况下跳过这步标量乘,把省下的指令周期让给矩阵乘。而 FlashAttention-1(2022)正是靠这套在线机制把 N×N 的注意力矩阵从 HBM 里解放出来,在 A100 上拿到 2-4 倍加速;FlashAttention-2 进一步优化并行策略后,相对标准 PyTorch 注意力在长序列上的加速比达到接近 10 倍:

数值上”减最大值 + 重缩放”看起来只是一个小技巧,但它直接决定了注意力能不能分块、能不能上 GPU 的 SRAM、能不能用低精度——数值稳定性不是锦上添花,而是整个推理优化栈的地基。
注意力的精度敏感点:指数放大与长尾#
再补一个为什么注意力对数值特别敏感的原因。softmax 的指数函数把输入差异放大:输入相差 Δ,输出概率比相差 eΔ。logit 差 10,概率就差 22026 倍。这意味着注意力分布是高度长尾的:绝大多数概率集中在少数 token 上,其余 token 的概率小到 10−4 甚至更低。低精度格式对”小概率”的相对误差很差:一个 10−4 的概率在 E4M3(ϵ=0.125)里只能表示成 0 或 1.25×10−4 之类的粗糙值,相对误差几十个百分点。幸好输出 O=∑pivi 对这些小项不敏感(它们贡献本来就小),这也是为什么注意力可以用低精度矩阵乘、但 softmax 的统计量必须保精度的深层原因——误差必须落在”不影响结果”的地方。
推理系统中的数值实践#
GEMM 的累积器:为什么”算完再存”比”存完再算”准#
矩阵乘 C=A×B 中,每个输出元素是 K 项乘积的和(K 是收缩维度)。如果每乘一次就把部分和写成 FP16,误差按 K(随机游走)到 K(最坏同号)增长;如果全程在 FP32 累积器里累加,最后才写回低精度,误差主要来自最后一次舍入。这就是”累积器精度 > 输入精度”原则:NVIDIA 张量核心的矩阵乘指令(如 HMMA)内置 FP32 累加器,输入可以是 FP16/BF16/FP8/TF32,累积一律 FP32。DeepSeek-V3 更进一步,在 FP8 GEMM 中每累积 128 个元素就把部分和提升到 CUDA Core(FP32 单元)做一次高精度累加,论文 Figure 7(b) 展示的就是这个”每 128 元素提升”的机制:

注意这张图的左半部分(a)画的是另一个问题——离群值。同一张量里如果有少数元素比其他元素大 100 倍(比如注意力投影的某些通道),按全局最大值选缩放因子会让其余元素的有效精度再掉 7 位。DeepSeek-V3 的解法是把缩放因子从 per-tensor 细化到 per-tile/per-block(小到 128 个元素一组),让每一组内幅度相近,这就是”细粒度量化”(fine-grained quantization)。
FP8 为什么必须配缩放因子#
FP8 的 E4M3 范围只有 [2−9≈0.002,448]。LLM 的权重通常在 ±0.1 量级、激活在 ±10 量级,直接 cast 会同时发生灾难性的下溢(权重小值变 0)和上溢(激活大值变 Inf)。所以 FP8 使用前必须做缩放(scaling):先求缩放因子 s,把数据整体放大到格式的满量程,再量化:
x^=clamp(round(x/s),−448,448),x≈x^⋅ss 的取法从粗到细有几种粒度:per-tensor(整个张量一个 s,最简单,但被离群值拖累)、per-channel/per-token(权重按输出通道、激活按 token 各给一个 s,缓解离群值,代价是反量化多一次乘法)、per-block(把通道进一步切块,每块一个 s,精度最好但元数据更多)。缩放因子本身也要存成高精度(FP32),并在计算时融合进 GEMM(A⋅B 的缩放因子可以合并成一个标量乘)。DeepSeek-V3 的 FP8 框架图把这条链画得很清楚:所有 Linear 算子输入输出都走 FP8,缩放因子 FP32 并行流动,累积在 FP32:

671B 参数用 FP8 训练:损失曲线几乎重合#
DeepSeek-V3(2024 年 12 月,论文)是 FP8 大规模应用的标志性案例:671B 参数的 MoE 模型全程 FP8 训练。它的数值方案值得逐条拆解,因为每一条都是本节讲过的原则的实例化:
- 前向全用 E4M3(不采用”前向 E4M3、反向 E5M2”的常规分工),理由是前向的数值范围可控,E4M3 的精度收益更值——这依赖下面两条兜底;
- 细粒度 tile/block 缩放(Figure 7a):把缩放粒度做细,把离群值的影响关进笼子;
- 每 128 个元素提升到 CUDA Core 做 FP32 累积(Figure 7b):累积器保持 FP32 精度;
- 结果:训练损失曲线与 BF16 基线在 EMA 平滑后几乎重合,直到 2.8 万亿 token:

“损失曲线重合”对工程意味着什么?FP8 相比 BF16 把权重/激活内存砍半(对 671B 的模型这是几百 GB 的差别),同时张量核心吞吐翻倍(H100 上 FP8 是 BF16 的 2 倍)。数值格式的每一次降位,都是在”精度 - 内存 - 速度”三角上做一次显式的权衡——这也是为什么你读到的每一篇量化/加速论文,本质上都是一篇数值稳定性论文。
推理侧的其它数值细节#
- RMSNorm 的 eps:
x / sqrt(mean(x²) + eps)里的 eps(通常 1e-5 或 1e-6)不止是防除零,还避免方差极小层的输出被放大到溢出——RMSNorm 的平方和均值用 FP32 累积也是同一套”统计量保精度”逻辑。 - RoPE 的大角度问题:位置 t 很大时旋转角 tθ 也很大,FP32 里
sin/cos的精度随角度增大而下降,部分实现用 FP64 计算角度再降回 FP32,或直接查表。这属于”表示精度不足导致结果漂移”的典型场景。 - 整数 vs 浮点量化:INT8 是均匀量化(固定步长),对幅度小的值相对误差极差;浮点格式是相对误差恒定。所以 FP8 天然比 INT8 更适合”数值动态范围大”的 LLM 激活——这也是 2024 年后 FP8 推理(W8A8)逐渐取代 INT8 的数值原因。你之前在 XFP 那篇里看到的码本量化(codebook)走的是另一条路:用多个缩放码本拟合不同幅度的子分布,本质也是”把不均匀的数据用多段均匀/浮点尺度去拟合”。
小结:数值思维的三个原则#
把全文压缩成三条原则,以后遇到任何”该用多少位精度”的决策都可以套:
- 范围优先,精度其次。先保证不溢出不下溢(BF16 胜 FP16、E5M2 分工),再谈有效数字。
- 中间统计量保精度,批量计算可降位。softmax 的 m、l,RMSNorm 的方差,GEMM 的累积器——这些”一棵树上挂全部”的量必须 FP32;真正的大矩阵乘可以 FP16/FP8。
- 误差要落在不影响结果的地方。在线 softmax 的重缩放让旧项误差下溢在”本来就可忽略”处;缩放因子把离群值的伤害限定在小块内;误差放大倍数(条件数)分析决定算法要不要换写法。
最后留一张速查表,核心数字都在这里:
| 问题 | 数字 | 结论 |
|---|---|---|
| FP32 相对精度 | 2−23≈1.2×10−7 | 约 7 位十进制有效数字 |
| FP16 最大数 | 65504 | 长序列 l 求和、大梯度会溢出 |
| BF16 范围 | 同 FP32(±3.4×1038) | 训练首选,永不范围翻车 |
| E4M3 / E5M2 最大数 | 448 / 57344 | 前向精度优先 / 反向范围优先 |
| 在线 softmax 中间值 | 全部落在 (0,1] | 不溢出、误差被钉在可忽略项上 |
| lt 上界 | 序列长度 N | N>65504 时必须 FP32 累积 |
| 条件数 κ=σmax/σmin | 输入误差放大倍数 | κ⋅ϵ≳1 时结果基本是噪声 |
数值稳定性是那种”看起来只有几行公式、实际决定一切”的主题:没有在线 softmax,FlashAttention 的整个分块体系就不存在;没有缩放因子,FP8 就是个只能看不能用的格式。这份地基的用处在于:之后读任何一篇”精度-速度权衡”的论文,第一反应应该是去查它的数值方案——格式选了什么、统计量在哪一档精度累积、缩放因子粒度多细——而不是被性能数字带着走。
参考资料#
- FP8 Formats for Deep Learning(NVIDIA/ARM/Intel,2022:FP8 E4M3/E5M2 定义与 GPT-3 实验)
- A Study of Bfloat16 for Deep Learning Training(Google,2019:BF16 格式与训练实验)
- FlashAttention: Fast and Memory-Efficient Exact Attention with IO-Awareness(FA-1/FA-2 论文,含在线 softmax 与加速数据)
- DeepSeek-V3 Technical Report(671B FP8 训练:细粒度缩放与 FP32 累积)
- Online normalizer calculation for softmax(Milakov & Gimelshein,2018:在线 softmax 原始论文)
- TensorFloat-32 in the A100 GPU Accelerates AI Training, HPC up to 20x(NVIDIA 官方博客:TF32 定位)
- IEEE 754 浮点数标准(Wikipedia)(位布局、特殊值与舍入模式)
- Kahan summation algorithm(Wikipedia)(补偿求和)
- FlashAttention-4:面向 Blackwell 的算法-流水线协同设计(本站文章:条件 softmax 重缩放的工程实现)
- XFP:质量地板驱动的自适应码本量化(本站文章:量化误差与码本缩放的进一步讨论)
文章分享
如果这篇文章对你有帮助,欢迎分享给更多人!
部分内容可能已过时
评论区
分享你的想法,与大家交流讨论
音乐
暂未播放



