GCC と Clang の内部

このページの目次

システムプログラミングにおいて、コンパイラの内部を理解するための最も実用的な入り口は、成果物を追跡することです。ソースコードがどの段階で IR(中間表現)に変換され、ファイル間情報がいつ可視化され、最終的にどのランタイムライブラリとリンクされるかを確認します。これにより、あるオプションがセマンティクス、最適化、ターゲット命令、あるいはリンクプロセスのいずれに影響を与えるかを判断できます。

単一のプログラムで各段階を貫通する

まず、独立したディレクトリに prog.c を保存します。このプログラムは引数の数に応じて2種類のデータを選択し、すべての計算は符号なし値を使用して行います。これにより、符号付きオーバーフローによる未定義動作の導入を避けます。結果は終了ステータスと出力に使用され、異なるビルド間で動作が保持されているかどうかを簡単に確認できます。

#include <stdint.h>
#include <stddef.h>
#include <stdio.h>

uint32_t sum(const uint32_t *values, size_t count) {
    uint32_t total = 0;
    for (size_t i = 0; i < count; ++i) total += values[i];
    return total;
}
int main(int argc, char **argv) {
    (void)argv;
    const uint32_t values[] = {1, 2, 3, 4};
    uint32_t result = argc > 1 ? sum(values, 4) : sum(values, 2);
    printf("%lu\n", (unsigned long)result);
    return result == (argc > 1 ? 10u : 3u) ? 0 : 1;
}

引数なしの場合は 3 を、任意の引数付きの場合は 10 を出力すべきです。この非常に小さなワークロードはコンパイルチェーンを観察するためのものであり、ある最適化オプションがより高速であることを証明するには不十分です。コンパイラは、main 内の既知のデータ計算を直接折りたたむ可能性さえあります。これが最終的な成果物を確認する必要がある理由です。

ドライバは複数のステージを編成する

gcc や clang は通常、ユーザー向けのドライバエントリポイントであり、フロントエンド、アセンブラ、リンカ、スタートアップファイル、およびランタイムライブラリを選択します。C++ の最終リンクには、対応する g++ または clang++ ドライバを使用して、一致する C++ ランタイムサポートを組み込む必要があります。.o ファイルにすでにマシンコードが含まれているからといって、ld を直接呼び出すことが完全に同等であると見なしてはいけません。

clang '-###' -O2 prog.c -o prog
clang -E prog.c -o prog.i
clang -S -emit-llvm -O2 prog.c -o prog.ll
clang -S -O2 prog.c -o prog.s
clang -c -O2 prog.c -o prog.o

1行目は実行せずに呼び出す予定のコマンドを表示し、残りはそれぞれプリプロセス、LLVM IR、アセンブリ、ターゲットファイルの段階で停止します。完全なビルドでは、複数のステージを融合させることができ、必ずしもすべての中間ファイルをディスクに書き出す必要はありません。Clang は統合アセンブラや異なるリンカを使用でき、LLVM IR が最終的に LLD に渡される必要もありません。Clang ツールチェーン

同一のコンパイルタスク:フロントエンドのセマンティクス、最適化表現、ターゲットコード ソースコードとコンパイル引数 GCC フロントエンド 言語木 → GENERIC / GIMPLE Clang フロントエンド AST → LLVM IR GIMPLE SSA → RTL 最適化とターゲット依存処理 LLVM IR → Machine IR 最適化とターゲット依存処理 ドライバはコンパイル、アセンブリ、リンク、およびランタイムライブラリを調整します。中間層もターゲットレイアウトとコスト情報を使用します。

GIMPLE と LLVM IR は単純な「高低のランク付け」ではない

GCC フロントエンドは言語構造を汎用表現に降格させ、GIMPLE は簡素化された文の形式を提供します。多くの最適化は SSA 表現上で動作し、その後、ターゲットマシンに近い処理である RTL などに移行します。LLVM IR も同様に SSA 値を使用しますが、ターゲットデータレイアウト、呼び出し規約、メモリセマンティクス、および属性を含みます。

