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 知识体系总览

下面这张图总结了本章的核心内容:

向量算术运算知识体系 加减乘除 vadd / vsub / vmul / vdiv 乘加融合 vfmacc / vfmadd 比较与选择 vmseq / vmerge 类型转换 vfcvt / vfcvt.rtz 关键要点:向量长度控制、掩码寄存器管理、融合指令精度优势、类型转换延迟隐藏 注意事项 • 整数溢出检查 • 浮点精度控制 • 向量长度动态调整 • 指针前进步长 优势 • 减少指令数量 • 降低寄存器压力 • 更高计算精度 • 更低延迟 应用场景 • ReLU激活函数 • 边界裁剪 • 条件累加 • 数据过滤 优化策略 • 提到循环外 • 隐藏延迟 • 整数运算替代 • 饱和处理 核心原则:理解指令语义 → 选择合适指令 → 管理向量长度 → 优化数据流 每个运算类型都有对应的优化技巧,组合使用才能发挥向量化最大威力

4.6 实战建议

最后,我总结几条实战经验:

  • 先写标量版本,验证正确性,再向量化。别一上来就写向量代码,调试起来很痛苦。
  • 用 vsetvli 动态调整长度,别硬编码。不同硬件支持的向量长度不同。
  • 优先用融合指令,减少指令数量,提升精度。
  • 掩码寄存器用完就清,避免残留数据影响后续操作。
  • 类型转换尽量少做,如果数据范围允许,全程用同一种类型。