29

別の SO question への回答を作成しているときに、Mathematica の末尾再帰に関する奇妙な動作に遭遇しました。

Mathematicaのドキュメントには、末尾呼び出しの最適化が実行される可能性があることが示唆されています。しかし、私自身の実験では相反する結果が得られます。たとえば、次の 2 つの表現を対比してください。最初の例では、おそらくスタックの枯渇が原因で、7.0.1 カーネルがクラッシュします。

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

2 番目の実行は完了するまで実行され、意味のある結果を返すためにテール コールの最適化を利用しているように見えます。

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

どちらの式も末尾再帰関数を定義していますf。最初の関数の場合,Mathematica は明らかに複合文の存在を考慮して,テールコールの最適化の可能性を無効にします.$RecursionLimitまた、最初の式は によって、2 番目の式は --によって管理されていることに注意してください。これ$IterationLimitは、 Mathematica が 2 つの式を異なる方法で扱っていることを示しています。(注:上記のSOの回答には、テールコールの最適化をうまく活用するあまり工夫されていない機能があります)。

そこで質問です: Mathematica が再帰関数の末尾呼び出し最適化を実行する状況を知っている人はいますか? Mathematica のドキュメントまたはその他のWRI資料の決定的なステートメントへの参照が理想的です。憶測も大歓迎です。

4

2 に答える 2

25

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

これは、ルール適用の結果として、(サブ) 式を完全に書き換えることができる場合、式ブランチを式スタックに保持する必要がないことを意味します。これはおそらく、Mathematica で末尾呼び出しの最適化と呼ばれるものです。これが、このような場合に再帰ではなく反復を使用する理由です (これは、ルールの適用と関数呼び出しの違いの非常に良い例の 1 つです)。のようなルール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]]}}}}

ここからわかることは、式のスタックはfand fff(これが一般的なメカニズムであることを示すために使用されたもので、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]]]

Block と selrecBad を使用する理由は、Trace を使いやすくするためです。さて、これは私のマシンのスタックを吹き飛ばします:

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,{}}}}}==={},{},selrecBad@@{2,{3,{4, 
{5,{}}}}}]},{selrecBad[2,{3,{4,{5,{}}}}],If[{3,{4,{5,{}}}}==={},{},selrecBad@@{3,{4,{5, 
{}}}}],selrecBad[3,{4,{5,{}}}],{3,If[{4,{5,{}}}==={},{},selrecBad@@{4,{5,{}}}]},{selrecBad[4,
{5,{}}],If[{5,{}}==={},{},selrecBad@@{5,{}}],selrecBad[5,{}],{5,If[{}==={},{},selrecBad@@{}]}}}}}}

何が起こるかというと、結果がリストの奥深くに蓄積されていくということです。解決策は、結果の式の深さを増やさないことです。これを達成する 1 つの方法は、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]]

それに応じて main 関数を変更します。

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 を変更する必要があったことに注意してください。これは良い兆候です)。Trace を使用すると、その理由が明らかになります。

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}]]}}}

つまり、このバージョンでは、結果が別のリストに保持されるため、中間式の深さは蓄積されません。

于 2011-01-07T16:24:01.747 に答える
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]]

Total[Range[1000]] を正しく計算します。

- - - ノート - - -

設定すると誤解を招くことに注意してください

wrapper[fin_] := fin;

場合のように

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

末尾再帰は発生しません (HoldRest を持つラッパーは、wrapper[fin_] に関連付けられたルールを適用する前に特異引数を評価するため)。

繰り返しますが、上記の f の定義は役に立ちません。

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

そして、望ましいテール再帰を持っています。

------追記-----

ラッパーに多くの引数を指定すると、必要以上に遅くなる可能性があります。ユーザーは書き込みを選択できます

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

2 番目のラッパー

2 番目のラッパーはその引数を 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

この関数は、HoldRest ではなく HoldAll 属性を持っているため、引数が 1 つしか指定されていない場合でも機能することに注意してください。

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

末尾再帰を生成します (ラッパーにはこの動作はありません)。

速度比較

命令の素敵な長いリスト (実際には Head Hold を使用した式) を作成しましょう

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[args, Unevaluated[expr]] では、CompoundExpression が削除される前に expr が評価されるため、このタイプのソリューションは役に立ちません。

于 2013-03-08T11:06:30.820 に答える