.NET / C#が末尾呼び出しの再帰に対して最適化しないのはなぜですか?


111

どの言語が末尾再帰を最適化するについて、この質問を見つけました。なぜC#は可能な限り末尾再帰を最適化しないのですか?

具体的なケースでは、なぜこのメソッドがループに最適化されていないのですか(Visual Studio 2008 32ビット、重要な場合)?:

private static void Foo(int i)
{
    if (i == 1000000)
        return;

    if (i % 100 == 0)
        Console.WriteLine(i);

    Foo(i+1);
}

私は今日、再帰関数を2つにpreemptive(すなわち、階乗アルゴリズム)とNon-preemptive(たとえば、ackermannの関数)に分岐するデータ構造に関する本を読んでいました。著者は、この分岐の背後に適切な理由を与えずに、私が言及した2つの例のみを示しました。この分岐は、末尾および非末尾の再帰関数と同じですか?
RBT

5
Jon skeetとScott Hanselmanによる2016年の有用な会話youtu.be/H2KkiRbDZyc?t=3302
Daniel B

@RBT:違うと思います。再帰呼び出しの数を指します。末尾呼び出しは、末尾の位置にある呼び出しに関するものです。つまり、関数が最後に行うのは、呼び出し先から直接結果を返すためです。
JD、

回答:


84

JITコンパイルは、コンパイルフェーズの実行に時間をかけすぎないこと(したがって、有効期間が短いアプリケーションを大幅に遅くすること)と、標準の事前コンパイルでアプリケーションの長期的な競争力を維持するのに十分な分析を行わないことの間のトリッキーなバランス作業です。

興味深いことに、NGenのコンパイル手順は、最適化をより積極的に行うことを目的としていません。これは、JITとNGenのどちらがマシンコードの原因であるかに依存して動作が異なるバグを望まないためだと思います。

CLR自体がサポート末尾呼び出しの最適化を行いますが、言語固有のコンパイラは、関連生成する方法を知っている必要がありオペコードをし、JITはそれを尊重して喜んでなければなりません。 F#の fscは、関連するオペコードを生成します(単純な再帰の場合、全体をwhile直接ループに変換する場合があります)。C#のcscはサポートしていません。

詳細については、このブログ投稿を参照してください(最近のJITの変更により、かなり古い可能性があります)。4.0 、x86、x64、およびia64のCLRの変更はそれを尊重することに注意してください。


2
次の投稿も参照してください:social.msdn.microsoft.com/Forums/en-US/netfxtoolsdev/thread/…ここで、通常の呼び出しよりも末尾が遅いことがわかりました。うん!
台座、

77

このMicrosoft Connectフィードバック送信は、あなたの質問に答えるはずです。マイクロソフトからの正式な回答が含まれているので、そちらをお勧めします。

提案をありがとう。C#コンパイラの開発では、いくつかの時点でテールコール命令の発行を検討しました。ただし、これを回避するために私たちを駆り立ててきたいくつかの微妙な問題があります:1)CLRで.tail命令を使用すると、実際には重要なオーバーヘッドコストがあります(末尾呼び出しは最終的には単なるジャンプ命令ではないため)末尾呼び出しが大幅に最適化されている関数型言語ランタイム環境など、それほど厳密ではない多くの環境で)。2)末尾呼び出しを発行することが合法となる実際のC#メソッドはほとんどありません(他の言語では、より多くの末尾再帰を含むコーディングパターンを推奨していますが、そして、末尾呼び出しの最適化に大きく依存している多くのユーザーは、末尾再帰の量を増やすために、グローバルな書き換え(Continuation Passing変換など)を実際に行います)。3)2)が原因で、成功したはずの深い再帰が原因でC#メソッドスタックがオーバーフローするケースはかなりまれです。

そうは言っても、これは引き続き検討しており、コンパイラの将来のリリースでは、.tail命令を発行することが理にかなっているいくつかのパターンを見つける可能性があります。

ちなみに、指摘したように、x64では末尾再帰最適化されていることに注意してください。


3
:あなたはこのあまりにも役に立つかもしれませんweblogs.asp.net/podwysocki/archive/2008/07/07/...
Noldorin

問題ありません。参考になりました。
ノルドリン2009年

17
404になりましたので、引用していただきありがとうございます。
Roman Starkov

3
リンクが修正されました。
luksan 2013年

15

C#はF#の目的のため、末尾呼び出しの再帰を最適化しません。

C#コンパイラが末尾呼び出しの最適化を実行できない条件の詳細については、JIT CLR末尾呼び出し条件を参照してください。

C#とF#間の相互運用性

C#とF#は非常に相互運用性が高く、.NET共通言語ランタイム(CLR)はこの相互運用性を考慮して設計されているため、各言語はその目的と目的に固有の最適化を使用して設計されています。C#コードからF#コードを呼び出すのがいかに簡単かを示す例については、「C#コードからF#コード呼び出す」を参照してください。F#コードからC#関数を呼び出す例については、「F#からC#関数呼び出す」を参照してください。

