>_tech-draft
Vercelのアイコン
Vercel
動画公開日
タイトル

Are We Turbo Yet?

再生時間

24分 3秒

TurboPackの高速化戦略:インクリメンタルビルドとキャッシングの挑戦

ポイント

  • TurboPackが目指す「コールドビルドゼロ」を実現するためのインクリメンタルビルドとキャッシング戦略について、その実装の難しさを解説します。
  • ファイル変更検知、複雑な設定管理、コールバック地獄など、ナイーブなキャッシュ導入が直面する具体的な技術的課題とその限界を詳述します。
  • 本記事は、高速なバンドラー開発におけるキャッシングの本質的な複雑性を理解し、より洗練されたアプローチの重要性を学ぶための洞察を提供します。

TurboPackの高速化戦略:インクリメンタルビルドとキャッシングの挑戦

こんにちは。VercelのソフトウェアエンジニアであるLuke Sandbergです。私はVercelでTurboPackの開発に携わっており、今回はTurboPackの設計において私たちが下したいくつかの選択と、それがいかにして現在の優れたパフォーマンスを継続的に構築していくかを共有したいと思います。

導入:TurboPackの究極の目標

私たちの設計における全体的な目標は、開発者が「コールドビルドを一切経験しない」ことです。コールドビルドは確かに重要ですが、私たちの理想はそれを全く体験させないようなシステムを構築することです。本記事では、この目標を達成するためにTurboPackがどのようにインクリメンタリティ(増分性)を活用し、バンドルパフォーマンスを向上させているかに焦点を当てます。

キーノートでも触れられたように、TurboPackのインクリメンタリティの鍵となるアイデアは「キャッシング」です。バンドラーが行うすべての処理をキャッシュ可能にすることで、変更があった際にはその変更に関連する作業のみをやり直すだけで済むようにしたいと考えています。言い換えれば、ビルドのコストはアプリケーション全体の規模や複雑さではなく、あなたの「変更の規模や複雑さ」に応じて増減するべきです。これにより、どれだけ多くのライブラリをインポートしても、TurboPackは開発者に常に優れたパフォーマンスを提供し続けることができます。

インクリメンタルビルドの核心:キャッシング

インクリメンタルビルドとは、コードに変更が加えられた際に、変更された部分とその影響範囲のみを再構築し、変更されていない部分については以前のビルド結果を再利用することで、ビルド時間を短縮する手法です。これを実現するための中心的な技術がキャッシングです。

ナイーブなバンドラーとキャッシングの課題

このアイデアを理解するために、世界で最もシンプルなバンドラーを想像してみましょう。このバンドラーは、以下の処理を行います。

  1. すべてのエントリポイントを解析します。
  2. インポートを辿り、アプリケーション全体で再帰的に参照を解決し、すべての依存関係を見つけます。
  3. 最後に、各エントリポイントが依存するすべてを収集し、出力ファイルにまとめます。

このナイーブなアプローチでは、インクリメンタリティは考慮されていません。例えば、特定のファイルが何度もインポートされる場合、そのファイルは複数回解析される可能性があります。また、reactのような一般的なライブラリの解決も何百回、何千回と冗長に行われるでしょう。

この冗長な作業を避けるために、まずはparse関数にキャッシュを追加することを考えます。parse関数は、ファイルの内容を読み込み、SWCに渡してAST(Abstract Syntax Tree: 抽象構文木)を生成する、バンドラーの主要な処理の一つです。

function parse(filename: string): AST {
  if (cache.has(filename)) {
    return cache.get(filename);
  }
  const contents = readFile(filename);
  const ast = swc.parse(contents);
  cache.set(filename, ast);
  return ast;
}

これは一見、シンプルで効果的な解決策に見えますが、すぐに多くの問題に直面します。

