非常に単純に、末尾呼び出しの最適化とは何ですか?
より具体的には、それが適用される可能性のあるいくつかの小さなコードスニペットは何ですか?
非常に単純に、末尾呼び出しの最適化とは何ですか?
より具体的には、それが適用される可能性のあるいくつかの小さなコードスニペットは何ですか?
回答:
呼び出し側の関数は呼び出された関数から取得した値を返すだけなので、テール呼び出しの最適化では、関数に新しいスタックフレームを割り当てることを回避できます。最も一般的な使用法は、末尾再帰です。末尾呼び出しの最適化を利用するために作成された再帰関数は、一定のスタックスペースを使用できます。
Schemeは、あらゆる実装がこの最適化を提供する必要があることを仕様で保証する数少ないプログラミング言語の1つです(JavaScriptもES6以降)。したがって、Schemeの階乗関数の2つの例を次に示します。
(define (fact x)
(if (= x 0) 1
(* x (fact (- x 1)))))
(define (fact x)
(define (fact-tail x accum)
(if (= x 0) accum
(fact-tail (- x 1) (* x accum))))
(fact-tail x 1))
最初の関数は末尾再帰ではありません。これは、再帰呼び出しが行われる場合、関数は、呼び出しが戻った後の結果に対して実行する必要がある乗算を追跡する必要があるためです。そのため、スタックは次のようになります。
(fact 3)
(* 3 (fact 2))
(* 3 (* 2 (fact 1)))
(* 3 (* 2 (* 1 (fact 0))))
(* 3 (* 2 (* 1 1)))
(* 3 (* 2 1))
(* 3 2)
6
対照的に、末尾再帰階乗のスタックトレースは次のようになります。
(fact 3)
(fact-tail 3 1)
(fact-tail 2 3)
(fact-tail 1 6)
(fact-tail 0 6)
6
ご覧のとおり、fact-tailを呼び出すたびに同じ量のデータを追跡するだけで済みます。これは、単純に取得した値を先頭に返すためです。つまり、(事実1000000)を呼び出したとしても、(事実3)と同じ量のスペースしか必要ありません。これは、末尾再帰以外の事実には当てはまりません。そのような大きな値は、スタックオーバーフローを引き起こす可能性があるためです。
簡単な例を見てみましょう。Cで実装された階乗関数です。
明白な再帰的な定義から始めます
unsigned fac(unsigned n)
{
if (n < 2) return 1;
return n * fac(n - 1);
}
関数が戻る前の最後の操作が別の関数呼び出しである場合、関数は末尾呼び出しで終了します。この呼び出しが同じ関数を呼び出す場合、末尾再帰です。
fac()
一見、末尾再帰的に見えますが、実際には何が起こっているかではありません
unsigned fac(unsigned n)
{
if (n < 2) return 1;
unsigned acc = fac(n - 1);
return n * acc;
}
つまり、最後の演算は乗算であり、関数呼び出しではありません。
ただし、fac()
累積値を追加の引数として呼び出しチェーンに渡し、最終結果のみを戻り値として渡すことにより、末尾再帰に書き換えることができます。
unsigned fac(unsigned n)
{
return fac_tailrec(1, n);
}
unsigned fac_tailrec(unsigned acc, unsigned n)
{
if (n < 2) return acc;
return fac_tailrec(n * acc, n - 1);
}
では、なぜこれが便利なのでしょうか。テールコールの直後に戻るため、テール位置で関数を呼び出す前に前のスタックフレームを破棄できます。再帰関数の場合は、スタックフレームをそのまま再利用できます。
末尾呼び出しの最適化により、再帰的なコードが
unsigned fac_tailrec(unsigned acc, unsigned n)
{
TOP:
if (n < 2) return acc;
acc = n * acc;
n = n - 1;
goto TOP;
}
これをインライン化fac()
して、
unsigned fac(unsigned n)
{
unsigned acc = 1;
TOP:
if (n < 2) return acc;
acc = n * acc;
n = n - 1;
goto TOP;
}
これは
unsigned fac(unsigned n)
{
unsigned acc = 1;
for (; n > 1; --n)
acc *= n;
return acc;
}
ここでわかるように、十分に高度なオプティマイザは、末尾再帰を反復に置き換えることができます。これは、関数呼び出しのオーバーヘッドを回避し、一定量のスタック領域のみを使用するため、はるかに効率的です。
TCO(Tail Call Optimization)は、スマートコンパイラが関数を呼び出し、追加のスタックスペースを必要としないプロセスです。機能で実行された最後の命令場合、この問題が発生する唯一の状況は、fは関数gの呼び出しである(:注gがあってもよいF)。ここで重要なのは、ということですfは、それは単に呼び出す-もはやニーズスタックスペースグラム何でも、その後戻っgが戻ってくるし。この場合、gが実行され、fを呼び出したものに必要な値を返すように最適化を行うことができます。
この最適化により、再帰呼び出しは、爆発するのではなく、一定のスタックスペースを使用するようになります。
例:この階乗関数はTCOptimizableではありません:
def fact(n):
if n == 0:
return 1
return n * fact(n-1)
この関数は、returnステートメントで別の関数を呼び出す以外にも機能します。
以下の関数はTCOptimizableです。
def fact_h(n, acc):
if n == 0:
return acc
return fact_h(n-1, acc*n)
def fact(n):
return fact_h(n, 1)
これは、これらの関数のいずれかで最後に起こることは別の関数を呼び出すことだからです。
おそらく、末尾呼び出し、再帰末尾呼び出し、末尾呼び出しの最適化について私が見つけた最高の高レベルの説明はブログ投稿です
ダンスガルスキ 末尾呼び出しの最適化について、彼は次のように書いています。
少しの間、この単純な関数を考えてみましょう:
sub foo (int a) { a += 15; return bar(a); }
それで、あなた、またはあなたの言語コンパイラは何ができますか?まあ、それができることは、フォームのコードを
return somefunc();
低レベルのシーケンスに変えることですpop stack frame; goto somefunc();
。この例では、我々は呼んでその手段の前にbar
、foo
自分自身をクリーンアップしてから、むしろ呼び出しよりbar
サブルーチンとして、我々は、低レベルやるgoto
の開始に操作をbar
。Foo
はすでにスタックから自分自身をクリーンアップしているので、bar
それが見え起動するようにと呼ばれる人は誰でもfoo
、本当に求めているbar
、とするときbar
、その値を返し、それが直接呼び出さ誰にそれを返すfoo
というに戻すよりも、foo
その呼び出し側にそれを返すことになります。
そして末尾再帰:
関数が最後の操作として自身を呼び出した結果を返すと、末尾再帰が発生します。テール関数の再帰は、ランダム関数の最初にどこかにジャンプする必要がなく、自分自身の最初に戻るだけなので、扱いが簡単です。
だからこれ:
sub foo (int a, int b) { if (b == 1) { return a; } else { return foo(a*a + a, b - 1); }
静かに変わります:
sub foo (int a, int b) { label: if (b == 1) { return a; } else { a = a*a + a; b = b - 1; goto label; }
この説明で私が気に入っているのは、命令型言語のバックグラウンド(C、C ++、Java)の出身者を理解するのがいかに簡潔で簡単かです。
foo
関数末尾呼び出しは最適化されていませんか?最後のステップとして関数を呼び出すだけで、その値を返すだけですよね?
まず、すべての言語でサポートされているわけではないことに注意してください。
TCOは、再帰の特殊なケースに適用されます。その要点は、関数で最後に行うのがそれ自体の呼び出し(たとえば、「末尾」の位置から自分自身を呼び出す)である場合、これはコンパイラーによって最適化され、標準の再帰ではなく反復のように動作することです。
通常、再帰中に、ランタイムはすべての再帰呼び出しを追跡する必要があるため、1つが戻ったときに前の呼び出しから再開できるようになっています。(再帰呼び出しの結果を手動で書き出して、これがどのように機能するかを視覚的に把握してみてください。)すべての呼び出しを追跡すると、スペースを占有します。これは、関数自体が頻繁に呼び出されるときに重要になります。しかし、TCOを使用すると、「最初に戻って、今回のみパラメーター値をこれらの新しい値に変更する」と言うことができます。再帰呼び出しの後は何もこれらの値を参照しないため、これを行うことができます。
foo
メソッド末尾呼び出しは最適化されていませんか?
x86逆アセンブリ分析を使用したGCC最小実行可能例
生成されたアセンブリを見て、GCCがテールコールの最適化を自動的に行う方法を見てみましょう。
これは、https: //stackoverflow.com/a/9814654/895245などの他の回答で言及された内容の非常に具体的な例として機能しますた最適化が再帰的な関数呼び出しをループに変換できるます。
これにより、メモリが節約され、パフォーマンスが向上します。これは、最近のメモリアクセスが、プログラムを遅くする主な原因であることが多いためです。です。
入力として、GCCに最適化されていない単純なスタックベースの階乗を与えます。
tail_call.c
#include <stdio.h>
#include <stdlib.h>
unsigned factorial(unsigned n) {
if (n == 1) {
return 1;
}
return n * factorial(n - 1);
}
int main(int argc, char **argv) {
int input;
if (argc > 1) {
input = strtoul(argv[1], NULL, 0);
} else {
input = 5;
}
printf("%u\n", factorial(input));
return EXIT_SUCCESS;
}
コンパイルと逆アセンブル:
gcc -O1 -foptimize-sibling-calls -ggdb3 -std=c99 -Wall -Wextra -Wpedantic \
-o tail_call.out tail_call.c
objdump -d tail_call.out
ここ-foptimize-sibling-calls
で、末尾呼び出しの一般化の名前は次のとおりですman gcc
。
-foptimize-sibling-calls
Optimize sibling and tail recursive calls.
Enabled at levels -O2, -O3, -Os.
で述べたように:gccが末尾再帰最適化を実行しているかどうかを確認するにはどうすればよいですか?
私が選ん-O1
だ理由:
-O0
。これは、必要な中間変換が欠落しているためと考えられます。-O3
非常に教育的ではない、信じられないほど効率的なコードを生成しますが、テールコールも最適化されています。分解-fno-optimize-sibling-calls
:
0000000000001145 <factorial>:
1145: 89 f8 mov %edi,%eax
1147: 83 ff 01 cmp $0x1,%edi
114a: 74 10 je 115c <factorial+0x17>
114c: 53 push %rbx
114d: 89 fb mov %edi,%ebx
114f: 8d 7f ff lea -0x1(%rdi),%edi
1152: e8 ee ff ff ff callq 1145 <factorial>
1157: 0f af c3 imul %ebx,%eax
115a: 5b pop %rbx
115b: c3 retq
115c: c3 retq
と-foptimize-sibling-calls
:
0000000000001145 <factorial>:
1145: b8 01 00 00 00 mov $0x1,%eax
114a: 83 ff 01 cmp $0x1,%edi
114d: 74 0e je 115d <factorial+0x18>
114f: 8d 57 ff lea -0x1(%rdi),%edx
1152: 0f af c7 imul %edi,%eax
1155: 89 d7 mov %edx,%edi
1157: 83 fa 01 cmp $0x1,%edx
115a: 75 f3 jne 114f <factorial+0xa>
115c: c3 retq
115d: 89 f8 mov %edi,%eax
115f: c3 retq
2つの主な違いは次のとおりです。
-fno-optimize-sibling-calls
用途callq
典型的な非最適化された関数呼び出しです。
この命令は、戻りアドレスをスタックにプッシュするため、増加します。
さらに、このバージョンではpush %rbx
、スタックにプッシュ%rbx
します。
GCCはこれをedi
実行します。これは、最初の関数引数(n
)であるを格納してからebx
、factorial
です。
GCCはfactorial
、新しいを使用するへの別の呼び出しの準備をしているため、これを行う必要がありますedi == n-1
。
これはebx
、このレジスターが呼び出し先に保存されているために選択されます。Linuxのx86-64関数呼び出しを通じてどのレジスターが保存されるかによって、サブコールfactorial
が変更されずに失われn
ます。
これ-foptimize-sibling-calls
は、スタックにプッシュする命令を使用しません:命令でgoto
ジャンプするだけで、factorial
je
jne
。
したがって、このバージョンは、関数呼び出しのないwhileループと同等です。スタックの使用量は一定です。
Ubuntu 18.10、GCC 8.2でテスト済み。
ここを見て:
http://tratt.net/laurie/tech_articles/articles/tail_call_optimization
おそらくご存じのとおり、再帰的な関数呼び出しはスタックに大混乱をもたらす可能性があります。スタック領域がすぐに不足するのは簡単です。末尾呼び出しの最適化は、一定のスタックスペースを使用する再帰的なスタイルアルゴリズムを作成できる方法です。そのため、定数は増加せず、スタックエラーが発生します。
関数自体にgotoステートメントがないことを確認する必要があります。呼び出し先の関数の最後にある関数呼び出しによって処理されます。
大規模な再帰はこれを最適化に使用できますが、小規模では、関数呼び出しを末尾呼び出しにするための命令オーバーヘッドにより、実際の目的が減少します。
TCOにより、機能が永久に実行される可能性があります。
void eternity()
{
eternity();
}
再帰関数アプローチには問題があります。サイズO(n)のコールスタックを構築します。これにより、合計メモリコストはO(n)になります。これにより、コールスタックが大きくなりすぎて領域が不足するスタックオーバーフローエラーに対して脆弱になります。
テールコール最適化(TCO)スキーム。再帰関数を最適化して、高い呼び出しスタックの構築を回避できるため、メモリコストを節約できる場合。
PythonやJavaはTCOを行わないのに対し、(JavaScript、Ruby、およびいくつかのC)のようにTCOを行う多くの言語があります。
JavaScript言語は、:) http://2ality.com/2015/06/tail-call-optimization.htmlを使用して確認されています
関数型言語では、末尾呼び出しの最適化は、関数呼び出しが部分的に評価された式を結果として返し、呼び出し元によって評価されるかのようです。
f x = g x
f 6はg 6に減少します。したがって、実装が結果としてg 6を返し、その式を呼び出した場合、スタックフレームが保存されます。
また
f x = if c x then g x else h x.
f 6に還元してg 6またはh 6のいずれかにします。したがって、実装がc 6を評価し、それが真であるとわかった場合、それは減少できます。
if true then g x else h x ---> g x
f x ---> h x
単純な非末尾呼び出し最適化インタープリターは次のようになります。
class simple_expresion
{
...
public:
virtual ximple_value *DoEvaluate() const = 0;
};
class simple_value
{
...
};
class simple_function : public simple_expresion
{
...
private:
simple_expresion *m_Function;
simple_expresion *m_Parameter;
public:
virtual simple_value *DoEvaluate() const
{
vector<simple_expresion *> parameterList;
parameterList->push_back(m_Parameter);
return m_Function->Call(parameterList);
}
};
class simple_if : public simple_function
{
private:
simple_expresion *m_Condition;
simple_expresion *m_Positive;
simple_expresion *m_Negative;
public:
simple_value *DoEvaluate() const
{
if (m_Condition.DoEvaluate()->IsTrue())
{
return m_Positive.DoEvaluate();
}
else
{
return m_Negative.DoEvaluate();
}
}
}
末尾呼び出しの最適化インタープリターは次のようになります。
class tco_expresion
{
...
public:
virtual tco_expresion *DoEvaluate() const = 0;
virtual bool IsValue()
{
return false;
}
};
class tco_value
{
...
public:
virtual bool IsValue()
{
return true;
}
};
class tco_function : public tco_expresion
{
...
private:
tco_expresion *m_Function;
tco_expresion *m_Parameter;
public:
virtual tco_expression *DoEvaluate() const
{
vector< tco_expression *> parameterList;
tco_expression *function = const_cast<SNI_Function *>(this);
while (!function->IsValue())
{
function = function->DoCall(parameterList);
}
return function;
}
tco_expresion *DoCall(vector<tco_expresion *> &p_ParameterList)
{
p_ParameterList.push_back(m_Parameter);
return m_Function;
}
};
class tco_if : public tco_function
{
private:
tco_expresion *m_Condition;
tco_expresion *m_Positive;
tco_expresion *m_Negative;
tco_expresion *DoEvaluate() const
{
if (m_Condition.DoEvaluate()->IsTrue())
{
return m_Positive;
}
else
{
return m_Negative;
}
}
}