Read OSS

Goコンパイラのパイプライン:ソースコードからSSA、機械語へ

上級

前提知識

  • 第1〜2回:リポジトリ構造とgoコマンドのアーキテクチャ
  • コンパイラ理論の基礎(字句解析、構文解析、AST、SSA形式)
  • レジスタ割り当ての基本的な理解

Goコンパイラのパイプライン:ソースコードからSSA、機械語へ

go コマンドが cmd/compile を呼び出すと、Goのソースコードをアーキテクチャごとのマシンコードへと変換するパイプラインが動き始めます。コンパイラはパース、型チェック、エスケープ解析、SSA構築、最適化、コード生成という一連のフェーズで構成されており、各フェーズは前のフェーズの出力を受け取って処理を進めます。本記事ではこのパイプライン全体を最初から最後まで追いかけます。特に、コードベースの中でも特に洗練された設計といえるSSAパスシステムに焦点を当てて解説します。

コンパイラのエントリポイントとアーキテクチャの振り分け

第1回で見たとおり、コンパイラの main.go はシンプルなディスパッチャです。

src/cmd/compile/main.go#L28-L59

archInits マップは GOARCH の値に応じてアーキテクチャ固有の初期化関数を選択します。各アーキテクチャの Init 関数は、レジスタセット、命令セレクタ、ABI規約などの情報を ssagen.ArchInfo 構造体に設定します。初期化が完了すると、制御は実際のコンパイラドライバである gc.Main に渡されます。

flowchart TD
    A["main()"] --> B["archInits[GOARCH]"]
    B --> C["e.g., amd64.Init(&ssagen.Arch)"]
    C --> D["gc.Main(archInit)"]
    D --> E["Parse & IR Construction"]
    E --> F["Type Checking"]
    F --> G["Inlining & Devirtualization"]
    G --> H["Escape Analysis"]
    H --> I["SSA Construction"]
    I --> J["SSA Optimization Passes"]
    J --> K["Code Generation & Object File"]

gc.Main はコンパイル全体を統括する大きな関数です。

src/cmd/compile/internal/gc/main.go#L64-L97

最初にリンカのコンテキストを初期化し、デバッグ基盤を整え、DWARFの生成設定を行います。細かい点として、環境変数で上書きされていない場合、コンパイラ自身のGCに使うヒープの初期サイズを128MBに調整します。これにより、大きなパッケージのコンパイル時にGCの負荷を抑えることができます。

パースとIR構築

コンパイラは syntax パッケージ内に独自の手書き再帰下降パーサーを持っています。標準ライブラリの go/parser ではなく、コンパイラ専用に最適化された実装です。ソースファイルはこのパーサーによって構文木に変換され、さらに noder パッケージを通じてコンパイラ内部のIRへと変換されます。

src/cmd/compile/internal/gc/main.go#L217

noder.LoadPackage(flag.Args())

この1行の裏には複雑な処理が隠れています。noderは「統合」エクスポート形式を採用しており、依存パッケージのコンパイル済み情報のインポートと、現在のパッケージのソースファイル処理を1つの仕組みで担っています。この統合アプローチは、インポートデータを別途処理していた旧来の方式を置き換えたもので、コードの重複を減らし、一貫性を高めています。

その出力がIRノードの集合であり、以降のすべてのフェーズはこのノードを操作します。

型チェックとIRノードシステム

IRノードシステムは cmd/compile/internal/ir/node.go で定義されています。Goのインターフェースを用いて、式、文、宣言、関数といったGoの構文要素を豊かな型階層で表現しています。

src/cmd/compile/internal/ir/node.go

コンパイラには実は2つの型システムが存在します。ひとつはコンパイラのバックエンド全体で使われている types パッケージ、もうひとつは標準ライブラリの go/types とコードを共有する、より新しく完全な実装である types2 です。types2 チェッカーはnoding時に動作し、その型情報はバックエンドが使う types 表現へと変換されます。

classDiagram
    class Node {
        +Op() Op
        +Type() *types.Type
        +Pos() src.XPos
    }
    class Name {
        +Sym() *types.Sym
        +Class byte
    }
    class CallExpr {
        +Fun Node
        +Args []Node
    }
    class FuncExpr {
        +Body []Node
        +Type() *types.Type
    }
    Node <|-- Name
    Node <|-- CallExpr
    Node <|-- FuncExpr

この二重型システムは現実的な設計判断の結果です。新しい types2 はより質の高いエラーメッセージを提供し、ジェネリクスにもネイティブ対応しています。一方、レガシーな types パッケージはバックエンドの最適化パスに深く組み込まれています。バックエンド全体を書き直す代わりに、2つの型システム間で変換を行うという選択をしたわけです。

エスケープ解析とインライン展開

SSA構築の前に、IRレベルで2つの重要な最適化が行われます。インライン展開とエスケープ解析です。この順序には意味があります。インライン展開を先に行うことで、エスケープ解析により多くの最適化機会が生まれるからです。

src/cmd/compile/internal/gc/main.go#L257-L293

インライン展開は関数呼び出しをその関数本体に置き換えることで、呼び出しのオーバーヘッドを削減し、さらなる最適化を可能にします。devirtualizationとインライン展開を組み合わせたパスは、コールグラフを解析し、関数のサイズや複雑度、PGO(プロファイルガイド最適化)データをもとにインライン展開の判断を下します。

interleaved.DevirtualizeAndInlinePackage(typecheck.Target, profile)

