前言

2020 年代,AI 算力的需求呈现出爆炸式增长。从大语言模型的推理,到计算机视觉的实时处理,再到科学计算的海量数据处理,计算领域对数据并行处理能力的需求从未如此迫切。传统的标量 CPU 虽然通用,但面对海量重复运算时显得力不从心;GPU 虽然并行能力强大,但功耗和延迟问题使其难以在嵌入式和端侧场景中广泛应用。

正是在这样的背景下,RISC-V 向量扩展(RISC-V Vector Extension,简称 RVV) 应运而生。作为 RISC-V 指令集架构的官方标准扩展,RVV 提供了一套灵活、可扩展的向量处理机制,能够以远低于 GPU 的功耗和延迟,实现高效的数据并行计算。从低功耗的 IoT 设备,到高性能的服务器 CPU,RVV 正在成为 RISC-V 生态中最具变革性的技术之一。

RVV 的设计哲学与传统的 SIMD 扩展(如 x86 的 SSE/AVX、ARM 的 NEON/SVE)有着本质的不同。它不是简单地固定宽度的向量寄存器堆,而是引入了运行时可配置向量长度向量寄存器分组掩码操作等一系列创新设计,使得同一份 RVV 代码能够在不同硬件实现上高效运行,真正实现了"一次编写,处处加速"。

本文将从底层原理出发,带你深入理解 RVV 1.0 规范的设计精髓,通过完整的代码示例,手把手教你掌握 RVV 编程和优化技巧。无论你是芯片架构师、系统工程师,还是想要在 RISC-V 平台上优化算法性能的开发者,这篇文章都会为你提供完整的知识体系和实战指南。

RVV 架构概览

一、为什么我们需要向量扩展?

在深入探讨 RVV 的具体细节之前,让我们先回答一个最基本的问题:为什么 CPU 需要向量扩展?

1.1 数据级并行的本质

现代计算任务中,绝大多数密集运算都具有一个共同的特征:对大量数据执行相同的操作。例如:

  • 图像卷积:对每个像素点执行相同的乘加运算
  • 矩阵乘法:大量的元素级乘累加操作
  • 神经网络推理:张量之间的批量运算
  • 信号处理:FFT、滤波等时域频域变换

这种"单指令,多数据"的模式,正是向量计算能够发挥巨大优势的场景。如果用传统的标量指令来处理这些任务,每个数据元素都需要取指、译码、执行一次,这会造成巨大的指令开销和控制开销。而向量指令可以在一条指令中处理数十甚至上百个数据元素,将指令吞吐量提升一个数量级。

1.2 传统 SIMD 的局限性

在 RVV 出现之前,主流 CPU 架构都有自己的 SIMD 扩展:

  • x86: SSE、AVX、AVX2、AVX-512,向量宽度从 128 位逐步增加到 512 位
  • ARM: NEON(128 位固定宽度)、SVE(可伸缩向量)
  • MIPS: MSA(128 位向量)

这些传统 SIMD 扩展虽然在特定场景下表现出色,但普遍存在几个问题:

固定向量宽度的耦合:传统 SIMD 的向量宽度是硬件定义的,软件必须针对特定宽度编写代码。当硬件升级(如从 AVX2 升级到 AVX-512)时,软件需要重新编写才能利用更大的向量宽度。

寄存器资源浪费:对于较小的数据类型(如 8 位、16 位),固定宽度的向量寄存器虽然能存放更多元素,但指令集往往缺乏灵活的类型转换和操作支持。

代码可移植性差:不同架构、甚至同一架构的不同代之间,SIMD 指令集往往不兼容。为了在多平台上获得最佳性能,开发者需要维护多个版本的 SIMD 代码。

1.3 RVV 的设计突破

RVV 的设计者们深刻认识到了传统 SIMD 的这些问题,提出了一系列创新的设计理念:

  • 运行时可配置向量长度:软件在运行时查询硬件支持的最大向量长度(VLEN),然后根据实际需求设置当前操作的向量长度(vl)。同一份代码在不同 VLEN 的硬件上都能正确运行。

  • 灵活的数据类型支持:向量元素宽度(SEW)可以在运行时配置,支持 8、16、32、64 位的整数和浮点数。

  • 向量寄存器分组(LMUL):通过配置 LMUL 参数,可以将多个物理寄存器组合成一个逻辑寄存器组,在需要更多元素时动态扩展向量长度。

  • 内置掩码支持:每条向量指令都支持掩码操作,可以只处理向量中的部分元素,避免了传统 SIMD 中复杂的掩码处理逻辑。

