Subscribed unsubscribe Subscribe Subscribe

遅延I/Oとメモリリーク

Haskell

先週の土曜日にReal World Haskell読書会に行ってきた。とても有意義な読書会だったのだけど、遅延I/Oとメモリリークに関して腑に落ちない点があったので書いてみる。

遅延I/Oの例

Real World Haskell 7.4.1節の注意マークのところ、邦訳版から引用すると、

上の例で、inpStrを使った一箇所(つまりprocessDataを呼んだところ)を過ぎてから、inpStrを捕まえようとすると、プログラムのメモリ効率が失われてしまいます。

という部分が気になっている。

本を持っていない方のために、わかりやすいサンプルプログラムを例に挙げる。Haskellではテキストファイルを読んでそのまま出力するプログラムをこう書くことができる。

ここで注目すべき点は、BS.readFileは遅延I/Oとなっているので、実際に読むファイルがメモリに載らない大きなものであったとしても、プログラムの実行には定数オーダの領域しか消費しないということ。

上のプログラムを実際にコンパイルして、/dev/zeroをddして作ったNULL文字だけで構成される1 GBのファイルを読み込ませ、プロファイルをとった結果が次のようになる。

縦軸がメモリ使用量で横軸が経過時間。メモリ消費量は常に一定なことがわかる。カレントディレクトリにできたlazyio-single.profファイルを見ると総アロケートバイト数は1,111,994,176 bytesとなっている。

メモリリーク

上のプログラムにもう一行putStrを追加するとどうなるか。

これを先ほどと同じように実行すると、メモリの使い方は激変する。

時間とともにメモリ消費量が増え、ある一点から減っていく。一方で総アロケートバイト数は1,132,824,096 bytesで、上の例とほとんど同じという結果になった。

総アロケート数が変わらないのにメモリの使い方が変わったのは、先ほどはreadFileしてputStrしたものから随時GCして行けば良かったが、今回は2回目のputStrのためにGCすることができず、結果的にファイルサイズに比例するメモリを消費しまうことが原因。これをメモリリークというのは語弊があるかもしれないが、正格評価に慣れ親しんだプログラマが見るとぎょっとする現象なのではないかなと思う。

先の読書会ではこの問題に関して、1パスのreadFileでうまく扱う方法はないものかと質問してみたが、

  • ファイルを2度読むべし
  • Haskellではケチな方法はとらず、発想の転換をして富豪的に考えるべし

と言われてしまった。

本当にそうなんだろうか?

ファイルを2度読む方法は確かに定数オーダのメモリ消費になるし、富豪的な方法でもうまくいく場合はあると思う。でも、ファイルを2度も読みたくない例はいくらでも考えられる。たとえば次のような場合はどうだろう。

  • ログファイルの解析をするとき、1ファイル(あるいは生のログストリーム)から複数種類の解析を行いたい場合
    • 頻出IPアドレスの提示とページビューの計算とユニークユーザ数の計算などを同時にしたい
  • ログファイル自体がgzip圧縮されていて、展開のコストが馬鹿にならない場合
  • ログがNFSなど、レイテンシが大きく速度も出ないストレージ上にあり、I/O自体がとても高コストになる場合

こういった場合、ログを2度読むのは馬鹿げている。以前Haskellでログ解析プログラムを書いていたときは、forkIOして同時に読んでいたが、微妙に多いメモリを消費するプログラムになってうまくいかなかった記憶があった。

そこで今回、

  • ファイルを2度開く方法
  • forkIOで並行してputStrしていく方法
  • forkIOするが、両スレッドの処理速度が異なる場合

についてプロファイルをとり、まとめてみることにした。

ファイルを2度開く

読書会で言われた方法。

実行結果を見ると、確かに定数オーダにはなっているが、総アロケートバイト数は2,223,959,960 bytesとなり、上の例と比較すると当然2倍程度になっている。

forkIOで並行してputStrする

読書会でもでたforkIOして並行にputStrする方法。

今回はthreadedランタイムは用いずに計測した。実行結果を見ると、定数オーダになっているように見えるし、総アロケートバイト数も1,132,736,048 bytesと他と変わらない。

これは一見うまく動いているように見えるが、両スレッドの処理速度が異なる場合を考えると、そんなにうまくいくわけなさそうに見える。

スレッド間の処理速度に差がある場合

処理速度の違いを顕著にするために、おやスレッドではunpackしてStringに変換し、それをPrelude.putStrに変えてみる。

この変更による速度の違いは顕著で、他の例では十数秒で終わっていた処理が、今回は200秒近くもかかった。プロファイル結果を見てみると、メモリ消費が激しくなることがわかる。BS.putStrの処理が速く、一気にreadFileがすんでしまい、その後とろとろとPrelude.putStrが進み、GCしながら徐々にメモリ消費量が減っていく様が見て取れる。

まとめ

まとめると、伝統的な遅延I/Oのシンプルなやり方(readFileなどを使ったイディオム)では、一つの遅延ストリームから、複数のストリーム消費者を定数オーダのメモリ消費で動かす方法が見つからなかった。シンプルなやり方を捨てて、他の正格言語と同じように、1文字ずつ、あるいはチャンクに分けて徐々に読み込んでいけばうまくいくとは思うが、関数的とは言い難いコードになりそう。

これに対するエレガントな解がLazy IO considered harmful; way to go, Left-fold enumerator! - Haskellあたりにありそうなんだけど、残念ながら今のところちゃんと理解できていないという状況。

誰かご存じでしたら教えてください。