再帰関数のスタックオーバーフローを倒す話 その2
連載目次
- 再帰関数のスタックオーバーフローを倒す話 その1
- 再帰関数のスタックオーバーフローを倒す話 その1.5
- F#での「末尾」についての話
- 再帰関数のスタックオーバーフローを倒す話 その2 ← 今回
- .NETにおける末尾最適化の方法についての話
- 再帰関数のスタックオーバーフローを倒す話 その2.5
- 継続モナドと、F#の残念さの話
- 再帰関数のスタックオーバーフローを倒す話 その3
- すべてをあきらめて再帰をwhileに書き直す方法の話
はじめに
再帰関数のスタックオーバーフローを倒す話 その1の続きです。 前回はCPS変換することでスタックオーバーフローが回避できるよやったー!という話でした。 今回は、CPS変換じゃスタックオーバーフロー回避できない場合もあるよ、という話をします*1。 前提知識は、その1の記事を理解していることと、ILがなんとなくわかることです。 その1.5に対する深い理解は不要ですが、 読んでいるに越したことはありません。
CPS変換のおさらい
非末尾再帰の関数を末尾再帰化できる、というのがCPS変換でした。 そして、末尾呼び出しの最適化によってスタックが食いつぶされなくなるので、 スタックオーバーフローが起きなくなるようにできる、というものでした。
2種類の末尾呼び出しの最適化
さて、実はF#では末尾呼び出しの最適化は2つのパターンがあります。
一つ目はジャンプ命令への書き換えで、もう一つはtail.
プレフィックスの付与です。
ジャンプ命令への書き換え
ジャンプ命令への書き換えは、呼び出しをbr
系のIL命令に書き換えてしまうものです。
例えば、
let rec fact acc n = if n = 0 then acc else fact (acc * n) (n - 1)
このようなアキュムレータ変数を使った末尾再帰関数をコンパイルすると、以下のようなILが吐かれます。
IL_0000: nop IL_0001: ldarg.1 // 1番目の引数(n)のload IL_0002: brtrue.s IL_0006 // loadした値が0以外(0: false, 0以外: true)ならIL_0006にジャンプ IL_0004: ldarg.0 // 0番目の引数(acc)のload IL_0005: ret // loadした値を返して関数終了 IL_0006: ldarg.0 // 0番目の引数(acc)のload IL_0007: ldarg.1 // 1番目の引数(n)のload IL_0008: mul // loadした2つの値を乗算(acc * n)・・・(1) IL_0009: ldarg.1 // 1番目の引数(n)のload IL_000a: ldc.i4.1 // 定数1をload IL_000b: sub // loadした2つの値を減算(n - 1)・・・(2) IL_000c: starg.s n // (2)の結果を引数nにstore IL_000e: starg.s acc // (1)の結果を引数accにstore IL_0010: br.s IL_0000 // 無条件でIL_0000にジャンプ
このコードには、call
系の命令がないことが分かります。
その代わりに、最後に無条件で先頭にジャンプしており(br.s IL_0000
)、これが元のコードだと再帰呼び出し部分に相当します。
ILでは分かりにくい、という人向けに、上記ILをF#風言語で表現してみました。
let fact acc n = while n <> 0 do let tmp = acc * n n <- n - 1 acc <- tmp acc
このように、末尾再帰呼び出しをジャンプ命令に置き換えることで、ループと等価になりました*2。
自分自身を末尾で呼び出す再帰関数*3の場合、関数から戻ってきた後にすることがないということは、(引数の状態を更新してから)関数の先頭にジャンプしてもいいと言うことです。
tail.プレフィックスの付与
tail.
プレフィックスの付与は、末尾呼び出しされているcall
系の呼び出し命令にtail.
というプレフィックスを付けることで、
JITコンパイラの最適化によってスタックフレームを消費しないようにしてもらうものです。
例えば、
let rec fact n cont = if n = 0 then 1 |> cont else fact (n - 1) (fun pre -> pre * n |> cont)
このような継続渡しスタイルの関数を「末尾呼び出しの生成」オプションをオンにしてコンパイルすると、以下のようなILが吐かれます。
IL_0000: nop IL_0001: ldarg.0 // 0番目の引数(n)をload IL_0002: brtrue.s IL_000e // loadした値が0以外(0: false, 0以外: true)ならIL_000eにジャンプ IL_0004: ldarg.1 // 1番目の引数(cont)をload・・・(1) IL_0005: ldc.i4.1 // 定数1をload・・・(2) IL_0006: tail. // (1)でloadした関数値に(2)でloadした値を渡して実行 IL_0008: callvirt instance !1 class [FSharp.Core]Microsoft.FSharp.Core.FSharpFunc`2<int32, !!a>::Invoke(!0) IL_000d: ret // 実行した結果を返して関数終了 IL_000e: ldarg.0 // 0番目の引数(n)をload IL_000f: ldc.i4.1 // 定数1をload IL_0010: sub // loadした2つの値を減算(n - 1)・・・(3) IL_0011: ldarg.0 // 0番目の引数(n)をload・・・(4) IL_0012: ldarg.1 // 1番目の引数(cont)をload・・・(5) // (4)でloadした値と(5)でloadした値を渡してfact@22オブジェクトを生成 IL_0013: newobj instance void class Sample/fact@22<!!a>::.ctor(int32, class [FSharp.Core]Microsoft.FSharp.Core.FSharpFunc`2<int32, !0>) IL_0018: starg.s cont // 生成したオブジェクトを引数contにstore IL_001a: starg.s n // (3)の結果を引数nにstore IL_001c: br.s IL_0000 // 無条件でIL_0000にジャンプ
基本的な構造は同じですが、少し複雑になっています。 アキュムレータ変数による末尾再帰関数の例と同様にF#風の言語で表現してみるとこうなります。
let fact n cont = while <> 0 do let tmp = n - 1 cont <- fact@22(n, cont) n <- tmp cont 1
ループの中でcont
をネストさせていき、関数の最後でcont
に1
を渡していることが分かります。
再帰部分の処理は、fact@22
というクラス*4に行ってしまい、
このコードだけでは読み取れなくなりました。
fact@22
というクラスをまずはF#風の言語で表現してみます。
type fact@22<'a> (n: int, cont: int -> 'a) = inherit FSharpFunc<int, 'a>() override __.Invoke(pre) = cont (pre * n)
このように、本体の処理はInvoke
メソッド内に移動しています。
Invoke
メソッドのILはこのようになっています。
IL_0000: nop IL_0001: ldarg.0 // 0番目の引数(this)をload // loadしたオブジェクト(this)のフィールドcontをload・・・(1) IL_0002: ldfld class [FSharp.Core]Microsoft.FSharp.Core.FSharpFunc`2<int32, !0> class Sample/fact@22<!a>::cont IL_0007: ldarg.1 // 1番目の引数(pre)をload IL_0008: ldarg.0 // 0番目の引数(this)をload // loadしたオブジェクト(this)のフィールドnをload IL_0009: ldfld int32 class Sample/fact@22<!a>::n IL_000e: mul // loadした2つの値を乗算(pre * this.n) IL_000f: tail. // (1)でloadした関数値(this.cont)に乗算結果を渡して実行 IL_0011: callvirt instance !1 class [FSharp.Core]Microsoft.FSharp.Core.FSharpFunc`2<int32, !a>::Invoke(!0) IL_0016: ret // 実行した結果を返して関数終了
さて、ここでfact
本体のIL_0006
とInvoke
のIL_000f
を見てみましょう。
tail.
というILが発行されていることが分かります。
このILが発行された後に続くcall
系の呼び出しは、
JITコンパイラによって現在のスタックフレームを再利用*5するようにして実行されるようになります。
2つの最適化の違い
なぜ末尾呼び出しの最適化に2種類の方法があるのでしょうか? それは、両者の性質が大きく異なるためです。
まず、前者のジャンプ命令に変換する方法ですが、
br
系の命令はメソッド内の移動にしか使えません。
そのため、メソッドをまたぐようなジャンプはできず、CPSで現れるようなラムダ式を表すクラスのメソッドへのジャンプはできません。
それに対して、tail.
プレフィックスを付与する方法は、
call
系のメソッドが末尾で呼び出されていれば別のクラスのメソッド呼び出しであろうが有効です。
しかし、tail.
プレフィックスはJITコンパイラ任せなため、
本当に呼び出しが最適化されるかどうかの保証をF#の処理系レベルで担保することができません*6。
また、その1.5でみましたが、コンストラクタ呼び出し(newobj
)は末尾呼び出しの最適化対象ではありません*7。
そのため、ループごとにコンストラクタ呼び出しが走ることになります。
これは、F#ではtail.
プレフィックスを用いた最適化よりもジャンプ命令に変換する最適化の方が効率が良いことを意味します。
まとめると、
最適化方法 | 適用可能場所 | 処理系での保証 |
---|---|---|
ジャンプ命令での置き換え | 自己末尾再帰 | できる |
tail. プレフィックスの付与 |
末尾再帰一般 | できない |
となります。
tail.プレフィックスでスタックオーバーフローになる場合
tail.
プレフィックスが付いているコードであっても、スタックオーバーフローを起こす場合があります。
まだ原因はわかっていないのですが、
というような状況で、リビルド後の初回のアタッチでスタックオーバーフローが起きました。 2回目以降のアタッチではスタックオーバーフローが起きませんし、 小さくて単純な再帰関数をCPS変換してもオーバーフローは起きませんでした。
また、32bitのIISでは試していませんが、 一般的に32bit環境ではスタックフレームのサイズが64bit環境よりも小さくなるため、 スタックオーバーフローを起こしにくくなる可能性はあります。
ということで、特定の条件下ではCPS変換によってスタックオーバーフローを防ぐことができない場合もあるため、 F#においてはCPS変換したからと言って完全に安全とは言い切れない、という話でした。 「特定の条件」がまだ定かではないので、調査は継続しますが、今のところIISでホストしない場合には再現しませんので、非Webアプリであれば問題ないかもしれません。 次回の「その3」がおそらく最後で、ではどうやってスタックオーバーフローを倒せばいいか、の予定です。