4. 向量算术运算基础:vadd/vsub、vmul/vdiv、vmin/vmax、vneg指令详解

好,咱们直接进入正题。向量算术运算,说白了就是让处理器一次性处理一堆数据。你想想看,如果让你对着一个数组里的1000个数做加法,用标量指令你得循环1000次,但用向量指令,一条vadd就搞定了。这就是向量扩展的魅力所在。

我个人习惯把向量算术指令分成三类:基本运算、比较运算、和取反运算。今天咱们就把这几类都过一遍。

4.1 vadd / vsub:加减法,最常用的基本功

vaddvsub是向量指令集里最基础的指令。它们的格式非常直观:

vadd.vv vd, vs2, vs1   # 向量 + 向量
vadd.vx vd, vs2, rs1   # 向量 + 标量
vadd.vi vd, vs2, imm   # 向量 + 立即数

vsub.vv vd, vs2, vs1   # 向量 - 向量
vsub.vx vd, vs2, rs1   # 向量 - 标量

嗯,这里要注意:vsub没有立即数版本。为什么?因为你可以用vadd.vi加一个负数来实现。RISC-V的设计哲学就是精简,能省则省。

我在项目中遇到过一个问题:两个向量相加,结果溢出了。很多人以为向量指令会自动处理溢出,其实不会。RISC-V向量扩展默认是饱和运算关闭的,溢出就直接截断。如果你需要饱和运算,得用vsaddvssub这些变种。

关键点:vadd和vsub支持三种数据源组合:向量-向量(.vv)、向量-标量(.vx)、向量-立即数(.vi)。立即数范围通常是5位有符号数,也就是-16到15。

4.2 vmul / vdiv:乘除法,小心陷阱

乘法指令比加法复杂一些。RISC-V向量扩展提供了多种乘法变种:

vmul.vv vd, vs2, vs1    # 低精度乘法(保留低半部分)
vmulh.vv vd, vs2, vs1   # 高精度乘法(保留高半部分)
vmulhu.vv vd, vs2, vs1  # 无符号高精度乘法
vmulhsu.vv vd, vs2, vs1 # 有符号-无符号混合高精度乘法

你可能会问:为什么要有这么多乘法指令?

原因很简单:两个N位数相乘,结果是2N位。但向量寄存器宽度是固定的,你只能存一半。所以RISC-V让你自己选——要低半部分还是高半部分。

我记得有一次做音频处理,需要做定点数乘法。当时我直接用vmul,结果精度完全不对。后来才发现,定点数乘法需要保留高半部分,应该用vmulh。这个坑我踩过,希望大家别重蹈覆辙。

除法指令相对简单:

vdiv.vv vd, vs2, vs1   # 有符号除法
vdivu.vv vd, vs2, vs1  # 无符号除法

警告:除法指令的延迟通常比乘法高很多。在我测试过的某款RISC-V处理器上,vdiv需要20多个周期,而vmul只要3-4个周期。所以,能用乘法替代除法的地方,尽量用乘法。

4.3 vmin / vmax:找极值,比你想的更有用

vminvmax指令用来比较两个向量中对应元素的大小,取较小或较大的那个:

vmin.vv vd, vs2, vs1   # 取最小值
vmax.vv vd, vs2, vs1   # 取最大值

这两条指令看似简单,但实际用途很广。比如做图像处理时做像素裁剪(clamp),或者做数据归一化时的边界处理。

我曾经在做一个神经网络推理加速器时,ReLU激活函数就用vmax来实现——把输入和0比较,取最大值,就是ReLU的输出。一条指令搞定,效率极高。

小技巧:如果你想实现「把向量中所有元素限制在[a, b]范围内」,可以这样写:

vmax.vx vd, vs, a    # 下限裁剪
vmin.vx vd, vd, b    # 上限裁剪

两条指令,干净利落。

4.4 vneg:取反,简单但容易忽略

vneg指令用来对向量中的每个元素取相反数:

vneg.v vd, vs         # vd[i] = -vs[i]

这条指令其实可以用vrsub(向量反向减法)来实现,但RISC-V专门提供了vneg,说明它在实际编程中很常见。

嗯,这里有个细节:vneg对整数和浮点数都有效。但对整数来说,最小的负数(比如int8的-128)取反后会溢出,结果还是-128。这个行为在IEEE 754标准里是定义好的,但很多人第一次遇到时会懵。

4.5 实战:向量运算的组合应用

光讲指令没意思,咱们来点实际的。假设你要实现一个向量归一化函数:

# 输入:向量 vs,标量 scale
# 输出:vd[i] = (vs[i] - min) * scale / (max - min)

# 第一步:找最小值和最大值
vmin.vv vmin_val, vs, vs   # 先复制一份
vmax.vv vmax_val, vs, vs
# 这里需要做规约操作(后面章节会讲),先假设已经得到min和max

# 第二步:计算范围
vsub.vx vrange, vmax_val, vmin_val  # range = max - min

# 第三步:减去最小值
vsub.vx vshifted, vs, vmin_val       # shifted = vs - min

# 第四步:缩放
vdiv.vv vnorm, vshifted, vrange      # 归一化到[0,1]
vmul.vx vresult, vnorm, scale        # 缩放到目标范围

你看,几条向量指令组合起来,就能完成复杂的运算。而且每条指令都是对整条向量操作,没有循环开销。

性能提示:在实际编码时,尽量把vdiv替换成vmul。比如上面的例子,如果scalerange都是固定的,可以预先计算inv_range = scale / range,然后用一次乘法代替除法和乘法:

vmul.vx vresult, vshifted, inv_range  # 一条指令搞定

性能提升立竿见影。

4.6 避坑指南

最后,分享几个我踩过的坑:

  • 数据类型匹配:向量指令的操作数必须类型一致。你不能把int32和float32混在一起做vadd,编译器不会报错,但结果完全不对。
  • 向量长度管理:使用vsetvli设置向量长度后,后续指令只处理前vl个元素。我刚开始时经常忘记设置,导致只处理了部分数据。
  • 溢出问题:整数运算溢出不会抛出异常,结果直接截断。如果你需要饱和运算,记得用vsaddvssub这些指令。
  • 除法性能:前面说过,除法很慢。在性能敏感的场景下,能避免就避免。

好了,这一章的内容就到这里。向量算术运算看似简单,但用好它们需要你对数据流和性能有清晰的认识。下一章咱们会讲更高级的运算——乘加运算和点积,那才是向量处理器的真正杀手锏。