基线解释器:Firefox 70 中更快的 JS 解释器

简介

现代 Web 应用程序加载并执行的 JavaScript 代码比几年前要多得多。虽然 JIT(即时)编译器 在提高 JavaScript 性能方面非常成功,但我们需要更好的解决方案来处理这些新的工作负载。

为了解决这个问题,我们在 Firefox 70 中的 JavaScript 引擎中添加了一个新的、生成的 JavaScript 字节码解释器。该解释器现在可以在 Firefox Nightly 通道中使用,并将在 10 月份正式发布。我们没有从头开始编写或生成新的解释器,而是找到了一种方法,通过与我们现有的基线 JIT 共享大部分代码来实现这一点。

新的基线解释器提高了性能,减少了内存使用量,并简化了代码。以下是我们的方法。

执行层

在现代 JavaScript 引擎中,每个函数最初都在 字节码解释器 中执行。被频繁调用(或执行多次循环迭代)的函数会被编译成本地机器代码。(这称为 JIT 编译。)

Firefox 有一个用 C++ 编写的解释器和多个 JIT 层。

  • 基线 JIT。每个字节码指令都被直接编译成一小段机器代码。它使用 内联缓存 (IC) 作为性能优化,并为 Ion 收集类型信息。
  • IonMonkey(或简称 Ion),优化 JIT。它使用高级编译器优化,为热门函数生成快速代码(以较慢的编译时间为代价)。

函数的 Ion JIT 代码可能会因各种原因被“反优化”并丢弃,例如当函数被调用时使用新的参数类型时。这称为回退。当发生回退时,执行将继续在基线代码中进行,直到下一个 Ion 编译。

在 Firefox 70 之前,非常热门函数的执行管道是这样的。

Timeline showing C++ Interpreter, Baseline Compilation, Baseline JIT Code, Prepare for Ion, Ion JIT Code with an arrow (called bailout) from Ion JIT Code back to Baseline JIT Code

问题

虽然这种方法运行良好,但我们在管道的第一个部分(C++ 解释器和基线 JIT)遇到了以下问题。

  1. 基线 JIT 编译速度很快,但现代 Web 应用程序(如 Google Docs 或 Gmail)执行的 JavaScript 代码量如此之大,以至于我们在基线编译器中花费了相当多的时间,编译了数千个函数。
  2. 由于 C++ 解释器速度很慢,并且不收集类型信息,延迟基线编译或将其移出线程将是一个性能风险。
  3. 如上图所示,优化的 Ion JIT 代码只能回退到基线 JIT。为了使这种方法有效,基线 JIT 代码需要额外的元数据(对应于每个字节码指令的机器代码偏移量)。
  4. 基线 JIT 有一些用于回退、调试器支持和异常处理的复杂代码。在这些功能相互交叉的地方尤其如此!

解决方案:生成更快的解释器

我们需要从基线 JIT 获取类型信息,以启用更优化的层,并且我们希望使用 JIT 编译来提高运行时速度。然而,现代 Web 具有如此庞大的代码库,即使是相对较快的基线 JIT 编译器也花费了大量时间进行编译。为了解决这个问题,Firefox 70 在管道中添加了一个新的层,称为基线解释器。

Same timeline of execution tiers as before but now has the 'Baseline Interpreter' between C++ interpreter and Baseline compilation. The bailout arrow points to Baseline Interpreter instead of Baseline JIT Code.

基线解释器位于 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 代码)。
JSScript (bytecode) points to JitScript (IC and profiling data). JitScript points to BaselineScript (Baseline JIT Code) and IonScript (Ion JIT code).

请注意,函数的基线 JIT 数据现在只是机器代码。我们将所有内联缓存和概要分析数据移到了 JitScript 中。

共享帧布局

基线解释器使用与基线 JIT 相同的帧布局,但我们在帧中添加了一些 特定于解释器的字段。例如,字节码 PC(程序计数器)是当前正在执行的字节码指令的指针,在基线 JIT 代码中不会被显式更新。如果需要,可以从返回地址确定它,但基线解释器必须将其存储在帧中。

像这样共享帧布局有很多优点。我们几乎没有对 C++ 和 IC 代码进行任何更改来支持基线解释器帧——它们就像基线 JIT 帧一样。此外,当脚本足够热以进行基线 JIT 编译时,从基线解释器代码切换到基线 JIT 代码仅仅是 解释器代码 跳转到 JIT 代码。

