GHCにおけるヒープとスタック
先ほど、このような記事をQiitaに書いた。
Haskellを書いていて、再帰関数の終了条件設定をミスって無限ループさせてしまうことが何回かあったのだが、CPUだけでなくメモリも無限に持って行かれ、スワップを使い始めたあたりからキーボードへの応答も著しく鈍くなり、とにかく大変な目にあった。
どうやら再帰関数の実行によってサンクが無限増殖し、そいつがヒープメモリを肥大化させているようだったので、RTSオプションでヒープの上限を与える方法を調べたのだった。
さて、RTSオプションのドキュメントを読むと、以下のような記述があることに気付いた。
-K
size [Default: 80% physical memory size] Set the maximum stack size for an individual thread to size bytes.-M
size [Default: unlimited] Set the maximum heap size to size bytes.
-M
オプションはヒープメモリサイズの上限である。こいつがデフォルトで青天井なので、サンクの無限増殖で大変な目にあったのだった。
一方、-K
オプションはスタックサイズの上限であり、こいつはデフォルトで(物理メモリの8割という大容量とはいえ)上限が設定されている。
では、ヒープとスタックの違いはなんだろうか?と思って調べてみた。
結論から言うと、どうやら以下のようなことらしい。
- ヒープはサンク(など)が置かれる
- スタックはサンクを評価する時に使われる
上記のスレでは定番のfoldl
を用いて説明されている。
遅延評価版のfoldlでは以下のように評価が進む。
foldl add 0 [1,2,3,4] = foldl add (add 0 1) [2,3,4] = foldl add (add (add 0 1) 2) [3,4] = foldl add (add (add (add 0 1) 2) 3) [4] = foldl add (add (add (add (add 0 1) 2) 3) 4) [] = add (add (add (add 0 1) 2) 3) 4
ここで、add
は(+)
の別名とする(見やすさのため)。
上記で積み上がるadd
の列は未評価の式、つまりサンクであり、ヒープに置かれる。よって、例えば
foldl add 0 $ repeat 0
とするとサンクの無限増殖によってヒープが肥大化する。この場合、いくらスタックサイズを制限してもどうしようもない。
さて、foldlの評価ではadd
が積み上がったサンクがさらに評価されていくが、ここでadd
(というか(+)
)は正格評価である。よって、
- (add X 4)を評価する。
- Xを評価する。そのためにaddと4は一旦脇においておく。
- Xは(add Y 3)である。これを評価する。
- Yを評価する。そのためにaddと3は一旦脇においておく。
- ...
となる。ここで、評価を進めるために「一旦脇においておく」場所が(多分)スタックとなる。よって、リストに要素が増えれば増えるほどスタックも深く、大きくなり、そのうち上限に当たって死ぬ。
同様の議論はHaskell-cafe Stack, Heap and GHCでもされていた。
ちなみに正格版のfoldl'では以下のように評価が進む。
foldl' add 0 [1,2,3,4] = foldl' add 1 [2,3,4] -- (add 0 1)が評価される = foldl' add 3 [3,4] -- (add 1 2)が評価される = foldl' add 6 [4] -- (add 3 3)が評価される = foldl' add 10 [] -- (add 6 4)が評価される = 10
正格版ではサンクは肥大化しないし、評価も1ステップごとに行われるのでスタックも肥大化しない。よって、
foldl' add 0 $ repeat 0
としてもメモリは食われない。ただCPUがぶん回るだけである。