GHCヒーププロファイリングの手引き

Mitsutoshi Aoe
19 min readDec 8, 2015

--

非正格評価をデフォルトとするHaskellは、注意を怠るとしばしばスペースリークと呼ばれる非効率なメモリの使い方を引き起こす。幸いなことにGHCには多くの場合において有効なヒーププロファイラを備えているので、実際にスペースリークを特定するのはそれほど難しくない。

GHCのヒーププロファイラについて解説した文書としてはGHCの公式マニュアルがあるが、これだけで効果的に使うのは難しいのと、非公式のツールの使い方は紹介されていないので、それらを含めてまとめてみたい。

スペースリークの正格な定義があるのかはさておき、実際の使われ方としては、プログラマが意図しない非効率なメモリの使い方を指すと言ってよいと思う。一口にスペースリークと言っても様々な原因があり、これらを分類する試みとしては例えばSpace leak zooがある。いくつかの例はあとで触れることにして、まずはスペースリークの見つけ方について取り上げる。

スペースリークを見つける

熟練したLisperはコード上の無数の括弧が見えなくなるが、熟練したHaskellerはコード上の見えないスペースリークが見えるようになる。 — Mitsutoshi Aoe

この格言は思いつきだが、実際慣れてくるとリークしやすいコードに気がつきやすくなってくる。とはいえ見落とすこともあるので、熟練しなくともスペースリークを見つけられる方法が欲しい。

  • 実行バイナリにRTSオプション-sまたは-Sを渡してメモリ使用量をチェックする
  • ekgや通常のプロセス監視でメモリ使用量の変化をチェックする
  • 実行バイナリにRTSオプション-hTオプションを渡して簡易的なヒーププロファイルを取る

実行バイナリに+RTS -s -RTSオプションを渡して得られる統計情報は、プログラム開始から終了までの全体を通しての値なので時間変化はわからないが、total memory in useを見ればライフサイクルを通じた最大のメモリ使用量がわかる。-Sでは同じような統計情報をGCが走るたびに出力するため、時間変化がわかる。どちらのオプションも-sstderrや-Sfileとすることで出力先を変えることが出来る。

ekgはGHCのランタイムシステムが持つ統計情報に対してプログラマブルにアクセスするためのライブラリで、サーバプロセスのメモリ使用量監視などに使える。いくつかのバックエンドをサポートしているので、用途に応じて選ぶとよい。スペースリークの検出にはrts.gc.current_bytes_usedが不必要に大きくなっていないかチェックすればよい。

実際にスペースリークがあるのか、あるとすればどこにあるのかを調べるにはプロファイリングを有効にしてヒーププロファイラを使うことになる。ただしプロファイリングビルドは時間がかかる上に、生成されるバイナリは通常ビルドで生成されたバイナリに比べ性能が劇的に劣化するため、本番環境では使いにくいかもしれない。そういう場合には、通常ビルドでも簡易的にヒーププロファイリングが出来る-hTフラグを使う方がよい場合もある。このオプションが渡されると、GHCのランタイムシステムは、プログラムの実行中にプログラム名.hpという名前でヒーププロファイルを出力する。この簡易ヒーププロファイリングのオーバヘッドがどの程度なのかは測ったことがないので知らないが、バイナリは通常ビルドと全く同じなので、それほど大きくないのではないかと思う。

生成されるhpファイルはappend onlyなテキスト形式で、次に挙げる可視化ツールを使って見られる。

hp2ps

hp2psはGHCに同梱されているコマンドで、hpファイルを可視化してPSファイルに出力する。

% hp2ps -c /path/to/your.hp

とするとカラフルなPSファイルを出力する。出力されたPSファイルをUbuntu Trustyのevinceでみると90°回転した画像になっていて、向きを直すと真っ白になるが、何故なのかは知らない。ps2pdfでPDFに直せば正しい向きで見られる。

GHCのヒーププロファイラはデフォルトでコストセンタの名前を25文字に切り詰める。この制約はしばしば厳しすぎるのでRTSオプションとして-L100などを渡して制約を緩めることが出来るが、hp2anyを使う場合はこの値を大きくしすぎると出力されるグラフ領域が狭くなり、凡例しか読めなくなってしまうことがある。その場合はRTSオプション-pを併用すればよい。ヒーププロファイル内のコストセンタ名に付随するコストセンタIDは-pで得られる時間およびアロケーションプロファイル内のIDとマッチするので、そこから切り詰められる前のコストセンタの名前を特定できる。

hp2any-manager

毎度hpファイルをPSファイルに変換するのも面倒なので、普段はhp2any-managerというGTK+アプリを使っている。どういうアプリかはスクリーンショットを見るのが早い。

