本指南旨在協助網頁程式開發人員,充分發揮 WebAssembly 的效益,讓您瞭解如何透過執行範例,利用 Wasm 外包會耗用大量 CPU 的工作。這份指南涵蓋的範圍廣泛,從載入 Wasm 模組的最佳做法,到最佳化編譯和例項化程序,應有盡有。後續內容則說明如何將需要大量 CPU 的工作轉移到網路工作站,並研究您會瞭解自己在何時建立網路工作站,以及要在何時讓網路工作站持續運作,或要在需要時啟動。本指南會逐步開發方法,並逐一介紹效能模式,直到建議最佳解決方案為止。
假設
假設您有一項會耗用大量 CPU 的工作,並將工作外包給 WebAssembly (Wasm),以獲得近乎原生的效能。本指南中用來示範 CPU 密集工作的工作,是計算某個數字的階乘。階乘是整數與其下方所有整數的乘積。例如,四的階乘 (以 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 模組,才能使用 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 資源的工作,就可能會阻斷整個應用程式。常見做法是將這類工作移至 Web Worker。
重新建構主執行緒
如要將耗用大量 CPU 的工作移至 Web Worker,第一步是重新建構應用程式。主執行緒現在會建立 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 密集工作負載的 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
的訊息並未串流,因此網路工作站中的程式碼現在會使用 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 模組傳遞至網路工作站以進行例項化,因為網路工作站和主執行緒的結構定義不同,即使以相同的 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 的引導程式碼可能相當複雜,因此如果每次都建立新的引導程式碼,可能會造成大量額外負擔。幸好,您可以使用 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,
});
});
示範
你可以試用兩個示範模式。一個使用臨時網路工作站 (原始碼),另一個使用永久網路工作站 (原始碼)。如果您開啟 Chrome 開發人員工具並檢查控制台,就可以查看 UserTiming API 記錄,以評估從點選按鈕到畫面上顯示結果所需的時間。「Network」分頁會顯示 blob:
網址要求。在本例中,臨時和永久的時間差約為 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 的最佳時機。考量的因素包括記憶體耗用量、網路工作站執行個體化持續時間,以及可能必須處理並行要求的複雜性。
一旦將這些模式納入考量,就代表您正朝著正確的方向取得最佳效能。
特別銘謝
本指南由 Andreas Haas、Jakob Kummerow、Deepti Gandluri、Alon Zakai、Francis McCabe、François Beaufort 和 Rachel Andrew 審查。