TL:DR:
- コンパイラの内部は、この最適化を簡単に探すように設定されていない可能性が高く、呼び出し間の大きな関数の内部ではなく、小さな関数の周りでのみ役立つ可能性があります。
- 大規模な関数を作成するためのインライン化は、ほとんどの場合、より良いソリューションです
foo
RBXを保存/復元しない場合、レイテンシとスループットのトレードオフが発生する可能性があります。
コンパイラは複雑な機械です。それらは人間のように「スマート」ではなく、あらゆる可能な最適化を見つけるための高価なアルゴリズムは、多くの場合、追加のコンパイル時間のコストに見合う価値がありません。
私はこれをGCCバグ69986として報告しました- 2016年にプッシュ/ポップを使用してスピル/リロードを戻すことにより、-Oでより小さなコードが可能です。GCC開発者からの活動や返信はありません。:/
わずかに関連:GCCバグ70408-同じ呼び出し保持レジスタを再利用すると、コードが小さくなる場合があります -コンパイラ開発者は、評価の選択順序を必要とするため、GCCがその最適化を実行するには膨大な作業が必要になると私に言ったfoo(int)
ターゲットasmをより単純にするものに基づく2つの呼び出しの。
が それ自体をfoo
保存または復元しない場合rbx
、x
-> retval依存関係チェーンでのスループット(命令カウント)と追加の保存/再読み込みレイテンシの間のトレードオフがあります。
imul reg, reg, 10
ほとんどのコードの平均はSkylakeのような一般的な4ワイドパイプラインの平均で4 uops /クロックよりもはるかに小さいため、コンパイラーは通常、スループットよりもレイテンシを優先します(たとえば、3サイクルのレイテンシ、1 /クロックスループット)。(ただし、命令/ uopsを増やすと、ROBでより多くのスペースが必要になり、同じ順不同ウィンドウが表示される先までの距離が短縮されます。実際には、4つ未満のuops /クロック平均。)
foo
RBXのプッシュ/ポップを行う場合、レイテンシについて得るものはあまりありません。リターンアドレスでのコードのフェッチを遅延さret
せるret
予測ミスまたはIキャッシュミスがない限り、復元を直後ではなく直前に実行することはおそらく関係ありません。
重要な関数のほとんどはRBXを保存/復元するので、変数をRBXに残しても実際には呼び出し全体で実際にレジスターに留まることを意味することはよくありません。(関数が選択する呼び出し保存レジスターをランダム化することは、これを緩和するための良いアイデアかもしれません。)
したがって、この場合はpush rdi
/のpop rax
方が効率的であり、何を実行するか、呼び出し元のを保存/復元するための追加の命令と追加の保存/再読み込みのレイテンシのバランスによっては、小さな非リーフ関数の最適化を見逃している可能性があります。foo
x
rbx
スタックスロット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
大型で、かつ何らかの理由で、彼らはないインライン呼び出し側にすることができます。