十年匠心定制 · 商业建站与技术教学双线并行 咨询热线:400-886-1026 service@lmnt.cn
ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

STM32嵌入式浮点性能优化:编译器VFMA融合乘加指令实战解析

STM32嵌入式浮点性能优化:编译器VFMA融合乘加指令实战解析 上周调一块 STM32H743 板子性能分析器显示一个 64 阶 FIR 滤波函数吃掉了将近 30% 的 CPU。我打开反汇编窗口往下翻满眼都是成对的VMUL.F32和VADD.F32。当时第一反应是这个函数明明就是acc coeff[i] * data[i]一路累加编译器为什么不用一条融合乘加指令把这俩事一起干了后来沿着编译选项、浮点语义、工具链默认行为一路查下去才把问题彻底理顺。这篇文章把整个链条拆开讲清楚涉及编译器如何生成 VFMA 融合乘加指令、AC5/AC6/GCC 各怎么配、为什么有时候配了还是不出 VFMA以及实测数据到底能省多少。适合正在抠嵌入式浮点性能的朋友尤其是用 Keil MDK 或者 arm-none-eabi-gcc 工具链的。1. 从一对浪费的指令讲起VFMA 到底在解决什么问题1.1 反汇编里的“二合一”机会先看一个最常见的浮点计算模式float acc 0.0f; for (int i 0; i n; i) { acc coeff[i] * data[i]; }acc coeff[i] * data[i]本质上是一次乘法加一次加法。如果你用 Cortex-M4F 或者 Cortex-M7 这类带 FPU 的内核而且编译选项没有专门针对浮点融合做设置反汇编出来的指令序列大概率长这样vmul.f32 s0, s1, s2 vadd.f32 s0, s0, s3这里s1是系数s2是采样点s3是累加器acc。乘法算完放进s0再执行加法。两条指令完成一个乘加似乎没什么问题但实际上目标芯片的 FPU 里有一条专门干这件事的指令vfma.f32 s0, s1, s2一个VFMA.F32就能完成s0 s0 s1 * s2的完整语义寄存器少占一个指令条数减半。累加器直接作为目标寄存器既不需要额外的暂存也不需要额外的数据搬移。1.2 VFMA 和 VMLA、VMULVADD 的区别ARM 文档里把这类指令统称为 Fused Multiply-Add融合乘加。Cortex-M4F 和之后出现的带 FPU 的 M 系列内核在 FPU 指令集里同时提供了VMLA、VMUL、VADD、VFMA、VFMS、VFNMA、VFNMS等一堆指令。很多初学者容易把VMLA和VFMA搞混实际上这是两码事VMLA.F32 Sd, Sn, Sm执行Sd Sd Sn * Sm但乘法结果先按单精度舍入一次再做加法加法结果再舍入一次也就是两次舍入。VFMA.F32 Sd, Sn, Sm执行同样的运算但乘法结果保持完整精度和累加器相加后只做一次舍入。VMUL.F32VADD.F32的组合自然就是两次舍入而且需要额外寄存器周转。所以VFMA不光是省了一条指令的问题它连舍入次数都变了。这一点放到第 2 节详细说因为它直接关系到“优化会不会引入精度损失”这个敏感话题。1.3 哪些内核支持 VFMA不是所有内核都支持VFMA。这个判断很重要否则你在不支持的内核上折腾半天编译器怎么都不会吐出一条VFMACortex-M0 / M0 / M3没有硬件 FPU浮点运算靠软件库VFMA无从谈起。Cortex-M4F支持单精度 FPU支持VFMA.F32。Cortex-M7支持单精度和双精度 FPU支持VFMA.F32和VFMA.F64。Cortex-M33 / M55 / M85ARMv8-M Mainline支持单精度可配双精度同样支持VFMA。Cortex-A 系列处理器里的 VFPv4 / NEON 也有对应的 FMA 指令原理类似但编译选项和寄存器名不同。如果你用的是 STM32F4 系列默认的内核是 Cortex-M4F支持VFMA.F32。如果你用 STM32H7 系列Cortex-M7 不仅支持单精度双精度VFMA.F64也能用。我自己实测的场景是 STM32H743双精度浮点性能原本就比 F4 强不少但真正把 FIR 循环吃 CPU 的比例压下来还是靠编译器把乘加对换成VFMA之后达成的。2. 原理拆解一次舍入和指令流水带来的双重收益2.1 为什么说“数学等价但二进制不等价”先看一个比较反直觉的结论a*b c用VMUL VADD计算和用VFMA计算数学上完全等价但二进制结果可能不一样。原因是中间结果的舍入次数不同。嵌入式浮点基本都是 IEEE 754 单精度。a*b的结果如果无法精确表示硬件要先把它舍入到 23 位尾数然后 c又一次舍入总共两次舍入。VFMA则不一样它把乘法的完整中间结果不被舍入直接加上c最后只做一次舍入。一次舍入的误差当然小于等于两次舍入累积的误差。我举个例子说明。假设a 1.0000001fb 1.0000001fc -0.9999999f。用普通乘法先算a*b单精度下这个值并不精确要先舍入一次再和c相加后再舍入一次用VFMA则整个计算只在最后舍入一次。在某些特定数值组合下两者最后一位会有差异而且VFMA的结果通常更接近真实数学结果。这就打破了很多人“性能优化必然牺牲精度”的思维定式。VFMA是少见的提升性能的同时还提升数值质量的优化手段。2.2 指令条数减半流水线压力大不一样再回到性能。Cortex-M7 这类带浮点流水线的内核单精度VMUL、VADD、VFMA的单条指令周期数其实差不多都在几个周期量级。关键不在于单条指令快多少而在于指令总数和寄存器依赖关系。看VMUL VADD的写法vmul.f32 s0, s1, s2 vadd.f32 s0, s0, s3第一条指令的结果s0是第二条指令的输入硬件浮点流水线必须等VMUL写回之后才能开始VADD中间有明确的依赖等待。换成VFMAvfma.f32 s0, s1, s2乘法结果直接进入加法阶段整条指令在浮点流水线里是一体化处理的不用等两条独立指令之间的写回和唤醒。对 FIR、矩阵乘法、PID 运算这种连续乘加型计算循环体里原本的乘加对直接变成一条指令指令数少一半寄存器压力也小很多。在我那 64 阶 FIR 的例子里开启 VFMA 之后整个循环体的汇编指令数量肉眼可见地缩小采样循环的周期数也明显下降。后面第 4 节有具体数据。2.3 那编译器为什么不默认开既然VFMA又快又准编译器为什么默认不生成核心原因在于 C 语言标准对浮点表达式舍入语义的保护。C 标准允许编译器做“融合乘加”优化的前提是必须实现#pragma STDC FP_CONTRACT而且程序员可以关闭它。标准里还留了一句话如果优化改变了程序的可观察行为那编译器就不该做。很多嵌入式编译器在默认配置下走保守路线不主动融合乘加避免因为结果差几个 ULP 引发用户的兼容性投诉。GCC 在严格的 ISO C 模式比如-stdc99、-stdc11下可能把FP_CONTRACT视为关闭状态而 Keil 的 AC5 老编译器在默认浮点模式下也不会激进地重排浮点运算。所以指望编译器自己悟出来是不可靠的必须在编译选项层面明确告诉它允许你做浮点收缩。3. 编译选项与代码写法AC5、AC6、GCC 的一份可抄配置3.1 先确认 FPU 型号配置之前先搞清楚目标内核的 FPU 类型。这一项搞错后面可能连硬浮点指令都生成不了Cortex-M4F通常用 FPv4-SP-mfpufpv4-sp-d16Cortex-M7通常用 FPv5-D16-mfpufpv5-d16Cortex-M33FPv5-SP-mfpufpv5-sp-d16Keil MDK 里的图形化界面通常在下拉菜单选GCC 则在-mfpu参数里指定。以我常用的arm-none-eabi-gcc为例完整的一套 CFLAGS 大概长这样arm-none-eabi-gcc -mcpucortex-m7 -mthumb -mfpufpv5-d16 -mfloat-abihard \ -O3 -ffp-contractfast -ffast-math \ -Wall -Wextra -c fir.c -o fir.o-mfloat-abihard表示浮点参数用 FPU 寄存器传递-mfpufpv5-d16指定 FPU 类型。这两个参数错误编译器可能退回软件浮点一条VFMA都别想看到。-mfloat-abisoftfp则允许使用硬件浮点指令但参数传递走通用寄存器性能会打折。3.2 GCC 的关键参数-ffp-contract 的三个值GCC 的-ffp-contract参数有三个值很多人只记得fast实际上区别挺大参数行为适用场景-ffp-contractoff禁止任何融合乘加需要严格 IEEE 舍入行为或者做逐位一致性测试-ffp-contracton允许在同一个表达式语句内收缩默认情况下满足语言标准对FP_CONTRACT的要求-ffp-contractfast允许跨语句收缩嵌入式性能优化最常用的选择实际的坑在于GCC 在-stdc99、-stdc11这类严格 ISO 模式下对浮点收缩的默认态度会收敛很多。即使你在代码里写了acc a[i] * b[i]编译器也可能不融合。我的习惯是不管默认值是什么直接显式写上-ffp-contractfast这样在不同的-std设置下行为才一致。顺带说一句-ffast-math这个大礼包里已经包含了-ffp-contractfast但同时还会开启-ffinite-math-only、-fno-signed-zeros等一堆激进优化。如果项目里有 NaN 和无穷大的业务判断慎用-ffast-math单独加-ffp-contractfast更可控。3.3 Keil AC5armcc老编译器的玩法老项目还在用 AC5 的编译选项和 GCC 完全不同。AC5 用--fpu指定 FPU用--fpmode控制浮点语义armcc --cpu Cortex-M7 --fpu fpv5-d16 -O3 --fpmodefast--fpmodefast会允许编译器执行一些宽松的浮点优化包括融合乘加。需要提醒的是这个参数和 GCC 的-ffast-math有些类似它不只是控制FP_CONTRACT还涉及无穷大、NaN 等处理方式。如果你不想全局放开可以只对包含 DSP 算法的那个 C 文件单独加这个选项其他文件保持默认。AC5 还有一个特点它对VFMA的生成积极性比 AC6 要弱一些。如果你打开反汇编发现 AC5 即便加了--fpmodefast仍然在关键循环里吐出VMUL VADD可能需要检查代码写法是不是把乘法和加法拆到了不同的语句里float t coeff[i] * data[i]; acc t;这种写法在--fpmodefast下 AC5 有时候也会融合但在更老的编译器版本上不一定。所以代码层面尽量写成acc coeff[i] * data[i];这种单表达式形式给编译器更大的融合空间。3.4 Keil AC6armclang的配置AC6 底子是 armclang浮点收缩的默认策略比 AC5 积极。但为了让跨环境行为一致我还是会显式加上-O3 -ffp-contractfast在 Keil MDK 的 Options for Target → C/C → Misc Controls 里直接填-ffp-contractfast就行。AC6 常用的其他选项还包括-mcpucortex-m7 -mfpufpv5-d16 -mfloat-abihard这些在 Keil 图形界面里通常已经在 Target 标签页选好了。我的建议是无论 AC5 还是 AC6都不要只靠界面下拉框要习惯把关键编译参数显式写死。项目交接时后面接手的人才能一眼看出优化是怎么开的。3.5 代码层面给编译器的三条体面出路编译选项正确的前提下代码写法仍然会影响 VFMA 生成。我这里列三条最常见的经验第一把乘加写成单一表达式用自然累加形式。acc a * b;优于先temp a * b;再acc temp;。虽然理论上编译器可能把后者优化成前者但语义越清晰越不容易被其他因素干扰。第二避免在乘加之间插入 volatile 变量。如果acc被声明为volatile float编译器每次更新都要严格写回内存VFMA的“融合”会被视为违反语义因为中间结果没有单独写回。第三如果确实需要强制融合可以使用 C 标准库函数fmaf()#include math.h float r fmaf(a, b, c);fmaf在硬件支持VFMA的内核上通常直接映射成一条指令。即便你在某些情况下关闭了-ffp-contractfmaf也能保证生成融合指令。不过我一般只在少量关键计算处用fmaf整个工程全面铺开还是靠编译选项更省心。4. 实测对比在 STM32H743 上把 FIR 循环缩短 40%4.1 测试代码和硬件条件我用一块 STM32H743 板子做验证主频 480MHzCortex-M7 内核单精度 FPU。测试代码是一个 64 阶 FIR 滤波器函数长这样// fir.c #include stdint.h #define FIR_TAPS 64 float fir_filter(const float *coeff, const float *data) { float acc 0.0f; for (int i 0; i FIR_TAPS; i) { acc coeff[i] * data[i]; } return acc; }测试主函数里把coeff和data填成固定值循环调用 10000 次用 DWT 周期计数器统计总周期数。4.2 测量周期的方法Cortex-M7 里有 DWTData Watchpoint and Trace单元其中DWT-CYCCNT是一个周期计数器常用于性能测量。启用方法比较简单#include core_cm7.h static void dwt_init(void) { CoreDebug-DEMCR | CoreDebug_DEMCR_TRCENA_Msk; DWT-CYCCNT 0; DWT-CTRL | DWT_CTRL_CYCCNTENA_Msk; } uint32_t t0 DWT-CYCCNT; volatile float result fir_filter(coeff, data); uint32_t cycles DWT-CYCCNT - t0;注意几件事测量前关掉中断避免周期计数被打断函数第一遍调用最好做一次暖机让指令缓存和数据缓存进入稳定状态把返回值赋给一个volatile变量防止编译器把整个循环优化掉。4.3 开启与关闭的对比数据我在两种编译参数下分别测试# 关闭融合 arm-none-eabi-gcc -mcpucortex-m7 -mthumb -mfpufpv5-d16 -mfloat-abihard \ -O3 -ffp-contractoff -c fir.c -o fir_off.o # 开启融合 arm-none-eabi-gcc -mcpucortex-m7 -mthumb -mfpufpv5-d16 -mfloat-abihard \ -O3 -ffp-contractfast -c fir.c -o fir_fast.o测试结果如下编译配置10000 次循环总周期单次平均周期相对性能-ffp-contractoff9,420,0009421.00x-ffp-contractfast5,650,0005651.67x在我的测试环境下开启融合乘加之后性能提升约 40% 到 67%具体取决于循环里 load 指令和索引计算的占比。如果把数组数据放到 SRAM并且系数数组对齐编译器还可以配合LDR双发射把循环体压得更紧凑。4.4 为什么不是 100% 的提升有人可能会问乘加从两条指令变成一条时间不是应该减半吗实际达不到原因有几点第一循环里除了乘加还有 load 指令。coeff[i]和data[i]都要从内存加载这部分指令没有减少。第二浮点流水线本身的吞吐上限有限VFMA虽然融合了但指令延迟还是存在循环体里的依赖链可能成为瓶颈。第三Cortex-M7 的双发射能力有限load 和浮点指令之间的并行程度取决于具体代码。所以 40% 到 60% 的收益属于比较典型的水平。如果你在 Cortex-M4F 上测由于内核没有 M7 那么强的流水线收益可能略低但趋势一致。5. 反汇编找不到 VFMA从现象到根因的完整排查5.1 现象我明明加了参数还是看到 VMUL VADD这是最让人恼火的情况。编译选项写了-O3 -ffp-contractfast目标内核也支持VFMA但打开反汇编一看关键循环里还是VMULVADD成对出现。下结论之前先按顺序检查。5.2 排查链路第一步FPU 选项到底生效没有先用命令行编译一次然后用readelf或者objdump检查文件属性arm-none-eabi-readelf -A fir.o输出里会列出 Tag_FP_arch 等属性能看到是不是FPv5-D16。如果这里显示的是VFPv2或者根本没有 FPU 属性说明-mfpu参数没生效或者被后面的参数覆盖了。Makefile 里如果CFLAGS和CPPFLAGS同时出现编译命令的展开顺序可能导致某个-mfpu被后一个覆盖。5.3 排查链路第二步优化等级过低-O0或者-Og下编译器基本不做指令选择优化也几乎不会生成VFMA。至少要-O2推荐-O3。如果你的 Makefile 的 debug 版本用-O0release 版本用-O3那确认一下测的是哪个版本。这里还有一个容易忽略的点GCC 的-O3和 Keil 的-O3语义不同。GCC 的-O3比-O2多了一些自动向量化选项Keil/armcc 的-O3是“高优化且偏向执行速度”对应的还有-O3 -Otime。我在 AC5 上一般直接用-O3 -Otime把速度取向明确告诉编译器。5.4 排查链路第三步严格 C 标准悄悄关掉了 FP_CONTRACT这一步最隐蔽。很多工程为了可移植性会加上-stdc11或者-stdc99。在这种严格 ISO C 模式下GCC 对浮点收缩的态度是保守的。我遇到过的实际案例是Makefile 里CFLAGS包含-O3 -ffp-contractfast但后面又跟着-stdc11当时觉得没问题结果反汇编里就是没有VFMA。后来我把编译参数顺序调整改成-stdc11 -O3 -ffp-contractfast并且在命令行用-dM -E查看宏定义确认FP_FAST_FMA以及相关宏的状态才定位到是参数顺序和标准模式配合的问题。经验是-ffp-contractfast要放在-std参数之后并且最好用编译器的-S输出汇编文件直接确认。5.5 排查链路第四步常数折叠把指令优化掉了还有一种“找不到 VFMA”其实是好事编译器把乘加直接算成了常数。比如测试代码里coeff和data都是常量初始化且编译器能通过内联看到它们的值那acc 2.0f * 3.0f会直接被折叠成acc 6.0f根本不需要运行时指令。看反汇编时不要只搜vfma要仔细观察vldr后面的立即数是不是已经是结果了。这种情况不是问题但会让初次排查的人误以为优化没生效。5.6 排查链路第五步连接器或汇编文件里混入了非 FPU 代码还有一种情况是C 文件编译成了fir.o里面确实有VFMA但在最终链接生成的 ELF 里调用方函数没有内联它或者链接器做了某种重排导致你搜索整个固件时只看到个别VFMA。这时候要确认调用关系。如果fir_filter没有被内联每次调用都会有调用开销性能分析器看到的可能不只是循环本身。5.7 快速检查清单把上面的排查整理成一张表格下次直接对着看检查项方法失败表现内核和 FPU 匹配readelf -A查看 Tag_FP_archFPU 类型不符或缺失浮点 ABI确认-mfloat-abihard编译报错或回退软浮点优化等级至少-O2推荐-O3反汇编指令原始FP_CONTRACT 开启显式加-ffp-contractfast搜索不到 VFMA严格标准模式检查-std是否影响默认收缩编译器保守常数折叠反汇编看立即数没有运行时乘加指令函数内联检查汇编是否包含完整循环体调用开销掩盖收益6. 使用边界什么时候该开、什么时候该关6.1 值得开 VFMA 的场景凡是浮点乘加密集的地方都值得开。常见三类第一数字信号处理包括 FIR、IIR、FFT 蝶形运算、互相关计算。这类算法的核心就是一连串乘加累加VFMA的收益最明显。第二电机控制、电源控制里的 PID 控制器。增量式 PID 输出本身就是一个乘加链而且对实时性要求高。第三矩阵运算尤其是 3x3 或者 4x4 的旋转矩阵、姿态解算。这些运算同样充满a*bc模式。我在之前一个电机控制项目里速度环和电流环的控制频率从 10kHz 提到 16kHz主要就是靠把控制器里几十个乘加对变成融合指令省下来的周期。6.2 必须关掉的场景有一类场景必须关需要和外部系统做逐位一致的浮点比较。比如你的固件计算结果要传给上位机上位机用标准 C 库在 x86 上算同样公式如果两边舍入行为不同就可能导致最后几位不一致进而触发某些校验失败。再比如自动化测试里测试脚本用 Python 的numpy或 MATLAB 做基准值你希望嵌入式端的每个中间结果都和基准完全一致。这种情况下VFMA的“更精确”反而是麻烦因为它和别的平台的一次舍入语义不一致。如果遇到这种场景把-ffp-contractoff打开或者只在测试构建里关闭稳定复现问题后再恢复。6.3 和 -ffast-math 的关系务必谨慎这里要单独强调一下-ffast-math。它包含-ffp-contractfast但绝不等价。-ffast-math还会开启-ffinite-math-only意思是不管 NaN 和无穷大编译器默认输入输出都是有限数。如果你的代码里有isnan()、isinf()检查或者依赖 IEEE 754 的 NaN 传播做状态判断-ffast-math会直接破坏这些假设而且很多时候编译器优化掉之后你毫无感知。我见过一个工程因为全局开了-ffast-math导致异常数据产生的 NaN 没有按预期传播固件在错误状态里继续算了好几个控制周期才触发了别的保护。排查起来非常痛苦。所以我的建议是优先只加-ffp-contractfast不要为了一点浮点优化直接上-ffast-math。如果确实需要-ffast-math那些激进优化限定在经过评审的 DSP 算法模块里通过文件级编译选项单独控制。6.4 工程规范上的一个小建议最后说点工程层面的体会。VFMA这类优化不要把开关散落在各个源文件里。最好在 Makefile 或者 CMake 里集中管理并且把“允许融合乘加”作为一个显式的编译配置项target_compile_definitions(firmware PRIVATE USE_FMA_OPT1) target_compile_options(firmware PRIVATE -ffp-contractfast)代码里也可以配合宏做策略控制#if USE_FMA_OPT #define FMUL_ADD(a, b, c) fmaf((a), (b), (c)) #else #define FMUL_ADD(a, b, c) (((a) * (b)) (c)) #endif这种做法在写算法原型的阶段尤其有用可以快速对比融合和非融合两种模式之间的计算差异而不是反复改编译选项重编整个工程。从我个人经历来看嵌入式浮点性能优化先看指令选择再看算法复杂度这两步往往比把 MCU 主频抬高一倍还管用。VFMA算是指令选择里性价比最高的一项因为改动极小、收益明确而且还能顺手提升数值精度。但整个工程是否启用仍然要根据你的数值一致性要求和平台兼容性来权衡不能一刀切。
返回列表