4. 向量加载存储指令:连续加载存储、stride加载存储、索引加载存储、segment加载存储

向量加载存储指令,说白了就是CPU和内存之间的数据搬运工。但RISC-V的向量指令集把这活儿玩出了花——四种加载存储模式,覆盖了从最简单的连续数组到最复杂的结构体访问。我个人习惯把这四种模式称为「四把钥匙」,开不同的锁用不同的钥匙。

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

这是最基础、最常用的模式。数据在内存里挨个排着队,你一次性把它们全部搬进向量寄存器。比如一个float数组,从地址A开始连续存放,你用一条vle32.v指令就能把N个元素全部加载进来。

核心指令:

  • vle8.v / vse8.v — 8位整数
  • vle16.v / vse16.v — 16位整数
  • vle32.v / vse32.v — 32位整数或单精度浮点
  • vle64.v / vse64.v — 64位整数或双精度浮点

我在项目中遇到过一个问题:有人用连续加载指令去读一个非对齐的地址,结果性能直接腰斩。嗯,这里要注意——虽然RISC-V向量指令支持非对齐访问,但代价很大。我建议你始终保证数据地址是元素大小的整数倍。

// 连续加载示例:加载32个单精度浮点数
float array[32];
vfloat32m1_t vec = __riscv_vle32_v_f32m1(array, 32);
// 处理数据...
__riscv_vse32_v_f32m1(array, vec, 32);

4.2 Stride加载存储(Strided)

Stride模式解决的是「每隔K个元素取一个」的问题。你想想看,处理矩阵的某一列、或者访问结构体数组中的某个字段,数据在内存里就不是连续的了。这时候vlsse32.v就派上用场了。

场景 Stride值 示例
矩阵列访问 行大小(字节) 1024x1024 float矩阵,取第5列,stride=4096
结构体字段 结构体大小(字节) struct {float x, y, z;} arr[1024],取所有x,stride=12
图像像素通道 像素步长(字节) RGB图像,取所有R通道,stride=3

性能陷阱:Stride加载存储的带宽利用率通常只有连续模式的30%-60%。我曾经在一个图像处理项目中,用stride模式读RGB的R通道,结果发现带宽利用率不到40%。后来改成先连续加载整个像素,再用shuffle指令提取通道,性能直接翻倍。

// Stride加载示例:取矩阵第5列
float matrix[1024][1024];
vfloat32m1_t col = __riscv_vlsse32_v_f32m1(
    &matrix[0][5],    // 基地址
    1024 * sizeof(float), // stride = 4096字节
    1024                  // 向量长度
);

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

索引模式是最灵活、也是最昂贵的。它允许你用另一个向量寄存器来指定每个元素的地址偏移量。说白了就是「想读哪个地址就读哪个地址」。

为什么会需要这个?比如稀疏矩阵运算、图计算、或者某些查找表操作。数据在内存里东一个西一个,你没法用连续或stride模式搞定。

指令格式:

  • vluxei8.v — 以8位索引加载
  • vluxei16.v — 以16位索引加载
  • vluxei32.v — 以32位索引加载
  • vluxei64.v — 以64位索引加载

对应的存储指令为vsuxei*.v。

// Gather加载示例:稀疏矩阵非零元素
float values[1024];      // 数据数组
int indices[128];        // 索引数组
vint32m1_t idx_vec = __riscv_vle32_v_i32m1(indices, 128);
vfloat32m1_t data_vec = __riscv_vluxei32_v_f32m1(
    values,      // 基地址
    idx_vec,     // 索引向量
    128          // 向量长度
);

我的经验:索引加载的延迟大约是连续加载的5-10倍。如果你发现程序里gather/scatter操作占了很大比例,建议先想想能不能用其他模式替代。我曾经优化过一个图算法,把gather操作换成了连续加载+permute,性能提升了3倍。

4.4 Segment加载存储(Segmented / Struct-of-Arrays)

Segment模式专门处理「结构体数组」(Array of Structs, AoS)到「数组结构体」(Struct of Arrays, SoA)的转换。比如一个RGB像素结构体,你想一次性加载多个像素的所有R、G、B值。

指令格式里有个数字表示段数:vlseg2e32.v表示2段(比如复数实部虚部),vlseg3e32.v表示3段(比如RGB),vlseg4e32.v表示4段(比如RGBA)。

段数 典型场景 数据布局
2 复数、二维坐标 R0,I0,R1,I1,R2,I2,...
3 RGB像素、三维坐标 R0,G0,B0,R1,G1,B1,...
4 RGBA像素、四元数 R0,G0,B0,A0,R1,G1,B1,A1,...
// Segment加载示例:加载RGB像素
typedef struct { float r, g, b; } pixel;
pixel image[1024];

// 一次性加载8个像素,得到3个向量寄存器
vfloat32m1_t r_vec, g_vec, b_vec;
__riscv_vlseg3e32_v_f32m1(
    &r_vec, &g_vec, &b_vec,
    image,    // 基地址
    8         // 加载8个像素
);

// 处理完后,用segment存储写回
__riscv_vsseg3e32_v_f32m1(
    image,
    r_vec, g_vec, b_vec,
    8
);

寄存器压力:Segment指令会占用多个向量寄存器。比如vlseg4e32.v一次占用4个向量寄存器。如果你的向量寄存器组只有32个,一次用掉4个还好说,但如果你同时做多个segment操作,寄存器可能不够用。我曾经在寄存器压力大的时候,不得不把segment拆成多次连续加载+手动重组。

4.5 四种模式对比与选择

向量加载存储四种模式对比 连续加载存储 Unit-Stride Stride加载存储 Strided 索引加载存储 Gather/Scatter Segment加载存储 AoS ↔ SoA 带宽利用率 95%-100% 延迟最低 带宽利用率 30%-60% 中等延迟 带宽利用率 10%-30% 延迟最高 带宽利用率 60%-80% 中等延迟 适用场景 连续数组 线性数据 适用场景 矩阵列访问 结构体字段 适用场景 稀疏数据 随机访问 适用场景 RGB/RGBA 复数/坐标 选择建议 能用连续就别用stride,能用stride就别用索引 Segment适合AoS→SoA转换,但注意寄存器压力

4.6 避坑指南与实战建议

  1. 对齐问题:连续加载虽然支持非对齐,但性能会下降。我建议你始终对齐到元素大小的整数倍。
  2. Stride的陷阱:当stride不是cache line大小的整数倍时,可能会触发cache bank冲突。我曾经遇到过stride=64字节时性能反而比stride=60字节差的情况。
  3. 索引加载的替代方案:如果索引模式性能不理想,试试连续加载+shuffle的组合。很多情况下,shuffle的开销比gather小得多。
  4. Segment的寄存器管理:使用segment指令前,先算算你还有多少空闲向量寄存器。别让segment指令把寄存器吃光了,导致后面的计算被迫溢出到内存。

我的个人习惯:写代码时先用连续加载存储把功能跑通,然后profile看瓶颈在哪里。如果内存访问是瓶颈,再考虑换成更高效的模式。别一开始就上索引或segment,那往往是过度设计。