为啥迭代二维数组时循环的顺序会影响性能?
Posted
技术标签:
【中文标题】为啥迭代二维数组时循环的顺序会影响性能?【英文标题】:Why does the order of the loops affect performance when iterating over a 2D array?为什么迭代二维数组时循环的顺序会影响性能? 【发布时间】:2012-04-13 17:38:01 【问题描述】:下面是两个几乎相同的程序,只是我切换了i
和j
变量。它们都运行不同的时间。有人可以解释为什么会这样吗?
版本 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;
【问题讨论】:
en.wikipedia.org/wiki/… 你能添加一些基准测试结果吗? 相关:***.com/questions/9888154/… @naught101 基准测试将显示 3 到 10 倍的性能差异。这是基本的 C/C++,我完全不知道它是如何获得这么多选票的...... @TC1:我不认为这是基本的;也许是中间的。但是,“基本”的东西往往对更多人有用也就不足为奇了,因此得到了很多人的支持。此外,这是一个很难用谷歌搜索的问题,即使它是“基本的”。 【参考方案1】:除了缓存命中的其他出色答案外,还存在可能的优化差异。您的第二个循环可能会被编译器优化为相当于:
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
不能,因此优化它的好处较少。这也更加困难,因为编译器需要知道并使用内部数组的大小。并且在普通代码的内部循环中不会经常发生(它只发生在多维数组中,其中最后一个索引在循环中保持不变,倒数第二个是步进的),所以优化不是优先级.
【讨论】:
我不明白'因为每次都需要用 4000 跳转指针“p”'是什么意思。 @Veedrac 指针需要在内部循环内增加 4000:p += 4000
i.s.o。 p++
为什么编译器会发现这个问题? i
已经增加了一个非单位值,因为它是一个指针增量。
我已经添加了更多解释
尝试将int *f(int *p) *p++ = 10; return p; int *g(int *p) *p = 10; p += 4000; return p;
输入gcc.godbolt.org。两者似乎编译基本相同。【参考方案2】:
正如其他人所说,问题是存储到数组中的内存位置: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 在内存中布置二维数组的方式,您要求它在整个地方跳跃。但现在对于踢球者:为什么这很重要?所有的内存访问都是一样的,对吧?
否:因为缓存。内存中的数据以小块(称为“缓存线”)的形式被带到 CPU,通常为 64 字节。如果您有 4 字节整数,这意味着您将在一个整洁的小包中获得 16 个连续整数。获取这些内存块实际上相当慢;您的 CPU 可以在加载单个缓存行所需的时间内完成大量工作。
现在回顾一下访问的顺序:第二个例子是 (1) 抓取 16 个整数的块,(2) 修改所有整数,(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
【讨论】:
你的“第一”和“第二”版本是错误的;第一个示例改变了内部循环中的 first 索引,将是执行速度较慢的示例。 很好的答案。如果 Mark 想了解更多关于这些细节的信息,我会推荐一本像 Write Great Code 这样的书。 指出 C 更改了 Fortran 的行顺序的奖励积分。对于科学计算而言,L2 缓存大小就是一切,因为如果您的所有数组都适合 L2,那么计算可以在不进入主内存的情况下完成。 @birryree:免费提供的What Every Programmer Should Know About Memory 也是不错的读物。 很好的答案,但我实际上将数组想象为 0,0 1,0 2,0.. 你为什么要说 0,0 1,0 2,0 ?【参考方案3】:我试着给出一个笼统的答案。
因为i[y][x]
是 C 语言中*(i + y*array_width + x)
的简写(试试经典的int P[3]; 0[P] = 0xBEEF;
)。
当您迭代 y
时,您会迭代大小为 array_width * sizeof(array_element)
的块。如果您的内部循环中有它,那么您将在这些块上进行array_width * array_height
迭代。
通过翻转顺序,您将只有 array_height
块迭代,并且在任何块迭代之间,您将只有 array_width
迭代 sizeof(array_element)
。
虽然在非常旧的 x86-CPU 上这并不重要,但如今的 x86 会进行大量的数据预取和缓存。您可能会以较慢的迭代顺序生成许多 cache misses。
【讨论】:
【参考方案4】:这行是罪魁祸首:
x[j][i]=i+j;
第二个版本使用连续内存,因此会更快。
我试过了
x[50000][50000];
版本 1 的执行时间为 13 秒,而版本 2 的执行时间为 0.6 秒。
【讨论】:
【参考方案5】:原因是缓存本地数据访问。在第二个程序中,您正在线性扫描内存,这得益于缓存和预取。您的第一个程序的内存使用模式更加分散,因此缓存行为更差。
【讨论】:
【参考方案6】:版本 2 会运行得更快,因为它比版本 1 更好地使用您计算机的缓存。如果您考虑一下,数组只是内存的连续区域。当您请求数组中的元素时,您的操作系统可能会将内存页面引入包含该元素的缓存中。但是,由于接下来的几个元素也在该页面上(因为它们是连续的),下一次访问将已经在缓存中!这就是第 2 版为加快速度所做的工作。
另一方面,版本 1 是按列访问元素,而不是按行访问元素。这种访问在内存级别不是连续的,因此程序无法充分利用操作系统缓存。
【讨论】:
有了这些数组大小,可能是 CPU 中的缓存管理器而不是操作系统中的缓存管理器负责。【参考方案7】:与组装无关。这是由于cache misses。
C 多维数组以最后一维为最快的存储。所以第一个版本在每次迭代时都会错过缓存,而第二个版本不会。所以第二个版本应该会快很多。
另请参阅:http://en.wikipedia.org/wiki/Loop_interchange。
【讨论】:
以上是关于为啥迭代二维数组时循环的顺序会影响性能?的主要内容,如果未能解决你的问题,请参考以下文章
哪个更好,单 for 循环或双 for 循环迭代二维数组? C++
JavaScript 通过循环按执行顺序,做一个5×5的二维数组,赋1到25的自然数,然后输出该数组的左下半三角。