作者 | Jan de Mooij
译者 | 王强
编辑 | 张之栋、Yonie
现代 Web 应用程序需要加载并执行的 JavaScript 代码越来越多,为了解决不断增长的负载需求,Firefox 70 中为 JavaScript 引擎添加了一个新的 JavaScript 字节码解释器,目前它已进入到 Firefox Nightly 通道试用,并将在 10 月份正式发布。
    前言    
与几年前相比,现代 Web 应用程序需要加载并执行的 JavaScript 代码要多很多。虽然 JIT(即时)编译器大幅提升了 JavaScript 的执行性能,但我们还是需要一个更好的解决方案来应对不断增长的负载需求。
为了解决这个问题,我们在 Firefox 70 中为 JavaScript 引擎添加了一个新的 JavaScript 字节码解释器。这个解释器现在已进入 Firefox Nightly 通道试用,将在 10 月份正式发布;它并不是从零开始新造的一个轮子,而是在现有的基线 JIT 代码基础上发展而来的。
这个新生成的基线解释器提升了性能、减少了内存占用并简化了代码库。本文将具体介绍其背后的机制:
    执行层    
在现代 JavaScript 引擎中,所有函数都是在字节码解释器中开始执行的。需要频繁调用(或执行许多循环迭代)的函数被编译为原生机器码。(这称为 JIT 编译。)
Firefox 有一个用 C++ 编写的解释器和多个 JIT 层:
  • 基线 JIT。(Baseline JIT)每条字节码指令直接编译为一小段机器码。它使用 内联缓存 来提升性能并从 Ion 收集类型信息。
  • IonMonkey(简称 Ion),优化版的 JIT。它使用高级编译优化技术为热点函数生成速度较快的代码(代价是编译时间较长)。
函数的 Ion JIT 代码可能因为种种原因被“去优化”并丢弃,例如使用新的参数类型调用函数时就会出现这种情况。这被称为 bailout。当 bailout 发生时,在下一次 Ion 编译之前都会使用基线代码执行。
在 Firefox 70 之前的版本中,一个高热函数的执行流水线如下所示:
C++ 解释器➡️基线编译器➡️基线 JIT 代码➡️为 Ion 准备➡️基线 JIT 代码 / 主线程外的 Ion 编译➡️Ion JIT 代码
    问题    
虽然上面这套机制很不错,但在流水线的第一部分(C++ 解释器和基线 JIT)中我们遇到了以下问题:
  1. 基线 JIT 编译速度很快,但像谷歌文档或 Gmail 这样的现代 Web 应用程序需要运行的 JavaScript 代码太多了,基线编译器往往需要花费很长的时间来编译数以千计的函数。
  2. 因为 C++ 解释器速度非常慢,而且不收集类型信息,所以不管是等待基线编译结果还是将其移出主线程(off-thread)都可能带来性能损失。
  3. 正如你在上图中所看到的,优化过的 Ion JIT 代码只能 bailout 到基线 JIT 代码上。为此基线 JIT 代码需要额外的元数据(对应于每条字节码指令的机器码偏移)。
  4. 基线 JIT 的 bailout、调试器支持和异常处理功能需要很复杂的代码,当这些功能交叉应用时代码尤其复杂!
解决方案:生成一个速度更快的解释器
我们需要来自基线 JIT 的类型信息来优化编译层,并使用 JIT 编译来提高运行时性能。但现代 Web 应用的代码库太大了,即便是相对较快的基线 JIT 编译器也需要花费大量时间。为了解决这个问题,Firefox 70 在流水线中新生成了一个名为基线解释器(Baseline Interpreter)的处理层:
基线解释器位于 C++ 解释器和基线 JIT 之间,并且融入了这两者的一些元素。它使用固定的解释器循环(类似 C++ 解释器)执行所有字节码指令。此外,它使用内联缓存来提高性能并收集类型信息(类似基线 JIT)。
加入一个解释器并不是什么新鲜事。但我们复用了基线 JIT 编译器的大多数代码,很好地实现了这个需求。基线 JIT 是一个模板 JIT,意思是字节码指令会被编译成基本上固定的机器指令序列,然后我们将这些序列放到解释器循环里。
共享内联缓存和分析数据
如前所述,基线 JIT 使用内联缓存(IC)来提高性能并辅助 Ion 编译。需要获取类型信息时,Ion JIT 编译器可以检查基线 IC。
因为我们希望基线解释器使用与基线 JIT 完全相同的内联缓存和类型信息,所以我们新添加了一个名为 JitScript 的数据结构。JitScript 包含基线解释器和 JIT 使用的所有类型信息和 IC 数据结构。
下图展示了内存中的内容。每个箭头都是 C++ 中的指针。一开始这个函数只有一个带有字节码的 JSScript,可以由 C++ 解释器解释。在几次调用 / 迭代之后我们创建了 JitScript,将它附加到 JSScript,然后就可以在基线解释器中运行脚本了。
代码热度不断提升后,我们也可以创建 BaselineScript(基线 JIT 代码),随后是 IonScript(Ion JIT 代码)。
请注意,函数的基线 JIT 数据现在还只是机器码。我们将所有内联缓存和分析(profile)数据都移动到了 JitScript 里。
共享帧布局
基线解释器使用的帧布局与基线 JIT 是一样的,但我们在帧中添加了一些解释器专用的字段: https://searchfox.org/mozilla-central/rev/325c1a707819602feff736f129cb36055ba6d94f/js/src/jit/BaselineFrame.h#55-58 比如说字节码 PC(程序计数器),这是一个指向我们当前正在执行的字节码指令的指针,不会在基线 JIT 代码中显式更新。如果需要,它可以根据返回地址来确定,但基线解释器必须将其存储在帧中。 
像这样的共享帧布局有很多优点。我们的基线解释器帧几乎没对 C++ 和 IC 代码做什么更改——它们和基线 JIT 帧是一回事。此外当脚本热度够高,适合用基线 JIT 编译时,从基线解释器代码切换到基线 JIT 代码也会很简单,只需从解释器代码跳转到 JIT 代码: https://searchfox.org/mozilla-central/rev/8ea946dcf51f0d6400362cc1d49c8d4808eeacf1/js/src/jit/BaselineCodeGen.cpp#1262-1275
共享代码生成
因为基线解释器和 JIT 非常相似,所以很多代码生成代码也可以共享。为此,我们添加了一个带有两个派生类的模板化 BaselineCodeGen 基类:
  • BaselineCompiler:基线 JIT 用其将脚本的字节码编译为机器码。
  • BaselineInterpreterGenerator:用于生成基线解释器代码。


