3. 数据局部性优化:时间局部性与空间局部性、循环交换与数据重排、缓存行对齐

好,咱们接着聊内存带宽。前面几章我讲了带宽瓶颈在哪,也讲了怎么用工具去定位。但说实话,工具只能告诉你「这里慢了」,真正要解决问题,还得靠代码层面的优化。

这一章,我重点聊聊数据局部性。说白了,就是怎么让CPU缓存更高效地工作。你想想看,CPU从缓存读数据,延迟只有几个纳秒;从主存读,几十甚至上百纳秒。这差距,就是性能的分水岭。

3.1 时间局部性与空间局部性

这两个概念,教科书上讲得挺玄乎。我用自己的话给你翻译一下:

  • 时间局部性:刚用过的数据,很快还会再用。比如循环里的累加变量。
  • 空间局部性:刚用过的数据附近的数据,很快也会被用到。比如数组的连续访问。

嗯,就这么简单。但实际代码里,很多人会忽略这两点。

一个典型的反面例子:

// 糟糕的访问模式 - 跳跃访问
int sum = 0;
for (int i = 0; i < N; i++) {
    sum += data[i * STRIDE];  // STRIDE很大,比如1024
}

这种写法,每次访问都跳到很远的地方。缓存根本没法预取,每次都得从主存读。我见过有人用这种模式处理稀疏矩阵,性能直接掉到十分之一。

那怎么改?很简单,把数据排紧凑。比如用结构体数组代替数组结构体,或者把热点数据单独拎出来。

我的经验: 做图像处理时,我习惯把像素的R、G、B分量分开存成三个连续数组。这样处理单通道时,空间局部性极好。比用结构体数组快30%以上。

3.2 循环交换与数据重排

这是我最常用的优化手段。很多性能问题,其实就是循环顺序不对。

看个例子:

// 坏写法 - 按列访问
for (int j = 0; j < COLS; j++) {
    for (int i = 0; i < ROWS; i++) {
        sum += matrix[i][j];  // 每次跳一行
    }
}

// 好写法 - 按行访问
for (int i = 0; i < ROWS; i++) {
    for (int j = 0; j < COLS; j++) {
        sum += matrix[i][j];  // 连续访问
    }
}

为什么会这样?因为C/C++里二维数组是按行存储的。按行访问,就是连续内存访问,缓存行利用率接近100%。按列访问,每次都要跨过一整行,缓存行里只取一个元素,剩下的全浪费了。

我曾经在一个项目中,把矩阵乘法里的循环顺序调了一下,性能提升了4倍。当时同事都惊呆了。其实原理就这么简单。

注意: 循环交换不是万能的。如果数据本身就不连续,比如链表,那怎么交换都没用。这时候得考虑数据重排。

数据重排,就是把不连续的数据,重新组织成连续的内存块。比如:

// 原始数据 - 分散在不同对象里
struct Particle {
    float x, y, z;
    float vx, vy, vz;
    float mass;
    int type;
};

// 重排后 - 把x坐标单独拿出来
float* x_positions = new float[N];
for (int i = 0; i < N; i++) {
    x_positions[i] = particles[i].x;
}

这样,当你只需要处理x坐标时,就能连续访问了。嗯,这就是所谓的「面向数据设计」的核心思想。

3.3 缓存行对齐

缓存行是CPU缓存的最小单位,通常是64字节。如果你的数据跨了两个缓存行,那访问一次就得读两次内存。这太亏了。

怎么避免?对齐。

// 对齐到64字节边界
struct alignas(64) CacheLineAligned {
    int data[16];  // 正好64字节
};

// 或者用预处理器指令
struct __attribute__((aligned(64))) MyStruct {
    // ...
};

我个人习惯在定义热点数据结构时,都加上对齐属性。尤其是多线程场景下,还能避免伪共享(False Sharing)问题。

伪共享的例子:

struct Counter {
    int a;  // 线程1修改
    int b;  // 线程2修改
};  // 如果a和b在同一个缓存行,两个线程互相拖累

// 解决方案:填充到不同缓存行
struct alignas(64) Counter {
    int a;
    char padding[60];  // 填充到64字节
    int b;
};

我曾经在一个高并发服务里,就是因为没注意这个,性能死活上不去。加上填充后,吞吐量直接翻倍。

小技巧: 可以用 std::hardware_destructive_interference_size(C++17)来获取当前平台的缓存行大小,不用硬编码64。

知识体系总览

下面这张图,是我自己总结的数据局部性优化框架。你可以对照着检查自己的代码:

数据局部性优化知识体系 数据局部性优化 时间局部性 空间局部性 缓存行对齐 循环内重用变量 减少重复计算 循环展开与融合 连续内存访问 循环交换(行优先) 数据重排(SoA vs AoS) alignas(64) 对齐 避免伪共享 缓存行填充(padding) 核心目标:让数据尽可能在缓存中命中,减少主存访问

嗯,这张图基本把这一章的核心内容都串起来了。你写代码的时候,可以对照着看看,自己的数据访问模式属于哪一类,有没有优化的空间。

最后说一句:数据局部性优化,不是银弹。但它是最低成本、最高回报的优化手段之一。很多时候,改几行代码,性能就能翻倍。何乐而不为呢?