「译」使用 WebAssembly 替换应用程序中的Hot Path

(183) 2024-03-17 23:01:01

原文链接: Replacing a hot path in your app's JavaScript with WebAssembly

February 15, 2019.

在之前的文章中我讲述了 WebAssembly 是如何允许我们将 C/C++ 生态中的库应用于 web 应用中的。一个典型的使用了 C/C++ 扩展包的 web 应用就是 squoosh,这个应用使用了一系列从 C++ 语言编译成 WebAssembly 的代码来压缩图片。

WebAssembly 是一个底层虚拟机,可以用来运行 .wasm 文件中存储的字节码。这些字节码是强类型、结构化的,相比 JavaScript 能更快速的被宿主系统编译和识别。WebAssembly 可以运行已知界限和依赖的代码。

据我所知,web 应用中的大多数性能问题都是由强制布局和过度绘制造成的,但应用程序又时不时地需要执行一项计算成本高昂、需要大量时间的任务。这中情况下 WebAssembly 就可以派上用场了。

Hot Path

在 squoosh 这个 web 应用中,我们写了一个 JavaScript 函数,将图像以 90 度的倍数进行旋转。尽管 OffscreenCanvas 是实现这一点的理想之选,但它在我们使用的浏览器中并不支持该特性,而且在 Chrome 中也存在一些小 bug。

为了实现旋转,该 JavaScript 函数在输入图片的每一个像素上进行迭代,将每一个像素复制到输出图片的相应位置上。对于一个 4094px * 4094px 的图像(1600 万像素)来说,内部代码块将迭代超过 1600 万次,这些被多次迭代的代码块就被称之为 hot path。经过测试,尽管这次计算需要大量的迭代,仍有 2/3 的浏览器能在两秒以内完成。在此种交互中这是一个可接受的耗时。

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 只有在 fast path 之下才能达到峰值性能,并且代码总是处于 fast path 之下。WebAssembly 另一个优势是,即使通过浏览器运行,它也能提供可预测的性能。强类型和低级语言保证了 WebAssembly 被优化一次,就能一直被快速执行。

WebAssembly 书写

之前,我们将 C/C++ 的库编译成 WebAssembly ,将其中的方法应用于 web 应用中。我们还没有真正接触到库中的代码,只是写了一点 C/C++ 代码来适配库和浏览器的桥接。这一次我们有另外一个目标:要用 WebAssembly 从头写一段代码,这样就能应用上 WebAssembly 的一系列优势。

WebAssembly 的架构

在写 WebAssembly 时,我们最好多了解一下 WebAssembly 究竟是什么。

引用自 WebAssembly.org:

 WebAssembly (缩写 Wasm )是一种基于堆栈的虚拟机的二进制指令格式。将高级语言(如 C/C++/Rust )编译为 Wasm, 来支持在 web 应用中客户端和服务端的开发.

当编译一段 C 或者 Rust 代码到 WebAssembly 时, 我们将会得到一个.wasm 文件,该文件是用于模块声明的。文件中包括模块从环境中的导入列表、模块提供给宿主系统的导出列表(函数、常量、内存块),当然还有包含其中的函数的实际二进制指令。

仔细研究了一下我才意识到:WebAssembly 堆栈虚拟机的堆栈,并没有存储在 WebAssembly 模块使用的内存中。这个堆栈完全是 vm 内部的,web 开发人员无法直接访问(除非通过 DevTools )。因此,我们可以编写完全不需要任何额外内存只使用 vm 内部堆栈的 WebAssembly 模块。

提示:(严格来说)例如 Emscripten 这样的编译器仍然是使用 WebAssembly 的内存来实现堆栈的。这是有必要的,因为如此一来我们就可以随时随地通过类似 C 语言中的指针这样的东西来访问堆栈了,而 VM-internal 堆栈却是不能被这样访问的。所以,这里有点令人困惑,当用 WebAssembly 跑一段 C 代码时,两个堆栈都会被使用到。

在我们的案例中,我们需要一些额外的内存空间方便访问图像上的每一个像素,并生成该图像的旋转版本,这就是 WebAssembly.Memory 的作用。

内存管理

通常,只要我们使用了额外的内存,就需要做内存管理。哪部分内存正在被使用?哪些是空闲的?例如,在 C 语言中,有一个函数 malloc(n) 用于获取 n 连续字节的空闲内存。这种函数也被叫做”内存分配器“。当然,被引用的内存分配器的实现必须包含在 WebAssembly 模块中,它将增大文件的大小。内存分配器的大小和空间管理的性能会因所使用算法的不同而有显著的差异,因此很多语言都提供了多种实现可供选择("dmalloc", "emmalloc", "wee_alloc",...)。

