4. Realm管理扩展基础
好,咱们进入SVE2。说实话,ARMv9最让我兴奋的就是这个扩展。NEON用了这么多年,突然来了个SVE2,很多人第一反应是“又要学新东西了”。但我想说,这玩意儿值得学。
4.1 SVE2与NEON的对比
先说说NEON。NEON是固定宽度的SIMD——128位,你写代码的时候就得知道这个宽度。SVE2呢?它是向量长度无关的。什么意思?你的代码写一次,可以在128位、256位、甚至2048位的硬件上跑,不用改。
| 特性 | NEON | SVE2 |
|---|---|---|
| 向量宽度 | 固定128位 | 128~2048位,实现定义 |
| 寄存器数量 | 32个(128位) | 32个Z寄存器 + 16个P寄存器 |
| Predicate支持 | 无 | 有,逐元素控制 |
| Gather/Scatter | 有限 | 原生支持 |
| 代码可移植性 | 需针对宽度调整 | 向量长度无关 |
我在一个项目里遇到过这种情况:用NEON写的图像处理库,在Cortex-A72上跑得好好的,换到A76上想用更宽的SIMD,结果得重写。SVE2就解决了这个痛点。
核心差异:NEON是“我知道宽度,我来优化”;SVE2是“我不知道宽度,但代码能自适应”。
4.2 向量长度无关编程
这是SVE2的精髓。你的代码不依赖具体的向量宽度,硬件跑多宽就多宽。怎么做到的?靠的是whilelo指令和Predicate。
举个例子,数组加法:
// NEON版本:固定处理4个float
void add_neon(float *a, float *b, float *c, int n) {
for (int i = 0; i < n; i += 4) {
float32x4_t va = vld1q_f32(a + i);
float32x4_t vb = vld1q_f32(b + i);
float32x4_t vc = vaddq_f32(va, vb);
vst1q_f32(c + i, vc);
}
}
// SVE2版本:向量长度无关
void add_sve2(float *a, float *b, float *c, int n) {
while (n > 0) {
svbool_t pg = svwhilelt_b32(0, n);
svfloat32_t va = svld1_f32(pg, a);
svfloat32_t vb = svld1_f32(pg, b);
svfloat32_t vc = svadd_f32_m(pg, va, vb);
svst1_f32(pg, c, vc);
a += svcntw();
b += svcntw();
c += svcntw();
n -= svcntw();
}
}
看到没?svcntw()返回当前硬件能处理多少个float。在128位上是4个,256位上是8个。代码完全不用改。
我的习惯:写SVE2代码时,先假设向量宽度是未知的。用svcnt系列函数获取实际宽度,用whilelo处理边界。这样代码天然可移植。
4.3 Predicate寄存器与Loop Vectorization
Predicate是SVE2的“开关”。每个Predicate位控制对应数据通道是否执行。这玩意儿在循环向量化里太有用了。
想想看,循环的剩余元素怎么处理?NEON里你得写尾循环处理代码。SVE2呢?Predicate直接帮你搞定。
// 处理任意长度的数组,不需要尾循环
void process_any_length(int32_t *in, int32_t *out, int n) {
svbool_t pg;
int32_t i = 0;
while (i < n) {
// 生成有效的Predicate
pg = svwhilelt_b32(i, n);
// 加载、处理、存储
svint32_t vec = svld1_s32(pg, in + i);
svint32_t result = svmul_n_s32_m(pg, vec, 2);
svst1_s32(pg, out + i, result);
i += svcntw();
}
}
我曾经在一个音频处理项目里用这个特性。采样率不同,帧长也不同。用SVE2的Predicate,一个函数处理所有情况,省了多少维护工作。
注意:Predicate不是免费的。生成Predicate的指令(如whilelo)有开销。如果循环体很小,这个开销可能抵消向量化的收益。我建议循环体至少4条以上向量指令时再用。
4.4 SVE2的Intrinsic编程
写汇编太痛苦,用Intrinsic吧。ARM提供了完整的SVE2 Intrinsic接口,风格和NEON类似,但更灵活。
命名规则:sv<操作>_<类型>_<变体>
| Intrinsic模式 | 含义 | 示例 |
|---|---|---|
| _m | 合并模式,受Predicate控制 | svadd_f32_m(pg, a, b) |
| _z | 归零模式,Predicate为0的通道置0 | svadd_f32_z(pg, a, b) |
| _x | 不关心模式,Predicate为0的通道值未定义 | svadd_f32_x(pg, a, b) |
我个人习惯用_m模式,因为它最直观——Predicate控制哪些通道写结果。但如果你需要未激活的通道清零,用_z。
// 实际项目中的例子:带条件的向量乘法
svfloat32_t conditional_mul(svbool_t pg, svfloat32_t a, svfloat32_t b, float threshold) {
// 先比较
svbool_t cmp = svacgt_f32(pg, a, threshold);
// 满足条件的做乘法,不满足的保留原值
return svmul_f32_m(cmp, a, b);
}
避坑指南:我曾经在_x模式上栽过跟头。未定义的值在某些微架构上会导致性能异常。除非你确定不在乎那些通道的值,否则用_m或_z更安全。
知识结构图
实战建议
学SVE2,我建议你从迁移NEON代码开始。找个你熟悉的NEON函数,用SVE2重写。你会发现,大部分情况下代码更简洁,而且不用操心边界处理。
小技巧:GCC和LLVM都支持SVE2 Intrinsic。编译时加-march=armv9-a+sve2。如果你在QEMU上测试,用-cpu max,sve128=on模拟128位SVE2。
嗯,SVE2的内容远不止这些。后面我们会深入具体的指令和优化技巧。先把基础打牢,后面的路就好走了。