模板化 BaselineCodeGen 基类: https://searchfox.org/mozilla-central/rev/8ea946dcf51f0d6400362cc1d49c8d4808eeacf1/js/src/jit/BaselineCodeGen.h#264-269
这个基类有一个 Handler C++ 模板参数,可用于基线解释器或 JIT 的特定行为。可以通过这种方式共享许多基线 JIT 代码。例如,JSOP_GETPROP 字节码指令的实现(用于像 JavaScript 代码中的 obj.foo 这样的属性访问)是共享代码。它调用emitNextIC 辅助方法,该方法专用于解释器或 JIT 模式。
emitNextIC 辅助方法: https://searchfox.org/mozilla-central/rev/8ea946dcf51f0d6400362cc1d49c8d4808eeacf1/js/src/jit/BaselineCodeGen.cpp#531-588
生成解释器
万事俱备,接下来我们就能够实现 BaselineInterpreterGenerator 类来生成基线解释器了!它生成了一个线程解释器循环:每条字节码指令的代码后会间接跳转到下一条字节码指令。
例如,在 x64 平台上我们现在生成如下机器码来解释 JSOP_ZERO(将零值推到栈上的字节码指令):
// Push Int32Value(0).

movabsq $-0x7800000000000, %r11

pushq %r11

// Increment bytecode pc register.

addq $0x1, %r14

// Patchable NOP for debugger support.

nopl (%rax,%rax)

// Load the next opcode.

movzbl (%r14), %ecx

// Jump to interpreter code for the next instruction.

leaq 0x432e(%rip), %rbx

jmpq *(%rbx,%rcx,8)

我们在 7 月份的 Firefox Nightly(版本 70)中启用基线解释器时,将基线 JIT 预热阈值从 10 增加到了 100。预热计数是等于函数调用次数 + 到目前为止的循环迭代次数。基线解释器的阈值为 10,与之前的基线 JIT 阈值相同。这意味着基线 JIT 需要编译的代码要少得多。
    结果    
性能和内存使用情况
在 Firefox Nightly 加入这个解释器后,我们的基础性能测试体系检测到了一些性能提升:
  • 2-8%不等的页面加载速度提升。页面加载和 JS 执行(解析、样式、布局、图形)有很多改善,这样的提升幅度还是很明显的。
  • 许多开发工具性能测试结果提高了 2-10%不等。
  • 内存占用也有小幅改善。
请注意,我们之后版本的性能提升幅度还会更大一些。
为了对比基线解释器与 C++ 解释器和基线 JIT 的性能,我在 Mozilla 的测试服务器的 Windows 10 64 位系统中测试了 Speedometer 和谷歌文档,并逐个启用了这些层。(以下结果是七次测试中的最好成绩):
在谷歌文档上,我们看到基线解释器比 C++ 解释器快很多。启用基线 JIT 也会使页面加载速度提高一点。
在 Speedometer 基准测试中,启用基线 JIT 层时结果要好得多。基线解释器还是比 C++ 解释器更快:
我们认为这样的结果很不错:基线解释器比 C++ 解释器快得多,它的启动时间(JitScript 分配)比基线 JIT 编译快得多(至少快 10 倍)。
    简化    
这些工作都完成后,我们就能利用基线解释器来简化基线 JIT 和 Ion 代码了。
例如,Ion 的去优化 bailout 现在会在基线解释器中恢复,无需再用基线 JIT。解释器可以在 JS 代码的下一个循环迭代中重新输入基线 JIT 代码。在解释器中恢复比在基线 JIT 代码中恢复要容易得多。我们现在不用为基线 JIT 代码记录那么多元数据,因此基线 JIT 编译的性能也随之提升。此外我们还能移除许多用于调试器支持和异常处理的复杂代码: https://hg.mozilla.org/mozilla-central/rev/49a2da59aa3e#l3.535
未来规划
有了基线解释器后,现在我们应该可以把基线 JIT 编译放到主线程外了。我们将在未来几个月内开展这项工作,并期望在这一领域取得更多性能提升。
    声明    
基线解释器的大部分工作是我做的,但也有很多人为此项目作出了贡献。尤其值得一提的是 Ted Campbell 和 Kannan Vijayan 审核了多数代码更改并提供了很好的设计反馈意见。
还要感谢 Steven DeTar、Chris Fallin、Havi Hoffman、Yulia Startsev 和 Luke Wagner 对本文的反馈。
英文原文: https://hacks.mozilla.org/2019/08/the-baseline-interpreter-a-faster-js-interpreter-in-firefox-70/

继续阅读
阅读原文