FP32位级误差复盘:从softmax的expf差异到bit-exact修复
2026/9/5 6:52:40 网站建设 项目流程

从“整网输出和参考实现对不上”到锁定位数误差,我用了整整两天。这中间最折磨人的不是数值本身,而是它看起来一片正常:Top-1 结果一致、特征分布一致、所有差值都在 1e-6 以下,但逐 bit 比对就是差了那么一口气。Atome-LM 在 QEMU 模拟的 Cortex-M3 上跑出来的结果,和 x86_64 参考实现之间出现了约3.4e-7的偏差。我一开始以为是软浮点精度不够,后来发现事情没那么简单——软件浮点和 IEEE 单精度“规定动作”之间,隔着一整条由编译器、数学库、表达式求值顺序共同决定的暗河。

如果你也想做类似的事:把一个小模型烧进 MCU 环境做验证,或者在 QEMU 里对比不同架构下的输出是否 bit-exact,这篇文章应该能帮你少走很多弯路。我会把这套排查链路完完整整写出来,包括怎么快速定位到具体算子、为什么 FMA 会制造位级差异、expf 这种数学函数怎么从参考实现演变成 MCU 上的另一个结果,以及最后一锤定音的修复方案。

1. 一场浮点“对账”:为 Atome-LM 制定 bit-exact 基线

先说清楚我到底在做什么。Atome-LM 的体积小到可以把权重全部放进片上内存,非常适合在没有硬件浮点单元的 MCU 上做前向推理实验。但在真正把固件烧到 ARM 芯片之前,我习惯先用 QEMU 模拟的 Cortex-M3 做一轮“架构级验证”。这一步很关键:QEMU 能跑 ARM 机器码,但调试起来比真机方便太多,可以在任意指令地址停下来,直接读寄存器、读内存、看整个模型每一层的中间张量。

验证目标听起来很单纯:给定同一份权重、同一条输入样本,让同一个 C 程序分别在 x86_64 主机和 QEMU 的 Cortex-M3 环境里各跑一遍前向。如果移植没有引入逻辑错误,两侧的输出应该在 FP32 精度下逐位一致。我管这个叫“bit-exact 对账”。

但对账结果给了我一记闷棍:

  • 两侧输出张量的形状完全一致;
  • 每一个元素的绝对误差都很小;
  • 最大绝对误差约为3.4147e-7,大概是在某个数值接近 1.0 的特征上出现的,换算成 IEEE 单精度的一小部分;
  • 如果把张量按二进制 dump 出来比较,错误的 bit 集中在尾数低位,并不是 0x80 覆盖这种整段错位。

如果只看精度,这个误差在工程上完全可以接受,1e-7级别对 FP32 推理来说已经是正常范围内的浮动。但只要目标是 bit-exact,这3.4e-7就是一道没跨过去的坎。我不能假装没看见,因为一旦允许这种“差不多”存在,下一次模型移植里真正的维度颠倒、权重错位、算子覆盖错误,也会被混进同一个误差池里,导致我无法判断是哪一步出了问题。

所以,我给自己立了个标准:先搞清楚这个3.4e-7是从哪个算子、哪条指令、哪一个数学函数调用里冒出来的,而不是直接拍脑袋说“软浮点精度就这样,忍了吧”。

这不是小题大做。FP32 计算本来就不是“在实数域上做数学运算然后四舍五入到单精度”这么简单。IEEE 754 标准只规定了基本运算的结果语义,却没有规定编译器必须按哪种顺序组合这些基本运算,更没有规定expflogfpowf必须返回哪个位级一致的近似值。只要任一层变换走了一条不同的数值路径,尾数就会分岔。

在开始排查之前,我先给手头两个环境做了一个快照:

项目参考环境目标模拟环境
CPU 架构x86_64ARM Cortex-M3(QEMU)
硬件浮点有 SSE/AVX无 FPU
编译器host gcc / clangarm-none-eabi-gcc
典型 FP32 运算movss + mulss/addss软浮点函数调用,如__aeabi_fmul__aeabi_fadd
数学库来源glibc/libmnewlib libm
编译选项默认 + 未开 fast-math默认 + 未开 fast-math

把这张表摊开之后,我心里基本有底了:两侧的浮点执行路径完全不一样,出现 bit 级差异几乎是必然事件,问题是它到底差在哪个环节。

2. 5 轮二分定位:把误差从“整网输出”缩到“一个 expf”

定位误差不能靠盯输出,要靠中间快照。

我给 Atome-LM 的前向流程加了快照函数:在 embedding、每一层 Transformer 之前的输入、attention 的 Q/K/V、softmax 之后、FFN 输出、LayerNorm 之后,都往固定缓冲区里写一份 FP32 原始字节。这个函数不能在最终固件里保留,但它帮我打开了模型的“黑盒”:

