Turbopackの内部:より少ないビルドでより高速に構築する
編集、保存、リフレッシュ。待機…待機…待機…
コードのコンパイルは通常待機を意味しますが、Turbopackはキャッシュとインクリメンタル計算により反復ループを高速化します。
すべての現代的なバンドラーがインクリメンタルアプローチを使用しているわけではありませんが、それには十分な理由があります。インクリメンタル計算は大幅な複雑性とバグの機会を導入する可能性があります。キャッシュには追加の追跡とデータのコピーが必要で、CPUとメモリの両方のオーバーヘッドが追加されます。適切に適用されない場合、キャッシュは実際にパフォーマンスを悪化させる可能性があります。
これらすべてにもかかわらず、インクリメンタルアーキテクチャがTurbopackの成功に不可欠であることを知っていたため、これらの課題に取り組みました。
Turbopackは、世界最大級のWebアプリケーションの構築に使用されているフレームワークであるNext.jsの新しいデフォルトバンドラーです。最大かつ最も困難なワークロードでも、インスタントビルドと高速なタイプ時のインタラクティブなReact Fast Refresh体験を可能にする必要がありました。私たちのインクリメンタルアーキテクチャは、これを実現するための中核です。
キャッシュなしのソリューションは、アプリケーションのサイズが増加するにつれて遅くなります。インクリメンタル計算は、小さな反復的変更のサイズにのみ依存するため、大規模なアプリケーションにスケールできます。
Turbopackのアーキテクチャは、キャッシュを念頭に置いてゼロから構築されました。そのインクリメンタル設計は10年以上の研究に基づいています。webpackでのキャッシュ実装の課題からの直接的な経験を基に構築し、Salsa(Rust-AnalyzerとRuffを支える)、Parcel、Rustコンパイラのクエリシステム、Adaptonなど多くのものからインスピレーションを得ました。
Turbopackは、内部関数がどのように呼び出され、どの値に依存するかを自動的に追跡することで、きめ細かいキャッシュを実現します。何かが変更されたとき、最小限の作業で結果を再計算する方法を知っています。
背景:手動インクリメンタル計算
多くのビルドシステムには、ビルドルールを評価する際に手動で設定する必要がある明示的な依存関係グラフが含まれています。依存関係グラフを明示的に宣言することは理論的には最適な結果をもたらす可能性がありますが、実際にはエラーの余地を残します。明示的な依存関係グラフを指定することの困難さは、通常キャッシュが粗いファイルレベルの粒度で行われることを意味します。
この粒度にはいくつかの利点があります:インクリメンタル結果が少ないということは、キャッシュするデータが少ないということであり、ディスク容量やメモリが限られている場合には価値があるかもしれません。
そのようなアーキテクチャの例はGNU Makeで、出力ターゲットと前提条件が手動で設定され、ファイルとして表現されます。GNU Makeのようなシステムは、粗い粒度のためキャッシュの機会を逃します:コンパイラ内の内部データ構造を理解せず、キャッシュできません。
関数レベルのきめ細かい自動インクリメンタル計算
Turbopackでは、入力ファイルと結果のビルドアーティファクトの関係は単純ではありません。バンドラーは、デッドコード除去(「tree shaking」)とモジュールグラフ内の共通依存関係のクラスタリングのために、プログラム全体の分析を採用します。その結果、ビルドアーティファクト(複数のアプリケーションルート間で共有されるJavaScriptファイル)は、入力ファイルと複雑な多対多の関係を形成します。
Turbopackは非常にきめ細かいキャッシュアーキテクチャを使用します。グラフに依存関係を手動で宣言して追加することは人的エラーを起こしやすいため、Turbopackにはスケールできる自動化されたソリューションが必要です。
値セルによるコンパイルグラフの追跡
自動キャッシュと依存関係追跡を促進するため、Turbopackは「値セル」(Vc<…>)の概念を導入します。各値セルは、スプレッドシートのセルのように、きめ細かい実行の一部を表します。セルを読み取るとき、現在実行中の関数とそのすべてのセルをそのセルに依存するものとして記録します。これは、SolidJSのようなフレームワークでのシグナルの動作に似ています。
値セルは待機されたときに追跡されます。セルが読み取られるまで依存関係としてマークしないことで、Turbopackは従来のトップダウンメモ化アプローチが提供するよりもきめ細かいキャッシュを実現します。
例えば、引数は多くの値セルのオブジェクトやマッピングかもしれません。オブジェクトやマッピングの任意の部分が変更されたときに追跡関数を再計算する必要がある代わりに、実際に読み取ったセルが変更されたときにのみ追跡関数を再計算する必要があります。
値セルは、ディスク上のファイル、抽象構文木(AST)、モジュールのインポートとエクスポートに関するメタデータ、チャンキングとバンドリングに使用されるクラスタリング情報など、Turbopack内のほぼすべてを表します。
ダーティマーキングと変更の伝播
Turbopackが初回関数を実行するとき、関数とそれらが作成または依存する値セルのグラフを構築します。グラフのルートは要求された出力(バンドルされたアセット)で、リーフはソースコードファイルです。AST、メタデータ、部分的に変換されたモジュール、チャンキング情報など、中間表現が中間にあります。
ファイルシステムウォッチャーが変更されたソースコードを見つけると、ファイルの値セルを読み取るすべての関数を「ダーティ」としてマークし、再計算のためにキューに入れます。
ファイルを読み取るダーティ化された関数は、JavaScriptモジュールを解析または変換し、新しい中間表現を生成する可能性があります。関数を再計算すると、変更された中間表現を含むセルが更新され、より多くの関数がダーティとしてマークされる可能性があります。
セルの内容が等しい場合、セルの更新はスキップされます。この伝播は、影響を受けるすべての関数が再計算されるまでグラフを上に向かって波及します。
追加の最適化として、実行は「需要駆動」であり、システムはダーティ関数が「アクティブクエリ」の一部になるまで再実行を延期します。開発では、アクティブクエリはホットリロードが有効になっている現在開いているWebページかもしれません。ビルドでは、これは完全な本番アプリの要求です。
集約グラフ
ダーティ伝播アルゴリズムのような大部分の操作は、グラフ内の隣接エッジと隣接ノードに関する情報のみを必要としますが、一部の操作は依存関係グラフのより重要な部分に関する情報をクエリする必要があります:
- サブグラフが新しいアクティブクエリの一部になったときのすべてのダーティノードの検索(再計算をスケジュールするため)
- サブグラフのエラー、警告、またはリントの収集
- サブグラフの計算の完了を待機
きめ細かいキャッシュを維持するため、グラフには数十万または数百万の中間結果が含まれる可能性があります。このグラフの重要な部分を訪問することは高コストになります。
これらのクエリを効率的にするため、Turbopackは依存関係グラフの上に「集約グラフ」と呼ばれる追加のデータ構造を使用します。依存関係グラフを構築または更新するとき、依存関係グラフの一部を要約する集約グラフ内の並列ノードを維持します。
発行されたエラーや警告など、頻繁にアクセスされる情報は集約ノードに添付されます。この集約グラフには複数の解像度レイヤーがあり、より高い集約レイヤーは各ノードでより多くの関数を参照し、解像度を下げ、情報を収集する際に横断する必要があるノード数を減らします。
すべての潜在的なアクティブクエリ(アプリケーションエントリポイントやルートなど)は、集約グラフ内のルートを表します。最終的な集約グラフレイヤーでは、各ルートは自身と元の依存関係グラフ内のすべての子の情報を表します。
依存関係グラフにルートを追加すると集約グラフの再編成が必要になる可能性がありますが、それは稀な操作です。
ファイルシステムキャッシュ
最近のNext.js 16.1リリースまで、これらのキャッシュはすべてメモリ内にのみ保存されていました。この新しいリリースでは、next dev用のファイルシステムキャッシュを安定版としてデフォルトで有効にして出荷しました。
このキャッシュにより、依存関係グラフ、集約グラフ、および値セルに保存されたすべての中間結果をディスクに永続化できます。next devが再起動されると、このウォームキャッシュから迅速に再開できます。
ファイルシステムキャッシュには独自の課題があり、私たち自身の高いパフォーマンスと品質基準を満たすために1年以上の専用作業が必要でした。これについては、今後のエンジニアリングブログ投稿で詳しく説明します。
フィードバックとコミュニティ
フィードバックを共有し、Next.jsの未来を形作るのを手伝ってください: