[译] 使 WebAssembly 更快:Firefox 的新流式和分层编译器

人们都说 WebAssembly 是一个游戏规则改变者,因为它可以让代码更快地在网络上运行。有些加速已经存在,还有些在不远的将来。

其中一种加速是流式编译,即浏览器在代码还在下载的时候就对其进行编译。截至目前,这只是潜在的未来加速(方式)。但随着下周 Firefox 58 版本的发布,它将成为现实。

Firefox 58 还包含两层新的编译器。新的基线编译器编译代码的速度比优化编译器快了 10-15 倍。

综合起来,这两个变化意味着我们编译代码的速度比从网络中编译代码速度快。

在台式电脑上,我们每秒编译 30-60 兆字节的 WebAssembly 代码。这比网络传送数据包的速度还快。

如果你使用 Firefox Nightly 或者 Beta,你可以在你自己设备上试一试。即便是在很普通的移动设备上,我们可以每秒编译 8 兆字节 —— 这比任何移动网络的平均下载速度都要快得多。

这意味着你的代码几乎是在它完成下载后就立即执行。

为什么这很重要?

当网站发布大批量 JavaScript 代码时,Web 性能拥护者会变得束手无策。这是因为下载大量的 JavaScript 会让页面加载变慢。

这很大程度是因为解析和编译时间。正如 Steve Souder 指出,网络性能的旧瓶颈曾是网络。但现在网络性能的新瓶颈是 CPU,特别是主线程。

Old bottleneck, the network, on the left. New bottleneck, work on the CPU such as compiling, on the right

所以我们想要尽可能多的把工作从主线程中移除。我们也想要尽可能早的启动它,以便我们充分利用 CPU 的所有时间。更好的是,我们可以完全减少 CPU 工作量。

使用 JavaScript 时,你可以做一些这样的事情。你可以通过流入的方式在主线程外解析文件。但你还是需要解析它们,这就需要很多工作,并且你必须等到它们都解析完了才能开始编译。然后编译的时候,你又回到了主线程上。这是因为 JS 通常是运行时延迟编译的。

Timeline showing packets coming in on the main thread, then parsing happening simultaneously on another thread. Once parse is done, execution begins on main thread, interrupted occassionally by compiling

使用 WebAssembly,启动的工作量减少了。解码 WebAssembly 比解析 JavaScript 更简单,更快捷。并且这些解码和编译可以跨多个线程进行拆分。

这意味着多个线程将运行基线编译,这会让它变得更快。一旦完成,基线编译好的代码就可以在主线程上开始执行。它不必像 JS 代码一样暂停编译。

Timeline showing packets coming in on the main thread, and decoding and baseline compiling happening across multiple threads simultaneously, resulting in execution starting faster and without compiling breaks.

当基线编译的代码在主线程上运行时,其他线程则在做更优化的版本。当更优化的版本完成时,它就会替换进来使得代码运行更加快捷。

这使得加载 WebAssembly 的成本变得更像解码图片而不是加载 JavaScript。并且想想看 —— 网络性能倡导者肯定接受不了 150kB 的 JS 代码负载量,但相同大小的图像负载量并不会引起人们的注意。

Developer advocate on the left tsk tsk-ing about large JS file. Developer advocate on the right shrugging about large image.

这是因为图像的加载时间要快得多,就像 Addy Osmani 在 JavaScript 的成本 中解释的那样,解码图像并不会阻塞主线程,正如 Alex Russell 在你能接受吗?真实的 Web 性能预算中所讨论的那样。

但这并不意味着我们希望 WebAssembly 文件和图像文件一样大。虽然早期的 WebAssembly 工具创建了大型的文件,是因为它们包含了很多运行时(内容),目前来看还有很多工作要做让文件变得更小。例如,Emscripten 有一个“缩小协议”。在 Rust 中,你已经可以通过使用 wasm32-unknown-unknown 目标来获取相当小尺寸的文件,并且还有像 wasm-gcwasm-snip 这样的工具来帮助进一步优化它们。

这就意味着这些 WebAssembly 文件的加载速度要比等量的 JavaScript 快得多。

这很关键。正如 Yehuda Katz 指出,这是一个游戏规则改变者。

Tweet from Yehuda Katz saying it's possible to parse and compile wasm as fast as it comes over the network.

所以让我们看看新编译器是怎么工作的吧。

流式编译:更早开始的编译

如果你更早开始编译代码,你就更早完成它。这就是流式编译所做的 —— 尽可能快地开始编译 .wasm 文件。

当你下载文件时,它不是单件式的。实际上,它带来的是一系列数据包。

之前,当 .wasm 文件中的每个包正在下载时,浏览器网络层会把它放进 ArrayBuffer(译者注:数组缓存)中。

Packets coming in to network layer and being added to an ArrayBuffer

然后,一旦完成下载,它会将 ArrayBuffer 转移到 Web VM(也就是 JS 引擎)中。也就到了 WebAssembly 编译器要开始编译的时候。

