精度问题分类:数值误差、类型截断、数学等价性破坏

做算子融合的时候,我最怕什么?不是性能不达标,而是精度对不上。

明明融合前跑得好好的,融合后结果就飘了。这种问题排查起来特别头疼。因为算子融合本身不改变计算逻辑,但实际执行时,各种细节差异会导致精度偏差。

我个人习惯把精度问题分成三类:数值误差、类型截断、数学等价性破坏。这三类问题的根源不同,排查手段也不一样。

核心观点:算子融合的精度问题,本质上都是「计算路径改变」导致的。融合前和融合后,虽然数学公式等价,但计算机执行时,中间结果的存储、类型、顺序都变了。

第一类:数值误差

数值误差是最常见的一类。说白了,就是浮点数计算本身就不精确。

举个例子,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 量级 逐层对比中间结果,检查累加精度
类型截断 中间结果超出目标类型表示范围 误差很大,甚至全错 检查每个中间变量的值范围和数据类型
数学等价性破坏 浮点数运算不满足结合律/分配律 误差较小,但分布有规律 对比数学公式和实际实现,检查数值稳定性

知识体系图

下面这张图展示了三类精度问题的关系,以及排查时的决策路径:

算子融合精度问题分类与排查路径 精度问题 数值误差 类型截断 数学等价性破坏 浮点精度有限 计算顺序改变 值范围溢出 量化分辨率不足 结合律失效 分配律失效 排查路径 1. 逐层对比中间结果 → 2. 检查数据类型和值范围 3. 分析数学公式的数值稳定性 → 4. 定位到具体问题类型

这张图把三类问题的关系理清楚了。排查时,先看误差量级:1e-3 左右可能是数值误差,误差很大可能是类型截断,误差有规律分布可能是数学等价性破坏。

我的建议:遇到精度问题,不要一上来就怀疑算子融合框架。先自己手动算一遍,看看融合前后的中间结果。很多时候,问题出在数据类型选择上,而不是融合逻辑本身。

好了,三类精度问题就讲到这里。记住:数值误差看精度,类型截断看范围,数学等价性破坏看实现。排查时按这个思路走,能省不少时间。

可可精品资料库