2017-10-05 104 views
4

我有一个简单的例程,需要一个向量的产品Double。我试图并行化这些代码,但许多火花最终失败。这是也提供as a gist独立的基准:GHC Sparks为什么会失败?

{-# LANGUAGE BangPatterns #-} 
{-# LANGUAGE MagicHash #-} 

{-# OPTIONS_GHC -O2 -Wall -threaded -fforce-recomp #-} 

import Criterion.Main 
import Control.Monad (when) 
import Control.Parallel.Strategies (runEval,rpar,rseq) 
import qualified Data.Vector.Primitive as PV 

main :: IO() 
main = do 
    let expected = PV.product numbers 
    when (not (serialProduct numbers == expected)) $ do 
    fail "serialProduct implementation incorrect" 
    defaultMain 
    [ bgroup "product" 
     [ bench "serial" $ whnf serialProduct numbers 
     , bench "parallel" $ whnf parallelProduct numbers 
     ] 
    ] 

numbers :: PV.Vector Double 
numbers = PV.replicate 10000000 1.00000001 
{-# NOINLINE numbers #-} 

serialProduct :: PV.Vector Double -> Double 
serialProduct v = 
    let !len = PV.length v 
     go :: Double -> Int -> Double 
     go !d !ix = if ix < len then go (d * PV.unsafeIndex v ix) (ix + 1) else d 
    in go 1.0 0 

-- | This only works when the vector length is a multiple of 8. 
parallelProduct :: PV.Vector Double -> Double 
parallelProduct v = runEval $ do 
    let chunk = div (PV.length v) 8 
    p2 <- rpar (serialProduct (PV.slice (chunk * 6) chunk v)) 
    p3 <- rpar (serialProduct (PV.slice (chunk * 7) chunk v)) 
    p1 <- rseq (serialProduct (PV.slice (chunk * 0) (chunk * 6) v)) 
    return (p1 * p2 * p3) 

这可以建立与运行:

ghc -threaded parallel_compute.hs 
./parallel_compute +RTS -N4 -s 

我有一个八芯盒,所以给人运行四个能力应没事的。该基准测试的结果是不是超级重要,但在这里,他们是:现在

benchmarking product/serial 
time     11.40 ms (11.30 ms .. 11.53 ms) 
        0.999 R² (0.998 R² .. 1.000 R²) 
mean     11.43 ms (11.37 ms .. 11.50 ms) 
std dev    167.2 μs (120.4 μs .. 210.1 μs) 

benchmarking product/parallel 
time     10.03 ms (9.949 ms .. 10.15 ms) 
        0.999 R² (0.999 R² .. 1.000 R²) 
mean     10.17 ms (10.11 ms .. 10.31 ms) 
std dev    235.7 μs (133.4 μs .. 426.2 μs) 

,运行时统计信息。这是我很困惑的地方:

124,508,840 bytes allocated in the heap 
    529,843,176 bytes copied during GC 
    80,232,008 bytes maximum residency (8344 sample(s)) 
     901,272 bytes maximum slop 
      83 MB total memory in use (0 MB lost due to fragmentation) 

            Tot time (elapsed) Avg pause Max pause 
Gen 0  19 colls, 19 par 0.008s 0.001s  0.0001s 0.0003s 
Gen 1  8344 colls, 8343 par 2.916s 1.388s  0.0002s 0.0008s 

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

TASKS: 13 (1 bound, 12 peak workers (12 total), using -N4) 

SPARKS: 1024 (502 converted, 0 overflowed, 0 dud, 28 GC'd, 494 fizzled) 

INIT time 0.000s ( 0.002s elapsed) 
MUT  time 11.480s (10.414s elapsed) 
GC  time 2.924s ( 1.389s elapsed) 
EXIT time 0.004s ( 0.005s elapsed) 
Total time 14.408s (11.811s elapsed) 

Alloc rate 10,845,717 bytes per MUT second 

Productivity 79.7% of total user, 88.2% of total elapsed 

在处理火花的部分,我们可以看到其中大约一半失败。这对我来说似乎令人难以置信。在parallelProduct中,我们的主线程工作的任务比任何一个火花都大6倍。然而,似乎这些火花之一总是失败(或GCed)。这也不是一件小事。我们正在讨论一个需要几毫秒的计算,所以主线程可能在其他的thunk被触发之前完成它似乎是不可信的。

我的理解(这可能是完全错误的)是这种计算对于并发运行时应该是理想的。垃圾收集似乎是GHC中并发应用程序的最大问题,但我在这里执行的任务并没有产生任何垃圾,因为GHC将所有内容都拆箱的serialProduct的内部变成了一个紧密的循环。

好的方面,我们看到在基准测试中的并行版本加快11%。因此,成功引发的第八部分工作确实产生了可衡量的影响。我只是想知道为什么其他火花不能像我期望的那样工作。

任何帮助理解这一点,将不胜感激。

编辑

我已经更新the gist包括另一种实现方式:

-- | This only works when the vector length is a multiple of 4. 
parallelProductFork :: PV.Vector Double -> Double 
parallelProductFork v = unsafePerformIO $ do 
    let chunk = div (PV.length v) 4 
    var <- newEmptyMVar 
    _ <- forkIO $ evaluate (serialProduct (PV.slice (chunk * 0) chunk v)) >>= putMVar var 
    _ <- forkIO $ evaluate (serialProduct (PV.slice (chunk * 1) chunk v)) >>= putMVar var 
    _ <- forkIO $ evaluate (serialProduct (PV.slice (chunk * 2) chunk v)) >>= putMVar var 
    _ <- forkIO $ evaluate (serialProduct (PV.slice (chunk * 3) chunk v)) >>= putMVar var 
    a <- takeMVar var 
    b <- takeMVar var 
    c <- takeMVar var 
    d <- takeMVar var 
    return (a * b * c * d) 

这其中有优异的性能:

benchmarking product/parallel mvar 
time     3.814 ms (3.669 ms .. 3.946 ms) 
        0.986 R² (0.977 R² .. 0.992 R²) 
mean     3.818 ms (3.708 ms .. 3.964 ms) 
std dev    385.6 μs (317.1 μs .. 439.8 μs) 
variance introduced by outliers: 64% (severely inflated) 

但是,它倒在传统的并发原语,而不是使用火花。我不喜欢这个解决方案,但我提供它作为一个证据,证明它应该有可能通过基于火花的方法实现相同的性能。

+0

没有什么会跳出来,但是如果您将'-A200M'和'-qa'中的一个/两个都添加到您的RTS选项,会发生什么情况? – jberryman

+0

使用'./parallel_compute + RTS -N4 -s -qa -A200M',结果几乎相同。这是我所怀疑的,因为该计划没有真正分配太多。 –

+0

嗯,我想那些统计数据来自'main'标准,并且可能不是非常有用(例如,标准计算上次并行计算的统计数据)。从经验来看,我不相信运行时的方式以及GC与并行/并发与默认设置交互的方式。一个观察:做一些快速的数学,你的序列版本就像我们希望的那样快。看起来可能的是你并行评估的这两个小块很容易被缓存行为损坏,但是我不能让这些数字加起来 – jberryman

回答

4

这里的问题是创建火花不会立即唤醒闲置功能,请参阅here。默认情况下,调度时间间隔为20ms,因此当您创建一个spark时,最多需要20 ms才能将其转换为实际线程。那时调用线程很可能已经评估了thunk,并且火花将会被GC'd或失败。

相比之下,forkIO将立即唤醒闲置能力,如果有的话。这就是为什么显式并发比并行策略更可靠。

您可以使用-C选项(docs)减少调度间隔来解决问题。例如。 +RTS -C0.01似乎够用了。

+0

这解决了它。虽然调整这样的调度间隔似乎可能会对其他事情产生负面影响。所以我认为,在正常情况下,如果你要引发事情,你应该确保所有火花加上主线程完成的工作需要超过20毫秒。否则,几乎所有的东西都会失效,除非调度即将到来。我一直想知道应该如何细化火花的门槛,我的理解是,现在大致就是这样。另外,你在哪里找到'-C的文档?我找不到它。 –

+0

@AndrewThaddeusMartin我添加了一个链接到文档。重新设定门槛 - 是的,这也是我的印象。尽管我在实践中从未使用过并行策略。 – Yuras

+0

@AndrewThaddeusMartin可能是有道理的提出一个错误来看看ghc开发人员对火花调度的看法。我想知道是否可以立即唤醒一个能力。 – Yuras