ウェブアプリの WebAssembly のパフォーマンス パターン

このガイドは、WebAssembly のメリットを活用したいウェブ デベロッパーを対象としています。実行例を参考にしながら、Wasm を使用して CPU 使用率の高いタスクをアウトソーシングする方法を学びます。このガイドでは、Wasm モジュールの読み込みからコンパイルとインスタンス化の最適化まで、あらゆるベスト プラクティスについて説明します。また、CPU 負荷の高いタスクを Web Worker に移行することについても説明し、Web Worker をいつ作成するか、永続的に存続させるか、必要に応じて起動するかなど、実装の決定についても検討します。このガイドでは、問題に対する最適なソリューションを提案するまで、アプローチを繰り返し開発し、パフォーマンス パターンを 1 つずつ紹介します。

前提条件

ネイティブに近いパフォーマンスを実現するために、CPU 使用率の高いタスクを WebAssembly(Wasm)にアウトソースするとします。このガイドの例で使用されている CPU 使用率の高いタスクは、数値の階乗を計算します。階乗は、整数とその下のすべての整数の積です。たとえば、4 の階乗(4! と表記)は 24(つまり 4 * 3 * 2 * 1)に等しくなります。数値はすぐに大きくなります。たとえば、16!2,004,189,184 です。CPU 使用率の高いタスクのより現実的な例としては、バーコードのスキャンラスター画像のトレースなどがあります。

factorial() 関数のパフォーマンスの高い反復(再帰的ではない)実装を、次の C++ で記述されたコードサンプルに示します。

#include <stdint.h>

extern "C" {

// Calculates the factorial of a non-negative integer n.
uint64_t factorial(unsigned int n) {
    uint64_t result = 1;
    for (unsigned int i = 2; i <= n; ++i) {
        result *= i;
    }
    return result;
}

}

この記事の残りの部分では、この factorial() 関数を Emscripten でコンパイルして、コード最適化のベスト プラクティスをすべて使用して factorial.wasm というファイルに保存した Wasm モジュールがあるとします。この方法については、ccall/cwrap を使用して JavaScript からコンパイル済み C 関数を呼び出すをご覧ください。次のコマンドを使用して、factorial.wasmスタンドアロン Wasm としてコンパイルしました。

emcc -O3 factorial.cpp -o factorial.wasm -s WASM_BIGINT -s EXPORTED_FUNCTIONS='["_factorial"]'  --no-entry

HTML では、forminput があり、output と送信 button がペアになっています。これらの要素は、名前に基づいて JavaScript から参照されます。

<form>
  <label>The factorial of <input type="text" value="12" /></label> is
  <output>479001600</output>.
  <button type="submit">Calculate</button>
</form>
const input = document.querySelector('input');
const output = document.querySelector('output');
const button = document.querySelector('button');

モジュールの読み込み、コンパイル、インスタンス化

Wasm モジュールを使用する前に、読み込む必要があります。ウェブでは、これは fetch() API を介して行われます。ウェブアプリが CPU を多用するタスクで Wasm モジュールに依存していることがわかっている場合は、できるだけ早く Wasm ファイルをプリロードする必要があります。これは、アプリの <head> セクションで CORS 対応の fetch を使用して行います。

<link rel="preload" as="fetch" href="factorial.wasm" crossorigin />

実際には、fetch() API は非同期であるため、結果を await する必要があります。

fetch('factorial.wasm');

次に、Wasm モジュールをコンパイルしてインスタンス化します。これらのタスクには WebAssembly.compile()WebAssembly.compileStreaming() を含む)と WebAssembly.instantiate() という名前の関数がありますが、代わりに WebAssembly.instantiateStreaming() メソッドは、fetch() などのストリーミングされた基盤となるソースから Wasm モジュールを直接コンパイルしてインスタンス化します。await は必要ありません。これが、Wasm コードを読み込む最も効率的で最適化された方法です。Wasm モジュールが factorial() 関数をエクスポートしていると仮定すると、すぐに使用できます。

const importObject = {};
const resultObject = await WebAssembly.instantiateStreaming(
  fetch('factorial.wasm'),
  importObject,
);
const factorial = resultObject.instance.exports.factorial;

button.addEventListener('click', (e) => {
  e.preventDefault();
  output.textContent = factorial(parseInt(input.value, 10));
});

