如何使用 WebAssembly 提升性能

阅读数:2789 2019 年 4 月 23 日

在之前的文章中,我谈到了如何借助 WebAssembly 将 C/C++ 库生态系统引入到 Web 中。squoosh是一个广泛使用了 C/C++ 库的 Web 应用程序,使用各种从 C++ 编译为 WebAssembly 的 codec 来压缩图像。

WebAssembly 是一种低级虚拟机,可以保存在.wasm 文件中的字节码。这种字节代码是强类型和结构化的,在宿主系统上经过编译很优化后可以运行得比 JavaScript 更快。

根据我的经验,Web 的大多数性能问题都是由强制布局和过多的绘制引起的,偶尔也需要执行一些耗时的高计算成本任务,而 WebAssembly 在这个时候就可以派上用场。

热路径

在 squoosh 中,我们提供了一个JavaScript 函数,用于将图像缓冲区旋转 90 度。虽然OffscreenCanvas也可以用来实现这个功能,但它并不支持我们的所有目标浏览器,而且在 Chrome 中还有一些小 bug。

这个函数迭代输入图像的每个像素,并将它们复制到输出图像的不同位置,以此来实现旋转。对于一张 4094×4096 像素的图像(1600 万像素),它需要进行 1600 万次内部代码块迭代,也就是我们所说的“热路径”。尽管迭代次数很多,但我们测试的三个浏览器中有两个可以在 2 秒或更短时间内完成迭代。对于这种迭代任务,这样的时间是可接受的。

复制代码
for (let d2 = d2Start; d2 >= 0 && d2 < d2Limit; d2 += d2Advance) {
  for (let d1 = d1Start; d1 >= 0 && d1 < d1Limit; d1 += d1Advance) {
    const in_idx = ((d1 * d1Multiplier) + (d2 * d2Multiplier));
    outBuffer[i] = inBuffer[in_idx];
    i += 1;
  }
}
{1}

但是,其中有一个浏览器需要 8 秒钟。浏览器优化 JavaScript 的方式非常复杂,不同的引擎所优化的东西是不一样的。有一些针对原始执行进行了优化,有一些针对与 DOM 的交互进行了优化。因此,在一些浏览器中,我们遇到了未经优化的路径。

WebAssembly 完全是围绕提升原始执行速度而构建的。因此,如果我们希望这些代码能够获得快速、可预测的跨浏览器性能,可以考虑使用 WebAssembly。

WebAssembly 实现可预测的性能

通常,JavaScript 和 WebAssembly 可以达到相同的峰值性能。但是,对于 JavaScript 来说,这种性能只能在“快速路径”上实现,而且要保持在“快速路径”上并不容易。WebAssembly 的一个主要优势是可预测的性能,即使是跨浏览器也是如此。严格的类型和低级架构可以让编译器做出更强的保证,只需要对 WebAssembly 代码优化一次,就可以始终使用“快速路径”。

之前我们使用了 C/C++ 库,并将它们编译为 WebAssembly,以便在 Web 上使用它们。但实际上,我们并没有真正触及库的代码,我们只是写了少量的 C/C++ 代码作为浏览器和库之间的桥梁。但这次不一样:我们想要从头开始写一些东西,以便利用 WebAssembly 的优势。

WebAssembly 架构

在开始写代码之前,有必要先了解一下 WebAssembly。

引用 WebAssembly.org 的话:

WebAssembly(缩写为 Wasm)是一种用于栈虚拟机的二进制指令格式。Wasm 被设计为一个可移植的目标,用于编译 C/C++/Rust 等高级语言,支持在 Web 上部署客户端和服务器应用程序。

在将一段 C 语言或 Rust 代码编译为 WebAssembly 后,会得到一个包含模块声明的.wasm 文件。声明中包含了一个导入列表、一个导出列表(函数、常量、内存块)和函数的二进制指令。

有一些需要注意的东西:WebAssembly 虚拟机栈并没有保存在 WebAssembly 模块所使用的内存块中。虚拟机栈完全处在虚拟机内部,Web 开发人员无法访问它(除了通过 DevTools)。因此,我们可以编写完全不需要任何额外内存(只是有虚拟机内部栈)的 WebAssembly 模块。

在我们的例子中,我们需要使用一些额外的内存来访问图像的像素并生成图像的旋转版本。这个时候要用到 WebAssembly.Memory。

内存管理

