如何减少Haskell的并行化开销?

Ste*_*han 12 parallel-processing performance haskell

我试图了解Haskell的并行化性能.

我有一个长列表(长度> 1000),我正在使用parallel并行评估parMap.

以下是+RTS -s用于单个线程的完整统计信息输出(编辑:完整统计输出):

        54,248,802,288 bytes allocated in the heap
           324,451,424 bytes copied during GC
             2,970,272 bytes maximum residency (4 sample(s))
                52,064 bytes maximum slop
                   217 MB total memory in use (1 MB lost due to fragmentation)

                                          Tot time (elapsed)  Avg pause  Max pause
        Gen  0       251 colls,     0 par    1.45s    1.49s     0.0059s    0.0290s
        Gen  1         4 colls,     0 par    0.03s    0.05s     0.0125s    0.0319s

        TASKS: 4 (1 bound, 3 peak workers (3 total), using -N1)

        SPARKS: 6688 (0 converted, 0 overflowed, 0 dud, 1439 GC'd, 5249 fizzled)

        INIT    time    0.00s  (  0.03s elapsed)
        MUT     time   19.76s  ( 20.20s elapsed)
        GC      time    1.48s  (  1.54s elapsed)
        EXIT    time    0.00s  (  0.00s elapsed)
        Total   time   21.25s  ( 21.78s elapsed)

        Alloc rate    2,745,509,084 bytes per MUT second

        Productivity  93.0% of total user, 90.8% of total elapsed

      gc_alloc_block_sync: 0
      whitehole_spin: 0
      gen[0].sync: 0
      gen[1].sync: 0
Run Code Online (Sandbox Code Playgroud)

如果我运行两个线程,使用+RTS -N2,我得到:

        54,336,738,680 bytes allocated in the heap
           346,562,320 bytes copied during GC
             5,437,368 bytes maximum residency (5 sample(s))
               120,000 bytes maximum slop
                   432 MB total memory in use (0 MB lost due to fragmentation)

                                          Tot time (elapsed)  Avg pause  Max pause
        Gen  0       127 colls,   127 par    2.07s    0.99s     0.0078s    0.0265s
        Gen  1         5 colls,     4 par    0.08s    0.04s     0.0080s    0.0118s

        Parallel GC work balance: 41.39% (serial 0%, perfect 100%)

        TASKS: 6 (1 bound, 5 peak workers (5 total), using -N2)

        SPARKS: 6688 (6628 converted, 0 overflowed, 0 dud, 0 GC'd, 60 fizzled)

        INIT    time    0.00s  (  0.01s elapsed)
        MUT     time   25.31s  ( 13.35s elapsed)
        GC      time    2.15s  (  1.03s elapsed)
        EXIT    time    0.01s  (  0.01s elapsed)
        Total   time   27.48s  ( 14.40s elapsed)

        Alloc rate    2,146,509,982 bytes per MUT second

        Productivity  92.2% of total user, 175.9% of total elapsed

      gc_alloc_block_sync: 19922
      whitehole_spin: 0
      gen[0].sync: 1
      gen[1].sync: 0
Run Code Online (Sandbox Code Playgroud)

并在四个线程上:

        54,307,370,096 bytes allocated in the heap
           367,282,056 bytes copied during GC
             8,561,960 bytes maximum residency (6 sample(s))
             3,885,784 bytes maximum slop
                   860 MB total memory in use (0 MB lost due to fragmentation)

                                          Tot time (elapsed)  Avg pause  Max pause
        Gen  0        62 colls,    62 par    2.45s    0.70s     0.0113s    0.0179s
        Gen  1         6 colls,     5 par    0.20s    0.07s     0.0112s    0.0146s

        Parallel GC work balance: 40.57% (serial 0%, perfect 100%)

        TASKS: 10 (1 bound, 9 peak workers (9 total), using -N4)

        SPARKS: 6688 (6621 converted, 0 overflowed, 0 dud, 3 GC'd, 64 fizzled)

        INIT    time    0.01s  (  0.01s elapsed)
        MUT     time   37.26s  ( 10.95s elapsed)
        GC      time    2.65s  (  0.77s elapsed)
        EXIT    time    0.01s  (  0.01s elapsed)
        Total   time   39.94s  ( 11.76s elapsed)

        Alloc rate    1,457,427,453 bytes per MUT second

        Productivity  93.4% of total user, 317.2% of total elapsed

      gc_alloc_block_sync: 23494
      whitehole_spin: 0
      gen[0].sync: 10527
      gen[1].sync: 38
Run Code Online (Sandbox Code Playgroud)

因此,根据经过的时间(每个输出中的最后一个数字),使用两个内核,程序占用单线程版本的约66%,而四个内核占54%的时间.这种加速并不算太糟糕,但远远低于理论上预期的内核数量的线性改进,这将导致4个内核的运行时间为25%.

现在,当查看上面的统计输出时,我可以看到程序的实际工作CPU时间(以行开头的行MUT)随着使用更多内核而显着增加.有了1,2和4个内核,我得到的CPU时间分别为19.76秒,25.31秒和37.26秒,这个增加就是 - 我相信 - 正在耗尽我的并行化性能.

我想到的具有多个内核的CPU运行时开销的典型原因是:

  • 太精细的工作负载分配.不过,我尝试使用相同的程序parListChunkedparallel包装,用10块大小但结果是非常相似的,所以我不会在那一刻觉得开销是由于太细的粒度.
  • 垃圾收集:这对我的代码来说是一个重要的性能杀手,但是由于我将GC大小增加到100Mb,因此在GC中花费的总时间非常少,如上面的统计数据所示.

这种强大开销的其他原因是什么,我该如何减轻它们?

Yur*_*ras 3

我看到人们投票结束这个问题,因为没有足够的细节,但我相信可以使用已经提供的信息找到答案(尽管总是欢迎更多细节。)

我的鼻子告诉我你受到内存吞吐量的限制。我将尝试描述为什么我这么认为,但我不是硬件专家,所以我可能部分或完全错误。毕竟,它是基于我个人的一套硬件架构神话。

让我们假设限制在每秒 50-100Gb 之间(我不确定这是正确的数字,如果您有更好的数字,请纠正我。)

您在 10 秒内分配 54Gb(本-N4例),因此吞吐量为 5Gb/秒。它相当高,但通常它本身不是问题。

大多数分配通常都是短暂的,一旦 gen0 分配区域(托儿所)被填满,它们就会被 GC。默认情况下,nursery 大小为 512 Kb,因此所有分配都发生在 L2 缓存中。如此短暂的数据永远不会进入主内存,这就是为什么它几乎是免费的。

但是您将 Nursery 大小增加到 100Mb。它不适合二级缓存,将被转移到主内存。这已经是一个不好的迹象了。

嗯,5Gb/秒还远未达到极限。但增加苗圃规模是有原因的——您的数据并不是短暂的。经过一段时间的延迟后,它将在其他地方使用。这意味着这 54Gb 迟早会从主内存加载回缓存。所以你至少有 10Gb/秒的吞吐量。

它距离限制还很远,但请注意,这是最好的情况——顺序内存访问模式。实际上,您正在以随机顺序访问内存,因此相同的缓存行会被加载和卸载多次,并且您可以轻松达到 100Gb/秒。

要解决这个问题,您应该确定为什么我们的数据不是短暂的,并尝试解决它。如果这是不可能的,您可以尝试增加数据局部性并更改内存访问模式以使其顺序化。

我想知道硬件专家对我天真的解释有何看法:)

  • 来自粗略的谷歌:现代内存的带宽约为 5-10Gb/秒,而在某些现代系统上,L2 缓存和 L1 缓存的吞吐量在 200-1000GB/秒范围内,L3 缓存为 50-100GB/秒。因此,当 Nursery 大于系统 L3 缓存时,可能会出现一些性能悬崖。 (2认同)