2017-10-05 8 views
4

私はDoubleのベクトルの積をとる単純なルーチンを持っています。私はこのコードを並列化しようとしていますが、多くのスパークはふらつきます。なぜGHCスパークリングは吹き飛ぶのですか?

{-# 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) 

これは、構築されたとして実行することができます:ここにもas a gistを提供する自己完結型のベンチマークであるので、実行時に4つの機能を与え、私は8コアボックスを持っている

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倍大きなタスクでメインスレッドの作業を行います。しかし、これらの火花の1つがいつも狂ったように見える(またはGCed)ようです。そして、これは小さな仕事でもありません。我々はミリ秒かかる計算を話しているので、他のサンクが発生する前にメインスレッドが完了することができないと思われます。

私の理解(これは完全に間違っているかもしれません)は、この種の計算が同時実行時に理想的であるということです。ガーベジコレクションは、GHCの並列アプリケーションでは最大の問題ですが、GHCはserialProductの内臓をすべて箱詰めにしてタイトなループにするので、私がここでやっている作業はほとんどゴミを生成しません。

上側に、doベンチマークでパラレルバージョンの速度が11%向上します。したがって、成功裏にスパークされた作品の8番目の部分が実際に測定可能な影響を与えました。私はそれが他のスパークがなぜ私が期待するように動作しないのか不思議です。

これを理解する上での助けとなります。

EDIT

私は別の実装を含めるように更新the gistをしました:それは代わりに、従来の並行処理プリミティブにフォールバック、

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) 

しかし:

-- | 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) 

この1つは優れた性能を持っていますスパークを使って。私はこの解決策が嫌いですが、スパークベースのアプローチで同じ性能を達成することが可能であるという証拠として提供しています。

+0

私は何もジャンプしませんが、 '-A200M'と' -qa'の両方をRTSオプションに追加するとどうなりますか? – jberryman

+0

'./parallel_compute + RTS -N4 -s -qa -A200M'では、結果はほぼ同じです。これは、プログラムが実際にはあまり配分していないので、私が疑うところです。 –

+0

Hm、私はそれらの統計が基準 'main'から来て、おそらくそれほど役に立たないと思います(基準は、例えば前回見たときに統計を並行して計算します)。経験から私は、ランタイムとGCがデフォルト設定で並列性/並行性と相互作用する方法を不信にしてきました。観察:いくつかの素早い計算をすると、あなたのシリアルバージョンは私たちが望むほど速くなります。あなたが並行して評価している2つの小さなチャンクは、単にキャッシュの動作が悪いだけですが、数字をそこに追加することはできません。 – jberryman

答えて

4

ここで問題となるのは、sparkを作成してもアイドル機能がただちに起動するわけではないことです。hereを参照してください。デフォルトでは、スケジューリング間隔は20msです。スパークを作成すると、それを実際のスレッドに切り替えるのに最大20msかかります。その時までに、呼び出し元のスレッドはすでにサンクを評価しており、スパークはGCまたはfizzledのいずれかになります。

対照的に、forkIOはアイドル機能があればそれをただちに起動します。そのため明示的な並行性がより信頼性が高く、次に並列の戦略である理由です。

-Cオプション(docs)を使用してスケジューリング間隔を減らすことで問題を回避できます。例えば。 +RTS -C0.01と思われます。

+0

これで解決します。このようなスケジューリング間隔を調整することは、他のものに悪影響を与えるかもしれないようです。だから、通常の場合、スパークする場合は、すべてのスパークとメインスレッドで行われる作業が20ms以上かかることを確認する必要があります。そうしなければ、スケジューリングが間もなく起きない限り、ほとんどすべてが揺らぎます。私はいつもきめ細かなスパークがどうあるべきかの閾値について疑問を抱いていました。私はこれが大体のことです。また、 '-C'の文書はどこにありますか?それは見つけられません。 –

+0

@AndrewThaddeusMartin私はドキュメントへのリンクを追加しました。しきい値を再 - はい、それも私の印象です。私は実際には平行戦略を使ったことはありませんでした。 – Yuras

+0

@AndrewThaddeusMartinおそらく、ghcの開発者がスパークのスケジューリングについて考えるバグを報告するのは理にかなっています。私はすぐに能力を目覚めることが可能かどうか疑問です。 – Yuras

関連する問題