キャッシングにおける課題の噴出

  1. ファイル変更の検知: ファイルの内容が変更された場合、キャッシュされたASTは古くなります。これをどう検知し、キャッシュを無効化するかが問題です。
  2. 複雑なファイルパス: ファイルが単なるファイルではなく、複数のシンボリックリンクを経由している場合(多くのパッケージマネージャーがこのような依存関係を整理します)、ファイル名だけをキャッシュキーとして使用するのは不十分です。
  3. 複数ターゲットへの対応: クライアントとサーバーの両方でバンドルする場合、同じファイルが両方のターゲットで使用されることがあります。ファイル名だけでは、異なるビルドターゲット向けのキャッシュを適切に管理できません。
  4. ASTのミューテーション: キャッシュされたASTをそのまま返すと、呼び出し元がASTを変更した場合に、キャッシュされた値が意図せず変更されてしまう可能性があります。これは深刻なバグにつながりかねません。
  5. 設定の考慮: コンパイラには非常に多くの設定が存在します。これらの設定(例えばトランスフォームの定義やターゲット環境)も、キャッシュキーの一部として含める必要があります。しかし、設定オブジェクトをJSON.stringifyするだけでは、キーが非常に大きくなったり、設定の細かい変更が正しく検知されなかったりする問題があります。
function parse(filename: string, config: Config): AST {
  const key = `${filename}:${JSON.stringify(config)}`; // ナイーブなキャッシュキー
  if (cache.has(key)) {
    return cache.get(key);
  }
  const contents = readFile(filename);
  const ast = applyTransforms(swc.parse(contents), config);
  cache.set(key, ast);
  return ast;
}

このような複雑なキャッシュキーの管理は、コードの可読性やメンテナンス性を著しく低下させます。

キャッシュ無効化とコールバック地獄

ファイル変更に対応するため、readFile関数にコールバックAPIを追加し、ファイルが変更されたらキャッシュから削除するアプローチを考えます。しかし、これだけでは不十分です。キャッシュが削除されたことを呼び出し元も知る必要があります。そのため、コールバックをスタックの最上位まで伝播させる必要があります。

// 概念的なコード
function bundle(entryPoint: string, onFileChange: (file: string) => void) {
  // ...
  readFile(file, (changedFile) => {
    // ファイルが変更されたら、キャッシュを無効化し、
    // 呼び出し元に通知するためにonFileChangeを呼び出す。
    invalidateCache(changedFile);
    onFileChange(changedFile);
  });
  // ...
}

これにより、「リアクティブなバンドラー」が実現でき、ファイル変更時にバンドル全体を再実行できます。しかし、これはまだインクリメンタルとは言えません。ファイルが一つ変更されただけで、すべてのモジュールをウォークし、すべての出力ファイルを再生成しているため、多くの冗長な作業が発生しています。

その他の冗長な作業と制御フローの課題

parse関数だけでなく、インポートの解決結果や、最終的な出力ファイルの生成ロジックなどもキャッシュする必要があります。例えば、resolveの結果は依存関係の更新や新規ファイルの追加によって変更される可能性があるため、ここにもコールバックが必要になります。HMR(Hot Module Replacement)セッションでは、アプリケーションの一部だけを編集しているのに、なぜ毎回すべての出力ファイルを再書き込みする必要があるのでしょうか?

さらに、この関数全体の制御フローにも問題があります。ファイルが一つ変更された場合でも、毎回最初から処理をやり直しています。理想的には、ループの途中にジャンプして、変更された部分から処理を再開したいはずです。

そして、呼び出し元に対するAPIもナイーブすぎます。開発者は、どのファイルが変更されたかを知り、それに応じてクライアントに更新をプッシュしたいと考えるでしょう。

これらすべての場所にコールバックを埋め込み、スタックを通して伝播させるとどうなるでしょうか?コードは保守不可能になり、新しい機能を追加することは非常に困難になります。このアプローチでは、システムは確実に破綻します。

まとめ

インクリメンタルなバンドラーを構築することは、想像を絶するほど複雑な課題です。ナイーブなキャッシングやコールバックベースのアプローチでは、多くの問題に直面し、最終的にはコードの複雑性が管理不能になります。私たちは、この経験から「本当に何を求めているのか」を明確にし、それを非常に具体的に定義することの重要性を学びました。

TurboPackは、これらの課題に対してより洗練されたアプローチを採用することで、優れたパフォーマンスと開発体験を提供しています。本記事では、その複雑な道のりの一端をご紹介しました。

参考動画

TurboPack - Luke Sandberg - Vercel