4. 向量加载存储:四种访存模式的底层逻辑

向量化编程里,最核心也最容易出问题的,就是数据怎么搬进搬出。我见过太多人把精力花在计算指令上,结果性能瓶颈全卡在访存上。说白了,计算再快,数据喂不进去也是白搭。

RISC-V 向量扩展提供了四种加载存储模式。每种模式对应不同的数据布局场景。选对了,性能起飞;选错了,可能比标量还慢。

4.1 连续加载存储(Unit-Strided)

这是最基础的模式。数据在内存里是挨着的,一个接一个。比如一个 int 数组,从地址 A 开始,每个元素间隔 4 字节。

对应的指令是 vlb/vlh/vlw/vld(加载)和 vsb/vsh/vsw/vsd(存储)。

// 连续加载示例:加载 8 个 float
float a[8] = {1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0, 8.0};
// 假设 vl = 8, vtype 设为 e32
vfloat32m1_t v = __riscv_vle32_v_f32m1(a, 8);
// 等价于:从 a 地址连续读取 8 个 float 到向量寄存器

我刚开始做向量化时,总觉得连续加载没什么好讲的。直到有一次,我在一个图像处理项目里,发现连续加载配合循环展开,带宽利用率能到 95% 以上。嗯,这里要注意:连续加载对缓存最友好,硬件能自动做预取。

个人习惯:只要数据布局允许,优先用连续加载。它是最简单、最高效的模式。编译器也最容易优化。

4.2 分段加载存储(Strided)

数据不是连续排列的,每隔固定步长取一个。典型场景:RGB 图像里只取 R 通道,或者结构体数组里只取某个字段。

指令是 vlsb/vlsh/vlsw/vlsd(加载)和 vssb/vssh/vssw/vssd(存储)。步长由额外参数指定。

// 分段加载示例:从 RGB 数据中提取 R 通道
// 假设数据布局:R0 G0 B0 R1 G1 B1 ...
// 步长 = 3 个元素(12 字节)
uint8_t rgb[24] = {255, 0, 0, 128, 128, 0, ...};
// 步长 = 3,每次跳 3 个 uint8
vuint8m1_t v = __riscv_vlsbu_v_u8m1(rgb, 3, 8);

我曾经在一个视频编码项目里,用分段加载提取 YUV 分量。一开始没注意对齐,结果性能比标量还差。后来发现:分段加载对硬件要求高,步长不是 1 时,内存访问模式会打乱预取逻辑。

避坑指南:分段加载的步长如果很大(比如超过缓存行大小),性能会急剧下降。我曾经遇到过步长 1024 的情况,带宽利用率不到 10%。这时候要考虑转置或者重新布局数据。

4.3 索引加载存储(Indexed / Gather-Scatter)

这是最灵活的模式。每个元素的地址由单独的索引数组指定。说白了,就是“按需取数”。

指令是 vlxb/vlxh/vlxw/vlxd(加载,Gather)和 vsxb/vsxh/vsxw/vsxd(存储,Scatter)。

// 索引加载示例:根据索引数组取数
float data[100] = {...};
int indices[8] = {3, 17, 42, 5, 88, 12, 65, 9};
// 从 data[indices[i]] 加载 8 个 float
vfloat32m1_t v = __riscv_vluxei32_v_f32m1(data, indices, 8);

你想想看,这种模式在稀疏矩阵、图计算、粒子模拟里特别有用。但代价也大:每次访问都可能在不同缓存行,硬件几乎没法预取。

模式 访存连续性 硬件预取效果 适用场景
连续加载 完全连续 极好 数组、线性数据
分段加载 固定步长 一般 通道提取、结构体字段
索引加载 随机 差 稀疏数据、查表
单元步长加载 连续(单元内) 好 矩阵分块、子矩阵提取
关键指标:索引加载的延迟通常是连续加载的 3-10 倍。如果索引有规律(比如等差数列),尽量转换成分段加载。

4.4 单元步长加载存储(Unit-Strided Fault-Only-First)

这个模式名字有点绕。其实就是在连续加载的基础上,加了一个“容错”机制。当访问到无效地址(比如页边界)时,不会触发异常,而是只加载有效部分。

指令是 vlbff/vlhff/vlwff/vldff。它会返回一个掩码,告诉你哪些元素成功加载了。

// 单元步长加载示例:安全地处理数组末尾
// 假设数组只有 5 个元素,但 vl = 8
float a[5] = {1.0, 2.0, 3.0, 4.0, 5.0};
// 使用 fault-only-first 加载
vfloat32m1_t v = __riscv_vle32ff_v_f32m1(a, 8);
// 硬件自动截断到有效范围,不会崩溃

我记得在做一个边界处理算法时,每次都要手动算剩余元素个数,然后调整 vl。用了 fault-only-first 之后,代码简洁多了。硬件自动帮你处理边界情况。

我建议:在循环的末尾处理剩余元素时,优先用 fault-only-first。它比手动掩码操作更高效,而且代码更干净。

4.5 四种模式的选型决策

实际项目中,选哪种模式不是拍脑袋决定的。我一般按这个顺序判断:

  1. 数据在内存里连续吗?是 → 连续加载。这是默认选项。
  2. 数据有固定步长吗?是 → 分段加载。注意步长不要太大。
  3. 数据访问是随机的吗?是 → 索引加载。但先问问自己:能不能重新排布数据让它变连续?
  4. 需要处理边界吗?是 → 单元步长加载。安全第一。

下面这张图总结了四种模式的访存特征和适用场景:

向量加载存储四种模式对比 连续加载 Unit-Strided 分段加载 Strided 索引加载 Gather/Scatter 单元步长加载 Fault-Only-First 性能(高→低) 连续加载:带宽利用率 90-100% 分段加载:带宽利用率 50-80% 索引加载:带宽利用率 10-30% 单元步长加载:带宽利用率 85-95% 选型建议 • 连续加载:默认选项,性能最好 • 分段加载:步长 ≤ 缓存行大小时可用 • 索引加载:仅当数据无法重排时使用

4.6 实战中的避坑经验

最后分享几个我踩过的坑:

  • 对齐问题:向量加载要求地址对齐到元素大小。不对齐时,有些硬件会触发异常,有些会降速。我习惯在分配内存时用 aligned_alloc 保证 16 字节对齐。
  • 分段加载的步长陷阱:步长不是元素大小的整数倍时,硬件可能做多次访问。比如步长 5 字节加载 4 字节元素,每次都要读两个缓存行。
  • 索引加载的缓存污染:随机访问会冲掉缓存里的有用数据。我在一个图计算项目里,用索引加载后,L1 缓存命中率从 95% 掉到 30%。后来改成先排序索引再加载,才救回来。
  • 单元步长加载的掩码处理:fault-only-first 返回的掩码是硬件生成的,不要手动修改。我见过有人试图用软件掩码覆盖硬件掩码,结果出了诡异的 bug。
重要提醒:不同厂商的 RISC-V 向量单元实现差异很大。有的硬件对分段加载做了优化,有的则没有。写代码前,最好先看目标硬件的优化手册。不要假设所有模式都一样快。