特徴としては

  • アプリから直接ファイルを開ける
  • グラフを縦横に並べて形を比較できる(ただし時間軸は同期しない)
  • グラフの領域にマウスを持って行くと、対応するコストセンタが右のリストビューでハイライトされる
  • コストセンタの名前が長くてもリストの幅を広げれば読めるので常に-Lオプションに大きい値を設定できる

などが挙げられる。

hpファイルはプログラムの実行中に随時追記されるので、リアルタイムにグラフを更新してくれると便利なのだが、その機能はない。

hp2any-relayとhp2any-graph

リアルタイムのレンダリングをしたければhp2any-graphが使える。

% hp2any-graph -e <executable> -- <arguments>

とすると.hpをレンダリングしてくれる。以前試したときはグラフ上でマウスカーソルを動かしたときだけ最新の内容で再描画するという一風変わった挙動だったが、最近もそうなのかは知らない。

hp2any-relayはリモートデバッグするときに有効で、hp2any-graphと一緒に使う。

% hp2any-relay -p <port> -e <executable> -- <arguments> # リモート
% hp2any-graph -s <server>:<port> # ローカル

ghp

ヒーププロファイルはプログラム実行とともに追記され次第に大きくなっていく。上記で挙げた可視化ツールはプロファイルが大きすぎるとメモリをたくさん使ったり可視化に時間が掛かりすぎることがある。その場合はヒーププロファイルを適当な頻度で間引けばよい。hpファイルは単純なテキストフォーマットなので間引くのはたやすい。ghpはヒーププロファイルを間引くためのコマンドで次のようにして使う。

% ghp 10 /path/to/your.hp > smaller.hp

第一引数の整数がどの程度間引くかを表していて、この場合は10サンプルに1つに間引くためsmaller.hpはyour.hpのおおよそ1/10のサイズになる。

-hTオプションで得た簡易ヒーププロファイルをこれらのビューアで見れば、スペースリークがあるかはどうかはすぐにわかる。スペースリークの存在がわかったら、次は場所を特定したい。

スペースリーク特定の基本的な流れは

  1. メモリ使用量に貢献しているデータを生成しているのは何か(-hc)
  2. 生成されたデータはどのようなデータか(-hyまたは-hd)
  3. 生成されたデータを保持しているのは何か(-hr)
  4. 生成されたデータはどのように使われるか(-hb)

を調べる繰り返しになる。

まずはプログラムをプロファイリングを有効にしてコンパイルしよう。前述したとおりパフォーマンスが劇的に劣化するので、実環境で試すときは慎重に。

