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

このガイドでは、WebAssembly を活用したいと考えているウェブ デベロッパーを対象に、Wasm を使用して CPU 使用率の高いタスクをアウトソーシングする方法を、サンプルの実行方法について説明します。このガイドでは、Wasm モジュールの読み込みのベスト プラクティスから、コンパイルやインスタンス化の最適化まで、すべてを網羅しています。また、CPU 使用率の高いタスクを Web Worker に移行することについて詳しく説明します。また、Web Worker を作成するタイミングや、Web Worker を常時実行しておくか、必要に応じて起動するかを検討するなど、実装に関する意思決定についても説明します。このガイドでは、問題の最適な解決策を提案するまで、このアプローチを繰り返し開発し、一度に 1 つのパフォーマンス パターンを導入します。

前提条件

CPU 使用率が非常に高いタスクを、ネイティブに近いパフォーマンスを実現するために WebAssembly(Wasm)にアウトソーシングする必要があるとします。このガイドの例として使用される CPU 使用率の高いタスクは、数値の階乗を計算します。階乗は、整数とその下のすべての整数の積です。たとえば、4 の階乗(4! と表記)は 244 * 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 には、inputformoutput の組み合わせ、submit 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 対応の取得を使用して行います。

<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));
});

タスクをウェブワーカーに移行する

CPU 使用率が非常に高いタスクのメインスレッドで実行すると、アプリ全体がブロックされてしまうリスクがあります。一般的な方法は、そのようなタスクをウェブ ワーカーに移すことです。

メインスレッドの再構築

CPU 使用率の高いタスクをウェブワーカーに移動するには、まずアプリケーションを再構築する必要があります。メインスレッドは 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 ワーカーの準備が整っていないときにメインスレッドがデータを送信し、Web ワーカーがメッセージを受信しません。

/* 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) });
});

より良い: タスクはウェブ ワーカーで実行されますが、読み込みとコンパイルが冗長になる可能性があります

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

非同期コードをウェブ ワーカーの先頭に移動し、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 });
});

良い例: タスクはウェブ ワーカーで実行され、読み込みとコンパイルが 1 回だけ行われる

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

/* 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,
  });
});

ウェブワーカー側では、WebAssembly.Module オブジェクトを抽出してインスタンス化することのみが残ります。WebAssembly.Module を含むメッセージはストリーミングされないため、Web Worker のコードでは、以前の instantiateStreaming() バリアントではなく WebAssembly.instantiate() が使用されます。インスタンス化されたモジュールは変数にキャッシュに保存されるため、インスタンス化処理はウェブワーカーの起動時に 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 });
});

完璧: タスクはインライン ウェブ ワーカーで実行され、一度だけ読み込まれてコンパイルされる

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

/* 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 の作成コードをボタンのイベント リスナーの外側に移動します。

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 のブートストラップ コードは複雑になる可能性があるため、毎回新しいコードを作成するとオーバーヘッドが大きくなる可能性があります。幸い、これは User Timing API で測定できます。

これまでのコードサンプルでは、1 つの永続的な 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 つあります。1 つはアドホック ウェブワーカーソースコード)を使用、もう 1 つは永続的なウェブワーカーソースコード)を使用します。Chrome DevTools を開いてコンソールを確認すると、ボタンのクリックから画面に結果が表示されるまでの時間を測定する User Timing API ログを確認できます。[ネットワーク] タブには、blob: URL リクエストが表示されます。この例では、アドホックと永続のタイミングの差は約 3 倍です。実際には、人間の目には、このケースでは両方を区別できません。実際のアプリでは、結果が異なる可能性があります。

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

永続ワーカーを使用した Factorial Wasm デモアプリ。Chrome DevTools が開きます。[ネットワーク] タブには 1 つの blob(URL リクエスト)のみがあり、コンソールに 4 つの計算タイミングが表示されています。

まとめ

この投稿では、Wasm を扱うためのパフォーマンス パターンをいくつか見てきました。

  • 一般的に、ストリーミング以外のメソッド(WebAssembly.compile()WebAssembly.instantiate())よりもストリーミング メソッド(WebAssembly.compileStreaming()WebAssembly.instantiateStreaming())を使用することをおすすめします。
  • 可能であれば、パフォーマンスに負荷の高いタスクを Web Worker にアウトソースし、Wasm の読み込みとコンパイル作業は Web Worker の外部で 1 回だけ行います。これにより、Web Worker は、WebAssembly.instantiate() で読み込みとコンパイルが行われたメインスレッドから受け取った Wasm モジュールをインスタンス化する必要があります。つまり、Web Worker を永続的に保持している場合は、インスタンスをキャッシュに保存できます。
  • 1 つの永続的なウェブ ワーカーを永久に保持することが適切であるか、または必要なときにアドホックのウェブ ワーカーを作成することが適切であるかを慎重に判断してください。また、ウェブ ワーカーを作成する最適なタイミングについても検討してください。考慮すべき点は、メモリ消費量、Web Worker のインスタンス化時間だけでなく、同時実行リクエストに対処する必要がある可能性のある複雑さも含まれます。

これらのパターンを考慮すると、Wasm のパフォーマンスを最適化できます。

謝辞

このガイドは、Andreas HaasJakob KummerowDeepti GandluriAlon ZakaiFrancis McCabeFrançois BeaufortRachel Andrew が確認しました。