4. 向量算术运算基础:vadd/vsub、vmul/vdiv、vmin/vmax、vneg指令详解
好,咱们直接进入正题。向量算术运算,说白了就是让处理器一次性处理一堆数据。你想想看,如果让你对着一个数组里的1000个数做加法,用标量指令你得循环1000次,但用向量指令,一条vadd就搞定了。这就是向量扩展的魅力所在。
我个人习惯把向量算术指令分成三类:基本运算、比较运算、和取反运算。今天咱们就把这几类都过一遍。
4.1 vadd / vsub:加减法,最常用的基本功
vadd和vsub是向量指令集里最基础的指令。它们的格式非常直观:
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向量扩展默认是饱和运算关闭的,溢出就直接截断。如果你需要饱和运算,得用vsadd和vssub这些变种。
关键点: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:找极值,比你想的更有用
vmin和vmax指令用来比较两个向量中对应元素的大小,取较小或较大的那个:
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。比如上面的例子,如果scale和range都是固定的,可以预先计算inv_range = scale / range,然后用一次乘法代替除法和乘法:
vmul.vx vresult, vshifted, inv_range # 一条指令搞定
性能提升立竿见影。
4.6 避坑指南
最后,分享几个我踩过的坑:
- 数据类型匹配:向量指令的操作数必须类型一致。你不能把int32和float32混在一起做
vadd,编译器不会报错,但结果完全不对。 - 向量长度管理:使用
vsetvli设置向量长度后,后续指令只处理前vl个元素。我刚开始时经常忘记设置,导致只处理了部分数据。 - 溢出问题:整数运算溢出不会抛出异常,结果直接截断。如果你需要饱和运算,记得用
vsadd、vssub这些指令。 - 除法性能:前面说过,除法很慢。在性能敏感的场景下,能避免就避免。
好了,这一章的内容就到这里。向量算术运算看似简单,但用好它们需要你对数据流和性能有清晰的认识。下一章咱们会讲更高级的运算——乘加运算和点积,那才是向量处理器的真正杀手锏。