中間層の再利用は、完全にアーキテクチャ非依存であることを意味しません。ベクトル幅、有効な命令、コストモデルは最適化に影響を与える可能性があります。バックエンドも、単に一度「命令の選択、レジスタ割り当て、スケジューリング」を実行するだけではありません。異なるターゲットや最適化パイプラインでは、これらの処理が異なる位置で組み合わされます。GCC passes

gcc -O2 -c -fdump-tree-optimized prog.c
clang -O2 -c -Rpass=loop-vectorize -Rpass-missed=loop-vectorize \
  -Rpass-analysis=loop-vectorize prog.c

dump は結果を観察するために使用し、最適化リマークは意思決定を理解するために使用します。あるループがベクトル化されない理由は、エイリアシング、安全性、利益の見積もり、またはターゲット能力によるものかもしれません。「O3 を有効にしたから」という理由だけで SIMD が生成されるべきだと判断することはできません。概念的な導出については 古典的最適化 を参照してください。

IR を読む際は、まずデータフローと制御フローを探す

sum 関数はソースコードで total を繰り返し変更しますが、SSA 表現では各結果の名前に定義が1つだけ存在します。ループは通常、phi 関数を使用して、エントリとバックエッジから異なる前回の値を選択します。phi はすべての先行ノードで評価されるわけではなく、必ずしも1つのマシン命令に対応するわけではありません。最適化により、ループの展開、ベクトル化、または全体置換が行われる可能性があるため、具体的な出力は現在の引数に応じて読み取る必要があります。LLVM phi

観察位置問いかけるべきこと直接結論付けられないこと
プリプロセス出力 prog.iマクロとヘッダーは最終的にどのように展開されるかビジネスロジックの型チェックが完了した
AST / IR型、分岐、呼び出し、メモリアクセスがどのように表現されるか各 IR 命令に対応するマシン命令がある
最適化リマーク特定のパスが変換を受け入れる/拒否する理由メッセージがないことは、最適化が行われなかったことを意味する
prog.sターゲットが実際にどの命令を選択したかリンク後にレイアウトとアドレスが確定した
最終実行ファイル呼び出しが保持されているか、シンボルがどのように解決されたかシンボルが消えたからといって、必ずしも高速に実行されるわけではない

例えば、要素ごとの変換に restrict を追加すると、コンパイラがポインタの重複を除外するのに役立ちますが、同時に呼び出し元により厳しいエイリアス契約を強制します。呼び出し元が依然として許可されていない方法で入力と出力を重複させている場合、その後発生する結果の変化を「最適化の不安定性」と呼ぶことはできません。まず前提を証明し、その前提を利用して最適化する必要があります。

LTO:部分的な最適化をリンク可視範囲に延期する

以下の2ファイルの準備として、a.c と b.c を用意します。a.c は関数宣言しか知らず、b.c のみが実装を含みます。

/* a.c */
#include <stdio.h>
int doubled(int value);
int main(void) {
    int result = doubled(21);
    printf("%d\n", result);
    return result == 42 ? 0 : 1;
}
/* b.c:この例では 21 のみ呼び出されるため、乗算はオーバーフローしない */
int doubled(int value) { return value * 2; }

通常の分離コンパイルでは、現在の翻訳ユニットのみが参照されます。LTO は、オブジェクトファイルやその他の中間成果物に、リンクフェーズで処理できる情報を保存し、ファイル間インライン展開、定数伝播、および削除を可能にします。

gcc -O2 -flto -c a.c b.c
gcc -O2 -flto a.o b.o -o prog

両方のフェーズに必要なオプションを渡すとともに、コンパイラドライバを使用して一致するツールチェーンを選択します。LTO に参加するオブジェクト、アーカイブツールプラグイン、コンパイラバージョン、およびリンカのサポートは互換性がある必要があります。すべての .o ファイルが、同じ LTO 形式で消費できる表現を含んでいるわけではありません。

Clang ThinLTO は以下のように記述できます。

clang -O2 -flto=thin -c a.c b.c
clang -O2 -flto=thin -fuse-ld=lld a.o b.o -o prog

