在2D数组上进行迭代时,为什么循环顺序会影响性能?


359

下面是两个几乎完全相同的程序,除了我切换了ij变量。它们都以不同的时间运行。有人可以解释为什么会这样吗?

版本1

#include <stdio.h>
#include <stdlib.h>

main () {
  int i,j;
  static int x[4000][4000];
  for (i = 0; i < 4000; i++) {
    for (j = 0; j < 4000; j++) {
      x[j][i] = i + j; }
  }
}

版本2

#include <stdio.h>
#include <stdlib.h>

main () {
  int i,j;
  static int x[4000][4000];
  for (j = 0; j < 4000; j++) {
     for (i = 0; i < 4000; i++) {
       x[j][i] = i + j; }
   }
}


7
您可以添加一些基准测试结果吗?
naught101 2012年


14
@ naught101基准测试将显示3到10倍之间的性能差异。这是基本的C / C ++,对于如何获得如此多的选票,我完全感到困惑……
TC1 2012年

12
@ TC1:我不认为这是最基本的。也许是中间的。但是不足为奇的是,“基本”东西往往对更多人有用,因此得到了很多支持。而且,即使是“基本”问题,这也是一个很难谷歌搜索的问题。
LarsH 2012年

Answers:


594

正如其他人所说,问题是存储到数组中的内存位置:x[i][j]。以下是一些见解的原因:

您有一个二维数组,但是计算机中的内存本质上是一维的。因此,当您想象这样的数组时:

0,0 | 0,1 | 0,2 | 0,3
----+-----+-----+----
1,0 | 1,1 | 1,2 | 1,3
----+-----+-----+----
2,0 | 2,1 | 2,2 | 2,3

您的计算机将其作为一行存储在内存中:

0,0 | 0,1 | 0,2 | 0,3 | 1,0 | 1,1 | 1,2 | 1,3 | 2,0 | 2,1 | 2,2 | 2,3

在第二个示例中,您将通过首先循环第二个数字来访问数组,即:

x[0][0] 
        x[0][1]
                x[0][2]
                        x[0][3]
                                x[1][0] etc...

这意味着您正在按顺序击打它们。现在来看第一个版本。你在做:

x[0][0]
                                x[1][0]
                                                                x[2][0]
        x[0][1]
                                        x[1][1] etc...

由于C在内存中布置2-d数组的方式,您要求它在整个位置跳转。但是,现在开始讨论:为什么这很重要?所有内存访问都一样,对吗?

否:由于缓存。来自内存的数据会以小块(称为“缓存行”)的形式带入CPU,通常为64个字节。如果您有4字节的整数,这意味着您将在一个整齐的小束中获得16个连续的整数。获取这些大块内存实际上相当慢。您的CPU可以在加载单个缓存行时花费大量时间。

现在回头看一下访问顺序:第二个示例是(1)抓取16个int块,(2)修改所有int,(3)重复4000 * 4000/16次。这样既好又快速,并且CPU总是需要处理一些事情。

第一个示例是(1)抓取16个int的块,(2)仅修改其中一个,(3)重复4000 * 4000次。这将需要从内存中“获取”次数的16倍。实际上,您的CPU必须花时间坐在那里等待内存显示,而当它坐在周围时,您正在浪费宝贵的时间。

重要的提示:

现在您有了答案,这里有一个有趣的注释:没有第二个例子必须是快速的例子的内在原因。例如,在Fortran中,第一个示例将很快,而第二个示例将很慢。这是因为Fortran并未像C那样将其扩展为概念性的“行”,而是将其扩展为“列”,即:

0,0 | 1,0 | 2,0 | 0,1 | 1,1 | 2,1 | 0,2 | 1,2 | 2,2 | 0,3 | 1,3 | 2,3

C的布局称为“行优先”,而Fortran的布局称为“列主”。如您所见,了解您的编程语言是行优先还是列优先非常重要!这是更多信息的链接:http : //en.wikipedia.org/wiki/Row-major_order


