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

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

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

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

Hot Path

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

为了实现旋转,该 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 操做作优化的。在本例中,咱们遇到了一个未经优化的路径。api

WebAssembly 正是围绕原生计算的速度优化而生的。因此针对相似上述代码,若是咱们但愿其在浏览器中具备快速、可预测的性能,WebAssembly 就很是有用了。数组

WebAssembly 之于可预测的性能

通常来讲,JavaScript 和 WebAssembly 能达到相同的性能峰值。可是 JavaScript 只有在 fast path 之下才能达到峰值性能,而且代码老是处于 fast path 之下。WebAssembly 另外一个优点是,即便经过浏览器运行,它也能提供可预测的性能。强类型和低级语言保证了 WebAssembly 被优化一次,就能一直被快速执行。浏览器

WebAssembly 书写安全

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

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
复制代码

在这个案例中,咱们能够看到大部分空间占用都来自于内存分配器。这有些使人惊讶,由于咱们的代码并无使用动态分配。第二大空间占用来自于 “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-opt、wasm-strip 和 gzip 以后生成 1.6KB 的 wasm 模块。虽然它仍然比 C 和 AssemblyScript 生成的模块大,但它已经足够小,能够被认为是轻量级的了。

性能

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

怎样进行基准测试

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

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

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

性能对比

这两个图是同一数据上的不一样视图。在第一个图中,咱们比较每一个浏览器,在第二个图中,咱们比较每种使用的语言。请注意,我选择了对数时间尺度。一样重要的是,全部基准测试都使用相同的 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,您应该使用什么语言。对于性能问题,答案老是:视状况而定。那么咱们选择了什么呢?

注意:请注意图中两个坐标轴都是对数增加的,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 。

原文连接:Replacing a hot path in your app's JavaScript with WebAssembly
相关文章
相关标签/搜索