Read OSS

コンパイルのオーケストレーション、キャッシュ、インクリメンタルコンパイル

上級

前提知識

  • 本シリーズの記事 1〜4
  • キャッシュ戦略と依存関係グラフの理解
  • 並行プログラミング(スレッドプール、ミューテックス、アトミック操作)の基礎知識

コンパイルのオーケストレーション、キャッシュ、インクリメンタルコンパイル

これまでの4本の記事では、Zig コンパイラの各フェーズ(パース、AstGen、Sema、コードジェン、リンク)を順に追ってきました。しかし、これら全体を動かしているのは誰でしょうか?ソースファイルが変更されたとき、コンパイラはどの関数を再解析すればよいかをどう判断するのでしょうか?キャッシュはどのように機能しているのでしょうか?そして、ブートストラップはこれらすべてを活用して、WebAssembly の blob から完全なコンパイラをどのように構築するのでしょうか?

この最終記事では、オーケストレーションレイヤーを取り上げます。具体的には Compilation.update() ループ、ジョブキューシステム、3つのキャッシュモード、InternPool の依存関係追跡、そして PerThread スレッディングモデルです。

Compilation.update() ループ

すべては Compilation.update() から始まります。この関数は、buildOutputType()Compilation を構築した後に呼び出すものであり、長時間起動し続けるコンパイルサーバーが編集サイクルごとに呼び出すものでもあります。

関数の処理フローはキャッシュモードによって異なりますが、全体的な構造は次のとおりです。

flowchart TD
    START["update()"] --> CLEAR["Clear misc failures"]
    CLEAR --> CACHE{"Cache mode?"}
    CACHE -->|"whole"| CHECK["Check cache manifest"]
    CHECK -->|"hit"| DONE["Return (cache hit)"]
    CHECK -->|"miss"| WORK
    CACHE -->|"none"| WORK["Create temp directory"]
    CACHE -->|"incremental"| WORK2["Detect changed files"]
    WORK --> QUEUE["Queue AstGen, C compilation,\nSema, codegen, linking jobs"]
    WORK2 --> QUEUE
    QUEUE --> PROCESS["Process work queues\n(thread pool)"]
    PROCESS --> LINK["Wait for link tasks"]
    LINK --> FINALIZE["Finalize output"]
    FINALIZE --> END["Write cache manifest"]

2884 行目で、関数は comp.cache_use によって分岐します。

  • whole: 最初にキャッシュマニフェスト全体をチェックします。すべての入力が一致すれば、コンパイル全体をスキップします。ワンショットビルドのデフォルトモードです。
  • none: キャッシュを一切使用しません。特殊なケースに使われます。
  • incremental: 宣言ごとに変更を追跡します。変更されたファイルが検出されると、対象を絞った再解析が行われます。

whole モードでは、2898〜2960 行目のキャッシュチェックが全入力(ソースファイル、フラグ、ターゲットトリプルなど)からマニフェストを計算し、キャッシュされた出力が存在するかどうかを確認します。これにより、ファイルシステムへの1回のチェックだけでコンパイル全体をショートサーキットできます。

ステージ制ワークキューとジョブ優先度

Compilation はジョブステージ数に基づいてコンパイル時にサイズが決まるワークキューの配列を保持しています。

work_queues: [len: { ... }]std.Deque(Job),

119〜127 行目の長さ計算では、Job.Tag の全バリアントを走査して最大ステージ番号を求めています。Job ユニオンは処理の種類を定義しています。

const Job = union(enum) {
    codegen_func: struct { func: InternPool.Index, air: Air },
    link_nav: InternPool.Nav.Index,
    link_type: InternPool.Index,
    analyze_comptime_unit: InternPool.AnalUnit,
    analyze_func: InternPool.Index,
    analyze_mod: *Package.Module,
    resolve_type_fully: InternPool.Index,
    windows_import_lib: usize,
};

stage() 関数は優先度を割り当てます。

fn stage(tag: Tag) usize {
    return switch (tag) {
        .resolve_type_fully, .analyze_func, .codegen_func => 0,
        else => 1,
    };
}

