V8 引擎在 v6.9 版本中加入了一个全新的 WebAssembly baseline 编译器 —— Liftoff。它目前在桌面系统平台上是默认开启的。本文将会详细讲解引入新的编译层的动机,并介绍一下 Liftoff 的具体实现以及性能情况。

在 WebAssembly 开始发展的这一年多时间里,其在 web 上的应用一直在稳步发展。采用 WebAssembly 技术的大型应用已开始出现。例如 Epic 的 ZenGarden benchmark 推出了一版 39.5 MB 的 WebAssembly 二进制包,以及 AutoDesk 推出了一版 36.8 MB 的二进制包。因为编译时间基本上是相对包大小线性增长的,所以这些应用都需要花费相当长的时间在启动上。在许多机器上甚至会超过 30 秒,这可不是一个很好的用户体验。

为什么一个 WebAssembly 应用启动要花这么久的时间,而一个类似的 JS 应用相比之下可以很快启动呢?原因是 WebAssembly 需要保证提供一个可预期的性能,这样你的应用启动后就可以稳定得达到预期的运行性能。(比如每秒渲染 60 帧,无音频延迟等等...)。为了达到这一目标,V8 对 WebAssembly 代码会提前编译,这样就可以避免任何运行时编译器引起的编译暂停让应用发生可感知的卡顿。

现存的编译管线(TurboFan)

V8 过去对 WebAssembly 的编译是基于 TurboFan 的。TurboFan 是专为 JavaScript 和 asm.js 设计的优化编译器。他是一款功能强大的编译器,内部使用一种基于图的中间表达(IR),其适用于进一步的优化,例如强度折减(strength reduction)、内联(inlining)、代码外提(code motion)、指令合并(instruction combining)、精密寄存器分配(sophisticated register allocation)。TurboFan 的设计支持在整个管线的很后面才会引入,接近机器码这边,所以会跳过许多帮助 JavaScript 编译的必要步骤。因为设计原因,通过一个单次前向处理来将 WebAssembly 代码转换到 TurboFan 的 IR(包含 SSA-构造)是非常有效率的,部分是因为 WebAssembly 结构化的控制流。不过编译进程后台仍然要消耗相当多的时间与内存。

新的编译管线(Liftoff)

Liftoff 的目标是通过尽快生成可执行代码来缩减 WebAssembly 应用的启动时间。代码的质量则是放在第二位的,毕竟 “hot” 的代码还是会被 TurboFan 再编译一次的。Liftoff 在对 WebAssembly 函数的字节码的单次处理中,规避了因构建 IR 和生成机器码发生的时间与内存开销。

从上面这张图表可以很明显地看出 Liftoff 会比 TurboFan 产出代码的速度快很多,因为它的管线只由两个步骤组成。事实上,函数体解码器(Function Body Decoder)只对源 WebAssmebly 字节码做一次处理,并通过回调方式与后面的步骤进行交互,所以代码生成是在解码与校验函数体的时候同时执行的。再结合上 WebAssembly 的流式(streaming)API,可以让 V8 在从网络上下载代码的同时将 WebAssembly 代码编译成机器码。

Liftoff 的代码生成

Liftoff 是一款简单高效的代码生成器。它只对函数内的操作(opcode)做一轮处理,将操作转换成代码,一次一个。像计算这样的简单操作,一般就对应一个机器指令,但是像调用这样的操作就会对应更多的机器指令。Liftoff 维护着一个操作数栈的元数据,用以知晓每个操作的输入正被存储在什么位置。这个虚拟栈仅存在于编译期间。WebAssembly 的结构化控制流与校验规则保证了这些输入的位置可以被静态确定。这样就不再需要一个用于入栈出栈操作元的真实运行时栈了。在运行期间,虚拟栈上的每个值会被存储于寄存器或者是被溢出到那个函数的物理栈帧。那些小的整型常量(由 i32.const 创建),Liftoff 只会将他的常量值记录在虚拟栈上,而不会生成任何代码。只有当这个常量被用于随后的一个操作,他会被与这个操作一起发出或组合,例如在 x64 上直接发出一个 addl <reg>, <const> 指令。这避免了将这个值写入寄存器的操作,产出了更为简洁的代码。

让我们来看一个非常简单的函数,来看下 Liftoff 是如何生成代码的。

