关于性能:为什么这个C ++ for循环的执行时间存在显着差异?

Why is there a significant difference in this C++ for loop's execution time?

本问题已经有最佳答案,请猛点这里访问。

我在查看循环,发现在访问循环时有一个显著的区别。我不明白是什么导致了这两种情况的差异?

第一个例子:

执行时间;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];
        }
}

什么导致这么多执行时间差,只是交换

1
matrix[i][j]

1
matrix[j][i]


这是内存缓存的问题。

由于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缓存的性能优化中是典型的。


性能差异是由计算机的缓存策略造成的。

二维数组matrix[i][j]表示为内存中的一长列值。

例如,阵列A[3][4]看起来如下:

1
1 1 1 1   2 2 2 2   3 3 3 3

在本例中,[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的总体性能中,有效地使用内存缓存比任何其他因素都重要。


答案有点取决于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]的指针。

如果矩阵是简单的二维数组:

1
T matrix[n][m];

那么,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的最终值无关)


矩阵作为一个向量存储在内存中。第一种方法是按顺序访问内存。第二种方式访问它需要在内存位置上跳跃。参见http://en.wikipedia.org/wiki/row-major_order


如果您访问j-i,j维度会被缓存,这样机器代码就不必每次都更改它,第二个维度也不会被缓存,所以每次导致差异的原因都会删除缓存。


基于引用位置的概念,一段代码很可能访问相邻的内存位置。因此,加载到缓存中的值比请求的值多。这意味着更多的缓存命中。第一个示例很好地满足了这一点,而第二个示例中的代码则不满足。