[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\
即可使用)

以上便是本次分享的全部内容,感谢您的阅读,在评论区发表你的看法吧~。
@21小跑堂 :感谢资持 (*^▽^*)
探究Helium在G32R501上的向量并行计算能力,作者通过详细的描述介绍Helium 的背景和能力,并在G32R501上实操Helium,展示Helium的计算能力,文章整体结构紧凑合理,质量较佳。