コンパイラが呼び出し先に保存されたレジスタの使用をここで主張するのはなぜですか?


10

次のCコードを考えてみます。

void foo(void);

long bar(long x) {
    foo();
    return x;
}

-O3またはのいずれかを使用してGCC 9.3でコンパイルすると-Os、次のようになります。

bar:
        push    r12
        mov     r12, rdi
        call    foo
        mov     rax, r12
        pop     r12
        ret

clangからの出力は、呼び出し先保存レジスタとしてではrbxなく選択することを除いて同じですr12

しかし、私はこのように見えるアセンブリを見たいと思っています/期待しています:

bar:
        push    rdi
        call    foo
        pop     rax
        ret

英語では、これが私の目に見えるものです。

  • 呼び出し先が保存したレジスタの古い値をスタックにプッシュします
  • x呼び出し先が保存したレジスタに移動します
  • コール foo
  • 移動しx、戻り値レジスタに呼び出し先保存レジスタから
  • スタックをポップして、呼び出し先が保存したレジスタの古い値を復元します

呼び出し先に保存されたレジスターをいじる必要がないのはなぜですか?代わりにこれを行わないのはなぜですか?それはより短く、よりシンプルで、おそらくより高速に見えます:

  • xスタックにプッシュ
  • コール foo
  • xスタックから戻り値レジスタにポップ

私の組み立ては間違っていますか?余分なレジスタをいじるよりも効率が悪いのでしょうか?これらの両方に対する答えが「いいえ」である場合、GCCまたはclangのいずれかがこのようにしないのはなぜですか?

ゴッドボルトリンク


編集:変数が意味のある形で使用されている場合でも発生することを示すために、それほど簡単ではない例を次に示します。

long foo(long);

long bar(long x) {
    return foo(x * x) - x;
}

私はこれを手に入れます:

bar:
        push    rbx
        mov     rbx, rdi
        imul    rdi, rdi
        call    foo
        sub     rax, rbx
        pop     rbx
        ret

私はむしろこれを持っています:

bar:
        push    rdi
        imul    rdi, rdi
        call    foo
        pop     rdi
        sub     rax, rdi
        ret

今回は2つではなく1つだけの命令ですが、コアコンセプトは同じです。

ゴッドボルトリンク


4
興味深い見逃された最適化。
fuz

1
最も可能性が高いのは、渡されたパラメーターが使用されるため、揮発性レジスターを保存し、そのパラメーターへの後続のアクセスがレジスターから高速になるため、渡されたパラメーターをスタックにないレジスターに保持することです。xをfooに渡すと、これが表示されます。そのため、スタックフレームセットアップの一般的な部分にすぎない可能性があります。
old_timer

確かにfooがないとスタックは使用されないので、はい、それは最適化の失敗ですが、誰かが関数を追加して分析する必要があり、値が使用されておらず、そのレジスタとの競合がない場合(通常はそこにあります)です)。
old_timer

armバックエンドもこれをgccで行います。おそらくバックエンドではない
old_timer

clang 10同じストーリー(armバックエンド)。
old_timer

回答:


5

TL:DR:

  • コンパイラの内部は、この最適化を簡単に探すように設定されていない可能性が高く、呼び出し間の大きな関数の内部ではなく、小さな関数の周りでのみ役立つ可能性があります。
  • 大規模な関数を作成するためのインライン化は、ほとんどの場合、より良いソリューションです
  • fooRBXを保存/復元しない場合、レイテンシとスループットのトレードオフが発生する可能性があります。

コンパイラは複雑な機械です。それらは人間のように「スマート」ではなく、あらゆる可能な最適化を見つけるための高価なアルゴリズムは、多くの場合、追加のコンパイル時間のコストに見合う価値がありません。

私はこれをGCCバグ69986として報告しました- 2016年にプッシュ/ポップを使用してスピル/リロードを戻すことにより、-Oでより小さなコードが可能です。GCC開発者からの活動や返信はありません。:/

わずかに関連:GCCバグ70408-​​同じ呼び出し保持レジスタを再利用すると、コードが小さくなる場合があります -コンパイラ開発者は、評価の選択順序を必要とするため、GCCがその最適化を実行するには膨大な作業が必要になると私に言ったfoo(int)ターゲットasmをより単純にするものに基づく2つの呼び出しの。


それ自体をfoo保存または復元しない場合rbxx-> retval依存関係チェーンでのスループット(命令カウント)と追加の保存/再読み込みレイテンシの間のトレードオフがあります。

imul reg, reg, 10ほとんどのコードの平均はSkylakeのような一般的な4ワイドパイプラインの平均で4 uops /クロックよりもはるかに小さいため、コンパイラーは通常、スループットよりもレイテンシを優先します(たとえば、3サイクルのレイテンシ、1 /クロックスループット)。(ただし、命令/ uopsを増やすと、ROBでより多くのスペースが必要になり、同じ順不同ウィンドウが表示される先までの距離が短縮されます。実際には、4つ未満のuops /クロック平均。)

fooRBXのプッシュ/ポップを行う場合、レイテンシについて得るものはあまりありません。リターンアドレスでのコードのフェッチを遅延さretせるret予測ミスまたはIキャッシュミスがない限り、復元を直後ではなく直前に実行することはおそらく関係ありません。

