为什么在两个循环中数组相加比在一个循环中更快?解析经典的 C++ 错误
\n\n您是否曾经编写过直觉上正确但运行缓慢的代码?然后,偶然将一个循环拆分成两个,却获得了 2-3 倍的加速?熟悉的痛苦。在 Stack Overflow 上有一个传奇般的问题,获得了数千次浏览和数百条评论:“为什么元素相加在单独的循环中比在组合循环中快得多?”。今天,我们将用通俗易懂的方式,结合 C++ 代码示例,深入浅出地解析这一现象。
\n\n这不是魔法。这是缓存未命中 (cache misses) 以及处理器实际访问内存的方式。如果您使用 C++ 编程并希望代码飞起来——请仔细阅读。文章末尾将提供具体的可操作建议,您可以立即应用。
\n\n问题核心:两个循环 vs 一个循环
\n\n假设我们有两个大数组 a 和 b,我们想要对每个元素先进行加法,再进行乘法。直觉告诉我们:“让我们用一个循环,免得让处理器来回折腾!”
下面是组合循环的样子:
\n\nconst int N = 1000000;\nfloat a[N], b[N];\n\n// 一个循环——一次性完成所有操作\nfor (int i = 0; i < N; ++i) {\n a[i] = a[i] + b[i]; // 加法\n a[i] = a[i] * 2.0f; // 乘法\n}\n\n而下面是两个单独的循环:
\n\n// 第一个循环——仅加法\nfor (int i = 0; i < N; ++i) {\n a[i] = a[i] + b[i];\n}\n\n// 第二个循环——仅乘法\nfor (int i = 0; i < N; ++i) {\n a[i] = a[i] * 2.0f;\n}\n\n逻辑上会认为第一个版本更快:循环组织的开销更少。但实际上恰恰相反——第二个版本通常快 2-3 倍。为什么?欢迎来到缓存层次结构的世界。
\n\n缓存未命中:性能的头号敌人
\n\n现代处理器并非每次都直接访问主内存。它们使用缓存——位于芯片上的快速内存。缓存有多个级别(L1、L2、L3),每个后续级别更慢但容量更大。
\n\n当您的代码读取或写入数据时,处理器会将整个块(缓存行,通常为 64 字节)加载到缓存中。如果下一个请求落在同一个块中,则为命中。如果没有,则为未命中,处理器必须等待来自慢速 RAM 的数据。一次未命中可能耗费数百个时钟周期。
\n\n现在回到我们的示例。在组合循环中,每次迭代我们都会访问 a[i] 和 b[i] 两次:先读取,后写入。当我们处理 a[0] 和 b[0] 时,缓存会拉取它们的缓存行。但在下一次迭代中,我们访问 a[1] 和 b[1]——如果数据位于不同的缓存行中,则可能发生未命中。结果,我们不断地“拉扯”内存,迫使缓存重新加载。
在两个单独的循环中,情况完全不同。第一个循环顺序读取 a[i] 和 b[i] 并将结果写回。数据线性流动——缓存可预测且完美工作(空间局部性)。第二个循环再次遍历同一个数组 a——数据已经在 L1 缓存中处于“热”状态。几乎没有未命中。
扼杀性能的顶级错误
\n\n错误 #1——在同一个循环中混合对不同数组的操作。如果您可以将逻辑分成两次遍历(尤其是在处理大数据时)——请这样做。缓存会感谢您。
\n\n错误 #2——忽略缓存行大小。64 字节大约相当于 16 个 float 或 8 个 double。如果您的数组步长奇怪,或者您不按顺序跳跃索引,您将不可避免地遇到缓存未命中。
\n\n错误 #3