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 知识结构总览

下面这张图把本章的指令体系串起来了。你可以把它当作速查地图:

向量算术运算指令知识结构 向量算术运算指令体系 加减乘除 逻辑运算 移位运算 比较指令 vadd / vsub vmul / vdiv vrem(求余) vand / vor vxor / vnot vsll(左移) vsrl(逻辑右移) vsra(算术右移) vmseq / vmsne vmslt / vmsle vmsgt / vmsge 所有指令均支持三种操作数形式 .vv(向量-向量) | .vx(向量-标量) | .vi(向量-立即数) 比较指令特殊之处 结果存入掩码寄存器 v0~v7,每个元素仅占 1 位

这张图把四类指令的层级关系画清楚了。你写代码时,先想清楚你要做的是哪一类操作,再选具体的指令和操作数形式。

4.6 实战建议

最后,给几个我在项目中总结出来的经验:

  • 优先用 .vi 形式:如果立即数能表达你的常数,就用立即数版本。它省去了加载标量寄存器的指令,代码更紧凑。
  • 注意除法性能:除法指令的延迟通常是加法的 10 倍以上。能避免就避免,实在避不开,考虑用查表或近似算法替代。
  • 掩码寄存器是稀缺资源:只有 8 个,别浪费。如果比较结果只用一次,用完就覆盖没问题;如果需要保留多个条件,提前规划好。
  • 移位量别越界:移位量 ≥ SEW 位宽时,行为未定义。不同硬件可能给出不同结果,别依赖它。
我的习惯: 每次写向量运算代码前,我会先在纸上画出数据流——哪些数据从哪来,经过什么运算,结果存哪去。画清楚了再写指令,基本不会出错。你也不妨试试。