连续内存 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流程图:非连续拷贝优化决策

下面这张图是我自己总结的决策流程,遇到非连续拷贝时可以参考:

非连续拷贝场景 能否合并成连续拷贝? 是 一次memcpy 否 能否改变数据布局? 提前重排数据 能否用共享内存中转? 是 共享内存中转 否 分块+向量化拷贝 带宽优化完成

代码实战:非连续拷贝的优化示例

最后给个实际例子。假设我们要把矩阵的每一行拷贝到目标缓冲区:

// 原始版本:逐行拷贝,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,拷贝性能会好很多。

好了,关于连续内存和非连续内存的拷贝技巧,今天就聊到这儿。记住一句话:让数据访问尽量连续,是内存优化的第一原则。

想要更多资料,联系可可