Haskellのスペースリークとプロファイリング

目次

プロファイラ

プログラムをチューニングしたい時は、どの関数のどの部分がどれだけのCPU時間やメモリを消費しているのかがわかると便利なことがある。例えば特定の関数がCPU時間の90%を使っている時に、時間をかけて他の部分をチューニングしても、プログラム全体の実行時間の削減への効果は低い。基本的にはCPUやメモリのコストの高い関数から見直していくと良い。

GHCにはプロファイラが付属しているので、CPUの消費時間やメモリの消費量を分析でき、結果をグラフ化することもできる。
7. Profiling — Glasgow Haskell Compiler 8.10.1 User's Guide

1. プロファイリングを行うためには、まず、幾つかオプションを付けて対象のプログラムをコンパイルする。

ghc -prof -fprof-auto -rtsopts -O3 -o prog prog.hs

2. 次に、RTS (ランタイムシステム) の色々なオプションを付けてプログラムを実行すると、実行時のデータを採集してまとめてくれる。

全体的なレポートを採る

./prog +RTS -p

この場合、結果はprog.profに出力される。

前回の記事で比較に使った4段4次のRunge-Kuttaのプログラムで試してみた結果 (一部) は以下のようになった。CPU時間の大半が、結果を出力する際に文字列化して整形する部分に使われてしまっているのがわかる。rk44は計算が軽いので仕方ないかもしれないが、メインの計算部分より重いのは残念な気がする。

	Sat Dec  1 10:03 2018 Time and Allocation Profiling Report  (Final)

	   rk44 +RTS -p -RTS

	total time  =        0.25 secs   (248 ticks @ 1000 us, 1 processor)
	total alloc = 459,835,272 bytes  (excludes profiling overheads)

COST CENTRE MODULE SRC                       %time %alloc

toString    Main   rk44.hs:28:1-50            72.6   83.0
writeLists  Main   rk44.hs:41:1-54             9.3    9.3
rk44.kf     Main   rk44.hs:56:9-79             5.2    4.8
rk44.ss     Main   rk44.hs:57:9-44             3.6    1.0
rk44.kf.\   Main   rk44.hs:56:58-67            2.4    0.3
lorentz     Main   rk44.hs:(174,1)-(179,49)    2.0    0.1
rk44        Main   rk44.hs:(49,1)-(58,66)      2.0    0.6


                                                                                    individual      inherited
COST CENTRE       MODULE                SRC                      no.     entries  %time %alloc   %time %alloc

MAIN              MAIN                  <built-in>               113          0    0.0    0.0   100.0  100.0
 CAF              Main                  <entire-module>          225          0    0.0    0.0    95.6   99.9
  main            Main                  rk44.hs:319:1-19         226          1    0.0    0.0    95.6   99.9
   lorentz        Main                  rk44.hs:(174,1)-(179,49) 227          1    1.6    0.1    95.6   99.9
    lorentz.file  Main                  rk44.hs:175:7-26         230          1    0.0    0.0     0.0    0.0
    lorentz.fps   Main                  rk44.hs:176:7-64         233          1    0.4    0.0    16.1    7.5
     l_dxdt       Main                  rk44.hs:170:1-35         248      39996    0.0    0.0     0.0    0.0

メモリ消費を詳しく調べる

./prog +RTS -[h,hm,hd,hy,hr,hb]

結果はprog.profやprog.hpに出力される。グラフにしたい場合は結果をpsに変換するツールもある。

hp2ps -c prog.hp

pdfにする場合はさらに変換する。

ps2pdf prog.ps prog.pdf

rk44で試してみた結果は以下のようになった。

-h コストセンタ
f:id:lqtmirage:20181201192311p:plain:w400

-hm モジュール
f:id:lqtmirage:20181201192314p:plain:w400

-hd コンストラク
f:id:lqtmirage:20181201102931p:plain:w400

-hy
f:id:lqtmirage:20181201102926p:plain:w400

