关于C#:为什么在迭代2D数组时,循环的顺序会影响性能?

Why does the order of the loops affect performance when iterating over a 2D array?

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

版本1

1
2
3
4
5
6
7
8
9
10
11
#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

1
2
3
4
5
6
7
8
9
10
11
#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; }
   }
}


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

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

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

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

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

在第二个示例中,首先循环第二个数字,即:

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

也就是说你在按顺序打他们。现在看第1版。你正在做:

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

因为C在内存中布置二维数组的方式,所以您要求它跳到各处。但现在,对于kicker来说:为什么这很重要?所有的内存访问都是相同的,对吗?

不:因为有缓存。内存中的数据以小块(称为"缓存线")的形式被带到CPU,通常是64字节。如果你有4字节的整数,那意味着你得到了一个整齐的小束中16个连续的整数。实际上,获取这些内存块的速度相当慢;CPU可以在加载单个缓存线所需的时间内完成大量工作。

现在回顾一下访问的顺序:第二个例子是(1)获取16个整数的块,(2)修改所有整数,(3)重复4000*4000/16次。这很好,速度也很快,而且CPU总是有一些工作要做。

第一个例子是(1)抓取16个整数块,(2)只修改其中一个整数,(3)重复4000*4000次。这需要16倍的内存"回迁"次数。你的CPU实际上需要花时间坐着等待内存出现,而当它坐在你身边时,你却在浪费宝贵的时间。

重要注意事项:

现在你有了答案,这里有一个有趣的提示:没有固有的原因让你的第二个例子必须是快速的。例如,在Fortran中,第一个例子是快速的,第二个例子是缓慢的。这是因为fortran没有像c那样将内容扩展为概念上的"行",而是扩展为"列",即:

1
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的布局称为"row major",fortran的布局称为"column major"。如您所见,了解您的编程语言是行专业还是列专业非常重要!这里有一个链接可以获取更多信息:http://en.wikipedia.org/wiki/row-majoru order


与装配无关。这是由于缓存未命中造成的。

多维数组以最后一个维度存储最快。所以第一个版本在每次迭代中都会错过缓存,而第二个版本不会,所以第二个版本应该更快。

另请参见:http://en.wikipedia.org/wiki/loop_interchange。


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

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


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


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

1
2
3
4
5
6
  for (j=0; j<4000; j++) {
    int *p = x[j];
    for (i=0; i<4000; i++) {
      *p++ = i+j;
    }
  }

对于第一个循环,这是不太可能的,因为它需要每次增加4000个指针"p"。

编辑:在大多数CPU中,p++甚至*p++ = ..都可以编译成单个CPU指令。*p = ..; p += 4000不能,因此优化它的好处不大。这也更加困难,因为编译器需要知道和使用内部数组的大小。在正常代码的内部循环中不会经常发生这种情况(它只发生在多维数组中,其中最后一个索引在循环中保持不变,第二个到最后一个索引是逐步进行的),因此优化不是一个优先事项。


罪魁祸首是:

1
x[j][i]=i+j;

第二个版本使用连续内存,因此将大大加快速度。

我尝试过

1
x[50000][50000];

版本1的执行时间是13秒,版本2的执行时间是0.6秒。


我尝试给出一般性的答案。

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

当您迭代y时,您迭代大小为array_width * sizeof(array_element)的块。如果在您的内部循环中有这样的代码,那么您将在这些块上进行array_width * array_height迭代。

通过翻转顺序,您将只有array_height块迭代,并且在任何块迭代之间,您将只有sizeof(array_element)array_width迭代。

虽然在真正老的x86 CPU上,这并不重要,但现在的x86做了大量的数据预取和缓存。您可能会以较慢的迭代顺序产生许多缓存未命中。