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 时,内存访问模式会打乱预取逻辑。
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);
你想想看,这种模式在稀疏矩阵、图计算、粒子模拟里特别有用。但代价也大:每次访问都可能在不同缓存行,硬件几乎没法预取。
| 模式 | 访存连续性 | 硬件预取效果 | 适用场景 |
|---|---|---|---|
| 连续加载 | 完全连续 | 极好 | 数组、线性数据 |
| 分段加载 | 固定步长 | 一般 | 通道提取、结构体字段 |
| 索引加载 | 随机 | 差 | 稀疏数据、查表 |
| 单元步长加载 | 连续(单元内) | 好 | 矩阵分块、子矩阵提取 |
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 之后,代码简洁多了。硬件自动帮你处理边界情况。
4.5 四种模式的选型决策
实际项目中,选哪种模式不是拍脑袋决定的。我一般按这个顺序判断:
- 数据在内存里连续吗?是 → 连续加载。这是默认选项。
- 数据有固定步长吗?是 → 分段加载。注意步长不要太大。
- 数据访问是随机的吗?是 → 索引加载。但先问问自己:能不能重新排布数据让它变连续?
- 需要处理边界吗?是 → 单元步长加载。安全第一。
下面这张图总结了四种模式的访存特征和适用场景:
4.6 实战中的避坑经验
最后分享几个我踩过的坑:
- 对齐问题:向量加载要求地址对齐到元素大小。不对齐时,有些硬件会触发异常,有些会降速。我习惯在分配内存时用
aligned_alloc保证 16 字节对齐。 - 分段加载的步长陷阱:步长不是元素大小的整数倍时,硬件可能做多次访问。比如步长 5 字节加载 4 字节元素,每次都要读两个缓存行。
- 索引加载的缓存污染:随机访问会冲掉缓存里的有用数据。我在一个图计算项目里,用索引加载后,L1 缓存命中率从 95% 掉到 30%。后来改成先排序索引再加载,才救回来。
- 单元步长加载的掩码处理:fault-only-first 返回的掩码是硬件生成的,不要手动修改。我见过有人试图用软件掩码覆盖硬件掩码,结果出了诡异的 bug。