先做个小实验。写下这行代码:double r = a * b + c;。一个教科书级别的浮点表达式,先做乘法,再做加法。但从处理器角度看,这件事有完全不同的玩法:现代CPU可以在一条指令里完成整个乘加运算,并且中间的乘法结果不做舍入,直到最后才“四舍五入”一次。这条指令就是FMA,全称Fused Multiply-Add,融合乘加浮点指令。
我最初意识到FMA的价值,是在一次数值库调试里。当时同一个函数在不同编译版本下结果差了几个ulp,查到最后发现编译器自动把a*b+c改成了FMA指令。这个改动本身是对的,但它告诉我一件事:FMA不仅是一个性能优化点,更是一个能改变计算结果精度的语义级操作。做科学计算、数值分析、编译器优化、图形学或AI推理底层开发的人,几乎都会和它打交道。如果你正在调数值代码的精度,或者好奇“为什么同样的算法换个CPU结果就不一样”,这篇文章值得看完。
本文会把FMA的数学原理、硬件行为、编译器控制、实测对比和避坑经验一起讲清楚,最后附带几个可以抄走的实验代码。所有内容都基于我实际跑过的实验和踩过的坑。
1. FMA到底是什么:一条指令如何改变浮点运算
1.1 普通乘加为什么会产生两次舍入
要理解FMA,先要理解浮点数舍入。IEEE 754的double类型只有53位有效二进制位,任何运算结果落在这53位之外,都要做就近舍入。a*b+c这个表达式,如果按普通方式执行,乘法会先算出一个精确乘积,然后舍入成double;舍入后的积再和c相加,又得到一个精确和,再次舍入成double。也就是说,一次普通的乘加运算,会经历两次舍入。
这两次舍入单独看都不起眼,误差都在半个ulp以内。但在某些数值环境里,它们叠加起来会造成实质性的错误。尤其是乘法的结果很接近某个可表示浮点数,或者后续加法里出现大数消去小数的情况,两步舍入会让一个本应“看出问题”的计算直接变成0,或者反向放大误差。这类问题不会在普通业务代码里暴露,但在迭代算法、递推公式、几何判断和统计计算里非常致命。
1.2 “融合”的意思:中间结果不落内存
FMA全称是Fused Multiply-Add,关键就在Fused这个单词上。它的计算方式是a*b+c整体作为一个原子操作,先算出a*b的精确值,直接与c相加,得到精确和,最后统一舍入一次。
“中间结果不落内存”是这句话的实质。普通乘加里,a*b的中间结果必须被舍入成为一个double,它占用了实打实的寄存器/栈空间;FMA里,中间乘积以全精度形式保留在硬件乘法器的内部,根本不对外展示。因此从外部视角看,FMA的舍入次数从两次降为一次,精度边界直接从“最多一个ulp误差”变成“最多半个ulp误差”。
需要注意,FMA的a*b全精度是“无限精度”的吗?实际上不是。严格说是乘积未舍入就送到加法网络,数值上等同于无限精度参与后续操作,因为硬件没有在中间截断。对于普通用户来说,把FMA理解为“算完整个式子才舍入一次”,完全够用。
1.3 硬件指令长什么样
x86平台上,FMA3指令组的助记符通常是VFMADD开头。以VFMADD132SD xmm1, xmm2, xmm3为例,它的语义是:
xmm1 = xmm1 * xmm3 + xmm2其中132表示三个操作数的用法:目标寄存器同时是第一个乘数,xmm3是另一个乘数,xmm2是加数。编译器在生成代码时会在132、213、231几种排列里自动选择,不需要开发者关心。SD后缀表示Scalar Double,即单个双精度浮点数;如果看到VFMADD231PS,就是Packed Single,即打包的单精度SIMD版本。
ARM平台的AArch64指令集也有对应的FMLA指令,语义同样是融合乘加。GPU平台上,CUDA等编程环境提供fmaf、fma这类内建函数,底层同样是一条融合乘加指令。可以说,凡是面向密集数值计算设计的现代处理器,都内置了FMA单元。它已经不是一个可选优化,而是数值计算的基本盘。
2. FMA的精度账:一个乘法加法的舍入差在哪里
2.1 一个能复现的经典差异
理论说多了容易飘,我们直接上一组真实数据。取两个double常量:
x = 1 + 2⁻⁵² y = 1 - 2⁻⁵²然后分别用“普通乘加”和“FMA”计算x*y - 1。
先算普通方式。x*y的精确值是:
(1 + 2⁻⁵²)(1 - 2⁻⁵²) = 1 - 2⁻¹⁰⁴这个值无法被double精确表示。它距离1只有2⁻¹⁰⁴,而距离上一个可表示数1-2⁻⁵²还有大约2⁻⁵²的距离。就近舍入后,x*y变成了1。接着1 - 1 = 0。普通乘加的最终结果是0。
再看FMA。fma(x, y, -1.0)先算精确乘积1 - 2⁻¹⁰⁴,再和-1相加,得到-2⁻¹⁰⁴。这个值虽然很小,但它是一个合法的double(属于subnormal范围),可以被正常表示。所以FMA的最终结果是-2⁻¹⁰⁴。
结果从0变成了一个非零值。这个例子说明,普通乘加里“乘法先舍入到1”这个步骤,把本应暴露的微小量彻底抹掉了;FMA因为不提前舍入,反而保住了这个小量。
2.2 从误差界理解:1个ulp变成0.5个ulp
如果你做过浮点误差分析,会更喜欢下面这个视角。普通乘加的最坏相对误差,等于乘法舍入误差和加法舍入误差之和,上界大约是2个ulp;FMA只舍入一次,误差上界是1个ulp。这里的“ulp”指浮点数最小刻度单位。别小看这半个身位,很多数值算法的精度证明里,误差界从“会发散”变成“可收敛”,区别就在这一步。
我实际调过一个与几何拓扑相关的库,里面大量使用“叉积后判断符号”的逻辑。叉积公式展开后就是a*b - c*d这类乘减组合。普通写法在两条边几乎平行时,符号会被两次舍入彻底压死,程序判断出错误方向;改成FMA后,由于中间结果不丢精度,特征值计算稳定得多。这类案例没法用“性能提升”衡量,FMA直接决定了结果对不对。
2.3 FMA还能用来“找回”乘法误差
FMA另一个很有趣的用途是精确求乘法余项。给定两个doublea和b,先算出它们的普通乘积h = a * b,然后执行:
double l = fma(a, b, -h);这里的l是什么?fma(a, b, -h)返回a*b - h的精确值。由于h已经是a*b舍入后的最接近double,两者相减后得到的l,正好是乘法舍入过程中丢掉的那个余项。也就是说,h + l以“双精度对”的形式重新表达了更精确的乘积。
这是double-double算术的基础。很多高精度计算库就是靠一组FMA指令,把double的53位精度扩展到106位,而不需要使用昂贵的多精度整数库。单从这一点看,FMA的价值就远超“省一条指令”的范畴。
3. 性能账:为什么矩阵乘和AI算力都盯着FMA
3.1 指令数减半,峰值翻倍
性能上FMA的优势很直白:一条指令干了乘法器和加法器两份活。普通乘加至少需要mul和add两条指令,FMA只需要一条vfmadd。在处理器每个周期能发射的指令数有限的前提下,把两条运算压缩成一条,意味着同样的发射带宽下,浮点运算量几乎翻倍。
延迟方面,现代x86处理器的FMA延迟通常和一条普通乘法延迟相近,大概在4到5个周期左右。也就是说,它不是“先算乘法再算加法”的串联流水线,而是专门设计的融合运算单元。这也是为什么单纯比较“每周期浮点操作数”时,开FMA的机器理论峰值明显高出一截。
不过要强调的是,性能提升的幅度取决于算子的访存特征和依赖链。如果一段代码本来就被内存带宽卡死,比如简单逐元素a[i] * b[i] + c[i],减少一条指令并不会让速度翻倍,因为等待内存数据的时间并没有缩短。FMA的收益主要在计算密集、数据能在寄存器/缓存里循环的场景中体现。
3.2 矩阵乘法和卷积为什么尤其受益
矩阵乘法是FMA的典型主场。内层循环不断重复“取两个数,相乘,累加”,恰好就是FMA的原生形态。编译器只要把三重循环里的乘加识别出来,就能自动替换成vfmadd指令。以一次常见的4x4矩阵乘为例,展开后的代码里几十次乘加全部映射到FMA单元,寄存器周转量大幅减小。
卷积神经网络里的卷积计算本质上也是乘加流。每个输出点都是一堆权重 * 输入的累加,用FMA做累加比单独乘法和加法交替执行效率高得多。许多AI加速芯片宣传的“每周期亿次运算”,背后就是一堵FMA计算墙。可以说,没有FMA,现代深度学习推理的性能可能要倒退好几年。
3.3 一个直观的基准经验
我自己的实测经验是这样:在一台三年前的x86台式机上,跑500万次霍纳法多项式求值,-O2 -ffp-contract=off(完全不让编译器融合)用时约3.8秒;换成-O2 -march=native后,编译器把霍纳循环里的r = r * x + coeff[i]全部变成vfmadd指令,同样的计算降到2.3秒左右。速度提升约40%。如果代码里还用了SIMD,单精度场景下提升会更明显。
但请注意,这个基准里多项式求值具有天然的串行依赖:每次r都依赖上一次结果,FMA虽然减少指令数却没法完全隐藏延迟。真正接近峰值收益的是把多个独立多项式计算交错起来,让多个FMA流水线并行跑满。如果你的循环里只有一个串行累加链,还要手动做循环展开和多路并行,否则FMA再快也会被延迟拖住。
4. 让编译器真正输出FMA的三种方式
4.1 编译开关:最省事但不能盲开
对GCC和Clang来说,让编译器自动生成FMA的基本开关是-mfma。更推荐的是直接用-march=native,这会让编译器根据当前CPU的特性自动打开包括FMA在内的指令集开关。
gcc -O2 -march=native program.c -o program clang -O2 -march=native program.c -o program但编译选项只是“允许”生成FMA,实际是否生成还受浮点语法约定限制。GCC在默认的GNU模式下,通常允许将乘加表达式合成为FMA;如果使用了严格的-std=c99或者显式关闭了-ffp-contract,编译器可能就不动了。最直接的办法还是编译后用反汇编确认,这点我放在4.3节讲。
Windows平台的C++编译器也有对应机制,需要显式打开FMA代码生成开关,并配合合适的SIMD指令级别,否则即使代码里写了fma(),也不一定会转化为单条硬件指令。具体名称因编译环境而异,最稳的验证方式依然是反汇编。
4.2 显式调用:fma函数和intrinsic
如果不想依赖编译器的心情,可以自己写显式调用。C语言标准库里提供了fma、fmaf、fmal三个函数,分别对应double、float、long double版本。
double r = fma(a, b, c);GCC和Clang在开启-mfma后,会把fma()直接内联为一条vfmadd指令;如果目标平台不支持FMA,则会回退到libm里的软件仿真实现,结果语义依然是“只舍入一次”,只是性能会明显下降。
想完全掌控指令级别,就得用编译器提供的intrinsic接口。x86平台包含<immintrin.h>后,可以用_mm_fmadd_sd处理单个double,用_mm256_fmadd_ps处理8个float:
#include <immintrin.h> __m256d acc = _mm256_setzero_pd(); __m256d a = _mm256_loadu_pd(ptr_a); __m256d b = _mm256_loadu_pd(ptr_b); __m256d out = _mm256_fmadd_pd(a, b, acc);用intrinsic写法的好处是明确告诉编译器“这里必须用FMA”,不留给它优化的自由裁量权。坏处是代码可移植性下降,一旦切到ARM平台或非FMA平台,就要换成别的方式。我的建议是:追求可移植性用fma()库函数,追求极致性能且确定平台时用intrinsic。
4.3 验证FMA是否真的生成
编译器到底生成没生成FMA,看代码是看不出来的,直接看汇编最稳。Linux下可以这样操作:
gcc -O2 -march=native -S program.c -o program.s grep -i fmadd program.s如果看到vfmadd开头的指令,说明融合已经生效。另一种方式是objdump可执行文件:
objdump -d program | grep fmadd输出里有vfmadd132sd、vfmadd231pd之类的指令名,就说明代码确实在目标机器上用了FMA。需要注意的是,如果用了-ffp-contract=off,即使开了-mfma,乘加表达式也可能不会被融合,但显式的fma()调用依然会生成指令。这里的区别很容易让人混淆,排查时可以先检查编译命令。
为了更直观,我还建议各位做一个实验:把4.2里的fma(a, b, c)写成a * b + c,分别加-ffp-contract=off和-ffp-contract=fast编译,再反汇编对比。你会看到同一个源码,汇编里一个是分开的乘法加法指令,一个是单条融合指令,非常直观。
5. 实测:FMA和非FMA在同一段代码上的差异
5.1 实验代码与编译方式
下面是一段可以独立运行的C代码,验证第2节里的精度差异。它同时计算普通乘加和显式FMA,并打印十六进制浮点表示:
#include <stdio.h> #include <math.h> int main(void) { volatile double x = 1.0 + 0x1p-52; volatile double y = 1.0 - 0x1p-52; double c = -1.0; double mul_then_add = x * y + c; double fma_result = fma(x, y, c); printf("普通乘加: %a (%g)\n", mul_then_add, mul_then_add); printf("FMA调用 : %a (%g)\n", fma_result, fma_result); return 0; }编译时我建议做两遍对比。第一遍禁止自动融合:
gcc -O2 -ffp-contract=off demo.c -lm -o demo_off第二遍允许自动融合并打开FMA:
gcc -O2 -ffp-contract=fast -mfma demo.c -lm -o demo_fast跑demo_off时,输出大概是:
普通乘加: 0x0p+0 (0) FMA调用 : -0x1p-104 (-4.93038e-32)而demo_fast很可能两行输出一样,因为编译器把x * y + c直接改成了FMA。这正好说明了我之前在4.3节里的提醒:同样的源码,编译设置不同,结果会不同。
5.2 霍纳法多项式的性能对比
第二个实验验证性能。用霍纳法求一个5次多项式,普通写法和FMA写法只差一个函数调用:
double poly_normal(double x, const double *a) { double r = a[5]; r = r * x + a[4]; r = r * x + a[3]; r = r * x + a[2]; r = r * x + a[1]; r = r * x + a[0]; return r; } double poly_fma(double x, const double *a) { double r = a[5]; r = fma(r, x, a[4]); r = fma(r, x, a[3]); r = fma(r, x, a[2]); r = fma(r, x, a[1]); r = fma(r, x, a[0]); return r; }用一个足够大的循环调用poly_normal和poly_fma,注意把r累积到某个全局变量里防止编译器把整个调用优化掉。在我的实测里,循环次数拉到5000万次后,poly_normal明显更慢,poly_fma快30%以上。如果追求更好的效果,可以把多个独立的r拆分计算,增加指令级并行度,速度还能再上一个台阶。
5.3 性能测量时的三个坑
性能测试看着简单,但有几个坑容易让结论失真。
第一个坑是编译器死代码消除。如果调用结果没有“副作用”,编译器可能直接把整个循环优化消失。解决方法是把结果累加到一个volatile变量,或者通过printf输出一次。
第二个坑是内存带宽瓶颈。像逐元素数组乘加这种场景,数据量一大就被内存速度卡住,FMA的收益会被访存延迟完全淹没。测性能前先确认你的代码是计算密集的,否则对比出来只是“内存操作的差异”。
第三个坑是频率波动和超线程干扰。尽量在测试前保持系统空闲,多次运行取中位数,不要用单次最大值。数值计算社区有个共识:性能测试要写清测试环境和迭代次数,否则结果不可复现。
6. 常见问题与排查技巧实录
6.1 写了fma函数,反汇编里却没有指令
这是出现频率最高的问题。原因通常有两个:一是编译时没开-mfma或-march=native,编译器只能回退到libm里的软件仿真;二是编译器版本太老,对fma()的内联支持不完整。排查方法是先加-mfma,确认CPU支持FMA,再用4.3节的反汇编命令验证。如果反汇编里出现call fma而不是vfmadd,说明回退到函数调用了。
另一个隐蔽原因是优化等级太低。-O0下编译器通常不会生成FMA指令,至少要用-O2。如果你在调试版本里检查性能,请先把优化等级提上来,否则会误判。
6.2 不同平台结果不一致,回归测试挂了
这是FMA带来的典型“副作用”。同一个a*b+c表达式,在支持FMA且允许融合的平台上会被编译成FMA,在不支持或未打开的平台上则分成两条指令。两种结果在多数时候只差一点点,但某些边界值会像第2节实验那样,一个结果是0、一个结果是非零,直接导致单元测试断言失败。
处理方式分两种。如果是绝对需要可复现性的场景,例如金融计算或跨平台回归测试,可以考虑统一关闭自动融合,使用-ffp-contract=off,同时代码里避免隐式依赖编译器行为。如果看重精度和性能,则应该统一使用显式fma()调用,并明确记录“本模块依赖FMA指令”,在文档里写明硬件要求。我最常采用的做法是:核心数值模块显式调用fma(),对外保证精度语义;周边非关键路径交给编译器自由处理。
6.3 开了FMA性能没提升,甚至变慢
如果你开了FMA但性能纹丝不动,先别急着怀疑指令本身。检查你的代码是否受内存带宽限制。以dst[i] = src1[i] * src2[i] + src3[i]为例,即便每条都改成FMA,最耗时的还是等内存数据,指令减少了但总时间变化很小。
还有一个可能:依赖链太长。FMA虽然合成了运算,但并没有消除数据依赖。每个FMA都等待上一个FMA的结果,延迟完全暴露。解决办法是循环展开,把独立的计算流并行起来。比如同时维护4个累加器,每个累加器一条FMA依赖链,4条链交错执行,就能把FMA单元的流水线填满。
如果代码变慢,还有一种可能是目标CPU的FMA指令延迟比普通乘法更高,或者混合了不同精度的运算导致流水线切换开销。遇到这种情况,可以用-march=native重新编译,确认指令集匹配,不要手动指定一个过宽的目标型号。
6.4 异常标志和NaN这些边角问题
普通乘加会发生两次舍入,因此可能触发两次浮点异常标志;FMA只发生一次舍入,异常标志的触发路径完全不同。例如a*b溢出成极大值后,普通乘法会置溢出标志,但FMA里如果加上c后结果回到了可表示范围,可能不产生溢出标志。这类行为在启用异常捕获的代码里会引发差异。
NaN和无穷大也有细微差别。如果a*b是NaN,但c是无穷大,普通乘加会把NaN传播下去,FMA在硬件实现上通常也遵循IEEE 754的规定,但不同实现之间可能存在一致性差异。如果你在写高可靠数值代码,建议对这些边角值单独写测试,不要假设FMA和普通乘加在所有特殊输入上都行为一致。
7. 延伸玩法:把FMA用在更高精度计算里
最后一节分享几个能在实际项目里直接用的小技巧,前两个我在前面的章节已经埋了伏笔。
第一个是把霍纳法写成FMA循环。多项式求值的标准形式就是霍纳法:r = r * x + c[i]。直接改成r = fma(r, x, c[i]),既减少一条指令,又消除中间舍入。这个方法对普通多项式、切比雪夫逼近、样条插值都适用。如果你要实现手写函数库,这是一个稳定有效的起点。
第二个是用FMA恢复乘法误差项,也就是2.3节提到的double-double技巧。原始乘法算出hi,用FMA算出lo,然后hi和lo组成一对double。这里的关键点在于,fma(a, b, -hi)里的乘积是未舍入的,减去hi后得到的余项完全精确。这个能力在实现正确舍入的对数函数、指数函数时几乎是必需品。
第三个是方差和协方差的稳定计算。朴素方差公式E[x²] - E[x]²在数据均值很大时会有灾难性消去。用FMA累加x与均值之差的平方,可以缓解一部分精度损失。虽然不能彻底解决病态问题,但比直接计算平方和再相减稳健不少。
我个人在实际操作里的体会是,FMA是一个“性价比极高”的优化起点。它通常不需要改算法结构,只是一个函数调用或一个编译选项的事,却能在不改变语义的前提下同时改善精度和速度。我写数值模块时,第一件事就是确认编译命令里有没有-mfma,然后检查核心循环里适不适合显式写fma()。这样处理过的代码,至少能在“结果更可靠”这件事上赢一半。如果你手头正好有一段跑得慢或者结果不稳的数值代码,可以从把第一个a*b+c改成fma(a,b,c)开始。