[i=s] 本帖最后由 kai迪皮 于 2025-7-14 20:58 编辑 [/i]<br />
<br />
1. 背景
在 Arm 处理器的大家族中,Cortex-A 系列因其高性能、高主频常被用于智能手机、平板电脑或高端应用处理器。而在以嵌入式、物联网为主的 Cortex-M 系列中,“低功耗、成本敏感、实时性”是更重要的需求。如果在 Cortex-M 设备上做大量数字信号处理、机器学习,过去往往只能依赖:
- 标量浮点单元(FPU):一次只能对一个浮点数做运算;
- 一些编译器层面的 loop-unrolling、优化,让标量运算稍微提速;
- 若需要真正的 SIMD 并行,则很难在 Cortex-M 里找到现成的硬件方式。
然而,Arm 在 2020 年左右推出了“Helium”——这是一套专门面向 M Profile 的向量扩展(MVE,M-Profile Vector Extension)。它为像 Cortex-M52、M55、M85 这样的内核带来了真正的 SIMD 特性,使得一次加载可处理 4 个 32 位浮点数或更多定点数据,并且提供了丰富的指令(vld/vmul/vadd/vsub/vst等)。这样,一颗Cortex-M MCU就能接近 Cortex-A 上 Neon 指令那种“单指令多数据”的高并行度,极大提升处理效率。
2. 为什么不直接把Neon搬过来?
谈到 SIMD 并行,大家或许首先想到的是 Neon 指令集——Neon 在Cortex-A及部分Cortex-R处理器里十分常见,广泛用于多媒体、图像视频处理和机器学习。那为何 Arm 不把 Neon 直接塞进 Cortex-M 就完事了?
2.1 Neon 是什么?
Neon 是 Arm 针对 Cortex-A(以及部分 Cortex-R)推出的 SIMD 指令集,用于如手机 SoC 的多媒体、图像/视频处理、机器学习推理等繁重运算。Neon 要求较大的硬件和寄存器资源,且通常在运行 Linux/Android 这类操作系统的平台上,能充分发挥高吞吐率、多线程流水线调度的优势。
2.2 Helium(MVE)为何另起炉灶
Cortex-M 作为 MCU,面积更小、功耗更低、且对实时中断响应要求严苛。如果把 Neon 全搬过来,可能会带来:
• 资源占用和成本显著增加;
• 功耗不符合 MCU 级需求;
• 复杂的流水线与中断机制可能冲突。
因此,Arm 重新定义了更“轻巧”的向量扩展——Helium (MVE),在保留 SIMD 并行优势的同时,兼顾 Cortex-M 的低功耗与实时特质。
2.3 Helium 与 Neon 的相似与差别
- 相同点:
- 都是 128-bit SIMD;
- 都能批量处理多路数据,如同时处理 4 个 float;
- 指令类型相似,如 vld、vst、vmul、vadd 等。
- 不同点:
- Helium 更贴合 M Profile 的堆栈、上下文切换;
- 使用谓词寄存器(tail predication),方便应对剩余元素不整除的情形;
- 面向低功耗与小型 MCU 环境进行了特殊优化。
总的来说,Neon 面向“大块头”的 Cortex-A,而 Helium (MVE) 为“小巧”的 Cortex-M52 或 M55 等内核而生。二者血脉相承,却分工不同。
3. 设置应用场景:基于矩阵元素运算
为了让 Helium 的效果更直观,我们选了一个常见场景:对矩阵(或向量)里每个元素做运算,比如:
- 元素自乘:A[i] = A[i] × A[i];
- 两个矩阵 A[i] 与 B[i] 对应元素相乘,写入 C[i];
- A[i] = scaleFactor × A[i] 等缩放操作。
这些“逐元素乘法”在图像滤波、音频处理、神经网络激活函数中常见。若矩阵规模较大,仅靠标量 FPU 循环就会效率偏低;如果能同时处理 4 个浮点数,就能让【总循环数】大幅减少,真正凸显 SIMD 并行优势。
3.1 代码层面:两个版本的对比目标
我们将在下文分别展示:
- 标量版本(FPU 模式):朴素的 while 循环,每次只乘 1 个数;
- MVE 向量版本:手写 Helium Intrinsics,让编译器明确生成向量指令。
通过编译并在 G32R501 上实际运行,我们能清晰看到这两种方法在不同时期、不同编译优化条件下的性能表现。
4. 代码实现
在CMSIS-DSP库里,你可能会看到类似下面的函数原型“arm_mult_f32”。这里我们把arm_mult_f32拆分成两个函数以便可以同时在G32R501平台上运行: arm_mult_f32_fpu 与 arm_mult_f32_mve。
在正式开始我们的测试之前,我们要安装好CMSIS-DSP pack,里面有一些我们所使用到的内容,我们引用该pack可以让我们少做很多“轮子”。

