并行随机读取似乎效果很好–为什么?


18

考虑以下非常简单的计算机程序:

for i = 1 to n:
    y[i] = x[p[i]]

这里y是字节的n个元素数组,而p是字的n个元素数组。在这里,n很大,例如n = 2 31(这样,只有很小一部分的数据适合任何类型的高速缓存)。Xÿñpñññ=231

假设随机数组成,在1n之间均匀分布。p1个ñ

从现代硬件的角度来看,这意味着:

  • 读取很便宜(顺序读取)p[一世]
  • 读取非常昂贵(随机读取;几乎所有读取都是高速缓存未命中;我们将不得不从主内存中获取每个单独的字节)X[p[一世]]
  • 很便宜(顺序写)。ÿ[一世]

这确实是我所观察到的。与仅执行顺序读取和写入的程序相比,该程序非常慢。大。

现在出现一个问题:该程序在现代多核平台上的并行度如何


我的假设是该程序不能很好地并行化。毕竟,瓶颈是主要内存。单核已经在浪费大部分时间,只是在等待主存储器中的某些数据。

但是,当我开始尝试瓶颈是这种操作的某些算法时,这并不是我观察到的!

我只是用OpenMP并行for循环替换了朴素的for循环(本质上,它将范围拆分为较小的部分,并在不同的CPU内核上并行运行这些部分)。[1个ñ]

在低端计算机上,加速确实很小。但是在高端平台上,我惊讶地发现我获得了出色的近线性加速。一些具体示例(确切的时间安排可能会有所偏离,会有很多随机变化;这些只是快速的实验):

  • 2 x 4核Xeon(总共8核):与单线程版本相比,速度提高了5-8倍。

  • 2 x 6核Xeon(总共12核):与单线程版本相比,速度提高了8-14倍。

现在,这完全出乎意料。问题:

  1. 究竟为什么这种程序并行化如此好?硬件会发生什么?(我目前的猜测是沿着这些思路的:从不同线程进行的随机读取是“流水线式的”,得到这些答案的平均速率比单线程情况要高得多。)

  2. 是否有必要使用多个线程和多个内核来获得任何加速?如果确实在主内存和CPU之间的接口中发生了某种流水线操作,那么单线程应用程序是否不能让主内存知道很快将需要 x [ p [ i] + 1 ] ],...,计算机可以开始从主内存中提取相关的缓存行吗?如果原则上可行,我如何在实践中实现?X[p[一世]]X[p[一世+1个]]

  3. 我们可以使用什么正确的理论模型来分析此类程序(并对性能做出正确的预测)?


编辑:现在这里有一些源代码和基准测试结果:https : //github.com/suomela/parallel-random-read

球场数字的一些示例():ñ=232

  • 大约 单线程每次迭代42 ns(随机读取)
  • 大约 每次迭代5 ns(随机读取),包含12个内核。

Answers:


9

暂时忘记与访问主内存和3级缓存有关的所有问题。从并行的角度来看,由于忽略了这些问题,因此在使用处理器(或内核)时,该程序可以完美地并行化,原因是,一旦通过域分解对要分割的工作进行了划分,则每个内核都必须处理pÑñp元件并且没有通信和/或同步开销,因为没有在处理器之间的功能依赖。因此,忽略内存问题,您希望加速比等于pñpp

现在,让我们考虑一下内存问题。您在基于Xeon的高端节点上实际观察到的超线性加速的理由如下。

如果并行系统的内存是分层的,并且访问时间随着程序使用的内存而增加(以不连续的步长),则并行系统可能会表现出这种行为。在这种情况下,串行处理器上的有效计算速度可能比使用类似处理器的并行计算机上的有效计算速度慢。这是因为使用字节内存的顺序算法将在p处理器并行系统的每个处理器上仅使用n / p字节,而缓存和虚拟内存的影响可能反而会降低串行处理器的有效计算速率。ññ/pp

对于字节,我们需要2048 MB的内存。但是,如您上一个示例中那样使用12个内核时,每个内核仅需要处理2048/12 MB的数据,大约为170 MB。高端Xeon处理器配备了3级缓存,其大小范围为15到30 MB。显然,具有如此大的缓存大小,缓存命中率很高,这可以解释观察到的良好甚至超线性的加速。ñ=231

关于第二个问题,当前的体系结构已经通过逐出缓存行并根据需要替换它们以利用数据的时间和空间局部性来预取数据。但这对于处理2048 MB数据的单核是不够的。如果你限制为大约170 MB,那么您应该在一个内核上或多或少地看到相同的性能,因为您现在在(或多或少,并不完全)相同的条件下运行。ñ

最后,除了QSM(排队共享内存)之外,我还不知道任何其他理论上的并行模型在同一级别上考虑了访问共享内存的争用(在您的情况下,当使用OpenMP时,主内存在内核之间共享) ,并且缓存也总是在内核之间共享)。无论如何,尽管该模型很有趣,但并没有取得很大的成功。


1
当每个内核提供或多或少的固定数量的内存级别并行性(例如,在给定时间处理中有10 x []个负载)时,这可能也会有所帮助。如果共享L3命中率为0.5%,则单个线程将有0.995 ** 10(95 +%)的机会要求所有这些负载等待主内存响应。L3中有6个内核提供总共60个x []待定读取,因此几乎有26%的机会命中至少一个读取。另外,MLP越多,内存控制器就可以安排更多的访问以增加实际带宽。
保罗·克莱顿

5

我决定自己尝试__builtin_prefetch()。我将其发布在此处作为答案,以防其他人想要在其计算机上对其进行测试。结果接近Jukka的描述:与预取0个元素相比,预取20个元素可将运行时间减少20%。

