「译」Liftoff:V8 引擎中全新的 WebAssembly baseline 编译器

v8-logo

翻译自: Liftoff: a new baseline compiler for WebAssembly in V8

Monday, August 20, 2018html

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

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

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

现存的编译管线(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-01

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

Liftoff 的代码生成

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

让咱们来看一个很是简单的函数,来看下 Liftoff 是如何生成代码的。less

liftoff-02

这个范例函数接受两个参数而后返回他们的和。当 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 只须要生成代码去重排一下寄存器与物理栈帧上的值就能够了。

让咱们看一下以下例子。

liftoff-03

上面的例子设定了一个拥有两个值的操做数栈的虚拟栈。在开始新区块以前,虚拟栈最顶端的值被出栈用做 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-04

性能

有两个指标在咱们评估新的 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-05

liftoff-06

如咱们所预期的,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 的代码更快一些。下面咱们来看结果:

liftoff-07

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

liftoff-08

执行 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

文章可随意转载,但请保留此 原文连接
很是欢迎有激情的你加入 ES2049 Studio,简历请发送至 caijun.hcj(at)alibaba-inc.com 。
相关文章
相关标签/搜索