让 WebAssembly 更快:Firefox 的新流式和分层编译器

人们称 WebAssembly 为游戏规则改变者,因为它使在网络上更快地运行代码成为可能。其中一些 加速已经出现,而一些加速还有待实现。

这些加速之一是流式编译,即浏览器在代码仍在下载时编译代码。到目前为止,这只是一个潜在的未来加速。但随着下周发布的 Firefox 58,它将成为现实。

Firefox 58 还包括一个新的两层编译器。新的基线编译器比优化编译器快 10-15 倍。

结合起来,这两个更改意味着我们编译代码的速度比网络传输的速度更快。

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

如果您使用 Firefox Nightly 或 Beta,您可以在自己的设备上 尝试一下。即使在相当普通的移动设备上,我们也能以每秒 8 兆字节的速度进行编译,这比几乎所有移动网络的平均下载速度都快。

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

为什么这很重要?

当网站发送大量 JavaScript 时,Web 性能倡导者会感到很棘手。这是因为下载大量 JavaScript 会使页面加载速度变慢。

这主要是因为解析和编译时间。正如 Steve Souders 指出,Web 性能的旧瓶颈曾经是网络。但 Web 性能的新瓶颈是 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。想想看……Web 性能倡导者确实对 150 kB 的 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 级编译器会在后台再次遍历代码并编译一个更优化的版本。

完成后,它会将优化后的代码热交换为之前的基线版本。这使代码执行速度更快。

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 的高级开发部门工作,专注于 Rust 和 WebAssembly。

更多 Lin Clark 的文章...


22条评论

  1. yoshua wuyts

    哦,这太棒了!是否有任何迹象表明预加载标签/标题将为此提供支持?我还想知道它与 HTTP2 Push 的交互方式。

    我可以想象,如果 WASM 流可以在初始握手后立即启动,性能可以真正提升到 100%!✨

    谢谢!

    2018年1月17日 下午11:39

    1. Lin Clark

      我相信您可以使用带有 as=”fetch” 的链接标签。我不确定它与 HTTP2 Push 的交互方式。我认为它只会使字节准备好并可用于实例化调用,但我认为它不会触发编译。

      2018年1月19日 下午12:10

  2. Dawid

    很棒的文章,感谢您以如此通俗易懂的方式分享您的知识。

    2018年1月17日 下午12:47

  3. Thomas E Enebo

    直到最后一段,我都能理解你的意思。

    缓存 JS(如启动代码)可以从缓存副本中执行,因为您可以隐式信任它始终是相同的代码,或者您可以指纹内容以验证没有任何更改(您已经拥有它)。

    对于来自网络的 JS,您最多只能在加载时进行流编译,然后在完成时决定不再需要流,因为它碰巧已经在过去的会话中编译了。我认为从这个意义上说,开销时间只是流时间,但您仍然在推测性地进行流编译,因为您还不知道您已经拥有它。

    对吗?

    2018年1月17日 下午1:55

    1. Eric

      我也想到了同样的问题,所以我假设他们会根据函数确定指纹,就像他们对核心选择所做的那样。

      2018年1月17日 下午3:18

    2. George Mauer

      您的浏览器已经决定是否从缓存中加载任何给定的资源。我将那部分内容解读为 *如果* 我们打算从缓存中加载,那么跳过编译,因为缓存的是机器代码。

      2018年1月17日 下午5:18

  4. Dalin William s

    精彩的演示,还有很棒的新技术!我也想知道它与 HTTP2 Push 的交互方式。这将如何帮助通过类似 GRPC-Web 的内容加载?

    是时候做所有研究了!

    2018年1月17日 下午2:43

  5. Omar

    感觉就像在读科幻小说。
    太棒了

    2018年1月17日 下午6:02

  6. Raahul Kumar

    很高兴看到 Ubuntu 已经预装了 FireFox Quantum。只是想知道如何基准测试这些速度提升?而不是运行 3 个不同的 Firefox 版本(Ubuntu 提供的稳定版、开发者版和夜间版),我可以用夜间版来替代吗?或者它太不稳定,而且 bug 很多,无法用作日常浏览器。

    57.0.4
    Firefox 发布

    2018年1月4日
    版本 57.0.4,于 2018 年 1 月 4 日首次提供给发布频道用户

    2018年1月17日 下午8:29

  7. ilya

    写得很好,读起来很舒服。感谢您分享。

    2018年1月17日 下午11:40

  8. Mark Entingh

    用手绘图像解释如此复杂的思想,真是太棒了。感谢您为此篇文章付出了如此多的努力 :)

    2018年1月18日 上午2:51

  9. Willian

    感谢您撰写这篇文章。它对 JS 编译也以相同的方式工作吗?如果答案是否定的,您是否计划为 JS 也实现它?当二进制 AST 就位后,我们可以拥有它吗?

    2018年1月18日 上午5:28

  10. Matt Cheung

    这篇文章写得非常好。作为一个这方面的新手,我觉得我终于开始理解了这一切。感谢您的这篇文章。

    2018年1月18日 上午6:50

  11. oanchasa

    非常好

    2018年1月18日 上午6:53

  12. asgs

    惊人的创新(或改进)。感谢您撰写这篇文章,它解释得非常清晰!

    2018年1月18日 下午2:05

  13. Sandra Jane Kays

    我觉得这篇文章非常有趣,也很有教育意义。

    2018年1月18日 下午8:50

  14. Bruno Santos

    Mozilla 在改进 Web 方面做得非常出色。

    这不仅仅是一个改变游戏规则的东西,这将是我们所有人很快通过我们的 UI 使用 Kotlin、Rust、Clojure、C# 或我们喜欢的任何语言来开发应用程序的方式,以完成工作。

    迫不及待!

    2018年1月19日 上午7:40

  15. John Paul Barbagallo

    很棒的文章,对 WA 的未来进行了很好的解释,绝对引起了我的兴趣!

    2018年1月19日 下午3:47

  16. Klas Š.

    新的 WA 编译器是用 Rust 编写的吗?

    2018年1月24日 上午2:42

  17. Ian Vickers

    Lin Clark,你的文章就像 Mozilla 的 XKCD!我喜欢它!

    2018年1月24日 下午7:40

  18. Brian Gaucher

    我知道这有点偏题,但如果将代码映射到“人类”语言以进行调试的功能很快出现,我想调试只会使用基线编译器,因为优化后的代码在某种程度上会更少“人类可读”的组织形式。
    或者,基线代码只会在需要调试的特定部分使用,而没有调试的部分仍然会使用优化版本。

    2018年1月25日 上午9:39

  19. Alec

    太棒了!是否有地方可以找到用于衡量使用 WebAssembly.{compile|instantiate}Streaming 后性能改进的测试?
    我正在准备关于 WebAssembly 和流编译的技术演讲,所以我需要一些反映编译性能改进的数字。

    2018年1月28日 上午11:34

本文评论已关闭。