タスクを Web Worker に移行する

これをメインスレッドで実行すると、CPU を大量に消費するタスクでは、アプリ全体がブロックされる可能性があります。一般的な方法は、このようなタスクを Web Worker に移行することです。

メインスレッドの再構築

CPU 使用率の高いタスクを Web Worker に移動するには、まずアプリケーションを再構築する必要があります。メインスレッドは Worker を作成し、それ以外は、Web Worker に入力を送信し、出力を受信して表示する処理のみを行います。

/* Main thread. */

let worker = null;

// When the button is clicked, submit the input value
//  to the Web Worker.
button.addEventListener('click', (e) => {
  e.preventDefault();

  // Create the Web Worker lazily on-demand.
  if (!worker) {
    worker = new Worker('worker.js');

    // Listen for incoming messages and display the result.
    worker.addEventListener('message', (e) => {
      output.textContent = e.result;
    });
  }

  worker.postMessage({ integer: parseInt(input.value, 10) });
});

悪い例: タスクは Web Worker で実行されるが、コードが競合状態にある

Web Worker は Wasm モジュールをインスタンス化し、メッセージを受信すると、CPU 負荷の高いタスクを実行して、結果をメインスレッドに返します。この方法の問題は、WebAssembly.instantiateStreaming() を使用して Wasm モジュールをインスタンス化すると、非同期オペレーションになることです。つまり、コードが競合状態にあるということです。最悪の場合、メインスレッドは Web Worker がまだ準備できていないときにデータを送信し、Web Worker はメッセージを受信しません。

/* Worker thread. */

// Instantiate the Wasm module.
// 🚫 This code is racy! If a message comes in while
// the promise is still being awaited, it's lost.
const importObject = {};
const resultObject = await WebAssembly.instantiateStreaming(
  fetch('factorial.wasm'),
  importObject,
);
const factorial = resultObject.instance.exports.factorial;

// Listen for incoming messages, run the task,
// and post the result.
self.addEventListener('message', (e) => {
  const { integer } = e.data;
  self.postMessage({ result: factorial(integer) });
});

改善: タスクは Web Worker で実行されるが、読み込みとコンパイルが重複する可能性がある

非同期の Wasm モジュールのインスタンス化の問題を回避する 1 つの方法は、Wasm モジュールの読み込み、コンパイル、インスタンス化をすべてイベント リスナーに移動することですが、この場合、受信したメッセージごとにこの作業を行う必要があります。HTTP キャッシュとコンパイル済み Wasm バイトコードをキャッシュに保存できる HTTP キャッシュを使用すれば、最悪の解決策にはなりませんが、より良い方法があります。

非同期コードを Web Worker の先頭に移動し、Promise の完了を実際に待つのではなく、Promise を変数に保存することで、プログラムはコードのイベント リスナー部分にすぐに移動し、メインスレッドからのメッセージが失われることはありません。イベント リスナー内で、Promise を待機できます。

/* Worker thread. */

const importObject = {};
// Instantiate the Wasm module.
// 🚫 If the `Worker` is spun up frequently, the loading
// compiling, and instantiating work will happen every time.
const wasmPromise = WebAssembly.instantiateStreaming(
  fetch('factorial.wasm'),
  importObject,
);

// Listen for incoming messages
self.addEventListener('message', async (e) => {
  const { integer } = e.data;
  const resultObject = await wasmPromise;
  const factorial = resultObject.instance.exports.factorial;
  const result = factorial(integer);
  self.postMessage({ result });
});

良好: タスクが Web Worker で実行され、読み込みとコンパイルが 1 回だけ行われる

静的 WebAssembly.compileStreaming() メソッドの結果は、WebAssembly.Module に解決される Promise です。このオブジェクトの優れた機能の 1 つは、postMessage() を使用して転送できることです。つまり、Wasm モジュールはメインスレッド(または読み込みとコンパイルのみを行う別の Web Worker)で 1 回だけ読み込んでコンパイルし、CPU 使用率の高いタスクを担当する Web Worker に転送できます。次のコードは、このフローを示しています。

/* Main thread. */

const modulePromise = WebAssembly.compileStreaming(fetch('factorial.wasm'));

let worker = null;

