プログラミング
Zigのインクリメンタルコンパイルの内部
Zig's Incremental Compilation Internals (mlugg.co.uk)
要約
この記事は、Zigコンパイラにおけるインクリメンタルコンパイル機能の内部実装について、Zigコアチームのメンバーが解説したものです。この機能により、変更された関数や宣言のみを再コンパイルし、結果のバイトコードを直接出力バイナリにパッチすることで、ビルド時間を劇的に短縮します。ソースファイルの解析からZIRへの変換、そしてより複雑なセマンティック解析における「分析ユニット」の概念と依存関係のモデリングまで、その仕組みを詳細に説明しています。
全文翻訳
Zigのインクリメンタルコンパイルの内部
Zigコアチームのメンバーとして、私が関わってきた最も影響力のあるプロジェクトの1つが、Zigコンパイラへのインクリメンタルコンパイル機能の実装です。この機能により、コンパイラはプロジェクトが最後にビルドされてから変更された個々の関数や宣言を検出し、そのコードのみを再コンパイルし、結果のバイトを直接出力バイナリにパッチすることで、リビルドを非常に高速化します。
Zigプロジェクトはこの機能に向けて長らく取り組んでおり、過去数回のリリースサイクルで、ついに概念実証レベルの機能から、実際のプロジェクトで利用可能で、Zigコアチームのほとんどが日常的に使用するレベルのものへと進化しました。
今日、Zigのインクリメンタルコンパイルを使用すると、実際の複雑なアプリケーションの変更をミリ秒単位で行うことができます。
しかし、私の言葉だけを鵜呑みにしないでください!ここでは、私がFizzyというピクセルエディタアプリケーションの変更を迅速に行い、テストしている様子を示す簡単なビデオ(音声なし)を紹介します。最初のビルドは約5秒かかりますが、変更を加えるたびに、リビルドは50〜70ミリ秒で完了します。
このデモのために、FizzyをZigのマスターブランチにアップグレードする必要がありました。これは、Zig 0.16.0にはインクリメンタルコンパイルのサポートが含まれていますが、その後実装されたいくつかの重要なリンカー機能が欠けているためです。これは、Zigのタグ付きリリースにこだわりたい場合、0.17.0がリリースされるまで試すことができない可能性が高いということです。申し訳ありません!
Fizzyへのランダムな変更に対する高速なインクリメンタルリビルド
すでに納得していて、使い方が知りたいだけなら、素晴らしいです!この記事の最後のセクションに進んでください。しかし、おそらく、これがほとんどのプロジェクトに適用可能であることに懐疑的であるか、私のように、このようなものがどのように機能するかを学ぶことを楽しんでいるかのどちらかでしょう。皆さんのために、詳細を見ていきましょう!
ソースファイルの処理
Zigコンパイラのパイプラインはいくつかの部分に分割でき、それらを順番に見ていきます。最初の部分は、ソースファイル全体という粒度で動作し、基本的に以下のプロセスをループで実行します。
ディスクからソースファイルを読み込む
そのファイルをAST(抽象構文木)にパースする
そのASTを「AstGen」というパスを使用して「ZIR」という形式に変換する
興味のある方は、ZIR(Zig Intermediate Representation)は型なしSSA形式のIRですが、それが何を意味するのかわからなくても心配しないでください。ここではあまり重要ではありません。重要なのは、ソースファイル全体を別の形式に変換しているということです。
AstGenが実行される間、ソースファイル内のすべてのZigインポート(@import("foo.zig"))について学習するため、このプロセス全体をインポートされたすべてのファイルで繰り返すことができます。したがって、このプロセスをループで実行することで、最終的にコンパイル内のすべてのZigソースファイルを発見し、それらをすべてZIRに変換します。
Zigコンパイラ内のファイル処理パイプライン
このパイプラインの部分は、実際にはいくつかの有用なプロパティを持っています。
各ファイルに対して実行される処理は、共有または外部の状態を含まない、そのファイルのコンテンツの純粋な関数です。
パースとAstGenは、それ自体で非常に高速です。私のラップトップでは、これら両方をZigコンパイラのsrc/ディレクトリ全体で(並列化なしで)実行すると、約920ミリ秒かかります。
Zigのデータ指向設計パターンの使用のおかげで、ZIRはディスクへの書き込みと読み取りが非常に簡単で、1回のwritev/readvシステムコールで完了します。つまり、「シリアライゼーション」ステップはありません。
これらのプロパティには、2つの良い結果があります。
まず、ソースファイルごとに1つの「タスク」を仮定すると、このプロセス全体は非常に並列化しやすいです。つまり、インポートから新しいソースファイルを発見するたびにタスクをキューに入れることで、スレッドプールで簡単に実行できます。唯一の共有状態(ミューテックスで保護します)は、既に見たファイルパスを追跡するハッシュセットです。
第二に、そしておそらくさらに重要なことに、これらのプロパティにより、このパイプライン部分のインクリメンタルコンパイルを実装することが非常に容易になります。必要なのは、各ソースファイルが生成したZIRをディスクにキャッシュし、ファイルが変更されたことを検出した場合にのみ再ビルドすることです。
これらの両方の最適化は、長年Zigでデフォルトで有効になっており、実証済みで、ほとんどの場合、パイプラインのこの部分をほぼ瞬時にします。Zigを使用している場合、標準エラー出力の進捗状況表示でその速さを確認できます。「AST Lowering」と表示されるとき、パイプラインのこの部分が実行されています。多くのZigユーザーは、コンパイラがZig標準ライブラリとcompiler_rt全体に対してこの作業を行う必要がある最初の実行時にのみ、これが実行されていることに気づく程度だと思います。
さて、この部分を高速化しました!それは素晴らしいですが、悪いニュースは、これが簡単な部分だったということです。多くのコンパイラは既にこの種のキャッシングを実行できます。ここから先は、より複雑になります。
セマンティック解析
パイプラインの次の部分は、おそらく最も重要です。セマンティック解析です。これには、型チェックとコンパイル時評価の両方が含まれます。
セマンティック解析のジョブは、基本的に、以前に生成したZIRを「解釈」し、コンパイルエラー(型エラーなど)を発生させながら、実行時関数については、パイプラインの後半に送信できる別の内部表現を構築することです。
先に進む前に、用語の簡単な明確化を行います。「コンテナレベル宣言」は、他の言語で「トップレベル宣言」と呼ばれるものに相当するZigの用語です。Zigでは、コンテナレベル宣言は構文的にトップレベルにある必要はないため、この用語は不正確ですが、概念は同じです。「コンテナレベル宣言」と言う場合、私は基本的に「関数、グローバル定数、またはグローバル変数」を意味します。
セマンティック解析は、インクリメンタルに処理するのが最も難しいコンパイラ部分です。おそらく驚くことではないでしょうが、ここで言語設計が非常に重要になります。ほとんどの現代言語は、私たちがやっているのと同様のインクリメンタルコンパイルをサポートできると確信していますが、特定の設計上の決定はそれをはるかに困難にする可能性があります。Zigは、高速なインクリメンタルコンパイルをサポートしやすくするために、長年にわたって(時には物議を醸す方法で)設計が調整されてきました。
ここでのゲームの名前は、コンパイルを多くの小さな部分に分割することです。これらの部分はほとんど互いに独立して分析でき、そして最も重要なことに、存在する依存関係は依存関係グラフで簡単にモデル化できることです。
Zigコンパイラでは、これらの部分を「分析ユニット」、または単に「ユニット」と呼びます。ここでは、物事をわずかに単純化して、Zigコンパイラには4つの異なる種類の分析ユニットがあると説明します。
構造体または共用体型のレイアウト(サイズ、アライメントなど)。
コンテナレベル宣言の型。
コンテナレベルのconst宣言の値。
実行時関数の本体。
特定のユニットのセマンティック解析中に、このユニットが依存する他のユニットのセットを生成します。基本的な例を見てみましょう。
var global_0: u32 = 123;
const global_1: u32 = 456;
pub fn foo(cond: bool) u32 {
if (cond) {
return global_0;
} else {
return global_1;
}
}
関数fooの本体を解析するときに何が起こるかを見てみましょう。
引数condがコンパイル時不明であるため、ifのすべてのブランチをセマンティック解析します。
global_0へのポインタを取得し、そこからのロードを準備します。
依存関係を追加します:global_0の型。
実行時にglobal_0をロードします。varであるため、コンパイル時不明の値を持たないためです。
global_1へのポインタを取得し、そこからのロードを準備します。
依存関係を追加します:global_1の型。
コンパイル時にglobal_1をロードします。constであり、コンパイル時不明の値を持つためです。
依存関係を追加します:global_1の値。
したがって、この関数本体はglobal_0とglobal_1の型、そしてglobal_1の値(コンパイル時不明であるため)に依存することになります。これにより、コンパイラは、global_0またはglobal_1の型が変更された場合、またはglobal_1のコンパイル時不明の値が変更された場合に、関数を再解析する必要があることを知ります。
実行時関数の本体への依存関係は(少なくとも私が示している単純化されたビューでは)不可能です。