重要な関数のほとんどはRBXを保存/復元するので、変数をRBXに残しても実際には呼び出し全体で実際にレジスターに留まることを意味することはよくありません。(関数が選択する呼び出し保存レジスターをランダム化することは、これを緩和するための良いアイデアかもしれません。)


したがって、この場合はpush rdi/のpop rax方が効率的であり、何を実行するか、呼び出し元のを保存/復元するための追加の命令と追加の保存/再読み込みのレイテンシのバランスによっては、小さな非リーフ関数の最適化を見逃している可能性があります。fooxrbx

スタックスロットsub rsp, 8へのスピル/リロードxに使用された場合と同様に、スタックアンワインドメタデータがRSPへの変更をここで表すことが可能です。(しかし、コンパイラーは、この最適化を知りませんpush。スペースを予約して変数を初期化するために使用します。 ローカル変数を作成するために、espを1度増やすだけでなく、プッシュポップ命令を使用できるC / C ++コンパイラーはどれですか。 1つのローカル変数を.eh_frame使用すると、プッシュごとにスタックポインターを個別に移動するため、スタックの巻き戻しのメタデータが大きくなります。ただし、コンパイラーがプッシュ/ポップを使用して呼び出しで保存されたregを保存/復元することを妨げるものではありません。)


この最適化を探すことをコンパイラに教える価値がある場合はIDK

関数内での1回の呼び出しではなく、関数全体を中心にした方がいいでしょう。そして私が言ったように、それはfooとにかくRBXを保存/復元するという悲観的な仮定に基づいています。(または、xから戻り値までの待ち時間が重要でないことがわかっている場合は、スループットを最適化します。しかし、コンパイラーはそれを知らず、通常は待ち時間を最適化します)。

多くのコード(関数内の単一の関数呼び出しなど)でその悲観的な仮定をし始めると、RBXが保存/復元されない場合が多くなり、それを利用できるようになります。

また、ループでこの追加の保存/復元のプッシュ/ポップを望まず、ループの外でRBXを保存/復元し、関数呼び出しを行うループで呼び出し保存レジスタを使用します。ループがなくても、一般的なケースでは、ほとんどの関数が複数の関数呼び出しを行います。この最適化のアイデアはx、最初と最後の呼び出しの直前で、どの呼び出しの間でも実際に使用しない場合に適用できます。それ以外の場合は、呼び出しの後にポップを1つ実行すると、それぞれについて16バイトのスタックアライメントを維持するという問題がありcallます。別の呼び出しの前に呼び出します。

コンパイラは、一般的に小さな関数には向いていません。しかし、これはCPUにも適していません。 非インライン関数呼び出しは、コンパイラーが呼び出し先の内部を確認し、通常より多くの仮定を行うことができない限り、最適な状態で最適化に影響を与えます。非インライン関数呼び出しは暗黙的なメモリバリアです。呼び出し側は関数がグローバルにアクセス可能なデータを読み書きする可能性があると想定する必要があるため、そのようなすべての変数はC抽象マシンと同期している必要があります。(アドレスが関数をエスケープしていない場合、エスケープ分析により、呼び出し間でレジスター内のローカルを保持できます。)また、コンパイラーは、呼び出しが破棄されたレジスターがすべて破棄されていると想定する必要があります。これは、x86-64 System Vの浮動小数点を吸引します。これには、コール保存のXMMレジスタがありません。

のような小さな関数bar()は、呼び出し元にインライン化した方がよいでしょう。 でコンパイルすると-flto、ほとんどの場合、ファイルの境界を越えても発生します。(関数ポインターと共有ライブラリー境界はこれを無効にすることができます。)


コンパイラーがこれらの最適化を実行するのに煩わしくない理由の1つは、通常のスタックと、コール保存を保存する方法を知っているレジスター割り当てコードとは異なり、コンパイラーの内部にさまざまなコードが大量に必要になることです。登録して使用します。

つまり、実装するには多くの作業が必要であり、維持するコードも多くなります。これを行うことに熱心すぎると、コードが悪化する可能性があります。

そして、それは(うまくいけば)重要ではないことも。問題がある場合はbar、呼び出し元にインライン化fooするか、にインライン化する必要がありますbar。これは、異なる多くのない限り大丈夫ですbar様機能とfoo大型で、かつ何らかの理由で、彼らはないインライン呼び出し側にすることができます。


一部のコンパイラーがなぜコードをそのように変換するのか、翻訳のエラーではないにせよ、。たとえば、clangがこのループを奇妙な(最適化されていない)トランスレーションした理由を尋ねる可能性があります。gcc 、icc、さらにはmsvcと比較してください
RbMm

1
@RbMm:私はあなたの言い分を理解していません。それは、この質問が何であるかとは無関係に、clangの完全に分離された失敗した最適化のように見えます。失われた最適化のバグが存在し、ほとんどの場合は修正されるはずです。Bugs.llvm.orgで
Peter Cordes

はい、私のコード例は元の質問とは無関係です。奇妙な(私の見た目では)変換(そして単一のclangコンパイラのみ)のもう1つの例です。とにかく結果としてasmコードが正しい。最高ではなく、実際にはネイティブではないgcc / icc / msvcを比較
RbMm
弊社のサイトを使用することにより、あなたは弊社のクッキーポリシーおよびプライバシーポリシーを読み、理解したものとみなされます。
Licensed under cc by-sa 3.0 with attribution required.