Network layer pushing array buffer over to compiler

但是没有充分的理由让编译器等待。从技术上讲,逐行编译 WebAssembly 是可行的。这意味着你能够在第一个块进来的时候就开始启动。

所以这就是我们新编译器所做的。它利用了 WebAssembly 的流式 API。

WebAssembly.instantiateStreaming call, which takes a response object with the source file. This has to be served using MIME type application/wasm.

如果你提供给 WebAssembly.instantiateStreaming 一个响应的对象,则(对象)块一旦到达就会立即进入 WebAssembly 引擎。然后编译器可以开始处理第一个块,即便下一个块还在下载中。

Packets going directly to compiler

除了能够并行下载和编译代码外,它还有另外一个优势。

.wasm 模块中的代码部分位于任何数据(它将引入到模块的内存对象)之前。因此,通过流式传输,编译器可以在模块的数据仍在下载的时候就对其进行编译。如果当你的模块需要大量的数据,且可能是兆字节的时候,这些就会显得很重要。

File split between small code section at the top, and larger data section at the bottom

通过流式传输,我们可以提前开始编译。而且我们同样可以更快速地进行编译。

第 1 层基线编译器:更快的编译代码

如果你想要代码跑的快,你就需要优化它。但是当你编译时执行这些优化会花费时间,也就会让编译代码变得更慢。所以这里需要一个权衡。

但鱼和熊掌可以兼得。如果我们使用两个编译器,就能让其中一个快速编译但是不做过多的优化工作,而另一个虽然编译慢,但是创建了更多优化的代码。

这就称作为层编译器。当代码第一次进入时,将由第 1 层(或基线)编译器对其编译。然后,当基线编译完成,代码开始运行之后,第 2 层编译器再一次遍历代码并在后台编译更优化的版本。

一旦它(译者注:第 2 层编译)完成,它会将优化后的代码热插拔为先前的基线版本。这使代码执行得更快。

Timeline showing optimizing compiling happening in the background.

JavaScript 引擎已经使用分层编译器很长一段时间了。然而,JS 引擎只在一些代码变得“温热” —— 当代码的那部分被调用太多次时,才会使用第 2 层(或优化)编译器。

相比之下,WebAssembly 的第 2 层编译器会热切地进行全面的重新编译,优化模块中的所有代码。在未来,我们可能会为开发者添加更多选项,用来控制如何进行激进的优化或者惰性的优化。

基线编译器在启动时节省了大量时间。它编译代码的速度比优化编译器的快 10-15 倍。并且在我们的测试中,它创建代码的速度只慢了 2 倍。

这意味着,只要仍在运行基线编译代码,即便是在最开始的几分钟你的代码也会运行地很快。

并行化:让一切更快

关于 Firefox Quantum 的文章中,我解释了粗粒度和细粒度的并行化。我们可以用它们来编译 WebAssembly。

我在上文有提到,优化编译器会在后台进行编译。这意味着它空出的主线程可用于执行代码。基线编译版本的代码可以在优化编译器进行重新编译时运行。

但在大多数电脑上仍然会有多个核心没有使用。为了充分使用所有核心,两个编译器都使用细粒度并行化来拆解工作。

并行化的单位是功能,每个功能都可以在不同的核心上单独编译。这就是所谓的细粒度,实际上,我们需要将这些功能分批处理成更大的功能组。这些批次会被派送到不同的核心里。

...然后通过隐式缓存完全跳过所有工作(未来的任务)

目前,每次重新加载页面时都会重做解码和编译。但是如果你有相同的 .wasm 文件,它编译后都是一样的机器代码。

这意味着,很多时候这些工作都可以跳过。这些也是未来我们要做的。我们将在第一页加载时进行解码和编译,然后将生成的机器码缓存在 HTTP 缓存中。之后当你再次请求这个 URL 的时候,它会拉取预编译的机器代码。

这就能让后续加载页面的加载时间消失了。

Timeline showing all work disappearing with caching.

这项功能已经有了基础构建。我们在 Firefox 58 版本中缓存了这样的 JavaScript 字节代码。我们只需扩展这种支持来缓存 .wasm 文件的机器代码。

关于 Lin Clark

Lin 是 Mozilla Developer Relations 团队的工程师。她致力于 JavaScript、WebAssembly、Rust 和 Servo,还会绘制代码漫画。

Lin Clark 的更多文章...

如果发现译文存在错误或其他需要改进的地方,欢迎到 掘金翻译计划 对译文进行修改并 PR,也可获得相应奖励积分。文章开头的 本文永久链接 即为本文在 GitHub 上的 MarkDown 链接。


掘金翻译计划 是一个翻译优质互联网技术文章的社区,文章来源为 掘金 上的英文分享文章。内容覆盖 AndroidiOS前端后端区块链产品设计人工智能等领域,想要查看更多优质译文请持续关注 掘金翻译计划官方微博知乎专栏

猜你喜欢

转载自juejin.im/post/5bc70955e51d450e98754dad
今日推荐