适用于 Web 应用的 WebAssembly 性能模式

本指南面向希望从 WebAssembly 中受益的 Web 开发者,将通过正在运行的示例了解如何使用 Wasm 外包 CPU 密集型任务。该指南涵盖了从加载 Wasm 模块的最佳做法到优化其编译和实例化的方方面面。并进一步讨论了将 CPU 密集型任务迁移到 Web 工作器的任务,并研究了您将遇到的实现决策,例如何时创建 Web Worker,以及是使其永久保持活动状态还是在需要时启动它。该指南以迭代方式开发该方法,一次引入一种性能模式,直到为问题推荐最佳解决方案。

假设

假设您有一个 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 模块,该模块基于使用所有代码优化最佳实践在名为 factorial.wasm 的文件中使用 Emscripten 编译此 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,其中带有与 output 配对的 input 和一个提交 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 模块,然后才能使用该模块。在 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 工作器。

重构主线程

为了将 CPU 密集型任务移至 Web 工作器,第一步是重构应用。主线程现在会创建一个 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 工作器)中加载和编译一次,然后转移到负责 CPU 密集型任务的 Web 工作器。以下代码展示了此流程。

/* 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 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 工作器代码并可能连接到网络的成本很高。一种常见的性能技巧是内嵌 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 的结果映射回请求。另一方面,Web 工作器的引导代码可能相当复杂,因此,如果您每次都创建一个新代码,就会产生大量开销。幸运的是,您可以使用 User Timing API 衡量此指标。

到目前为止,代码示例一直保留一个永久性的 Web Worker。以下代码示例会根据需要创建新的 Web 工作器临时创建任务。请注意,您需要自行跟踪终止 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: 网址请求。在此示例中,临时和永久性设置之间的时间差约为 3 倍。在实践中,对人眼而言,两者在这种情况下是无法区分的。针对您自己的实际应用,结果很可能有所不同。

带有临时 worker 的阶乘 Wasm 演示应用。Chrome 开发者工具已打开。其中有两个 blob:“网络”标签页中的网址请求和控制台会显示两种计算时间。

具有永久工作器的阶乘 Wasm 演示应用。Chrome 开发者工具已打开。“Network”标签页中只有 1 个 blob:“网址”请求,而控制台会显示四种计算时间。

总结

这篇博文探索了处理 Wasm 的一些性能模式。

  • 一般来说,与非流式传输方法(WebAssembly.compile()WebAssembly.instantiate())相比,首选流式传输方法(WebAssembly.compileStreaming()WebAssembly.instantiateStreaming())。
  • 如果可以的话,将性能要求较高的任务外包到 Web 工作器中,并且仅在 Web 工作器外部执行一次 Wasm 加载和编译工作。这样一来,Web Worker 只需实例化从主线程(加载和编译发生在 WebAssembly.instantiate())的 Wasm 模块,这意味着如果您永久保留 Web Worker,该实例就会被缓存。
  • 请仔细衡量是永久保留一个永久性 Web Worker,还是在需要时创建临时 Web Worker。此外,还要考虑何时是创建 Web Worker 的最佳时机。要考虑的因素包括内存消耗、Web 工作器实例化时长,以及可能必须处理并发请求的复杂性。

如果您将这些模式纳入考量,Wasm 性能的优化就有望实现。

致谢

本指南由 Andreas HaasJakob KummerowDeepti GandluriAlon ZakaiFrancis McCabeFrançois BeaufortRachel Andrew 审阅。