4. 向量算术运算:加减乘除、乘加融合、比较选择与类型转换
向量算术运算,说白了就是让处理器一次性处理一堆数据。你写一个加法指令,它帮你把数组里几十个元素同时加完。我刚开始接触RISC-V向量扩展时,第一反应是“这不就是SIMD换了个马甲吗?”后来深入才发现,向量化的灵活性和寄存器分组机制,比传统SIMD强太多了。
4.1 向量加减乘除:最基础的计算单元
先看最常用的向量加减法。RISC-V向量指令集里,vadd.vv、vsub.vv 这些指令,操作数都是向量寄存器。举个例子:
# 假设 v0 存数组A,v2 存数组B,结果放 v4
vadd.vv v4, v0, v2 # v4[i] = v0[i] + v2[i]
vsub.vv v6, v0, v2 # v6[i] = v0[i] - v2[i]
这里有个细节——向量长度由 vsetvli 指令控制。我见过不少新手直接写死长度,结果换到不同硬件上就崩了。正确的做法是:
loop:
vsetvli t0, a2, e32, m1 # 设置向量长度,a2是剩余元素数
vle32.v v0, (a0) # 加载A
vle32.v v2, (a1) # 加载B
vadd.vv v4, v0, v2 # 加法
vse32.v v4, (a3) # 存结果
add a0, a0, t0 # 指针前进
add a1, a1, t0
add a3, a3, t0
sub a2, a2, t0 # 剩余元素减少
bnez a2, loop # 没完就继续
乘除法也类似,但要注意:整数乘法可能溢出,浮点乘法则要关注精度。我在项目中遇到过一个问题——用 vmul.vv 做定点乘法时,忘记考虑中间结果位宽,导致数据截断。后来我养成了习惯:先确认元素类型(e8/e16/e32/e64),再选指令。
vmul + vadd 两条指令。RISC-V 提供了 vfmacc 这类融合指令,一条搞定,延迟更低。
4.2 向量乘加融合:一条指令顶两条
乘加融合(FMA)是向量化的核心优化手段。RISC-V 提供了多种融合指令:
| 指令 | 含义 | 典型场景 |
|---|---|---|
vfmacc.vv |
v[i] = a[i] * b[i] + c[i] | 矩阵乘法、卷积 |
vfmadd.vv |
v[i] = a[i] * b[i] + c[i] | 与vfmacc类似,顺序不同 |
vfnmsac.vv |
v[i] = -(a[i] * b[i]) + c[i] | 负乘加 |
为什么用融合指令?因为 减少了寄存器压力 和 指令发射数量。我做过一个图像滤波算法,原来用 vmul + vadd 需要 4 条指令,换成 vfmacc 后变成 2 条,性能提升约 30%。
4.3 向量比较与选择:条件运算的向量化
向量比较指令生成的是掩码寄存器(mask register),不是布尔数组。比如:
vmseq.vv v0, v2, v4 # 比较 v2 和 v4,相等则 v0 对应位为1
vmslt.vv v0, v2, v4 # 小于比较
vmsle.vv v0, v2, v4 # 小于等于
掩码寄存器有什么用?配合 vmerge 指令做条件选择:
vmerge.vvm v6, v2, v4, v0 # 如果 v0 掩码位为1,选 v2,否则选 v4
这其实就是向量化的三目运算符。我在做ReLU激活函数时用过:
# 假设 v0 存输入数据
vmslt.vv v2, v0, v0_zero # 比较是否小于0
vmerge.vvm v4, v0_zero, v0, v2 # 小于0取0,否则取原值
嗯,这里要注意:掩码寄存器只有一位宽,不是每个元素一个字节。所以比较结果非常紧凑,适合做分支预测优化。
vmslt 做边界检查,结果忘记重置掩码寄存器,导致后续操作用了上次的比较结果。记得每次比较前清零掩码,或者用 vmset 初始化。
4.4 向量类型转换:数据格式的桥梁
类型转换在异构计算中很常见。RISC-V 提供了 vfcvt 系列指令:
| 指令 | 转换方向 | 示例 |
|---|---|---|
vfcvt.f.x.v |
整数 → 浮点 | int32 → float32 |
vfcvt.x.f.v |
浮点 → 整数 | float32 → int32(截断) |
vfcvt.rtz.x.f.v |
浮点 → 整数(向零舍入) | float32 → int32 |
为什么需要多种舍入模式?因为 不同算法对精度要求不同。比如图像处理中,浮点转整数常用向零舍入;而科学计算中,可能要用就近舍入。
我做过一个音频处理算法,输入是16位整数,内部计算用32位浮点,输出再转回16位。类型转换成了性能瓶颈。后来我发现:
- 用
vfcvt.f.x.v转浮点时,可以配合vfmul做归一化 - 用
vfcvt.rtz.x.f.v转回整数时,注意饱和处理 - 如果数据范围已知,可以跳过类型转换,直接用整数运算
vsetvli 调整向量长度来隐藏延迟。
4.5 知识体系总览
下面这张图总结了本章的核心内容:
4.6 实战建议
最后,我总结几条实战经验:
- 先写标量版本,验证正确性,再向量化。别一上来就写向量代码,调试起来很痛苦。
- 用
vsetvli动态调整长度,别硬编码。不同硬件支持的向量长度不同。 - 优先用融合指令,减少指令数量,提升精度。
- 掩码寄存器用完就清,避免残留数据影响后续操作。
- 类型转换尽量少做,如果数据范围允许,全程用同一种类型。