14
这是一个非常彻底的答案。这是我在处理缓存未命中和内存管理时所学的知识。
Makoto 2012年

7
您有错误的“第一个”和“第二个”版本;第一个示例将更改内部循环中的第一个索引,这将是执行速度较慢的示例。
caf 2012年

好答案。如果Mark想要阅读更多有关这种细腻的东西的信息,我会推荐一本类似Write Great Code的书。
wkl 2012年

8
指出C从Fortran更改行顺序的要点。对于科学计算而言,L2缓存的大小便是一切,因为如果您所有的阵列都适合L2,则无需进入主存储器即可完成计算。
Michael Shopsin 2012年

4
@birryree:每个程序员都应该免费获得的有关内存的知识也是一本好书。
caf 2012年


23

版本2的运行速度要快得多,因为它比版本1更好地使用了计算机的缓存。如果您考虑一下,数组只是内存的连续区域。当您请求数组中的元素时,您的操作系统可能会将内存页面带入包含该元素的缓存中。但是,由于接下来的几个元素也在该页面上(因为它们是连续的),因此下一次访问将已经在缓存中!这是版本2为加快速度所做的工作。

另一方面,版本1是按列而不是按行访问元素。这种访问在内存级别不是连续的,因此程序无法充分利用OS缓存。


对于这些阵列大小,在这里可能是CPU中而不是OS中的缓存管理器负责。
krlmlr 2012年

12

原因是本地缓存数据访问。在第二个程序中,您将通过内存进行线性扫描,这得益于缓存和预取。您第一个程序的内存使用模式分布得更广,因此缓存行为更糟。


11

除了有关缓存命中的其他出色答案外,还有可能存在优化差异。您的第二个循环可能会被编译器优化为等效于:

  for (j=0; j<4000; j++) {
    int *p = x[j];
    for (i=0; i<4000; i++) {
      *p++ = i+j;
    }
  }

对于第一个循环,这种可能性较小,因为每次都需要将指针“ p”增加4000。

编辑: p++甚至*p++ = ..可以在大多数CPU中被编译为单个CPU指令。*p = ..; p += 4000不能,因此对其进行优化的好处较小。这也更加困难,因为编译器需要知道和使用内部数组的大小。而且,这种情况不会在普通代码的内部循环中发生(仅发生在多维数组中,在该数组中,最后一个索引在循环中保持恒定,而倒数第二个是步进的),因此优化的优先级较低。


我没有得到“因为每次需要将指针“ p”与4000跳转”的意思。
Veedrac'3

@Veedrac内循环中的指针需要增加4000:p += 4000isop++
fishinear

编译器为什么会发现问题?i给定一个指针增量,它已经以非单位值递增。
Veedrac '16

我添加了更多的解释
fishinear

您可以尝试输入int *f(int *p) { *p++ = 10; return p; } int *g(int *p) { *p = 10; p += 4000; return p; }gcc.godbolt.org。两者似乎编译基本相同。
Veedrac '16


4

我尝试给出一个通用的答案。

因为i[y][x]*(i + y*array_width + x)C中的简写(试用class int P[3]; 0[P] = 0xBEEF;)。

当您遍历时y,您遍历了大块的块array_width * sizeof(array_element)。如果您在内部循环中具有该功能,则将array_width * array_height在这些块上进行迭代。

通过翻转顺序,您将仅具有array_height块迭代,并且在任何块迭代之间,您将具有array_widthonly的迭代sizeof(array_element)

尽管在真正的旧x86-CPU上这没什么大不了,但如今的x86进行了大量的数据预取和缓存。您可能会以较慢的迭代顺序产生许多缓存未命中

By using our site, you acknowledge that you have read and understand our Cookie Policy and Privacy Policy.
Licensed under cc by-sa 3.0 with attribution required.