25

answer to another SO questionを公式化している間、私はMathematicaの尾部再帰に関するいくつかの奇妙な振る舞いに出くわしました。Mathematicaのテールコールの最適化?

Mathematica documentationtail call optimizationが実行される可能性があります。しかし、私自身の実験は相反する結果をもたらします。たとえば、以下の2つの式を対比させます。

Module[{f, n = 0}, 
    f[x_] := Null /; (n += 1; False); 
    f[x_] := f[x + 1]; 
    TimeConstrained[Block[{$IterationLimit = Infinity}, f[0]], 300, n] 
] 

両方の式が定義:意味のある結果を返すために、末尾呼び出しの最適化を利用するために表示されて、完成に

(* warning: crashes the kernel! *) 
Module[{f, n = 0}, 
    f[x_] := (n += 1; f[x + 1]); 
    TimeConstrained[Block[{$RecursionLimit = Infinity}, f[0]], 300, n] 
] 

二のラン:最初は枯渇を積層するおそらく7.0.1カーネルを、クラッシュテール再帰関数f。最初の関数の場合、Mathematicaは、テールコールの最適化の可能性を十分に克服するのに十分な複合ステートメントの存在を明らかに見なします。また、最初の式は$RecursionLimitで、2番目の式は$IterationLimitで、Mathematicaが2つの式を別々に扱っているということに注意してください。 (注:上で参照されているSOの答えには、テールコールの最適化をうまく利用した、あまり機能しない関数があります)。

だから、質問はです:誰でもMathematicaは再帰関数の末尾呼び出しの最適化を実行する状況を知っているのですか? Mathematicaのドキュメントや他のWRIの中の決定的なステートメントへの言及は理想的でしょう。推測も歓迎です。

答えて

22

私は私の個人的な経験によって導かれた結論を要約することができますが、それに続くのはまったく正しい説明ではないという免責事項があります。 Mathematicaの呼び出しスタックと従来の呼び出しスタックの違いは、Mathematicaのパターン定義関数が本当にルールになっていることにあります。したがって、実際の関数呼び出しはありません。 Mathematicaは別の理由からスタックを必要とします。通常の評価は式ツリーの一番下から行われるため、(サブ)式のより深くて深い部分がルール適用の結果として置き換えられる場合に中間式を保持する必要があります下から発現が増える)。これは、特に、他の言語で非末尾再帰関数と呼ぶものを定義する規則の場合です。だから、Mathematicaのスタックは、関数呼び出しではなく、中間式のスタックである。

これは、ルール適用の結果、(サブ)式全体が書き換えられる場合、式ブランチを式スタックに保持する必要はないことを意味します。これはおそらくMathematicaのテールコール最適化と呼ばれています。そのため、このような場合、再帰ではなく繰り返しがあります(これはルールアプリケーションと関数呼び出しの違いの非常に良い例です)。 f[x_]:=f[x+1]のようなルールはこのタイプのルールです。しかし、一部のサブ式が書き換えられ、より多くの式構造を生成する場合、式はスタックに格納されなければなりません。規則f[x_ /; x < 5] := (n += 1; f[x + 1])はこのタイプのものです。()CompoundExpression[]の略です。ここで何が起こるかは、f[1] -> CompoundExpression[n+=1, f[2]] -> CompoundExpression[n+=1,CompoundExpression[n+=1,f[3]]]->etcです。 fの呼び出しが毎回最後であるにもかかわらず、完全なCompoundExpression[]が実行される前に起こります。したがって、これはまだ式スタックに保持されなければなりません。これは、CompoundExpressionの例外を作るために、最適化を行うことができる場所だと主張できるかもしれませんが、実装するのはおそらく簡単ではありません。

Clear[n, f, ff, fff]; 
n = 0; 
f[x_ /; x < 5] := (n += 1; f[x + 1]); 

