web-dev-qa-db-ja.com

テールコール最適化とは

非常に簡単に言うと、テールコール最適化とは何ですか?もっと具体的に言うと、誰かがそれが適用される可能性がある場所と、適用されない場所に、なぜ小さいコードスニペットを示すことができますか?

697
majelbstoat

末尾呼び出しの最適化は、呼び出し元の関数が呼び出し先の関数から取得した値を単に返すため、関数に新しいスタックフレームを割り当てないようにできる場合です。最も一般的な使い方は末尾再帰です。末尾呼び出しの最適化を利用するように書かれた再帰関数は、一定のスタック空間を使うことができます。

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

ご覧のとおり、ファクトテールを呼び出すたびに同じ量のデータを追跡するだけで済みます。これは、取得した値を単純にトップに戻すためです。これは、たとえ私が電話したとしても(事実1000000)、(事実3)と同じ量のスペースしか必要としないことを意味します。これは非末尾再帰的事実には当てはまりません。値が大きいとスタックオーバーフローが発生する可能性があります。

658
Kyle Cronin

簡単な例を見てみましょう: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;
}

ここでわかるように、十分に高度なオプティマイザは末尾再帰を反復に置き換えることができます。これは、関数呼び出しのオーバーヘッドを避け、一定量のスタックスペースしか使用しないため、はるかに効率的です。

500
Christoph

TCO(Tail Call Optimization)は、スマートコンパイラが関数を呼び出して追加のスタックスペースを取らないようにするプロセスです。これが起こる唯一の状況は、関数内で実行された最後の命令fが関数への呼び出しである場合ですg。 (注:gfになります)。ここで重要なのは、fはもはやスタックスペースを必要としないということです - それは単にgを呼び出し、そして何でもgを返します。 )戻ります。この場合、gが実行され、fと呼ばれるものに必要な値が返されるように最適化を行うことができます。

この最適化により、再帰呼び出しは爆発するのではなく、一定のスタックスペースを取ることができます。

例:この階乗関数はTCO最適化できません。

def fact(n):
    if n == 0:
        return 1
    return n * fact(n-1)

この関数は、return文の中で別の関数を呼び出すこと以外のことを行います。

以下のこの関数はTCO最適化可能です。

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)

これは、これらの関数のいずれかで最後に起こることは別の関数を呼び出すことであるためです。

177
Claudiu

テールコール、再帰テールコール、およびテールコール最適化について私が見つけた最高の高レベルの説明は、おそらくブログ投稿です。

「一体何なのか:末尾呼び出し」

dan Sugalskiによる。テールコールの最適化に関して、彼は次のように書いています。

ちょっと、この単純な関数を考えてください。

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)から来た人たちにとってどれほど簡潔で簡単なのかを把握することができることです。

56
btiernay

すべての言語がそれをサポートしているわけではないことを最初に注意してください。

TCOは再帰の特別な場合に適用されます。その要点は、関数内で最後に行うことが自分自身を呼び出すことである場合(つまり、 "末尾"位置から自分自身を呼び出すこと)、これは標準の再帰ではなく反復のように動作するようにコンパイラによって最適化できることです。

通常、再帰の間、ランタイムはすべての再帰呼び出しを追跡する必要があるので、1回戻ったときに前の呼び出しから再開できます。 (これがどのように機能するかを視覚的に把握するには、再帰呼び出しの結果を手動で書き出してみてください。)すべての呼び出しを追跡するとスペースが必要になります。しかしTCOでは、「最初に戻って、今回はパラメータ値をこれらの新しい値に変更するだけ」と言うことができます。再帰呼び出しの後にこれらの値を参照するものは何もないので、それは可能です。

13
J Cooper

ここを見て:

http://tratt.net/laurie/tech_articles/articles/tail_call_optimization

ご存じかもしれませんが、再帰的な関数呼び出しはスタックに大混乱をもたらす可能性があります。スタックスペースをすぐに使い尽くすのは簡単です。末尾呼び出しの最適化は、一定のスタックスペースを使用する再帰的スタイルのアルゴリズムを作成できる方法です。したがって、それは大きくならず、スタックエラーになります。

6
BobbyShaftoe

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

GitHubアップストリーム

コンパイルして逆アセンブルします。

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-callsman 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

両者の主な違いは次のとおりです。

  • -fno-optimize-sibling-callscallqを使用します。これは典型的な最適化されていない関数呼び出しです。

    この命令はリターンアドレスをスタックにプッシュするため、増加します。

    さらに、このバージョンではPush %rbxも行われます。これは %rbxをスタックにプッシュします

    GCCは、これを最初の関数引数(edi)であるnebxに格納し、次にfactorialを呼び出すのでこれを行います。

    GCCはfactorialへの別の呼び出しを準備しているので、これを行う必要があります。これは新しいedi == n-1を使用します。

    このレジスタは呼び出し先で保存されるため、ebxが選択されます。 linux x86-64関数呼び出し によって保持されるレジスタのうち、factorialのサブコールは変更されません。それを失い、nを失います。

  • -foptimize-sibling-callsはスタックにプッシュする命令を使用しません。それはgoto内でfactorial内でjejneをジャンプするだけです。

    したがって、このバージョンは、関数呼び出しなしのwhileループと同等です。スタック使用量は一定です。

GCC 8.2のUbuntu 18.10でテスト済み。

  1. 関数自体にはgoto文がないことを確認してください。関数呼び出しが呼び出し先関数の最後のものであることに注意してください。

  2. 大規模再帰は最適化にこれを使用できますが、小規模では、関数呼び出しを末尾呼び出しにするための命令オーバーヘッドが実際の目的を減らします。

  3. TCOは永久に実行中の機能を引き起こすかもしれません:

    void eternity()
    {
        eternity();
    }
    
4
grillSandwich

再帰的関数アプローチには問題があります。それはサイズO(n)の呼び出しスタックを構築し、それは我々の総メモリコストO(n)になります。これにより、呼び出しスタックが大きくなりすぎて領域が不足するスタックオーバーフローエラーに対して脆弱になります。テールコスト最適化(TCO)スキームそれは、高い呼び出しスタックを構築することを避けるために再帰的関数を最適化することができ、それ故にメモリコストを節約します。

TCOをしている言語はたくさんあります(Javascript、Ruby、少数のC)。PythonやJavaはTCOをしません。

JavaScript言語での使用が確認されています:) http://2ality.com/2015/06/tail-call-optimization.html

3