我在查看循环,发现在访问循环时有一个显著的区别。我不明白是什么导致了这两种情况的差异?
第一个例子:
执行时间;8秒
1 2 3 4 5 6 7 8 9
| for (int kk = 0; kk < 1000; kk++)
{
sum = 0;
for (int i = 0; i < 1024; i++)
for (int j = 0; j < 1024; j++)
{
sum += matrix[i][j];
}
} |
第二个例子:
执行时间:23秒
1 2 3 4 5 6 7 8 9
| for (int kk = 0; kk < 1000; kk++)
{
sum = 0;
for (int i = 0; i < 1024; i++)
for (int j = 0; j < 1024; j++)
{
sum += matrix[j][i];
}
} |
什么导致这么多执行时间差,只是交换
到
?
- 引用的局部性(C++中,原始多维数组以行主要格式存储)。对于10亿个元素来说,8秒(更不用说23秒)的执行时间似乎太长了。
- @Etixpp哦,不,当然是10亿。我仍然不确定所讨论的代码是否已经过优化。
- 取决于矩阵存储的内容。例如,带有重载+运算符的类pbject可能会导致这样的时间,但实际上不是这里的问题。如果作者想要优化,他可以发布整个代码
- 如何申报matrix?
- 它以静态方式声明,双矩阵[1024][1024];
- @参数控制程序代码没有经过优化,它只是一个简单的运行,以不同的方式检查执行时间差访问数组。
- @在度量代码性能之前,通常应该对代码进行优化,否则可能会得到无意义的结果。
- 此外,要在基准中使用"重要"一词,请至少平均一次以上运行的结果,并包括有关标准偏差的信息。
- @参数控制权高于…下次我会小心的。
- @我跑了几次,几乎是平均时间,每次都有点起伏不定。
- @按摩棒,"几乎"和"上下咬"在这里没有什么区别。"重要"是一个很强的词,特别是在基准测试环境中。我不是说我怀疑你的结果,只是下次要准确点:)
- @Bartoszkp我使用了"significant"这个词来表示两个示例执行时间的差异,您可以看到15秒的差异。然而,我所说的"位向上或向下",是关于一个示例的不同执行时间,以获得平均值,例如7、8或9秒(例如1)。希望你能理解。别介意。
- @是的,我理解你用这个词的意思。我只是说,在这种情况下,这个词通常有一个不同的、更强大的、更精确的含义。这里没什么大不了的,但一般来说,你应该报告一下统计数据。他们会比你的模糊(是的,还是模糊的!)请在评论中解释测量结果的表现;)
- @巴托斯基,好吧,那很好。下次我会小心的。萨克斯:
- 请访问stackoverflow.com/questions/3928995/how-do-cache-lines-work。
- @massab下载一个pdf:操作系统书籍galvin阅读第9章:虚拟内存部分:9.9.5程序结构。
- 请注意,声明功率为2的大型阵列也会导致性能下降stackoverflow.com/questions/12264970/&hellip;stackoverflow.com/questions/6060985/&hellip;stackoverflow.com/questions/11413855/&hellip;stackoverflow.com/questions/7905760/&hellip;
- 尝试将数组大小更改为1025x1025,然后再次测量该时间,如果该时间更快,则它也是与缓存使用相关的问题。
- 完整的例子怎么样?你是怎么编译的?
- @马萨:只是出于好奇(如果你不介意我问的话),你是在自学编程还是在学校正式学习/学习?
- @我已经毕业了,现在正在教一个CS的学生,在我自己的时间里没有学习太多,但是现在在教学中学习了很多。D
- @参数专家:你在说什么?profile first"几十年来一直是标准的优化实践,因为您从不想花费数小时优化您认为是瓶颈的东西,结果发现它只占用了运行时的0.2%。或者你说的是打开编译器优化?
- @用户2357112是的,显然是编译器优化。
- @程序员,你能告诉我一些关于编译器优化的事情吗??
- @马萨我该怎么说?
- @参数管理员什么是编译器优化?它的目的是什么?我们如何才能实现或说,在编译器中打开它?
- @如果你不知道什么是编译器优化,我将无法在评论中向你解释。你需要做你的研究。
- @参数控制者还好,亲爱的,没问题……谢谢)
这是内存缓存的问题。
由于matrix[i][j]具有更多的连续内存访问机会,因此matrix[i][j]比matrix[j][i]具有更好的缓存命中率。
例如,当我们访问matrix[i][0]时,缓存可以加载包含matrix[i][0]的连续内存段,因此访问matrix[i][1]、matrix[i][2]…,将受益于缓存速度,因为matrix[i][1]、matrix[i][2]…。离matrix[i][0]很近。
但是,当我们访问matrix[j][0]时,它离matrix[j - 1][0]很远,可能没有缓存,也不能从缓存速度中受益。特别是,矩阵通常存储为连续的大内存段,缓存器可以预测内存访问的行为并始终缓存内存。
这就是为什么matrix[i][j]更快。这在基于CPU缓存的性能优化中是典型的。
- @理由你能解释一下那个缓存问题吗?因为这是我正在研究的主题,所以我需要完全理解它。阿森特。
- @马萨希望能有所帮助。
- 一年前,我遇到一个有趣的事实,那就是,正如我在上一个问题中讨论的那样,在CUDA中为GPU编程时,情况并非如此,尽管这超出了当前问题的范围。
- @Godricseer GPU有多个内核来处理并行工作,数据必须从内存传输到GPU才能执行计算,因此,我们将集中精力使用更多的内核,而数据传输更少。
- @理由是,我不是建议改变你的过程/答案,我只是想指出我认为在不同的架构上是一个非直觉的事实。
- @Godricseer谢谢你的建议。
- 这个循环的内存访问模式是非常可预测的;使用预取,如果优化器(或人工)对此进行优化,那么它就没有理由会出现缓存丢失。问题在于,内存硬件经过了优化,可以提供连续的内存(缓存线),而不是分散的内存,因此,如果我们不能一次使用整个缓存线,那么我们就没有效率地使用内存。
- 如果您在缓存常见之前返回,仍然知道内存访问是矩阵行/列顺序的问题-最终,磁鼓内存具有相同的行为,即读取下一个字比读取更大的偏移量更快。
性能差异是由计算机的缓存策略造成的。
二维数组matrix[i][j]表示为内存中的一长列值。
例如,阵列A[3][4]看起来如下:
在本例中,[0][x]的每个条目都设置为1,[1][x]的每个条目都设置为2,…
如果将第一个循环应用于此矩阵,则访问顺序如下:
1
| 1 2 3 4 5 6 7 8 9 10 11 12 |
第二个循环访问顺序如下:
1
| 1 4 7 10 2 5 8 11 3 6 9 12 |
当程序访问数组的一个元素时,它也加载随后的元素。
例如,如果您访问A[0][1],也会加载A[0][2]和A[0][3]。
因此,第一个循环必须执行较少的加载操作,因为有些元素在需要时已经在缓存中了。第二个循环将不需要的条目加载到缓存中,从而导致更多的加载操作。
- 回复时请注意。
- @你应该把最有用的回答标记为有效的回答。
- 理由是有根据的,但这个答案对那些不知道问题的人来说是最好的解释。
其他人已经很好地解释了为什么一种形式的代码比另一种更有效地使用内存缓存。我想添加一些您可能不知道的背景信息:您可能不知道现在访问主内存有多昂贵。
在我看来,这个问题中公布的数字是正确的,我将在这里复制它们,因为它们非常重要:
1 2 3 4 5 6 7 8 9
| Core i7 Xeon 5500 Series Data Source Latency (approximate)
L1 CACHE hit, ~4 cycles
L2 CACHE hit, ~10 cycles
L3 CACHE hit, line unshared ~40 cycles
L3 CACHE hit, shared line in another core ~65 cycles
L3 CACHE hit, modified in another core ~75 cycles remote
remote L3 CACHE ~100-300 cycles
Local Dram ~60 ns
Remote Dram ~100 ns |
注意最后两个条目的单位变化。根据您的具体型号,这个处理器的运行速度是2.9-3.2 GHz;为了简化计算过程,我们把它称为3 GHz。所以一个周期是0.33333纳秒。因此,DRAM访问也是100-300个周期。
关键是CPU可能在从主内存读取一条缓存线所需的时间内执行了数百条指令。这叫做记忆墙。因此,在现代CPU的总体性能中,有效地使用内存缓存比任何其他因素都重要。
- 然后有一个页面错误…
- 这是很好的信息。谢谢,伙计。+ 1
- 虽然这都是真的,但是(imo)很清楚内存延迟并不是唯一的因素:预取可以消除缓存未命中,并且可以适当地优化循环。事实上,硬件预取器甚至可以在没有任何帮助的情况下计算出其中的大部分。在这里,内存带宽是另一个相关的瓶颈,因为您一次加载整个缓存线,但是一个循环排序在丢弃缓存线之前只使用了缓存线的一小部分,因此使用带宽效率低下。未优化循环的实际计时可能是两者的组合。
- @赫斯基的所有这些都包含在了理由和H4kor的答案中,至少是含蓄的。
答案有点取决于matrix是如何定义的。在完全动态分配的数组中,您将拥有:
1 2 3 4 5 6
| T **matrix;
matrix = new T*[n];
for(i = 0; i < n; i++)
{
t[i] = new T[m];
} |
因此,每个matrix[j]都需要对指针进行新的内存查找。如果在外部执行j循环,则内部循环可以为整个内部循环重复使用matrix[j]的指针。
如果矩阵是简单的二维数组:
那么,matrix[j]将只是1024 * sizeof(T)的乘法,这可以通过在优化代码中添加1024 * sizeof(T)循环索引来实现,因此任何一种方式都应该相对较快。
除此之外,我们还有缓存位置因子。缓存的数据"行"通常为每行32到128个字节。因此,如果代码读取地址X,缓存将在X周围加载32到128个字节的值。因此,如果您接下来需要的只是从当前位置向前移动sizeof(T),那么很可能已经在缓存中了[现代处理器还检测到您正在循环读取每个内存位置,并预加载数据]。
对于j内环,您正在读取每个环的sizeof(T)*1024距离的新位置[如果动态分配,可能会有更大的距离]。这意味着所加载的数据对于下一个循环没有用处,因为它不在接下来的32到128个字节中。
最后,由于SSE指令或类似指令,第一个循环完全有可能得到更优化,这使得计算运行得更快。但是对于这样一个大的矩阵来说,这可能是边缘,因为性能在这个大小上是高度内存限制的。
- 谢谢你的解释,亲爱的。这有点难,但不知怎么理解。:)
- 我同意马萨的说法,解释得更透彻,但写得更好。
内存硬件没有经过优化以传递单个地址:相反,它倾向于在称为缓存线的更大的连续内存块上操作。每次读取矩阵的一个条目时,它所在的整个缓存线也会随之加载到缓存中。
更快的循环顺序设置为按顺序读取内存;每次加载缓存线时,都使用该缓存线中的所有条目。每次通过外部循环,您只需读取一次每个矩阵条目。
但是,较慢的循环排序在继续之前只使用每个缓存行中的一个条目。因此,每条缓存线必须多次加载,对于该行中的每个矩阵条目加载一次。例如,如果一个double是8 byes,一个缓存线是64字节长,那么每个通过外部循环的线程必须读取每个矩阵条目8次,而不是一次。
综上所述,如果您打开了优化,您可能看不到任何区别:优化器了解这一现象,而优秀的优化器能够认识到,它们可以交换这个特定代码片段的哪个循环是内部循环,哪个循环是外部循环。
(同样,一个好的优化器只会做一次最外面的循环,因为它认识到前999次通过与sum的最终值无关)
- 参加聚会有点晚,但我认为你的解释写得很好,所以无论如何+1。
- 好吧。非常感谢。+ 1
矩阵作为一个向量存储在内存中。第一种方法是按顺序访问内存。第二种方式访问它需要在内存位置上跳跃。参见http://en.wikipedia.org/wiki/row-major_order
- 好帖子。桑克斯
- 虽然你的解释是正确的,但我不喜欢你使用的术语矩阵和向量。向量是只有一个维度的矩阵(即向量(4,5,6)可以说是一个1x3矩阵),所讨论的矩阵显然有多个维度,所以说它被存储为一个向量是不正确的。不过,顺序内存访问是正确的,使用引用总是一件好事。
- @内存中的pharap只有一个维度。
- @Wllerin内存是一维的。
- @是的,没错。
- @威林的观点是,数学术语是错误的。内存只是一个连续内存块,但所表示的结构(矩阵)却不是。
- @不,数学术语很流行。你可能希望重读他写的东西,因为他指的是它是如何存储在记忆中的,而不是概念上的。
如果您访问j-i,j维度会被缓存,这样机器代码就不必每次都更改它,第二个维度也不会被缓存,所以每次导致差异的原因都会删除缓存。
基于引用位置的概念,一段代码很可能访问相邻的内存位置。因此,加载到缓存中的值比请求的值多。这意味着更多的缓存命中。第一个示例很好地满足了这一点,而第二个示例中的代码则不满足。