web-dev-qa-db-ja.com

gccが末尾再帰の最適化を実行しているかどうかを確認するにはどうすればよいですか?

Gcc(より具体的にはg ++)が末尾再帰を最適化しているかどうかは、どうすればわかります特定の関数内? (数回表示されるため:gccが一般的に末尾再帰を最適化できるかどうかをテストしたくありません。最適化されているかどうかを知りたいですmy末尾再帰関数。)

答えが「生成されたアセンブラーを確認する」である場合、私が探しているものを正確に知り、最適化があるかどうかを確認するためにアセンブラーを調べる簡単なプログラムを書くことができるかどうかを知りたいです。

PS。私はこれが質問の一部として表示されることを知っています もしあれば、C++コンパイラは末尾再帰の最適化を行いますか? 5か月前から。しかし、その質問のthis partは十分に回答されたとは思いません。 (答えは、「コンパイラーが(私が知っている)最適化を行ったかどうかを確認する最も簡単な方法は、スタックオーバーフローを引き起こす呼び出しを実行すること、またはアセンブリの出力を確認することでした。)

59
A. Rex

他の質問のサンプルコード を使用してみましょう。コンパイルしますが、アセンブルしないようにgccに指示します。

 gcc -std = c99 -S -O2 test.c 

次に、結果のtest.sファイル(Mac OS 10.5のgcc 4.0.1)の_atoi関数を見てみましょう。

        .text
        .align 4,0x90
_atoi:
        pushl   %ebp
        testl   %eax, %eax
        movl    %esp, %ebp
        movl    %eax, %ecx
        je      L3
        .align 4,0x90
L5:
        movzbl  (%ecx), %eax
        testb   %al, %al
        je      L3
        leal    (%edx,%edx,4), %edx
        movsbl  %al,%eax
        incl    %ecx
        leal    -48(%eax,%edx,2), %edx
        jne     L5
        .align 4,0x90
L3:
        leave
        movl    %edx, %eax
        ret

コンパイラーは、この関数に対して末尾呼び出しの最適化を実行しました。そのコードにはcall命令がないため、元のCコードには明らかに関数呼び出しがあったため、これがわかります。さらに、jne L5命令を見ることができます。これは、関数内で逆方向にジャンプし、Cコードに明らかにループがない場合のループを示します。最適化をオフにして再コンパイルすると、call _atoiという行が表示され、後方ジャンプも表示されません。

これを自動化できるかどうかは、別の問題です。アセンブラコードの詳細は、コンパイルするコードによって異なります。

プログラムでそれを発見できると思います。関数にスタックポインタの現在の値を出力させます(x86ではレジスタESP)。関数が最初の呼び出しで再帰呼び出しと同じ値を出力する場合、コンパイラーは末尾呼び出しの最適化を実行しています。ただし、このアイデアでは、観察したい関数を変更する必要があります。これは、コンパイラーが関数を最適化するために選択する方法に影響を与える可能性があります。テストが成功した場合(同じESPの値を両方とも出力します)、最適化はインストルメンテーションなしでも実行されると想定するのが妥当だと思いますが、テストが失敗した場合は、失敗は、インストルメンテーションコードの追加が原因でした。

62
Rob Kennedy