デリゲートの相互運用性については、F#、C#、およびVisual Basic間のデリゲートの相互運用性に関する記事を参照してください。

C#とF#の理論的および実用的な違い

これは、C#とF#間のテールコール再帰の設計の違いを説明し、C#とF#でのテールコールオペコードの生成に関する記事です。

これは、C#、F#、およびC ++ \ CLIのいくつかの例を含む記事です。C#、F#、およびC ++ \ CLIの末尾再帰の冒険

主な理論上の違いは、C#はループを使用して設計されているのに対し、F#はラムダ計算の原理に基づいて設計されていることです。Lambda微積分の原理に関する非常に優れた本については、この無料の本を参照してください:Abelson、Sussman、およびSussmanによる「コンピュータプログラムの構造と解釈」

F#での末尾呼び出しに関する非常に優れた紹介記事については、次の記事を参照してください:F#での末尾呼び出しの詳細な紹介。最後に、非末尾再帰と末尾呼び出し再帰(F#での)の違いを説明する記事を次に示します。Fシャープでの末尾再帰と非末尾再帰


8

最近、64ビット対応のC#コンパイラは末尾再帰を最適化すると言われました。

C#もこれを実装します。これが常に適用されるわけではない理由は、末尾再帰を適用するために使用されるルールが非常に厳しいためです。


8
x64 ジッタはこれを行いますが、C#コンパイラは行いません
Mark Sowul

情報のおかげで。これは私が以前考えていたものとは異なる白です。
アレクサンドルブリ

3
これら2つのコメントを明確にするために、C#がCILの「テール」オペコードを発行することはありません。これは2017年にも当てはまります。ただし、すべての言語について、そのオペコードは常にそれぞれのジッター(x86、x64 )雑多な条件が満たされていない場合、黙って無視します(まあ、スタックオーバーフローの可能性を除いてエラーはありません)。これが、「テール」と「レット」を追うことを余儀なくされる理由を説明しています-これはこの場合のためです。一方、CILに「テール」プレフィックスがない場合も、.NET言語に関係なく、ジッターは自由に最適化を適用できます。
Glenn Slayden 2017年

3

C#(またはJava)で末尾再帰関数にトランポリン手法を使用できます。ただし、より良い解決策(スタックの使用率を気にするだけの場合)は、この小さなヘルパーメソッドを使用して同じ再帰関数の一部をラップし、関数を読みやすく保ちながら反復させることです。


トランポリンは侵襲的であり(呼び出し規約に対するグローバルな変更です)、適切な末尾呼び出しの排除よりも10倍遅く、すべてのスタックトレース情報が難読化され、コードのデバッグとプロファイルが非常に困難になります
JD

1

他の回答が述べたように、CLRは末尾呼び出しの最適化をサポートしており、歴史的には進歩的な改善があったようです。ただしProposal、C#でのサポートには、C#プログラミング言語のサポートテール再帰#2544の設計に関するgitリポジトリで未解決の問題があります。

あなたはそこにいくつかの有用な詳細と情報を見つけることができます。たとえば、@ jaykrellについて

私が知っていることをあげましょう。

時々、テールコールはパフォーマンスに有利です。CPUを節約できます。jmpはcall / retよりも安価ですスタックを節約できます。少ないスタックに触れると、局所性が向上します。

時々、テールコールはパフォーマンスの低下、スタックの勝利です。CLRには、呼び出し元が受け取ったよりも多くのパラメーターを呼び出し先に渡す複雑なメカニズムがあります。具体的には、パラメーター用のより多くのスタック領域を意味します。これは遅いです。しかし、それはスタックを節約します。これは尾でのみ行われます。接頭辞。

呼び出し元のパラメーターが呼び出し先のパラメーターよりもスタックが大きい場合、通常は非常に簡単なwin-win変換です。パラメータの位置がマネージドから整数/浮動小数点に変更されたり、正確なStackMapが生成されたりするなどの要因があるかもしれません。

さて、固定/小さいスタックで任意の大きなデータを処理できるようにするために、テールコールの除去を要求するアルゴリズムの別の角度があります。これはパフォーマンスの問題ではなく、まったく実行できる能力の問題です。

また、(追加情報として)言及させてください。System.Linq.Expressions名前空間の式クラスを使用してコンパイル済みラムダを生成するとき、コメントで説明されているように、「tailCall」という名前の引数があります。

作成された式をコンパイルするときに末尾呼び出しの最適化が適用されるかどうかを示すブール値。

まだ試していないので、質問に関連してどのように役立つかわかりませんが、おそらく誰かが試してみて、いくつかのシナリオで役立つかもしれません。


var myFuncExpression = System.Linq.Expressions.Expression.Lambda<Func<  >>(body:  , tailCall: true, parameters:  );

var myFunc =  myFuncExpression.Compile();

弊社のサイトを使用することにより、あなたは弊社のクッキーポリシーおよびプライバシーポリシーを読み、理解したものとみなされます。
Licensed under cc by-sa 3.0 with attribution required.