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 四种模式对比与选择
4.6 避坑指南与实战建议
- 对齐问题:连续加载虽然支持非对齐,但性能会下降。我建议你始终对齐到元素大小的整数倍。
- Stride的陷阱:当stride不是cache line大小的整数倍时,可能会触发cache bank冲突。我曾经遇到过stride=64字节时性能反而比stride=60字节差的情况。
- 索引加载的替代方案:如果索引模式性能不理想,试试连续加载+shuffle的组合。很多情况下,shuffle的开销比gather小得多。
- Segment的寄存器管理:使用segment指令前,先算算你还有多少空闲向量寄存器。别让segment指令把寄存器吃光了,导致后面的计算被迫溢出到内存。
我的个人习惯:写代码时先用连续加载存储把功能跑通,然后profile看瓶颈在哪里。如果内存访问是瓶颈,再考虑换成更高效的模式。别一开始就上索引或segment,那往往是过度设计。