WebAssembly 网络应用程序性能模式

在本指南中,面向希望从 WebAssembly 中获益的 Web 开发者,您将学习如何在运行示例的帮助下,利用 Wasm 外包 CPU 密集型任务。本指南涵盖了从加载 Wasm 模块的最佳实践到优化其编译和实例化的所有内容。它进一步讨论了将 CPU 密集型任务转移到 Web Worker,并探讨了您将面临的实现决策,例如何时创建 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 模块,该模块基于使用 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 模块之前,您需要加载它。在 Web 上,这通过 fetch() API 完成。由于您知道您的 Web 应用程序依赖于 Wasm 模块来执行 CPU 密集型任务,因此您应该尽早预加载 Wasm 文件。您可以使用 启用 CORS 的获取在应用程序的 <head> 部分执行此操作。

<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() 方法的结果是一个 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: URL 加载。这仍然需要将编译后的 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 Worker,则可能会产生大量开销。幸运的是,您可以使用用户计时 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 开发者工具并检查控制台,您可以看到用户计时 API 日志,该日志测量从按钮点击到屏幕上显示结果所花费的时间。网络选项卡显示 blob: URL 请求。在此示例中,临时和永久之间的计时差异约为 3 倍。实际上,对于人眼而言,在这种情况下两者是无法区分的。您自己的实际应用程序的结果很可能会有所不同。

Factorial Wasm demo app with an ad hoc Worker. Chrome DevTools are open. There are two blob: URL requests in the Network tab and the Console shows two calculation timings.

Factorial Wasm demo app with a permanent Worker. Chrome DevTools are open. There is just one blob: URL request in the Network tab and the Console shows four calculation timings.

结论

这篇文章探讨了一些处理 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 HaasJakob KummerowDeepti GandluriAlon ZakaiFrancis McCabeFrançois BeaufortRachel Andrew 审阅。