将应用的 JavaScript 中的热路径替换为 WebAssembly

速度始终如一

在我的之前 文章中,我介绍了 WebAssembly 如何让您能够将 C/C++ 的库生态系统引入到 Web 中。squoosh 是我们的一款 Web 应用,它广泛使用 C/C++ 库,可让您使用从 C++ 编译为 WebAssembly 的各种编解码器压缩图片。

WebAssembly 是一种底层虚拟机,用于运行存储在 .wasm 文件中的字节码。这种字节码采用的是强类型和结构,因此与 JavaScript 相比,它可以更快地针对主机系统进行编译和优化。WebAssembly 提供了一个环境,可运行从一开始就考虑了沙盒化和嵌入的代码。

根据我的经验,网络上的大多数性能问题都是由强制布局和过度绘制导致的,但应用偶尔需要执行计算量高且耗时较长的任务。WebAssembly 可以派上用场。

热路径

在 squoosh 中,我们编写了一个 JavaScript 函数,用于按 90 度倍数旋转图片缓冲区。虽然 OffscreenCanvas 非常适合此用途,但我们定位的浏览器不支持它,而且它在 Chrome 中存在一些 bug

此函数会迭代输入图片的每个像素,并将其复制到输出图片的其他位置,以实现旋转。对于 4094 x 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;
    }
}

不过,有一款浏览器的用时超过 8 秒。浏览器优化 JavaScript 的方式非常复杂,不同的引擎会针对不同的方面进行优化。有些优化针对原始执行,有些优化针对与 DOM 的互动。在本例中,我们在一个浏览器中遇到了未优化的路径。

另一方面,WebAssembly 完全以原始执行速度为基础构建而成。因此,如果我们希望此类代码在各浏览器中都能提供快速、可预测的性能,WebAssembly 可以派上用场。

WebAssembly,可实现可预测的性能

一般来说,JavaScript 和 WebAssembly 可以达到相同的峰值性能。不过,对于 JavaScript,只有在“快路”上才能达到这种性能,而要想一直走在“快路”上,通常很难。WebAssembly 提供的一个主要优势是可预测的性能,即使在不同浏览器之间也是如此。严格的类型和低级架构使编译器能够做出更强的保证,因此 WebAssembly 代码只需优化一次,并且将始终使用“快速路径”。

为 WebAssembly 编写代码

以前,我们会将 C/C++ 库编译为 WebAssembly,以便在 Web 上使用其功能。我们实际上没有接触库的代码,只编写了少量 C/C++ 代码,以便在浏览器和库之间建立桥梁。这次我们的动机有所不同:我们希望从头开始编写一些内容,并考虑到 WebAssembly,以便利用 WebAssembly 的优势。

WebAssembly 架构

WebAssembly 编写代码时,不妨详细了解一下 WebAssembly 的实际含义。

如需引用 WebAssembly.org,请使用以下格式:

将一段 C 或 Rust 代码编译为 WebAssembly 时,您会得到一个包含模块声明的 .wasm 文件。此声明由模块预期从其环境导入的“导入”列表、此模块向主机提供的导出(函数、常量、内存块)列表,以及其中包含的函数的实际二进制指令组成。

在研究 WebAssembly 时,我发现了一个之前没有意识到的问题:使 WebAssembly 成为“基于堆栈的虚拟机”的堆栈并未存储在 WebAssembly 模块使用的内存块中。该堆栈完全位于虚拟机内部,Web 开发者无法访问(除非通过开发者工具)。因此,您可以编写完全不需要任何额外内存且仅使用虚拟机内部堆栈的 WebAssembly 模块。

在本例中,我们需要使用一些额外的内存,以允许对图片的像素进行任意访问,并生成该图片的旋转版本。这就是 WebAssembly.Memory 的用途。

内存管理

通常,在使用额外内存后,您会发现需要以某种方式管理该内存。内存的哪些部分正在使用?哪些是免费的? 例如,在 C 中,您可以使用 malloc(n) 函数查找 n 个连续字节的内存空间。此类函数也称为“分配器”。当然,您必须在 WebAssembly 模块中添加所用分配器的实现,这会增加文件大小。这些内存管理函数的大小和性能可能会因所用算法而异,这也是许多语言提供多个实现可供选择(“dmalloc”“emmalloc”“wee_alloc”等)的原因。

在本例中,我们在运行 WebAssembly 模块之前就知道输入图片的尺寸(因此也知道输出图片的尺寸)。我们发现了一个机会:传统上,我们会将输入图片的 RGBA 缓冲区作为参数传递给 WebAssembly 函数,并将旋转后的图片作为返回值返回。为了生成该返回值,我们必须使用分配器。不过,由于我们知道所需的内存总量(输入图片的两倍,一次用于输入,一次用于输出),因此可以使用 JavaScript 将输入图片放入 WebAssembly 内存,运行 WebAssembly 模块以生成第二张旋转图片,然后使用 JavaScript 读回结果。我们完全无需使用任何内存管理即可实现!

选择困难