这个范例函数接受两个参数然后返回他们的和。当 Liftoff 解码这个函数的字节码时,他先根据 WebAssembly 的函数调用约定为本地变量初始化他的内部状态。拿 x64 来说,依照 V8 的调用约定,要将这两个参数传入 raxrdx 两个寄存器。

对于 get_local 指令,Liftoff 不会实际生成任何代码,而只是对他的内部状态进行更新,以反映这些寄存器值已被入栈到虚拟栈中。然后 i32.add 指令出栈了这两个寄存器,并且为结果值选择一个寄存器。我们不能选择两个入参寄存器中的任何一个来给结果值使用,因为这两个寄存器都还作为存放本地变量的位置出现在栈上。覆盖他们会导致后面的 get_local 指令返回不正确的值。因此 Liftoff 会选择一个新的寄存器(在例子中是 rcx)然后将计算出的 raxrdx 的和写入这个寄存器。之后 rcx 会被入栈到虚拟栈中。

在 i32.add 指令之后,函数体结束了,Liftoff 此时需要开始准备返回内容了。范例中的函数只有一个返回值,所以校验环节需要保证在函数体结束时虚拟栈上只有一个值。因此 Liftoff 生成代码将返回值从 rcx 移动到更合适的返回值寄存器 rax 然后从函数中返回出来。

为了让例子尽量简单,上面的代码并没有涉及任何区块(if, loop …)或者是分支。在 WebAssembly 中,由于代码可以分支到任何父区块并且 if-区块可以被跳过,所以区块引入了控制合并。这些合并点可能会在多种不同的栈状态下被执行到。然而后面的代码必须基于一个确定的状态去生成。因此 Liftoff 会将虚拟栈当前的状态存储为快照,这个状态会作为新区块之后的代码(回到当前所在的控制层级的时候)的状态。然后新区块继续使用当前的状态,可能后面会更改栈值或者是本地值的存储位置:有一些可能会溢出到栈上或者是被放到别的寄存器上。当分支到另一个区块或者结束了一个区块(也可以理解为分支到了父级区块)时,Liftoff 需要生成代码去将当前状态转换到那个点上期望的状态,这些代码运行后可以让之后的代码在其期望的位置找到正确的值。校验环节保证了虚拟栈的高度与所期望的状态下的高度是相等的,因此 Liftoff 只需要生成代码去重排一下寄存器与物理栈帧上的值就可以了。

让我们看一下如下例子。

上面的例子设定了一个拥有两个值的操作数栈的虚拟栈。在开始新区块之前,虚拟栈最顶端的值被出栈用作 if 指令的参数。栈上剩下的一个值需要被放到另一个寄存器去,因为他现在实际指向的是与第一个函数参数相同的寄存器,但当我们之后回到现在状态的时候,这个栈上的值与参数值我们很可能需要存为两个不同的值。这种情况下,Liftoff 会复制一份值到寄存器 rcx 。之后这个状态就会被快照存储,后面区块的代码会对当前状态继续进行修改。在这个区块结束时,我们一定会分支回到父区块,所以我们将当前状态合并到快照上,具体做法就是将 rbx 的值迁移到 rcx 上,然后将 rdx 的值从栈帧上加载回来。

从 Liftoff 到 TurboFan 的层级提升(Tiering up)

有了 Liftoff 和 TurboFan,现在 V8 引擎针对 WebAssembly 有两个编译层级了:Liftoff 作为 baseline 编译器提供快速启动的能力,TurboFan 作为优化编译器提供最佳性能。这就带来了一个问题,如何协调使用这两个编译器以带来全局最佳的用户体验。

