【问题标题】:Why is there a significant difference in this C++ for loop's execution time? [duplicate]为什么这个 C++ for 循环的执行时间有显着差异? [复制]
【发布时间】:2025-12-03 19:40:01
【问题描述】:

我正在遍历循环,发现在访问循环方面存在显着差异。 我不明白在这两种情况下导致这种差异的原因是什么?

第一个例子:

执行时间; 8 秒

for (int kk = 0; kk < 1000; kk++)
{
    sum = 0;
    for (int i = 0; i < 1024; i++)
        for (int j = 0; j < 1024; j++)
        {
            sum += matrix[i][j];
        }
}

第二个例子:

执行时间:23秒

for (int kk = 0; kk < 1000; kk++)
{
    sum = 0;
    for (int i = 0; i < 1024; i++)
        for (int j = 0; j < 1024; j++)
        {
            sum += matrix[j][i];
        }
}

是什么导致仅仅交换这么大的执行时间差异

matrix[i][j] 

matrix[j][i]

?

【问题讨论】:

  • 引用的局部性(在 C++ 中,原始多维数组以行主要格式存储。)另外,您是否优化了代码? 8 秒(更不用说 23 秒)对于 10 亿个元素来说似乎是一个非常长的执行时间。
  • @Etixpp 哎呀,不,当然是十亿。我仍然不确定有问题的代码是否已经优化。
  • @Massab 你通常应该在测量其性能之前优化代码,否则你可能会得到毫无意义的结果。
  • 另外,要在基准测试中使用“显着”一词,请至少对多次运行的结果进行平均,并包括有关标准偏差的信息。
  • @Massab Lol,“几乎”和“有点高或低”在这里真的没有区别。 “显着”是一个强有力的词,尤其是在基准测试环境中。我不是说我怀疑你的结果,下次要准确:)

标签: c++ performance nested-loops


【解决方案1】:

这是内存缓存的问题。

matrix[i][j]matrix[j][i] 具有更好的缓存命中率,因为 matrix[i][j] 具有更多的连续内存访问机会。

例如,当我们访问matrix[i][0]时,缓存可能会加载包含matrix[i][0]的连续内存段,因此,访问matrix[i][1]matrix[i][2],...,将受益于缓存速度,因为@ 987654329@, matrix[i][2], ... 在matrix[i][0]附近。

但是,当我们访问matrix[j][0] 时,它与matrix[j - 1][0] 相距甚远,可能没有被缓存,无法从缓存速度中受益。尤其是矩阵通常存储为连续的大段内存,缓存器可以预测内存访问的行为并始终缓存内存。

这就是matrix[i][j] 更快的原因。这在基于 CPU 缓存的性能优化中很常见。

【讨论】:

  • @raison 你能解释一下缓存问题吗?因为那是实际正在研究的主题,所以我需要完全理解它。非常感谢。
  • @Massab 希望对您有所帮助。
  • 大约一年前我遇到的一个有趣的事实是,在我之前的question 中讨论过在 CUDA 中为 GPU 编程时并非如此,尽管这超出了当前问题的范围.
  • @GodricSeer GPU 有许多核心来处理并行工作,数据必须从内存传输到 GPU 来执行计算,因此,我们将专注于利用更少的数据传输来利用更多的核心。
  • 这个循环的内存访问模式是非常可预测的;如果优化器(或人类)对此进行优化,那么预取就没有理由出现缓存未命中。问题是内存硬件经过优化以提供连续的内存——缓存线——而不是分散的内存,因此如果我们不能在一次。
【解决方案2】:

性能差异是由计算机的缓存策略造成的。

二维数组matrix[i][j]在内存中表示为一长串值。

例如,数组 A[3][4] 看起来像:

1 1 1 1   2 2 2 2   3 3 3 3

在此示例中,A[0][x] 的每个条目都设置为 1,A[1][x] 的每个条目都设置为 2,...

如果您的第一个循环应用于此矩阵,则访问顺序如下:

1 2 3 4   5 6 7 8   9 10 11 12

而第二个循环的访问顺序如下:

1 4 7 10  2 5 8 11  3 6 9 12

当程序访问数组的一个元素时,它还会加载后续元素。

例如如果你访问A[0][1]A[0][2]A[0][3] 也会被加载。

因此,第一个循环必须执行较少的加载操作,因为某些元素在需要时已经在缓存中。 第二个循环将当时不需要的条目加载到缓存中,从而导致更多的加载操作。

【讨论】:

  • 非常感谢您的回复。
  • @Massab 您应该将最有帮助的回复标记为有效答案。
  • 理由很明确,但这个答案最适合那些不知道问题所在的人
【解决方案3】:

其他人已经很好地解释了为什么一种形式的代码比另一种更有效地利用了内存缓存。我想补充一些您可能不知道的背景信息:您可能没有意识到现在主内存访问有多昂贵

this question 中发布的数字对我来说似乎是正确的,我将在这里复制它们,因为它们非常重要:

Core i7 Xeon 5500 Series Data Source Latency (approximate)
L1 CACHE hit, ~4 cycles
L2 CACHE hit, ~10 cycles
L3 CACHE hit, line unshared ~40 cycles
L3 CACHE hit, shared line in another core ~65 cycles
L3 CACHE hit, modified in another core ~75 cycles remote
remote L3 CACHE ~100-300 cycles
Local Dram ~60 ns
Remote Dram ~100 ns

