title:RVV向量化

注意:

首先硬件可能支持不同版本的向量扩展,这之间不同版本互不兼容。

qemu上虽然可以跑RVV程序,但是开销比标量运算还大,所以只能做程序验证,实际运行效率要上实际的riscvgv的机器上才行。

ubuntu22.04尽管使用qemu但是也不支持rvv,因为kernel内核中的mstatus寄存器(机器模式下的状态寄存器)未初始化,当mstatus.vs域值被写0时候,试图执行向量指令或访问向量寄存器均会引发非法指令异常。

自动向量化和手动向量化

关于向量化:

  1. 对GCC或者LLVM进行开发(自动向量化)

​ **前端编译扩展 Target Transform Info (TTI)**:

  • 定义目标架构的向量化能力(RVV 支持可扩展向量)。
  • 例如为 RISC-V 增强 getRegisterBitWidthgetTypeLegalizationCost 方法。

修改 Loop 和 SLP Vectorizer

  • 添加对 RVV 动态向量寄存器的支持。
  • 修改默认的向量化策略以生成动态向量类型。

支持 RVV 的 IR 到目标代码映射

  • 在后端的 SelectionDAGGlobalISel 中实现 RVV 指令的生成逻辑。
  1. Intrinsic函数的开发(手动向量化)
    • 本质上跳过了自动向量化的逻辑,不需要向量化Pass介入。
    • 直接提供了向量化代码的“翻译规则”,编译器无需再生成向量化逻辑或 IR,代码中调用的 Intrinsic 已经指定了目标平台指令。
  2. 自动向量化与显式使用 Intrinsic 的关系
    • 显式调用 Intrinsic:
      • 开发者直接编写 Intrinsic,相当于手动向量化。
      • 编译器只负责将这些 Intrinsic 映射到硬件指令,而不会尝试进行进一步优化。
      • 优点:避免冗余优化,无需等待编译器的进一步支持。
      • 缺点:增加了维护成本。
    • 自动向量化:
      • 开发者编写标量代码,向量化 Pass 根据硬件平台的能力和代码逻辑,自动生成调用 Intrinsic 的 IR。
      • 编译器根据向量化 Pass 生成的规则,优化代码并映射到目标平台指令。

向量扩展

RISC-V 向量扩展(RVV)是一种灵活的矢量处理架构,允许程序在运行时动态调整向量长度(VL),以适应不同硬件实现的特性(例如矢量寄存器大小)。

概念

1. 向量寄存器

RVV 定义了一组 向量寄存器(Vector Registers, VRs)

  • 寄存器名:v0v31,共 32 个。
  • 每个寄存器的实际宽度(VLEN,以位为单位)由硬件实现决定。例如,硬件可能支持 128 位、256 位或 512 位寄存器。
  • 基本元素宽度(SEW,Standard Element Width):表示每个向量元素的大小,例如 8-bit, 16-bit, 32-bit, 64-bit
  • 每个寄存器可以存储的最大元素个数为 VLEN / SEW,称为 最大向量长度(VLmax)

补充:一个向量元素的最大位宽为ELEN(<VLEN)

本文的位宽都是2的次幂

2.LMUL(Vector register group multiplier)

当一个向量寄存器不够用,就将多个寄存器组进行合并叫做LMUL((合并寄存器组数量),有合并就有拆解,可以分份:1/2,1/4,1/8 (小数表示一个向量寄存器的被使用的位宽)
$$
LMUL >= \frac{SEW}{ELEN}
$$

向量长度(VL)

  • 动态向量长度(VL) 是指当前有效的向量长度(单位:元素个数),由程序通过指令动态设置。
  • 动态设置使得程序可以在剩余数据不足时调整向量长度,从而避免浪费计算资源。

设置向量长度

1
size_t vl = __riscv_vsetvl_e32m8(n);

例如这个函数:获取32位数据(SEW),并且8倍寄存器宽度倍数(LMUL)的数据长度,如果硬件支持的矢量寄存器长度位VLEN是128位,那么根据公式
$$
元素数目 = ( \frac{\text{VLEN}}{\text{SEW}} \times \text{LMUL} = \frac{128}{32} \times 8 = 32 )
$$
因此这个函数是说明一次性处理32个32位数据。

1
size_t vl = __riscv_vsetvl_e16m1(n);

还假设VLEN = 128,这个函数将设置一次性处理8个16位数据。

如何计算最终的 VL

对于每次向量指令的实际运行,向量长度(VL)取决于待处理数据的个数 n 和硬件寄存器的处理能力 VLmax(即硬件最大能处理的数据量)。通常 VL 取以下值:
$$
VL = \min(n, \frac{VLEN}{SEW} \times LMUL)
$$

即从待处理元素数 n 和硬件支持的最大向量长度 VLmax 中选择较小的那个值。

  • vsetvl_e32m8:假设有 100 个 32 位数据,硬件支持最大处理 32 个 32 位数据,那么: VL=min⁡(100,32)=32VL = \min(100, 32) = 32VL=min(100,32)=32
  • vsetvl_e16m1:假设有 10 个 16 位数据,硬件支持最大处理 8 个 16 位数据,那么: VL=min⁡(10,8)=8VL = \min(10, 8) = 8VL=min(10,8)=8

这种动态调整向量长度的方式使得 RVV 架构能够灵活适应不同大小的数据处理,优化性能。

通用Intrinsic编程(手动向量化)

  1. 使用适当的向量长度设置函数
  2. 采用合适数据加载存储函数
  3. 通过向量操作(乘法、加法等)实习复杂运算
  4. 逐步迭代处理数据

IntrinsicAPI函数解析

Intrinsic 命名遵循着以下通用规则:

例如:

1
__riscv_vle8_v_i8mf4

构成为:__riscv_前缀,在最新的版本中这个前缀不能省略。

执行操作常用的有:vadd加法,vmul乘法,vle向量加载,vse向量存储,vsetvl设置向量长度。

数据类型:

  • u32:无符号32整形

  • f64: 64位浮点型

操作对象是向量还是标量,通过后缀区分,以下为常用操作:

  • vv:两个向量操作
  • vx:向量与标量操作
  • vi:向量与立即数操作
  • v:针对向量的

LMUL表示向量寄存器逻辑长度是基础寄存器长度的倍数,即VLEN的倍数,例如:

  • LMUL = 1: 一个逻辑向量寄存器等于一个物理向量寄存器。

  • LMUL = 2: 一个逻辑向量寄存器需要两个物理向量寄存器。

  • LMUL = 1/2 或 1/4: 一个物理向量寄存器被划分为多个逻辑寄存器

_tumu是操作掩码

在执行时会参考一个布尔掩码向量(类型通常是 vboolN_t)。掩码决定了哪些向量元素参与计算:

  • 掩码值为 1true):参与计算。
  • 掩码值为 0false):跳过计算,通常保留原值或设置为零,具体行为由指令定义。

