3. 向量加载存储:连续加载存储、分段加载存储、索引加载存储、单元步长加载存储

向量加载存储,说白了就是怎么把数据从内存搬到向量寄存器,再搬回去。这事看着简单,但坑特别多。我刚开始做RISC-V向量优化时,以为就是个memcpy,结果性能死活上不去。后来才发现,加载存储的模式选不对,流水线能直接卡死。

RISC-V向量指令集提供了四种加载存储模式。每种模式对应不同的数据访问规律。选对了,带宽跑满;选错了,性能腰斩。咱们一个一个来看。

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

这是最基础的模式。数据在内存里是挨着放的,像数组那样。指令用 vlb、vlh、vlw、vld 这些。

核心特点:连续加载存储的地址步长固定为1个元素大小。硬件能预取,能合并访存,带宽利用率最高。

举个例子,把一个float数组加载到向量寄存器:

// 假设 vl 已经设置为 8,vlen 为 256 位
// 从地址 a 加载 8 个 float 到 v0
vlw v0, (a)        // 连续加载,步长 = 4 字节

// 计算处理...

// 存回地址 b
vsw v0, (b)        // 连续存储

我在项目中遇到过一个问题:有人用连续加载处理一个结构体数组,每个结构体只取一个字段。结果带宽利用率不到20%。为什么?因为连续加载要求数据在内存里是连续排列的,结构体数组里字段之间有空洞,硬件没法合并访问。

使用建议:连续加载存储最适合处理数组、图像的行数据、音频采样等连续排列的数据。如果数据不是连续排列的,别硬用,换其他模式。

3.2 分段加载存储(Strided)

分段加载存储,也叫跨步加载存储。数据在内存里每隔固定步长出现一次。指令用 vlsb、vlsh、vlsw、vlsd 这些。

你想想看,处理矩阵的列、图像的列、或者结构体数组的某个字段,是不是正好用得上?

// 从矩阵中加载第 3 列,矩阵每行 16 个 float
// 步长 = 16 * 4 = 64 字节
li t0, 64          // 步长
vlsw v0, (a), t0   // 分段加载,步长 64 字节

// 处理...

// 存回第 3 列
vssw v0, (b), t0   // 分段存储,步长 64 字节

嗯,这里要注意。分段加载存储的带宽利用率比连续加载低。因为每次访存地址不连续,硬件没法做大的预取和合并。我做过测试,分段加载的带宽通常是连续加载的60%-80%。

加载模式 带宽利用率 适用场景
连续加载 90%-100% 数组、图像行、音频流
分段加载 60%-80% 矩阵列、结构体字段、图像列
索引加载 30%-60% 稀疏数据、查找表、不规则访问
单元步长加载 70%-90% 多通道数据、复数、RGB像素

避坑指南:我曾经在优化一个矩阵转置时,用了分段加载去读列,结果发现步长不是2的幂次,硬件没法做地址对齐,性能反而比标量还差。后来改成先连续加载行,再用洗牌指令重排,性能才上去。记住,分段加载的步长最好是2的幂次,且对齐到缓存行边界。

3.3 索引加载存储(Indexed)

索引加载存储,也叫聚集加载和散射存储。每个元素的地址由一个基地址加上一个偏移向量计算得到。指令用 vlxb、vlxh、vlxw、vlxd 这些。

说白了,就是每个元素想去哪就去哪,地址完全不连续。这在处理稀疏矩阵、图数据、或者某些查找表时特别有用。

// 从稀疏数组中加载数据,索引在 v1 中
// v1 中存的是偏移量(字节为单位)
vlxw v0, (a), v1   // 索引加载,地址 = a + v1[i]

// 处理...

// 散射存储回稀疏数组
vsxw v0, (b), v1   // 索引存储,地址 = b + v1[i]

索引加载存储的带宽利用率是最低的。为什么?因为每个元素的地址都不一样,硬件没法做任何预取和合并。每个访存都是独立的,甚至可能触发多次缓存缺失。

我记得有一次优化一个图算法的核心循环,用了索引加载,结果性能还不如标量。后来分析发现,索引向量里的地址分布太散,导致缓存命中率极低。最后改成先对索引排序,再连续加载,性能提升了3倍。

优化技巧:如果必须用索引加载,尽量让索引向量里的地址在同一个缓存行内,或者至少在同一页内。这样可以减少TLB缺失和缓存行填充的开销。

3.4 单元步长加载存储(Unit-Strided with Stride)

单元步长加载存储,这个名称容易和连续加载搞混。其实它指的是每个元素占多个单元,但元素之间是连续的。比如处理RGB图像,每个像素3个字节,但像素之间是挨着的。

RISC-V里没有专门的单元步长指令,但可以通过设置向量长度和元素大小来实现。或者用分段加载,步长设为一个元素的大小。

// 处理 RGB 图像,每个像素 3 字节
// 方法1:用分段加载,步长 = 3
li t0, 3
vlsb v0, (rgb_ptr), t0   // 加载 R 通道

// 方法2:先连续加载,再用洗牌提取
vlb v0, (rgb_ptr)         // 连续加载所有字节
// 然后用洗牌指令提取 R 通道

我个人习惯用第二种方法。为什么?因为连续加载的带宽利用率高,洗牌指令的开销比分段加载的访存开销小。当然,这取决于具体的微架构实现。

3.5 四种模式的对比与选择

咱们来总结一下。四种加载存储模式,各有各的适用场景。选错了,性能损失很大。

向量加载存储模式选择流程图 向量加载存储模式选择流程 数据访问模式 地址是否连续? 是 连续加载存储 否 非连续访问 步长是否固定? 是 分段加载存储 否 索引加载存储 单元步长加载存储:元素连续但每个元素占多字节,可用连续加载+洗牌或分段加载实现

选型的时候,我一般按这个思路来:

  1. 先看地址是否连续。连续就用连续加载存储,这是最快的。
  2. 如果不连续,看步长是否固定。固定步长用分段加载存储。
  3. 步长不固定,或者数据是稀疏的,用索引加载存储。
  4. 如果每个元素占多个单元但元素之间连续,考虑用连续加载加洗牌,或者用分段加载。

重要提醒:不要盲目追求用向量指令。如果数据访问模式太不规则,索引加载的带宽利用率可能低于30%,这时候用标量循环反而更快。我见过太多人为了用向量而用向量,结果性能更差。先分析访存模式,再选指令。

3.6 实战中的注意事项

最后,分享几个我在实战中踩过的坑:

  • 地址对齐:连续加载要求地址对齐到元素大小。不对齐的话,有些微架构会触发异常,或者性能骤降。我建议所有向量加载的地址都对齐到16字节。
  • 边界处理:向量长度不一定是数据量的整数倍。最后剩下的元素要用掩码加载存储,或者用标量处理。别越界。
  • 缓存行污染:分段加载和索引加载容易造成缓存行污染。如果一个缓存行里只取一个元素,其他数据也被拉进缓存,浪费带宽。可以考虑用非暂时性加载指令(如果硬件支持)。
  • 向量长度选择:不是越长越好。向量长度太长,分段加载的步长跨度大,缓存命中率下降。我一般根据数据集的局部性来调,通常64位到256位之间效果最好。