ff[x_] := Null /; (n += 1; False); 
ff[x_ /; x < 5] := ff[x + 1]; 

fff[x_ /; x < 5] := ce[n += 1, fff[x + 1]]; 

評価をトレースする:

、私は概略的に、上述のスタック蓄積処理を説明するために、私たちは再帰呼び出しの数を制限させ

In[57]:= Trace[f[1],f] 
Out[57]= {f[1],n+=1;f[1+1],{f[2],n+=1;f[2+1],{f[3],n+=1;f[3+1],{f[4],n+=1;f[4+1]}}}} 

In[58]:= Trace[ff[1],ff] 
Out[58]= {ff[1],ff[1+1],ff[2],ff[2+1],ff[3],ff[3+1],ff[4],ff[4+1],ff[5]} 

In[59]:= Trace[fff[1],fff] 
Out[59]= {fff[1],ce[n+=1,fff[1+1]],{fff[2],ce[n+=1,fff[2+1]],{fff[3],ce[n+=1,fff[3+1]], 
{fff[4],ce[n+=1,fff[4+1]]}}}} 

あなたから見ることができますどのようなこれは、式スタックがffffのために累積していることです(後者はce[]の任意の頭部を持つ一般的な仕組みであることを示すために使用されます)。ffではなくパターンマッチングのために、ffの最初の定義は試行されていますが一致しないルールであり、2番目の定義はff[arg_]全体を書き換え、さらに書き換えが必要な深い部分を生成しません。したがって、最終行は、関数を分析し、その再帰呼び出しが評価された式を底から拡大するかどうかを確認する必要があるようです。もしそうであれば、Mathematicaに関するテール再帰的なものではありません。

テールコールの最適化を手動で行う方法を示さなければ、私の答えは完全ではありません。例として、Selectの再帰的な実装を考えてみましょう。私たちは、Mathematicaのリンクリストを使って、おもちゃではなく合理的に効率を上げるようにします。以下は、非末尾再帰的な実装のためのコードは次のとおりです。