eg:

1
vint32m1_t __riscv_vadd_vv_i32m1_m(vbool32_t mask, vint32m1_t dest, vint32m1_t op1, vint32m1_t op2);

参数解析:

  • mask: 掩码向量,类型为 vbool32_t,控制哪些元素参与运算。
  • dest: 原目标向量,对于未参与计算的元素,将保留此向量中的对应值。
  • op1, op2: 输入向量,表示两个操作数。

输入:

  • op1 = {1, 2, 3, 4, 5, 6, 7, 8}
  • op2 = {8, 7, 6, 5, 4, 3, 2, 1}
  • mask = {1, 0, 1, 1, 0, 0, 1, 0}

结果:

  • 按掩码启用元素计算:{(1+8), 0, (3+6), (4+5), 0, 0, (7+2), 0}
  • 最终结果:{9, 0, 9, 9, 0, 0, 9, 0}

eg:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
#include <riscv_vector.h>
#include <stdio.h>

#define N 4
int main(){
vint32m1_t va, vb, vc;
size_t vlmax = __riscv_vsetvlmax_e32m1();

int32_t ia[N] = {1,3,5,7};
int32_t ib[N] = {0,2,4,8};
int32_t ic[N] = {0,0,0,0};
va = __riscv_vle32_v_i32m1(ia, N);
vb = __riscv_vle32_v_i32m1(ib, N);
vc = __riscv_vadd_vv_i32m1(va, vb, N);
__riscv_vse32_v_i32m1(ic, vc, N);

for( unsigned i=0; i < N; ++i)
printf("%d", ic[i]);
printf("\n");
return 0;
}

在实际应用中,选择 SEW 时需要综合考虑以下因素:

  1. 数据类型(决定了基本的 SEW)。
  2. 精度要求(高精度需要 64 位,性能优先则选择 32 位)。
  3. 硬件资源(寄存器长度和支持的 SEW)。
  4. 并行性能(更小的 SEW 提高并行性,但可能导致精度不足)。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
#include "common.h"
#include <riscv_vector.h>
#include <time.h>

// 计算时间差的辅助函数
void calc_elapsed_time(const char *label, struct timespec start, struct timespec end) {
long seconds = end.tv_sec - start.tv_sec;
long nanoseconds = end.tv_nsec - start.tv_nsec;
if (start.tv_nsec > end.tv_nsec) {
--seconds;
nanoseconds += 1000000000;
}
printf("%s elapsed time: %ld.%09ld seconds\n", label, seconds, nanoseconds);
}

// index arithmetic
void index_golden(double *a, double *b, double *c, int n) {
for (int i = 0; i < n; ++i) {
a[i] = b[i] + (double)i * c[i];
}
}

void index_(double *a, double *b, double *c, int n) {
size_t vlmax = __riscv_vsetvlmax_e32m1();
vuint32m1_t vec_i = __riscv_vid_v_u32m1(vlmax);
for (size_t vl; n > 0; n -= vl, a += vl, b += vl, c += vl) {
vl = __riscv_vsetvl_e64m2(n);

vfloat64m2_t vec_i_double = __riscv_vfwcvt_f_xu_v_f64m2(vec_i, vl);

vfloat64m2_t vec_b = __riscv_vle64_v_f64m2(b, vl);
vfloat64m2_t vec_c = __riscv_vle64_v_f64m2(c, vl);

vfloat64m2_t vec_a =
__riscv_vfadd_vv_f64m2(vec_b, __riscv_vfmul_vv_f64m2(vec_c, vec_i_double, vl), vl);
__riscv_vse64_v_f64m2(a, vec_a, vl);

vec_i = __riscv_vadd_vx_u32m1(vec_i, vl, vl);
}
}