-hr リテイナー
誰がオブジェクトを保持しているのかを説明してくれる。あるオブジェクトに到達できるポインタを直接持っているオブジェクト。
7. Profiling — Glasgow Haskell Compiler 8.10.1 User's Guide
f:id:lqtmirage:20181201102929p:plain:w400

-hb 履歴

  • lag : 生成後、使用待ちの状態
  • use : 最初の使用後で、最後の使用前の状態
  • drag : 最後の使用後で、最後の参照が消える前の状態
  • void : 1度も使用されなかったオブジェクト

7. Profiling — Glasgow Haskell Compiler 8.10.1 User's Guide
f:id:lqtmirage:20181201102939p:plain:w400

スペースリークなどへの対応

GHCはガベージコレクタを持っているので、メモリについて意識する必要はあまりないが、プログラムによってはサンク (遅延評価による未評価の値) が蓄積してスペースリークと呼ばれるメモリの浪費が起きることがある。メモリが破壊されることはまずないと思うが、メモリリークと同様の現象は起きてしまう。また、状況によっては (パフォーマンスを落とさずに) メモリの消費量を一定以下に抑えたいという場合もあるかもしれない。そんな時はプロファイラを使ってどこでサンクが蓄積されているのかを調べた上で、プログラムの構成を見直したり、単に、部分的にあるいは全体的に正格評価すれば良い場合もある。

末尾再帰への書き換え

関数の再帰呼び出し後に何らかの処理が残されているような再帰関数で、コンパイラの最適化が効かなかった時、サンクが蓄積されてしまうことがある。再帰後の処理結果を引数に織り込むなどして、再帰呼び出し後に処理が残らない形に書き変えると、再帰はループに置き換え可能なので、スペースリークを回避できるかもしれない。

-- 末尾再帰になっていない例 (これくらいなら最適化が効くかもしれない)
fact :: Integer -> Integer
fact n | n < 2 = 0
       | otherwise = n * fact (n - 1)

-- 末尾再帰に書き換えた例
factT :: Integer -> Integer
factT n = f n 1
  where f m t | m < 2 = t
              | otherwise = f (m - 1) (m * t)

実際のリスト生成の回避

計算の途中でリストが生成されていると考えるとわかりやすい処理があり、コード上もそのように表現されたとしても、実際にはリスト生成を必要としない場合がある。より抽象的なリスト操作関数を使ったりして気をつけると、コンパイラが最適化して不必要なリスト生成を回避してくれることがある。

-- いずれも実際にはリストは生成されないはず
sum [1..1000000]
foldl1 (*) [1..10000]

-- リスト生成させる
(product . reverse) [1..10000]

正格評価版$!とseq

関数適用演算子$には正格評価版$!があり、引数を弱頭部正規形 (WHNF) まで評価してから関数適用する。また、a `seq` bのようにseq関数を適用すると、aを評価してからbを返す。言語標準の機能。

f $! p q r
a `seq` b `seq` (a, b)

Prelude

正格フィールド

データ型を定義する時、値コンストラクタのフィールドの型名の前に!をつけると、引数が評価されてから値がつくられるようになる。こちらも言語標準の機能。

data StrictData = StrictData !Integer !String

BangPatterns

また、BangPatternsプラグマを指定すると、任意の変数束縛時に、変数名の前に!を付けることで個々の変数を評価することができるようになる。
9.1. Language options — Glasgow Haskell Compiler 8.10.1 User's Guide

StrictとStrictData

GHC8.0.2以降では、StrictDataプラグマを指定すると、値コンストラクタの引数は自動的に!が付けられた状態になる。また、Strictプラグマではこれに加えて、関数やwhere、letなどの変数束縛時にもデフォルトで評価が行われるようになる。逆に遅延評価したい場合に変数の前に~を付ける。

{-# LANGUAGE Strict #-}
{-# LANGUAGE StrictData #-}

同様の効果は-XStrictData、-XStrictフラグでも得られる。
9.1. Language options — Glasgow Haskell Compiler 8.10.1 User's Guide