4.1 标量循环:arm_mult_f32_fpu
这是一个常见的写法:在 while 循环里,每次取一个元素做乘法,然后放回结果数组。这个函数对编译器而言信号很明确:它是标量运算,没有使用SIMD 指令。
ARM_DSP_ATTRIBUTE void arm_mult_f32_fpu(
const float32_t * pSrcA,
const float32_t * pSrcB,
float32_t * pDst,
uint32_t blockSize)
{
uint32_t blkCnt; /* Loop counter */
blkCnt = blockSize;
while (blkCnt > 0U)
{
*pDst++ = (*pSrcA++) * (*pSrcB++);
blkCnt--;
}
}
在日常开发中,这种循环最常见:灵活、好理解。不过一旦数据规模变大,就可能遇到性能瓶颈。
4.2 MVE向量循环:arm_mult_f32_mve
Helium 里一次能处理 128 bits,也就是 4 个 32 位浮点数。这里我们利用了 MVE 提供的 C intrinsics,明确告诉编译器要走向量化指令:
ARM_DSP_ATTRIBUTE void arm_mult_f32_mve(
const float32_t * pSrcA,
const float32_t * pSrcB,
float32_t * pDst,
uint32_t blockSize)
{
uint32_t blkCnt; /* Loop counter */
f32x4_t vec1;
f32x4_t vec2;
f32x4_t res;
/* Compute 4 outputs at a time */
blkCnt = blockSize >> 2U;
while (blkCnt > 0U)
{
vec1 = vld1q(pSrcA); // load 4 floats from pSrcA
vec2 = vld1q(pSrcB); // load 4 floats from pSrcB
res = vmulq(vec1, vec2); // vector multiply
vst1q(pDst, res); // store 4 results
pSrcA += 4;
pSrcB += 4;
pDst += 4;
blkCnt--;
}
/* Tail: handle leftover if not multiple of 4 */
blkCnt = blockSize & 0x3;
if (blkCnt > 0U)
{
mve_pred16_t p0 = vctp32q(blkCnt);
vec1 = vld1q(pSrcA);
vec2 = vld1q(pSrcB);
vstrwq_p(pDst, vmulq(vec1, vec2), p0);
}
}
一次性能处理4个浮点数据,不再是单条标量指令。最大好处:缩减循环迭代次数,编译器也不用猜测你是不是想要 SIMD——代码里就是实打实的向量指令。
5. 测试数据:无优化→-Ofast,一步步见证性能悬殊
接下来我们在 G32R501(支持 Helium)上,用 blockSize=192 做了三组实验。编译器为 armclang,重点考察“标量函数 vs. 显式 MVE 函数”的实时性能(CPU cycles)。
5.1 场景一:无优化 (-O0)
如果我们把编译优化关掉,或者保持低级别,让编译器不做自动向量化,那就能观察到纯“显式向量”VS.“标量循环”的对比。
大致结果如下:
- MVE 版(arm_mult_f32_mve)约 3209 cycles
- 标量版(arm_mult_f32_fpu)约 5021 cycles
- 速度比 = 5021 / 3209 ≈ 1.56

可以明显看出,显式向量化要比标量快 50% 以上。这也是 SIMD 并行的直观魅力——一次指令并行处理多个数据,循环次数减少。
5.2 场景二:-Ofast 最高优化
当我们切换到 -Ofast 编译等级时,编译器会尽可能地利用各种高级别优化。

结果如下:
- MVE 版:约 544 cycles
- 标量版:约 551 cycles
- 速度比 = 1.01

不可思议的是,这时标量循环已经能做到几乎和显式MVE一样快。二者仅差 7 个周期,几乎可以当作持平。这跟我们在无优化时看到的 1.56 倍差距完全不一样。难道说 Helium 自己失灵了吗?还是标量浮点爆发了?
我们深究一下,看看汇编代码的情况。我们通过使用 fromelf --text -c -o "$L@L.asm" "#L"
查看反汇编代码(设置完毕后重新编译一次代码):