共享代码生成

由于基线解释器和 JIT 非常相似,因此许多代码生成代码也可以共享。为此,我们添加了一个模板化的 BaselineCodeGen 基类,它有两个派生类。

基类具有一个 Handler C++ 模板参数,可用于专门针对基线解释器或 JIT 行为。很多基线 JIT 代码都可以通过这种方式共享。例如,JSOP_GETPROP 字节码指令的实现(用于 JavaScript 代码中的属性访问,例如 obj.foo是共享代码。它调用了 emitNextIC 辅助方法,该方法被 专门化 用于解释器或 JIT 模式。

生成解释器

有了所有这些部件,我们就能实现 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 的 Try 服务器上,在 Windows 10 64 位系统上运行了 Speedometer 和 Google Docs,并逐一启用各个层级。(以下数字反映了 7 次运行中的最佳结果。)
C++ Interpreter 901 ms, + Baseline Interpreter 676 ms, + Baseline JIT 633 ms
在 Google Docs 上,我们看到基线解释器比仅使用 C++ 解释器快得多。启用基线 JIT 后,页面加载速度略有提升。

在 Speedometer 基准测试中,当我们启用基线 JIT 层级时,获得了明显更好的结果。基线解释器再次比仅使用 C++ 解释器好得多。
C++ Interpreter 31 points, + Baseline Interpreter 52 points, + Baseline JIT 69 points
我们认为这些数字非常棒:基线解释器比 C++ 解释器快得多,并且它的启动时间(JitScript 分配)比基线 JIT 编译快得多(至少快 10 倍)。

简化

在所有这些功能落地并稳定后,我们能够通过利用基线解释器来简化基线 JIT 和 Ion 代码。

例如,来自 Ion 的反优化逃逸现在会在基线解释器中恢复,而不是在基线 JIT 中恢复。解释器可以在 JS 代码中下一次循环迭代时重新进入基线 JIT 代码。在解释器中恢复比在基线 JIT 代码中间恢复要容易得多。现在我们只需要为基线 JIT 代码记录更少的元数据,因此基线 JIT 编译也变得更快。类似地,我们能够 移除大量用于调试支持和异常处理的复杂代码

下一步是什么?

有了基线解释器,现在应该可以将基线 JIT 编译移到线程之外。我们将在未来几个月内致力于这项工作,预计这方面将有更多性能提升。

致谢

虽然我做了大部分基线解释器的工作,但许多其他人也为这个项目做出了贡献。特别是 Ted Campbell 和 Kannan Vijayan 审查了大部分代码更改,并提供了很好的设计反馈。

还要感谢 Steven DeTar、Chris Fallin、Havi Hoffman、Yulia Startsev 和 Luke Wagner 对这篇博文提出的反馈。

关于 Jan de Mooij

Jan 是 Mozilla 的软件工程师,他在那里负责 SpiderMonkey,Firefox 中的 JavaScript 引擎。他住在荷兰。

Jan de Mooij 的更多文章…


14 条评论

  1. Kelly

    有没有可能为那些“梦想成为程序员的傻瓜系统管理员”(我)提供一个简化的解释?

    比如,我不理解你所说的“C++ 解释器”。这实际上是经典的 SpiderMonkey JS 解释器吗(换句话说,是用 C++ 编写的 JS 解释器)?“基线解释器”与“C++ 解释器”有什么不同(它不也还是用 C++ 编写的吗)?“基线 JIT”与“Ion JIT”有什么不同?如果一个东西被 JIT 编译了,它就被 JIT 编译了,对吧?你不能编译一个已经被编译过的东西?

    我的天,我以前稍微理解 JIT 被添加到 JS 引擎中的情况,但我现在完全迷茫了。

    2019 年 8 月 30 日 上午 11:34

    1. Jan de Mooij

      没错,所有这些都可能变得非常复杂!我希望以下内容能有所帮助

      C++ 解释器:你说得对,它是用 C++ 编写的 JS 字节码解释器。

      基线解释器:一个动态生成的解释器。生成解释器的代码仍然是用 C++ 编写的。它比 C++ 解释器快,原因之一是它使用了内联缓存。

      为什么我们有两个 JIT,一个是基线 JIT,另一个是 Ion?因为它们的目標不同:基线 JIT 的目標是:(1)非常快地编译代码(编译后的代码不是特别快,但仍然比解释器快)(2)收集有关脚本的信息以供 Ion JIT 编译器使用。

      Ion JIT 的目標不同:(1)生成非常快的代码(但这可能需要更长时间,因此我们只对热点代码进行此操作)(2)根据基线解释器/JIT 观察到的内容做出假设(这使得它更快)并在这些假设发生变化时丢弃代码。

      所以,是的,一个函数实际上可以被多次 JIT 编译!

      2019 年 8 月 30 日 下午 11:31

  2. bob

    感谢!这对 Web 和所有使用 Web 应用程序的人来说都是好消息!

    2019 年 8 月 30 日 下午 1:51

  3. Yan Luo

    基线解释器看起来像 LuaJIT 中的那个吗?

    2019 年 8 月 30 日 下午 8:48

    1. Jan de Mooij

      抱歉,我对 LuaJIT 不太熟悉,也许我们的读者中有人能更好地回答这个问题 :) (我认为 LuaJIT 有一个跟踪 JIT,所以我预计解释器和 JIT 与我们的会有很大不同,但我可能错了。)

      2019 年 8 月 30 日 下午 11:58

      1. Jaen

        LuaJIT 也使用汇编语言编写了一个解释器,在表面上它与这里生成的解释器类似。

        由于更高层级是一个跟踪 JIT,因此它不需要使用内联缓存。

        2019 年 8 月 31 日 上午 12:13

        1. Yan Luo

          谢谢!

          2019 年 8 月 31 日 下午 3:32

      2. Yan Luo

        谢谢!读起来很不错。

        2019 年 8 月 31 日 下午 3:33

      3. DRSAgile

        Jan,感谢您的文章。

        您提到您的下一个项目是将一些编译移到辅助进程线程中,这很棒。

        但是,在那之后,为什么不也像 LuaJIT 一样用汇编语言重写更多东西?

        问题是,无论你尝试用编译器优化标志进行多大的努力,一般来说,C++ 比 C 慢,C 比汇编慢。Firefox 现在不是在成千上万个 CPU/ISA 上运行的,你只需要从 AMD x64 开始,所以一切都应该没问题,对吧?

        考虑到 JS 引擎之间的“斗争”主要是为了微小的提升,用汇编语言重写东西不会大幅提升性能的旧论点已经不成立了:现在每个百分比都很重要。

        2019 年 9 月 3 日 下午 11:00

        1. Jan de Mooij

          感谢您的回复!总的来说,我们认为我们的大部分性能提升将来自于更智能地了解我们应该编译什么以及如何编译。仍然有很多潜在的改进,这些改进的层级更高,影响更大,而不是 C/C++ 与汇编的比较。

          2019 年 9 月 3 日 下午 11:39

        2. Aaron W

          我更愿意继续关注架构改进,而不是花大量时间进行特定于架构的优化。

          Firefox 目前在 arm 32/64、x86 32/64、Power(很确定)、mips 和谁知道还有什么其他架构上运行。这些架构中的任何一个都可能拥有各种不同的指令集扩展,应该在探索完所有可能的与指令集无关的架构改进之后再探索对这种矩阵的支持。

          尽可能保持代码可移植性是一个很棒的功能。

          由于它的可移植性,我甚至在我的 90 年代中期的 Alpha 上运行着 libmozjs,作为 gnome shell 的一部分。速度很慢,但能做到这一点很棒。

          2019 年 9 月 6 日 下午 4:20

        3. Horacio

          作为一名从事多平台代码工作的嵌入式工程师,我很想看到一些证明用汇编语言编写是一个优势的证据,更不用说一个可持续的优势了;)

          2019 年 9 月 8 日 上午 5:27

  4. Spudd86

    最终会用基线解释器替换 C++ 解释器吗?如果没有,为什么?

    2019 年 9 月 23 日 上午 11:34

    1. Jan de Mooij

      与 C++ 解释器相比,基线解释器对冷代码有一些开销(IC 需要分配一些数据结构,并且对冷代码有性能开销)。网络上很多 JS 代码都是非常冷的,所以我们需要谨慎对待。此外,我们需要保留 C++ 解释器,因为它适用于没有 JIT 后端的平台,而且它也便于调试 :)

      也许将来会这样做,但现在这不是我们的优先事项。

      2019 年 9 月 24 日 上午 3:01

这篇文章的评论已关闭。