// When the button is clicked, submit the input value
// and the Wasm module to the Web Worker.
button.addEventListener('click', async (e) => {
  e.preventDefault();

  // Create the Web Worker lazily on-demand.
  if (!worker) {
    worker = new Worker('worker.js');

    // Listen for incoming messages and display the result.
    worker.addEventListener('message', (e) => {
      output.textContent = e.result;
    });
  }

  worker.postMessage({
    integer: parseInt(input.value, 10),
    module: await modulePromise,
  });
});

Web Worker 側では、WebAssembly.Module オブジェクトを抽出してインスタンス化するだけです。WebAssembly.Module を含むメッセージはストリーミングされないため、Web Worker のコードでは、以前の instantiateStreaming() バリアントではなく WebAssembly.instantiate() が使用されるようになりました。インスタンス化されたモジュールは変数にキャッシュに保存されるため、インスタンス化の作業は Web Worker の起動時に 1 回だけ行えば済みます。

/* Worker thread. */

let instance = null;

// Listen for incoming messages
self.addEventListener('message', async (e) => {
  // Extract the `WebAssembly.Module` from the message.
  const { integer, module } = e.data;
  const importObject = {};
  // Instantiate the Wasm module that came via `postMessage()`.
  instance = instance || (await WebAssembly.instantiate(module, importObject));
  const factorial = instance.exports.factorial;
  const result = factorial(integer);
  self.postMessage({ result });
});

最適: タスクがインラインの Web Worker で実行され、読み込みとコンパイルが 1 回のみ行われる

HTTP キャッシュを使用しても、キャッシュに保存された Web Worker コードを取得し、ネットワークにアクセスする可能性は高く、コストがかかります。一般的なパフォーマンスのトリックとして、Web Worker をインライン化して blob: URL として読み込む方法があります。ただし、同じ JavaScript ソースファイルに基づいていても、Web Worker とメインスレッドのコンテキストは異なるため、コンパイルされた Wasm モジュールをインスタンス化のために Web Worker に渡す必要があります。

/* Main thread. */

const modulePromise = WebAssembly.compileStreaming(fetch('factorial.wasm'));

let worker = null;

const blobURL = URL.createObjectURL(
  new Blob(
    [
      `
let instance = null;

self.addEventListener('message', async (e) => {
  // Extract the \`WebAssembly.Module\` from the message.
  const {integer, module} = e.data;
  const importObject = {};
  // Instantiate the Wasm module that came via \`postMessage()\`.
  instance = instance || await WebAssembly.instantiate(module, importObject);
  const factorial = instance.exports.factorial;
  const result = factorial(integer);
  self.postMessage({result});
});
`,
    ],
    { type: 'text/javascript' },
  ),
);

button.addEventListener('click', async (e) => {
  e.preventDefault();

  // Create the Web Worker lazily on-demand.
  if (!worker) {
    worker = new Worker(blobURL);

    // Listen for incoming messages and display the result.
    worker.addEventListener('message', (e) => {
      output.textContent = e.result;
    });
  }

  worker.postMessage({
    integer: parseInt(input.value, 10),
    module: await modulePromise,
  });
});

遅延または即時 Web Worker の作成

これまでのコードサンプルでは、ボタンが押されたときに Web Worker がオンデマンドで遅延起動されていました。アプリケーションによっては、アプリがアイドル状態のときや、アプリのブートストラップ プロセスの一部として、Web Worker をより積極的に作成することが理にかなっている場合があります。そのため、Web Worker の作成コードをボタンのイベント リスナーの外に移動します。

const worker = new Worker(blobURL);

// Listen for incoming messages and display the result.
worker.addEventListener('message', (e) => {
  output.textContent = e.result;
});

Web Worker を保持するかどうか

Web Worker を永続的に保持するか、必要なときに再作成するかを検討する必要があります。どちらの方法も可能であり、それぞれにメリットとデメリットがあります。たとえば、Web Worker を永続的に保持すると、アプリのメモリ使用量が増加し、同時実行タスクの処理が難しくなる可能性があります。これは、Web Worker から返される結果をリクエストにマッピングする必要があるためです。一方、Web Worker のブートストラップ コードはかなり複雑になる可能性があるため、毎回新しいコードを作成するとオーバーヘッドが大きくなる可能性があります。幸いなことに、これは User Timing API で測定できます。

