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

速度一直很快,

之前的 文章中,我介绍了 WebAssembly 如何帮助您将 C/C++ 的库生态系统引入到 Web 中。我们的 Web 应用 squoosh 充分利用了 C/C++ 库,这是一款应用,可让您使用从 C++ 编译到 WebAssembly 的各种编解码器来压缩图片。

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

根据我的经验,大多数 Web 性能问题是由强制布局和过度绘制导致的,但应用时常需要执行一项计算开销很大的任务,而该任务需要大量时间。WebAssembly 可以在这里提供帮助

热门路径

在 squoosh 中,我们编写了一个 JavaScript 函数,该函数将图片缓冲区旋转 90 度的倍数。虽然 OffscreenCanvas 非常适合这一用途,但其不支持我们的目标浏览器,并且 Chrome 中仍有一些问题

此函数会迭代输入图片的每个像素,并将其复制到输出图片中的其他位置,以实现旋转。对于 4094x4096 像素的图片(1600 万像素),需要对内部代码块进行超过 1600 万次迭代,也就是我们所说的“热路径”。尽管需要进行大量迭代,但我们测试的浏览器中有 2/3 会在 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,以在网络上使用其功能。我们并未修改库的代码,只是编写了少量的 C/C++ 代码以在浏览器与库之间架起桥梁。这一次,我们的动机有所不同:我们想借助 WebAssembly 从头开始编写一些代码,以便充分利用 WebAssembly 的优势。

WebAssembly 架构

在为 WebAssembly 编写代码时,详细了解一下 WebAssembly 的实际含义会很有帮助。

引用 WebAssembly.org 的话:

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

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

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

内存管理

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

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

品类众多,令人爱不释手

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

C 和 Emscripten

Emscripten 是适用于 WebAssembly 目标的 C 编译器。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 模块 gzip 压缩后的大小约为 260 字节,而粘合代码在 gzip 后大约为 3.5KB。稍作调整,我们就能够舍弃粘合代码,并使用原版 API 实例化 WebAssembly 模块。 使用 Emscripten 通常可以执行此操作,前提是您未使用 C 标准库中的任何内容。

Rust

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

其中一种工具是 rustwasm 工作组开发的 wasm-packwasm-pack 接受您的代码,并将其转换为适合网页的模块,该模块可直接与 webpack 等打包器一起使用。wasm-pack 是一种极其方便的体验,但目前仅适用于 Rust。该团队正在考虑增加对其他以 WebAssembly 为目标的语言的支持。

在 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 字节的粘合代码(均在 gzip 之后)。

AssemblyScript

AssemblyScript 是一个相当年轻的项目,旨在成为从 TypeScript 到 WebAssembly 的编译器。但请务必注意,它不会仅使用任何 TypeScript。AssemblyScript 使用的语法与 TypeScript 相同,但其本身使用的是标准库。其标准库为 WebAssembly 的功能建模。这意味着您不能只编译任何位于 WebAssembly 中的 TypeScript,但这确实意味着您不必学习新的编程语言来编写 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() 函数具有较小的类型 surface,因此将此代码移植到 AssemblyScript 非常简单。函数 load<T>(ptr: usize)store<T>(ptr: usize, value: T) 由 AssemblyScript 提供,用于访问原始内存。如需编译 AssemblyScript 文件,我们只需安装 AssemblyScript/assemblyscript npm 软件包并运行

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

AssemblyScript 将向我们提供约 300 字节的 wasm 模块,且粘合代码。 该模块仅适用于原始 WebAssembly API。

WebAssembly 取证

与另外两种语言相比,Rust 的 7.6KB 大到惊人。WebAssembly 生态系统中有一些工具可以帮助您分析 WebAssembly 文件(无论创建时使用的是哪种语言),告诉您发生的情况,并帮助您改善状况。

树枝

Twiggy 是 Rust 的 WebAssembly 团队提供的另一种工具,可从 WebAssembly 模块中提取大量富有参考价值的数据。该工具并非特定于 Rust,可让您检查模块的调用图等信息,确定未使用或多余的部分,并确定哪些部分占模块的总文件大小。后者可以使用 Twiggy 的 top 命令完成:

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

在本例中,我们可以看到文件大小的大部分来自分配器。出乎意料的是,我们的代码没有使用动态分配。另一个主要影响因素是“函数名称”子部分。

Wasm-Strip

wasm-stripWebAssembly 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(使用 gzip 之后)。

wasm-opt

wasm-optBinaryen 中的工具。它需要一个 WebAssembly 模块,并尝试仅根据字节码在大小和性能方面对其进行优化。有些工具(例如 Emscripten)已经可以运行此工具,有些则没有。通常,最好尝试使用这些工具节省一些额外的字节。

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

借助 wasm-opt,我们可以再削减少量字节,从而在 gzip 后保留 6.2KB 的总大小。

#![无标准]

经过一些咨询和研究,我们使用 #![no_std] 功能重新编写了 Rust 代码,而不使用 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 秒的 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 上开设的公关

特别感谢 Ashley WilliamsSteve KlabnikNick FitzgeraldMax Graey 在此旅程中提供的大力帮助。