Share
Embed
Haskellの並列・並行処理
しゅん🌙
May 14, 2022
Transcript
41 slides
-
アジェンダ - 並列処理と並行処理の違い - Haskell基礎知識 - Haskellの評価戦略 - Evalモナドを使った並列処理 -
IOモナドを使った並行処理 - MVarを使った共有メモリ - STMモナドを使った共有メモリ 2 -
アジェンダ - 並列処理と並行処理の違い - Haskell基礎知識 - Haskellの評価戦略 - Evalモナドを使った並列処理 -
IOモナドを使った並行処理 - MVarを使った共有メモリ - STMモナドを使った共有メモリ 3 -
今更聞けない並列処理と並行処理の違い - 計算を速くするための処理 - 結果は決定的 - 基本的に複数コアが前提 - 効率化のための技術 5
並列処理 - 複数の対話を同時に扱う - 結果は非決定的 - 複数コアが必要とは限らない - 構造化のための技術 並行処理 -
アジェンダ - 並列処理と並行処理の違い - Haskell基礎知識 - Haskellの評価戦略 - Evalモナドを使った並列処理 -
IOモナドを使った並行処理 - MVarを使った共有メモリ - STMモナドを使った共有メモリ 7 -
基本文法 - インデントに意味がある - <:より先は型の世界 - doはasyncの一般化(モナドで使用) - <-はawait+変数拘束の一般化(モナ ドで使用)
- 関数適用は最も優先度が高い - $は括弧の省略(に使える) - <>の左側は型クラス制約 8 main <: IO () main = do let x = 5 y = 10 result <- ioAdd x y putStrLn $ show result add <: (Num a) <> a <> a <> a add x y = x + y ioAdd <: (Num a) <> a <> a <> IO a ioAdd x y = pure $ add x y -
アジェンダ - 並列処理と並行処理の違い - Haskell基礎知識 - Haskellの評価戦略 - Evalモナドを使った並列処理 -
IOモナドを使った並行処理 - MVarを使った共有メモリ - STMモナドを使った共有メモリ 9 -
Haskellは非正格言語 - 中でもGHCは遅延評価を採用している - GHCiで:sprintを使うと非破壊で式を調べることができる 10 Prelude> x = 5
+ 3 <: Int Prelude> :sprint x x = _ Prelude> x 8 Prelude> :sprint x x = 8 -
Haskellは非正格言語 11 Prelude> x = 5 + 3 <: Int
Prelude> y = x + 3 Prelude> :sprint x x = _ Prelude> :sprint y y = _ Prelude> y 11 Prelude> :sprint x x = 8 Prelude> :sprint y y = 11 -
アジェンダ - 並列処理と並行処理の違い - Haskell基礎知識 - Haskellの評価戦略 - Evalモナドを使った並列処理 -
IOモナドを使った並行処理 - MVarを使った共有メモリ - STMモナドを使った共有メモリ 12 -
Evalモナドとrpar / rseq - 並列性はEvalモナドで表現される - rparは並列性を作り出す - rseqは直列評価を強制する -
runEvalでEvalモナドから値を取り出 す - Haskellではモナドから値を取り出す関数 の名前をrunFooやunFooなどにする習慣 がある 13 data Eval a instance Monad Eval runEval <: Eval a <> a rpar <: a <> Eval a rseq <: a <> Eval a -
普通の計算プログラム - 未最適化のフィボナッチ数列のn番目 を求める関数を用意 - 僕のマシンでは約10秒ほどかかる 14 module Main where
main <: IO () main = do print $ fib 45 print $ fib 44 fib <: Int <> Int fib 0 = 0 fib 1 = 1 fib n = fib (n - 2) + fib (n - 1) -
並列性のある計算プログラム - rparを使うとその式が並列処理できる ことを表す - rparに渡す式は未評価の計算である ことが望ましい(というか意味がない) - runEvalすると値が取り出せる 15
module Main where import Control.Parallel.Strategies main <: IO () main = do let (x, y) = runEval $ do x <- rpar $ fib 45 y <- rpar $ fib 44 pure (x, y) print x print y -
並列性のある計算プログラム 16 module Main where import Control.Parallel.Strategies main <: IO
() main = do let (x, y) = runEval $ do x <- rpar $ fib 45 y <- rpar $ fib 44 pure (x, y) print x print y -
並列性のある計算プログラム - rseqを使うと評価の完了を待つように なる - この場合はxとyの評価が終わるまで pureには行かない 17 module Main
where import Control.Parallel.Strategies main <: IO () main = do let (x, y) = runEval $ do x <- rpar $ fib 45 y <- rpar $ fib 44 rseq x rseq y pure (x, y) print x print y -
並列性のある計算プログラム 18 module Main where import Control.Parallel.Strategies main <: IO
() main = do let (x, y) = runEval $ do x <- rpar $ fib 45 y <- rpar $ fib 44 rseq x rseq y pure (x, y) print x print y -
並列性のある計算プログラム - -threadedをコンパイルオプションで付けるだけで並列プログラムの完成 - +RTS -Nnで使用するコア数を指定可能 19 $ time ./haskell-test
1134903170 701408733 10.17s user 0.04s system 99% cpu 10.275 total $ time ./haskell-test +RTS -N2 1134903170 701408733 +RTS -N2 12.24s user 0.43s system 197% cpu 6.415 total -
Haskellの並列処理の仕組み - rparに渡された引数はスパークと呼ばれる - スパークはランタイムによってプールされる - ワークスチールと呼ばれる手段でプロセッサに割り当てられる - スパークの生成コストは非常に安い -
Haskell公式パッケージであるparallelには他にも並列処理のための便利関数が多 数用意されているのでお試しあれ - Strategy型シノニムによって評価戦略の入れ替えも容易 20 -
アジェンダ - 並列処理と並行処理の違い - Haskell基礎知識 - Haskellの評価戦略 - Evalモナドを使った並列処理 -
IOモナドを使った並行処理 - MVarを使った共有メモリ - STMモナドを使った共有メモリ 21 -
IOモナドとは - 副作用がある(かもしれない)動作「そのもの」 - IOモナドが作られ、評価されるだけでは副作用は発生しない - 副作用の発生はmain関数(≒ランタイム)の仕事 22 main <:
IO () main = do let _ = putStrLn “Hello, world!” `seq` () putStrLn “done” $ cabal run done -
IOモナドを使った並列処理の実現方法 - forkIOで新しいスレッドを生成すること ができる - 渡すのはIOモナド - 帰ってくるのはThreadId(IOモナドに 包まれている) -
forkIOは非常にプリミティブで、join等 の機能は無い - 共有メモリや別ライブラリを使う 23 forkIO <: IO () <> IO ThreadId -
GHCのスレッドについて - forkIOで作られるスレッドはいわゆる「グリーンスレッド」 - 大量に作成しても大きなオーバーヘッドはない - I/OライブラリはLinuxのepoll、BSD系のkqueue等を使用しているため、スレッド間 でI/Oを同時処理しても性能劣化が少ない - ネイティブスレッドが欲しい場合はforkOSという関数も提供されている
- なお、IOモナド内に並行処理不可能な処理を入れてもforkIOは成功してしまうので 注意 - Haskellで提供されているライブラリや関数全てがスレッド安全ではない - 例) IORef: IOモナド内で使用可能な可変参照 24 -
アジェンダ - 並列処理と並行処理の違い - Haskell基礎知識 - Haskellの評価戦略 - Evalモナドを使った並列処理 -
IOモナドを使った並行処理 - MVarを使った共有メモリ - STMモナドを使った共有メモリ 25 -
MVarを使った共有メモリ - MVarはスレッド間通信で使用できる 共有メモリ - 値が入ってるか・空であるかのどちら かの状態を取る - 空の時にtakeしようとするとブロック -
値が入ってる時にputしようとするとブ ロック - 単一項チャンネルとしてやロック機構 としても使用可能 26 data MVar a newEmptyMVar <: IO (MVar a) newMVar <: a <> IO (MVar a) takeMVar <: MVar a <> IO a putMVar <: MVar a <> a <> IO () -
MVarの特徴(安全性) - ランタイムはデッドロックを検知すると BlockedIndefinitelyOnMVarという例 外を投げる - うっかりデッドロックしても例外が飛ぶ のでデバッグに便利 27 main
= do x <- newEmptyMVar takeMVar x $ cabal run haskell-test: thread blocked indefinitely in an MVar operation -
MVarの特徴(公平性) - GHCはラウンドロビン方式のスケ ジューラを使用している - CPU時間の割当を受けられないスレッ ドがないことを保証する - MVarには公平性がある -
他スレッドが永遠にMVarを抱え込 むことが無い限り、スレッドが永遠 にブロックされることはない - ブロックされたスレッドはキューに入 れられる - ブロック解除されるのは必ず 1つのス レッドだけ 28 main = do hSetBuffering stdout NoBuffering forkIO $ replicateM_ 100 $ putChar ‘A’ replicateM_ 100 $ putChar ‘B’ $ cabal run BABABABABABABABABABABABABABABABABABABABA BABABABABABABABABABABABABABABABABABABABA BABABABABABABABABABABABABABABABABABABABA BABABABABABABABABABABABABABABABABABABABA BABABABABABABABABABABABABABABABABABABABA -
MVarの特徴(遅延) - putMVarした時、xに入るのは未評 価の式fib 45 - この場合、putMVarによるロックが 短時間で済む - ただしスペースリークに注意
- $!で正格評価可能 29 main <: IO () main = do x <- newEmptyMVar putMVar x $ fib 45 main <: IO () main = do x <- newEmptyMVar putMVar x $! fib 45 -
アジェンダ - 並列処理と並行処理の違い - Haskell基礎知識 - Haskellの評価戦略 - Evalモナドを使った並列処理 -
IOモナドを使った並行処理 - MVarを使った共有メモリ - STMモナドを使った共有メモリ 30 -
STMモナドを使った共有メモリ - STMはモナド - TVarがトランザクション変数 - 基本的にSTMに関連する変数やチャ ンネルはSTMモナド内でのみ読み書 きが可能 -
STMモナド内の計算はatomically関 数で実行できる - 名前の通り、操作全体が不可分になる 32 data STM a instance Monad STM atomically <: STM a <> IO a data TVar a newTVar <: a <> STM (TVar a) readTVar <: TVar a <> STM a writeTVar <: TVar a <> a <> STM () retry <: STM a orElse <: STM a <> STM a <> STM a -
STMモナドの良さ data Account = Account (TVar Int) transferSTM <: Account
<> Account <> Int <> STM () transferSTM (Account from) (Account to) amount = do from' <- readTVar from to' <- readTVar to writeTVar from $ from' - amount writeTVar to $ to' + amount transfer <: Account <> Account <> Int <> IO () transfer from to amount = atomically $ transferSTM from to amount - 銀行の送金システムを考える - もしAccountがMVarやMutexだと、デッドロックの可能性がある - STMならデッドロックの心配がない! 33 -
STMモナドの良さ transfer <: Account <> Account <> Int <> IO
() transfer from to amount = atomically $ transferSTM from to amount transferToMany <: Account <> [Account] <> Int <> IO () transferToMany from toList amount = atomically $ forM_ toList $ \to <> transferSTM from to amount - STMモナドは合成することが可能 - 合成可能性という - STM操作をatomicallyに包まずに提供することで利用側が自由に合成することが できる 34 -
STMモナドの良さ transferSTM' <: Account <> Account <> Int <> STM
() transferSTM' (Account from) (Account to) amount = do from' <- readTVar from to' <- readTVar to when (from' < amount) retry writeTVar from $ from' - amount writeTVar to $ to' + amount - retryは現在のトランザクションを破棄してもう一度やり直す - ランタイムはトランザクション内で読み込まれたTVarを知っているので、他のトラン ザクションでTVarが変更されるまでスレッドをブロックする 35 -
STMの特徴色々 - STMトランザクションはreadTVarとwriteTVarのログを蓄積しながら動いている - writeTVarはすぐにメモリへ書き込むのではなく、ログを蓄積することでトランザク ションの破棄をしやすくしている - readTVarはログの走査をして、前のwriteTVar処理を検査する - よって、raedTVarはログの長さに対して
O(n)のコストがかかる - トランザクションの最後まで来ると、ログとメモリの内容を比較する - メモリの内容とreadTVarで読みだした値が一致するならば、メモリへコミットされる - 一致しない場合、トランザクションは retryされる - 上記の工程中のみトランザクションに含まれるTVarをロックする - トランザクションが長時間に渡る場合、無限に再実行される可能性がある 36 -
STMの特徴色々 - それぞれTVarは自身が変更された際に起こす必要のあるスレッドの監視リストを 持っている - retryは現在のスレッドをトランザクション中に含まれる全てのTVarの監視リストに追 加する - つまりretryはTVarの数に対してO(n)の操作 -
トランザクションがコミットされた時、変更を受けたTVarの監視リストにあるスレッド をすべて起こしに行く 37 -
まとめ - Haskellの遅延評価は並列・並行処理に一役買っている - 並列処理はEvalモナドを使うと嬉しい - 並行処理はIOモナドを使うと嬉しい - STMが安全に処理できる言語はHaskellくらい -
純粋性と柔軟な型システムのおかげ - ぶっちゃけ困ったらasyncパッケージ使ってればいいと思います 39