メインコンテンツまでスキップ

WebAssembly開発者のためのコードキャッシュ

· 約13分
[ビル・バッジ](https://twitter.com/billb)、キャッシュにCa-ching!を加える

開発者の間で「最も速いコードは実行されないコードだ」という言葉があります。同様に、最も速いコンパイルコードは、コンパイルする必要がないコードです。WebAssemblyコードキャッシュはChromeとV8における新しい最適化技術で、コンパイラによって生成されたネイティブコードをキャッシュすることでコードのコンパイルを回避することを目指しています。以前、ChromeとV8がJavaScriptコードをキャッシュする方法や、これらの最適化を活用するためのベストプラクティスについて執筆 しました 今回のブログ記事では、ChromeのWebAssemblyコードキャッシュの動作と、大規模なWebAssemblyモジュールを持つアプリケーションの読み込みを高速化するために、開発者がこれをどのように活用できるかを説明します。

WebAssemblyコンパイルの再確認

WebAssemblyは非JavaScriptコードをWeb上で実行するための方法です。Webアプリは、.wasmというリソースを読み込むことでWebAssemblyを利用できます。このリソースには、C、C++、またはRustなど、他の言語で部分的にコンパイルされたコードが含まれています(他の言語も今後追加される予定です)。WebAssemblyコンパイラの役割は、.wasmリソースをデコードし、それが正しい形式かどうかを検証し、その後ユーザーのマシンで実行可能なネイティブマシンコードにコンパイルすることです。

V8にはWebAssembly用に2つのコンパイラがあります:LiftoffとTurboFanです。Liftoffはベースラインコンパイラで、モジュールを可能な限り迅速にコンパイルし、早期実行を可能にします。TurboFanは、JavaScriptとWebAssemblyの両方に最適化されたコンパイラで、バックグラウンドで動作して高品質なネイティブコードを生成し、Webアプリに長期的な最適なパフォーマンスを提供します。大規模なWebAssemblyモジュールでは、TurboFanが完全にコンパイルを終了するまでに30秒から1分以上かかる場合があります。

そこで、コードキャッシュが登場します。TurboFanが大規模なWebAssemblyモジュールのコンパイルを完了すると、Chromeはコードをキャッシュに保存できます。そのため、次回そのモジュールがロードされる際には、LiftoffとTurboFanのコンパイルをスキップでき、より高速な起動と電力消費の削減が実現します。コードのコンパイルは非常にCPU負荷が高い作業です。

WebAssemblyコードキャッシュは、ChromeがJavaScriptコードキャッシュに使用するのと同じメカニズムを使用します。同じ種類のストレージとダブルキーキャッシュ技術を使用しており、これはサイト分離というChromeの重要なセキュリティ機能に従って異なるオリジンによってコンパイルされたコードを分離させています。

WebAssemblyコードキャッシュアルゴリズム

現時点では、WebAssemblyキャッシュはストリーミングAPI呼び出しであるcompileStreaminginstantiateStreamingにのみ実装されています。これらは.wasmリソースのHTTPフェッチ操作に基づいており、Chromeのリソースフェッチとキャッシュメカニズムを利用しやすくするとともに、WebAssemblyモジュールを特定するためのキーとして使用できる便利なリソースURLを提供します。キャッシュアルゴリズムは以下のように動作します:

  1. .wasmリソースが初めてリクエストされる(つまり_コールドラン_)と、Chromeはこれをネットワークからダウンロードし、V8にストリーミングしてコンパイルします。同時に、Chromeは.wasmリソースをブラウザのリソースキャッシュに保存します。このリソースキャッシュはユーザーのデバイスのファイルシステムに保存され、次回このリソースが必要になる際にはChromeが迅速にロードできるようにします。
  2. TurboFanがモジュールのコンパイルを完全に終了した場合、そして.wasmリソースが十分に大きい場合(現在では128 kB以上)、Chromeはコンパイル済みコードをWebAssemblyコードキャッシュに書き込みます。このコードキャッシュはステップ1のリソースキャッシュとは物理的に別々です。
  3. .wasmリソースが再度リクエストされる(つまり_ホットラン_)際には、Chromeはリソースキャッシュから.wasmリソースをロードし、同時にコードキャッシュを照会します。キャッシュヒットがある場合は、コンパイル済みモジュールバイトがレンダラープロセスに送信され、V8に渡されます。V8はモジュールをコンパイルする代わりにコードを逆シリアル化します。逆シリアル化はコンパイルよりも高速でCPU負荷が低いです。
  4. キャッシュされたコードが無効になることもあります。これは、.wasmリソースが変更された場合や、V8が変更された場合に発生します。Chromeの迅速なリリースサイクルのため、こうした変更は少なくとも6週間ごとに起こると予想されます。この場合、キャッシュされたネイティブコードはキャッシュからクリアされ、ステップ1のようにコンパイルが行われます。

この説明に基づいて、WebAssemblyコードキャッシュの使用を改良するためのいくつかの推奨事項を挙げることができます。

ヒント1: WebAssembly ストリーミングAPIを使用する

コードキャッシュがストリーミングAPIでのみ機能するため、compileStreamingまたはinstantiateStreamingを使用してWebAssemblyモジュールをコンパイルまたはインスタンス化してください。以下のJavaScriptスニペットのように:

(async () => {
const fetchPromise = fetch('fibonacci.wasm');
const { instance } = await WebAssembly.instantiateStreaming(fetchPromise);
const result = instance.exports.fibonacci(42);
console.log(result);
})();

この記事では、WebAssembly ストリーミングAPIを使用する利点について詳しく説明しています。Emscriptenは、アプリケーションのローダーコードを生成する際にデフォルトでこのAPIを使用しようとします。ストリーミングには.wasmリソースが正しいMIMEタイプを持つ必要があるため、サーバーはレスポンスにContent-Type: application/wasmヘッダーを送信する必要がある点に注意してください。

ヒント2:キャッシュに優しくする

コードキャッシュはリソースURLと.wasmリソースが最新かどうかに依存するため、これらを安定させるよう心がけてください。.wasmリソースが異なるURLからフェッチされた場合、それは異なるものと見なされ、V8はモジュールを再コンパイルする必要があります。同様に、.wasmリソースがリソースキャッシュ内で無効になった場合、Chromeはキャッシュされたコードを破棄する必要があります。

コードを安定化させる

新しいWebAssemblyモジュールを配信するたびに、それは完全に再コンパイルされなければなりません。新しい機能を提供する場合やバグを修正する場合を除き、新しいバージョンのコードを配信するのは必要最低限にしてください。コードが変更されていない場合は、Chromeにそのことを知らせましょう。ブラウザがWebAssemblyモジュールなどのリソースURLに対してHTTPリクエストを行う際、以前そのURLを取得した日時を含めます。サーバーがファイルが変更されていないと認識している場合、304 Not Modifiedレスポンスを返すことができます。これにより、ChromeおよびV8はキャッシュされたリソースとコードが引き続き有効であることを認識します。一方、200 OKレスポンスを返すと、キャッシュされた.wasmリソースを更新し、コードキャッシュを無効化します。これにより、WebAssemblyは再度ゼロからの実行に戻ります。Webリソースのベストプラクティスに従い、レスポンスを使用してブラウザに.wasmリソースがキャッシュ可能どうか、有効期限、または最後に変更された日時を通知してください。

コードのURLを変更しない

キャッシュされたコンパイル済みコードは.wasmリソースのURLに関連付けられており、実際のリソースをスキャンしなくても簡単に検索できるようになっています。つまり、リソースのURL(クエリパラメータを含む)を変更すると、新しいリソースキャッシュエントリが作成され、完全な再コンパイルおよび新しいコードキャッシュエントリの作成が必要になります。

大きくする(大きすぎないように!)

WebAssemblyコードキャッシュの主な判断基準は.wasmリソースのサイズです。.wasmリソースが特定の閾値サイズ未満の場合、コンパイル済みモジュールのバイトはキャッシュされません。この理由として、V8は小さなモジュールを迅速にコンパイルできる可能性があり、キャッシュからコンパイル済みコードを読み込むよりも速い場合があるためです。現在、128 kB以上の.wasmリソースが対象となっています。

ただし、大きいほうが良いのは一定の限度までです。キャッシュはユーザーのマシン上の容量を消費するため、Chromeは過剰に容量を消費しないよう注意しています。現在のところ、デスクトップマシンではコードキャッシュは通常数百メガバイトのデータを保持しています。また、Chromeキャッシュではキャッシュ内の最大エントリサイズをキャッシュ全体のサイズの一部に制限しているため、コンパイル済みのWebAssemblyコードに対する追加の制限は約150MB(全キャッシュサイズの半分)程度です。一般的なデスクトップマシンでは、コンパイル済みモジュールは対応する.wasmリソースの約5〜7倍のサイズになることが多い点に注意が必要です。

このサイズ基準や他のキャッシュ動作は、ユーザーおよび開発者に最適な方法を確定する際に変更される可能性があります。

サービスワーカーを使用する

WebAssemblyコードキャッシュはワーカーやサービスワーカーでも有効であるため、それらを使用してコードの新しいバージョンをロード、コンパイル、キャッシュし、次回アプリが起動するときに利用可能にすることができます。すべてのWebサイトは少なくとも1回はWebAssemblyモジュールを完全にコンパイルする必要があります — ワーカーを使用してそれをユーザーに隠しましょう。

トレース

開発者として、コンパイル済みモジュールがChromeによってキャッシュされていることを確認したい場合があります。WebAssemblyコードキャッシュイベントはChromeの開発者ツールにデフォルトで表示されないため、モジュールがキャッシュされているかどうかを調べる最良の方法は、やや低レベルなchrome://tracing機能を使用することです。

chrome://tracingは、特定の期間中にChromeの動作を記録したインストルメントされたトレースを収録します。トレースは他のタブ、ウィンドウ、拡張機能を含むブラウザ全体の動作を記録するため、拡張機能を無効にし、他のブラウザタブを開かず、クリーンなユーザープロファイルで行うのが最善です。

# クリーンなユーザープロファイルで拡張機能を無効化した状態で新しいChromeブラウザセッションを開始
google-chrome --user-data-dir="$(mktemp -d)" --disable-extensions

chrome://tracing に移動して「Record」をクリックしてトレーシングセッションを開始します。表示されるダイアログウィンドウで「Edit Categories」をクリックし、右側の「Disabled by Default Categories」の下にあるdevtools.timelineカテゴリをチェックしてください。(収集されるデータ量を減らすために、他の事前選択されているカテゴリのチェックを解除することができます)。その後、ダイアログの「Record」ボタンをクリックしてトレースを開始します。

別のタブでアプリをロードまたはリロードします。TurboFanのコンパイルが完了することを確認するために、10秒以上十分に実行してください。終了したら「Stop」をクリックしてトレースを終了します。イベントのタイムラインビューが表示されます。トレーシングウィンドウの右上にはテキストボックスがあり、「View Options」の右側にあります。v8.wasmと入力して非WebAssemblyイベントをフィルタリングしてください。以下のいずれかのイベントが表示されるはずです:

  • v8.wasm.streamFromResponseCallback — instantiateStreamingへ渡されたリソースフェッチがレスポンスを受け取りました。
  • v8.wasm.compiledModule — TurboFanが.wasmリソースのコンパイルを完了しました。
  • v8.wasm.cachedModule — Chromeがコンパイル済みモジュールをコードキャッシュに書き込みました。
  • v8.wasm.moduleCacheHit — Chromeが.wasmリソースを読み込む際にキャッシュ内でコードを見つけました。
  • v8.wasm.moduleCacheInvalid — V8がキャッシュ済みコードを適用できなかったため、コードが古くなっていました。

初回実行の場合、v8.wasm.streamFromResponseCallbackv8.wasm.compiledModuleイベントを見ることが期待されます。これは、WebAssemblyモジュールが受信され、コンパイルが成功したことを示しています。どちらのイベントも観測されない場合は、WebAssemblyストリーミングAPIの呼び出しが正しく機能しているか確認してください。

初回実行後にサイズの閾値を超えた場合、v8.wasm.cachedModuleイベントを見ることも期待されます。これにより、コンパイル済みコードがキャッシュに送られたことを意味します。ただし、このイベントが発生しても書き込みが成功しない可能性があります。現在、これを観測する方法はありませんが、イベントメタデータからコードのサイズを確認することができます。非常に大きなモジュールはキャッシュに収まりきらない場合があります。

キャッシングが正常に機能している場合、再実行(ホットラン)では2つのイベントが生成されます:v8.wasm.streamFromResponseCallbackv8.wasm.moduleCacheHit。これらのイベントのメタデータでは、コンパイル済みコードのサイズを確認することができます。

chrome://tracingの使用についてさらに詳しくは、開発者向けJavaScript(バイト)コードキャッシュに関する記事をご覧ください。

結論

ほとんどの開発者にとって、コードキャッシュは「ただ動作する」ものであるべきです。どのキャッシュも同様に、安定している場合に最もよく機能します。Chromeのキャッシングヒューリスティックはバージョンごとに変化する可能性がありますが、コードキャッシュには利用できる挙動と回避できる制限があります。chrome://tracingを使用した綿密な分析により、ウェブアプリでWebAssemblyコードキャッシュの利用を微調整し最適化することが可能です。