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

このガイドは、WebAssembly を利用したいと考えているウェブ デベロッパー向けに、 Wasm を利用して CPU 使用率の高いタスクをアウトソーシングする方法、 実行例のヘルプをご覧ください。ベスト プラクティスから、Google Workspace の Wasm モジュールの読み込みによるコンパイルとインスタンス化の最適化。これは、 さらに、CPU 使用率の高いタスクをウェブ ワーカーにシフトすることについて説明し、 実装に関する意思決定が頭に浮かびます ワーカーを永続的に存続させるか、必要に応じてスピンアップするかを指定します。「 手法を繰り返し開発し、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.wasm というファイルに Emscripten を含む factorial() 関数がある すべて使用 コード最適化のベスト プラクティスをご覧ください。 方法については、 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 を組み合わせた form があり、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ご存じのとおり、ウェブアプリはインフラストラクチャの Wasm モジュールに依存 CPU 使用率の高いタスクの場合は、できるだけ早く Wasm ファイルをプリロードする必要があります。マイページ 使用します。 CORS 対応の取得 (アプリの <head> セクション内)。

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

実際には、fetch() API は非同期であるため、await 表示されます。

fetch('factorial.wasm');

次に、Wasm モジュールをコンパイルしてインスタンス化します。Google Meet の 呼び出される関数 WebAssembly.compile() (プラス WebAssembly.compileStreaming()) および WebAssembly.instantiate() 担いますが、代わりに WebAssembly.instantiateStreaming() メソッドはコンパイルし、ストリーミングされたインスタンスから直接 Wasm モジュールをインスタンス化 fetch() などの基盤となるソースがあり、await は必要ありません。この方法が最も効率的です。 Wasm コードを読み込むための最適な方法ですWasm モジュールが BigQuery の 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) });
});

悪い例: タスクはウェブ ワーカーで実行されるが、コードは際どい

ウェブワーカーは Wasm モジュールをインスタンス化し、メッセージを受信すると CPU 使用率の高いタスクを実行し、結果をメインスレッドに返します。 この方法の問題は、 WebAssembly.instantiateStreaming() は非同期オペレーションです。つまり 際立たなくなります。最悪の場合、メインスレッドが ウェブ ワーカーの準備ができておらず、ウェブ ワーカーはメッセージを受信しません。

/* 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 モジュールのインスタンス化に関する問題の回避策の一つは、 Wasm モジュールの読み込み、コンパイル、インスタンス化のすべてをイベントに移動する つまり、この作業はすべてのインスタンスに対して 受信メッセージを返します。HTTP キャッシュと 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 });
});

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

静的な WebAssembly.compileStreaming() メソッドは Promise で解決され、 WebAssembly.Module。 このオブジェクトの優れた機能の一つは、 postMessage()。 つまり、Wasm モジュールの読み込みとコンパイルはメイン モジュールで一度だけ (読み込みとコンパイルだけを扱う別のウェブ ワーカーでも) CPU 使用率の高いトラフィックを処理するウェブ ワーカーに タスクを実行します。次のコードに、このフローを示します。

/* 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 を含むメッセージは ウェブ ワーカーのコードでは、 WebAssembly.instantiate() 以前の instantiateStreaming() バージョンではなく、インスタンス化された 変数にキャッシュされるため、インスタンス化作業は 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 });
});

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

HTTP キャッシュを使用する場合でも、キャッシュされたウェブ ワーカーのコードを取得し、 コストが高くなる可能性がありますパフォーマンスのコツとして、 ウェブワーカーをインライン化し、blob: URL として読み込みます。この場合も、 インスタンス化のためにウェブ ワーカーに渡す、コンパイル済みの Wasm モジュールは、 メインスレッドのコンテキストは、たとえメインスレッドが 基づいています。

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

ウェブ ワーカーの作成の遅延または先行する

ここまでのところ、すべてのコードサンプルでウェブ ワーカーはオンデマンドで遅延していました。つまり、 トリガーします。アプリケーションによっては、アプリケーション コードを より積極的にウェブワーカーを作成するようにします。たとえば、アプリがアイドル状態のときや、 自動的に行われます。そのため、ウェブワーカーで作成されたジョブを ボタンのイベント リスナーの外側に置く必要があります。

const worker = new Worker(blobURL);

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

Web Worker を常に使用する

1 つの疑問点は、ウェブ ワーカーを永続ボリュームに保持すべきかどうかを 永続的に再利用することも、必要なときにいつでも再作成することもできます。どちらの方法も メリットとデメリットがあります。たとえば、ウェブ アプリケーションを ワーカーを永続的に動作させると、アプリのメモリ使用量が増加し、 なんらかの方法で結果をマッピングする必要があるため、同時実行タスクの処理が難しくなる リクエストに戻ります一方 ワーカーのブートストラップ コードはかなり複雑になる可能性があるため、 オーバーヘッドが発生するためです。幸いこれは 測定します User Timing API:

ここまでのコードサンプルでは、1 つの永続的なウェブ ワーカーを保持しています。次の いつでも新しいウェブ ワーカーをアドホックに作成できます。なお、 追跡するために ウェブワーカーの終了 できます。(コード スニペットではエラー処理がスキップされますが、 成功か失敗かにかかわらず、すべてのケースで必ず終了してください)。

/* 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 を開いてコンソールを確認すると、 ボタンのクリックから 表示されます。[ネットワーク] タブに blob: の URL が表示される できます。この例では、アドホックと永続化のタイミングの 約 3 倍です。実際には、この 2 つは人間には見分けがつかないので、 あります。実際のアプリでは、結果はそれぞれ異なります。

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

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

まとめ

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

  • 原則として、ストリーミング方式を優先して (WebAssembly.compileStreaming()WebAssembly.instantiateStreaming()) 他のアセット(WebAssembly.compile() および WebAssembly.instantiate() など)。
  • 可能であれば、パフォーマンスが重視されるタスクをウェブワーカーでアウトソーシングして、Wasm を実行します。 読み込みとコンパイルは、ウェブワーカーの外部で 1 回だけ行います。これにより、 ウェブ ワーカーは、メインシステムから受け取った Wasm モジュールをインスタンス化するだけでよい 読み込みとコンパイルが実行された WebAssembly.instantiate()。次の場合にインスタンスをキャッシュに保存できます。 永続性を確保する必要があります。
  • 永続ウェブ ワーカーを 1 つ保持することが適切かどうかを慎重に測定する 必要なときにいつでもアドホックのウェブワーカーを作成できます。また、 Web Worker を作成する最適なタイミングを見極めます。重要ポイント メモリ消費、ウェブワーカーのインスタンス化時間 同時リクエストを処理する必要がある場合の複雑さも増します。

これらのパターンを考慮に入れれば、 Wasm のパフォーマンス。

謝辞

このガイドをレビューしたユーザー Andreas Haas 氏 Jakob Kummerow 氏、 Deepti Gandluri Alon ZakaiFrancis McCabeFrançois Beaufort Rachel Andrew