ThinLTO はサマリーを使用してモジュール間の分析を行い、バックエンド処理を並列化およびキャッシュ可能にします。これは「最適化を少しだけ減らす」ものではなく、またリンクフェーズを必要としないものでもありません。バージョンの一致とキャッシュ戦略はビルド効果に影響します。ThinLTO ドキュメント

LTO は、実行時にロードされるすべてのコードを自動的に参照できるわけではありません。共有ライブラリの境界、シンボルの可視性、動的置換セマンティクス、および外部呼び出しは、行える仮定を制限します。これにより、コードサイズが縮小または増大し、実行時間が短縮または延長する可能性があります。固定された利益の範囲を保証するのではなく、ターゲットの負荷を測定する必要があります。

ファイル間最適化:まず可視性を確立し、最終コードを確認する a.c: doubled(21) 通常のコンパイルでは宣言と呼び出しのみが見える b.c: value * 2 関数本体は別の翻訳ユニットにある LTO は許可されたリンク範囲内で結合分析を行う インライン展開して定数 42 を伝播する場合もあれば、独立したシンボルを保持する場合もある 外部可視性、動的置換、および最適化戦略は変換を制限します。最終的な逆アセンブリを基準とします。

通常のリンクと LTO の成果物を対照する

gcc -O2 -c a.c b.c
gcc a.o b.o -o normal
objdump -d normal
nm normal
gcc -O2 -flto a.c b.c -o with-lto
objdump -d with-lto
nm with-lto

両方のプログラムは 42 を出力すべきです。まず main の逆アセンブリで doubled の呼び出しを探し、次に LTO バージョンが直接使用する定数に変更されたかどうかを確認します。外部シンボルが存在するかどうかと、呼び出しがインライン展開されるかどうかは、関連しているが異なる問題です。nm はシンボルを確認するのに適していますが、各呼び出しポイントがどのように実行されるかを単独で証明することはできません。

ツールのバージョンも一致させる必要があります。PATH 内の clang、ld.lld、llvm-profdata が異なるインストールから来ている可能性があります。それぞれの --version を記録し、bitcode やプロファイルのバージョンが互換性がない場合に、実際に呼び出されているツールを最初に確認してください。すべて LLVM という名前が付いているからといって、任意に混ぜて使用できるとは限りません。

ファイル間定数伝播で、何が不足しているのか?

通常の分離コンパイルでは、a.c には宣言しかありません。実引数 21 が定数であっても、関数名だけで 42 を導き出すことはできません。リンクフェーズで実装を取得した後、その呼び出しがその実装にバインドできることを確認する必要があります。LTO はファイル間の情報を提供しますが、シンボルのセマンティクスによって使用できる情報が決定されます。インライン展開や折りたたみを行うかどうかは、最終的にコンパイル成果物を確認する必要があります。

図を準備しています
ファイル間定数伝播で、何が不足しているのか?

関数名と引数を知っているだけでは不十分です。ファイル間伝播には実装を見る必要があり、実際のバインドターゲットに対する仮定を許可する必要があります。最適化の機会があるからといって、すべてのバージョンで採用されるわけではありません。

PGO:トレーニング実行による最適化意思決定のガイダンス

PGO は、実際のパス頻度をコンパイラにフィードバックし、インライン展開、レイアウト、ホット/コールドの分離を支援します。これは CPU の分岐予測器の静的設定を変更するものではなく、トレーニング外の入力でも最速であることを保証するものではありません。

以下は Clang の計測(instrumentation)フローです。prog.c は直接実行可能である必要があります。引数が必要なプログラムの場合、トレーニングステップで代表的な引数を渡します。

clang -O2 -fprofile-instr-generate prog.c -o prog-train
LLVM_PROFILE_FILE='train-%p.profraw' ./prog-train
llvm-profdata merge -output=prog.profdata train-*.profraw
clang -O2 -fprofile-instr-use=prog.profdata prog.c -o prog

