在之前的文章中我讲述了 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(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
复制代码
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-opt、wasm-strip 和 gzip 之后生成 1.6KB 的 wasm 模块。虽然它仍然比 C 和 AssemblyScript 生成的模块大,但它已经足够小,可以被认为是轻量级的了。