循环遍历二维数组的最快方法?
Posted
技术标签:
【中文标题】循环遍历二维数组的最快方法?【英文标题】:Fastest way to loop through a 2d array? 【发布时间】:2010-11-03 02:09:49 【问题描述】:我刚刚偶然发现了 this blog post 关于缓存算法的信息。
作者展示了两个代码示例,它们循环通过一个矩形并计算一些东西(我的猜测是计算代码只是一个占位符)。
在其中一个示例中,他垂直扫描矩形,而在另一个示例中水平扫描。然后他说第二个是最快的,每个程序员都应该知道为什么。现在我一定不是程序员,因为对我来说它看起来完全一样。
谁能解释为什么前者更快?
【问题讨论】:
【参考方案1】:缓存一致性。当你水平扫描时,你的数据在内存中会更靠近,所以你会有更少的缓存未命中,因此性能会更快。对于足够小的矩形,这无关紧要。
【讨论】:
如果将二维数组存储为 array[ywidth+x](如原始示例中所示),水平扫描会更快。如果将数组存储为 array[xheight+y],那么垂直扫描会更快。 为了进一步澄清这个出色的答案:c/c++ 中的数组实际上存储为单个存储块。因此 a[x][y] 实际上与访问 a[x*width + y] 相同,当您首先迭代高度时,您将跳过大块数据,如果您的数组足够大,可能会导致缓存未命中。 @Kai 没错,如果他们想存储这样的数组,那么先垂直存储会更快。 嗯,你是对的,但这不叫“缓存一致性”。这只是针对缓存命中进行优化。缓存一致性是一种完全不同的动物。【参考方案2】:一个答案已被接受,但我认为这不是全部。
是的,缓存是所有这些元素必须以一些顺序存储在内存中的重要原因。如果按照它们的存储顺序对它们进行索引,则可能会减少缓存未命中。可能。
另一个问题(很多答案也提到)是几乎每个处理器都有一个非常快的整数增量指令。它们通常不会具有非常快的“增量乘以第二个任意数量”。这就是您在索引“反对谷物”时所要求的。
第三个问题是优化。已经为优化此类循环投入了大量精力和研究,如果您以某种合理的顺序对其进行索引,您的编译器将更有可能将其中一项优化发挥作用。
【讨论】:
这里提出了一些有趣的观点。然而,缓存未命中会花费 100 到 1000 条指令。因此,其他两点每次访问最多花费 2-3 条指令。因此缓存效果将占主导地位一个数量级。【参考方案3】:缓存确实是原因,但如果你想知道争论的实质,你可以看看 U. Drepper 的“What Every Programmer Should Know About Memory”:
http://people.redhat.com/drepper/cpumemory.pdf
【讨论】:
【参考方案4】:稍微扩展之前的答案:
通常,作为程序员,我们可以将程序的可寻址内存视为一个扁平的字节数组,从 0x00000000 到 0xFFFFFFFF。操作系统将保留其中一些地址(例如,所有低于 0x800000000 的地址)供自己使用,但我们可以对其他地址做我们喜欢的事情。所有这些内存位置都位于计算机的 RAM 中,当我们想要读取或写入它们时,我们会发出相应的指令。
但这不是真的!这个简单的进程内存模型有很多复杂性:虚拟内存、交换和缓存。
与 RAM 对话需要相当长的时间。它比硬盘快得多,因为没有任何旋转板或磁铁,但按照现代 CPU 的标准,它仍然相当慢。因此,当您尝试从内存中的特定位置读取数据时,您的 CPU 不会只是将该位置读入寄存器并称其为好。相反,它会将该位置/和一堆附近的位置/读取到一个处理器缓存中,该缓存位于 CPU 上并且可以比主内存更快地访问。
现在我们对计算机的行为有了更复杂但更正确的看法。当我们尝试读取内存中的某个位置时,首先我们查看处理器缓存以查看该位置的值是否已存储在那里。如果是,我们使用缓存中的值。如果不是,我们需要更长的时间进入主内存,检索该值以及它的几个邻居并将它们保存在缓存中,踢掉一些曾经存在的内容以腾出空间。
现在我们可以看到为什么第二个代码 sn-p 比第一个快。在第二个示例中,我们首先访问a[0]
、b[0]
和c[0]
。这些值中的每一个都与它们的邻居一起被缓存,例如a[1..7]
、b[1..7]
和c[1..7]
。然后当我们访问a[1]
、b[1]
和c[1]
时,它们已经在缓存中,我们可以快速读取它们。最终我们到达a[8]
,并且不得不再次访问 RAM,但八分之七的时候我们使用的是不错的快速缓存内存,而不是笨重的慢速 RAM 内存。
(那么为什么不访问a
、b
和c
将彼此踢出缓存?这有点复杂,但本质上处理器决定将给定值存储在缓存中的哪个位置通过其地址,因此空间上不相邻的三个对象不太可能缓存到同一位置。)
相比之下,请考虑 lbrandy 帖子中的第一个 sn-p。我们首先读取a[0]
、b[0]
和c[0]
,缓存a[1..7]
、b[1..7]
和c[1..7]
。然后我们访问a[width]
、b[width]
和c[width]
。假设宽度 >= 8(它可能是,否则我们不会关心这种低级优化),我们必须再次进入 RAM,缓存一组新值。当我们到达a[1]
时,它可能已被踢出缓存以为其他东西腾出空间。在三个大于处理器缓存的数组的不常见情况下,/每次读取/都可能会错过缓存,从而极大地降低性能。
这是对现代缓存行为的高级讨论。对于更深入和技术性的东西,this 看起来像是对该主题的全面而可读的处理。
【讨论】:
【参考方案5】:是的,'缓存一致性'...当然,这取决于,您可以优化垂直扫描的内存分配。传统上,视频内存是从左到右、从上到下分配的,我敢肯定回到 CRT 屏幕以相同方式绘制扫描线的时代。理论上你可以改变这一点——所有这些都是说水平方法没有任何内在的东西。
【讨论】:
【参考方案6】:原因是当您深入了解内存布局的硬件级别时,实际上并没有二维数组这样的东西。因此,“垂直”扫描以到达您需要访问的下一个单元格,您正在按照这些思路进行操作
对于索引为 (row, column) 的二维数组,需要将其转换为 array[index] 的一维数组,因为计算机中的内存是线性的。
因此,如果您是垂直扫描,则下一个索引计算为:
index = row * numColumns + col;
但是,如果您是水平扫描,则下一个索引如下:
index = index++;
单次加法对 CPU 的操作码比乘法 AND 加法要少,因此由于计算机内存的架构,水平扫描速度更快。
缓存不是答案,因为如果这是您第一次加载此数据,则每次数据访问都将是缓存未命中。对于第一次执行,水平更快,因为操作更少。缓存会使通过三角形的后续循环更快,如果三角形足够大,则垂直可能会因为缓存未命中而变慢,但由于操作数量增加,将始终比水平扫描慢需要访问下一个元素。
【讨论】:
为您的个人资料获取图片。这个网站上的 Jim 太多了,很难区分。 这不是真的。提供的两个版本的算法都需要相同数量的增量、乘法、读取和存储。索引的计算方式没有任何区别。 你是说你是否访问一个用 C 定义的数组,它看起来像 a[10][10] 的顺序是:a[0][0], a[1][0] , a[2][0] 与: a[0][1], a[0][2], a[0][3] 没有什么不同,因为发生完全相同的翻译 a[row * numCols + col ] 即使我们告诉它水平访问。这里的低效率是让编译器决定您要如何访问数据。如果编译器不够聪明,无法注意到您完全按顺序访问数据并因此针对加法情况进行了优化,那么您最好在 ASM 中编写此代码,在这种情况下我的答案成立 嗯,有点。示例中的数组不是多维的,但这没关系;一个功能合理的 C 编译器可以将内部循环转换为指针算术。但是无论访问顺序如何,它都可以做到这一点,因此两种情况之间的循环成本是将 y 添加到指针和添加 4 之间的差异(因为整数是 4 对齐的)。这不是大量的循环,甚至可能为零。一个缓存未命中的成本高达数百个寄存器到寄存器添加,因此缓存一致性是任何大到值得关注的工作集的正确答案。 缓存是正确答案,看下一个答案和lbrandy.com/blog/2009/07/…以上是关于循环遍历二维数组的最快方法?的主要内容,如果未能解决你的问题,请参考以下文章