Zigのインクリメンタルコンパイルの内部構造
Zigのインクリメンタルコンパイルの内部構造
Zigのインクリメンタルコンパイルの概要
Zigのインクリメンタルコンパイルは、ZIRのキャッシュ、依存グラフの使用、およびインクリメンタルリンキングにより、変更されたコードをミリ秒単位で再構築します。
コンパイラはまず各ソースファイルをZIRに変換して結果をキャッシュし、ファイルのハッシュが変更された場合にのみ再構築を行います。
セマンティック解析中、コンパイラは解析ユニット(layout, type, value, body)とソースコード領域の間の依存関係を追跡するため、変更されたハッシュに依存するユニットのみが無効化されます。
コード生成は関数単位で行われ、生成されたMIRはすぐにリンカに渡されます。
リンカは出力バイナリをメモリマップし、ノードツリー(MappedFile)を使用してコードセクションの挿入やサイズ変更を行い、ノードがdirty(変更あり)としてマークされている場合にのみfix-upを適用します。
コード生成後、コンパイラはリファレンスグラフを走査して到達可能なシンボルのみを保持し、リンカのflushを呼び出してバイナリを確定させます。
Tracyプロファイリングによると、時間の大部分はリファレンスグラフの走査に費やされており、さらなる最適化の機会があることが示されています。
現在インクリメンタルコンパイルを試すには、x86_64-linuxをターゲットとした最近のmasterビルドで zig build --watch -fincremental を実行してください(またはステップごとのオプションを使用してください)。
この機能はまだ安定していません。デバッグビルドで最も効果的に動作し、手動でのキャッシュクリアが必要であり、今後の作業では安定化、他のターゲットへの対応、およびフルリファレンスグラフの再計算の回避に焦点が当てられます。
ファイルレベルのキャッシュと並列性
コンパイラは各ソースファイルのZIRをディスクにキャッシュし、パースとAstGenは純粋関数であるため、ファイルを並列に処理します。
ファイルを読み込み、ASTにパースし、そのASTをZIRに変換するプロセスには、共有状態や外部状態が含まれません。
これらのステップは高速です。Zigコンパイラの src/ ディレクトリ全体に対するパースとAstGenは、シングルスレッドで約920msかかります。
ZIRは writev/readv システムコールを使用してディスクへの書き込みと読み込みができるため、キャッシュの実装は極めて単純です。
純粋関数の性質により、この作業は「embarrassingly parallel(極めて並列化が容易)」です。スレッドプールが新しく発見された各ソースファイルを独立して処理でき、既知のパスのハッシュセットのみをミューテックスで保護すれば済みます。
ZIRをキャッシュし、ファイルのソースハッシュが変更されたときにのみ再構築する仕組みは、長年デフォルトで有効になっており、このパイプラインの部分はほとんどの場合でほぼ瞬時に完了します。
依存グラフによるセマンティック解析
インクリメンタルな再コンパイルは、変更を検出するためにソースコードのハッシュを追跡する、解析ユニット(layout, type, value, body)のきめ細かなグラフに依存しています。
セマンティック解析中、コンパイラは各ユニットについて、それが依存する他のユニットのセットを埋めます。
例えば、グローバル変数をロードする関数のbodyを解析すると、その変数の型への依存が追加されます。もし変数が comptime で既知であれば、その値への依存も追加されます。
ユニットは、それらを定義するソースコード領域にも依存します。コンパイラは各領域のハッシュをZIRに保存しているため、ソースの変更はハッシュを変更し、依存するユニットを「outdated(古い)」としてマークします。
ソースファイルが編集されると、コンパイラは名前によって古いZIRと新しいZIRをマッピングし、ハッシュを比較し、ハッシュが変更されたユニットのみを再解析し、それらの値に依存するユニットに伝播させます。
実行時の関数のbodyへの依存は存在し得ないため、関数bodyのユニットはグラフ内で出力エッジのみを持ちます。
この設計は、グラフを扱いやすくインクリメンタルにするために、依存の種類を意図的に制限しています。
コード生成とMIR
コード生成は関数単位で行われるため、AIRやMIRのキャッシュは必要ありません。出力は直接リンカに送られます。 このステージでは、セマンティック解析からのAIRを、マシン命令に密接に対応するMIRに変換します。 AIRとMIRは、インクリメンタルコンパイルで使用されるものと同じ粒度である個々の関数の粒度で存在するため、それらをキャッシュするメリットはありません。使用後に破棄されます。 コード生成は極限まで並列化可能です。各関数のAIRはキューに入れられ、任意の数のスレッドで処理できます。メモリの無制限な増大を防ぐためのキューサイズガードのみが必要です。
MappedFileによるインクリメンタルリンキング
リンカは出力バイナリをメモリマップし、ノードツリー(MappedFile)を使用してコードセクションの挿入やサイズ変更を行い、ノードがdirtyとしてマークされている場合にのみfix-upを適用します。 インクリメンタルリンキングがない場合、リンカはすべてのコードが判明した後にのみアドレスを割り当て、リロケーションを適用します。 MappedFileを使用すると、コード生成が関数のマシンコードを生成した後、リンカはマッピングされたファイル内にそのコードを保持するのに十分な大きさのノードを作成またはサイズ変更し、コードをコピーし、ノードにdirtyフラグを設定します。 親ノードにスペースがない場合、MappedFileは他のノードを移動してスペースを作り、必要に応じてdirtyフラグを伝播させます。 リンカのスレッドがアイドル状態になったとき、またはコンパイルの終了時に、すべてのdirtyなノードを処理します。仮想アドレスの割り当て、セクション/プログラムヘッダーの更新、シンボルテーブルエントリの更新、およびリロケーションの再適用を行います。 ノードは(ArrayListのように)指数関数的に増えるため、実際には移動は稀であり、平均コストは低く抑えられます。 このアプローチにより、オブジェクトファイル全体をdiff(比較)する必要がなくなります。コンパイラとリンカの統合により、リンカは何が変更されたかを正確に知ることができます。
Flushとリファレンス解決
コード生成後、コンパイラはリファレンスグラフを走査して到達可能なシンボルのみを保持し、リンカのflushを呼び出してバイナリを確定させます。 flushステップでは、前回のビルド以降に到達不可能になった宣言を無視し、どのZig宣言が実際に参照されているかを決定します。 その後、リンカにすべてのエクスポートされたグローバルシンボルを通知し、リンカが適切なシンボルテーブルエントリを追加できるようにします。 最後に、リンカのflush関数が残りの作業(.dynamicセクションとELFヘッダーのエントリフィールドの書き込み)を実行し、アップデートあたりの作業をO(1)にすることを目指します。 まだdirtyとしてマークされているMappedFileノードは、このステップで処理されます。
アップデートのTracyプロファイリング
Tracyは、時間の大部分がリファレンスグラフの走査に費やされていることを示しており、さらなる最適化の機会があることを示唆しています。
37msかかったサンプルインクリメンタルアップデートでは、最初の6msがファイルごとのZIR更新、セマンティック解析、コード生成、およびリンキングに費やされました。
残りの約31msは、到達可能な宣言を決定するために完全なリファレンスグラフを走査する resolveReferencesInner によって消費されました。
グラフ自体は変更されていなかったにもかかわらず、コンパイラはアップデートのたびにそれを再計算していました。
著者は、これは明確な最適化対象であると述べています。つまり、グラフが変更されていないときは再計算を避け、影響を受ける部分のみを更新すること(動的な単一始点最短経路問題)です。
現在のインクリメンタルコンパイルの使用方法
インクリメンタルコンパイルを試すには、x86_64-linuxをターゲットとした最近のmasterビルドで zig build --watch -fincremental (またはステップごとのオプション)を実行してください。
--watch フラグは、ファイルシステムの変更時にビルドシステムを再構築させます。-fincremental は、再構築にインクリメンタルコンパイルを使用するように指示します。
既存のディスクキャッシュはインクリメンタルコンパイルと互換性がないため、フラグを有効にした後の最初の実行ではすべてが再構築されます。
その後、ソースファイルを編集して保存すると、数十ミリ秒で完了する再構築がトリガーされます。
ステップごとのアプローチについては、build.zig で -Dincremental オプションを公開し、オプションが真のときに exe.incremental = true を設定し、-fincremental の代わりに -Dincremental を使用してください。
著者は、このワークフローは実行時間の短いプログラムに最適であると述べています。実行時間の長いグラフィカルアプリケーションの場合、現在のビルドシステムは、再構築をトリガーする前に前のプロセスが終了するのを待ちます。
現在の制限と今後の作業
インクリメンタルコンパイルはまだ安定しておらず、デバッグビルドで最もよく動作し、手動でのキャッシュクリアが必要です。今後の作業には、機能の安定化、他のターゲットのサポート、および完全なリファレンスグラフの再計算の回避が含まれます。 著者は、インクリメンタルコンパイルはまだ安定しておらず、誤検知のコンパイルエラーや誤コンパイルを含む可能性があることを明示しています。 この記事は、デバッグビルドにおけるピクセルエディタ(Fizzy)の高速な再構築を示していますが、この機能がリリースビルドで動作するかどうかについては触れていません。 あるコメント投稿者は、ZigコンパイラがCをコンパイルできることを踏まえ、この機能がCコードでも動作するかどうかを尋ねていますが、この記事はその質問に答えていません。 別のコメント投稿者は、なぜリンカがデバッグビルドにおいて、多くの小さな共有ライブラリを使用するのではなく、単一の巨大なバイナリを生成するのかと疑問を呈していますが、この記事では代替設計については議論していません。 著者は、ビルドシステムの将来の拡張として、実行中のプログラムの終了を待たずに再構築をトリガーするなど、他のワークフローをサポートする計画があると言及しています。 継続中の作業には、結果をキャッシュするか、インクリメンタルに更新することで、コストのかかるリファレンスグラフの走査を排除することも含まれています。
"これはリリースビルドでも動作しますか、それとも今はデバッグビルドだけですか?" – @remywang
"ZigコンパイラはCをコンパイルできるので、Cでも動作しますか?" – @hoppp
"この設計について完全には理解できていないことがあります。なぜ彼らはデバッグビルドにおいて、すべてのコードを含む巨大なバイナリを構築することに固執しているのですか?" – @thefaux