ステージ 0 のジョブ(型解決、関数解析、codegen)は、ステージ 1 のジョブ(モジュール解析、link_nav など)よりも先に処理されます。この設計により、Sema が関数の解析を完了した直後に codegen スレッドが仕事を開始できるため、並列性が最大化されます。

flowchart LR
    subgraph "Stage 0 (High Priority)"
        RF["resolve_type_fully"]
        AF["analyze_func"]
        CF["codegen_func"]
    end
    subgraph "Stage 1 (Normal Priority)"
        AM["analyze_mod"]
        LN["link_nav"]
        LT["link_type"]
        ACU["analyze_comptime_unit"]
    end
    AF -->|"produces"| CF
    CF -->|"produces"| LN

processOneJob 関数は各ジョブタイプをディスパッチします。第4回で見たとおり、codegen_func ジョブは SharedMir を生成し、スレッドを立ち上げるかインラインで codegen を実行することで処理されます。

Tip: 1012 行目のコンパイル時アサーション(assert(stage(.resolve_type_fully) <= stage(.codegen_func)))は、型解決が必ず codegen より先に完了することを保証します。これは単なる実行時の期待ではなく、構造上の保証です。

キャッシュモード:none、whole、incremental

3つのキャッシュモードは、根本的に異なる戦略を表しています。

モード 使用場面 粒度 無効化
none 特殊なケース すべてを再ビルド
whole デフォルト(ワンショット) コンパイル全体 任意の入力変更で無効化
incremental ウォッチモード/サーバー 宣言単位 依存関係追跡による無効化

Whole キャッシュは最もシンプルで一般的な方式です。すべての入力(ソースファイル、コンパイラフラグ、ターゲット)をハッシュ化してキャッシュマニフェストを作成し、マニフェストが一致すればキャッシュ済みのバイナリをそのまま使用します。「何も変わっていなければ何もしない」という高速パスを実現しています。

インクリメンタルキャッシュはより高度な仕組みです。AnalUnit の粒度——個々の関数・comptime ブロック・型解決の単位——で依存関係を追跡します。ソースファイルが変更された場合、実際に変更された宣言(ソースハッシュの比較で検出)だけが再解析され、その依存先のみが無効化されます。

インクリメンタルモードは InternPool の依存関係追跡基盤を必要とします。次のセクションで詳しく見ていきましょう。

InternPool における依存関係追跡

InternPool は 34〜65 行目に8つの依存関係ハッシュマップを持っています。

src_hash_deps: AutoArrayHashMap(TrackedInst.Index, DepEntry.Index),
nav_val_deps: AutoArrayHashMap(Nav.Index, DepEntry.Index),
nav_ty_deps: AutoArrayHashMap(Nav.Index, DepEntry.Index),
interned_deps: AutoArrayHashMap(Index, DepEntry.Index),
zon_file_deps: AutoArrayHashMap(FileIndex, DepEntry.Index),
embed_file_deps: AutoArrayHashMap(Zcu.EmbedFile.Index, DepEntry.Index),
namespace_deps: AutoArrayHashMap(TrackedInst.Index, DepEntry.Index),
namespace_name_deps: AutoArrayHashMap(NamespaceNameKey, DepEntry.Index),
graph TD
    subgraph "Dependency Sources (Dependees)"
        SH["src_hash_deps\n(ZIR instruction hashes)"]
        NV["nav_val_deps\n(Nav values)"]
        NT["nav_ty_deps\n(Nav types)"]
        ID["interned_deps\n(runtime funcs, containers)"]
        NS["namespace_deps\n(all names in scope)"]
        NN["namespace_name_deps\n(specific name existence)"]
    end
    subgraph "Dependency Consumers (Dependers)"
        AU["AnalUnit\n(func, comptime, nav_val, etc.)"]
    end
    SH -->|"invalidates"| AU
    NV -->|"invalidates"| AU
    NT -->|"invalidates"| AU
    ID -->|"invalidates"| AU
    NS -->|"invalidates"| AU
    NN -->|"invalidates"| AU

各ハッシュマップは、被依存側(変更される可能性のあるもの)から DepEntry 値の連結リストへのマッピングです。DepEntry には、どの AnalUnit がそれに依存しているかと、2つの連結リスト(「この被依存側のすべての依存関係」と「この依存元のすべての依存関係」)内の次のエントリへのポインタが記録されています。

TrackedInst は、更新をまたいで ZIR 命令への安定した参照を提供します。インクリメンタル更新では ZIR 命令のインデックスがずれることがあるため、TrackedInst は古いインデックスを新しいものにマッピングします。マッピングに失敗した場合(命令が削除された場合)、MaybeLost ラッパーはセンチネル値 .lost を使用します。

無効化の流れは次のとおりです。

  1. ソースファイルの変更 → AstGen が新しい ZIR を生成
  2. TrackedInst のソースハッシュを新旧で比較
  3. 変更されたハッシュが src_hash_deps のルックアップをトリガー
  4. 依存する各 AnalUnit が再解析対象としてマーク
  5. 再解析によって Nav の値や型が変化した場合、nav_val_depsnav_ty_deps を通じてさらなるカスケード無効化が発生

76 行目first_dependency マップは逆方向の参照を提供します。AnalUnit を指定してそのすべての依存関係を取得できるため、ユニットが再解析される際に依存関係を削除できます。

PerThread スレッディングモデル

ZcuInternPool へのスレッドセーフなアクセスは PerThread を通じて管理されます。

zcu: *Zcu,
tid: Id,  // dense, per-thread unique index

pub fn activate(zcu: *Zcu, tid: Id) Zcu.PerThread {
    zcu.intern_pool.activate();
    return .{ .zcu = zcu, .tid = tid };
}

pub fn deactivate(pt: Zcu.PerThread) void {
    pt.zcu.intern_pool.deactivate();
}

activate/deactivate パターンは軽量なスコープガードです。activate() は InternPool のアクティブスレッド数をインクリメントし、deactivate() はデクリメントします。これは何もロックしません。InternPool のシャーディング設計により、異なるシャード(tid で選択される)を使用する限り、複数のスレッドが競合なしに同時に値をインターンできます。

sequenceDiagram
    participant T1 as Thread 1
    participant T2 as Thread 2
    participant IP as InternPool
    participant S1 as Shard[tid=0]
    participant S2 as Shard[tid=1]

    T1->>IP: activate(tid=0)
    T2->>IP: activate(tid=1)
    T1->>S1: intern type (no contention)
    T2->>S2: intern type (no contention)
    T1->>IP: deactivate()
    T2->>IP: deactivate()

Compilation 自体には、エラーリスト、失敗したオブジェクトテーブル、ワークキューなどの共有ミュータブル状態を保護するための mutex があります。シングルスレッドビルドでは、このミューテックスは何もしない構造体になります。

このスレッディングモデルは、Sema が1つのスレッドで動作し、codegen が別のスレッドで動作するという一般的なケースを想定して設計されています。InternPool のスレッドごとの Local ストレージにより、Sema は codegen の読み取りをブロックせずに新しい型をインターンできます。依存関係追跡データは、更新間のシングルスレッドの無効化フェーズ中にのみ変更されます。

Tip: IdBacking 型は u7 であり、最大 128 スレッドをサポートします。tidtid_shift_* フィールドを通じて InternPool インデックスの上位ビットに埋め込まれているため、すべてのインデックスはそれを作成したスレッドを暗黙的にエンコードしています。

マルチステージブートストラップ

ここで全体をつなげて考えてみましょう。第1回で紹介したように、ブートストラップは3つのステージを使用します。コンパイルパイプライン全体を理解した今、この設計の優雅さをより深く味わえるはずです。

bootstrap.c プログラムがこの連鎖を調整します。

ステージ 1 — zig1(bootstrap 環境):

// 1. Build wasm2c from C
cc -o zig-wasm2c stage1/wasm2c.c
// 2. Convert zig1.wasm to C
./zig-wasm2c stage1/zig1.wasm zig1.c
// 3. Compile zig1 from C
cc -o zig1 zig1.c stage1/wasi.c

続いて bootstrap.c は 145 行目pub const dev = .core; を含む config.zig を書き出し、zig2 への準備を整えます。

ステージ 2 — zig2(core 環境):

