4. 向量算术运算指令:加减乘除、逻辑、移位与比较
好,咱们进入第四章。这一章是向量指令集里最常用、也最基础的部分——算术运算指令。说白了,就是怎么让处理器一次性处理一堆整数,怎么比大小,怎么做移位和逻辑操作。
我个人习惯把这类指令称为“向量计算的核心肌肉群”。你后面做任何性能优化,几乎都绕不开它们。咱们一个一个来看。
4.1 向量整数加减乘除
先看最简单的:加法。RISC-V 向量指令集里,加法指令是 vadd.vv、vadd.vx、vadd.vi。后缀 .vv 表示两个向量寄存器相加,.vx 是向量加标量,.vi 是向量加立即数。
举个例子:
# 两个向量寄存器相加
vadd.vv v8, v12, v16 # v8[i] = v12[i] + v16[i]
# 向量加标量(x5 寄存器里的值)
vadd.vx v8, v12, x5 # v8[i] = v12[i] + x5
# 向量加立即数
vadd.vi v8, v12, 15 # v8[i] = v12[i] + 15
减法指令是 vsub.vv 和 vsub.vx。注意,没有立即数版本——你想想看,减立即数完全可以用加负数来替代,所以指令集设计上就省掉了。
乘法指令是 vmul.vv、vmul.vx。这里有个坑:乘法结果可能溢出。RISC-V 向量指令集默认是“饱和”还是“截断”?嗯,这里要注意——默认是截断,也就是只保留低 SEW 位。如果你需要饱和运算,得用 vsmul 系列指令。
vmul,结果溢出后数据直接截断,画面出现奇怪的条纹。后来换成 vsmul 饱和乘法才解决。所以,涉及边界值计算时,一定想清楚你要截断还是饱和。
除法指令是 vdiv.vv、vdiv.vx,以及对应的求余指令 vrem.vv、vrem.vx。除法比较特殊,它通常需要多个周期才能完成。我在项目中遇到过,某些微架构里除法指令的吞吐量很低,如果你循环里频繁用除法,性能会很难看。
| 指令 | 操作 | 说明 |
|---|---|---|
vadd.vv |
向量 + 向量 | 最常用,两个向量逐元素相加 |
vadd.vx |
向量 + 标量 | 常用于加偏置、加常数 |
vsub.vv |
向量 - 向量 | 逐元素减法 |
vmul.vv |
向量 × 向量 | 截断乘法,注意溢出 |
vdiv.vv |
向量 ÷ 向量 | 整数除法,向零取整 |
vrem.vv |
向量 % 向量 | 求余,符号与被除数一致 |
4.2 向量逻辑运算
逻辑运算包括与、或、异或、取反。指令分别是 vand、vor、vxor、vnot。同样支持 .vv、.vx、.vi 三种形式。
我建议你重点记住 vxor 的一个妙用:它可以用来做条件取反。比如你想根据某个掩码来翻转数据,一条 vxor.vv 就搞定了,比分支判断快得多。
# 逻辑与:v8[i] = v12[i] & v16[i]
vand.vv v8, v12, v16
# 逻辑或:v8[i] = v12[i] | 0xFF
vor.vi v8, v12, 0xFF
# 逻辑异或:v8[i] = v12[i] ^ x5
vxor.vx v8, v12, x5
# 逻辑取反:v8[i] = ~v12[i]
vnot.v v8, v12
vand 或 vor 配合掩码来实现。我在做数据包解析时,经常用这招来快速提取或置位标志位。
4.3 向量移位运算
移位指令分逻辑左移、逻辑右移、算术右移。指令是 vsll(左移)、vsrl(逻辑右移)、vsra(算术右移)。
为什么要有算术右移?因为对于有符号数,逻辑右移高位补0,负数会变成正数,结果就错了。算术右移会复制符号位,保证负数右移后还是负数。
# 左移:v8[i] = v12[i] << 3
vsll.vi v8, v12, 3
# 逻辑右移:v8[i] = v12[i] >> 4(高位补0)
vsrl.vi v8, v12, 4
# 算术右移:v8[i] = v12[i] >>> 4(高位补符号位)
vsra.vi v8, v12, 4
移位量也可以来自向量寄存器:vsll.vv 表示每个元素按对应的移位量来移。这个特性在做可变位宽的编解码时特别有用。
SEW 位宽,结果是未定义的。不同硬件实现可能不一样,别依赖这个边界行为。
4.4 向量比较指令
比较指令是向量化的分支判断。指令有 vmseq(等于)、vmsne(不等于)、vmslt(小于)、vmsle(小于等于)、vmsgt(大于)、vmsge(大于等于)。
比较结果不是普通的数值,而是一个掩码向量——每个元素只占1位,存放在 v0 到 v7 这8个掩码寄存器里。你想想看,这设计多巧妙:一次比较就能得到整个向量的条件结果,后续可以用这个掩码来做条件执行。
# 比较 v12 和 v16,结果存入 v0
vmseq.vv v0, v12, v16 # v0[i] = (v12[i] == v16[i]) ? 1 : 0
# 比较 v12 和立即数 10
vmslt.vi v0, v12, 10 # v0[i] = (v12[i] < 10) ? 1 : 0
# 比较 v12 和标量 x5
vmsle.vx v0, v12, x5 # v0[i] = (v12[i] <= x5) ? 1 : 0
比较指令的返回值是掩码,那怎么用呢?最常见的用法是配合 vmerge 或 vcond 做条件赋值。比如:
# 如果 v12[i] > 0,则 v8[i] = v12[i],否则 v8[i] = 0
vmsgt.vi v0, v12, 0 # 生成掩码
vmerge.vvm v8, v16, v12, v0 # v8 = v0 ? v12 : v16(这里 v16 是零向量)
v0 到 v7 这8个。如果你同时需要多个比较结果,记得规划好掩码寄存器的使用。我早期写代码时没注意,结果掩码被覆盖了,调试了半天才发现。
4.5 知识结构总览
下面这张图把本章的指令体系串起来了。你可以把它当作速查地图:
这张图把四类指令的层级关系画清楚了。你写代码时,先想清楚你要做的是哪一类操作,再选具体的指令和操作数形式。
4.6 实战建议
最后,给几个我在项目中总结出来的经验:
- 优先用
.vi形式:如果立即数能表达你的常数,就用立即数版本。它省去了加载标量寄存器的指令,代码更紧凑。 - 注意除法性能:除法指令的延迟通常是加法的 10 倍以上。能避免就避免,实在避不开,考虑用查表或近似算法替代。
- 掩码寄存器是稀缺资源:只有 8 个,别浪费。如果比较结果只用一次,用完就覆盖没问题;如果需要保留多个条件,提前规划好。
- 移位量别越界:移位量 ≥ SEW 位宽时,行为未定义。不同硬件可能给出不同结果,别依赖它。