エスケープ解析は、変数のライフタイムが宣言された関数のスコープを超えるかどうかを判定します。スコープを超える場合、変数は「エスケープ」し、ヒープに割り当てられます。超えない場合はスタックに置くことができ、ポインタのインクリメントだけで割り当てが済み、解放コストもかからないため、大幅に高速です。

escape.Funcs(typecheck.Target.Funcs)
flowchart TD
    A["Variable declared in function"] --> B{"Does it escape?"}
    B -->|"Address taken &<br/>stored in heap object"| C["Heap allocated<br/>(runtime.newobject)"]
    B -->|"Address not taken,<br/>or only passed down"| D["Stack allocated<br/>(free on return)"]
    B -->|"Returned to caller"| C
    B -->|"Stored in closure<br/>that escapes"| C

ヒント: go build -gcflags='-m' を実行すると、エスケープ解析の判断結果を確認できます。-m を重ねる(最大 -m=3)と出力が詳しくなります。ヒープ割り当てが性能に直結するコードのチューニングに欠かせないツールです。

SSA構築と最適化パス

SSA(Static Single Assignment)コンパイラは、Goの最適化基盤の中心にあたります。IRレベルのパスが完了すると、各関数は ssagen パッケージによってSSA形式に変換され、続いて一連の最適化パスが実行されます。

パスのパイプラインは compile.go に静的配列として定義されています。

src/cmd/compile/internal/ssa/compile.go#L457-L517

このコードはコードベースの中でも特筆すべき箇所です。このpassesの配列には約60のエントリがあり、それぞれが pass 構造体として定義されています。

src/cmd/compile/internal/ssa/compile.go#L200-L211

type pass struct {
    name     string
    fn       func(*Func)
    required bool
    disabled bool
    time     bool
    mem      bool
    stats    int
    debug    int
    test     int
    dump     map[string]bool
}

required フラグは、lowerregalloc のような必須パスと、nilcheckelimprove のような任意の最適化パスを区別します。最適化が無効化されている場合(-N)、必須パスのみが実行されます。

主なパスを以下にまとめます。

パス 目的
early deadcode 最適化前のデッドコード除去
opt パターンマッチングによるリライトルール
generic cse 共通部分式の除去
nilcheckelim 冗長なnilチェックの除去
prove 範囲解析と境界チェックの除去
dse デッドストア除去
lower アーキテクチャ固有の命令選択
regalloc レジスタ割り当て
schedule 命令スケジューリング

Compile 関数はこれらのパスを順に処理します。

src/cmd/compile/internal/ssa/compile.go#L30-L97

チェックが有効なとき、コンパイラはパス間でブロック内の値の順序をランダム化します。これは、各パスが処理順序に誤って依存していないかを検出するための防御的なテクニックで、実際にバグの発見に役立ってきました。

アーキテクチャ固有のloweringとコード生成

lower パスは、汎用的なSSA演算をアーキテクチャ固有の命令に変換する場所です。lowering前の加算は OpAdd64 と表現されますが、amd64でloweringを行うと OpAMD64ADDQ になります。この変換は .rules ファイルに記述された宣言的なリライトルールによって駆動され、Goコードにコンパイルされます。

パス間の順序制約は明示的にドキュメント化されています。

src/cmd/compile/internal/ssa/compile.go#L527-L569

var passOrder = [...]constraint{
    {"dse", "insert resched checks"},
    {"insert resched checks", "lower"},
    {"generic cse", "prove"},
    {"prove", "generic deadcode"},
    // ...
}

これらの制約はinit時に検証され、passesの配列がすべての順序要件を満たしていることが保証されます。暗黙的な順序に頼って気づかないうちに壊れてしまうのではなく、宣言的な順序制約をランタイムで検証するこのパターンは、非常に参考になる設計です。

flowchart TD
    A["Generic SSA<br/>(OpAdd64, OpLoad, etc.)"] --> B["lower pass"]
    B --> C["Arch-specific SSA<br/>(OpAMD64ADDQ, etc.)"]
    C --> D["addressing modes"]
    D --> E["late lower"]
    E --> F["regalloc"]
    F --> G["schedule"]
    G --> H["Assembly emission"]
    H --> I["Object file (.o)"]

レジスタ割り当てとスケジューリングが完了すると、SSAの値は ssagen パッケージによってアセンブリ命令に変換され、obj ライブラリを通じて出力されます。最終的にはオブジェクトファイルが生成され、リンカがそれを他のパッケージのオブジェクトと結合して実行可能ファイルを作ります。

gc.Main のコンパイルループでは、複数の関数が並行処理されます。

src/cmd/compile/internal/gc/main.go#L315-L362

複数のgoroutineがSSAパイプラインで関数を並行コンパイルし、最終的なオブジェクトデータをディスクに書き出します。

ヒント: go build 実行時に GOSSAFUNC=YourFunctionName を設定すると、各パスの段階でその関数のSSAをHTMLで可視化したファイルが生成されます。コンパイラが自分のコードに何をしているかを理解するうえで、これ以上の手段はないでしょう。

コンパイラからランタイムへ

ここまでで、Goのソースコードがテキストからコンパイラのパイプラインを経てオブジェクトコードになるまでの流れを追いました。しかし、コンパイルされたGoのバイナリにはユーザーのコード以上のものが含まれています。goroutineやメモリ、ガベージコレクションを管理するGoランタイムです。次回は、Goのバイナリが起動したときに何が起こるのかを追います。最初のアセンブリ命令からランタイムの初期化を経て、ユーザーの main.main が呼ばれるまでを見ていきましょう。