GCC と Clang が 1 回だけではなく両方のブランチでポップするのはなぜですか? (末尾重複からエピローグの一部を因数分解) 質問する

GCC と Clang が 1 回だけではなく両方のブランチでポップするのはなぜですか? (末尾重複からエピローグの一部を因数分解) 質問する

GCCとClangはどちらもコンパイルできる

bool pred();
void f();
void g();

void h() {
    if (pred()) {
        f();
    } else {
        g();
    }
}

いくつかのバリエーション

# Clang -Os output.  -O3 is the same
h():
    push    rax
    call    pred()@PLT
    test    al, al
    je      .LBB0_2
    pop     rax
    jmp     f()@PLT
.LBB0_2:
    pop     rax
    jmp     g()@PLT

コードサイズを最適化するときに、コンパイラエクスプローラーでこれを見る

popこれまで一度だけ命令を発行するのではなく、両方のブランチで命令を発行する理由はありますかje?

たとえば、pred() の戻り値が破壊されるのを避けるために、別の呼び出し破棄レジスタにポップしたり、add rsp, 8(この場合、スタック同期 uop が必要なので、最新の CPU では実際には高速ではない可能性があります) を使用したりします。

# hand-written example of what compilers could be doing
h():
    push    rax             # align the stack
    call    pred()@PLT
    pop     rcx             # clean up the stack, restoring the stack pointer to its initial state
    test    al, al
    je      .LBB0_2
    jmp     f()@PLT        # tailcall f
.LBB0_2:
    jmp     g()@PLT        # tailcall g

ベストアンサー1

そうですね、そうかもしれませcallpop rcxtest al,al

この場合、両方のパスは他の作業なしで単に末尾呼び出しを行います。静的コードのサイズは小さくなりますが、他の点では明らかに優れている、または劣っているわけではありません。GCC/clang も、提案どおりに動作するはずです-O3

分岐して末尾呼び出しの引数 (ある場合) を設定する前にスタック フレームを破棄することが常に可能であるとは限らないため、一般的なケースでこれを安全に行うには、コンパイラがチェックしなければならないことがたくさんあります。このような理由は、小さくて単純な関数で最適化が見落とされる一般的な理由です。最適化を探すコードは、小さくない関数に対して正しいものでなければならず、その利点はコンパイル時間と GCC / LLVM ソースのコードのメンテナンス コストに見合うものでなければなりません。

より多くの分岐命令が互いに近くに配置され、一部の CPU では分岐予測に問題が生じる可能性があります。(コード フェッチはデコードでパイプライン化されるため、無条件分岐でもフロントエンドの停止を回避するために何らかの予測が必要です。) 分岐が多数近くにあると、予測の精度が低くなる可能性があります。popは 1 バイトの命令にすぎないため、おそらく大きな違いはありません。

これは報告すべき最適化の失敗です参考:そしてllvm プロジェクトすでに重複が存在しない場合。関連する検索用語には、「末尾の複製」、「エピローグ」、「スタック フレームの分解」などがあります。

この最適化を見つけることは、シュリンクラップ最適化の逆のようなものです。(これは、早期終了分岐の後にプロローグを実行する場合で、プロローグは必要ない可能性があります。) これは、まだいくつかの作業が残っている間に、呼び出しによって破棄されたレジスタの値のみを含むスタック フレームを早期に破棄することになります。


ところで、ここでもう一つの最適化が抜けています。x86-64 direct はjmp rel32と同じ範囲を持っているのでjcc rel32、末尾呼び出しとして JCC を使用している可能性があります (GCC バグ 82516Richard Biener からの短い返信があり、GCC がそれを見逃した理由が少し明らかになりました。

# Hand-written with both optimizations applied
h:
    push    rax         # Align the stack
    call    pred()@PLT
    pop     rcx         # Clean up the stack, restoring the stack
                        # pointer to its initial state
    test    al, al
    je      g()@PLT
    jmp     f()@PLT     # Tailcall f

でコンパイルしない限り、の代わりに、JCC と同等のものは存在しない-fno-pltが得られます。jmp qword ptr [rip + f()@GOTPCREL] # TAILCALLjmp f()@PLT

(リンク時にfと がg存在する場合、リンカーは への間接呼び出しを緩和できますaddr32 jmp f(addr32 プレフィックス バイトは、置き換える間接ジャンプと同じ長さになるようにスペースを埋めるだけです。 へのジャンプ/呼び出しをに67h緩和できるのと同じように、別の共有オブジェクトではなく静的にリンクされています。)f@PLTf

のようなものは__attribute__((visibility("hidden")))コンパイラに伝えることができfgコンパイル対象(メインの実行可能ファイルまたは共有ライブラリ)の内部にあるため、そもそも直接呼び出しを使用できます。

おすすめ記事