在我们的案例中,在跑 WebAssembly 模块之前我们就知道了输入图片的尺寸(同时也知道了输出图片的尺寸)。我们发现: 通常,我们应该把输入图片的 RGBA buffer 作为参数传给 WebAssembly 函数,并把输出图片的 RGBA buffer 返回出来。为了生成返回值,我们必须使用内存分配器。但是,因为已知所需内存空间的大小(两倍于输入图片的大小,一半给输入使用,一半给输出使用),我们可以用 JavaScript 将图片放到 WebAssembly 内存中,运行 WebAssembly 模块生成第二个旋转后的图片,然后用 JavaScript 把返回值读取出来。这样我们就可以不使用内存管理了!(演示))

多种选择

如果你查看一下原始的 JavaScript 函数,就会发现这是一段纯逻辑函数,没有使用任何 JavaScript 专属 API。因此,这段代码被移植为其他任何语言都应该没太大问题。我们评估了 3 种语言:C/C++、Rust 和 AssemblyScript。只有一个问题:对于每种语言,我们如何在不使用内存管理的情况下访问原生内存。

提示:我跳过了示例代码中一些繁琐的部分,聚焦在真正的 hot path 和内存调用上。完整的示例和性能测试在这里 gist.

C 与 Emscripten

Emscripten 是一个将 C 编译成 WebAssembly 的编译器。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;
  }
}

提示:我们选择从地址 4 而不是 0 开始的原因是地址 0 在许多语言中有特殊的含义:它是可怕的空指针。虽然从技术上讲 0 是一个完全有效的地址,但许多语言将 0 排除为指针的有效值,并抛出异常或直接返回未定义行为。

将整个 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 标准库的任何东西。

提示:我们正和 Emscripten 团队合作,来尽可能减小胶水代码文件的体积,甚至在某些情况下可以去掉这个文件。

Rust

提示:自本文发布以来,我们了解到更多关于如何为 WebAssembly 优化 Rust 的知识。请参阅本文末尾的更新部分。

Rust 是一种新的、现代的编程语言,具有丰富的类型系统,没有运行时,并且拥有一个保证内存安全和线程安全的所有权模型。Rust 还是支持 WebAssembly 的一等公民,Rust 团队为 WebAssembly 生态贡献了很多优秀的工具。

其中一个是  rustwasm working group 贡献的 wasm-pack 。wasm-pack 可以将代码转换成 web 友好的模块,像 webpack 一样提供开箱即用的 bundlers。wasm-pack 提供了一种非常方便的体验,但目前只适用于 Rust 。该团队正在考虑添加对其他想要转为 WebAssembly 的语言的支持。

在 Rust 中,slices 就是 C 中的数组。就像在 C 中一样,我们需要先使用起始地址创建一个 slices。这违背了 Rust 推崇的内存安全模型,因此为了达到目的,我们必须使用不安全关键字,编写不符合该模型的代码。

提示:这不是最好的实现。根据以往的经验,最好使用打包工具(类似于 embind in Emscripten 或者 wasm-bindgen ) 开发更高级的 Rust 代码。

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 相同的语法,但是拥有自己的标准库。AssemblyScript 的标准库为 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 会相当容易。load<T>(ptr: usize)store(ptr: usize, value: T)  是用来访问原生内存的。要编译 Assemblyscript 文件,我们只需安装AssemblyScript/assemblyscript npm 包并运行如下命令即可:

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

Assemyscript 将为我们生成一个大约 300 字节的 wasm 模块,没有胶水代码。该模块只使用了普通的 WebAssembly api。

WebAssembly 分析

与其他两种语言相比,Rust 的 7.6KB 大得惊人。在 WebAssembly 生态系统中有一些工具可以帮助我们分析 WebAssembly 文件(不管使用的是什么语言),并告诉我们它是做什么的,还可以帮助我们进行优化。

Twiggy

Twiggy 是 Rust WebAssembly 团队的另一个工具,它从 WebAssembly 模块中提取大量有价值的数据。该工具不是专门用于 Rust 的,它还可以用来检查模块调用关系图等,识别出未使用或多余的部分,并分析出哪些部分对模块的体积造成主要影响。后者可以通过 Twiggy 的 top 命令完成:

$ twiggy top rotate_bg.wasm