我们发现编译器把 arm_mult_f32_fpu 生成了包含向量操作的函数,出现了vmul.f32这种操作。
arm_mult_f32_fpu
0x00002298: b580 .. PUSH {r7,lr}
0x0000229a: f24310c0 C... MOVW r0,#0x31c0
0x0000229e: f2403100 @..1 MOVW r1,#0x300
0x000022a2: f2c00000 .... MOVT r0,#0
0x000022a6: f2c20100 .... MOVT r1,#0x2000
0x000022aa: 1a08 .. SUBS r0,r1,r0
0x000022ac: 2810 .( CMP r0,#0x10
0x000022ae: d212 .. BCS 0x22d6 ; arm_mult_f32_fpu + 62
0x000022b0: f04f0ec0 O... MOV lr,#0xc0
0x000022b4: f2403000 @..0 MOVW r0,#0x300
0x000022b8: f24311c0 C... MOVW r1,#0x31c0
0x000022bc: f2c20000 .... MOVT r0,#0x2000
0x000022c0: f2c00100 .... MOVT r1,#0
0x000022c4: ecb10a01 .... VLDM r1!,{s0}
0x000022c8: ee200a00 ... VMUL.F32 s0,s0,s0
0x000022cc: eca00a01 .... VSTM r0!,{s0}
0x000022d0: f00fc009 .... LE lr,{pc}-0xc
0x000022d4: e00e .. B 0x22f4 ; arm_mult_f32_fpu + 92
0x000022d6: f24312c0 C... MOVW r2,#0x31c0
0x000022da: 20c0 . MOVS r0,#0xc0
0x000022dc: f2c00200 .... MOVT r2,#0
0x000022e0: f020e001 ... DCI.W 0xf020e001 ; ? Undefined
0x000022e4: ecb21f04 .... LDC p15,c1,[r2],#0x10
0x000022e8: ff000d50 ..P. VMUL.F32 q0,q0,q0
0x000022ec: eca11f04 .... STC p15,c1,[r1],#0x10
0x000022f0: f01fc009 .... LETP lr,{pc}-0xc
0x000022f4: bd80 .. POP {r7,pc}
0x000022f6: 0000 .. MOVS r0,r0
可见不再是简单“FMUL + store”的标量操作,而是经过编译器一番“整容”式的高级编译,使得和MVE(其实已经是使用MVE指令了)的实现在周期数上拉近差距,也就出现了1.01的差异。
5.3 场景三:禁止自动向量化
了更好地对比,我们可以把 arm_mult_f32_fpu.c
放到单独的编译命令并指定 -mcpu=cortex-m52+cdecp0+nomve
选项,从而强行禁止编译器对那段标量循环进行自动向量化。也就是让它只能走单浮点 FMUL + store 的老路。

再测试,得到:
- MVE 版: 544 cycles(和上一场景相同)
- 标量版(禁止自动向量化): 817 cycles
- 速度比 = 817 / 544 ≈ 1.50
和前面无优化时的1.56差不多,再度证明如果不自动向量化,标量写法就没那么“神速”。

总结起来,我们通过这几步观察,就得到了非常有意思的结论:在 -Ofast 等较高优化等级下,编译器会自动分析循环并进行向量化(auto-vectorization),使得“标量函数”也变得和手动MVE版本几乎同等效率。
6. 总结
G32R501 作为支持 Helium 的代表之一,不仅仅是“功能上多了几条指令”,而是提供了充分的浮点(或定点)向量化能力、并且在相同主频下大幅提升数据吞吐。同时 Arm 在 CMSIS-DSP 库做了大量支持,可以直接调用现成的 API 做滤波、变换、统计等。这些 API 底层就已经根据宏定义自动用上 MVE 指令,可以让开发者几乎“零门槛”地享受向量化红利。
另外我们不需要过分担忧编译器不会向量化,只要数据结构和循环逻辑比较“干净”,-Ofast 就能把它变成并行处理。
所以,可以说在 G32R501 这类具备 Helium 指令集的 MCU 工程里,你不用担心“写不写 Intrinsics”的问题。只要你选对了编译选项( -Ofast ),就能享受到自动向量化的便利。如果你想要在某些关键环节进一步控制指令、管理缓存或内存对齐,可以再去写手工 intrinsics。可总结为:“能手写就手写,更有逼格;懒得管就让编译器替你搞定,也完全可行!”
这里是代码:
附件:g32r501_driverlib_mve.zip(解压至 G32R501_SDK_V1.1.0\driverlib\g32r501\examples\eval\
即可使用)

以上便是本次分享的全部内容,感谢您的阅读,在评论区发表你的看法吧~。