[〜#〜] edit [〜#〜]私の元の投稿では、GCCが末尾呼び出しの削除を実際に行うこともできませんでした。とにかくGCCをだまして末尾呼び出しの削除を行うように、いくつかの追加のトリッキーを追加しました。

Stevenの答えを拡張して、同じスタックフレームがあるかどうかをプログラムで確認できます。

#include <stdio.h>

// We need to get a reference to the stack without spooking GCC into turning
// off tail-call elimination
int Oracle2(void) { 
    char Oracle; int Oracle2 = (int)&Oracle; return Oracle2; 
}

void myCoolFunction(params, ..., int tailRecursionCheck) {
    int Oracle = Oracle2();
    if( tailRecursionCheck && tailRecursionCheck != Oracle ) {
        printf("GCC did not optimize this call.\n");
    }
    // ... more code ...
    // The return is significant... GCC won't eliminate the call otherwise
    return myCoolFunction( ..., Oracle);
}

int main(int argc, char *argv[]) {
    myCoolFunction(..., 0);
    return 0;
}

関数を非再帰的に呼び出す場合は、0を渡してチェックパラメータを渡します。それ以外の場合はOracleを渡します。削除する必要がある末尾再帰呼び出しが削除されていない場合は、実行時に通知されます。

これをテストすると、私のバージョンのGCCは最初の末尾呼び出しを最適化しないようですが、残りの末尾呼び出しは最適化されています。面白い。

11
Paul

生成されたアセンブリコードを調べて、x [86]での再帰呼び出しにcallまたはjmp命令を使用しているかどうかを確認します(他のアーキテクチャの場合は、対応する命令を検索します)。 nmおよびobjdumpを使用して、関数に対応するアセンブリのみを取得できます。次の関数について考えてみます。

int fact(int n)
{
  return n <= 1 ? 1 : n * fact(n-1);
}

としてコンパイル

gcc fact.c -c -o fact.o -O2

次に、末尾再帰を使用しているかどうかをテストします。

# get starting address and size of function fact from nm
ADDR=$(nm --print-size --radix=d fact.o | grep ' fact$' | cut -d ' ' -f 1,2)
# strip leading 0's to avoid being interpreted by objdump as octal addresses
STARTADDR=$(echo $ADDR | cut -d ' ' -f 1 | sed 's/^0*\(.\)/\1/')
SIZE=$(echo $ADDR | cut -d ' ' -f 2 | sed 's/^0*//')
STOPADDR=$(( $STARTADDR + $SIZE ))

# now disassemble the function and look for an instruction of the form
# call addr <fact+offset>
if objdump --disassemble fact.o --start-address=$STARTADDR --stop-address=$STOPADDR | \
    grep -qE 'call +[0-9a-f]+ <fact\+'
then
    echo "fact is NOT tail recursive"
else
    echo "fact is tail recursive"
fi

上記の関数で実行すると、このスクリプトは「ファクトは末尾再帰です」と出力します。代わりに-O3ではなく-O2を使用してコンパイルすると、奇妙なことに「事実は末尾再帰ではありません」と出力されます。

Ehemientがコメントで指摘したように、これは偽陰性をもたらす可能性があることに注意してください。このスクリプトは、関数にそれ自体への再帰呼び出しがまったく含まれていない場合にのみ正しい答えを生成し、兄弟の再帰も検出しません(たとえば、A()B()を呼び出し、A())。現時点では、生成されたアセンブリを人間の目で確認することを含まない、より堅牢な方法は考えられませんが、少なくともこのスクリプトを使用して、オブジェクトファイル内の特定の関数に対応するアセンブリを簡単に取得できます。

7
Adam Rosenfield

PolyThinkerの答えを拡張して、ここに具体例を示します。

int foo(int a, int b) {
    if (a && b)
        return foo(a - 1, b - 1);
    return a + b;
}

i686-pc-linux-gnu-gcc-4.3.2 -Os -fno-optimize-sibling-calls出力:

00000000 <foo>:
 0:55 Push%ebp 
 1:89 e5 mov%esp、%ebp 
 3:8b 55 08 mov 0x8(%ebp)、%edx 
 6:8b 45 0c mov 0xc(%ebp)、%eax 
 9:85 d2 test%edx、%edx 
 b:74 16 je 23 <foo + 0x23> 
 d:85 c0 test%eax、%eax 
 f:74 12 je 23 <foo + 0x23> 
 11:51 Push%ecx 
 12:48 dec%eax 
 13:51プッシュ%ecx 
 14:50プッシュ%eax 
 15:8d 42 ff lea -0x1(%edx)、%eax 
 18:50プッシュ%eax 
 19:e8 fc ff ff ff call 1a <foo + 0x1a> 
 1e:83 c4 10 add $ 0x10、%esp 
 21:eb 02 jmp 25 <foo + 0x25> 
 23:01 d0 %edx、%eax 
を追加します25:c9 leave 
 26:c3 ret 

i686-pc-linux-gnu-gcc-4.3.2 -Os出力:

00000000 <foo>:
 0:55 Push%ebp 
 1:89 e5 mov%esp、%ebp 
 3:8b 55 08 mov 0x8(%ebp)、%edx 
 6:8b 45 0c mov 0xc(%ebp)、%eax 
 9:85 d2 test%edx、%edx 
 b:74 08 je 15 <foo + 0x15> 
 d:85 c0 test%eax、%eax 
 f:74 04 je 15 <foo + 0x15> 
 11:48 dec%eax 
 12:4a dec%edx 
 13:eb f4 jmp 9 <foo + 0x9> 
 15:5d pop%ebp 
 16:01 d0 add%edx、%eax 
 18:c3 ret 

最初のケースでは、<foo+0x11>-<foo+0x1d>は関数呼び出しの引数をプッシュしますが、2番目の場合は<foo+0x11>-<foo+0x14>は、変数とjmpsをプリアンブルの後ろの同じ関数に変更します。それはあなたが探したいものです。

これをプログラムで行うことはできないと思います。可能なバリエーションが多すぎます。関数の「肉」は最初に近いか、離れている可能性があり、そのjmpをループまたは条件付きのものと見ないで区別することはできません。 jmpではなく、条件付きジャンプの可能性があります。 gcccallを残す場合がありますが、兄弟呼び出しの最適化を他の場合に適用します。

ちなみに、gccの「兄弟呼び出し」は末尾再帰呼び出しよりも少し一般的です。事実上、同じスタックフレームを再利用しても問題ない関数呼び出しは、おそらく兄弟呼び出しです。

[編集]

自己再帰callを探すだけの場合の例として、

int bar(int n) {
    if (n == 0)
        return bar(bar(1));
    if (n % 2)
        return n;
    return bar(n / 2);
}

GCCは、3つのbar呼び出しのうち2つに兄弟呼び出しの最適化を適用します。あなたはcall <bar+..>生成されたアセンブリ内。

6
ephemient

私は分解を見るのが面倒です。これを試して:

void so(long l)
{
    ++l;
    so(l);
}
int main(int argc, char ** argv)
{
    so(0);
    return 0;
}

このプログラムをコンパイルして実行します。それが永遠に実行される場合、末尾再帰は最適化されて離れました。スタックを吹き飛ばしても、そうではありません。

編集:申し訳ありませんが、あまりにも速く読んでください、OPはの特定の関数が末尾再帰を最適化しているかどうかを知りたがっています。 OK...

...原理は同じです-末尾再帰が離れて最適化されている場合、スタックフレームは同じままです。 backtrace function を使用して、関数内からスタックフレームをキャプチャし、それらが成長しているかどうかを判断できるはずです。末尾再帰が最適化されている場合は、バッファーに戻りポインターが1つしかありません

3
Steven A. Lowe

これを確認した別の方法は次のとおりです。

  1. 'gcc -O2'を使用してコードをコンパイルします
  2. 「gdb」を開始
  3. 末尾再帰の最適化/削除が予想される関数にブレークポイントを配置します
  4. コードを実行する
  5. テールコールが削除されている場合、ブレークポイントは1回だけヒットするか、ヒットしません。これについての詳細は this を参照してください
2
user59634

単純な方法:単純な末尾再帰プログラムを作成してコンパイルし、逆アセンブルして、最適化されているかどうかを確認します。

あなたはあなたの質問にすでにそれを持っていることに気づきました。アセンブリの読み方を知っていれば、簡単にわかります。再帰関数は、関数本体内から(「呼び出しラベル」を使用して)自分自身を呼び出し、ループは「jmpラベル」になります。

1
PolyThinker

最適化が行われていない場合、その関数呼び出しの再帰が深すぎるためにスタックオーバーフローを引き起こす入力データを作成し、それが発生するかどうかを確認できます。もちろん、これは簡単なことではなく、入力が十分に大きいと、関数が耐えられないほど長期間実行される場合があります。

0
sharptooth