通常,一旦你使用了额外的内存,就需要以某种方式来管理内存。内存的哪些部分正在使用中?哪些部分是可用的?例如,C 语言提供了 malloc(n) 函数,用来查找连续 n 个字节的内存空间。这种功能也被称为“分配器”。分配器需要被包含在 WebAssembly 模块中,这样会增加文件的大小。根据算法的不同,这些内存管理功能的体积和性能可能会有很大差异,这就是为什么很多语言提供了多种实现(“dmalloc”、“emmalloc”、“wee_alloc”……)。

在我们的例子中,在运行 WebAssembly 模块之前,我们知道输入图像的尺寸(以及输出图像的尺寸)。通常我们会将输入图像的 RGBA 缓冲区作为参数传给 WebAssembly 函数,并将旋转后的图像作为值返回。要生成这个返回值,我们需要使用分配器。但因为我们知道所需的内存总量(输入图像大小的两倍,一次用于输入,一次用于输出),所以可以使用 JavaScript 将输入图像放入 WebAssembly 内存,运行 WebAssembly 模块生成旋转图像,然后使用 JavaScript 回读结果。这样我们就可以不使用内存管理!

https://storage.googleapis.com/webfundamentals-assets/hotpath-with-wasm/animation_2_vp8.webm

如果你看一下原始的 JavaScript 函数,你会发现它其实是一些纯粹的计算代码,没有使用特定的 JavaScript API,所以可以很容易地将这些代码移植到其他语言。我们评估了 3 种可编译为 WebAssembly 的语言:C/C++、Rust 和 AssemblyScript。我们唯一要解决的问题是:如何在不使用内存管理功能的情况下访问原始内存?

C 语言和 Emscripten

Emscripten 是用于将 C 语言编译成 WebAssembly 的编译器。Emscripten 的目标是成为 GCC 或 clang 等知名 C 语言编译器的直接替代品。这是 Emscripten 的核心任务,它旨在尽可能简单地将现有 C 语言和 C++ 代码编译为 WebAssembly。

访问原始内存是 C 语言的本质,指针的存在就是为了这个:

复制代码
uint8_t* ptr = (uint8_t*)0x124;
ptr[0] = 0xFF;

我们将数字 0x124 转换为指向无符号 8 位整数(或字节)的指针,将 ptr 变量变成从内存地址 0x124 开始的数组,并且可以像使用其他数组一样使用它。在我们的例子中,我们想要重新排序图像的 RGBA 缓冲区,以便实现图像旋转。要移动一个像素,我们需要每次移动 4 个连续字节(每个通道一个字节:R、G、B 和 A)。为此,我们创建了一个无符号的 32 位整数数组。按照惯例,我们的输入图像将从地址 4 开始,输出图像从输入图像结束位置开始:

复制代码
int bpp = 4;
int imageSize = inputWidth * inputHeight * bpp;
uint32_t* inBuffer = (uint32_t*) 4;
uint32_t* outBuffer = (uint32_t*) (inBuffer + imageSize);
for (int d2 = d2Start; d2 >= 0 && d2 < d2Limit; d2 += d2Advance) {
  for (int d1 = d1Start; d1 >= 0 && d1 < d1Limit; d1 += d1Advance) {
    int in_idx = ((d1 * d1Multiplier) + (d2 * d2Multiplier));
    outBuffer[i] = inBuffer[in_idx];
    i += 1;
  }
}

在将整个 JavaScript 函数移植到 C 语言后,可以使用 emcc 编译 C 文件:

复制代码
$ emcc -O3 -s ALLOW_MEMORY_GROWTH=1 -o c.js rotate.c

与往常一样,Emscripten 会生成一个叫作 c.js 的胶水代码文件和一个叫作 c.wasm 的 wasm 模块。请注意,wasm 模块被压缩后只有 260 字节左右,而胶水代码在压缩大约是 3.5KB。经过一些调整之后,我们可以去掉胶水代码,并使用普通 API 来实例化 WebAssembly 模块。

Rust

Rust 是一门全新的现代编程语言,它提供了丰富的类型系统,没有运行时和所有权模型,可确保内存安全性和线程安全性。Rust 还将 WebAssembly 视为一等公民,而且 Rust 团队还为 WebAssembly 生态系统贡献了很多优秀的工具。