void dump_fp32_hex(const char *tag, const float *data, size_t len) { const uint32_t *bits = (const uint32_t *)data; for (size_t i = 0; i < len; i++) { printf("[%s][%zu] 0x%08" PRIX32 " %.9g\n", tag, i, bits[i], data[i]); } }

在 x86_64 主机和 QEMU 两侧分别运行同一份输入,然后把 dump 结果对齐比较。注意,这里我用的不是float打印后的人眼对比,而是直接比较十六进制 bit pattern。两边的 print 格式必须完全一致,否则小数点后几位差异会干扰判断。

第一轮我把模型分成 4 段:输入 Embedding、Attention Block、MLP Block、输出 Head。对比结果发现前两段完全一致,差异第一次出现在 MLP Block 之后。这直接排除了一大半问题,说明 QEMU 上执行的指令本身没有大毛病,前向计算图里的矩阵乘法、向量加法基本都是符合预期的。

第二轮把范围缩小到 MLP 内部的 GELU 激活函数。GELU 在数学上可以写成x * 0.5 * (1.0 + erf(x / sqrt(2.0))),也可以用sigmoid(1.702 * x)做近似。如果两侧代码恰好走的是前一种写法,那么误差很可能来自erff函数的库实现差异。我把 GELU 单独拎出来,用相同输入在两侧跑了一遍,结果确认了这一层有差异,但最大相对误差只有2e-7左右,不是我要找的那个最大的3.4e-7

第三轮把注意力拉回到 Attention 内部。我怀疑是 softmax 出的问题,因为 softmax 涉及expf和除法,数学函数是位级差异的重灾区。我把模型的注意力分数矩阵固定成随机种子生成的常量张量,这时候不再依赖前向流程里的原输入,而是伪造一个固定张量喂给 softmax,看两侧计算是否一致。结果是:只要进入 softmax 的输入张量完全一致,两侧输出的 bit pattern 就开始出现差异,而且这正是整网误差的源头。

第四轮,我从那一组注意力分数里挑出几个元素,构造了一个最小复现程序:

float test_input[4] = { -3.25f, -1.75f, 0.05f, 2.30f }; float max_v = test_input[3]; for (int i = 0; i < 4; i++) { float e = expf(test_input[i] - max_v); sum += e; }

然后在两侧都跑这个最小程序。结果非常干净:x86_64 上得到的四个指数函数值,与 QEMU 上对应值存在几个 ULP 的差别,最终 softmax 输出归一化后产生了约3.4e-7的偏差。

第五轮,我把偏差向上游继续逼:expf本身。如果你只是调用同一个expf,两边都会返回一个“正确”的单精度近似,但这两个近似不需要在二进制层面上一致。为了确认差异来源,我在两个环境里分别打印expf(1.05f)的十六进制结果,果然 bit 不一样。

到了这一步,结论已经很清晰了:这个3.4e-7不是某一个巨大 bug 造成的,而是数学函数实现差异被 softmax 归一化放大后的自然结果。真正需要解决的是“为什么两侧的 expf 结果不同”以及“如何让它们相同”。

3. 为什么软件浮点不等于 IEEE 单精度“规定动作”

很多做嵌入式的人会觉得:既然 IEEE 754 定义了一个float乘法和加法应该怎么舍入,那么不管在哪台机器上跑,只要输入一样,结果就应该一样。这个想法对最简单的基本运算基本成立,但一旦进入真实的编译器优化世界,就不成立。

Cortex-M3 内核没有 FPU,所以 arm-none-eabi-gcc 默认使用软浮点。这意味着任何一个float乘法都会被翻译成对__aeabi_fmul这类函数的调用,由整数指令模拟符号、指数、尾数的运算和舍入。QEMU 在模拟 Cortex-M3 时执行的就是这一串整数指令,理论上它和真实 Cortex-M3 芯片执行同一个二进制文件,结果应该一致。

但问题在于“参考环境”的二进制完全不同。x86_64 上,编译器可以不需要调用软浮点库,直接生成 SSE/AVX 指令,一条mulss就能完成 FP32 乘法。到这里,两侧虽然执行了不同的底层次数,但只要每一步都是“乘完马上舍入到 FP32”,最终结果仍然应该一致。真正的分岔点在于编译器有权改变表达式的结合方式和运算聚合方式,尤其是有权引入 FMA。

