为什么循环执行时间的此C++存在显着差异?

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

本文关键字:存在 C++ 循环 执行时间 为什么      更新时间:2023-10-16

我正在遍历循环,发现在访问循环方面存在显著差异。我不明白是什么原因导致了这两种情况下的差异?

第一个例子:

执行时间;8秒

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秒

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];
        }
}

仅仅交换,是什么导致了如此大的执行时间差

matrix[i][j] 

matrix[j][i]

这是内存缓存的问题。

matrix[i][j]matrix[j][i]具有更好的缓存命中率,因为matrix[i][j]具有更多的连续内存访问机会。

例如,当我们访问matrix[i][0]时,高速缓存可能加载包含matrix[i][0]的连续内存段,从而访问matrix[i][1]matrix[i][2]。。。,将受益于缓存速度,因为matrix[i][1]matrix[i][2]。。。接近CCD_ 10。

然而,当我们访问matrix[j][0]时,它与matrix[j - 1][0]相距甚远,可能没有被缓存,并且无法从缓存速度中获益。特别是,矩阵通常存储为连续的大内存段,缓存器可以断言内存访问的行为,并始终缓存内存。

这就是matrix[i][j]更快的原因。这在基于CPU缓存的性能优化中是典型的。

性能差异是由计算机的缓存策略引起的。

二维阵列CCD_ 14被表示为存储器中的值的长列表。

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

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

在本例中,A[0][x]的每个条目都设置为1,A[1][x]中的每个条目设置为2。。。

如果您的第一个循环应用于此矩阵,则访问顺序为:

1 2 3 4   5 6 7 8   9 10 11 12

而第二个循环访问顺序如下:

1 4 7 10  2 5 8 11  3 6 9 12

当程序访问数组的一个元素时,它还会加载后续元素。

例如,如果您访问A[0][1],则也会加载A[0][2]A[0][3]

因此,第一个循环必须执行较少的加载操作,因为一些元素在需要时已经在缓存中。第二个循环将当时不需要的条目加载到缓存中,从而导致更多的加载操作。

其他人已经很好地解释了为什么一种形式的代码比另一种更有效地使用内存缓存。我想补充一些你可能不知道的背景信息:你可能没有意识到现在主内存访问有多贵。

这个问题中发布的数字对我来说似乎是正确的,我将在这里复制它们,因为它们非常重要:

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,具体取决于您的型号;为了简化计算,我们称之为3GHz。所以一个周期是0.33333纳秒。因此DRAM访问也是100-300个周期。

关键是,在从主存储器读取一个缓存行的时间内,CPU可能已经执行了数百条指令。这被称为记忆墙。正因为如此,在现代CPU的整体性能中,内存缓存的有效使用比任何其他因素都更重要。

答案在一定程度上取决于matrix是如何定义的。在一个完全动态分配的数组中,您将拥有:

T **matrix;
matrix = new T*[n];
for(i = 0; i < n; i++)
{
   t[i] = new T[m]; 
}

因此,每个matrix[j]都需要对指针进行新的内存查找。如果在外部执行j循环,则内部循环可以对整个内部循环每隔一次重复使用matrix[j]的指针。

如果矩阵是一个简单的2D阵列:

T matrix[n][m];

matrix[j]将简单地与1024 * sizeof(T)相乘——这可以通过将优化代码中的循环索引1024 * sizeof(T)相加来完成,因此无论哪种方式都应该相对较快。

除此之外,我们还有缓存位置因子。缓存具有"行"数据,通常每行32到128字节。因此,如果您的代码读取地址X,那么缓存将在X周围加载32到128字节的值。因此,如果你需要的NEXT只是从当前位置转发的sizeof(T),那么它很可能已经在缓存中了[现代处理器也会检测到你正在循环读取每个内存位置,并预加载数据]。

j内循环的情况下,您正在为每个循环读取sizeof(T)*1024距离的新位置[或者,如果是动态分配的,则可能读取更大的距离]。这意味着加载的数据对下一个循环没有用处,因为它不在接下来的32到128个字节中。

最后,由于SSE指令或类似指令,第一个循环完全有可能得到更好的优化,这使得计算可以更快地运行。但对于如此大的矩阵来说,这可能是微不足道的,因为在这种大小下,性能是高度受限的。

内存硬件没有优化为提供单独的地址:相反,它倾向于在称为缓存线的更大的连续内存块上运行。每次你读取矩阵的一个条目,它所在的整个缓存行也会随之加载到缓存中

更快的循环排序被设置为按顺序读取内存;每次加载缓存行时,都会使用该缓存行中的所有条目。每次通过外循环,您只读取一次每个矩阵条目。

然而,较慢的循环排序在继续之前只使用每个缓存行中的一个条目。因此,每个缓存行必须加载多次,对于行中的每个矩阵条目加载一次。例如,如果double是8字节,并且高速缓存行是64字节长,则每次通过外循环都必须读取每个矩阵条目八次,而不是一次。


话虽如此,如果你打开了优化,你可能不会看到任何区别:优化器理解这种现象,好的优化器能够识别出他们可以为这个特定的代码片段交换哪个循环是内循环,哪个循环是外循环。

(此外,一个好的优化器只会对最外层的循环进行一次遍历,因为它识别出前999次遍历与sum的最终值无关)

矩阵作为向量存储在内存中。以第一种方式访问它会按顺序访问内存。以第二种方式访问它需要在内存位置之间跳转。看见http://en.wikipedia.org/wiki/Row-major_order

如果您访问j-i,则j维度会被缓存,因此机器代码不必每次都更改它,第二个维度不会被缓存,所以您实际上每次都会删除缓存,这是造成差异的原因。

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