如果您查看了我们要转换为 WebAssembly 的原始 JavaScript 函数,就会发现它是纯计算代码,不含任何 JavaScript 专用 API。因此,将此代码移植到任何语言应该都非常简单。我们评估了 3 种可编译为 WebAssembly 的不同语言:C/C++、Rust 和 AssemblyScript。对于每种语言,我们只需回答一个问题:如何在不使用内存管理函数的情况下访问原始内存?

C 和 Emscripten

Emscripten 是 WebAssembly 目标平台的 C 编译器。Emscripten 的目标是作为知名 C 编译器(如 GCC 或 clang)的直接替代品,并且在大多数标志方面具有兼容性。这是 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 模块。请注意,经过 gzip 压缩后,wasm 模块仅会缩减到大约 260 字节,而粘合代码在经过 gzip 压缩后大约为 3.5KB。经过一番调整,我们得以舍弃粘合代码,并使用原生 API 实例化 WebAssembly 模块。通常,只要您不使用 C 标准库中的任何内容,就可以使用 Emscripten 实现此目的。

Rust

Rust 是一种新型的现代编程语言,具有丰富的类型系统、无运行时,以及可保证内存安全性和线程安全性的所有权模型。Rust 还将 WebAssembly 作为一项核心功能加以支持,Rust 团队为 WebAssembly 生态系统贡献了许多出色的工具。

其中一个工具是 wasm-pack,由 rustwasm 工作组提供。wasm-pack 会将您的代码转换为适合 Web 的模块,该模块可与 webpack 等捆绑器开箱即用。wasm-pack 非常方便,但目前仅适用于 Rust。该团队正在考虑添加对其他以 WebAssembly 为目标平台的语言的支持。

在 Rust 中,slice 相当于 C 中的数组。与在 C 语言中一样,我们需要创建使用起始地址的 slice。这违反了 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 字节的粘合代码(均在 gzip 压缩后)。

AssemblyScript

AssemblyScript 是一个相当年轻的项目,旨在成为 TypeScript 到 WebAssembly 的编译器。不过,请务必注意,它不会使用任何 TypeScript。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 npm 软件包并运行

$ asc rotate.ts -b assemblyscript.wasm --validate -O3

AssemblyScript 将为我们提供一个大约 300 字节的 wasm 模块,并且无需使用粘合代码。该模块仅适用于原生 WebAssembly API。

WebAssembly 取证

与其他 2 种语言相比,Rust 的 7.6KB 大小令人惊讶。WebAssembly 生态系统中有一些工具可以帮助您分析 WebAssembly 文件(无论其使用的是哪种语言),告知您发生了什么情况,并帮助您改善现状。

Twiggy

Twiggy 是 Rust 的 WebAssembly 团队提供的另一款工具,可从 WebAssembly 模块中提取大量富有洞察力的数据。该工具并非 Rust 专用,可让您检查模块的调用图、确定未使用的或多余的部分,以及找出哪些部分会增加模块的总文件大小。后者可以使用 Twiggy 的 top 命令完成:

$ twiggy top rotate_bg.wasm
Twiggy 安装屏幕截图

在本例中,我们可以看到文件大小的大部分来自分配器。这令人惊讶,因为我们的代码并未使用动态分配。另一个主要原因是“函数名称”子部分。

wasm-strip

wasm-stripWebAssembly 二进制工具包(简称 wabt)中的一款工具。其中包含一些工具,可用于检查和操作 WebAssembly 模块。wasm2wat 是一个反汇编程序,可将二进制 wasm 模块转换为人类可读的格式。Wabt 还包含 wat2wasm,可让您将这种人类可读的格式转换回二进制 wasm 模块。虽然我们确实使用这两种互补工具来检查 WebAssembly 文件,但发现 wasm-strip 最有用。wasm-strip 会从 WebAssembly 模块中移除不必要的部分和元数据:

$ wasm-strip rotate_bg.wasm

这会将 rust 模块的文件大小从 7.5KB 缩减到 6.6KB(经过 Gzip 压缩)。

wasm-opt

wasm-optBinaryen 中的一款工具。它会接受一个 WebAssembly 模块,并尝试仅根据字节码优化其大小和性能。某些工具(例如 Emscripten)已运行此工具,但有些工具则没有。通常,最好尝试使用这些工具节省一些额外的字节。

wasm-opt -O3 -o rotate_bg_opt.wasm rotate_bg.wasm

使用 wasm-opt,我们可以再减少一些字节,使压缩后的总大小为 6.2KB。

#![no_std]

经过一些咨询和研究,我们在不使用 Rust 标准库的情况下,使用 #![no_std] 功能重写了 Rust 代码。这还会完全停用动态内存分配,从我们的模块中移除分配器代码。使用 此 Rust 文件进行编译

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

在执行 wasm-optwasm-strip 和 gzip 后,生成了 1.6KB 的 wasm 模块。虽然它仍然比 C 和 AssemblyScript 生成的模块大,但足够小,可以被视为轻量级模块。

性能

