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

在本指南中,適合想要受益於 WebAssembly 的網頁程式開發人員,您將參考如何執行範例,瞭解如何使用 Wasm 將耗用大量 CPU 的工作外包。本指南涵蓋所有內容,包括載入 Wasm 模組的最佳做法及最佳化編譯和例項化的最佳做法。我們將進一步探討如何將耗用大量 CPU 的工作工作轉移至網路工作站,並思考您何時會遇到的實作決策,例如建立 Web Worker,以及要永久保持運作,還是在需要時啟動。該指南會反覆開發方法,並一次導入一個效能模式,直到指出問題的最佳解決方案為止。

假設

假設您有一個需要大量 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;
}

}

至於本文其餘部分,假設有一個使用所有程式碼最佳化最佳做法,透過 Emscripten 編譯這個 factorial() 函式的 Wasm 模組,在名為 factorial.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 和提交 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() 方法會編譯 直接從串流的基礎來源執行個體化 Wasm 模組,因此不需要fetch()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 資源,可能會使整個應用程式遭到封鎖。常見做法是將這類工作轉移到 Web Worker。

重組主執行緒

如要將耗用大量 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 執行,但程式碼不適合

Web Worker 會將 Wasm 模組執行個體化,並在收到訊息時執行耗用大量 CPU 的工作,並將結果傳回主執行緒。這種做法的問題是,使用 WebAssembly.instantiateStreaming() 將 Wasm 模組執行個體化為非同步作業。這代表程式碼不適合使用。最糟糕的情況是,當網路工作站尚未準備就緒時,主要執行緒就會傳送資料,而網路工作站則不會收到訊息。

/* 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 的起始位置,並實際上不會等待承諾執行,而是將 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 中執行,且只會載入及編譯一次

靜態 WebAssembly.compileStreaming() 方法的結果代表解析為 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,
  });
});

在 Web Worker 端,剩下的工作就是擷取 WebAssembly.Module 物件並將其例項化。由於含有 WebAssembly.Module 的訊息並未串流,因此網路工作站中的程式碼現在會使用 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 建立程式碼移到按鈕的事件監聽器之外。

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 評估這一點。

截至目前為止,這些程式碼範例都已保留一個永久性 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,
  });
});

試聽帶

這裡有兩個示範模式供您試用。其中一個具備臨時網路工作站 (原始碼),搭配永久網路工作站 (原始碼) 使用。如果您開啟 Chrome 開發人員工具並查看主控台,可以查看 User Tiiming API 記錄,該記錄會評估從點選按鈕到顯示畫面結果所花費的時間。「Network」分頁會顯示 blob: 網址要求。在本例中,臨時和永久性之間的時間差異約為 3 倍。實際上,從實務上來說,兩者都無法區分。因此,應用程式的實際運作結果可能會有所不同。

搭配臨時工作站使用 Factorial Wasm 試用版應用程式。Chrome 開發人員工具已開啟。有兩個 blob:網路分頁中的網址要求,控制台會顯示兩個計算時間。

搭配永久工作站的 Factorial Wasm 試用版應用程式。Chrome 開發人員工具已開啟。只有一個 blob:「網路」分頁中的網址要求,控制台顯示四個計算時間。

結論

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

  • 一般而言,建議選擇串流方法 (WebAssembly.compileStreaming()WebAssembly.instantiateStreaming()),而不要使用非串流對應方法 (WebAssembly.compile()WebAssembly.instantiate())。
  • 如果可以的話,請在網路工作站外包出著重效能的工作,並在 Web Worker 之外執行 Wasm 載入和編譯作業一次。這樣一來,Web Worker 只需要將 Wasm 模組從主要執行緒 (因為使用 WebAssembly.instantiate() 載入和編譯作業) 所接收到的 Wasm 模組例項化,也就是說,只要您永久保留 Web Worker,就可以快取該執行個體。
  • 審慎評估是否適合永久使用一個永久 Web Worker,或隨時視需要建立臨時網路工作站。此外,何時是建立網路工作站的最佳時機。要考量的重點包括記憶體使用量、Web Worker 執行個體化時間長度,但可能也需要處理並行要求。

將這些模式納入考量後,您就能朝著最佳 Wasm 效能邁進。

特別銘謝

本指南由 Andreas HaasJakob KummerowDeepti GandluriAlon ZakaiFrancis McCabeFrançois BeaufortRachel Andrew 檢閱。