在N体仿真中执行较少的计算时,程序运行速度较慢?

时间:2019-04-20 08:35:45

标签: c loops

我正在编写一个带有粒子-粒子相互作用的简单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矩阵中计算对角线以下的项。我希望代码会更快,因为它执行的计算较少,但是执行时,它的运行速度明显慢。这怎么可能? 谢谢!

1 个答案:

答案 0 :(得分:3)

第一个循环以连续的内存顺序遍历r_rel,在遍历每一行之后才继续前进到下一行:它在访问r_rel[i][j]的同时循环访问j的每个值,然后递增{{1 }}。

第二个循环以两个移动的访问点遍历i,一个以连续的内存顺序进行,一个遍历矩阵的列,跨行。后一种行为不利于缓存,并且性能较差。 沿列遍历行主要数组对缓存性能非常不利。

高速缓存是昂贵的高性能内存,用于保存最近访问的数据或从内存加载的数据的副本,以备将来使用。当程序以经常访问高速缓存中数据的方式使用内存时,它可能会受益于高速缓存的高性能。当程序经常访问不在缓存中的数据时,处理器必须访问通用内存中的数据,这比缓存要慢得多。

缓存设计的典型特征包括:

  • 缓存被组织为,这是连续内存的单位。典型的行大小为64字节。
  • 缓存行被组织成 sets 。每个集合与来自存储器地址的某些位相关联。缓存每组可能有两行或八行。
  • 在每个组中,一行可以是已分配了其位的内存的任何部分的副本。例如,考虑一个具有位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 访问数组。而不是遍历整个数组迭代NNP,而是将数组划分为某些大小的图块,按i列划分为j行。两个外部循环遍历所有图块,并且两个内部循环遍历每个图块中的数组元素。

选择

AB,以便在进行内部循环时,一个图块的所有元素都将保留在高速缓存中。对于上面的样本编号,AB必须为8个或更少,因为在一个缓存集中只能保存阵列的八行。 (可能还有其他一些考虑因素会使最佳图块大小变小。或者,对于不同的元素大小或A的值,最佳图块大小可能会更大。)

请注意,平铺会在编写代码时引起一些问题。在对角线上处理图块时,代码将处理同一图块中两个点的元素。当处理对角线上的图块时,代码将处理一个图块中一个点的元素,以及另一图块中的转置点的元素。这可能会影响操纵数组索引的代码以及内部循环的边界。对于对角拼贴,内部循环看起来类似于您的B条件,处理了一个三角形。对于非对角线图块,内部循环将处理完整的正方形(如果Nj < i不同,则为矩形)。