本指南面向希望从 WebAssembly 中受益的 Web 开发者,通过一个正在运行的示例,介绍了如何利用 Wasm 将 CPU 密集型任务外包出去。本指南涵盖了从加载 Wasm 模块的最佳实践到优化其编译和实例化等方方面面的内容。该部分进一步探讨了将 CPU 密集型任务转移到 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 模块,该模块基于使用所有代码优化最佳实践将此 factorial()
函数通过 Emscripten 编译到名为 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
,其中包含与 output
和提交 button
配对的 input
。这些元素会根据其名称从 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 模块,然后才能使用它。在 Web 上,此操作是通过 fetch()
API 完成的。您知道,您的 Web 应用依赖于 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 缓存以及能够缓存已编译 Wasm 字节码的 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 });
});
良好:任务在 Web Worker 中运行,并且只加载和编译一次
静态 WebAssembly.compileStreaming()
方法的结果是一个 promise,会解析为 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
的消息不会流式传输,因此 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。因此,请将 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 DevTools 并查看控制台,则可以看到 User Timing API 日志,这些日志用于衡量从点击按钮到屏幕上显示结果所需的时间。“网络”标签页会显示 blob:
网址请求。在此示例中,临时和永久性连接之间的时间差约为 3 倍。在实践中,在这种情况下,人眼无法区分这两者。您自己实际应用的结果很可能会有所不同。
总结
本文探讨了一些处理 Wasm 的性能模式。
- 一般而言,应优先使用流式传输方法 (
WebAssembly.compileStreaming()
和WebAssembly.instantiateStreaming()
),而不是非流式传输方法 (WebAssembly.compile()
和WebAssembly.instantiate()
)。 - 如果可以,请将性能要求较高的任务外包到 Web Worker 中,并仅在 Web Worker 之外执行一次 Wasm 加载和编译工作。这样一来,Web Worker 只需实例化从使用
WebAssembly.instantiate()
进行加载和编译的主要线程收到的 Wasm 模块,这意味着,如果您永久保留 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 审核。