最近拿到一块ELF2学习板,主控是瑞芯微的RK3588。这块芯片最吸引我的地方,是它身上有4个Cortex-A76大核和4个Cortex-A55小核,算力底子摆在那里。我趁着周末把OpenMP和FFTW在这块板上完整调了一遍,目标很明确:把FFT的并行计算效率尽量榨干,尤其是处理图像滤波、频谱分析和批量信号变换这类任务时,多核加速的效果到底能到什么程度。如果你也在RK3588或类似ARM多核平台上做信号处理,这篇内容可以直接当参考,少走不少弯路。
先交代一下我要解决的问题。FFTW是业界很成熟的FFT库,但它默认编译出来的版本只跑单核,性能远达不到RK3588的潜力。而OpenMP是CPU并行编程最常用的手段。把FFTW和OpenMP结合起来,再针对RK3588的异构多核架构做调度优化,就能在不改核心算法的前提下获得非常可观的性能提升。这是我认为嵌入式学习和实际项目里性价比最高的一类优化。
1. 先搞清楚底子:ELF2学习板与RK3588的硬件底牌
1.1 ELF2学习板到底是什么配置
ELF2学习板是一块面向嵌入式Linux学习和项目验证的开发板,核心SoC是RK3588。这颗芯片采用8nm工艺,CPU部分是典型的ARM大小核架构:4个Cortex-A76大核最高跑到2.4GHz,4个Cortex-A55小核最高1.8GHz,GPU是Mali-G610 MP4,还有一颗6TOPS算力的NPU。板子内存常见的配置是8GB或16GB的LPDDR4x/LPDDR5。接口方面,USB 3.0、PCIe、HDMI、MIPI-CSI、GMAC网口这些基本都齐了,拿来跑视觉SLAM、YOLO部署、工业检测这类场景很合适。
我一开始以为这种学习板更多是教学用途,性能释放不会太好。但实际跑下来,RK3588的四颗A76大核在浮点计算上相当给力,尤其是配合NEON向量指令时,FFT这种计算密集任务表现明显比普通ARM开发板强一个档次。当然,四颗A55小核也不能无视,它们频率低、缓存小,却仍然会参与系统调度,这种情况下用OpenMP做并行就得特别注意线程到底落在哪些核上,否则优化效果会被小核拖垮。
1.2 在RK3588上偏偏选FFTW
很多人会问,FFT算法网上有大把实现,为什么非要选FFTW?其实答案在实际工程里很简单:FFTW不只是一个FFT函数实现,它内部有一套plan机制,会根据你的数据规模、精度、CPU平台和目标(比如追求速度还是节省内存)去自动搜索较优的计算方案。它同时支持SIMD向量化、多线程和分布式计算,接口稳定,跨平台移植也方便。
在RK3588上,FFTW可以通过NEON指令让A76大核的SIMD单元发挥作用,又可以通过OpenMP把任务拆到多个核上。相比之下,自己写FFT或许能加深原理理解,但想达到FFTW在ARM平台上的优化水平,至少要折腾很久。如果项目目标是尽快让信号处理链路跑起来,FFTW几乎是最稳妥的选择。
顺带说一句,FFTW在RK3588这种ARM64平台上开NEON优化后,单核性能已经比纯C参考实现快出好几倍。在此基础上再叠加OpenMP多核,效果会更加明显。
1.3 这次优化的完整技术思路
整个优化计划我拆成了三个层面。底层解决硬件指令利用问题,用FFTW的NEON编译选项让代码自动向量化;中间层解决多核并行问题,用OpenMP在FFTW内部和代码外层建立并行执行能力;上层解决系统调度问题,把线程合理绑定到大核,避免线程落到小核上拖慢整体速度。
打个比方,底层像是给赛车换上更合适的轮胎,中间层相当于让四个引擎同时出力,上层则是确保每个引擎都工作在理想转速区间。三者缺一不可。只开FFTW多线程但不做线程绑定,结果可能还不如单线程;只绑定CPU核心却不开启NEON,又浪费了RK3588的SIMD能力。这些组合起来的收益,才是并行计算优化的完整价值。
2. 环境准备:先编译出一个带OpenMP的FFTW
2.1 编译FFTW时千万别漏掉的三个开关
在ELF2学习板上编译FFTW,我用的是FFTW 3.3.10源码包。下载解压之后,configure阶段有几个开关非常关键,漏掉任何一个都会导致后续多线程功能不可用。
./configure --prefix=/opt/fftw \ --enable-openmp \ --enable-shared \ --enable-neon \ --enable-single make -j8 sudo make install第一,--enable-openmp。这个开关决定FFTW是否编译出libfftw3_omp库,后续代码里要调用的fftw_init_threads、fftw_plan_with_nthreads都在这个库里。如果漏掉,程序链接阶段就会直接报错,或者即使能跑也是单线程。
第二,--enable-neon。在ARM64平台上,NEON是CPU的SIMD指令集,FFTW会用它做向量化加速。凡是RK3588这类ARM芯片,我都建议把这个开关打开。默认configure脚本可能会根据目标架构自动判断,但显式指定更稳妥。
第三,--enable-single。如果你的FFT数据本身是单精度浮点,比如图像像素值或部分传感器数据,打开单精度编译会让占用带宽的FFT显著提速。代价是精度有所下降,但大多数视觉、音频处理场景下完全够用。
不要小看编译选项的细节。我见过不少人直接在系统包管理器里装FFTW,导致缺少OpenMP支持,最后不得不重新编译。嵌入式开发中,自己编译一遍其实更可控。
2.2 本地编译还是交叉编译
ELF2学习板上跑的是Ubuntu或Debian系统的话,我强烈建议直接在板子上本地编译FFTW。原因很简单:不用处理交叉编译工具链的兼容性问题,configure脚本会原原本本检测到板子上gcc支持的SIMD特性,生成的库也直接匹配当前系统环境。
在A55小核上,make -j8会稍慢,但RK3588的四颗A76大核参与编译时,整个过程其实只要两到四分钟。编译期间注意散热就好,不用额外干预。当然,如果你维护的是一套完整的交叉编译CI流程,也可以把FFTW源码放到工具链里编译,但至少要确认工具链是否支持--enable-neon和-fopenmp,否则一步错步步错。
2.3 用benchmark先摸清性能底数
优化要建立在数据对比之上,不能凭感觉。FFTW源码自带一个benchmark工具,也可以自己写一个计时小程序。我自己习惯用clock_gettime(CLOCK_MONOTONIC)做精确计时,测量时连续执行多次,取最小值,这样可以过滤掉系统调度造成的抖动。
#include <stdio.h> #include <time.h> #include <fftw3.h> double now_ms(void) { struct timespec ts; clock_gettime(CLOCK_MONOTONIC, &ts); return ts.tv_sec * 1000.0 + ts.tv_nsec / 1e6; } int main(void) { int N = 512; fftw_complex *in = fftw_alloc_complex(N * N); fftw_complex *out = fftw_alloc_complex(N * N); fftw_plan p = fftw_plan_dft_2d(N, N, in, out, FFTW_FORWARD, FFTW_ESTIMATE); double start = now_ms(); for (int i = 0; i < 20; i++) { fftw_execute(p); } double end = now_ms(); printf("avg time: %.3f ms\n", (end - start) / 20.0); fftw_destroy_plan(p); fftw_free(in); fftw_free(out); return 0; }这段代码只统计fftw_execute的耗时,不包含plan创建时间。第一次跑的时候plan的生成会花一些时间,但真正的执行时间是稳定的。我把这个程序作为基线工具,后面每一种优化手段都要用同一把尺子来衡量。
3. OpenMP并行化实操:从接口到线程绑定
3.1 最简单的加速:fftw_plan_with_nthreads
FFTW官方提供了一套非常方便的多线程接口。只要在创建plan之前告诉它要使用多少线程,后续fftw_execute就会自动并行执行。
#include <fftw3.h> #include <omp.h> int main(void) { int N = 512; int nthreads = 8; fftw_init_threads(); fftw_plan_with_nthreads(nthreads); fftw_complex *in = fftw_alloc_complex(N * N); fftw_complex *out = fftw_alloc_complex(N * N); fftw_plan p = fftw_plan_dft_2d(N, N, in, out, FFTW_FORWARD, FFTW_ESTIMATE); // 填充数据时也利用 OpenMP #pragma omp parallel for for (int i = 0; i < N * N; i++) { in[i][0] = i * 0.001; in[i][1] = 0.0; } fftw_execute(p); fftw_destroy_plan(p); fftw_free(in); fftw_free(out); fftw_cleanup_threads(); return 0; }这里有三个要点。第一,fftw_init_threads和fftw_plan_with_nthreads必须在任何plan创建之前调用,否则plan会按单线程方式生成,后续改了也没用。第二,程序结束时调用fftw_cleanup_threads做清理。第三,数据初始化过程用的是外层OpenMP并行,这和FFTW内部线程是两码事,可以同时存在,但要小心线程数翻倍的问题。
编译链接时,除了-lfftw3,还要链接线程库:
gcc -O2 -fopenmp test.c -I/opt/fftw/include -L/opt/fftw/lib \ -lfftw3 -lfftw3_omp -lm -o fft_test如果编译时报错找不到fftw_plan_with_nthreads,几乎可以断定是链接库出了问题,重点检查-lfftw3_omp有没有加上。
3.2 数据初始化与后处理的并行循环
FFTW只会并行计算核心的快速傅里叶变换,但一个完整的数据处理链路通常还包括数据填充、窗函数乘法、结果幅值计算等环节。这些环节往往也能用OpenMP并行,而且它们通常是纯内存或纯算术操作,加速比很高。
以批量500个一维FFT为例,每个FFT数据长度为1024。我先把500个数据帧依次排列在一块连续内存里,然后用#pragma omp parallel for把每一帧的填充和执行分配给不同线程。
#pragma omp parallel for schedule(static) for (int i = 0; i < 500; i++) { apply_window_and_load(input + i * 1024, plane + i * 1024); fftw_execute(p_1d[i % 8]); // 每个线程各持一个plan示例 }这里schedule(static)是OpenMP里默认且表现非常稳定的调度方式,因为每一帧FFT的计算量基本一致,静态划分可以避免线程动态调度的额外开销。实测下来,这种外层的Batch并行效果非常明显,甚至比单个大FFT调用内部多线程还要稳。
需要注意,多线程并发调用同一个FFTW plan执行不同内存区域的操作在官方文档中是允许的,但我更推荐每个线程各自维护自己的plan副本,尤其当你的业务代码里还有其他并行区域时,这种方式隔离性更好,出问题也容易排查。
3.3 线程亲和性设置:大核优先还是均匀分布
RK3588的大小核架构让线程调度变得微妙。默认情况下,Linux内核会根据负载自动迁移线程,但FFT这种计算密集任务如果线程没有正确绑定,很可能出现几个线程挤在A76大核上,而A55小核却在空转,或者反过来部分线程落到A55上,导致整体等待最慢的那个小核。
先用lscpu -e查看CPU拓扑,确认编号分布。我手里的ELF2板子以及大部分RK3588设备,CPU 0-3是A55小核,CPU 4-7是A76大核,但仍建议以实际输出为准。
针对不同场景,我总结了两套绑定策略。第一种,如果数据规模大、确实需要动用全部8个核心,那就把所有核都放进允许列表,并通过OMP_PROC_BIND=spread让线程尽量均匀分散到物理核心上:
export OMP_NUM_THREADS=8 export OMP_PROC_BIND=spread export OMP_PLACES=cores ./fft_test第二种,也是我很多实际场景下更推荐的做法:只使用4个A76大核,小核完全让出来给系统和其他任务:
export OMP_NUM_THREADS=4 export OMP_PROC_BIND=close export OMP_PLACES=cores taskset -c 4-7 ./fft_test为什么宁可只用4个大核,也不想8核全开?因为FFT对内存带宽的消耗非常大,A55小核不仅浮点能力弱,它们参与计算时还会抢占内存带宽,反而拖累A76的访存效率。在不少带宽密集的FFT测试中,8线程全开的结果只比4大核快一点点,有时甚至更慢。
4. RK3588专属调优:内存、缓存与执行策略
4.1 内存分配和字节对齐:fftw_malloc 的价值
我第一次在ELF2上跑FFTW时图省事,直接用malloc给输入输出数组分配内存,结果性能比官方示例慢了不少。后来查了FFTW文档才意识到问题出在内存对齐上。
FFTW在IMAGE路径下需要SIMD友好的对齐方式,通常至少是16字节对齐,ARM64上更偏好32字节对齐。普通malloc虽然一般会满足基本对齐,但不一定匹配FFTW内部优化后的加载指令要求。官方提供的fftw_malloc和fftw_free正是为此设计的,它能在当前平台上返回合适的对齐内存。
fftw_complex *in = fftw_alloc_complex(N * N); fftw_complex *out = fftw_alloc_complex(N * N);如果你用C++写代码,也可以自己用posix_memalign分配,效果类似,但直接用FFTW接口最省事。千万不要用std::vector默认分配器传给FFTW,然后抱怨性能不对,这一步是很多新手踩坑最多的地方。
4.2 多维FFT转换方向的并行规划
对2D FFT,FFTW内部会把二维变换拆成多个一维变换组合,并使用plan框架自动选择较优的分解方式。在启用OpenMP线程后,FFTW会沿某个维度把数据切块分给不同线程,尽量减少线程间的数据交换。
但并不是所有规模都适合内部多线程。我的经验是这样的:当单帧数据规模很小,比如64×64甚至32×32时,FFTW内部多线程的同步开销会吞掉并行收益,这时候用单线程plan配合外层批量任务并行反而更快;当单帧规模达到512×512或更大时,内部多线程的收益就非常明显了。
另外,如果你处理的是实数数据,比如灰度图像或传感器时域信号,一定要用fftw_plan_dft_r2c_2d、fftw_plan_dft_c2r_2d这类实数接口。实数FFT利用Hermitian对称性,计算量和内存占用比复数FFT少接近一半,在带宽受限的RK3588上,这个选择直接决定性能上限。
4.3 实测数据与加速比:不同线程数下的表现
在ELF2学习板环境相同的情况下,我用512×512单精度复数2D FFT做了一组内部线程数对比实验。数据经过多次执行取最小值,结果如下。
| 线程数 | 平均耗时(ms) | 加速比 |
|---|---|---|
| 1 | 3.21 | 1.00 |
| 2 | 1.76 | 1.82 |
| 4 | 0.95 | 3.38 |
| 8 | 0.78 | 4.12 |
从数据里可以看到两个现象。第一,从1线程到4线程,加速比接近线性,说明A76大核在并行时效率很高。第二,从4线程到8线程,提升幅度明显放缓,原因就是第5到第8个线程跑在A55小核上,并且内存带宽接近饱和。
我又做了一组批量FFT测试:500个1024点复数一维FFT,使用外层OpenMP并行,不启用FFTW内部多线程。
| 线程数 | 总耗时(s) | 加速比 |
|---|---|---|
| 1 | 5.34 | 1.00 |
| 4 | 1.52 | 3.51 |
| 8 | 1.21 | 4.41 |
这两组数据证明了前面说的结论:RK3588上并行优化不是无脑开8线程,而是要根据任务形态和数据规模选择内部并行或外部并行,并且关注大小核调度。
4.4 再叠加NEON与实数变换的收益
如果在FFTW配置阶段打开了--enable-neon,性能已经包含了NEON向量化的红利。我实际做过对比,关闭NEON后同样跑512×512复数FFT,单线程耗时大约是NEON版本的1.8倍,差距非常夸张。这也是为什么我一直强调要自己编译FFTW,系统自带的预编译包经常没开这个选项。
对实数输入数据,改用r2c接口还能再省一大截。以2048点实数FFT为例,r2c接口比用复数FFT手动构造数据快约30%到40%。如果项目里的FFT规模很大,例如处理高分辨率图像频谱分析,这个优势会直接反映在整条流水线的延迟上。
还可以配合FFTW的wisdom机制。FFTW的plan搜索过程有一定耗时,在数据规模固定的生产环境中,可以先把较优plan导出到文件,程序启动时再加载。
fftw_export_wisdom_to_filename("/data/fftw.wisdom"); fftw_import_wisdom_from_filename("/data/fftw.wisdom");我自己在ELF2板上验证过,同一块板子和同一套FFTW版本下,wisdom文件可以反复使用,省去了每次开机后plan搜索的时间。但换FFTW版本或换另一台不同架构的机器时,wisdom需要重新生成,否则可能出现兼容问题。
5. 常见问题与排查实录
5.1 找不到 omp.h 或链接报错
编译代码时如果提示找不到omp.h,多半是交叉编译工具链或系统gcc缺少OpenMP运行库。在Ubuntu/Debian系统上,安装libomp-dev或gcc完整组件即可。如果你用的是交叉编译器,记得检查工具链是否支持-fopenmp。
链接阶段如果出现fftw_plan_with_nthreads未定义引用,先确认编译FFTW时确实加过--enable-openmp,并且在编译命令末尾加了-lfftw3_omp。可以用ldconfig -p | grep fftw查看系统里有没有这个库,没有的话检查/opt/fftw/lib目录是否存在,并把LD_LIBRARY_PATH指过去。
5.2 开了多线程反而更慢
在调整OPT时,多线程变慢的情况一点也不罕见。最常见的原因是数据规模太小。比如只对一个128点FFT开8线程,线程创建、同步、内存访问的开销早就超过了计算本身,结果自然更慢。
另一个原因是线程绑定策略错误。比如你用OMP_PROC_BIND=close将8个线程绑定在一起,它们全都挤在同一个核心簇里,反而加剧了资源争抢。先用OMP_PROC_BIND=spread测试,再看看是否有改善。最后还要检查RK3588有没有触发降频,满载功率过大的时候,如果没有良好散热,核心频率会自动下降,并行度再高也顶不住频率下降的损失。
5.3 大小核负载不均:线程都跑到A55上去了
判断线程是否跑到小核上,最简单的方法是跑程序的同时执行top并按1查看每个CPU核心的使用率,或者直接看/sys/devices/system/cpu/cpu*/cpufreq/scaling_cur_freq。如果你发现CPU0-3的使用率远高于CPU4-7,说明线程都被调度到了小核上。
解决办法是绑定大核。比如只使用CPU4到CPU7,执行时加上taskset -c 4-7 ./fft_test。如果仍想用所有核心,至少设置OMP_PROC_BIND=spread让线程均匀铺开。还有一个容易忽略的点是CPU调频策略,建议在测试期间把所有核心的调频模式设为performance,避免内核为了省电把大核频率压下去。
for cpu in /sys/devices/system/cpu/cpu[0-7]; do echo performance > $cpu/cpufreq/scaling_governor done5.4 外部并行和内部并行叠加导致线程爆炸
当代码里既有外层#pragma omp parallel for,FFTW内部又开启了8线程,每个外层线程执行FFT时又会生成8个内部线程,总线程数瞬间膨胀到几十个。这种线程爆炸不仅不会加速,还会造成严重的上下文切换开销和内存带宽争抢。
解决办法是坚持一个原则:同一个时间段内,只在一个层次上启用并行。如果是大批量小FFT任务,就用外层OpenMP并行,并把fftw_plan_with_nthreads(1)固定成单线程;如果是一个大矩阵FFT,则让FFTW内部多线程,外层不要再用OpenMP包裹。这样线程数量可控,性能也最可预测。
6. 调完之后的几点体会
FFTW在RK3588上的优化,本质上是“计算并行度”和“内存带宽”两件事的平衡。四颗A76大核的浮点算力确实强,但FFT并不是单纯的算数任务,访存模式非常密集,小核加入并行很容易变成负优化。所以我不太建议一上来就打开8线程然后满怀期待等结果,先跑一遍不同线程数的对比实验,再根据曲线决定生产环境参数,这才是工程上应该有的工作方式。
散热和电源也一样重要。RK3588满载时芯片发热量很大,我最初在ELF2板子上连续跑大规模FFT,几分钟后耗时明显变长,检查频率才确认是大核降频了。后来换了一款主动散热风扇并接入PWM控制,让风扇根据温度动态调速,性能曲线才稳定下来。做性能测试时,一定要先把散热和调频策略固定,否则你测出来的数据根本不可复现。
最后建议你把FFT相关代码封装成一个独立模块,内部管理plan、线程数和wisdom文件。这样后续业务层只需调用接口,不用关心底层多线程细节。不管你是做视觉SLAM、雷达信号处理还是图像滤波,这套优化方法都能直接迁移过去,省下来的时间足够你再优化好几个处理环节。