这些设计使得 RVV 兼具了高性能灵活性可移植性,代表了下一代向量处理架构的发展方向。

二、RVV 1.0 规范的设计哲学

RVV 1.0 规范于 2021 年正式冻结,标志着 RISC-V 向量扩展进入了稳定可用的阶段。理解 RVV 的设计哲学,是掌握 RVV 编程的第一步。

2.1 向量长度的解耦:VLEN vs vl

RVV 中最核心的创新,就是将硬件实现的向量长度软件使用的向量长度完全解耦。

VLEN(Vector Length):硬件实现的每个向量寄存器的位数,是一个硬件参数,在不同芯片上可以有不同的取值。RVV 规范要求 VLEN ≥ 128 位,且必须是 2 的幂。常见的 VLEN 配置有 128、256、512、1024 位等。

vl(vector length 寄存器):软件在运行时设置的当前向量操作的元素个数。每次向量指令执行时,只会处理前 vl 个元素,超出的部分保持不变。

这种设计带来了巨大的灵活性:

// 查询硬件支持的最大向量长度(以位为单位)
uint32_t vlen = __riscv_vlenb() * 8;

// 设置当前向量长度为 16 个元素
vsetvl(16, SEW_32, LMUL_1);

// 这条指令只会处理 16 个元素
vadd.vv(v0, v1, v2);

当软件设置的 vl 超过硬件能够支持的最大值时,硬件会自动将其截断为最大支持值。这意味着同一份代码在 VLEN=128 的芯片上可能每次处理 4 个 32 位元素,在 VLEN=512 的芯片上可能每次处理 16 个 32 位元素,但代码逻辑完全不需要修改。

2.2 向量类型配置:vtype 寄存器

除了向量长度 vl,RVV 还通过 vtype 向量类型寄存器来控制向量操作的行为。vtype 包含以下关键字段:

  • SEW(Standard Element Width):标准元素宽度,指定向量元素的大小,可以是 8、16、32、64 位
  • LMUL(Vector Register Grouping Multiplier):向量寄存器分组倍数,决定每个逻辑向量使用多少个物理寄存器
  • vediv:向量元素宽度除数,用于窄化操作
  • vta(Vector Tail Agnostic):向量尾元素策略,指定超过 vl 的元素是否可以被硬件任意修改
  • vma(Vector Mask Agnostic):向量掩码策略,指定被掩码屏蔽的元素是否可以被硬件任意修改

每次执行向量指令前,都需要通过 vsetvlvsetivli 指令配置这些参数。这看起来增加了一条额外的指令,但实际上这为编译器和硬件提供了巨大的优化空间。

2.3 向量寄存器堆:v0 - v31

RVV 定义了 32 个向量寄存器 v0v31,每个寄存器的宽度为 VLEN 位。这些向量寄存器在逻辑上是独立的,但通过 LMUL 参数可以组合成更大的逻辑寄存器组。

值得注意的是,v0 具有特殊的地位——它是默认的掩码寄存器。当一条向量指令需要使用掩码时,如果没有指定其他掩码寄存器,就会默认使用 v0 中的掩码值。

三、向量寄存器分组(LMUL)机制详解

向量寄存器分组(LMUL)是 RVV 中最巧妙、也是最容易让人困惑的设计之一。理解 LMUL 的工作原理,是掌握 RVV 性能优化的关键。

RVV 向量寄存器分组

3.1 LMUL 的基本概念

LMUL(Vector Register Grouping Multiplier)决定了每个逻辑向量使用多少个物理向量寄存器。RVV 1.0 支持以下 LMUL 值:

  • 分数 LMUL:1/8、1/4、1/2 —— 一个物理寄存器可以容纳多个逻辑向量
  • 整数 LMUL:1、2、4、8 —— 一个逻辑向量占用多个物理寄存器

