恒美微站
首页
关于我们
建站服务
主题模板
案例展示
资讯中心
联系我们
嵌入式浮点优化:让编译器生成VFMA融合乘加指令的调校指南
首页
资讯中心
/
嵌入式浮点优化:让编译器生成VFMA融合乘加指令的调校指南
嵌入式浮点优化:让编译器生成VFMA融合乘加指令的调校指南
发布时间:2026/9/6 13:12:42
写嵌入式性能优化这个系列到第十二篇我终于决定专门碰一下浮点指令这块。平时交流群里经常有人问我的代码里明明写着out a * b c跑在不同的带 FPU 的 MCU 上反汇编却看到先是vmul再是vadd或者干脆用vmla就是没有等来那句vfma。这颗内核硬件上明明支持融合乘加指令编译器怎么就死活不肯用这一篇就围绕 VFMA 融合乘加指令把编译器调校这件事掰开揉碎讲透。这里说的 VFMA在 ARM VFPv4/FPv5 这类硬件浮点单元上就是 Fused Multiply-Add单条指令完成Sd Sd Sn * Sm的乘、加两步并且只在最终结果时做一次舍入。和同样常见的 VMLA非融合乘加相比最大的差别不在助记符而在舍入行为。这个差异既是编译器不愿下手的原因也是我们需要去主动配置、干预的入口。这篇文章适合正在调音频算法、控制环路、传感器融合或者其他浮点密集型代码的嵌入式工程师确认清楚这些机制比背多少条寄存器结构都更管用。1. VFMA 到底是什么以及它为什么值得抠这一下1.1 一条指令干了两步操作的活我们先看一条普通乘加在没有任何优化下被编译成什么。C 代码是这样float foo(float a, float b, float c) { return a * b c; }如果编译器完全不合并它会生成一条浮点乘法再生成一条浮点加法大致等价于vmul.f32 s0, s0, s1 vadd.f32 s0, s0, s2而 VFMA 的语义是vfma.f32 s0, s1, s2 ; s0 s0 s1 * s2原来需要两条指令的数据流现在变成一条。别小看这一条在循环体里如果反复执行乘累加比如 FIR 滤波器、IIR 滤波器、矩阵乘法、复数运算每次迭代少一条浮点指令整体执行时间肉眼可见往下降。而且融合后的数据依赖链也短了寄存器分配压力会更小对编译器做循环展开和软件流水也友好很多。1.2 编译器默认不生成问题出在“舍入”上很多人不理解既然指令集支持CPU 硬件也有为什么我开-O2后它还是生成vmla或者两条独立指令问题不在优化级别而在 C 语言本身的语义。普通乘加先算乘法把中间结果舍入成 float再算加法再次舍入。而 VFMA 把乘法的无穷精度结果直接拿来和加数相加只在最后舍入一次。也就是说融合之后的浮点结果可能和严格按源码顺序计算的结果差一点点。C 标准允许编译器在默认情况下做 contraction但要求它不改变可观察行为而浮点运算的舍入结果就是可观察行为的一部分。编译器不敢擅自冒险所以很多工具链在严格浮点模式下宁可多生成两条指令也不碰 VFMA。用个生活里的大白话解释先四舍五入一次再拿结果相加后再四舍五入一次和直接把两个原始数加完再四舍五入一次最终结果可能差一毛钱。这一毛钱在财务上叫误差在浮点上叫 1 ulp。绝大多数项目根本不在乎这 1 ulp但编译器默认要按“绝对保守”来这就是我们需要通过编译选项给它授权的根本原因。1.3 哪些内核支持哪些内核不用想VFMA 不是所有 ARM 内核都有。我整理了一张常见内核支持表方便你对照自己的项目内核/架构浮点单元是否支持 VFMA备注Cortex-M0/M0无不支持只能软浮点聊 FMA 没有意义Cortex-M3无不支持硬件无 FPUCortex-M4FFPv4-SP支持单精度 VFMA最常见的使用场景Cortex-M7FPv5支持单精度双精度可选取决于芯片具体配置Cortex-M33/M55FPv5支持单精度双精度可选注意内核配置差异Cortex-R4F/R5FVFPv3不支持 FMAVFPv3 没有融合乘加Cortex-R7F/R8FVFPv4支持部分 R 系列可用Cortex-A 系列VFPv4/NEON支持但汇编助记符体系更复杂从这张表能看出Cortex-M4F 和后续带 FPU 的 M 系列才是 VFMA 的主战场。Cortex-M7 如果要跑双精度 VFMA需要芯片厂商把 FPU 配成 FPv5-D16 而不是 SP很多传统 MCU 只给单精度用 double 反而会被编译器迁到软浮点库那就彻底和 VFMA 说再见了。1.4 影响范围不光是 CPU 跑分VFMA 对实际项目的影响范围可以很广。举几个我接触过的例子音频处理里的 Biquad 滤波器、回声消除的自适应滤波、电机控制里的 FOC 算法、惯性导航的姿态解算、传感器融合里的卡尔曼滤波核心矩阵乘法核心运算都是乘累加密集循环。这类代码如果能在编译阶段把循环体的乘加融合成 VFMA整体运算量会明显下降。反过来如果代码本身就是内存带宽瓶颈瓶颈不在算术单元那么 FMA 能提供的帮助就很有限。判断清楚自己的场景是哪些指令受限才能在优化时有的放矢。2. 让编译器吐出 VFMA选项与代码写法2.1 先让 FPU 真正参与-mfpu与-mfloat-abi要让编译器生成任何浮点硬件指令前提是你得把目标内核的浮点能力明确告诉编译器。以 GCC 工具链为例最少要同时指定这几项arm-none-eabi-gcc -mcpucortex-m4 -mfloat-abihard -mfpufpv4-sp-d16 -O2 -c fir.c -o fir.o-mcpucortex-m4告诉编译器目标内核是 M4 而不是 M3这样它才知道自身带不带 FPU-mfpufpv4-sp-d16指定浮点单元是单精度 VFPv4Cortex-M4F 的标配-mfloat-abihard表示函数调用时浮点参数用 FPU 寄存器传递性能最好如果用的是 Cortex-M7 且芯片支持双精度可以改成-mfpufpv5-d16。很多新手只写-O2连-mfpu都不加编译器自然默认按软浮点目标处理反汇编里全都是对__aeabi_fmul这类软浮点库的调用指令数量翻了不止一倍。我建议在 Makefile 里把这三组参数固定写清楚不要图省事只依赖 IDE 的默认值。注意事项如果用-mfloat-abisoftfp函数参数仍然通过通用寄存器传递但函数体内允许使用 FPU 指令。这种方式在 RTOS 环境里能减少调用时的上下文压力但它自身也有取舍这里不展开。如果你的项目跑了 RTOS启用硬浮点后务必确认任务切换代码保存了 FPU 寄存器否则任务一切浮点现场就乱了。2.2 关键开关优化级别与浮点收缩策略在 GCC 工具链里控制 FMA 融合的选项叫-ffp-contract可以取off、on或fast。对嵌入式场景我建议显式写-ffp-contractfastarm-none-eabi-gcc -mcpucortex-m4 -mfloat-abihard -mfpufpv4-sp-d16 -O2 -ffp-contractfast -c fir.c -o fir.o这个选项的含义是只要目标指令集支持编译器就可以把乘法和加减法融合成 FMA 指令不必在乎结果和严格逐次要一致。它比-ffast-math温和得多不会动 NaN、Inf 或者符号零等边界语义。很多人一上来就上-ffast-math那是把核弹当了开门钥匙常常把控制类项目里依赖 IEEE 754 边界行为的代码搞坏。Clang/armclang 也接受-ffp-contractfast。在 Keil MDK 的 AC6 编译器上我习惯在Options for Target - C/C - Misc Controls里直接加这一项然后优化级别至少选-O2。同样道理-O1也可能在局部生成 FMA但为了循环展开和指令调度效果更好建议直接-O2起步。有些开发者担心-ffp-contractfast是不是属于“非法优化”。不是。它是符合大多数嵌入式项目需求的常规配置先把正确性测试用例跑过再看性能通常没有任何问题。真正需要警惕的反而是-ffast-math这种会给比较运算和特殊值处理引入风险的选项。2.3 Keil MDK/AC6 与 IAR 下的对应设置如果用的是 Keil MDK且编译器是 AC5armcc它的浮点收缩开关不如 GCC 那么直接。老项目用 AC5 时我建议尽早迁移到 AC6原因之一就是 armclang 的浮点优化选项更透明也更接近新工具链的主流行为。AC6 下具体路径在Options for Target - Target里把Floating Point Hardware选成Single Precision或Double Precision在C/C页的Optimization里选-O2或-O3在Misc Controls里补一行-ffp-contractfast。IAR 的情况稍微特殊。IAR 的优化策略通常绑定在工程级别路径在Project - Options - C/C Compiler - Optimization想拿到自动 FMA 融合优化级别至少要选 High 或 Balanced并在浮点设置里确认启用硬件 FPU。部分新版本 IAR 支持通过命令行或预编译宏控制浮点收缩但菜单名称随版本有差异。如果你用的是 IAR我最常用的验证手段就是直接在反汇编窗口搜vfma搜到了就说明编译器知道了搜不到再回头查优化级别和 FPU 配置。2.4 代码要怎样写编译器才敢大胆融合除了编译选项源码写法也会影响 FMA 是否能生成。下面几点是我在实践中反复验证过的经验。尽量少用跨函数的表达式。如果乘加运算被拆到多个小函数里每个函数返回一个中间结果编译器在调用边界上很难做跨越函数调用边界的 FMA 融合。把核心运算写在内层循环里或者放到static inline函数中合体机会大很多。尽量避免指针别名掺和进来。C 语言里编译器要保证通过指针写入不会影响其他变量的值如果它无法判断两个指针是否指向同一块内存就可能不敢把读取和计算重排。写滤波器时我习惯先把滤波器系数和状态量读进局部变量循环处理完再写回结构体typedef struct { float b0, b1, b2, a1, a2; float z1, z2; } biquad_t; float biquad_process(biquad_t *f, float x) { float b0 f-b0, b1 f-b1, b2 f-b2; float a1 f-a1, a2 f-a2; float z1 f-z1, z2 f-z2; float y b0 * x z1; z1 b1 * x - a1 * y z2; z2 b2 * x - a2 * y; f-z1 z1; f-z2 z2; return y; }不要滥用volatile。volatile变量会让编译器每次都从内存加载结果等于明说“这里别做优化”FMA 自然不可能出现。调试结束后检查一下是否把临时用的volatile留在了正式代码里。3. 实测反汇编确认与性能验证3.1 用 objdump 快速确认 VFMA选项配完之后第一件事不是跑分而是先看汇编。用 arm-none-eabi 工具链的话一条命令搞定arm-none-eabi-objdump -d build/Project.elf | sed -n /biquad_process/,/^$/p重点搜vfma和vmla两个助记符。看到vfma.f32就是融合成功看到大量vmla.f32说明编译器做了乘加合并但没做浮点收缩看到vmul.f32vadd.f32连续出现说明连最基本的乘加合并都没做优先回去查优化级别和浮点选项。Keil 用户可以在工程生成后打开反汇编窗口或者在Output里勾选生成 Listing 文件直接看.lst里的汇编。IAR 的反汇编窗口同样可以直接搜索VFMA。我几乎每次改完浮点相关代码都会顺手搜一遍这个动作养成了习惯之后能避免很多“我以为优化了其实没优化”的尴尬。3.2 一个 FIR 循环的实战对比拿最典型的 FIR 滤波器循环举例float fir_process(const float *coef, const float *history, int n) { float acc 0.0f; for (int i 0; i n; i) { acc coef[i] * history[i]; } return acc; }在同一台 Cortex-M4F 目标上对照组用-O2但不加-ffp-contractfast反汇编里循环体经常是vmla.f32或者更保守的vmul.f32加vadd.f32。加上-ffp-contractfast后acc coef[i] * history[i]被融合成vfma.f32 s0, s1, s2实际项目里我用 DWT 周期计数器测过一段 256 阶 FIR纯套循环、不做展开的情况下融合后执行周期比非融合少了两成左右。如果再把循环展开和流水调度加上收益会更明显。当然具体比例受内存等待、编译器版本、循环长度影响很大不要把我的数字当绝对的 benchmark但方向是稳定的算术密集的乘加循环VFMA 带来的收益不会让你失望。3.3 没看到 VFMA按这个顺序排查我在群里看见最多的问题是“我明明开了强优化怎么还没有 VFMA”。我一般让他们按这个表格自查现象可能原因对策反汇编全是对__aeabi_fmul等库函数调用没有开启硬件浮点检查-mfpu与-mfloat-abi循环体还是vmulvadd两条浮点收缩没打开显式加-ffp-contractfast已经局部有vmla但没有vfma编译器在严格遵守 C 语言舍入语义确认是否把fast写成了on或确认代码里有没有volatile干扰代码全是double运算目标 FPU 不配对双精度改用float或用支持 FPv5-D16 的内核重编循环被优化没了什么都没看到结果没用死代码消除把最终结果写到volatile全局变量或调外部函数有一次同事调了整整一上午没找出问题结果只是 Makefile 里同时出现了-mfpufpv4-sp-d16和-mfpufpv5-d16后面一个把前面覆盖掉而目标芯片又不支持后者编译器悄悄退回了软浮点。所以排查选项时一定要看真正的命令行最终生效值别只看 makefile 里写了什么。3.4 用 DWT 周期计数器做科学验证确认汇编正确之后再用实际运行时间佐证。Cortex-M 自带的 DWT 循环计数器是性价比最高的测时工具代码很短CoreDebug-DEMCR | CoreDebug_DEMCR_TRCENA_Msk; DWT-CYCCNT 0; DWT-CTRL | DWT_CTRL_CYCCNTENA_Msk; uint32_t t0 DWT-CYCCNT; for (int i 0; i 100; i) { acc fir_process(coef, hist, 256); } uint32_t cycles DWT-CYCCNT - t0; volatile uint32_t sink acc;注意三点一是把被测函数多跑几轮避免罕见的 Cache miss 或中断造成单次测量抖动二是用volatile sink保存结果防止编译器把整个循环优化掉三是在目标系统不开中断或关中断时测量否则周期数会被中断污染。实测多次取最小值比取平均值更接近理论性能。4. 进阶手工内联汇编与真实收益边界4.1 手写一条 VFMAGCC 内联汇编示例多数情况下编译选项就够用但有些人喜欢极致控制或者编译器在某个特殊场景下就是不肯融合那可以考虑手工内联汇编。以 GCC/armclang 风格为例一个简单的 VFMA 封装可以写成static inline float vfma_f32(float acc, float a, float b) { float res; __asm__ volatile( vfma.f32 %0, %2, %3\n : t(res) : 0(acc), t(a), t(b) ); return res; }这里t约束让编译器把变量分配到单精度 VFP 寄存器0(acc)表示输入输出共用同一个寄存器语义正好匹配Sd Sd Sn * Sm。我把调用处写得很简单float process(float x, float y, float z) { return vfma_f32(x, y, z); }反汇编通常能看到清晰的vfma.f32。但我要提醒一句内联汇编是最后手段它把指令调度、寄存器分配的一部分责任从编译器手里拿回来了写错了就是硬 bug。如果不是极端性能敏感的函数优先用编译选项别让团队其他成员去猜你这段汇编的意图。4.2 收益边界不是所有浮点代码都能吃满红利VFMA 能省的是算术指令数不能改变内存读取模型。如果你的循环每次迭代都要等外部 Flash 或者低速 RAM 的数据可能真正卡你的是取指和访存而不是 FPU 流水线。这时候把代码写成 VFMA效果微弱。我把适合 VFMA 的场景归成几类复数乘法与共轭运算比如real*coef imag*coef2这类结构FIR/IIR 滤波器、相关运算、卷积计算矩阵乘法内层循环尤其是小尺寸、能留在寄存器里的矩阵FFT 蝶形运算中乘加交替的复数旋转因子计算卡尔曼滤波的协方差更新步骤虽然内存访问也不少但算术密度足够高。反过来字符串处理、外设驱动、状态机这类代码跟 VFMA 八竿子打不着不要为了优化而优化。4.3 不要忽视数值语义变化的影响融合乘加会改变舍入行为这在 99% 的工程里不是问题但有几个场景要特别谨慎你需要精确复现另一套平台的浮点结果比如老版本固件协议里有对浮点结果哈希或者按位比较或者你的算法依赖于 NaN 传播路径又或者你正在做严格符合 IEEE 754 标准的可复现计算。遇到这些场景要么全局保持严格浮点模式要么只对少数性能关键文件局部开启-ffp-contractfast再针对特定用例做一致性测试。我处理过的一个真实案例是设备端浮点结果和上位机参考结果最后几位对不上排查了一天发现就是编译器在某些文件里自动启用了 FMA 融合而上位机用的 MSVC 默认不融合。最后解决方案不是关掉优化而是在两边统一采用相同浮点编译策略并让算法设计人员接受 1 ulp 级别的差异。性能和一致性之间的取舍一定要提前摆到桌面上而不是等上线后才发现。5. 常见问题与避坑实录5.1 为什么-O2开了反汇编还是vmla最常见的原因是全局没开浮点收缩。-O2只代表整体优化强度不代表它愿意改变浮点舍入语义。这时候显式补-ffp-contractfast就行。另一个可能是你用的编译器版本太老它对 ARM 后端的 FMA 融合支持不完善这种情况我的建议是升级工具链或者退化到手工内联汇编作为过渡方案。5.2 VFMA 和 VMLA 数值差一点是不是 bug不是 bug。VMLA 其实也是把乘和加合并成一条指令执行但它内部对乘法结果做了舍入然后再做加法舍入VFMA 全程只舍入一次。所以同一个数据算下来两者结果大概率在后面几位上有差异而通常 VFMA 的误差更小。做嵌入式算法时看到这种差异先不要慌确认不是编译器把表达式整体结构改了再对比误差是否会积累到不可接受一般都能放心使用。5.3 全局开-ffast-math后控制环路开始出现奇怪抖动这个问题我见过不少。-ffast-math不是只开启 FMA它还包括把不关心 NaN/Inf 的假设、放宽浮点比较、甚至把0.0*Inf这类边界结果简化。控制类代码里的限幅、状态判断和涌值检测很容易受影响。我的建议是如果只是想用 FMA用-ffp-contractfast就好它影响的范围小得多实在要用 fast-math拆到独立编译单元只对性能核心文件开。5.4 双精度浮点能用 VFMA 吗要看内核。Cortex-M4F 的 FPU 是单精度 FPv4-SP它没有双精度硬件所以 C 代码里出现double会被编译器落到软浮点库根本轮不到 VFMA。Cortex-M7 如果芯片配置成 FPv5-D16 且支持双精度则vfma.f64是有可能生成的但要确认整个调用链没有发生双精度到单精度的隐式转换。还有一个更隐蔽的坑很多工程头文件里写着typedef double real_t;改到单精度芯片上所有矩阵运算瞬间变成软浮点性能断崖式下跌。如果你不是在做需要高动态范围的科学计算用float往往才是嵌入式浮点性能的正确打开方式。5.5 编译器生成了 VFMA但实测性能没提升如果反汇编里看到 VFMA 但实际测量几乎没有变化先检查性能瓶颈是否真的在算术上。可以人为把 VFMA 关闭再测一次如果两次周期差不多说明你的内存访问、分支跳转、函数调用开销才是大头。我习惯先用 DWT 分别测一段纯算术运算和一段带内存读取的循环把访存和计算分开量化这样能判断 FMA 到底有没有被“饿死”。还有一种情况是编译器本身已经很强非优化版本里它已经在用 NEON 或双发射流水并行处理留给 FMA 的边际收益本来就不大这种时候就不要死磕单条指令而是考虑整体算法结构和访存策略。最后分享一点个人体会。我做过的项目里真正从 VFMA 吃到红利最明显的不是一个看起来高深的矩阵求逆而是一段普普通通的音频 Biquad 滤波器。改动就是编译命令行里多了一行-ffp-contractfast业务代码一个字没改实测执行时间少了近两成。后来我就养成了一个习惯每次写完浮点密集代码不去花太多时间刷奇怪优化技巧先反汇编搜一遍有没有vfma和vmla没有就先把编译选项调对。这个习惯帮我在好几个项目里省下了本不该花的优化时间。你在自己的工程里也可以从这个小动作开始把 VFMA 融合乘加指令这件事彻底弄清楚。