「译」使用 WebAssembly 替换应用程序中的Hot Path (https://mushiming.com/)  第1张

在这个案例中,我们可以看到大部分空间占用都来自于内存分配器。这有些令人惊讶,因为我们的代码并没有使用动态分配。第二大空间占用来自于 “function names” 部分。

wasm-strip

wasm-strip 是 WebAssembly Binary Toolkit (简称 wabt )中的一个工具。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-opt 是 Binaryen 中的一个工具。它基于字节码对 WebAssembly 模块进行其大小和性能上的优化。一些编译器(如 Emscripten )已经在使用该工具,有些还没有。使用这些工具来压缩体积通常是一个好方法。

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

使用 wasm-opt,我们可以在 gzip 之后再减少一些字节,总共保留 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-stripgzip 之后生成 1.6KB 的 wasm 模块。虽然它仍然比 C 和 AssemblyScript 生成的模块大,但它已经足够小,可以被认为是轻量级的了。

性能

在我们仅仅根据文件大小得出结论之前——我们的一些列操作是为了优化性能,而不仅仅是优化文件大小。那么我们该如何衡量性能优劣?性能又到底如何呢?

怎样进行基准测试

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

我们的用例的有趣之处在于,旋转图片的代码只运行了一次,或者是两次。所以,在绝大多数情况下,永远也体现不出优化编译器的优势。在进行基准测试时,这一点非常重要。在一个循环中运行我们的 WebAssembly 模块 10000 次得到的数据可能并不确切。为了得到确切的数据,我们应该运行该模块一次,并根据这一次运行计算出相应的数据。

注意:理想情况下,我们应该自动化这个重新加载页面并运行一次模块的过程,并多次执行该过程。我们相信多次测量的平均值足以说明问题。

性能对比

「译」使用 WebAssembly 替换应用程序中的Hot Path (https://mushiming.com/)  第2张

「译」使用 WebAssembly 替换应用程序中的Hot Path (https://mushiming.com/)  第3张

这两个图是同一数据上的不同视图。在第一个图中,我们比较每个浏览器,在第二个图中,我们比较每种使用的语言。请注意,我选择了对数时间尺度。同样重要的是,所有基准测试都使用相同的 1600 万像素的测试图像和相同的主机,除了一个不能在这台机器上运行的浏览器。

无需过多地分析这两张图表,就可以清楚地看到我们解决了最初的性能问题:所有 WebAssembly 模块的运行时间都在大约 500ms 或更少。这证实了我们在一开始的论调: WebAssembly 提供了可预测的性能。无论我们选择哪种语言,耗时都是最小的。准确地说:JavaScript 在所有浏览器上的标准耗时是大约 400ms,而我们所有 WebAssembly 模块在所有浏览器上的标准耗时是大约 80ms。

易用性

另外一个衡量标准是易用性。这个东西是很难量化的,所以我不会给出任何图表,但是我想指出几点:

AssemblyScript 的使用几乎是丝般顺滑的。不仅仅是因为我们可以使用 TypeScript 来开发,让同事间可以轻松完成代码 review,还因为它的产物中不需要胶水代码,所以体积很小性能很高。TypeScript 生态中的工具(例如 prettier、tslint)似乎也能正常的为 AssemblyScript 所用。

Rust 和 wasm-pack 结合使用也是相当方便的,但是它比较擅长的是大型项目中打包,并且需要内存管理。我们不得不稍微违背 Rust 的初衷,来获取具有竞争力的输出文件的大小。

C 和 Emscripten 创建了一个开箱即用的即小又高效的 WebAssembly 模块,但是如果不努力将胶水代码文件的体积减小到可忍受的大小的话,产物的总体积(包括 WebAssembly 模块和胶水代码文件)还是太大了。

结论

因此,如果您有一个 JS Hot Path,并希望使它更快或更符合 WebAssembly,您应该使用什么语言。对于性能问题,答案总是:视情况而定。那么我们选择了什么呢?

「译」使用 WebAssembly 替换应用程序中的Hot Path (https://mushiming.com/)  第4张

注意:请注意图中两个坐标轴都是对数增长的,x 轴从 200 到 2000 比特,y 轴从 0.1 秒到 10 秒。

对比了不同语言的中模块的大小/性能,最好的选择似乎是 C 或 AssemblyScript。但是我们最终决定选择 Rust。做出这个决定有很多原因:到目前为止,在 Squoosh 中提供的所有编解码器都是使用 Emscripten 编译的。我们想要扩充关于 WebAssembly 生态系统的知识,在生产中使用不同的语言。AssemblyScript 是一个强大的替代方案,但它还相对较年轻,编译器不如 Rust 编译器成熟。

尽管在散点图中,看起来 Rust 和其他语言之间的文件大小差异非常大,但实际上这并不是什么大问题:加载 500B 或1.6KB,甚至超过 2G,所需时间也不到十分之一秒。而在不久的将来,Rust 有望缩小模块体积方面的差距。

就运行时性能而言,在不同浏览器之间,Rust 的平均速度要快于 AssemblyScript。特别是在较大的项目中,Rust 更有可能在不需要手动代码优化的情况下生成更快的代码。但这不影响你选择你觉得最舒服的那个。

综上所述:AssemblyScript 是一个伟大的发明。它允许 web 开发人员无需学习一种新语言就可以生成 WebAssembly 模块。并且,AssemblyScript 团队的响应非常迅速,他们正在积极改进他们的工具链。将来我们也一定会继续关注 AssemblyScript 的。

更新: Rust

在这篇文章发布之后,Rust 团队的 Nick Fitzgerald 向我们推荐他们的 Rust Wasm 手册,其中包含一章文件体积优化。按照手册的指引我们可以使用 Cargo(Rust 的 npm 包)正常编写 Rust 代码,而不用担心文件大小。最终 Rust 模块在 gzip 之后只有 370B。有关详细信息,请查看我在 Squoosh 上开的 PR 。

特别感谢 Ashley Williams、Steve Klabnik、Nick Fitzgerald 和 MaxGraey 在本次探索中给予的帮助。

THE END

发表回复