RTSオプション-hcで得られるプロファイルは、全体のメモリ使用量のうちどの程度の割合がどのコストセンタから生成されたデータで占められているかを表している。結果として出てきたコストセンタの粒度が低すぎて特定が難しい場合は、-fprof-autoや手動でSCCアノテーションを設定するとよい。任意のexpressionの前に{-# SCC “名前” #-}と書けばよい。

さらにRTSオプション-hyや-hdをつけると生成されたデータの型やコンストラクタ名が見られる。

どのコストセンタがどのようなデータを生成しているのか特定できたら、RTSオプション-hrをつけてプロファイルを取る(retainer profilingと呼ばれる)。このプロファイルでは、生成されたデータがどのコストセンタで保持されているのかがわかる。小さいプログラムではしばしば-hcと同じコストセンタが報告されるが、プログラムが大きく複雑になるにつれretainer profilingの有効性が増してくる。

個人的な経験では上の3ステップの繰り返しでたいていのスペースリークが特定できるが、さらに別の観点からプロファイルを取ることも出来る。RTSオプション-hbをつけてプロファイルを取る(biographical profilingと呼ばれる)ことで、データがどのように使われているかによっていくつかの状態に分類できる。

  • LAG: 生成されてから最初に使われるまでの間のデータ
  • USE: 最初に使われてから最後に使われるまでの間のデータ
  • INHERENT_USE: プリミティブな配列や参照など。GHCのRTSがbiographical profiligに必要な情報を持っていないためここに分類される。
  • DRAG: 最後に使われてからデータへの最後の参照がなくなるまでの間のデータ
  • VOID: 生成されるが一切使われることがなかったデータ

注目すべき状態はDRAGとVOIDの2つで、これらは(少なくとも再度)使われることがないオブジェクトであるという点でゴミと言ってよい。LAGはいずれ使われるデータなのでゴミではないが、データの生成が早すぎることを意味する。

GHCのヒーププロファイラの素晴らしいところは、これら複数のプロファイラを組み合わせて使えるところで、例えば-hcfoo -hrとすればコストセンタfooが生成したデータを、誰が保持しているのかを見ることが出来る。あるいは-hbvoid -hcとすれば、一切使われることのないデータを生成しているコストセンタがわかる。ただしretainer profilingとbiographical profilingは組み合わせられないので、他のプロファイラと組み合わせて絞り込む必要がある。例えば-hbdrag -hcでDRAGの原因となるデータを生成しているコストセンタを特定し、-hcコストセンタ名 -hrでその維持原因を見つけるという一手間が必要になる。

以上のように、スペースリークの特定はGHCが備える複数のプロファイラを組み合わせ、必要に応じてコストセンタを設定して原因となるコードを絞っていく作業になる。実際のプロファイリングの例としては、コードが単純すぎることを差し引いても本物のプログラマはHaskellを使う 第46回 ヒープ・プロファイラで領域漏れを探るが参考になる。現実のコードではプロファイル結果はより大きく、ステップを繰り返していくことで特定する。

スペースリークを直す

スペースリークの原因が特定できれば問題の9割は解決したと思ってよい。修正の方法にはいくつかの典型的なパターンがある。

最適化を有効にする

最適化を有効にしていないために現れるスペースリークがある。ビルドツールにcabalやstackを使っていれば最適化はデフォルトで有効になるので心配いらないが、記憶が正しければghcはデフォルトで-O0相当になっているので、ghcを直接呼ぶ場合は注意したい。

ちなみに最適化(例えばfull laziness a.k.a. let floating)を有効にしたときだけスペースリークが現れることもある。この場合はプロファイルを取りつつCoreを覗いて原因を探ることになる。

seq

非正格言語のよくあるスペースリークの一つとして、非正格な関数でサンクを積み上げてしまうことが挙げられる。

例えば次の式はfoldl’を使っていて一見スペースリークがないように見えるかもしれないが、実際にはスペースリークがある。

foldl' go (0, 1) [1..a]
where
go (n, m) i = (n + i, m * i)

foldl’はアキュムレータをWHNFまでにしか評価しないこと、タプルのコンストラクタは引数に対して非正格であったことを思い出せば、n + iとm * iがそれぞれ評価されることなくサンクとしてタプルに格納されることがわかる。これを解消するにはそれぞれの式をseqで評価してやればいい。

foldl' go (0, 1) [1..a]
where
go (n, m) i = n' `seq` m' `seq` (n', m')
where
n' = n + i
m' = m * i

このようにコード中にseqを散りばめるのは構文的なノイズが大きいので代替手段がいくつか用意されている。

  • BangPatterns言語拡張
  • $!と<$!>
  • データ型の正格フィールド

BangPatternsはパターンの前に!をつけることでそのパターンに束縛される値を正格に評価するよう指示できる。先の例をBangPatternsで書き直すと、

foldl' go (0, 1) [1..a]
where
go (!n, !m) i = (n + i, m * i)

となり、元のコードにかなり近い形で書ける。

別の例を考える。例えばByteStringからIntを取り出して2乗したものをレコードSomeRecordのフィールドにセットする関数fooがあるとする。

foo :: SomeRecord -> Parser SomeRecord
foo record = do
...
n <- parseInt
return record
{ ...
, someRecordInt = n
}

このコードで更新されたsomeRecordIntがサンクなのか評価済みの整数なのかはこのコード片だけではわからない。

  • parseIntは評価済みの値を返すか
  • Parserモナドのreturnは引数に対して正格か
  • someRecordIntフィールドは正格か

を調べる必要がある。例えばparseIntがサンクを返し、returnが引数に対して非正格、someRecordIntが非正格なフィールドだったとする。この場合someRecordIntにはサンクが格納され、fooの使われ方によってはスペースリークの原因になり得る。サンクを不必要に保持していると、parseIntが消費するByteStringがいつまでもGCされず、後述するがGHCのプロファイラでは原因を見つけにくいスペースリークになる可能性もある。

直し方にはいくつかパターンがある。someRecordIntを非正格のままにするならBangPatternsと$!で

foo record = do
...
!n <- parseInt
return $! record
{
, someRecordInt = n
}

とすればスペースリークはなくなる。$!は$の第二引数が正格になったものでf $! x = seq x (f x)と定義される。

あるいはsomeRecordIntを正格に出来るのであれば

foo record = do
...
n <- parseInt
return $! record
{ ...
, someRecordInt = n
}

とすればよい。あるいはレコード更新部分を別関数にくくり出せるなら<$>の正格版である<$!>を使って

foo record = do
...
updateRecord ... <$!> parseInt

と書くこともできる。<$!>は

(<$!>) :: Monad m => (a -> b) -> m a -> m b
f <$!> m = do
x <- m
let z = f x
z `seq` return z

と定義されている。<$>と違ってFunctorではなくMonadの制約が必要であることに注意したい。

型によってはサンクを潰しにくいこともある。リストをはじめとする再帰的なデータ型が代表的で、例えば先ほどのparseIntで複数個のIntをパーズする場合を考える。

parseInts = replicateM 10 parseInt

仮にparseIntがIntではなくサンクを返す関数であったとすると、parseIntsはサンクを要素に持つリストを返す。このサンクを潰すには少し手間が掛かる。

import Data.List

parseInts = do
xs <- replicateM 10 parseInt
return $! foldl' (flip seq) () xs `seq` xs

いずれのパターンも原則は同じで、

  • モナドのdo notationも含めて、式はすべて関数の組み合わせであることを思い出す
  • 関数が引数に対して正格かを確認する
  • 関数が構造を持った値を返す場合は、構造の中身が正格かを確認する。これはつまりデータコンストラクタが正格かを確認する

を念頭に見ていけば何故スペースリークが起きてしまったのか、どうしたら直せるのかはすぐにわかる。

deepseq

deepseqパッケージを使うとWHNFではなくNFまで評価できる。ただし評価する値の型がNFDataクラスのインスタンスになっている必要がある。先ほどの例はdeepseqを用いるともっと簡単に書ける。

import Control.DeepSeq

parseInts = do
xs <- replicateM 10 parseInts
return $!! xs

注意すべき点は、巨大なデータ構造に対してdeepseqを適用すると当然すべての要素をトラバースするので遅くなってしまう。完全に評価された値が手軽に得られるからと言って乱用してはいけない。

Strict言語拡張

近々リリースが予定されているGHC 8.0.1からはStrct言語拡張が使える見込みになっている。この拡張をあるモジュールで有効にするとトップレベルを除くモジュール内のすべての束縛が、あたかもBangPatternsを使ったかのようにコンパイルされる。

現時点ではこの拡張がどの程度広く使われるようになるのか、あるいはどの程度有効なのかはわからないが、コード中のあらゆる束縛に!が散りばめられているモジュールであればStrict言語拡張を使った方がすっきりすると思う。

注意すべき点は、この拡張は有効にしたモジュール内のみで影響があること。つまり、外部のモジュールやライブラリからimportしたものは依然としてlazyがデフォルトになっている。

Beautiful Foldingとストリーミングライブラリ

遅延データ構造を活用したストリーム処理の間違った使い方によって引き起こされるスペースリークがある。例えば次のコードにはスペースリークがある。

import System.IO

main = do
file <- getContents
putStrLn $
show (length $ lines file) ++ " " ++
show (length $ words file) ++ " " ++
show (length file)

仮にputStrLnの引数の最初の2行をコメントアウトしてshow (length file)だけにすると、このコード片はストリーム処理される。つまりlengthがfileを消費して行くにつれ不要になったオブジェクトはGCされる。しかし上記のようにfileが1回以上参照されているとGCはlength $ lines file、length $ words file、length fileの3つすべての計算が終わるまでfileをGC出来ない。結果としてスーペースリークを引き起こす。

同様の問題は遅延I/Oを使わない通常のリストでも起こる。数値のリストから平均値を求める関数meanを次のように定義する。

mean :: Fractional a => [a] -> a
mean xs = sum xs / fromIntegral (length xs)

この関数は引数xsを関数内で2度参照するため、リスト全体を構築してしまう。

起こっている現象はどちらもまったく同じで、遅延する巨大なデータ構造を複数回参照するとGCできずにストリーム処理できず、意図せず大量のメモリ領域を消費してしまう。

この問題は古典的な問題でBeautiful Foldingと呼ばれるよく知られた対処法がある。Hackageにはfoldlfoldsなどのパッケージが上がっている。

さらに複雑なストリーム処理が必要であればconduitpipesmachinesなどのストリーミングライブラリの利用を検討したい。

ヒーププロファイラがうまく動かないケース

GHCのヒーププロファイラがいつでも期待通りに動くとは限らない。

  • 主に-hrでプログラムがsegfaultしてプロファイルを得られないことがある。
  • ByteStringをはじめとするPINNEDオブジェクトは-hcや-hmでコストセンタ・モジュールの特定が出来ない。また-hrでは完全に無視される。従って例えばプログラムが大量のByteStringを不必要に保持していてもプロファイリングの結果のみでその原因を探ることは出来ない。

一つ目に関してはGHCのバグで職場のとあるプログラムをプロファイルするときに頻発する。最小限の再現コードを作るのは結構手間のかかることもあり、まだバグ報告していない。残念ながら今のところ回避策はない。

二つ目は、端的に言えば-hcの結果でPINNEDや-hyの結果でARR_WORDSが最も顕著な場合は、その原因を簡単に探る方法はないということ。たいていの場合、どこかでByteStringが生成され不必要に維持されている。ICFPでSimon Marlowに直接聞いてみたが、現状のヒーププロファイラの仕様ということらしい。幸いなことにGHC Trac #7275を見る限りプロファイラを改良することはできそうではある。

これらのケースに遭遇した場合は、地道にコードを小さくしていって特定するしか方法はない。

--

--