在 JavaScript 中,V8 使用了 Ignition 解释器与 TurboFan 优化编译器并通过一个动态升级策略(dynamic tier-up)进行调配。每一个函数首先都会在解释器上执行,当这个函数变得经常被执行(hot)时,TurboFan 会将其编译为高度优化的机器码执行。相同的方法也可以在 Liftoff 上做应用,不过其中的权衡点可能会稍有不同:

  1. WebAssembly 不需要类型反馈来生成更快的代码。JavaScript 的优化有很多是得益于类型反馈的,但 WebAssembly 是静态类型的,所以引擎可以独立生成优化代码。
  2. WebAssembly 代码必须在一个可预期的高速状态下运行,不能有一个长时间的热身阶段。应用选择使用 WebAssembly 的众多原因之一就是可以以一个可预期的高性能运行在 web 上。所以我们即不能容忍代码在次优化状态下运行太久,也不能允许运行时编译引发的暂停。
  3. JavaScript 的 Ignition 解释器的重要设计目标之一就是通过不用编译所有函数来减少内存的开销。然而我们发现一个 WebAssembly 解释器实在是太慢了,完全无法满足我们提供可预期高性能的目标。事实上,我们还真写过一个解释器,不管他节约了多少空间,他比运行编译后代码至少慢了20倍甚至更多,只能说他在 debug 时还有点用。因为这些原因,引擎不得不存储编译后代码;最后他应该只会存储那些最为精简高效的代码,那就是 TurboFan 优化后的代码。

从以上这些限制,我们可以发现动态升级对于当前 V8 对 WebAssembly 的优化实现并不是最佳的权衡点,因为这会引发代码大小的增加以及在一个不确定时间段内的性能缩水。在这里我们选择了另一个策略,叫做饥渴升级(eager tier-up)。在 Liftoff 完成对一个模块的编译之后,紧跟着,WebAssembly 引擎会拉起一个后台线程开始生成该模块的优化代码。这种策略使 V8 可以快速得开始运行代码(在 Liftoff 完成编译后),并且依然能够尽早地让代码运行在 TurboFan 优化后的性能下。

下面这张图片展示了在编译与运行 the EpicZenGarden benchmark 时的跟踪信息。图上显示,在 Liftoff 完成编译之后,我们就可以实例化 WebAssembly 模块并开始运行。TurboFan 的编译在这之后还需要一点时间完成,因此在这段升级过程的时间区间内,我们可以观察到运行性能在逐渐地提升,得益于单独的 TurboFan 函数可以在他们完成编译之后就马上投入使用。

性能

有两个指标在我们评估新的 Liftoff 编译器的性能时是非常感兴趣的。第一个是我们会比较他和 TurboFan 在编译速度(生成代码的用时)上的差异。第二个是我们会测量生成出的代码的运行性能(运行速度)。两者中第一个指标是我们更为关注的,毕竟 Liftoff 的最重要目标就是尽快生成代码来缩减应用启动时间。另一方面,生成出的代码的运行性能也需要是比较不错的,因为这些代码可能会需要执行几秒几十秒,在一些低性能硬件上甚至可能是几分钟。

生成代码性能

为了测量编译器性能,我们会运行几个 benchmark 并通过追踪(如上图所示)测量编译时间。我们会在一台 HP Z840 机器(2 x Intel Xeon E5-2690 @2.6GHz, 24 cores, 48 threads)和一台 Macbook Pro(Intel Core i7-4980HQ @2.8GHz, 4 cores, 8 threads)上进行 benchmark 测试。注意 Chrome 目前不会使用超过 10 个后台线程,因此 Z840 的大部分核心是不会被用到的。

我们运行了三个 benchmark:(神tm三个,明明是四个)

  1. EpicZenGarden: The ZenGarden demo running on the Epic framework: https://s3.amazonaws.com/mozilla-games/ZenGarden/EpicZenGarden.html
  2. Tanks!: A demo of the Unity engine: https://webassembly.org/demo/
  3. AutoDesk: https://web.autocad.com/
  4. PSPDFKit: https://pspdfkit.com/webassembly-benchmark/

每一个 benchmark 我们都会记录下追踪工具测量出的编译时长。这个数字会比 benchmark 自己跑出来的时长更加稳定,因为他不和某个主线程上注册的任务相关联,也不会包含任何类似创建 WebAssembly 实例这样无关的动作。

下图展示了这些 benchmark 的结果,每一个 benchmark 我们都重复跑了三次并对结果取平均数。

如我们所预期的,Liftoff 编译器不管是在高配置的桌面工作站还是 Macbook 上都有着更加快的代码生成速度。即使是在低性能的 MAcbook 硬件上,Liftoff 相比 TurboFan 的提速效果也要远远大得多。

产出代码的运行性能

虽然产出代码的运行性能是我们的二级目标,但毕竟 Liftoff 的代码在 TurboFan 生成代码之前还是很可能要跑个几秒几十秒的,所以我们还是期望能在启动阶段提供一个高性能的用户体验。

