Haskellは遅延評価を特徴としています。しかし,どのような機能にも弱点はあります。第9回第47回では,遅延評価自体が持つ弱点や,その問題を解決しようとした際に生じる問題点について解説しました。

 同様に,並列Haskellやデータ並列Haskellといった,GHCで提供されている並列処理の機能にも弱点があります。また,その問題を避けるために開発されたライブラリにも,相応の弱点があります。

 並列Haskellが提供する並列処理機能の抽象化能力は極めて強力ですが,抽象化され過ぎていて並列処理の制御が難しいという問題があります。一方,より低レベルの並列処理機能は,並列処理を直接制御できるものの,並列処理の抽象化能力が乏しく,大規模なプログラムを並列化するのは困難です。

 このように,現在のHaskellの並列処理機能は,理想的とはいえません。高速な並列プログラムをなるべく短期間で作成するには,それぞれの並列処理機能が持つ弱点を把握し,状況によって使い分けることが重要になります。

par関数やpseq関数の問題点

 これまでの回では,Haskellでの並列プログラミングを説明する際に,並列Haskellを使う例を多く取り上げました。しかし,並列Haskellには大きな欠点があります。「並列Haskellの基本的な関数であるpar関数やpseq関数の使い方をプログラマが間違いやすい」という点です。こうした関数の使い方を誤ると,プログラムの挙動が期待とは異なるものになる可能性があります。

 例を見てみましょう。第48回で取り上げた並列処理版のクイックソート関数では,「x `par` y `pseq` f x y 」という形の式を使うことで,xとyを並列に計算し,xとyのそれぞれの結果を使って計算を行っていました。第48回では,最終的なクイックソート関数の定義は以下のようになりました。

qsortPar :: (Ord a, NFData a) => Int -> [a] -> [a]
qsortPar = qsortParWithPivot' selectMiddle 0

qsortParWithPivot' :: (Ord a, NFData a) => ([a] -> a) -> Int -> Int -> [a] -> [a]
qsortParWithPivot' _ _ _ []  = []
qsortParWithPivot' _ _ _ [x] = [x]
qsortParWithPivot' f !currentDepth !limit xs
    | currentDepth >= limit = quickSortWithPivot f xs
    | otherwise             = losort ++ hisort `using` strategy
  where
      x      = f xs
      losort = qsortParWithPivot' f (currentDepth+1) limit [y | y <- xs, y < x]
      hisort = qsortParWithPivot' f (currentDepth+1) limit [y | y <- xs, y >= x]
      strategy result = rdeepseq losort `par`
                        rdeepseq hisort `pseq`
                        rdeepseq result

 ここで使われているstrategy関数の意味を復習しておきましょう。

 par関数を使った「x `par` y」という式は,「xを並列計算のタスクであるsparkに指定することで,xとyを並列に評価する」という意味です。pseq関数を使った「y `pseq` z」という式は,「yを評価した後にzを評価する」ことを意味します。par関数とpseq関数はそれぞれ右結合なので,「x `par` y `pseq` f x y」という式は,「x `par` (y `pseq` f x y)」と解釈され,「xとyを並列に評価し,その結果を使って『f x y』を計算する」という意味になります。

Prelude Control.Parallel> :i par
par :: a -> b -> b 	-- Defined in Control.Parallel
infixr 0 par
Prelude Control.Parallel> :i pseq
pseq :: a -> b -> b 	-- Defined in Control.Parallel
infixr 0 pseq

 同様に「x1 `par` x2 `par` ... `par` xn `pseq` f x1 x2 ... xn」という式は,「x1からxnまでを並列に計算し,それぞれの結果を利用して『f x1 x2 ... xn』を計算する」という定義になります。

 ただし,「x `par` y `pseq` f x y」という式では,xとyの中身はWHNFまでの評価しか保証されません。そこでstrategy関数では,第43回のコラムで説明したrdeepseq関数を利用することで,losortとhisortのリストの要素まで評価した後に「rdeepseq result」を評価しています。

 こう書くと簡単に見えますが,並列Haskellには罠があります。関数を間違って使ってしまっても,誤りに気づきにくいという問題です。

 評価を順番に行いたい場合,深く考えずにseq関数を使ってしまいがちです。しかし,第8回のコラムで説明したように,seq関数を使って「y `seq` z」と書いても,yの簡約がうながされるだけで「yを評価した後zを評価する」という意味にはなりません。一方,pseq関数を使って「y `pseq` z」と書けば,「yを評価した後にzを評価する」という意味になります。評価の順番を陽に指定したい場合には,seq関数ではなくpseq関数を使わなければなりません。

 しかし,seq関数とpseq関数の型や結合性は同じなので,pseq関数を使うべきところでseq関数を使ってしまった場合,誤りに気づきにくいという問題があります。唯一の解決方法はseq関数をインポートしないことですが,モジュール内の関数定義でseq関数が必要な場合には,この方法は利用できません。また,seq関数はPreludeモジュールで提供されている関数なので,hidingなどを使って陽にインポートの対象から取り除かない限り,必ずインポートされます(参考リンク)。

 また,par関数は「xとyを並列に評価する」という意味であることから,「xを並列計算のタスクであるsparkに指定する」という実装上での振舞いを意識せずに使ってしまうという問題もあります。par関数を単に「xとyを並列に評価する関数」だと誤解すると,pseq関数で良いところまでpar関数を使ってしまう可能性があります。pseq関数で済むところにpar関数を使ってしまうと,性能向上に寄与しないsparkが無駄に作られ,時間・空間的な効率を損なうという問題があります。par関数の型や結合性は,seq関数やpseq関数と同じなので,このような誤用があっても,seq関数とpseq関数の場合と同様に,誤りに気づくのは簡単ではありません。

Prelude> :i seq
seq :: a -> b -> b 	-- Defined in GHC.Prim
infixr 0 seq

 しかも,par関数やpseq関数を使った記述はプログラムの評価の流れに沿っていないため,並列Haskellではこうした関数の書き間違いが発生しやすくなっています。