網頁應用程式的 WebAssembly 效能模式

本指南適用於希望從 WebAssembly 中獲益的網頁開發人員,您將學習如何利用 Wasm 將 CPU 密集型工作外包,並透過執行範例瞭解相關操作。這份指南涵蓋的範圍廣泛,從載入 Wasm 模組的最佳做法,到最佳化編譯和例項化程序,應有盡有。這篇文章會進一步討論將 CPU 密集型工作轉移至 Web Worker,並探討您將面臨的實作決策,例如何時建立 Web Worker,以及是否要讓 Web Worker 永久運作,或在需要時才啟動。本指南會逐步開發方法,並逐一介紹效能模式,直到建議最佳解決方案為止。

假設您有非常 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;
}

}

在本文的其餘部分中,我們假設有一個 Wasm 模組,其編譯方式是使用 Emscripten 在名為 factorial.wasm 的檔案中,以所有程式碼最佳化最佳做法編譯這個 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 中,form 包含 input,並與 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 執行。您知道網頁應用程式會依賴 Wasm 模組來執行 CPU 密集工作,因此應盡早預先載入 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));
});

將工作轉移至 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 模組非同步例項化問題的其中一個方法,就是將 Wasm 模組的載入、編譯和例項化作業全部移至事件事件監聽器,但這表示每收到一則訊息就必須執行這項作業。由於 HTTP 快取和 HTTP 快取可快取編譯的 Wasm 位元碼,因此這並非最糟糕的解決方案,但還是有更好的方法。

將非同步程式碼移至 Web Worker 的開頭,並非實際等待承諾完成,而是將承諾儲存在變數中,這樣程式就會立即移至程式碼的事件事件監聽器部分,不會遺失來自主執行緒的訊息。在事件事件監聽器中,可以等待承諾。

/* 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 中執行,且只會載入及編譯一次

靜態 WebAssembly.compileStreaming() 方法的結果是解析為 WebAssembly.Module 的承諾。這個物件的一項優點是,可以使用 postMessage() 進行轉移。也就是說,Wasm 模組只需在主執行緒 (甚至是另一個純粹負責載入和編譯的 Web Worker) 中載入及編譯一次,然後轉移至負責 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,
  });
});

在 Web Worker 端,只需擷取 WebAssembly.Module 物件並將其例項化即可。由於含有 WebAssembly.Module 的訊息不會串流,Web Worker 中的程式碼現在會使用 WebAssembly.instantiate(),而非先前的 instantiateStreaming() 變體。經過建構的模組會在變數中快取,因此啟動 Web Worker 時,只需要執行一次建構作業。

/* 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 中執行,且只載入及編譯一次

即使使用 HTTP 快取,取得 (理想的) 快取 Web Worker 程式碼,並可能命中網路的成本仍相當高。常見的效能技巧是將 Web Worker 內嵌,並以 blob: 網址載入。這項操作仍需要將編譯的 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。視應用程式而定,您可能需要更積極地建立 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 來評估這項資訊。

目前的程式碼範例會保留一個永久的 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,
  });
});

示範

我們提供兩個範例供您試用。一個使用臨時 Web Worker (原始碼),另一個使用永久 Web Worker (原始碼)。開啟 Chrome 開發人員工具並查看控制台時,您可以看到 User Timing API 記錄,用於測量從按下按鈕到畫面顯示結果所需的時間。「Network」分頁會顯示 blob: 網址要求。在本例中,臨時和永久的時間差約為 3 倍。在實際情況下,人眼無法區分這兩種情況。您實際應用程式的結果可能會有所不同。

使用臨時 Worker 的因式展開 Wasm 示範應用程式。Chrome 開發人員工具已開啟。「Network」分頁中顯示了兩個 blob:網址要求,而控制台則顯示兩個計算時間。

使用永久性 Worker 的 Factorial Wasm 示範應用程式。Chrome 開發人員工具已開啟。網路分頁中只有一個 Blob:網址要求,控制台會顯示四個計算時間。

結論

這篇文章探討了一些處理 Wasm 的效能模式。

  • 一般來說,請優先使用串流方法 (WebAssembly.compileStreaming()WebAssembly.instantiateStreaming()),而非非串流方法 (WebAssembly.compile()WebAssembly.instantiate())。
  • 如果可以,請在 Web Worker 中外包效能較差的工作,並在 Web Worker 外部只執行一次 Wasm 載入和編譯作業。如此一來,Web Worker 只需將從主要執行緒接收的 Wasm 模組例項化,即可在 WebAssembly.instantiate() 中載入及編譯,這表示如果您將 Web Worker 保留,則可將例項快取。
  • 請仔細評估是否要永久保留一個 Web Worker,或是視需要建立臨時 Web Worker。也請思考何時是建立 Web Worker 的最佳時機。您需要考量記憶體用量、Web Worker 例項化時間長度,以及可能需要處理並行要求的複雜性。

如果您考量這些模式,就能順利取得最佳 Wasm 效能。

特別銘謝

本指南由 Andreas HaasJakob KummerowDeepti GandluriAlon ZakaiFrancis McCabeFrançois BeaufortRachel Andrew 審查。