Why does the order of the loops affect performance when iterating over a 2D array?
下面是两个几乎相同的程序,除了我切换了
版本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; } } } |
正如其他人所说,问题在于存储到数组中的内存位置:
你有一个二维数组,但是计算机中的内存本质上是一维的。所以当你想象你的数组是这样的时候:
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中,
罪魁祸首是:
1 | x[j][i]=i+j; |
第二个版本使用连续内存,因此将大大加快速度。
我尝试过
1 | x[50000][50000]; |
版本1的执行时间是13秒,版本2的执行时间是0.6秒。
我尝试给出一般性的答案。
因为
当您迭代
通过翻转顺序,您将只有
虽然在真正老的x86 CPU上,这并不重要,但现在的x86做了大量的数据预取和缓存。您可能会以较慢的迭代顺序产生许多缓存未命中。