连续内存 vs 非连续内存:行优先与列优先,stride对拷贝带宽的影响
内存拷贝这事儿,看着简单,其实坑特别多。
我刚开始做算子优化那会儿,总觉得memcpy就是搬数据嘛,能有多大区别?直到有一次,我写了个矩阵转置的kernel,性能死活上不去。查了半天,发现罪魁祸首就是——内存访问不连续。
嗯,今天咱们就聊聊这个。
行优先 vs 列优先:两种存储哲学
先说说最基础的概念。C/C++里,二维数组在内存中是按行存储的。什么意思?
// 行优先存储
float A[3][4] = {
{0, 1, 2, 3},
{4, 5, 6, 7},
{8, 9, 10, 11}
};
// 内存布局:0,1,2,3,4,5,6,7,8,9,10,11
你看,第一行所有元素排完,再排第二行。这叫行优先(row-major)。
但有些语言,比如Fortran、MATLAB,用的是列优先(column-major)。第一列排完再排第二列。
这两种方式本身没有好坏之分。但问题在于——你的访问模式必须和存储模式匹配。
核心原则:连续访问连续存储的数据,带宽才能跑满。
stride:藏在背后的杀手
stride这个词,说白了就是「步长」。你每次访问下一个元素,要跳多少个字节?
举个例子:
// 连续访问:stride = sizeof(float) = 4字节
for (int i = 0; i < N; i++) {
sum += data[i]; // 每次+4字节
}
// 跳跃访问:stride = 4 * 1024 = 4096字节
for (int i = 0; i < N; i++) {
sum += data[i * 1024]; // 每次跳4KB
}
为什么会这样?
因为CPU和GPU都有缓存(cache)和内存控制器。它们最喜欢的就是「顺序访问」——你读地址A,它就把A附近的数据一起预取过来。下次你读A+4,刚好命中缓存,快得很。
但如果你每次跳4KB,缓存基本就废了。每次都要从主存重新加载,带宽直接腰斩。
我的经验:在项目中遇到过一个大矩阵的转置操作,按列访问时带宽只有理论峰值的15%。改成按行分块后,直接飙到80%以上。差距就是这么大。
实测数据:stride对带宽的影响
我拿一个简单的拷贝实验来说明。在NVIDIA A100上,拷贝1GB数据,改变stride:
| stride(字节) | 访问模式 | 实测带宽(GB/s) | 利用率 |
|---|---|---|---|
| 4 | 连续 | 1550 | ~95% |
| 16 | 小步跳跃 | 1200 | ~74% |
| 64 | 中等跳跃 | 680 | ~42% |
| 256 | 大步跳跃 | 210 | ~13% |
| 1024 | 随机跳跃 | 45 | ~3% |
看到没?stride从4变成1024,带宽掉了30多倍。你想想看,同样的硬件,同样的数据量,就因为访问模式不同,性能差了一个数量级。
非连续内存拷贝的常见场景
实际项目中,非连续拷贝太常见了。我列几个:
- 矩阵转置:按行读、按列写,两边都不连续
- 图像处理:RGB通道分离,每个通道的数据是跳跃的
- 数据重排:比如NCHW转NHWC,维度顺序变了
- 稀疏操作:只拷贝某些特定位置的数据
这些场景下,直接memcpy肯定不行。你得想办法把数据「捋顺」了再拷。
优化策略:怎么处理非连续拷贝?
我个人习惯用这么几种方法:
1. 合并小拷贝
把多个小stride的拷贝合并成一个大块的连续拷贝。比如:
// 坏做法:逐行拷贝
for (int i = 0; i < rows; i++) {
memcpy(dst + i * cols, src + i * stride, cols * sizeof(float));
}
// 好做法:如果stride == cols,直接一次拷完
if (stride == cols) {
memcpy(dst, src, rows * cols * sizeof(float));
}
2. 使用共享内存做中转
在GPU上,先把非连续的数据加载到共享内存(连续存储),再一次性写回全局内存。这招在矩阵转置里特别管用。
3. 改变数据布局
如果某个操作频繁出现非连续访问,干脆在数据生成阶段就把它排成连续的。比如把NCHW提前转成NHWC。
我曾经踩过的坑:有一次做视频编解码的算子优化,输入数据是YUV420格式,Y、U、V三个通道是分开存储的。我一开始直接对每个通道分别拷贝,结果带宽利用率不到10%。后来改成先把三个通道交错排列成连续内存,再一次性拷贝,性能提升了8倍。
SVG流程图:非连续拷贝优化决策
下面这张图是我自己总结的决策流程,遇到非连续拷贝时可以参考:
代码实战:非连续拷贝的优化示例
最后给个实际例子。假设我们要把矩阵的每一行拷贝到目标缓冲区:
// 原始版本:逐行拷贝,stride可能不等于cols
void copy_rows_naive(float* dst, float* src, int rows, int cols, int stride) {
for (int i = 0; i < rows; i++) {
memcpy(dst + i * cols, src + i * stride, cols * sizeof(float));
}
}
// 优化版本:如果stride == cols,一次搞定
// 否则,用更大的拷贝粒度
void copy_rows_optimized(float* dst, float* src, int rows, int cols, int stride) {
if (stride == cols) {
// 连续内存,一次拷贝
memcpy(dst, src, rows * cols * sizeof(float));
return;
}
// 非连续:用向量化方式拷贝每行
// 这里用float4一次拷16字节
int vec_cols = cols / 4;
int remain = cols % 4;
for (int i = 0; i < rows; i++) {
float* row_src = src + i * stride;
float* row_dst = dst + i * cols;
// 向量化拷贝
for (int j = 0; j < vec_cols; j++) {
((float4*)row_dst)[j] = ((float4*)row_src)[j];
}
// 处理剩余元素
for (int j = 0; j < remain; j++) {
row_dst[vec_cols * 4 + j] = row_src[vec_cols * 4 + j];
}
}
}
这个优化版本,在stride不等于cols时,用float4向量化指令一次拷4个float,比逐元素拷贝快不少。当然,如果stride和cols相等,直接一次memcpy是最优解。
小提示:实际项目中,stride往往是因为内存对齐导致的。比如图像每行可能有padding,stride = width + padding。这时候如果能提前把padding去掉,让stride等于width,拷贝性能会好很多。
好了,关于连续内存和非连续内存的拷贝技巧,今天就聊到这儿。记住一句话:让数据访问尽量连续,是内存优化的第一原则。
想要更多资料,联系可可