在仅根据文件大小就得出结论之前,请注意,我们此次改进是为了优化性能,而不是文件大小。我们如何衡量效果,以及取得了怎样的成效?

如何进行基准测试

尽管 WebAssembly 是一种低级字节码格式,但仍需要通过编译器发送,以生成特定于主机的机器码。与 JavaScript 一样,编译器的工作分为多个阶段。简单来说:第一阶段的编译速度要快得多,但生成的代码运行速度往往较慢。模块开始运行后,浏览器会观察哪些部分被频繁使用,并通过优化程度更高但速度较慢的编译器发送这些部分。

我们的用例很有趣,因为用于旋转图片的代码将被使用一次,也许两次。因此,在大多数情况下,我们永远无法获得优化编译器的好处。在进行基准测试时,请务必注意这一点。在循环中运行 WebAssembly 模块 10,000 次会产生不切实际的结果。为了获得真实的数据,我们应运行模块一次,并根据单次运行的数据做出决策。

效果对比

不同语言的速度比较
各浏览器的速度比较

这两个图表是对同一数据的不同视图。在第一个图表中,我们按浏览器进行比较;在第二个图表中,我们按所用语言进行比较。请注意,我选择了对数时间尺度。值得注意的是,所有基准测试都使用了相同的 1600 万像素测试图片和相同的主机,但有一款浏览器无法在同一台机器上运行。

无需过多分析这些图表,我们就可以清楚地看到,我们解决了最初的性能问题:所有 WebAssembly 模块的运行时间均在 500 毫秒左右。这证实了我们在开头所说:WebAssembly 可为您提供可预测的性能。无论我们选择哪种语言,浏览器和语言之间的差异都很小。确切地说:所有浏览器中 JavaScript 的标准差约为 400 毫秒,而所有浏览器中所有 WebAssembly 模块的标准差约为 80 毫秒。

有效时间

另一个指标是创建 WebAssembly 模块并将其集成到 squoosh 所需的工作量。很难为“努力程度”分配一个数字值,因此我不会创建任何图表,但我想指出以下几点:

AssemblyScript 的使用非常顺畅。它不仅允许您使用 TypeScript 编写 WebAssembly,让我的同事能够轻松进行代码审核,还能生成无粘合 WebAssembly 模块,这些模块非常小且性能出色。TypeScript 生态系统中的工具(例如 prettier 和 tslint)应该可以正常使用。

Rust 与 wasm-pack 搭配使用也非常方便,但在需要绑定和内存管理的大型 WebAssembly 项目中更为出色。为了实现具有竞争力的文件大小,我们不得不稍微偏离常规路径。

C 和 Emscripten 开箱即用即可创建一个非常小且高性能的 WebAssembly 模块,但如果不敢深入研究粘合代码并将其缩减到仅包含必需内容,总大小(WebAssembly 模块 + 粘合代码)最终会非常大。

总结

因此,如果您有 JS 热路径,并且希望使其更快或更符合 WebAssembly 的规范,应该使用哪种语言?与性能问题一样,答案是:取决于具体情况。我们发布了什么?

比较图

比较我们使用的不同语言的模块大小 / 性能权衡,最佳选择似乎是 C 或 AssemblyScript。我们决定发布 Rust。做出此决定的原因有很多:Squoosh 中目前提供的所有编解码器都是使用 Emscripten 编译的。我们希望拓展对 WebAssembly 生态系统的了解,并在生产环境中使用其他语言。AssemblyScript 是一个不错的替代方案,但该项目相对较新,编译器不如 Rust 编译器成熟。

虽然在散点图中,Rust 与其他语言的文件大小差异看起来非常明显,但实际上差异并不大:加载 500B 或 1.6KB 甚至超过 2G 的文件都需要不到 1/10 秒的时间。Rust 有望很快缩小模块大小方面的差距。

在运行时性能方面,Rust 在各浏览器中的平均速度比 AssemblyScript 快。尤其是在大型项目中,Rust 更有可能生成运行速度更快的代码,而无需手动进行代码优化。不过,这不应妨碍您使用自己最熟悉的设备。

尽管如此,AssemblyScript 还是一个了不起的发现。借助它,Web 开发者无需学习新语言即可生成 WebAssembly 模块。AssemblyScript 团队的响应非常及时,并且正在积极改进其工具链。我们一定会在未来密切关注 AssemblyScript。

更新:Rust

在本文发布后,Rust 团队的 Nick Fitzgerald 向我们推荐了他们出色的 Rust Wasm 图书,其中包含关于优化文件大小的部分。按照其中的说明(最值得注意的是启用链接时间优化和手动 panic 处理)操作后,我们能够编写“正常”Rust 代码,并恢复使用 Cargo(Rust 的 npm),而不会增加文件大小。经过 gzip 压缩后,Rust 模块的大小为 370B。如需了解详情,请参阅我在 Squoosh 上打开的 PR

特别感谢 Ashley WilliamsSteve KlabnikNick FitzgeraldMax Graey 在此过程中提供的所有帮助。