精度问题分类:数值误差、类型截断、数学等价性破坏
做算子融合的时候,我最怕什么?不是性能不达标,而是精度对不上。
明明融合前跑得好好的,融合后结果就飘了。这种问题排查起来特别头疼。因为算子融合本身不改变计算逻辑,但实际执行时,各种细节差异会导致精度偏差。
我个人习惯把精度问题分成三类:数值误差、类型截断、数学等价性破坏。这三类问题的根源不同,排查手段也不一样。
核心观点:算子融合的精度问题,本质上都是「计算路径改变」导致的。融合前和融合后,虽然数学公式等价,但计算机执行时,中间结果的存储、类型、顺序都变了。
第一类:数值误差
数值误差是最常见的一类。说白了,就是浮点数计算本身就不精确。
举个例子,a + b + c 和 (a + b) + c 在数学上完全等价。但在浮点数世界里,结果可能不一样。因为浮点数的加法不满足结合律。
为什么会这样?
因为浮点数的精度有限。两个数相加时,如果数量级差太多,小数的尾数会被截掉。融合前,中间结果可能被写回显存,精度是 FP32。融合后,中间结果可能留在寄存器里,精度变成了 FP16 甚至 BF16。
我在项目中遇到过这样一个案例:
// 融合前:两个独立的算子
// 中间结果写回显存,精度 FP32
float* temp = gemm(A, B); // FP32
float* result = relu(temp); // FP32
// 融合后:合并成一个算子
// 中间结果留在寄存器,精度可能降级
half* result = fused_gemm_relu(A, B); // 内部用 FP16 累加
结果就是,融合后的输出和融合前对不上。误差大概在 1e-3 到 1e-2 的量级。对于某些对精度敏感的模型(比如分割、检测),这个误差就不可接受了。
我的排查经验:遇到数值误差,先看中间结果的精度。用 torch.allclose 逐层对比,找到第一个出现偏差的层。然后检查该层的累加精度、中间变量类型。
第二类:类型截断
类型截断,说白了就是「装不下了」。
算子融合时,为了性能,我们经常把多个算子的中间结果合并到一个缓冲区里。这个缓冲区的数据类型是固定的。如果某个中间结果的值范围超出了这个类型的表示范围,就会被截断。
举个例子:
// 融合前:两个独立的算子
// 中间结果用 FP32 存储,范围 [-3.4e38, 3.4e38]
float* temp = softmax(logits); // 输出范围 [0, 1]
float* result = log(temp); // 输入范围 (0, +∞)
// 融合后:合并成一个缓冲区
// 缓冲区用 FP16 存储,范围 [-65504, 65504]
half* buffer = softmax(logits); // 输出范围 [0, 1],FP16 够用
half* result = log(buffer); // 输入范围 (0, 1],FP16 也够用
这个例子看起来没问题。但换个场景:
// 融合前:中间结果范围 [-1000, 1000]
float* temp = layer_norm(x); // FP32,范围正常
float* result = gelu(temp); // FP32
// 融合后:缓冲区用 INT8
int8_t* buffer = layer_norm(x); // 范围 [-128, 127],直接截断!
int8_t* result = gelu(buffer); // 输入已经被截断,结果全错
嗯,这里要注意。INT8 量化后的范围只有 256 个值。如果 layer_norm 的输出范围是 [-1000, 1000],量化到 INT8 后,大部分信息都丢了。再去做 gelu,结果肯定不对。
我曾经踩过的坑:有一次融合 LayerNorm + GELU,为了省显存,把中间结果用 INT8 存。结果模型精度直接崩了。后来排查发现,LayerNorm 的输出范围是 [-5, 5],量化到 INT8 后,分辨率只有 0.04。GELU 在这个分辨率下,梯度完全不对。
类型截断的排查方法:
- 检查融合后每个中间变量的数据类型
- 对比融合前后,每个中间变量的值范围
- 如果范围超出目标类型的表示范围,就是截断问题
- 特别注意:INT8 的表示范围是 [-128, 127],FP16 是 [-65504, 65504]
第三类:数学等价性破坏
这类问题最隐蔽。数学上完全等价的变换,在计算机里可能不等价。
举个例子:
// 数学等价:a * (b + c) = a * b + a * c
// 融合前:
float* temp = add(b, c); // b + c
float* result = mul(a, temp); // a * (b + c)
// 融合后:
float* temp1 = mul(a, b); // a * b
float* temp2 = mul(a, c); // a * c
float* result = add(temp1, temp2); // a * b + a * c
数学上完全等价。但浮点数计算时,结果可能不一样。因为 a * (b + c) 只做了一次乘法一次加法,而 a * b + a * c 做了两次乘法一次加法。浮点数的舍入误差不同。
我在项目中遇到过更极端的例子:
// 融合前:softmax + log
// 数学公式:log(softmax(x_i)) = x_i - log(sum(exp(x_j)))
// 实现方式:
float* softmax_out = softmax(x); // 先算 softmax
float* log_out = log(softmax_out); // 再取 log
// 融合后:直接实现 log_softmax
// 实现方式:
float max_val = max(x);
float* log_softmax_out = x - max_val - log(sum(exp(x - max_val)));
这两个实现,数学上完全等价。但数值稳定性差很多。
融合前的实现:先算 softmax,再取 log。如果 softmax 的输出非常接近 0(比如 1e-30),取 log 后就是 -inf。直接炸了。
融合后的实现:用 x - max_val 做数值稳定化,避免了 exp 溢出。结果更稳定,精度反而更好。
关键点:数学等价性破坏不一定是坏事。有时候融合后的实现反而更稳定。但你需要知道,这两种实现的结果不会完全一样。如果模型对精度要求极高,这种差异可能成为问题。
三类问题的对比
| 问题类型 | 根本原因 | 典型表现 | 排查手段 |
|---|---|---|---|
| 数值误差 | 浮点数精度有限,计算顺序改变 | 误差在 1e-3 ~ 1e-2 量级 | 逐层对比中间结果,检查累加精度 |
| 类型截断 | 中间结果超出目标类型表示范围 | 误差很大,甚至全错 | 检查每个中间变量的值范围和数据类型 |
| 数学等价性破坏 | 浮点数运算不满足结合律/分配律 | 误差较小,但分布有规律 | 对比数学公式和实际实现,检查数值稳定性 |
知识体系图
下面这张图展示了三类精度问题的关系,以及排查时的决策路径:
这张图把三类问题的关系理清楚了。排查时,先看误差量级:1e-3 左右可能是数值误差,误差很大可能是类型截断,误差有规律分布可能是数学等价性破坏。
我的建议:遇到精度问题,不要一上来就怀疑算子融合框架。先自己手动算一遍,看看融合前后的中间结果。很多时候,问题出在数据类型选择上,而不是融合逻辑本身。
好了,三类精度问题就讲到这里。记住:数值误差看精度,类型截断看范围,数学等价性破坏看实现。排查时按这个思路走,能省不少时间。