3. 向量扩展概览

各位同学,今天我们来聊聊向量访存。这部分内容,说白了就是怎么把数据从内存搬到向量寄存器里,再搬回去。

我刚开始接触RVV时,觉得加载存储不就是个vld和vst嘛,有什么好讲的?后来在项目里踩了坑才发现——访存模式选不对,性能直接腰斩。嗯,这里面的门道还真不少。

3.1 连续加载存储:最基础也最高效

连续加载存储,就是数据在内存里是挨着放的,你一次性把它们整块搬进向量寄存器。这是RVV里最自然的访存模式,也是性能最好的。

举个例子,你有一个float数组,想把它全部加载到向量寄存器里做运算:

// 假设 VLEN=256, SEW=32, 则一个向量寄存器能装8个float
float a[1024];
// 连续加载
vfloat32m1_t va = __riscv_vle32_v_f32m1(a, 8);
// 连续存储
__riscv_vse32_v_f32m1(b, va, 8);

这里vle32就是连续加载指令,vse32是连续存储指令。它们要求数据在内存中是连续排列的,没有间隔。

性能关键点:连续访存能充分利用内存带宽,因为硬件可以预取整块数据。我实测过,在大部分处理器上,连续访存的带宽利用率能达到理论峰值的90%以上。

但要注意,连续加载存储对地址对齐有要求。如果地址没对齐,性能会下降不少。我建议你尽量保证向量起始地址是SEW字节对齐的——比如SEW=32时,地址要4字节对齐。

3.2 分段加载存储:处理交错数据

分段加载存储,英文叫"unit-stride fault-only-first"的亲戚?其实不是。分段加载存储是处理结构体数组这类交错数据的利器。

想象一下,你有一个RGB像素数组,每个像素包含R、G、B三个分量。你想把所有的R分量单独拿出来处理。这时候分段加载就派上用场了。

// 假设有RGB数组,每个像素3个float
// 分段加载:从交错数据中提取R、G、B分量
vfloat32m1_t vr, vg, vb;
// NF=3,表示3个字段,stride=3*sizeof(float)
__riscv_vlseg3e32_v_f32m1(&vr, &vg, &vb, rgb_array, 8);
// 处理完后再分段存储回去
__riscv_vsseg3e32_v_f32m1(rgb_array, vr, vg, vb, 8);

这里vlseg3e32就是分段加载指令,它把交错的数据拆成3个独立的向量。对应的vsseg3e32则把3个向量合并回交错格式。

指令 功能 典型场景
vlseg2e32 2字段分段加载 复数数组(实部+虚部)
vlseg3e32 3字段分段加载 RGB像素
vlseg4e32 4字段分段加载 RGBA像素、四元数

我的经验:分段加载虽然方便,但性能不如连续加载。因为硬件需要做数据重组。如果性能敏感,我建议在数据布局阶段就把同类数据连续存放,用连续加载替代分段加载。

3.3 单元步长加载:处理规则间隔的数据

单元步长加载,也叫"strided load"。它处理的是每隔固定间隔取一个元素的情况。

比如你有一个矩阵,想取它的某一列。或者你有一个数组,想每隔3个元素取一个。这时候就用vlse32指令。

// 每隔3个float取一个元素
// stride = 3 * sizeof(float) = 12字节
float src[1024];
vfloat32m1_t v = __riscv_vlse32_v_f32m1(src, 12, 8);
// 每隔3个float存一个元素
__riscv_vsse32_v_f32m1(dst, 12, v, 8);

这里第二个参数就是步长,单位是字节。步长可以是任意正整数,但要注意——步长越大,性能越差。

避坑指南:我曾经在一个图像处理项目里用步长加载取图像的隔行数据,结果性能惨不忍睹。后来改成先把数据连续化,再用连续加载,速度提升了3倍。步长加载虽然灵活,但尽量少用。

3.4 索引加载:最灵活也最慢

索引加载,也叫"gather load"。它允许你用一个索引向量来指定每个元素的地址。这是最灵活的访存方式——你可以从任意地址取数据。

// 索引加载:从任意地址取数据
uint32_t indices[8] = {0, 5, 10, 15, 20, 25, 30, 35};
vfloat32m1_t v = __riscv_vluxei32_v_f32m1(base_array, indices, 8);
// 索引存储:scatter store
__riscv_vsoxei32_v_f32m1(base_array, indices, v, 8);

vluxei32是索引加载,vsoxei32是索引存储。索引向量可以是i8、i16、i32、i64类型。

索引加载的性能嘛...说实话,很慢。因为每次访存都是随机的,硬件没法预取,缓存命中率也低。

3.5 四种访存模式对比

我把这四种模式放在一起对比一下,方便你选型:

访存模式 指令前缀 性能 灵活性 推荐场景
连续加载存储 vle/vse ★★★★★ ★ 数组、连续缓冲区
分段加载存储 vlseg/vsseg ★★★★ ★★ 结构体数组、多通道数据
单元步长加载 vls/vss ★★★ ★★★ 矩阵列访问、降采样
索引加载 vlux/vsox ★★ ★★★★★ 稀疏数据、查找表

你看这个表格就明白了——性能和灵活性是成反比的。连续加载最快但最死板,索引加载最灵活但最慢。

我的建议:能用连续加载就别用步长加载,能用步长加载就别用索引加载。如果非要用索引加载,尽量让索引向量有序,这样能提高缓存命中率。

3.6 知识结构图

下面这张图展示了四种访存模式的关系和适用场景:

RVV向量访存模式知识结构图 RVV向量访存模式知识结构 向量加载存储 连续加载存储 分段加载存储 单元步长加载 索引加载 性能:连续加载 > 分段加载 > 步长加载 > 索引加载 vle32 / vse32 vlseg3e32 / vsseg3e32 vlse32 / vsse32 vluxei32 / vsoxei32 数组 连续缓冲区 结构体数组 多通道数据 矩阵列访问 降采样 稀疏数据 查找表

3.7 实际工程中的选择策略

在实际项目中,我一般按这个优先级来选择访存模式:

  1. 先看能不能用连续加载——数据是否连续?如果是,直接用vle/vse。
  2. 再看能不能用分段加载——如果是结构体数组,用vlseg/vsseg。
  3. 然后考虑步长加载——如果数据间隔固定,用vls/vss。
  4. 最后才用索引加载——只有数据访问模式完全随机时才用。

小技巧:如果你发现步长加载或索引加载成了性能瓶颈,不妨考虑改变数据布局。把数据重新排列成连续格式,虽然多了一次数据搬运的开销,但后续的连续加载带来的性能提升往往能覆盖这个成本。

好了,关于向量加载存储的内容就讲到这里。记住一句话:访存模式的选择,决定了你向量化代码的性能上限。选对了,事半功倍;选错了,事倍功半。