Clear[toLinkedList, test, selrecBad, sel, selrec, selTR] 
toLinkedList[x_List] := Fold[{#2, #1} &, {}, Reverse[x]]; 
selrecBad[fst_?test, rest_List] := {fst,If[rest === {}, {}, selrecBad @@ rest]}; 
selrecBad[fst_, rest_List] := If[rest === {}, {}, selrecBad @@ rest]; 
sel[x_List, testF_] := Block[{test = testF}, Flatten[selrecBad @@ toLinkedList[x]]] 

私はブロックとselrecBadを使用する理由は、それが簡単にトレースを使用するようにすることです。さて、これは私のマシン上でスタックを吹く:

Block[{$RecursionLimit = Infinity}, sel[Range[300000], EvenQ]] // Short // Timing 

あなたはなぜ見るために小さなリストにトレースすることができます:

In[7]:= Trace[sel[Range[5],OddQ],selrecBad] 

Out[7]= {{{selrecBad[1,{2,{3,{4,{5,{}}}}}],{1,If[{2,{3,{4,{5,{}}}}}==={},{},[email protected]@{2,{3,{4, 
{5,{}}}}}]},{selrecBad[2,{3,{4,{5,{}}}}],If[{3,{4,{5,{}}}}==={},{},[email protected]@{3,{4,{5, 
{}}}}],selrecBad[3,{4,{5,{}}}],{3,If[{4,{5,{}}}==={},{},[email protected]@{4,{5,{}}}]},{selrecBad[4, 
{5,{}}],If[{5,{}}==={},{},[email protected]@{5,{}}],selrecBad[5,{}],{5,If[{}==={},{},[email protected]@{}]}}}}}} 

何が起こることは、結果がリストに深く深く蓄積してしまうことです。解決策は、結果の式の深さを成長しないことであり、それを達成するための一つの方法は、selrecBadが蓄積結果の(リンク)リストで1つの余分のパラメータ、受け入れるようにすることです:

selrec[{fst_?test, rest_List}, accum_List] := 
    If[rest === {}, {accum, fst}, selrec[rest, {accum, fst}]]; 
selrec[{fst_, rest_List}, accum_List] := 
    If[rest === {}, accum, selrec[rest, accum]] 

を、メインを変更しますそれに応じて機能:

selTR[x_List, testF_] := Block[{test = testF}, Flatten[selrec[toLinkedList[x], {}]]] 

これがうまく我々の力試験に合格します:

In[14]:= Block[{$IterationLimit= Infinity},selTR[Range[300000],EvenQ]]//Short//Timing 

Out[14]= {0.813,{2,4,6,8,10,12,14,16,18,20, 
<<149981>>,299984,299986,299988,299990,299992,299994,299996,299998,300000}} 

(ここでは、我々は良い兆候である$ IterationLimitを、修正しなければならなかったことに注意してください)。トレースを使用する理由を明らかにすると:

In[15]:= Trace[selTR[Range[5],OddQ],selrec] 

Out[15]= {{{selrec[{1,{2,{3,{4,{5,{}}}}}},{}],If[{2,{3,{4,{5,{}}}}}==={},{{},1},selrec[{2,{3,{4, 
{5,{}}}}},{{},1}]],selrec[{2,{3,{4,{5,{}}}}},{{},1}],If[{3,{4,{5,{}}}}==={},{{},1},selrec[{3, 
{4,{5,{}}}},{{},1}]],selrec[{3,{4,{5,{}}}},{{},1}],If[{4,{5,{}}}==={},{{{},1},3},selrec[{4, 
{5,{}}},{{{},1},3}]],selrec[{4,{5,{}}},{{{},1},3}],If[{5,{}}==={},{{{},1},3},selrec[{5, 
{}},{{{},1},3}]],selrec[{5,{}},{{{},1},3}],If[{}==={},{{{{},1},3},5},selrec[{},{{{{},1},3},5}]]}}} 

ある結果を別のリストに保持されているので、このバージョンは、中間表現の深さを蓄積しません。

+0

+1非常に良い分析。私は、CompoundExpressionが最適化の候補であることに同意しますが、サブ表現がCompoundExpressionに定義を追加し、セマンティクス(最適化が妨害される)を変更する状況を考案できました。 「挑戦」を重視する - 実際にはそれが問題になるかどうかはわかりません。 – WReach

+0

優秀な説明! '$ RecursionLimit'と' $ IterationLimit'の違いが明らかになりました。そして、「スタック」とは何かがはっきりと分かりました。 –

4

この回答の考え方は、ブラケット()を式が大きくならないラッパーに置き換えることです。この関数が、尾の再帰を台無しにしていたことを覚えていれば、OPが正しかったので、代わりに見つかっている関数は実際はCompoundExpressionです(Leonidの答えも参照してください)。 2つのソリューションが提供されています。これは最初のラッパー

SetAttributes[wrapper, HoldRest]; 
wrapper[first_, fin_] := fin 
wrapper[first_, rest__] := wrapper[rest] 

を定義する私たちは、その後、

Clear[f] 
k = 0; 
mmm = 1000; 
f[n_ /; n < mmm] := wrapper[k += n, f[n + 1]]; 
f[mmm] := k + mmm 
Block[{$IterationLimit = Infinity}, f[0]] 

が正しく合計[範囲[1000]]を計算していること。持っています

------注意-----

f[x_]:= wrapper[f[x+1]] 

末尾再帰がない場合のように、

wrapper[fin_] := fin; 

を設定するために誤解されること

注意(HoldRestを持つラッパーがラッパー[fin_]に関連付けられたルールを適用する前に特異な引数を評価するという事実のために)発生します。 1は、単に

f[x_]:= f[x+1] 

を書いて、希望末尾再帰を持っている可能性があるので

はその後、再び、fに対する上記の定義は、有用ではありません。場合

------別のノート-----

我々はそれが必要以上に遅くなることがあり、多くの引数を持つラッパーを提供します。ユーザが第2のラッパーに

f[x_]:=wrapper[g1;g2;g3;g4;g5;g6;g7 , f[x+1]] 

を書くことを選ぶかもしれ二ラッパーはCompoundExpressionに引数を供給し、多くの引数が提供されている場合ので、最初のラッパーよりも速くなります。これは、2番目のラッパーを定義します。

SetAttributes[holdLastWrapper, HoldAll] 
holdLastWrapper[fin_] := fin 
holdLastWrapper[other_, fin_] := 
Function[Null, #2, HoldRest][other, fin] 
holdLastWrapper[others__, fin_] := 
holdLastWrapper[ 
    Evaluate[CompoundExpression[others, Unevaluated[Sequence[]]]], fin] 

注:返す(空の)シーケンスは、一般的な再帰で非常に便利です。ここ

https://mathematica.stackexchange.com/questions/18949/how-can-i-return-a-sequence

それは

f[x]:= holdLastWrapper[f[x+1]] 

を設定すると得られますように、というHoldRestよりもホールドオール属性があるとして一つだけの引数が、提供されている場合、この機能はまだ動作することに注意してくださいまた、私の答えを参照してください。テール再帰(ラッパーにはこの動作がありません)。

速度比較

者が命令これらの命令については

nnnn = 1000; 
incrHeld = 
    Prepend[DeleteCases[Hold @@ ConstantArray[Hold[c++], nnnn], 
    Hold, {2, Infinity}, Heads -> True], Unevaluated[c = 0]]; 

の素敵な長いリスト(ヘッドホールドと実際の式)を作成してみましょう、我々はパフォーマンス(と結果)を比較することができ、私たちのCompoundExpressionを持つラッパー

holdLastWrapper @@ incrHeld // Timing 
CompoundExpression @@ incrHeld // Timing 
wrapper @@ incrHeld // Timing 

- > {{0.000856,999}、{0.000783,999}、{0。023752、999}}

結論

あなたは末尾再帰が起こるのだろうか、ラッパーにどのように多くの引数を供給する時期を正確にわからない場合は、2番目のラッパーが優れています。たとえば、2番目のラッパーがすべてCompoundExpressionへのフィードであり、これを自分で行うと決めた場合など、ラッパー2の引数を指定する場合は、最初のラッパーが適しています。

-----最終音符--​​-- CompoundExpressionで

[引数、未評価の[式]] CompoundExpressionが剥離される前に、このタイプのソリューションは全く使用されないので、exprはまだ、評価されます。

+0

これはとても素晴らしいです! +1。これは 'CompoundExpression'の問題を解決するようです。しかし、多くの場合、これは十分ではありません。例えば、呼び出しを囲むコンテナが 'CompoundExpression'ではない' f [x _]:= {f [x-1]、f [x-2]} ' (しかし、例えば、 'List')。それでも、これはとても素晴らしい業績です。もっとテストする必要がありますが、今は 'CompoundExpression'の解決策のようです。ある意味では、これは2つのルールに分割されているので、私のやり方に似ていますが、あなたの解決策は一般的です。私たちがテストして一般的にうまくいくと確信できるようになると、それは... –

+0

...「自動テールコール最適化のためのプログラム的ツール」のような質問をして、あなたの提案を答えの1つとして挙げてください。 @ Rojoには、Trott-Strzebonskiのテクニックに基づいた、テールコールの最適化の方法がいくつかあることを漠然と覚えています。 –

+0

@レオニードShifrin Woohoo :)。ありがとうございました。私はこれらのことをさらに調べることに非常に興味があります。私はTrott-Strzebonskiのテクニックも見ていきます。昨日と今日、私はあなたが描いた道をたどって多くを学びました。 –

関連する問題