// 4. zig1 compiles the compiler to C
./zig1 lib build-exe -ofmt=c -target host ...
// 5. System C compiler builds zig2
cc -o zig2 zig2.c compiler_rt.c

ステージ 3 — zig3(full 環境): zig2 は通常の Zig コンパイラとして(dev = .core ですべてのバックエンドとリンカーをサポートしながら)動作し、dev = .full で最終的なステージ 3 コンパイラをビルドします。

src/dev.zig の機能ゲーティングシステムが各ステージを成立させています。

sequenceDiagram
    participant B as bootstrap (zig1)
    participant C as core (zig2)
    participant F as full (zig3)

    Note over B: 6 features enabled
    Note over B: C backend only
    Note over B: No networking, no fmt
    B->>C: Compile with -ofmt=c

    Note over C: ~35 features enabled
    Note over C: All backends + linkers
    Note over C: No fmt, fetch, init
    C->>F: Compile with all features

    Note over F: All features enabled
    Note over F: Complete compiler

58〜67 行目bootstrap 環境がサポートするのは、build_exe_commandbuild_obj_commandast_gensemac_backendc_linker のみです。それ以外——x86_64 codegen、ELF リンク、LLVM、インクリメンタルコンパイル——はすべてコンパイル時にデッドコード除去されます。結果として生成される zig1.c は、どの C コンパイラでも素早くコンパイルできるほど小さくなります。

68〜130 行目core 環境はほとんどのコンパイル機能を有効にしますが、ネットワーク機能(network_listen)、fmt コマンド、その他の非必須機能は意図的に除外しています。126 行目のコメントにその理由が記されています。「zig2.c にネットワーク機能を引き込むのを避ける。ブートストラップ中に満たすのが面倒なリンカーシンボルへの依存が追加されるため。」

この3ステージ設計は、セルフホスティングの根本的な鶏と卵の問題を解決しています。Zig コンパイラをコンパイルするには Zig コンパイラが必要だからです。WebAssembly をポータブルな stage0 として使用し、普遍的なブートストラップ言語である C を経由して変換し、各ステージで段階的に機能を有効にすることで、Zig は C コンパイラさえあればどのプラットフォームでもソースからビルドできる完全セルフホスティングコンパイラを実現しています。

まとめ

この5本の記事を通じて、リポジトリ構造から最終的なバイナリ出力まで、Zig コンパイラの全体像を描いてきました。このアーキテクチャには、いくつかの特徴的な設計原則が反映されています。

  1. フラットなデータ構造の徹底。 AST、ZIR、AIR、InternPool はすべてポインタではなく整数インデックスを使用しています。これにより、優れたキャッシュ局所性と容易なシリアライズが実現されます。

  2. comptime 駆動の設定。 dev.zig の機能ゲーティング、importBackend() の comptime ディスパッチ、AnyMir ユニオンはいずれも Zig の comptime 機能を活用し、コンパイル時にデッドコードを除去しています。

  3. 最初からインクリメンタルを前提とした設計。 AnalUnit / Nav / TrackedInst / 依存関係追跡基盤は後付けではなく、コアデータ構造に深く組み込まれています。

  4. フロントエンドのライブラリとしての共有。 トークナイザー、パーサー、AstGen を lib/std/zig/ に配置することで、コンパイラ、フォーマッター、言語サーバー間で言語構文の唯一の情報源を共有しています。

  5. 段階的な機能拡張によるセルフホスティング。 dev.zig の機能ゲーティングを伴う3ステージブートストラップは、セルフホスティングのブートストラップ問題に対するエレガントな解決策です。

Zig コンパイラは 0.16.0-dev を目標とした進行中のプロジェクトであり、これらの構造はこれからも進化し続けます。しかし、IR チェーン、InternPool、ステージ制ジョブシステム、ブートストラップ戦略という根本的なアーキテクチャは、原則に基づきながらも実用的な基盤を形成しています。これを理解することで、コントリビューション、Zig を取り巻くツールの構築、あるいは現代のシステムプログラミングにおける最も野心的なコンパイラプロジェクトのひとつをただ味わうことへの、確かな出発点を得られるはずです。