本指南適用於想運用 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()
函式 (位於名為 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 中,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 的開頭,並非實際等待 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 中執行,且只會載入及編譯一次
靜態 WebAssembly.compileStreaming()
方法的結果是會解析為 WebAssembly.Module
的 Promise。這個物件的一項優點是可以使用 postMessage()
轉移。也就是說,Wasm 模組可以在主要執行緒中載入及編譯一次 (甚至可以載入及編譯另一個 Web Worker),然後傳輸至負責 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,
});
});
在 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:
URL 載入。即使 Web Worker 和主執行緒的內容是以相同的 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。視應用程式而定,您可能需要更積極地建立 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 記錄,瞭解從點選按鈕到畫面顯示結果所花費的時間。「網路」分頁會顯示 blob:
URL 要求。在本例中,臨時和永久性之間的時序差異約為 3 倍。實際上,以人眼來看,這兩種情況在本例中無法區分。實際應用程式的結果很可能有所不同。
結論
本文探討了處理 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 Haas、Jakob Kummerow、Deepti Gandluri、Alon Zakai、Francis McCabe、François Beaufort 和 Rachel Andrew 審查。