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

本指南面向希望从 WebAssembly 中受益的 Web 开发者, 您将学习如何利用 Wasm 通过 您可以参考运行中的示例来解决问题。该指南涵盖了从最佳做法和 加载 Wasm 模块以优化其编译和实例化。它 进一步讨论了将 CPU 密集型任务转移到 Web Workers,并深入了解 您会面对这样一个问题: 工作器,以及是让它永久保持活跃状态还是根据需要启动它。通过 Guide 以迭代方式开发方法并介绍一种性能模式 直到为问题提出最佳解决方案为止。

假设

假设您有一项 CPU 密集型任务需要外包给 WebAssembly (Wasm) 以实现接近原生的性能。CPU 密集型任务 计算数值的阶乘。通过 阶乘是整数与其以下所有整数的乘积。对于 例如,四的阶乘(写为 4!)等于 24(即 4 * 3 * 2 * 1)。这些数字很快就会变得越来越大。例如,16!2,004,189,184。更实际的 CPU 密集型任务示例可以是 扫描条形码跟踪光栅图片

factorial() 的高效迭代(而非递归)实现 函数,如以下使用 C++ 编写的代码示例所示。

#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 的文件中将此 factorial() 函数与 Emscripten 配合使用 使用全部 代码优化最佳实践。 如需回顾如何执行此操作,请参阅 使用 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,其 inputoutput 和提交作业配对。 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。如您所知,您的 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() 方法会直接从流式处理中编译 实例化 Wasm 模块 底层来源(如 fetch()),无需 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,第一步是重新构建 应用。主线程现在会创建一个 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 密集型任务,并将结果发送回主线程。 这种方法存在的问题是,使用以下代码实例化 Wasm 模块 WebAssembly.instantiateStreaming() 是一项异步操作。这意味着 因为这是少儿不宜的代码在最坏的情况下,主线程会在以下情况下发送数据: 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。 这个对象的一个优点是,可以使用 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() 变体。实例化的 模块缓存在变量中,因此实例化工作只需执行 运行一次。

/* 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 的 将代码置于按钮的事件监听器之外。

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 您可以永久保留它,也可以随时重新创建。这两种方法 及其优缺点例如,保留一个 永久存在的工作器可能会增加应用的内存占用量, 处理并发任务更加困难,因为您需要以某种方式 返回给请求。另一方面 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 开发者工具并检查控制台,就能看到 Timing API 日志测量从按钮点击到 屏幕上显示的结果“网络”标签页会显示 blob: 网址 请求。在本示例中,临时和永久性之间的时间差异 大约为 3 倍。在实践中,对于人眼而言,两者都是无法区分的, 这种情况。针对您实际生活的应用而得出的结果很可能有所不同。

包含临时 worker 的 Factorial Wasm 演示版应用。Chrome 开发者工具已打开。“Network”标签页中有两个 blob:网址请求;控制台会显示两种计算时间。

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

总结

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

  • 一般来说,最好使用流式传输方法 (WebAssembly.compileStreaming()WebAssembly.instantiateStreaming()) 高于非流式广告(WebAssembly.compile()WebAssembly.instantiate())。
  • 如果可以,将对性能密集型的任务外包给 Web Worker,并执行 Wasm 加载和编译在 Web Worker 之外执行一次。这样, 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