为什么需要这样的设计?这要从向量操作的两类需求说起:

  1. 需要更多元素:当处理大量相同类型的数据时,我们希望每次向量指令能处理尽可能多的元素,以摊薄指令开销。
  2. 需要更多临时变量:当实现复杂算法时,我们需要更多的向量寄存器来存放中间结果,避免频繁的寄存器溢出。

LMUL 机制正是为了在这两类需求之间取得平衡。

3.2 VLMAX 的计算

对于给定的 SEW 和 LMUL,硬件能够支持的最大向量元素个数 VLMAX 的计算公式为:

VLMAX = (VLEN × LMUL) / SEW

让我们通过几个例子来理解这个公式(假设 VLEN = 256 位):

LMULSEWVLMAX 计算结果说明
132 位(256 × 1) / 328 元素每个逻辑向量使用 1 个物理寄存器
432 位(256 × 4) / 3232 元素每个逻辑向量使用 4 个物理寄存器
1/832 位(256 × 1/8) / 321 元素8 个逻辑向量共享 1 个物理寄存器

可以看到,增大 LMUL 可以增加每次向量操作的元素个数,但代价是减少了可用的逻辑向量寄存器数量。例如,当 LMUL = 8 时,32 个物理寄存器只能提供 4 个逻辑向量寄存器(v0, v8, v16, v24)。

3.3 LMUL 的选择策略

在实际编程中,如何选择合适的 LMUL 值?这里有几个基本原则:

优先使用 LMUL = 1:这是最通用的配置,既能获得合理的向量长度,又能保留完整的 32 个向量寄存器用于算法实现。

计算密集型内核用大 LMUL:如果你的算法非常简单(如向量加法、点积),只需要很少的临时变量,那么使用 LMUL = 2 或 LMUL = 4 可以增加每次处理的元素数,提升计算吞吐量。

复杂算法用 LMUL = 1 或分数 LMUL:如果算法需要很多中间结果,或者有很多依赖关系,那么保留更多的向量寄存器比增加向量长度更重要。分数 LMUL 特别适合处理需要大量临时变量的场景。

根据数据类型调整:对于较小的数据类型(如 8 位),LMUL = 1 可能已经提供了足够的元素个数(VLEN=256 时是 32 个元素),不需要更大的 LMUL。

(第一部分完,约2300字)

四、RVV 指令集分类详解

RVV 1.0 规范定义了超过 150 条向量指令,涵盖了向量计算的各个方面。按照功能分类,我们可以将这些指令分为以下几大类。

4.1 向量配置指令

向量配置指令用于设置 vlvtype 寄存器,是执行任何向量操作前的"开场白"。

vsetvl(Vector Set Vector Length)

vsetvl rd, rs1, vtypei

这条指令根据 rs1 中的请求长度和 vtypei 编码的向量类型,计算实际可用的向量长度,并将其写入 rdvl 寄存器。

在 C intrinsic 中,对应的函数是:

size_t vsetvl_e32m1(size_t avl) {
    return __riscv_vsetvl_e32m1(avl);
}

vsetivli(Vector Set Immediate Vector Length)

vsetivli rd, uimm, vtypei

这是 vsetvl 的立即数版本,适合请求长度是编译期常量的情况。

4.2 向量 Load/Store 指令

RVV 的访存指令是其灵活性的重要体现,支持多种访存模式:

单位步长访存(Unit-Stride)

// 从连续的内存地址加载向量
vint32m1_t vle32_v_i32m1(const int32_t *base, size_t vl);

// 将向量存储到连续的内存地址
void vse32_v_i32m1(int32_t *base, vint32m1_t value, size_t vl);

这是最常用的访存模式,相当于传统 SIMD 的连续加载/存储。

跨步访存(Strided)

// 每隔 stride 个字节加载一个元素
vint32m1_t vlse32_v_i32m1(const int32_t *base, ptrdiff_t stride, size_t vl);

// 每隔 stride 个字节存储一个元素
void vsse32_v_i32m1(int32_t *base, ptrdiff_t stride, vint32m1_t value, size_t vl);

跨步访存非常适合处理矩阵的列访问,或者数组中特定间隔的元素。

索引访存(Indexed)

// 根据索引数组中的偏移量加载元素
vint32m1_t vluxei32_v_i32m1(const int32_t *base, vuint32m1_t bindex, size_t vl);

// 根据索引数组中的偏移量存储元素
void vsuxei32_v_i32m1(int32_t *base, vuint32m1_t bindex, vint32m1_t value, size_t vl);

