我正在编写一个带有粒子-粒子相互作用的简单N体模拟。我注意到,当我计算粒子之间的相对位置时,执行较少的计算时,我的代码实际上运行得更慢。
起初,我尝试了简单的实现(为简单起见,假定为1D):
for(int i=0; i<N; i++)
{
for(int j=0; j<N; j++)
{
r_rel[i][j] = r[i]-r[j];
}
}
这就像填充NxN矩阵。
自r_rel[i][j] = -r_rel[j][i]
起,这些循环对每个r_rel进行两次计算。因此,我尝试节省一些计算来实现以下解决方案:
for(int i=1; i<N; i++)
{
for(int j=0; j<i; j++)
{
r_rel[i][j] = r[i]-r[j];
r_rel[j][i] = -r_rel[i][j];
}
}
这样,我实际上只在相对位置的NxN矩阵中计算对角线以下的项。我希望代码会更快,因为它执行的计算较少,但是执行时,它的运行速度明显慢。这怎么可能? 谢谢!
答案 0 :(得分:3)
第一个循环以连续的内存顺序遍历r_rel
,在遍历每一行之后才继续前进到下一行:它在访问r_rel[i][j]
的同时循环访问j
的每个值,然后递增{{1 }}。
第二个循环以两个移动的访问点遍历i
,一个以连续的内存顺序进行,一个遍历矩阵的列,跨行。后一种行为不利于缓存,并且性能较差。 沿列遍历行主要数组对缓存性能非常不利。
高速缓存是昂贵的高性能内存,用于保存最近访问的数据或从内存加载的数据的副本,以备将来使用。当程序以经常访问高速缓存中数据的方式使用内存时,它可能会受益于高速缓存的高性能。当程序经常访问不在缓存中的数据时,处理器必须访问通用内存中的数据,这比缓存要慢得多。
缓存设计的典型特征包括:
r_rel
的地址。六个aa…aaabbbcccccc
位告诉我们这是高速缓存行中的哪个字节。 (2 6 =64。)这三个c
位告诉我们该字节必须进入哪个缓存集。 b
位与高速缓存行一起记录,记住它在内存中的位置。当某个进程以连续的内存顺序遍历a
时,则每次访问r_rel[i][j]
的成员时,它所访问的那个都是在缓存中同一行的一部分。上一次迭代,还是在下一个缓存行中。在前一种情况下,数据已经在缓存中,并且可以快速供处理器使用。在后一种情况下,必须从内存中获取它。 (某些处理器将已经启动了此提取操作,因为它们会预先提取最近访问内存之前的数据。之所以这样做是因为这种内存访问是一种常见的模式。)
从上面可以看到,第一组代码将必须为r_rel
中的每个高速缓存行执行一次高速缓存行的加载。下面,我们将将此数字与第二组代码的相似数字进行比较。
在第二组代码中,r_rel
的用法之一与第一组代码相同,尽管它仅遍历数组的一半。对于r_rel
,它执行第一个代码的大约一半的缓存负载。由于沿对角线使用效率低下,它会执行一些额外的负载,但是我们可以忽略它。
但是,r_rel[i][j]
,r_rel
的其他使用很麻烦。它通过r_rel[j][i]
行进行。
这个问题并没有为我们提供很多细节,因此我将为说明做些补充。假设r_rel
的元素每个为四个字节,行或列中的元素r_rel
的数量为128的倍数。还假定缓存为32,768字节,分为64组,每组8个每行64个字节。通过这种几何结构,地址模数512的余数(除法后的余数)决定了必须将内存分配给哪个高速缓存集。
因此,访问N
时发生的事情是该地址周围的64字节内存被带入缓存并分配给特定的缓存集。当r_rel[j][i]
递增时,该地址周围的内存将进入缓存并分配给特定的缓存集。 这些是相同的缓存集。。因为行是128个元素,每个元素是4个字节,所以正好相隔一行的两个元素之间的距离是128•4 = 512字节,即与用于确定某行进入哪个缓存集的数字相同。因此,这两个元素将分配给相同的缓存集。
起初很好。缓存集有八行。不幸的是,代码继续迭代j
。将j
递增八次后,它将访问j
的第九个元素。由于高速缓存集只有八行,因此处理器必须从该集中删除先前的行之一。随着代码继续迭代r_rel
,将删除更多行。最终,所有先前的行均被删除。当代码完成其j
的迭代并递增j
时,它返回到数组开头附近。
回想一下,在第一组代码中,当访问i
时,从访问r_rel[0][2]
开始,它仍处于高速缓存中。但是,在第二组代码中,r_rel[0][1]
早已脱离高速缓存。处理器必须再次加载它。
对于访问r_rel[0][2]
,第二组代码实际上没有从缓存中受益。每次访问都必须从内存中加载。由于在此示例中,每条高速缓存行中有16个元素(四字节元素,64字节行),因此对于一半矩阵,它具有大约16倍的内存访问。
如果整个数组中总共有 x 个缓存行,则第一组代码加载 x 个缓存行,第二组代码加载约<用于r_rel[j][i]
访问的em> x / 2个缓存行,以及{{}大约 x / 2•16 = 8• x 个缓存行1}}次访问,总共加载了8.5• x 个缓存行。
按列顺序遍历数组对于缓存性能而言是糟糕。
上面使用的示例编号。最灵活的一种是数组大小r_rel[i][j]
。我假设它是64的倍数。我们可以考虑其他一些值。如果它是32的倍数,则r_rel[i][j]
和N
将映射到不同的缓存集。但是,r_rel[j][i]
和r_rel[j+1][i]
映射到同一集合。这意味着,在r_rel[j][i]
进行了八次迭代之后,每个集合中仅使用了四行,因此不需要逐出旧行。不幸的是,这几乎无济于事,因为一旦r_rel[j+2][i]
超过16,代码将通过足够的值来迭代j
,以至于缓存集再次清空了较早的行,因此i
上的每个循环必须加载它遇到的每个缓存行。
另一方面,将j
设置为诸如73的值可能会减轻这种影响。当然,您不希望仅仅为了适应计算机硬件而更改阵列的大小。但是,您可以做的一件事是,即使仅使用j
×N
元素,也要使N
存储器NP
中的数组尺寸。选择N
(代表“ N已填充”)以使行相对于缓存几何形状具有奇数大小。多余的元素只是被浪费掉了。
这提供了一种快速的方法来更改程序,以证明高速缓存的影响正在使其变慢,但通常不是首选的解决方案。另一种方法是 tile 访问数组。而不是遍历整个数组迭代N
和NP
,而是将数组划分为某些大小的图块,按i
列划分为j
行。两个外部循环遍历所有图块,并且两个内部循环遍历每个图块中的数组元素。
A
和B
,以便在进行内部循环时,一个图块的所有元素都将保留在高速缓存中。对于上面的样本编号,A
和B
必须为8个或更少,因为在一个缓存集中只能保存阵列的八行。 (可能还有其他一些考虑因素会使最佳图块大小变小。或者,对于不同的元素大小或A
的值,最佳图块大小可能会更大。)
请注意,平铺会在编写代码时引起一些问题。在对角线上处理图块时,代码将处理同一图块中两个点的元素。当处理对角线上的图块时,代码将处理一个图块中一个点的元素,以及另一图块中的转置点的元素。这可能会影响操纵数组索引的代码以及内部循环的边界。对于对角拼贴,内部循环看起来类似于您的B
条件,处理了一个三角形。对于非对角线图块,内部循环将处理完整的正方形(如果N
和j < i
不同,则为矩形)。