FMA,也就是 fused multiply-add,一次完成a * b + c的运算,并在中间过程中不进行任何舍入,只在最后舍入一次。如果没有 FMA,编译器会先把a * b的结果舍入成 FP32,再和c相加,加完再舍入一次。这两个过程在数学上等价,在浮点结果上却不保证相等。

举个例子:

float y = x * scale + bias;

在不启用 FMA 的时候,如果x * scale恰好产生一个不能精确表示的中间值,它会被舍入一次;随后加上bias,结果再被舍入一次。这是“两次舍入”。

但如果在 Cortex-M3 的工具链里启用了快速浮点聚合,比如-ffp-contract=fast,编译器可以把同一个式子直接生成一条VMLA或调用软浮点 FMA 实现。这时x * scale + bias的中间结果以无限精度保留,只进行一次舍入。这个“一次舍入”的结果和“两次舍入”的结果,尾数可能就差 1 个 ulp。

别小看 1 个 ulp。Atome-LM 的推理过程里有大量 LayerNorm、矩阵乘、残差相加,只要某个位置的 FMA 被触发,那 1 个 ulp 就会沿着后续非线性变换被放大。更麻烦的是,x86_64 默认的编译器行为可能并不在同样的位置生成 FMA,于是输出就“合法地”不一致。

类似的事情也会发生在编译器决定将部分变量从 FP32 升级到更高精度时。C 语言标准允许浮点表达式在计算过程中使用比最终类型更高的精度,具体行为由FLT_EVAL_METHOD定义。x86 老式 x87 浮点栈会用 80 位扩展精度做中间计算,而 SSE 指令则严格使用 FP32。新式编译器一般使用 SSE,但如果参考端代码里有float acc = 0; acc += x;,编译器在某些优化选项下可能并行累加、分组求和,最终舍入顺序又变了。

在排查这种位级精度问题时,必须接受一个现实:在所有允许的 FP32 语义里,并不只有一个“正确结果”。IEEE 754 定义了每个单独运算的舍入行为,但编译器如何组合这些运算、用不用 FMA、按什么顺序求和、调用哪个版本的expf,这些都是未定义的。所谓 bit-exact,不能依靠“标准很精确”的假设,只能依靠“让两边走同一条代码路径”的工程约束。

4. expf 的“厂家差异”和 softmax 的误差放大器效应

找到 FMA 这个变量之后,我原本以为只要把 FMA 关掉就能解决,但实验告诉我:还差一步,罪魁祸首是更隐蔽的数学函数实现差异。

x86_64 参考端调用的是 glibc 的expf。这个函数经过了高度优化,内部可能会使用查表、多项式逼近和范围约减的混合策略。而 cortex-m3 裸机环境里,arm-none-eabi-gcc 默认链的是 newlib 的libm.a,它的expf实现和 glibc 非常不一样。两种实现各自的误差可能都控制在几 ulp 以内,你没法说谁错了,但它们的位级输出确实可能有差异。

具体来说,expf(y)这样简单的一个函数,内部计算的复杂度远超普通人的想象。IEEE float 指数函数没有任何标准说“你必须返回 round-to-nearest 的精确 exp(x)”。大部分 libm 选择返回一个“误差在一两个 ulp 内”的结果,而不是精确舍入后的结果,因为后者代价实在太高。

测一下我遇到的 case。同样是expf(-1.255f)这类输入,两个 libm 版本的结果可能只差最后 1 到 2 个 ulp。这个差异在指数函数这一层非常微小,但softmax是一个天然的误差放大器。

看 softmax 的标准公式:

softmax_i = exp(z_i) / sum_j exp(z_j)

实际实现里通常会先减掉最大值,防止数值溢出:

m = max(z) p_i = exp(z_i - m) sum = p_1 + p_2 + ... + p_n softmax_i = p_i / sum

这里的每一步都会引入新的舍入:

  • 每个exp结果自带 1~2 ulp 的库误差;
  • 多个p_i相加的顺序影响累计舍入,因为加法不满足结合律;
  • 最后的除法结果还得再次舍入。

如果其中一个p_i相对误差为 1e-7,那么它在总和里的贡献不会消失,最终 softmax 输出也会带出差不多比例的误差。当网络层的特征值范围在几到几十之间时,softmax 输出中某个元素出现3.4e-7的绝对偏差,完全在合理范围内。

所以,在这类模型验证项目里,“调用数学函数后结果和参考端不一致”不是 bug,而是默认状态。要是你打开编译器优化,比如-ffast-math,编译器甚至可能把expf(x)内部化简成一条vexpp硬件指令或用别的低精度多项式替代,误差会进一步加速膨胀。唯一的出路是让两侧跑完全相同的数学函数实现。

5. 把 bit-exact 找回来:一口气完成了四件事