为了测量 Liftoff 产出的代码的性能,我们关闭了自动升级,以求检测纯 Liftoff 代码的运行状态。在这个设定下,我们跑了两个 benchmark:

  1. Unity headless benchmarks

    这是一系列在 Unity 框架下运行的 benchmark。他们是无 UI 的,因此可以直接在 d8 shell 下运行。每一个 benchmark 会统计出一个得分,虽然这个分数并不一定是成比例得反应性能的,但已经足够用来比较性能了。

  2. PSPDFKit: https://pspdfkit.com/webassembly-benchmark/

    这个 benchmark 会统计对 pdf 文档做各种操作的时间开销,以及 WebAssembly 模块的实例化时间(包含编译)

和之前一样,我们会每个 benchmark 跑三次然后取平均值。因为 benchmark 结果数值的差异非常得明显,我们在这里选择展示 Liftoff 与 TurboFan 的相对性能。+30% 代表 Liftoff 的代码要比 TurboFan 的代码慢 30%。负值则代表着 Liftoff 的代码更快一些。下面我们来看结果:

执行 Unity 时,在台式机上 Liftoff 的代码要比 TurboFan 的代码平均慢 50%,在 Macbook 上平均慢 70%。有趣的是,你会发现有一个情况下(Mandelbrot Script)Liftoff 的代码性能要比 TurboFan 的代码好。这很可能是一个异常情况,例如 TurboFan 的寄存器分配器在一个高频循环中表现得不是很好。我们正在研究是否有什么办法让 TurboFan 能更好得处理这种情况。

执行 PSPDFKit benchmark 时,Liftoff的代码要比优化后的代码慢上 18-54%,不过就如我们所期望的,在初始化这块上有着显著的提升。这些数字告诉我们,对于那些真实项目的代码(可能会通过 JavaScript 调用与浏览器进行交互的),未优化代码的性能损失通常都要比那些计算集中型 benchmark 的代码损失得少。

并且在这里要再声明一下,这个结果是我们在完全关闭了升级策略的情况下跑出来的,我们只运行了 Liftoff 的代码。在生产版本的配置下,Liftoff 的代码会在运行时逐渐被 TurboFan 的代码替代,因此低性能的 Liftoff 代码只会执行很短的一段时间。

接下去要做的

在最初 Liftoff 项目启动后,我们就一直致力于改善启动耗时,减少内存消耗,以及将 Liftoff 带来的收益普惠到更多用户身上。从具体内容上来说,我们正在对下面这些内容进行优化:

  1. 将 Liftoff 移植到 arm 与 arm64 上,使移动设备也可以使用他。目前,Liftoff 只针对 Intel 的平台(32位与64位)做了实现,覆盖了大部分桌面端的用户。为了覆盖到移动端的用户,我们会移植 Liftoff 到更多的架构上。
  2. 为移动设备实现一套动态升级。因为移动设备相比桌面系统倾向于拥有更少的内存空间,我们需要为这些设备适配一套升级策略。只是用 TurboFan 重新编译所有函数的话很容易就会因为要存储那些代码造成内存的双倍消耗,至少一段时间内会出现这种情况(在 Liftoff 代码被弃置前)。所以我们正在实验一种 Liftoff 懒编译与高频函数动态升级到 TurboFan 的组合。
  3. 提高 Liftoff 产出代码的性能。第一次迭代的产物一般都不是最好的。还有不少东西有待调整,他们可以使 Liftoff 的编译速度上升更多。这些内容我们将在以后的发布中逐步带给大家。
  4. 提高 Liftoff 产出的代码的运行性能。除开编译器本身,他产出的代码在大小与执行速度上依然有着提升空间。这些我们也会在之后的发布中逐步加入。

总结

V8 目前已包含了 Liftoff 这一新款 WebAssembly baseline 编译器。Liftoff 他简单快速的代码生成器极大地提升了 WebAssembly 应用的启动速度。在桌面系统上,V8 依然会通过让 TurboFan 在后台重新编译代码的方式最终让代码运行性能达到峰值。V8 v6.9 (Chrome 69) 中 Liftoff 已经设置为默认工作状态,也可以显式地通过 --liftoff/--no-liftoff 或者 chrome://flags/#enable-webassembly-baseline 开关来控制。

本文作者:Clemens Hammacher, WebAssembly compilation maestro

03-05 23:42