结果:

prefetch =   0, time = 1.58000
prefetch =   1, time = 1.47000
prefetch =   2, time = 1.39000
prefetch =   3, time = 1.34000
prefetch =   4, time = 1.31000
prefetch =   5, time = 1.30000
prefetch =   6, time = 1.27000
prefetch =   7, time = 1.28000
prefetch =   8, time = 1.26000
prefetch =   9, time = 1.27000
prefetch =  10, time = 1.27000
prefetch =  11, time = 1.27000
prefetch =  12, time = 1.30000
prefetch =  13, time = 1.29000
prefetch =  14, time = 1.30000
prefetch =  15, time = 1.28000
prefetch =  16, time = 1.24000
prefetch =  17, time = 1.28000
prefetch =  18, time = 1.29000
prefetch =  19, time = 1.25000
prefetch =  20, time = 1.24000
prefetch =  19, time = 1.26000
prefetch =  18, time = 1.27000
prefetch =  17, time = 1.26000
prefetch =  16, time = 1.27000
prefetch =  15, time = 1.28000
prefetch =  14, time = 1.29000
prefetch =  13, time = 1.26000
prefetch =  12, time = 1.28000
prefetch =  11, time = 1.30000
prefetch =  10, time = 1.31000
prefetch =   9, time = 1.27000
prefetch =   8, time = 1.32000
prefetch =   7, time = 1.31000
prefetch =   6, time = 1.30000
prefetch =   5, time = 1.27000
prefetch =   4, time = 1.33000
prefetch =   3, time = 1.38000
prefetch =   2, time = 1.41000
prefetch =   1, time = 1.41000
prefetch =   0, time = 1.59000

码:

#include <stdlib.h>
#include <time.h>
#include <stdio.h>

void cracker(int *y, int *x, int *p, int n, int pf) {
    int i;
    int saved = pf;  /* let compiler optimize address computations */

    for (i = 0; i < n; i++) {
        __builtin_prefetch(&x[p[i+saved]]);
        y[i] += x[p[i]];
    }
}

int main(void) {
    int n = 50000000;
    int *x, *y, *p, i, pf, k;
    clock_t start, stop;
    double elapsed;

    /* set up arrays */
    x = malloc(sizeof(int)*n);
    y = malloc(sizeof(int)*n);
    p = malloc(sizeof(int)*n);
    for (i = 0; i < n; i++)
        p[i] = rand()%n;

    /* warm-up exercise */
    cracker(y, x, p, n, pf);

    k = 20;
    for (pf = 0; pf < k; pf++) {
        start = clock();
        cracker(y, x, p, n, pf);
        stop = clock();
        elapsed = ((double)(stop-start))/CLOCKS_PER_SEC;
        printf("prefetch = %3d, time = %.5lf\n", pf, elapsed);
    }
    for (pf = k; pf >= 0; pf--) {
        start = clock();
        cracker(y, x, p, n, pf);
        stop = clock();
        elapsed = ((double)(stop-start))/CLOCKS_PER_SEC;
        printf("prefetch = %3d, time = %.5lf\n", pf, elapsed);
    }

    return 0;
}

4
  1. DDR3访问确实是流水线的。http://www.eng.utah.edu/~cs7810/pres/dram-cs7810-protocolx2.pdf幻灯片20和24显示了在流水线读取操作期间内存总线中发生的情况。

  2. (部分错误,请参见下文)如果CPU体系结构支持缓存预取,则不需要多个线程。现代x86和ARM以及许多其他体系结构都具有显式的预取指令。许多其他尝试尝试检测内存访问中的模式并自动执行预取。该软件支持特定于编译器,例如GCC和Clang具有__builtin_prefech()固有的显式预取功能。

英特尔风格的超线程对于花费大部分时间等待高速缓存未命中的程序似乎效果很好。以我的经验,在计算密集型工作负载中,与物理核心数量相比,加速几乎没有。

编辑:我在第2点上错了。似乎预取可以优化单核的内存访问,但多核的组合内存带宽大于单核的带宽。更大多少取决于CPU。

硬件预取器和其他优化一起使基准测试非常棘手。有可能构造显式预取对性能产生非常明显或不存在影响的情况,该基准是后者之一。


__builtin_prefech听起来很有前途。不幸的是,在我的快速实验中,它似乎对单线程性能没有太大帮助(<10%)。我期望在这种应用中有多大的速度改进?
Jukka Suomela 2013年

我期望更多。由于我知道预取在DSP和游戏中具有重要作用,因此我不得不进行实验。原来兔子的洞更深了……
Juhani Simola 2013年

我的第一个尝试是创建存储在数组中的固定随机顺序,然后在有或没有预取的情况下按该顺序进行迭代(gist.github.com/osimola/7917602)。Core i5的价格相差约2%。听起来要么预取根本不起作用,要么硬件预测器可以理解间接寻址。
Juhani Simola 2013年

1
因此,为此进行测试,第二次尝试(gist.github.com/osimola/7917568)按固定随机种子生成的顺序访问内存。这次,预取版本大约是非预取版本的2倍,比提前1步预取版本快3倍。请注意,与非预取版本相比,预取版本对每个内存的访问执行更多的计算。
Juhani Simola 2013年

这似乎与机器有关。我在下面尝试了Pat Morin的代码(由于我没有声誉而无法对此帖子发表评论),并且对于不同的预取值,我的结果在1.3%以内。
Juhani Simola 2013年
By using our site, you acknowledge that you have read and understand our Cookie Policy and Privacy Policy.
Licensed under cc by-sa 3.0 with attribution required.