これまでのコードサンプルでは、1 つの永続的な Web Worker を保持していました。次のコードサンプルでは、必要に応じて新しい Web Worker をアドホックに作成します。Web Worker の終了はご自身で追跡する必要があります。(このコード スニペットではエラー処理が省略されていますが、何らかの問題が発生した場合は、成功または失敗のいずれの場合でも必ず終了してください)。

/* Main thread. */

let worker = null;

const modulePromise = WebAssembly.compileStreaming(fetch('factorial.wasm'));

const blobURL = URL.createObjectURL(
  new Blob(
    [
      `
// Caching the instance means you can switch between
// throw-away and permanent Web Worker freely.
let instance = null;

self.addEventListener('message', async (e) => {
  // Extract the \`WebAssembly.Module\` from the message.
  const {integer, module} = e.data;
  const importObject = {};
  // Instantiate the Wasm module that came via \`postMessage()\`.
  instance = instance || await WebAssembly.instantiate(module, importObject);
  const factorial = instance.exports.factorial;
  const result = factorial(integer);
  self.postMessage({result});
});  
`,
    ],
    { type: 'text/javascript' },
  ),
);

button.addEventListener('click', async (e) => {
  e.preventDefault();
  // Terminate a potentially running Web Worker.
  if (worker) {
    worker.terminate();
  }
  // Create the Web Worker lazily on-demand.
  worker = new Worker(blobURL);
  worker.addEventListener('message', (e) => {
    worker.terminate();
    worker = null;
    output.textContent = e.data.result;
  });
  worker.postMessage({
    integer: parseInt(input.value, 10),
    module: await modulePromise,
  });
});

デモ

2 つのデモを試すことができます。アドホック Web Workerソースコード)と永続 Web Workerソースコード)の 2 つがあります。Chrome DevTools を開いてコンソールを確認すると、ボタンのクリックから画面に結果が表示されるまでの時間を測定する User Timing API のログを確認できます。[ネットワーク] タブには、blob: URL リクエストが表示されます。この例では、アドホックと永続のタイミングの差は約 3 倍です。実際には、この場合、どちらも人間の目では区別できません。実際のアプリの結果は、ほとんどの場合、異なります。

アドホック Worker を使用した階乗 Wasm デモアプリ。Chrome DevTools が開きます。[ネットワーク] タブに 2 つの blob: URL リクエストがあり、コンソールに 2 つの計算タイミングが表示されます。

永続的な Worker を使用した階乗 Wasm デモアプリ。Chrome DevTools が開きます。[ネットワーク] タブには URL リクエストの 1 つの BLOB が表示され、コンソールには 4 つの計算タイミングが表示されます。

まとめ

この投稿では、Wasm を扱うためのパフォーマンス パターンについて説明しました。

  • 原則として、ストリーミング以外のメソッド(WebAssembly.compile()WebAssembly.instantiate())よりもストリーミング メソッド(WebAssembly.compileStreaming()WebAssembly.instantiateStreaming())を優先します。
  • 可能であれば、パフォーマンスを重視するタスクを Web Worker にアウトソースし、Wasm の読み込みとコンパイルの作業は Web Worker の外部で 1 回だけ行います。このようにすると、Web Worker は、WebAssembly.instantiate() で読み込みとコンパイルが行われたメインスレッドから受け取った Wasm モジュールをインスタンス化するだけで済みます。つまり、Web Worker を永続的に保持していれば、インスタンスをキャッシュに保存できます。
  • 永続的な Web Worker を常に 1 つ保持することが妥当かどうか、または必要なときにアドホック Web Worker を作成することが妥当かどうかを慎重に判断してください。また、Web Worker を作成する最適なタイミングについても検討してください。考慮すべき点は、メモリ消費量、Web Worker のインスタンス化の所要時間だけでなく、同時リクエストを処理する必要がある可能性の複雑さも含まれます。

これらのパターンを考慮すれば、Wasm のパフォーマンスを最適化する上で正しい方向に進むことができます。

謝辞

このガイドは、Andreas HaasJakob KummerowDeepti GandluriAlon ZakaiFrancis McCabeFrançois BeaufortRachel Andrew によってレビューされました。