弄清了根因,修复就不需要玄学。我按以下顺序做了四件事,最终让两侧的输出 bit pattern 完全一致。

5.1 统一数学函数来源,禁止两边各用各的 libm

我先从 newlib 的源码里把expflogferff等 Atome-LM 实际用到的函数实现抽出来,放到自己的公共数学库模块里,然后让 x86_64 参考端和 QEMU 目标端都调用这份代码,而不是调用各自的系统 libm。

这一步很关键。你不能指望在 ARM 端用 newlib、在 PC 端用 glibc,然后它们产生相同的位模式。就算两边误差都达标,也不能保证二进制一致。统一数学函数后,两侧执行的是同一套多项式逼近、同一套舍入顺序,编译器优化目标造成的差异会大幅缩减,至少 FMA/非 FMA 的问题被彻底锁死在一块非常可控的代码里。

常见的做法是:

// my_math.h float my_expf(float x); float my_logf(float x); float my_erff(float x);
// my_math.c(从 libm/newlib 授权合规地复制并改名) float my_expf(float x) { // 使用统一的 expf 实现 }

如果你的模型只用expferff,这个工作量很小。Atome-LM 这类轻量模型的算子并不复杂,逐个替换成本很低。对于更大的模型,建议先把所有非线性算子集中管理,不要散在各层代码里直接调用系统exp

5.2 关闭自动 FMA 和 fast-math,锁定编译选项

我之前一度以为“我没开 fast-math 就没事”,这是严重的误解。GCC 默认的-ffp-contract=fast允许编译器在单个表达式内自动生成 FMA,不需要你显式开启任何激进优化。

我在 Cortex-M3 工具链这边加上了:

-ffp-contract=off -fno-fast-math -fno-finite-math-only -fno-associative-math

同时,x86_64 的参考端也使用相同的一组浮点语义标志:

-ffp-contract=off -fno-fast-math

在两端同时关闭 FMA 契约之后,我把同一段 softmax 最小复现代码重新跑了一遍,结果如下:

方案x86_64 expfQEMU expfsoftmax 最大偏差
原始系统 libm + 默认编译0x3F76938E0x3F76936F3.4e-7
原始系统 libm + 关闭 FMA0x3F76938E0x3F76938A2.1e-7
统一 my_expf + 关闭 FMA0x3F76938E0x3F76938E0.0

这里数字只是示例,核心是:单靠关闭 FMA 还不足以拿到 bit-exact,因为系统数学库的实现差异仍然造成个位数 ulp 的分岔。只有把两边的expf替换成同一个实现,才能真正清零。

5.3 把中间累加器全部统一成 float,不偷用 double

Atome-LM 的 LayerNorm 在很多参考实现里会写成:

float mean = 0.0f; float var = 0.0f; for (...) { mean += x[i]; } mean /= n; for (...) { var += (x[i] - mean) * (x[i] - mean); }

如果你在参考端“顺便”把mean写成double mean = 0.0;,那在 x86 上因为硬件支持 double,性能不会察觉;但在 Cortex-M3 上,arm-none-eabi-gcc 会把 double 转成软浮点函数调用,结果就是两条完全不同的数值路径。更危险的是,double的中间精度比float高得多,于是 x86 端计算结果比“纯 float理想路径”更接近数学值,而 M3 端如果刻意保持 float 或也使用 double,反而可能不完全一致。

所以,我在这次修复里把所有推理路径中的中间累积都用float强制声明,不允许任何隐式提升到 double。如果确实是为了精度需要 double,那也是两端同时使用同一套 soft-double 库,不能靠 host 硬件 float 和 target 软浮点碰运气。

5.4 建立回归样本,把 bit-exact 变成 CI 里的一个检查项

修复完成后,我收集了 50 条不同长度的输入样本,包含均匀噪声、极短文本、全零输入、全极大值输入等边界情况。对每条样本,我把 Atome-LM 的中间层输出和最终输出分别以 FP32 hex 形式存成文件。

之后写了一个简单的脚本:

./x86_ref --input sample_01.bin --dump layer_outputs arm-none-eabi-run-qemu --kernel atome_lm.elf --input sample_01.bin --dump layer_outputs diff x86_ref_output.hex qemu_output.hex

对于 Atome-LM 这种体积的模型,全部层输出加起来也就几 MB,diff 完全跑得动。如果某个算子后来被改动,比如把 softmax 换成近似实现,CI 会立刻拉到红,不会让误差悄悄溜进下一个移植环节。

四件事做完后,我再跑之前那批样本,最大相对误差从3.4e-7直接降到了0.0f。两侧最终输出的float二进制完全一致。