int main() {
const int N = 31;
const uint32_t seed = 0xdeadbeef;
srand(seed);

// data gen
double B[N], C[N];
gen_rand_1d(B, N);
gen_rand_1d(C, N);

// compute
double golden[N], actual[N];

// 执行黄金实现
struct timespec start1, end1;
clock_gettime(CLOCK_REALTIME, &start1);
index_golden(golden, B, C, N);
clock_gettime(CLOCK_REALTIME, &end1);
calc_elapsed_time("Golden implementation", start1, end1);

// 执行优化实现
struct timespec start2, end2;
clock_gettime(CLOCK_REALTIME, &start2);
index_(actual, B, C, N);
clock_gettime(CLOCK_REALTIME, &end2);
calc_elapsed_time("Vector implementation", start2, end2);

// compare
puts(compare_1d(golden, actual, N) ? "pass" : "fail");
}

[^为什么仍需要循环?]: 为什么仍需要循环?向量寄存器长度有限,向量寄存器(例如 RVV 中的 VLEN)有固定的硬件限制。例如,VLEN 是 128、256 或 512 位等。每次操作能处理的元素数量取决于:数据类型的大小 (SEW: Scalar Element Width)。当前配置的 LMUL 倍数。如果数据量 n 超过硬件支持的向量长度,就必须分块处理,循环每次操作一个向量块。数据长度不一定是向量寄存器的倍数 数据长度 n 通常不是向量寄存器所能处理的元素数(VLEN / SEW)的整数倍,因此需要动态调整向量长度 (VL) 并处理剩余的尾部数据。循环有助于动态优化 向量化可以动态调整每次处理的元素数,利用循环灵活适配各种数据量。

向量化优化影响要素

1. 数据对齐 (Data Alignment)

向量指令通常要求数据在内存中是对齐的(aligned),以便更高效地加载和存储:

  • 对齐数据(如 64 位数据按 8 字节对齐)可以显著提高加载/存储性能。
  • 未对齐数据需要额外处理(例如使用 vle64ff 等指令),可能导致性能下降。

优化建议:尽量确保输入数据地址是对齐的,尤其在加载和存储时。

2. 数据大小与边界处理

向量化时,处理的数据大小往往不是向量寄存器长度的整数倍:

  • 完整向量操作:在主要循环中可以使用完整的向量寄存器。
  • 尾部处理:剩余的元素(小于寄存器宽度)需要额外的标量代码或专用尾处理逻辑。RVV 中可以用 vl 动态调整处理的元素数量。

优化建议

  • 利用 RVV 的动态向量长度特性,通过调整 vl 来处理剩余数据,无需显式的标量处理代码。

3. 数据依赖 (Data Dependency)

  • 如果当前迭代的计算依赖于前一迭代的结果(循环相关性),会限制向量化的潜力。
  • RVV 提供的掩码操作(masking)可以处理部分依赖问题,但性能可能下降。

优化建议:尝试重写算法,减少循环相关性或数据依赖。

4. 向量化指令集的特性

  • RVV 的灵活性:RVV 支持可变长度向量和 SEW,这使得编程灵活性更高,但需要仔细设计循环逻辑以充分利用硬件。
  • 向量扩展指令(例如 vfwcvt):将低精度数据扩展为高精度时会引入额外开销。
  • 掩码(Mask)操作:对部分元素操作时使用掩码,但这会降低性能,因为部分元素的计算会被跳过。

5. 分支与条件判断

在向量化代码中,分支和条件判断可能导致性能下降:

  • RVV 中可以通过掩码指令(masking)处理条件分支,但仍会产生一些冗余操作。
  • 如果分支逻辑复杂且数据不均匀,可能需要拆分数据进行分别处理。

优化建议

  • 尽量减少分支逻辑,或将分支替换为掩码操作。
  • 优化数据布局,使条件判断逻辑更简单。

6. 向量长度和 LMUL 配置

  • RVV 的向量寄存器长度(VLEN)和宽度配置(LMUL)会影响并行性能:

    • LMUL 增大(如 m4m8):更多寄存器组合,处理更多数据,性能更高,但资源消耗增加。
    • LMUL 减小(如 mf2mf4):每次操作处理的元素少,功耗低,但并行度下降。

优化建议:选择合适的 LMUL,在寄存器使用率和并行度之间找到平衡。

7. 循环展开 (Loop Unrolling)

  • 手动展开循环可以减少循环开销(如分支和索引计算),提高性能。
  • 但循环展开会增加代码复杂度,可能需要调整以适应不同 VLEN

优化建议:根据具体硬件配置展开循环,以充分利用寄存器。