化:讓編譯器生成VFMA融合乘加指令的調(diào)校指南)
寫嵌入式性能優(yōu)化這個系列到第十二篇我終于決定專門碰一下浮點指令這塊。平時交流群里經(jīng)常有人問我的代碼里明明寫著out a * b c跑在不同的帶 FPU 的 MCU 上反匯編卻看到先是vmul再是vadd或者干脆用vmla就是沒有等來那句vfma。這顆內(nèi)核硬件上明明支持融合乘加指令編譯器怎么就死活不肯用這一篇就圍繞 VFMA 融合乘加指令把編譯器調(diào)校這件事掰開揉碎講透。這里說的 VFMA在 ARM VFPv4/FPv5 這類硬件浮點單元上就是 Fused Multiply-Add單條指令完成Sd Sd Sn * Sm的乘、加兩步并且只在最終結(jié)果時做一次舍入。和同樣常見的 VMLA非融合乘加相比最大的差別不在助記符而在舍入行為。這個差異既是編譯器不愿下手的原因也是我們需要去主動配置、干預(yù)的入口。這篇文章適合正在調(diào)音頻算法、控制環(huán)路、傳感器融合或者其他浮點密集型代碼的嵌入式工程師確認(rèn)清楚這些機制比背多少條寄存器結(jié)構(gòu)都更管用。1. VFMA 到底是什么以及它為什么值得摳這一下1.1 一條指令干了兩步操作的活我們先看一條普通乘加在沒有任何優(yōu)化下被編譯成什么。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原來需要兩條指令的數(shù)據(jù)流現(xiàn)在變成一條。別小看這一條在循環(huán)體里如果反復(fù)執(zhí)行乘累加比如 FIR 濾波器、IIR 濾波器、矩陣乘法、復(fù)數(shù)運算每次迭代少一條浮點指令整體執(zhí)行時間肉眼可見往下降。而且融合后的數(shù)據(jù)依賴鏈也短了寄存器分配壓力會更小對編譯器做循環(huán)展開和軟件流水也友好很多。1.2 編譯器默認(rèn)不生成問題出在“舍入”上很多人不理解既然指令集支持CPU 硬件也有為什么我開-O2后它還是生成vmla或者兩條獨立指令問題不在優(yōu)化級別而在 C 語言本身的語義。普通乘加先算乘法把中間結(jié)果舍入成 float再算加法再次舍入。而 VFMA 把乘法的無窮精度結(jié)果直接拿來和加數(shù)相加只在最后舍入一次。也就是說融合之后的浮點結(jié)果可能和嚴(yán)格按源碼順序計算的結(jié)果差一點點。C 標(biāo)準(zhǔn)允許編譯器在默認(rèn)情況下做 contraction但要求它不改變可觀察行為而浮點運算的舍入結(jié)果就是可觀察行為的一部分。編譯器不敢擅自冒險所以很多工具鏈在嚴(yán)格浮點模式下寧可多生成兩條指令也不碰 VFMA。用個生活里的大白話解釋先四舍五入一次再拿結(jié)果相加后再四舍五入一次和直接把兩個原始數(shù)加完再四舍五入一次最終結(jié)果可能差一毛錢。這一毛錢在財務(wù)上叫誤差在浮點上叫 1 ulp。絕大多數(shù)項目根本不在乎這 1 ulp但編譯器默認(rèn)要按“絕對保守”來這就是我們需要通過編譯選項給它授權(quán)的根本原因。1.3 哪些內(nèi)核支持哪些內(nèi)核不用想VFMA 不是所有 ARM 內(nèi)核都有。我整理了一張常見內(nèi)核支持表方便你對照自己的項目內(nèi)核/架構(gòu)浮點單元是否支持 VFMA備注Cortex-M0/M0無不支持只能軟浮點聊 FMA 沒有意義Cortex-M3無不支持硬件無 FPUCortex-M4FFPv4-SP支持單精度 VFMA最常見的使用場景Cortex-M7FPv5支持單精度雙精度可選取決于芯片具體配置Cortex-M33/M55FPv5支持單精度雙精度可選注意內(nèi)核配置差異Cortex-R4F/R5FVFPv3不支持 FMAVFPv3 沒有融合乘加Cortex-R7F/R8FVFPv4支持部分 R 系列可用Cortex-A 系列VFPv4/NEON支持但匯編助記符體系更復(fù)雜從這張表能看出Cortex-M4F 和后續(xù)帶 FPU 的 M 系列才是 VFMA 的主戰(zhàn)場。Cortex-M7 如果要跑雙精度 VFMA需要芯片廠商把 FPU 配成 FPv5-D16 而不是 SP很多傳統(tǒng) MCU 只給單精度用 double 反而會被編譯器遷到軟浮點庫那就徹底和 VFMA 說再見了。1.4 影響范圍不光是 CPU 跑分VFMA 對實際項目的影響范圍可以很廣。舉幾個我接觸過的例子音頻處理里的 Biquad 濾波器、回聲消除的自適應(yīng)濾波、電機控制里的 FOC 算法、慣性導(dǎo)航的姿態(tài)解算、傳感器融合里的卡爾曼濾波核心矩陣乘法核心運算都是乘累加密集循環(huán)。這類代碼如果能在編譯階段把循環(huán)體的乘加融合成 VFMA整體運算量會明顯下降。反過來如果代碼本身就是內(nèi)存帶寬瓶頸瓶頸不在算術(shù)單元那么 FMA 能提供的幫助就很有限。判斷清楚自己的場景是哪些指令受限才能在優(yōu)化時有的放矢。2. 讓編譯器吐出 VFMA選項與代碼寫法2.1 先讓 FPU 真正參與-mfpu與-mfloat-abi要讓編譯器生成任何浮點硬件指令前提是你得把目標(biāo)內(nèi)核的浮點能力明確告訴編譯器。以 GCC 工具鏈為例最少要同時指定這幾項arm-none-eabi-gcc -mcpucortex-m4 -mfloat-abihard -mfpufpv4-sp-d16 -O2 -c fir.c -o fir.o-mcpucortex-m4告訴編譯器目標(biāo)內(nèi)核是 M4 而不是 M3這樣它才知道自身帶不帶 FPU-mfpufpv4-sp-d16指定浮點單元是單精度 VFPv4Cortex-M4F 的標(biāo)配-mfloat-abihard表示函數(shù)調(diào)用時浮點參數(shù)用 FPU 寄存器傳遞性能最好如果用的是 Cortex-M7 且芯片支持雙精度可以改成-mfpufpv5-d16。很多新手只寫-O2連-mfpu都不加編譯器自然默認(rèn)按軟浮點目標(biāo)處理反匯編里全都是對__aeabi_fmul這類軟浮點庫的調(diào)用指令數(shù)量翻了不止一倍。我建議在 Makefile 里把這三組參數(shù)固定寫清楚不要圖省事只依賴 IDE 的默認(rèn)值。注意事項如果用-mfloat-abisoftfp函數(shù)參數(shù)仍然通過通用寄存器傳遞但函數(shù)體內(nèi)允許使用 FPU 指令。這種方式在 RTOS 環(huán)境里能減少調(diào)用時的上下文壓力但它自身也有取舍這里不展開。如果你的項目跑了 RTOS啟用硬浮點后務(wù)必確認(rèn)任務(wù)切換代碼保存了 FPU 寄存器否則任務(wù)一切浮點現(xiàn)場就亂了。2.2 關(guān)鍵開關(guān)優(yōu)化級別與浮點收縮策略在 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這個選項的含義是只要目標(biāo)指令集支持編譯器就可以把乘法和加減法融合成 FMA 指令不必在乎結(jié)果和嚴(yán)格逐次要一致。它比-ffast-math溫和得多不會動 NaN、Inf 或者符號零等邊界語義。很多人一上來就上-ffast-math那是把核彈當(dāng)了開門鑰匙常常把控制類項目里依賴 IEEE 754 邊界行為的代碼搞壞。Clang/armclang 也接受-ffp-contractfast。在 Keil MDK 的 AC6 編譯器上我習(xí)慣在Options for Target - C/C - Misc Controls里直接加這一項然后優(yōu)化級別至少選-O2。同樣道理-O1也可能在局部生成 FMA但為了循環(huán)展開和指令調(diào)度效果更好建議直接-O2起步。有些開發(fā)者擔(dān)心-ffp-contractfast是不是屬于“非法優(yōu)化”。不是。它是符合大多數(shù)嵌入式項目需求的常規(guī)配置先把正確性測試用例跑過再看性能通常沒有任何問題。真正需要警惕的反而是-ffast-math這種會給比較運算和特殊值處理引入風(fēng)險的選項。2.3 Keil MDK/AC6 與 IAR 下的對應(yīng)設(shè)置如果用的是 Keil MDK且編譯器是 AC5armcc它的浮點收縮開關(guān)不如 GCC 那么直接。老項目用 AC5 時我建議盡早遷移到 AC6原因之一就是 armclang 的浮點優(yōu)化選項更透明也更接近新工具鏈的主流行為。AC6 下具體路徑在Options for Target - Target里把Floating Point Hardware選成Single Precision或Double Precision在C/C頁的Optimization里選-O2或-O3在Misc Controls里補一行-ffp-contractfast。IAR 的情況稍微特殊。IAR 的優(yōu)化策略通常綁定在工程級別路徑在Project - Options - C/C Compiler - Optimization想拿到自動 FMA 融合優(yōu)化級別至少要選 High 或 Balanced并在浮點設(shè)置里確認(rèn)啟用硬件 FPU。部分新版本 IAR 支持通過命令行或預(yù)編譯宏控制浮點收縮但菜單名稱隨版本有差異。如果你用的是 IAR我最常用的驗證手段就是直接在反匯編窗口搜vfma搜到了就說明編譯器知道了搜不到再回頭查優(yōu)化級別和 FPU 配置。2.4 代碼要怎樣寫編譯器才敢大膽融合除了編譯選項源碼寫法也會影響 FMA 是否能生成。下面幾點是我在實踐中反復(fù)驗證過的經(jīng)驗。盡量少用跨函數(shù)的表達式。如果乘加運算被拆到多個小函數(shù)里每個函數(shù)返回一個中間結(jié)果編譯器在調(diào)用邊界上很難做跨越函數(shù)調(diào)用邊界的 FMA 融合。把核心運算寫在內(nèi)層循環(huán)里或者放到static inline函數(shù)中合體機會大很多。盡量避免指針別名摻和進來。C 語言里編譯器要保證通過指針寫入不會影響其他變量的值如果它無法判斷兩個指針是否指向同一塊內(nèi)存就可能不敢把讀取和計算重排。寫濾波器時我習(xí)慣先把濾波器系數(shù)和狀態(tài)量讀進局部變量循環(huán)處理完再寫回結(jié)構(gòu)體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變量會讓編譯器每次都從內(nèi)存加載結(jié)果等于明說“這里別做優(yōu)化”FMA 自然不可能出現(xiàn)。調(diào)試結(jié)束后檢查一下是否把臨時用的volatile留在了正式代碼里。3. 實測反匯編確認(rèn)與性能驗證3.1 用 objdump 快速確認(rèn) VFMA選項配完之后第一件事不是跑分而是先看匯編。用 arm-none-eabi 工具鏈的話一條命令搞定arm-none-eabi-objdump -d build/Project.elf | sed -n /biquad_process/,/^$/p重點搜vfma和vmla兩個助記符??吹絭fma.f32就是融合成功看到大量vmla.f32說明編譯器做了乘加合并但沒做浮點收縮看到vmul.f32vadd.f32連續(xù)出現(xiàn)說明連最基本的乘加合并都沒做優(yōu)先回去查優(yōu)化級別和浮點選項。Keil 用戶可以在工程生成后打開反匯編窗口或者在Output里勾選生成 Listing 文件直接看.lst里的匯編。IAR 的反匯編窗口同樣可以直接搜索VFMA。我?guī)缀趺看胃耐旮↑c相關(guān)代碼都會順手搜一遍這個動作養(yǎng)成了習(xí)慣之后能避免很多“我以為優(yōu)化了其實沒優(yōu)化”的尷尬。3.2 一個 FIR 循環(huán)的實戰(zhàn)對比拿最典型的 FIR 濾波器循環(huán)舉例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 目標(biāo)上對照組用-O2但不加-ffp-contractfast反匯編里循環(huán)體經(jīng)常是vmla.f32或者更保守的vmul.f32加vadd.f32。加上-ffp-contractfast后acc coef[i] * history[i]被融合成vfma.f32 s0, s1, s2實際項目里我用 DWT 周期計數(shù)器測過一段 256 階 FIR純套循環(huán)、不做展開的情況下融合后執(zhí)行周期比非融合少了兩成左右。如果再把循環(huán)展開和流水調(diào)度加上收益會更明顯。當(dāng)然具體比例受內(nèi)存等待、編譯器版本、循環(huán)長度影響很大不要把我的數(shù)字當(dāng)絕對的 benchmark但方向是穩(wěn)定的算術(shù)密集的乘加循環(huán)VFMA 帶來的收益不會讓你失望。3.3 沒看到 VFMA按這個順序排查我在群里看見最多的問題是“我明明開了強優(yōu)化怎么還沒有 VFMA”。我一般讓他們按這個表格自查現(xiàn)象可能原因?qū)Σ叻磪R編全是對__aeabi_fmul等庫函數(shù)調(diào)用沒有開啟硬件浮點檢查-mfpu與-mfloat-abi循環(huán)體還是vmulvadd兩條浮點收縮沒打開顯式加-ffp-contractfast已經(jīng)局部有vmla但沒有vfma編譯器在嚴(yán)格遵守 C 語言舍入語義確認(rèn)是否把fast寫成了on或確認(rèn)代碼里有沒有volatile干擾代碼全是double運算目標(biāo) FPU 不配對雙精度改用float或用支持 FPv5-D16 的內(nèi)核重編循環(huán)被優(yōu)化沒了什么都沒看到結(jié)果沒用死代碼消除把最終結(jié)果寫到volatile全局變量或調(diào)外部函數(shù)有一次同事調(diào)了整整一上午沒找出問題結(jié)果只是 Makefile 里同時出現(xiàn)了-mfpufpv4-sp-d16和-mfpufpv5-d16后面一個把前面覆蓋掉而目標(biāo)芯片又不支持后者編譯器悄悄退回了軟浮點。所以排查選項時一定要看真正的命令行最終生效值別只看 makefile 里寫了什么。3.4 用 DWT 周期計數(shù)器做科學(xué)驗證確認(rèn)匯編正確之后再用實際運行時間佐證。Cortex-M 自帶的 DWT 循環(huán)計數(shù)器是性價比最高的測時工具代碼很短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;注意三點一是把被測函數(shù)多跑幾輪避免罕見的 Cache miss 或中斷造成單次測量抖動二是用volatile sink保存結(jié)果防止編譯器把整個循環(huán)優(yōu)化掉三是在目標(biāo)系統(tǒng)不開中斷或關(guān)中斷時測量否則周期數(shù)會被中斷污染。實測多次取最小值比取平均值更接近理論性能。4. 進階手工內(nèi)聯(lián)匯編與真實收益邊界4.1 手寫一條 VFMAGCC 內(nèi)聯(lián)匯編示例多數(shù)情況下編譯選項就夠用但有些人喜歡極致控制或者編譯器在某個特殊場景下就是不肯融合那可以考慮手工內(nèi)聯(lián)匯編。以 GCC/armclang 風(fēng)格為例一個簡單的 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。我把調(diào)用處寫得很簡單float process(float x, float y, float z) { return vfma_f32(x, y, z); }反匯編通常能看到清晰的vfma.f32。但我要提醒一句內(nèi)聯(lián)匯編是最后手段它把指令調(diào)度、寄存器分配的一部分責(zé)任從編譯器手里拿回來了寫錯了就是硬 bug。如果不是極端性能敏感的函數(shù)優(yōu)先用編譯選項別讓團隊其他成員去猜你這段匯編的意圖。4.2 收益邊界不是所有浮點代碼都能吃滿紅利VFMA 能省的是算術(shù)指令數(shù)不能改變內(nèi)存讀取模型。如果你的循環(huán)每次迭代都要等外部 Flash 或者低速 RAM 的數(shù)據(jù)可能真正卡你的是取指和訪存而不是 FPU 流水線。這時候把代碼寫成 VFMA效果微弱。我把適合 VFMA 的場景歸成幾類復(fù)數(shù)乘法與共軛運算比如real*coef imag*coef2這類結(jié)構(gòu)FIR/IIR 濾波器、相關(guān)運算、卷積計算矩陣乘法內(nèi)層循環(huán)尤其是小尺寸、能留在寄存器里的矩陣FFT 蝶形運算中乘加交替的復(fù)數(shù)旋轉(zhuǎn)因子計算卡爾曼濾波的協(xié)方差更新步驟雖然內(nèi)存訪問也不少但算術(shù)密度足夠高。反過來字符串處理、外設(shè)驅(qū)動、狀態(tài)機這類代碼跟 VFMA 八竿子打不著不要為了優(yōu)化而優(yōu)化。4.3 不要忽視數(shù)值語義變化的影響融合乘加會改變舍入行為這在 99% 的工程里不是問題但有幾個場景要特別謹(jǐn)慎你需要精確復(fù)現(xiàn)另一套平臺的浮點結(jié)果比如老版本固件協(xié)議里有對浮點結(jié)果哈?;蛘甙次槐容^或者你的算法依賴于 NaN 傳播路徑又或者你正在做嚴(yán)格符合 IEEE 754 標(biāo)準(zhǔn)的可復(fù)現(xiàn)計算。遇到這些場景要么全局保持嚴(yán)格浮點模式要么只對少數(shù)性能關(guān)鍵文件局部開啟-ffp-contractfast再針對特定用例做一致性測試。我處理過的一個真實案例是設(shè)備端浮點結(jié)果和上位機參考結(jié)果最后幾位對不上排查了一天發(fā)現(xiàn)就是編譯器在某些文件里自動啟用了 FMA 融合而上位機用的 MSVC 默認(rèn)不融合。最后解決方案不是關(guān)掉優(yōu)化而是在兩邊統(tǒng)一采用相同浮點編譯策略并讓算法設(shè)計人員接受 1 ulp 級別的差異。性能和一致性之間的取舍一定要提前擺到桌面上而不是等上線后才發(fā)現(xiàn)。5. 常見問題與避坑實錄5.1 為什么-O2開了反匯編還是vmla最常見的原因是全局沒開浮點收縮。-O2只代表整體優(yōu)化強度不代表它愿意改變浮點舍入語義。這時候顯式補-ffp-contractfast就行。另一個可能是你用的編譯器版本太老它對 ARM 后端的 FMA 融合支持不完善這種情況我的建議是升級工具鏈或者退化到手工內(nèi)聯(lián)匯編作為過渡方案。5.2 VFMA 和 VMLA 數(shù)值差一點是不是 bug不是 bug。VMLA 其實也是把乘和加合并成一條指令執(zhí)行但它內(nèi)部對乘法結(jié)果做了舍入然后再做加法舍入VFMA 全程只舍入一次。所以同一個數(shù)據(jù)算下來兩者結(jié)果大概率在后面幾位上有差異而通常 VFMA 的誤差更小。做嵌入式算法時看到這種差異先不要慌確認(rèn)不是編譯器把表達式整體結(jié)構(gòu)改了再對比誤差是否會積累到不可接受一般都能放心使用。5.3 全局開-ffast-math后控制環(huán)路開始出現(xiàn)奇怪抖動這個問題我見過不少。-ffast-math不是只開啟 FMA它還包括把不關(guān)心 NaN/Inf 的假設(shè)、放寬浮點比較、甚至把0.0*Inf這類邊界結(jié)果簡化??刂祁惔a里的限幅、狀態(tài)判斷和涌值檢測很容易受影響。我的建議是如果只是想用 FMA用-ffp-contractfast就好它影響的范圍小得多實在要用 fast-math拆到獨立編譯單元只對性能核心文件開。5.4 雙精度浮點能用 VFMA 嗎要看內(nèi)核。Cortex-M4F 的 FPU 是單精度 FPv4-SP它沒有雙精度硬件所以 C 代碼里出現(xiàn)double會被編譯器落到軟浮點庫根本輪不到 VFMA。Cortex-M7 如果芯片配置成 FPv5-D16 且支持雙精度則vfma.f64是有可能生成的但要確認(rèn)整個調(diào)用鏈沒有發(fā)生雙精度到單精度的隱式轉(zhuǎn)換。還有一個更隱蔽的坑很多工程頭文件里寫著typedef double real_t;改到單精度芯片上所有矩陣運算瞬間變成軟浮點性能斷崖式下跌。如果你不是在做需要高動態(tài)范圍的科學(xué)計算用float往往才是嵌入式浮點性能的正確打開方式。5.5 編譯器生成了 VFMA但實測性能沒提升如果反匯編里看到 VFMA 但實際測量幾乎沒有變化先檢查性能瓶頸是否真的在算術(shù)上??梢匀藶榘?VFMA 關(guān)閉再測一次如果兩次周期差不多說明你的內(nèi)存訪問、分支跳轉(zhuǎn)、函數(shù)調(diào)用開銷才是大頭。我習(xí)慣先用 DWT 分別測一段純算術(shù)運算和一段帶內(nèi)存讀取的循環(huán)把訪存和計算分開量化這樣能判斷 FMA 到底有沒有被“餓死”。還有一種情況是編譯器本身已經(jīng)很強非優(yōu)化版本里它已經(jīng)在用 NEON 或雙發(fā)射流水并行處理留給 FMA 的邊際收益本來就不大這種時候就不要死磕單條指令而是考慮整體算法結(jié)構(gòu)和訪存策略。最后分享一點個人體會。我做過的項目里真正從 VFMA 吃到紅利最明顯的不是一個看起來高深的矩陣求逆而是一段普普通通的音頻 Biquad 濾波器。改動就是編譯命令行里多了一行-ffp-contractfast業(yè)務(wù)代碼一個字沒改實測執(zhí)行時間少了近兩成。后來我就養(yǎng)成了一個習(xí)慣每次寫完浮點密集代碼不去花太多時間刷奇怪優(yōu)化技巧先反匯編搜一遍有沒有vfma和vmla沒有就先把編譯選項調(diào)對。這個習(xí)慣幫我在好幾個項目里省下了本不該花的優(yōu)化時間。你在自己的工程里也可以從這個小動作開始把 VFMA 融合乘加指令這件事徹底弄清楚。