其中一个工具是由 rustwasm 工作组开发的 wasm-pack(https://rustwasm.github.io/wasm-pack/)。wasm-pack 可以将你的代码转换为一个对 Web 友好的模块,支持 webpack 等捆绑器,但目前仅适用于 Rust。这个工作小组正在考虑增加对其他语言的支持。

Rust 中的切片相当于 C 语言中的数组。就像在 C 语言中一样,我们需要创建切片。这违反了 Rust 的内存安全模型,因此我们必须使用 unsafe 关键字来编写不遵循内存安全模型的代码。

复制代码
let imageSize = (inputWidth * inputHeight) as usize;
let inBuffer: &mut [u32];
let outBuffer: &mut [u32];
unsafe {
  inBuffer = slice::from_raw_parts_mut::<u32>(4 as *mut u32, imageSize);
  outBuffer = slice::from_raw_parts_mut::<u32>((imageSize * 4 + 4) as *mut u32, imageSize);
}
for d2 in 0..d2Limit {
  for d1 in 0..d1Limit {
    let in_idx = (d1Start + d1 * d1Advance) * d1Multiplier + (d2Start + d2 * d2Advance) * d2Multiplier;
    outBuffer[i as usize] = inBuffer[in_idx as usize];
    i += 1;
  }
}

编译 Rust 文件:

复制代码
$ wasm-pack build

这个命令将产生一个 7.6KB 的 wasm 模块,以及大约 100 个字节的胶水代码(压缩之后)。

AssemblyScript

AssemblyScript 是一个相当年轻的项目,用于将 TypeScript 编译成 WebAssembly。AssemblyScript 使用与 TypeScript 相同的语法,但使用了自己的标准库。它们的标准库模拟了 WebAssembly 的功能。这意味你无法将任意 TypeScript 代码编译成 WebAssembly,但确实意味着你不必为了编写 WebAssembly 而去学习新的编程语言!

复制代码
for (let d2 = d2Start; d2 >= 0 && d2 < d2Limit; d2 += d2Advance) {
  for (let d1 = d1Start; d1 >= 0 && d1 < d1Limit; d1 += d1Advance) {
    let in_idx = ((d1 * d1Multiplier) + (d2 * d2Multiplier));
    store<u32>(offset + i * 4 + 4, load<u32>(in_idx * 4 + 4));
    i += 1;
  }
}

因为 rotate() 函数具有较小的类型表面,可以很容易将其移植到 AssemblyScript。AssemblyScript 提供了用于访问原始内存的函数load<T>(ptr: usize)store<T>(ptr: usize, value: T)。要编译我们的AssemblyScript 文件,只需要安装 AssemblyScript/assemblyscript 包,并运行:

复制代码
$ asc rotate.ts -b assemblyscript.wasm --validate -O3

AssemblyScript 将生成约 300 字节的 wasm 模块,并且没有胶水代码。这个模块可以与 WebAssembly API 一起使用。

瘦身

与其他两种语言相比,Rust 的 7.6KB 显得非常大。WebAssembly 生态系统中有一些工具可以用来分析 WebAssembly 文件,可以告诉你发生了什么,并帮你改善这种情况。

twiggy

twiggy是 Rust 团队开发的另一个工具,可以从 WebAssembly 模块中提取大量有用的信息。这个工具并不是特定于 Rust 的,可用来检查模块调用图、找出未使用或多余的部分,以及哪些部分占用了模块的文件大小。可以使用 twiggy 的 top 命令来查看模块文件的组成:

复制代码
$ twiggy top rotate_bg.wasm

我们可以看到,大部分文件大小来自分配器。这个有点让我们感到惊讶,因为我们的代码没有使用动态分配功能。另一个占用较大体积的是“函数名”。

wasm-strip

wasm-strip 是来自WebAssembly Binary Toolkit,简称为 wabt 的一个工具。它提供了一些工具,可用于检查和操作 WebAssembly 模块。wasm2wat 是一个反汇编程序,可以将二进制 wasm 模块转换为人类可读的格式。wabt 还包含了 wat2wasm,可以将人类可读的格式转换回二进制 wasm 模块。我们确实有使用这两个工具来检查 WebAssembly 文件,不过我们发现 wasm-strip 是最有用的。wasm-strip 从 WebAssembly 模块中移除了不必要的部分和元数据:

复制代码
$ wasm-strip rotate_bg.wasm

这样就可以将 Rust 模块文件大小从 7.5KB 减小到 6.6KB(在压缩之后)。

wasm-opt

wasm-opt 是来自Binaryen的一个工具。它尝试基于字节码对 WebAssembly 模块进行大小和性能方面的优化。Emscripten 已经在使用这个工具,有些编译器则没有。使用这些工具来节省一些额外的字节是个好主意。

复制代码
wasm-opt -O3 -o rotate_bg_opt.wasm rotate_bg.wasm

通过使用 wasm-opt,我们可以减少另外一些字节,在压缩之后只有 6.2KB。

#![no_std]

经过一些咨询和研究,我们使用#![no_std]来重新编写 Rust 代码,这样就可以不使用 Rust 的标准库。这样就可以完全禁用动态内存分配,从而从模块中删除了分配器代码。使用以下命令编译 Rust 文件:

复制代码
$ rustc --target=wasm32-unknown-unknown -C opt-level=3 -o rust.wasm rotate.rs

在使用了 wasm-opt 和 wasm-strip 之后,压缩的 wasm 模块只剩下 1.6KB。虽然它仍然比 C 语言编译器和 AssemblyScript 生成的模块大,但也足以称得上是一个轻量级的模块。

性能

除了文件大小,我们还需要优化性能。那么我们应该如何衡量性能?它们的结果又是怎样的呢?

如何进行基准测试

尽管 WebAssembly 是一种低级的字节码格式,但仍需要通过编译器生成特定于主机的机器码。就像 JavaScript 一样,编译器包含了多个阶段的工作。简单地说:第一阶段编译速度较快,但生成的代码运行速度较慢。在模块开始运行后,浏览器就会观察哪些部分是经常使用的,并通过一个更优化但速度更慢的编译器发送这些部分。

我们的用例很有趣,旋转图像的代码可能会被使用一次,或者两次。因此,在绝大多数情况下,我们无法从优化编译器中获得好处。在进行基准测试时要记住这一点。循环运行 WebAssembly 模块 10,000 次会产生不真实的结果。为了获得更真实的数字,我们应该只运行一次模块,并根据单次运行的结果做出判断。

性能比较


这两张图是相同数据的不同视图。在第一张图中,我们根据浏览器来比较,在第二张图中,我们根据使用的语言来比较。请注意,我使用了对数时间尺度,而且所有基准测试使用了相同的 1600 万像素的测试图像和相同的主机。

从图中可以看出,我们解决了原始性能问题:所有 WebAssembly 模块的运行时间都在大约 500 毫秒或更短的时间内。这证实了我们在开始时的假设:WebAssembly 为我们提供了可预测的性能。无论我们选择哪种语言,浏览器和语言之间的差异都很小。确切地说:JavaScript 的跨浏览器标准偏差约为 400 毫秒,而 WebAssembly 模块的跨浏览器标准偏差约为 80 毫秒。

工作量

另一个度量指标是创建 WebAssembly 模块并将其集成到 squoosh 的工作量。我们很难使用准确的数值来表示工作量,所以我不会创建任何图表,不过我想指出一些东西:

AssemblyScript 不仅让我们可以使用 TypeScript 来编写 WebAssembly,进行代码评审也非常容易,而且还可以生成非常小且具有良好性能的无胶水 WebAssembly 模块。

Rust 与 wasm-pack 结合使用也非常方便,但在大型的 WebAssembly 项目(需要用到绑定和内存管理)中表现更好。我们必须付出额外的工作量才能获得有竞争力的文件大小。

C 语言和 Emscripten 可以生成非常小巧且高性能的 WebAssembly 模块,但是如果没有勇气直接使用胶水代码并将其缩减到最基本的需求,那么总体大小(WebAssembly 模块 + 胶水代码)就会变得非常大。

结论

那么,如果你有一个 JS 热路径并希望让它运行得更快或更者像 WebAssembly 那样保持稳定的性能,你应该使用什么语言?答案是:取决于具体情况。那么我们发布的是哪个?

在比较了不同语言的模块大小 / 性能权衡之后,我们发现最好的选择似乎是 C 语言或 AssemblyScript。但我们最后决定发布Rust 版本。我们做出这个决定基于多个原因:到目前为止,squoosh 的所有 codec 都是使用 Emscripten 编译的。我们希望能够扩展我们对 WebAssembly 生态系统的了解,并在生产环境中使用不同的语言。AssemblyScript 是一个很好的选择,但它相对年轻,编译器不像 Rust 编译器那样成熟。

虽然 Rust 和其他语言大小之间的文件大小差异在散点图中看起来非常明显,但在现实中的差异并没有那么大:即使是在 2G 网络上加载 500B 或 1.6KB 也只需要不到 1/10 秒。而且 Rust 很快就会在模块尺寸方面缩小差距。

在运行时性能方面,Rust 在浏览器中的平均速度比 AssemblyScript 快。特别是在大型项目中,Rust 更有可能在无需手动优化的情况下生成更快的代码。

AssemblyScript 允许 Web 开发人员在无需学习新语言的情况下生成 WebAssembly 模块。AssemblyScript 团队在非常积极地改进他们的工具链。我们会持续关注 AssemblyScript。

英文原文:https://developers.google.com/web/updates/2019/02/hotpath-with-wasm

收藏

评论

微博

发表评论

注册/登录 InfoQ 发表评论