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

このガイドでは、WebAssembly を活用したいウェブ デベロッパーを対象に、実行中のサンプルを参照しながら、Wasm を利用して CPU 負荷の高いタスクをアウトソーシングする方法について説明します。このガイドでは、Wasm モジュールを読み込むためのベスト プラクティスから、コンパイルとインスタンス化の最適化まで、あらゆることをカバーしています。CPU 負荷の高いタスクをウェブワーカーに移行する方法、および Web Worker をいつ作成するかや、永続的に存続させるか、必要に応じてスピンアップするかなど、直面する実装上の決定事項についても説明します。このガイドでは、このアプローチの開発を繰り返し、問題に対する最適な解決策を提案するまで、一度に 1 つのパフォーマンス パターンを紹介します。

前提条件

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

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

#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() 関数を factorial.wasm というファイルに Emscripten でコンパイルすることに基づく 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 では、inputoutput および送信 button とペアになった form があります。これらの要素は、名前に基づいて 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 を作成するようになりました。それとは別に、入力を 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 で実行されるが、コードが際どい

ウェブワーカーは 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 キャッシュと HTTP キャッシュでコンパイル済みの Wasm バイトコードをキャッシュに保存できる場合、これは最悪の解決策ではありませんが、もっと良い方法があります。

非同期コードをウェブ ワーカーの先頭に移動し、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 です。このオブジェクトの利点の一つは、postMessage() を使用して転送できることです。つまり、Wasm モジュールはメインスレッド(または読み込みとコンパイルだけを扱う別のウェブワーカー)で 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,
  });
});

ウェブワーカー側では、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 });
});

最適: タスクがインライン ウェブ ワーカーで実行され、読み込みとコンパイルが 1 回のみ

HTTP キャッシュを使用する場合でも、(理想的には)キャッシュに保存された Web Worker コードを取得し、ネットワークに到達する可能性があると、コストがかかります。パフォーマンスに関してよくあるコツは、Web Worker をインライン化し、blob: URL として読み込むことです。ただし、ウェブワーカーとメインスレッドのコンテキストは異なるため、同じ JavaScript ソースファイルに基づいていても、コンパイル済みの 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 の作成コードをボタンのイベント リスナーの外に移動します。

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

これまでのコードサンプルでは、永続的なウェブ ワーカーが 1 つ残っていました。次のコードサンプルは、必要に応じて新しいウェブワーカーをアドホックに作成します。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 つあります。[Network] タブの URL リクエストと、コンソールに 2 つの計算タイミングが表示されます。

パーマネント ワーカーを使用する Factorial Wasm デモアプリ。Chrome DevTools を開きました。[Network] タブには blob: URL リクエストが 1 つだけあり、コンソールには 4 つの計算タイミングが表示されます。

まとめ

この投稿では、Wasm を扱う際のパフォーマンス パターンをいくつか確認しました。

  • 原則として、非ストリーミング方法(WebAssembly.compile()WebAssembly.instantiate())よりもストリーミング方法(WebAssembly.compileStreaming()WebAssembly.instantiateStreaming())を優先します。
  • 可能であれば、パフォーマンスの負荷が高いタスクをウェブ ワーカーでアウトソーシングし、Wasm の読み込みとコンパイルの作業をウェブ ワーカーの外部で 1 回だけ行います。このように、Web Worker は、WebAssembly.instantiate() で読み込みとコンパイルが行われたメインスレッドから受け取る Wasm モジュールをインスタンス化するだけで済みます。つまり、Web Worker を永続的に維持すれば、インスタンスをキャッシュに保存できます。
  • 1 つの永続的なウェブ ワーカーを永続的に保持することと、必要なときにアドホックなウェブ ワーカーを作成することが合理的かどうかを慎重に測定してください。また、Web Worker を作成するのに最適なタイミングも考慮する必要があります。メモリの消費と Web Worker のインスタンス化時間だけでなく、同時リクエストを処理する場合もある複雑さも考慮する必要があります。

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

謝辞

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