索引访存支持对任意地址的数据进行 gather/scatter 操作,这是传统 SIMD 很难高效实现的功能。

段访存(Segment)

// 加载结构体数组(AoS)格式的数据
void vlsseg3e32_v_i32m1(vint32m1_t *v0, vint32m1_t *v1, vint32m1_t *v2, 
                         const int32_t *base, ptrdiff_t stride, size_t vl);

段访存指令可以一次性加载多个字段,完美支持结构体数组(AoS)的访问模式,避免了复杂的数据重排。

4.3 向量算术运算指令

RVV 提供了完整的算术运算指令,支持整数和浮点数:

向量-向量运算

// 向量加法
vint32m1_t vadd_vv_i32m1(vint32m1_t op1, vint32m1_t op2, size_t vl);

// 向量乘法
vint32m1_t vmul_vv_i32m1(vint32m1_t op1, vint32m1_t op2, size_t vl);

// 向量乘累加
vint32m1_t vmacc_vv_i32m1(vint32m1_t acc, vint32m1_t op1, vint32m1_t op2, size_t vl);

向量-标量运算

// 向量加标量
vint32m1_t vadd_vx_i32m1(vint32m1_t op1, int32_t op2, size_t vl);

// 向量乘标量
vint32m1_t vmul_vx_i32m1(vint32m1_t op1, int32_t op2, size_t vl);

饱和运算

// 饱和加法(结果不会溢出)
vint8m1_t vadd_sat_vv_i8m1(vint8m1_t op1, vint8m1_t op2, size_t vl);

饱和运算在图像处理和信号处理中非常有用,可以避免溢出导致的 artifacts。

4.4 向量比较与掩码指令

RVV 的每条指令都支持掩码操作,这是其重要特性之一。

向量比较

// 向量相等比较,生成掩码
vbool8_t vmseq_vv_i32m1_b8(vint32m1_t op1, vint32m1_t op2, size_t vl);

// 向量大于比较
vbool8_t vmsgt_vv_i32m1_b8(vint32m1_t op1, vint32m1_t op2, size_t vl);

比较指令的结果是一个掩码向量,每个元素是一个布尔值。

掩码化操作

// 带掩码的向量加法
vint32m1_t vadd_vv_i32m1_m(vbool8_t mask, vint32m1_t maskedoff, 
                           vint32m1_t op1, vint32m1_t op2, size_t vl);

mask 中的元素为 true 时,执行对应的运算;为 false 时,保留 maskedoff 中的值(或根据 vma 策略处理)。

4.5 向量置换与重排指令

向量置换指令用于重新排列向量中的元素,这是很多算法中的关键操作。

滑动窗口(Slide)

// 将向量向右滑动,左边填充新元素
vint32m1_t vslide1up_vx_i32m1(vint32m1_t dest, int32_t src, size_t vl);

滑动指令在实现 FIR 滤波器、滑动窗口求和等算法时非常高效。

收集(Gather)

// 根据索引向量收集元素
vint32m1_t vrgather_vv_i32m1(vint32m1_t op1, vuint32m1_t op2, size_t vl);

压缩(Compress)

// 根据掩码将向量中的有效元素压缩到一起
vint32m1_t vcompress_vm_i32m1(vbool8_t mask, vint32m1_t dest, 
                              vint32m1_t src, size_t vl);

压缩指令在实现条件过滤等操作时非常有用。

4.6 向量归约指令

归约指令将向量中的所有元素合并成一个标量结果。

// 向量求和归约
int32_t vredsum_vs_i32m1_i32(int32_t dst, vint32m1_t vector, 
                              int32_t scalar, size_t vl);

// 向量最大值归约
int32_t vredmax_vs_i32m1_i32(int32_t dst, vint32m1_t vector, 
                              int32_t scalar, size_t vl);

归约指令在实现点积、求和、找最值等操作时非常高效。

五、RVV 编程入门:从汇编到 Intrinsic

理解了 RVV 的原理和指令集后,现在让我们通过实际的代码示例来学习 RVV 编程。

5.1 环境准备