生データは最初にマージされ、フィードバックビルドに使用されます。トレーニングファイル名にプロセス ID を含めることで、並列プロセスが同じファイルに書き込むのを防ぎます。実際のアプリケーションでは、共有ライブラリ、複数のトレーニングデータの重み、ショートカットによる終了でディスクに書き込まれなかった場合、およびソースコードの変更によるマッチングの問題も考慮する必要があります。Clang PGO ユーザーガイド

トレーニング入力と独立した検証入力を別々に配置する

このページの prog.c については、トレーニングフェーズで少なくとも引数なしと引数ありの2つのパスを実行し、このラウンドで生成されたプロファイルをマージします。ディレクトリ内の古いソースコードに起因する train-*.profraw が現在の実験に自動的に混入しないようにしてください。各トレーニングのために独立したディレクトリを保持するか、明確な入力リストを使用します。

フィードバックビルドが完了した後、./prog と ./prog sample を実行して、出力が依然として 3 と 10 であることを確認します。また、llvm-profdata show --all-functions --counts prog.profdata を使用して、関数カウントが実際に収集されたかどうかを確認できます。空でないファイルが存在することは、重要なビジネスパスがトレーニングされたことを意味するわけでも、バージョンの一致に関する警告を無視できることを意味するわけでもありません。

トレーニングセットは、本番環境で重要なパスと入力サイズをカバーする必要があります。起動時の自己検出のみを使用してスループットサービスをトレーニングすると、得られるホットスポットは代表性に欠ける可能性があります。最終ビルドを独立した評価負荷で検証し、コンパイラバージョン、ソースコード、トレーニングデータのソースを記録してください。LTO と PGO は組み合わせて使用できますが、ビルドチェーンとデータの有効性を別々に確認する必要があります。

最適化レベルは戦略の集合体

オプション主な意図一般的な誤解
-O0デバッグ用の低最適化ビルドを保持各行のソースコードが逐字翻訳されるわけではない
-O1 / -O2 / -O3異なるコストと最適化戦略の集合体O3 が O2 よりも高速である保証はない。O1 がサイズを増加させない保証もない
-Os / -Ozコードサイズ削減を優先すべての速度最適化がオフになるわけではない
-march=nativeネイティブアーキテクチャ固有の命令とチューニングを許可成果物は、より古いまたは異なる CPU では実行できない可能性がある
-fno-omit-frame-pointer展開しやすいフレームチェーンの保持を要求具体的な関数やターゲットにおける効果は、実装に依存する

各レベルに含まれるパスは、コンパイラ、バージョン、ターゲットに応じて変化するため、「ベクトル化は O3 のみで有効になる」と固定して記述することはできません。GCC では gcc -Q -O2 --help=optimizers を使用して関連状態を照会できます。GCC 最適化オプション

-fno-semantic-interposition は、シンボルが外部によって置換可能であるという最適化の仮定を変更するものであり、単純な「PLT を生成しない」スイッチではありません。-ffast-math などのオプションは、依存可能な浮動小数点セマンティクスを変更するものであり、言語契約を変更しないパフォーマンススイッチとは混同してはいけません。

正しさの検証と性能の検証を分離する

Sanitizer のインストルメンテーションは、ランタイムの問題を発見するのに適しています。Valgrind と Sanitizers の実践 を参照してください。これらはレイアウト、所要時間、およびランタイムライブラリを変更するため、その測定値をリリースビルドのベンチマークとして直接使用することはできません。

コンパイルオプションを比較する際は、完了した作業、ターゲットプラットフォーム、および入力を固定し、まず動作を確認し、次に時間、コードサイズ、およびビルドコストを確認します。最適化後にエラーが O2 のみで発生する場合、まず UB(未定義動作)、データ競合、および ABI の不一致を確認し、最小限の用例を使用してコンパイラの問題かどうかを判断します。最適化を恒久的に無効化することが、原因をすでに説明したと見なしてはいけません。

キーワード:GCC, Clang, driver, GIMPLE, LLVM IR, RTL, LTO, ThinLTO, PGO, optimization remarks, toolchain