6. 复盘 QEMU Cortex-M3 的“烤制”工具链与坑位

最后说说这次把 Atome-LM 放进 QEMU Cortex-M3 时实际用到的一些工具链细节。如果你也想在本地复现类似实验,这些步骤可以直接抄。

6.1 选对 QEMU 机器型号

QEMU 支持的 Cortex-M3 机器不止一个,常见的有lm3s6965evbmps2-an385mps2-an386stm32vldiscovery等。我这次用的是mps2-an385,因为它的 memory map 比较清晰,加载 ELF、重映射向量表很方便,QEMU 对它的支持也成熟。

启动命令类似:

qemu-system-arm \ -machine mps2-an385 \ -cpu cortex-m3 \ -nographic \ -kernel build/atome_lm.elf

Cortex-M3 没有 FPU,所以千万不要在命令行里传入类似-cpu cortex-m3,has_fpu=true的东西,M3 压根不存在这个选项。相比之下,Cortex-M4/M7 才可能带可选 FPU,模拟细节完全不同。

6.2 串口输出和标准库依赖的处理

在 QEMU 里跑裸机 ELF,第一道坎是标准输出。printf本身在裸机上可能需要调用 semihosting。如果你用的是 arm-none-eabi-gcc + newlib,最简单的做法是启用 QEMU 的 semihosting:

qemu-system-arm \ -machine mps2-an385 \ -cpu cortex-m3 \ -nographic \ -semihosting \ -kernel build/atome_lm.elf

然后在 C 代码里检查 newlib 提供的_write实现。用 semihosting 时,printf输出会走 QEMU 的 console,你会直接看到打印结果。注意 semihosting 是调试辅助机制,和真正芯片上的 UART 输出机制不同。如果目标固件需要模拟真实串口,就应该配置 QEMU 的-chardev参数,然后实现对应的 UART 驱动。

6.3 用 GDB 连进去做逐层 snapshot

QEMU 对我们的最大价值是可以随时停住,看内存。启动时加一个 GDB server:

qemu-system-arm \ -machine mps2-an385 \ -cpu cortex-m3 \ -nographic \ -semihosting \ -S \ -gdb tcp::1234 \ -kernel build/atome_lm.elf

-S表示启动后立刻暂停。然后另一个终端里:

arm-none-eabi-gdb build/atome_lm.elf (gdb) target remote :1234 (gdb) break compute_softmax (gdb) continue

当程序停在 softmax 函数入口时,你可以手动读取输入张量、执行 exp 中间步骤、把结果与 x86 参考端对比。这类操作在 QEMU 里非常自然,没有物理烧录延迟,也没有 JTAG/SWD 带宽限制。

6.4 QEMU 不是 cycle 精确模拟器,但它没骗你

这次误差不在 QEMU,而是我错误地预设了“两边编译器的浮点计算路径天然一致”。真正需要记住的是:QEMU 执行架构指令的行为高度接近真实 CPU,但它不是时钟周期级精确模拟器,更不负责替你统一 host 和 target 的浮点 mathematical library 差异。在纯软浮点场景下,同一份二进制在 QEMU Cortex-M3 和一颗真实的 Cortex-M3 芯片上执行,结果通常应该一致;但如果你的比较对象是另一套架构、另一套编译器、另一份 libm,那 bit-exact 就必须靠自己构建单一计算路径来保证。

7. 一些小体会

这个3.4e-7的案例让我重新审视了“模拟器验证”的意义。过去我总觉得,只要参考环境和被测环境都遵守同一个数学标准,结果就应当逐位一致。实际情况是,FP32 的基本运算规则很紧凑,但编译器有太多合法的重排和收缩空间,数学库更是各有各的“正确”。除非明确固定中间变量精度、关闭 FMA 契约、屏蔽宽松浮点重排,否则差异几乎注定存在。

我现在的习惯是:所有涉及模型数值一致性的测试,第一轮就直接对比 hex,而不是看fabs(diff) < 1e-6。很多问题用相对误差看好像无所谓,但一旦开始做模型剪枝、量化和算子融合,这种微小的位级分岔会被层层放大,直到某一天你突然发现一个特征值偏差了0.01,这时候再回来揪根因,就已经晚了。

如果你只是想把 Atome-LM 这类小型模型放到 Cortex-M 系列芯片上体验一把运行速度,那没必要像我一样追求 bit-exact,3.4e-7的误差对最终效果几乎无影响。但如果你和我一样,需要在 PC 端做黄金标准、在 MCU 端做等价性验收,那么一次性把数学函数、编译选项、FP32 累加路径全部按同一份代码来约束,反而比反复容忍误差更省时间。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询