要编译 RVV 代码,你需要支持 RVV 1.0 的工具链。推荐使用:

  • GCC 13+Clang 17+:编译器需要支持 -march=rv64gcv-march=rv32gcv
  • QEMU 7+:用于在 x86 机器上模拟 RISC-V 环境
  • Spike:RISC-V 官方指令集模拟器

编译命令示例:

riscv64-linux-gnu-gcc -march=rv64gcv -O2 -o test test.c

5.2 第一个 RVV 程序:向量加法

让我们从最简单的向量加法开始:

#include <riscv_vector.h>
#include <stdio.h>

void vector_add(int32_t *a, int32_t *b, int32_t *c, size_t n) {
    size_t i = 0;
    while (i < n) {
        // 设置向量长度:取剩余元素数和最大可用长度中的较小值
        size_t vl = __riscv_vsetvl_e32m1(n - i);
        
        // 加载向量
        vint32m1_t va = __riscv_vle32_v_i32m1(&a[i], vl);
        vint32m1_t vb = __riscv_vle32_v_i32m1(&b[i], vl);
        
        // 向量加法
        vint32m1_t vc = __riscv_vadd_vv_i32m1(va, vb, vl);
        
        // 存储结果
        __riscv_vse32_v_i32m1(&c[i], vc, vl);
        
        i += vl;
    }
}

int main() {
    int32_t a[] = {1, 2, 3, 4, 5, 6, 7, 8, 9, 10};
    int32_t b[] = {10, 20, 30, 40, 50, 60, 70, 80, 90, 100};
    int32_t c[10];
    
    vector_add(a, b, c, 10);
    
    for (int i = 0; i < 10; i++) {
        printf("%d ", c[i]);
    }
    printf("\n");
    
    return 0;
}

这个程序的核心是一个循环,每次循环处理 vl 个元素。vl 的值由硬件在运行时决定——在 VLEN=128 位的硬件上,vl 最多是 4(128 / 32);在 VLEN=512 位的硬件上,vl 最多是 16。但无论硬件如何,代码逻辑都不需要修改。

5.3 汇编代码分析

让我们看看编译器为 vector_add 函数生成的汇编代码(简化版):

vector_add:
    li      a4,0
    j       .L2
.L3:
    slli    a5,a4,2
    add     a5,a0,a5
    vsetvli a5, a5, e32, m1, ta, ma  # 设置向量长度和类型
    vle32.v v1,0(a5)                  # 加载向量 a
    add     a5,a1,a4,slli 2
    vle32.v v2,0(a5)                  # 加载向量 b
    vadd.vv v1,v1,v2                  # 向量加法
    add     a5,a2,a4,slli 2
    vse32.v v1,0(a5)                  # 存储结果
    add     a4,a4,a3                  # 更新索引
.L2:
    bltu    a4,a3,.L3                 # 检查是否完成
    ret

可以看到,核心的向量操作只有几条指令,但这几条指令可以一次性处理多个元素。

5.4 条件操作:掩码的使用

让我们看一个更复杂的例子——条件向量加法,只处理偶数索引的元素:

void vector_add_even(int32_t *a, int32_t *b, int32_t *c, size_t n) {
    size_t i = 0;
    while (i < n) {
        size_t vl = __riscv_vsetvl_e32m1(n - i);
        
        vint32m1_t va = __riscv_vle32_v_i32m1(&a[i], vl);
        vint32m1_t vb = __riscv_vle32_v_i32m1(&b[i], vl);
        
        // 创建索引向量
        vuint32m1_t vid = __riscv_vid_v_u32m1(vl);
        vuint32m1_t offset = __riscv_vadd_vx_u32m1(vid, i, vl);
        
        // 生成掩码:只保留偶数索引
        vuint32m1_t mod = __riscv_vand_vx_u32m1(offset, 1, vl);
        vbool8_t mask = __riscv_vmseq_vx_u32m1_b8(mod, 0, vl);
        
        // 带掩码的向量加法
        vint32m1_t vc = __riscv_vadd_vv_i32m1_m(mask, va, va, vb, vl);
        
        __riscv_vse32_v_i32m1(&c[i], vc, vl);
        
        i += vl;
    }
}

在这个例子中,我们使用 vid 指令生成元素索引,然后通过位运算和比较生成掩码,最后在掩码的控制下执行加法。这展示了 RVV 灵活的条件处理能力。

(第二部分完,约2400字)