注意最后两个条目的单位变化。该处理器的运行频率为 2.9–3.2 GHz,具体取决于您使用的型号;为了使数学更简单,我们将其称为 3 GHz。所以一个周期是 0.33333 纳秒。所以DRAM访问也是100-300个周期。

关键是 CPU 在从主存读取 一个 高速缓存行所需的时间内可能已经执行了 数百 条指令。这称为memory wall。因此,在现代 CPU 的整体性能中,内存缓存的有效使用比任何其他因素都更重要。

【讨论】:

  • 然后出现页面错误...
  • 这是很棒的信息。谢啦。 +1
  • 虽然这一切都是正确的,但 (IMO) 很明显,内存延迟并不是这里唯一的因素:预取可以消除缓存未命中,并且可以优化循环以适当地做到这一点。事实上,硬件预取器甚至可以在没有任何帮助的情况下解决大部分问题。内存 带宽 是另一个相关的瓶颈,因为您一次加载整个缓存行,但是一个循环排序在丢弃之前只使用缓存行的一小部分,因此使用带宽效率低下。未优化循环的实际时间很可能是两者的结合。
【解决方案4】:

答案取决于matrix 的确切定义方式。在完全动态分配的数组中,您将拥有:

T **matrix;
matrix = new T*[n];
for(i = 0; i < n; i++)
{
   t[i] = new T[m]; 
}

因此,每个matrix[j] 都需要对指针进行新的内存查找。如果您在外部执行j 循环,则内部循环可以在整个内部循环中重复使用matrix[j] 的指针。

如果矩阵是一个简单的二维数组:

T matrix[n][m];

那么matrix[j] 将只是乘以1024 * sizeof(T) - 这可以通过在优化代码中添加1024 * sizeof(T) 循环索引来完成,因此无论哪种方式都应该相对较快。

除此之外,我们还有缓存位置因素。高速缓存具有通常每行 32 到 128 个字节的数据“行”。因此,如果您的代码读取地址X,缓存将在X 周围加载32 到128 个字节的值。因此,如果您需要的下一个东西只是从当前位置向前sizeof(T),它很可能已经在缓存中[并且现代处理器还检测到您正在循环读取每个内存位置,并预加载数据]。

对于j 内部循环,您正在为每个循环读取sizeof(T)*1024 距离的新位置[如果它是动态分配的,则可能是更大的距离]。这意味着正在加载的数据对下一个循环没有用处,因为它不在接下来的 32 到 128 个字节中。

最后,第一个循环完全有可能更加优化,这要归功于 SSE 指令或类似指令,这使得计算运行得更快。但这对于如此大的矩阵来说可能是微不足道的,因为在这种大小下性能受内存高度限制。

【讨论】:

  • 感谢您的解释,亲爱的。它有点难,但不知何故理解。 :)
  • 我同意马萨布的观点,解释更透彻,但可以写得更好。
【解决方案5】:

内存硬件并未针对提供单个地址进行优化:相反,它倾向于在称为高速缓存行的更大连续内存块上运行。每次读取矩阵的一个条目时,它所在的整个缓存行也会随之加载到缓存中。

更快的循环排序设置为按顺序读取内存;每次加载缓存行时,都会使用该缓存行中的所有条目。每次通过外循环,您只读取每个矩阵条目一次。

然而,较慢的循环排序在继续之前仅使用每个缓存行中的单个条目。因此,每个缓存行必须多次加载,行中的每个矩阵条目一次。例如如果double 是 8 个字节且缓存行是 64 个字节长,那么每次通过外循环都必须读取每个矩阵条目 八次 次,而不是一次。


话虽如此,如果您启用了优化,您可能不会发现任何区别:优化器理解这种现象,并且优秀的优化器能够识别他们可以交换哪个循环是内循环,哪个循环是外循环对于这个特定的代码sn-p。

(同样,一个好的优化器只会通过最外层循环一次,因为它识别出前 999 次通过与 sum 的最终值无关)

【讨论】:

  • 聚会有点晚了,但我认为你的解释写得很好,所以无论如何都要+1。
  • 很好。非常感谢。 +1
【解决方案6】:

矩阵作为向量存储在内存中。第一种方式访问​​它是按顺序访问内存。第二种方式访问​​它需要跳转内存位置。见http://en.wikipedia.org/wiki/Row-major_order

【讨论】:

  • 虽然您的解释是正确的,但我对您使用术语矩阵和向量并不满意。向量是只有一维的矩阵(即向量 (4, 5, 6) 可以说是一个 1x3 矩阵),所讨论的矩阵显然不止一维,所以说它正在存储是不正确的作为向量。不过,您对顺序内存访问是正确的,并且使用引用总是一件好事。
  • @Pharap 在内存中它只有一个维度。
  • @Wlerin 内存是一维的。
  • @Pharap 是的,没错。
  • @Wlerin 关键是数学术语是错误的。内存只是一块连续的内存,但所表示的结构(矩阵)不是。
【解决方案7】:

如果访问j-i,j维是缓存的,所以机器码不用每次都改变,第二维是不缓存的,所以每次都删除缓存是什么原因造成的差异。

【讨论】:

    【解决方案8】:

    基于引用局部性的概念,一段代码很可能会访问相邻的内存位置。因此,加载到缓存中的值比要求的要多。这意味着更多的缓存命中。您的第一个示例很好地满足了这一点,而您在第二个示例中的代码则没有。

    【讨论】: