WebKit FTL JIT 简介

WebKit 的 FTL JIT(超光速即时编译器)已切换到新的后端——Bare Bones Backend (B3) 取代 LLVM 成为 FTL JIT 中的低级优化器。

就在十年前,JavaScript——用于驱动网页交互的编程语言——曾被认为对于严肃的应用程序开发来说太慢了。但由于持续的优化努力,现在可以使用可移植的、符合标准的 JavaScript 和 HTML5 编写复杂、高性能的应用程序——甚至是图形密集型游戏。本文描述了 JavaScript 优化方面的一项新进展:WebKit 项目已将其现有 JavaScript 编译基础设施与最先进的 LLVM 优化器统一起来。这将使 JavaScript 程序能够利用以前仅限于用 C++ 或 Objective-C 等语言编写的本地应用程序所能利用的复杂优化。

所有主流浏览器引擎都具备复杂的 JavaScript 优化功能。在 WebKit 中,我们平衡了对当今网络上 JavaScript 应用程序的优化以及对下一代网络内容的优化。如今的网站提供大量高度动态的 JavaScript 代码,这些代码通常运行时间相对较短。此类代码的主要开销是加载时间和存储内存。这些开销在 JSBench 中得到了体现,这是一个直接基于 Facebook 和 Twitter 等现有网络应用程序的基准测试。WebKit 在此基准测试中一直表现出色。

WebKit 还具有一个更高级的编译器,可以消除动态语言的开销。该编译器有利于长时间运行的程序,以及执行时间主要集中在相对较少代码中的程序。此类代码的例子包括图像滤镜、压缩编解码器和游戏引擎。

在我们努力改进 WebKit 的优化编译器时,我们发现我们正在越来越多地重复传统预编译(AOT)编译器中已有的逻辑。我们没有继续复制数十年的编译器技术,而是研究将 WebKit 的编译器基础设施与现有的低级编译器基础设施 LLVM 统一起来。截至 r167958,该项目不再是调查阶段。我很高兴地报告,我们基于 LLVM 的即时(JIT)编译器,昵称 FTL——即 Fourth Tier LLVM 的缩写——已在 Mac 和 iOS 端口上默认启用。

本文总结了过去一年中进行的 FTL 工程。首先回顾了 WebKit 的 JIT 编译器在 FTL 之前的运作方式。接着描述了 FTL 架构以及我们如何解决了将 LLVM 用作动态语言 JIT 的一些基本挑战。最后,本文展示了 FTL 如何实现了一些 JavaScript 特有的优化。

WebKit JavaScript 引擎概述

本节概述了 WebKit 的 JavaScript 引擎在添加 FTL JIT 之前是如何工作的。如果您已经熟悉诸如配置文件引导类型推断和分层编译等概念,可以跳过本节

图 1. WebKit 三层架构。箭头表示栈上替换,简称 OSR。

在 FTL JIT 之前,WebKit 使用一种三层策略来优化 JavaScript,如图 1 所示。运行时可以按函数选择三种执行引擎或层级。每个层级都将我们的内部 JavaScript 字节码作为输入,并以不同程度的优化级别解释或编译字节码。三个层级分别为:LLInt(低级解释器)、Baseline JITDFG JIT(数据流图 JIT)。LLInt 针对低延迟启动进行了优化,而 DFG 则针对高吞吐量进行了优化。任何函数的首次执行总是从解释器层开始。一旦函数中的任何语句执行超过 100 次,或函数被调用超过 6 次(以先发生者为准),执行就会被转移到由 Baseline JIT 编译的代码中。这消除了解释器的一些开销,但缺乏任何重要的编译器优化。一旦 Baseline 代码中的任何语句执行超过 1000 次,或 Baseline 函数被调用超过 66 次,我们会再次将执行转移到 DFG JIT。执行可以通过一种称为栈上替换(简称 OSR)的技术在几乎任何语句边界进行转移。OSR 允许我们在字节码指令边界解构字节码状态,无论执行引擎是什么,并为任何其他引擎重建状态,以便使用该引擎继续执行。如图 1 所示,我们可以利用它将执行转移到更高层级(OSR 进入)或降级到更低层级(OSR 退出;详情见下文)。

三层策略在低延迟和高吞吐量之间提供了良好的平衡。对每个 JavaScript 函数都运行像 DFG 这样的优化 JIT 将会非常昂贵;这就像每次你想运行一个应用程序时都必须从源代码编译它一样。优化编译器需要时间运行,JavaScript 优化 JIT 编译器也不例外。多层策略确保我们用于优化 JavaScript 函数的 CPU 时间和内存始终与该函数之前使用的 CPU 时间成比例。我们的引擎对网络内容所做的唯一先验假设是,单个函数过去的执行频率是这些函数未来执行频率的良好预测指标。这三个层级各自带来了独特的优势。得益于 LLInt,我们不会浪费时间编译和存储只运行一次的代码。这可能是顶层 <script> 标签中的初始化代码,或者是一些绕过我们专用 JSON 路径的 JSONP 内容。这节省了总执行时间,因为如果每个语句只执行一次,编译所需时间会比解释更长;JIT 编译器本身仍然需要像解释器一样循环和切换所有字节码指令。一旦任何指令执行多次,LLInt 相对于 JIT 的优势就开始减弱。对于频繁执行的代码,LLInt 中大约四分之三的执行时间花在用于分派到下一条字节码指令的间接分支上。这就是为什么我们仅在函数调用 6 次后或语句重新执行 100 次后就升级到 Baseline JIT:这个 JIT 主要负责消除解释器分派开销,但除此之外,它生成的代码与解释器的代码非常相似。这使得 Baseline JIT 能够非常快速地生成代码,但它也留下了一些性能优化空间,例如寄存器分配和类型专门化。我们将高级优化留给 DFG JIT,它只有在代码在 Baseline JIT 中重新执行足够多次后才会被调用。

图 2. DFG JIT 优化管道。DFG 首先将字节码转换为 DFG CPS 形式,这揭示了变量和临时变量之间的数据流关系。然后,使用分析信息来推断类型猜测,并利用这些猜测插入一组最小的类型检查。接着是传统的编译器优化。编译器最终直接从 DFG CPS 形式生成机器码。

DFG JIT 将字节码转换为一种更易优化的形式,试图揭示操作之间的数据流关系;DFG 的中间代码表示形式的细节最接近于函数式语言编译器中常用的经典续体传递风格(CPS)形式。DFG 结合了传统编译器优化,例如寄存器分配、控制流图简化、公共子表达式消除、死代码消除和稀疏条件常量传播。但严肃的编译器需要了解变量的类型和堆中对象的结构才能执行优化。因此,DFG JIT 利用来自 LLInt 和 Baseline JIT 的分析反馈来构建对变量类型的预测。这使得 DFG 能够对任何传入值的类型进行猜测。请考虑以下简单程序作为示例

function foo(a, b) { return a + b + 42; }

仅从源代码来看,无法判断 ab 是数字、字符串还是对象;如果它们是数字,我们无法判断它们是整数还是双精度浮点数。因此,我们让 LLInt 和 Baseline JIT 为每个值来源不明显的变量(例如函数参数或从堆中加载)收集值配置文件。DFG 不会编译 foo,直到它执行了多次,并且每次执行都会为这些参数的配置文件贡献值。例如,如果参数是整数,那么 DFG 将能够以廉价的“你是整数吗?”检查两个参数,并对两次加法进行溢出检查来编译此函数。不需要生成双精度浮点数、字符串连接或对 valueOf() 等对象方法的调用路径,这既节省了空间又节省了总执行时间。如果这些检查失败,DFG 将使用栈上替换(OSR)将执行转移回 Baseline JIT。通过这种方式,Baseline JIT 作为 DFG 任何推测性优化被发现无效时的永久回退。

另一种形式的性能分析反馈是 Baseline JIT 的多态内联缓存。多态内联缓存是一种经典的动态分派优化技术,起源于 Smalltalk 社区。考虑以下 JavaScript 函数

function foo(o) { return o.f + o.g; }

在此示例中,属性访问可能导致从堆中已知位置的简单加载,到 getter 的调用,甚至是复杂的 DOM 陷阱,例如如果 o 是文档对象而 “f” 是页面中元素的名称。Baseline JIT 最初会将这些属性访问作为完全多态分派来执行。但它在执行过程中会记录所采取的步骤,然后就地修改堆访问,使其成为将来重复类似访问所需步骤的缓存。例如,如果对象在对象基地址偏移量 16 处有一个属性“f”,那么代码将被修改为首先快速检查传入对象是否在偏移量 16 处包含属性“f”,然后执行加载。这些缓存被称为内联,因为它们完全以生成的机器码表示。它们被称为多态,因为如果遇到不同的对象结构,机器码会被修改,在进行完全动态属性查找之前,根据先前遇到的对象类型进行切换。当 DFG 编译此代码时,它会检查内联缓存是否是单态的——仅针对一种对象结构进行了优化——如果是,它将仅发出对该对象结构的检查,然后进行直接加载。在此示例中,如果 o 始终是一个具有不变偏移量属性“f”和“g”的对象,那么 DFG 只需要为 o 发出一个类型检查,然后进行两次直接加载。

图 3. Richards 基准测试中,每个三层架构的相对加速比(越高越好)

图 2 展示了 DFG 优化管道的概览。DFG 使用值配置文件和内联缓存作为类型推断的起点。这使得它能够生成专门针对我们之前观察到的类型的代码。然后,DFG 插入一组最小的类型检查,以防止程序将来使用不同类型。当这些检查失败时,DFG 将OSR 退出回 Baseline JIT 的代码。由于任何失败的检查都会导致控制离开 DFG 代码,DFG 编译器可以在假设检查不需要重复的情况下优化检查后的代码。这使得 DFG 能够积极地最小化类型检查集。大多数变量的重用将不需要任何检查。

图 3 显示了每个层级在一个代表性基准测试——Martin Richards 的操作系统模拟——上的相对加速。每个层级生成代码的时间都比其下层级更长,但每个层级带来的吞吐量增加足以弥补这一点。

三层策略允许 WebKit 适应 JavaScript 代码的需求和特性。我们根据代码预计运行的时间量来选择层级。我们使用较低层级生成分析信息,以引导 DFG JIT 中的类型推断。这效果很好——但即使有三层,我们也不得不做出权衡。我们经常发现,调整 DFG JIT 进行更积极的优化会提高长时间运行代码的性能,但会损害短时间运行的程序,因为在这种情况下,DFG 的编译时间在总运行时间中所占的比重比 DFG 生成的代码质量更大。这让我们相信应该增加一个第四层。

构建第四层 JIT

WebKit 的优势在于它能够动态适应不同的 JavaScript 工作负载。但这种适应性仅限于我们可选择的层级所能提供的强大程度。DFG 无法同时成为低延迟优化编译器和生成高吞吐量代码的编译器——任何试图让 DFG 生成更好代码的尝试都会降低其速度,并增加短时间运行代码的延迟。增加第四层并将其用于更重的优化,同时保持 DFG 相对轻量,这使我们能够平衡长时间运行和短时间运行代码的需求。

图 4. DFG 和 FTL JIT 优化管道。我们重用了 DFG 的大部分阶段,包括其基于 CPS 的优化。新的 FTL 管道是第三层 DFG 后端的即插即用替代品。它包括在 DFG SSA 形式之上进行的额外 JavaScript 感知优化,然后是一个将 DFG IR(中间表示)降低到 LLVM IR 的阶段。然后我们调用 LLVM 的优化管道和 LLVM 的 MCJIT 后端来生成机器码。

FTL JIT 旨在为 JavaScript 带来激进的类 C 语言优化。我们希望确保我们投入 FTL 的工作能影响到种类最广泛的 JavaScript 程序——而不仅仅是那些用受限子集编写的程序——因此我们重用了 DFG JIT 中现有的类型推断引擎,以及现有的 OSR 退出和内联缓存基础设施来处理动态类型。但我们也想要最激进和最全面的低级编译器优化。因此,我们选择了低级虚拟机(LLVM)作为我们的编译器后端。LLVM 拥有一套复杂的、生产质量的优化管道,其中包含了我们已知所需的大部分功能,例如全局值编号、成熟的指令选择器和复杂的寄存器分配器。

图 5. Richards 基准测试中,使用全部四层架构时的相对加速比(越高越好)
图 6. DFG 和 FTL 在选定的 asm.js 基准测试中的执行时间(毫秒)(越低越好)。FTL 平均快 35%。

图 4 展示了 FTL 架构的概览。FTL 主要作为 DFG 的替代后端存在。我们没有调用 DFG 的机器码生成器——它速度非常快但几乎不进行低级优化——而是将 DFG 对 JavaScript 函数的表示转换为静态单赋值(SSA)形式并执行额外的优化。转换为 SSA 比 DFG 最初的 CPS 转换更昂贵,但它支持更强大的优化,例如循环不变代码外提。完成这些优化后,我们将代码从 DFG SSA 形式转换为 LLVM IR。由于 LLVM IR 也基于 SSA,这是一种直接的线性一对多转换。它只需要对代码进行一次遍历,并且 DFG SSA 形式中的每条指令都会生成一条或多条 LLVM IR 指令。在这一点上,我们消除了代码中任何 JavaScript 特定的知识。所有 JavaScript 惯用语都被转换为使用 LLVM IR 的内联实现,或者如果知道代码路径不常见,则转换为过程调用。

这种架构使我们能够自由地为 FTL 添加重量级优化,同时保留旧的 DFG 编译器作为第三层。我们甚至可以在编译管道的“分叉”之前添加新的优化,使其在 DFG 和 FTL 中都能工作。它还与 LLVM IR 的设计很好地集成:LLVM IR 是静态类型的,所以我们需要在转换为 LLVM IR 之前执行我们的动态语言优化。幸运的是,DFG 在类型推断方面已经做得很好——因此,当 LLVM 看到代码时,我们已经为所有变量指定了类型。采用我们的间接方法将 JavaScript 转换为 LLVM IR 结果非常自然——首先源代码转换为 WebKit 的字节码,然后转换为 DFG CPS IR,再转换为 DFG SSA IR,最后才转换为 LLVM IR。每次转换都负责消除 JavaScript 的部分动态性,当我们到达 LLVM IR 时,我们将完全消除其动态性。这使得我们即使对于普通人工编写的 JavaScript 也能利用 LLVM 的优化能力;例如,在图 3 的 Richards 基准测试中,FTL 相对于 DFG 额外提供了 40% 的性能提升,如图 5 所示。图 6 显示了 FTL 在 asm.js 基准测试中为我们带来的改进摘要。请注意,FTL 并非通过识别 "use asm" 指令来对 asm.js 进行特殊处理。所有性能都源于 DFG 的类型推断和 LLVM 的低级优化能力。

WebKit FTL JIT 是第一个使用 LLVM JIT 基础设施进行动态语言配置文件导向编译的重大项目。为了实现这一点,我们需要在 WebKit 和 LLVM 中进行一些重大更改。与我们现有的 JIT 相比,LLVM 需要明显更多的时间来编译代码。WebKit 使用了一种复杂的代际垃圾回收器,但 LLVM 不支持侵入式 GC 算法。配置文件驱动的编译意味着我们可能会在函数运行时调用优化编译器,并且我们可能希望在循环中间将函数的执行转移到优化代码中;据我们所知,FTL 是第一个对热循环转移到 LLVM 编译的代码进行栈上替换的编译器。最后,LLVM 以前不支持我们用来处理动态代码的自修改代码和反优化技巧。这些问题将在下面详细描述。

绕过 LLVM 编译时间

图 7. FTL 仅在代码已经在 LLInt、Baseline JIT 和 DFG JIT 中运行一段时间后才被调用。当我们从并发线程调用 FTL 编译器时,目标函数已经在执行由 DFG JIT 生成的相对快速的代码。FTL 随后可以利用从 LLInt、Baseline JIT 和 DFG JIT 收集到的分析信息,以最大限度地提高其对 JavaScript 惯用语的去虚拟化精度。

编译器设计面临经典的延迟-吞吐量权衡。很难设计出能够快速生成优秀代码的编译器——生成代码最快的编译器也生成最差的代码,而像 LLVM 这样生成优秀代码的编译器通常很慢。此外,编译器在延迟-吞吐量连续体中的位置往往是系统性的;很难制造出能够从低延迟转变为高吞吐量或反之亦然的编译器。

例如,DFG 的后端能非常快速地生成平庸的代码。它生成代码速度快正是因为它只对相对高级的表示进行一次遍历;但同样是这个特性意味着它遗留了大量的优化机会未被利用。LLVM 则相反。为了最大化优化机会的数量,LLVM 对非常低级的代码进行操作,这些代码最有可能揭示冗余和低效的惯用语。在将该代码转换为机器码的过程中,LLVM 会通过涉及不同表示的多个阶段执行多重降低转换。每种表示都揭示了不同的加速机会。但这也就意味着,与 DFG 相比,无法缩短 LLVM 的慢速。仅仅将代码转换为 LLVM 的 IR 就比运行整个 DFG 后端更昂贵,因为 LLVM IR 是如此低级。这就是权衡:编译器架构师必须先验地选择他们的编译器是具有低延迟还是高吞吐量。

FTL 项目旨在结合高吞吐量和低延迟编译的优势。对于短时间运行的代码,我们确保除非执行计数分析允许,否则我们不会支付调用 LLVM 的代价。对于长时间运行的代码,我们通过结合并发和并行来隐藏调用 LLVM 的成本。

我们的第二层和第三层——Baseline 和 DFG JIT——只有当函数变得足够“热”时才会被调用。FTL 也不例外。任何函数都不会由 FTL 编译,除非它已经由 DFG 编译过。DFG 随后重用 Baseline JIT 的执行计数分析方案。每个循环回边都会给内部的每个函数计数器加 1。返回时为同一计数器增加 15。如果计数器越过零,我们就会触发编译。计数器最初是一个负值,对应于我们希望在触发更高层编译器之前函数“执行”次数的估计值。对于 Baseline 到 DFG 的升级,我们将计数器设置为 -1000 × C,其中 C 是编译单元大小和可用可执行内存量的函数。C 通常接近 1。DFG 到 FTL 的升级更激进;我们将计数器设置为 -100000 × C。这确保了短时间运行的代码永远不会导致昂贵的基于 LLVM 的编译。任何在现代硬件上运行时间超过约 10 毫秒的函数都将由 FTL 编译。

除了保守的编译策略,我们还通过使用并发和并行编译来最小化 FTL 对启动时间的影响。DFG 已经作为并发编译器运行了将近一年。DFG 管道的每个部分都并发运行,包括字节码解析和分析。借助 FTL,我们将其提升到新的水平:我们启动多个 FTL 编译器线程,并且 FTL 管道的 LLVM 部分甚至可以与 WebKit 的垃圾回收器并发运行。我们还保留了一个以更高优先级运行的专用 DFG 编译器线程,确保即使 FTL 编译排队,任何新发现的 DFG 编译机会也会获得更高优先级。

图 7 描绘了函数从 LLInt 一路升级到 FTL 的时间线。解析和 Baseline JIT 编译仍不是并发的。一旦 Baseline JIT 代码开始运行,主线程将永远不会等待函数被编译,因为所有后续编译都是并发进行的。此外,如果函数运行不够频繁,它将不会被 DFG 编译,更不会被 FTL 编译。

LLVM 与垃圾回收的集成

图 8. WebKit 的 JavaScript 对象模型。固定大小的对象及其推断属性存储在分离的空闲列表标记-清除空间中的小型非移动单元格中。数组和字典等可变大小对象的有效负载存储在碰撞指针快速释放标记区域复制空间中的可移动蝴蝶中。我们允许 WebKit 中的任何代码——包括我们运行时的 C++ 代码以及包括 LLVM 在内的任何 JIT 生成的代码——直接引用单元格和蝴蝶,并拥有指向两者的内部指针。

WebKit 使用分代垃圾回收器,其中对象单元格(存储固定大小数据)采用非移动空间,而可变大小数据(见图 8)则采用混合标记区域/复制空间。对象的代际通过一个粘性标记位来跟踪。我们的收集器还具有额外的优化,例如并行收集以及精心调优的分配和屏障快速路径。

我们将垃圾回收与 LLVM 集成的方法旨在最大化吞吐量和收集器精度,同时消除 LLVM 了解我们的垃圾回收器的需要。我们采用的方法,源于 Joel Bartlett,自 20 世纪 80 年代末就已经为人所知,并且我们已经使用多年。对于像 WebKit 这样的高性能系统,我们认为这种方法优于完全保守的收集器(例如 Boehm-Demers-Weiser)或各种基于强制变量溢出到栈上的精确收集策略。完全 Boehm 风格的保守性意味着堆中和栈上的每个指针大小的字都保守地被认为可能是指针。这既慢——每个字都需要经过指针测试算法——又非常不精确,冒着保留大量死对象的风险。另一方面,强制任何东西——指针或被认为是指针的值——到栈上会导致额外的指令将这些值存储到内存中,从而导致速度下降。我们使用 LLVM 的原因之一是为了获得更好的寄存器分配;将东西放在栈上与此目标背道而驰。幸运的是,Bartlett 垃圾回收提供了一个优雅的折衷方案,它仍然允许我们使用复杂的分代复制收集器。

在 Bartlett 收集器中,堆中的对象应该具有类型映射,这些映射告诉我们它们的哪些字段包含指针以及这些指针应该如何解码。仅可从其他堆对象访问的对象可以移动,并且指向它们的指针可以更新。但栈不需要有栈图,并且被认为从栈引用的对象必须被固定。收集器的栈扫描必须:(1) 确保它除了栈本身之外还读取所有寄存器,(2) 保守地将每个指针宽度字视为可能是指针,以及 (3) 固定任何看起来可能从栈可达的对象。这意味着我们用来编译 JavaScript 的编译器可以随意处理值,并且它们不必知道这些值是否是指针。

由于程序栈通常比堆的其余部分小几个数量级,栈上“流氓”值意外导致大量死堆对象被保留的可能性非常低。事实上,在典型的堆中,超过 99% 的对象仅被其他堆对象引用,这使得 Bartlett 收集器能够移动这些对象——无论是为了提高缓存局部性还是为了整理堆——就像一个完全精确的收集器一样。当然,保留一些死对象是可能的,只要它只对应少量空间开销,我们就可以容忍它。在实践中,我们发现只要我们使用额外的栈清理技巧——例如确保我们的编译器使用紧凑的栈帧,并偶尔将栈中已知未使用的区域清零——死对象保留量可以忽略不计,无需担心。

我们最初选择 Bartlett 的垃圾回收方法,因为它允许我们的运行时以普通 C++ 编写,并直接在局部变量中引用垃圾回收对象。这既更高效(没有对象句柄的开销),又更容易维护。事实上,这种方法还允许我们将 LLVM 作为编译器后端,而无需通过栈溢出来损害我们的性能,这只会增强我们对这种知名算法的信心。

热循环转移

使用多个执行引擎运行 JavaScript 代码的挑战之一是用一个编译器编译的代码替换另一个编译器编译的代码,例如当我们决定从 DFG 升级到 FTL 时。这通常很容易。大多数函数生命周期较短——它们在被调用后不久就会返回。即使是那些占据总执行时间很大一部分的函数也是如此。这类函数倾向于被频繁调用,而不是在单次调用中长时间运行。因此,我们替换代码的主要机制是编辑用于虚调用解析的数据结构。此外,对于任何去虚拟化的调用,我们解除所有指向函数旧代码的传入调用,然后将它们重新链接到新编译的代码。对于大多数函数来说,这已经足够了:新优化代码可用后不久,所有对该函数的先前调用都会返回,所有未来调用都会进入优化代码。

虽然这对大多数函数有效,但有时一个函数会长时间运行而不返回。这最常发生在基准测试中——尤其是微基准测试。但有时它也会影响实际代码。我们发现的一个例子是类型化数组初始化循环,它在 FTL 中的运行速度比 DFG 快 10 倍,并且差异如此之大,以至于它在 DFG 中的总运行时间显著大于用 FTL 编译函数所需的时间。对于这类函数,在函数运行时将执行从 DFG 编译的代码转移到 FTL 编译的代码是有意义的。这个问题被称为热循环转移(因为它只会在函数有循环时发生),在 WebKit 的代码中,我们将其称为OSR 进入

值得注意的是,LLInt 到 Baseline 以及 Baseline 到 DFG 的层级提升已经支持 OSR 进入。进入 Baseline JIT 的 OSR 利用了这样一个事实:我们很容易知道 Baseline JIT 如何在每个指令边界表示所有变量。事实上,它的表示与 LLInt 相同,因此 LLInt 到 Baseline 的 OSR 进入只涉及跳转到适当的机器码地址。进入 DFG JIT 的 OSR 稍微复杂一些,因为 DFG JIT 通常会以与 Baseline JIT 不同的方式表示状态。因此,DFG 通过将函数的控制流图视为具有多个入口点来使 OSR 进入成为可能:一个入口点用于函数的开始,以及每个循环头的一个入口点。进入 DFG 的 OSR 运作方式很像一个特殊函数调用。这会抑制许多优化,但它使 OSR 进入变得非常廉价。

不幸的是,FTL JIT 无法使用这两种方法。我们希望 LLVM 在表示状态方面拥有最大的自由度,并且目前不存在任何机制可以询问 LLVM 它在任意循环头处如何表示状态。这意味着我们无法执行 Baseline 风格的 OSR 进入。此外,LLVM 假设函数只有一个入口点,更改这一点将需要重大的架构修改。因此,我们也无法执行 DFG 风格的 OSR 进入。但更根本的是,让 LLVM 假设执行可以通过函数开始以外的任何路径进入函数,将使某些优化——例如循环不变代码外提(简称 LICM)——变得更加困难。我们希望 FTL JIT 能够将代码移出循环。如果循环有两个入口点——一个沿着从函数开始的路径,另一个通过 OSR 进入——那么 LICM 将不得不复制它移出循环的任何代码,并将其提升到两个可能的入口点中。这将代表一个重大的架构挑战,并且实际上意味着为 FTL 添加新优化将比在可以进行单入口点假设的编译器中添加这些优化更具挑战性。简而言之,多入口点的复杂性加上 LLVM 目前不支持它这一事实,意味着我们宁愿不在 FTL 中使用这种方法。

我们最终用于热循环转移的方法是为每个我们想要使用的入口点拥有一个独立的函数副本。当一个函数足够“热”以至于需要 FTL 编译时,我们会机会性地假设该函数最终会返回并通过再次被调用而重新进入 FTL。但是,如果函数的 DFG 版本在我们已经有 FTL 编译后仍然持续“热”下去,并且我们检测到它之所以“热”是因为循环中的执行计数,那么我们假设通过该循环进入将是最有利的。然后我们以一个名为 FTLForOSREntry 的特殊模式触发该函数的二次编译,在该模式下我们使用循环前导块作为函数的入口点。这涉及到对 DFG IR 的转换,我们为函数创建一个新的起始块,并让该块从全局暂存缓冲区加载循环处的所有状态。然后我们让该块跳转到循环中。之前位于循环前的所有代码最终都将成为死代码。因此,从 LLVM 的角度来看,该函数不接受任何参数。我们的运行时 OSR 进入机制会 DFG 将其在循环头部的状态转储到该全局暂存缓冲区中,然后我们“调用”LLVM 编译的函数,该函数将从该暂存缓冲区加载状态并继续执行。

FTL OSR 进入可能涉及多次调用 LLVM,但我们只在有确凿证据表明函数绝对需要时才允许这样做。我们采取的方法不需要 LLVM 的任何新功能,并且它最大限度地提高了 FTL 可用的优化机会。

反优化和内联缓存

自修改代码是高性能虚拟机设计的基石。它出现在我们希望优化 JavaScript 编译器能够处理的三种场景中

部分编译。我们通常会保留函数中的某些部分不进行编译。最明显的好处是节省内存和编译时间。这在使用涉及类型检查的推测性优化时会发挥作用。在 DFG 中,原始 JavaScript 代码中的每个操作通常都会导致至少一次推测性检查——要么是验证某个值的类型,要么是确保某个条件(例如数组索引在边界内)已知成立。在一些精心编写的 JavaScript 程序中,或者在像 Emscripten 这样的转译器生成的程序中,大多数此类检查通常会被优化掉。但在普通人工编写的 JavaScript 代码中,许多这些检查将保留下来,并且保留这些检查仍然比不执行任何推测性优化更好。因此,FTL 能够处理可能包含数千条“侧面退出”路径(触发 OSR 回到 Baseline JIT)的代码非常重要。让 LLVM 急切地编译这些路径是没有意义的。相反,我们希望通过使用自修改代码来延迟修补这些路径。

失效。DFG 优化管道能够在堆中的对象和运行时状态的各个方面安装观察点。例如,用作原型的对象可以被设置观察点,以便对原型实例的任何访问都可以对原型的条目进行常量折叠。我们还使用这项技术来推断哪些变量是常量,这使得开发人员可以像使用 Java 中的 static final 变量一样轻松地使用 JavaScript 变量。当这些观察点触发时——例如因为一个推断为常量的变量被写入时——我们会使任何对这些变量值做出假设的优化代码失效。失效意味着解除对这些函数的任何调用,并确保函数内的任何调用点在其被调用者返回后立即触发 OSR 退出。失效和部分编译广义上都是我们反优化策略的一部分。

多态内联缓存。FTL 有许多不同的策略来为堆访问或函数调用生成代码。这两种 JavaScript 操作都可能是多态的。它们可以对多种类型的对象进行操作,并且流入操作的类型集可能是无限的——在 JavaScript 中,可以通过编程方式创建新对象或函数,如果无法推断出通用类型,我们的类型推断可能会为每个对象指定不同的类型。大多数堆访问和调用最终都会流入有限(且少量)的类型集,因此 FTL 通常可以发出相对直接的代码。但我们的分析可能会告诉我们流入操作的类型集很大。在这种情况下,我们发现处理该操作的最佳方法是使用内联缓存,就像 Baseline JIT 所做的那样。内联缓存是一种动态去虚拟化,其中我们为操作发出的代码可以根据操作使用的类型变化而重新修补。

每种情况都可以被视为自修改内联汇编,但它是在较低级别上,具有用于修补机器码内容并选择应使用哪些寄存器或栈位置的编程接口。就像内联汇编一样,实际内容是由 LLVM 的客户端而不是 LLVM 本身决定的。我们将这种新能力称为修补点,它在 LLVM 中作为 llvm.experimental.patchpoint 内置函数暴露。使用该内置函数的工作流程如下:

  1. WebKit(或任何 LLVM 客户端)决定哪些值需要对修补点可用,并选择这些值表示形式的约束。可以指定值可以以任何形式给出,在这种情况下,它们可能最终成为编译时常量、寄存器、带加数的寄存器(通过获取某个寄存器并向其添加常量值可恢复该值),或内存位置(通过从某个寄存器并向其添加常量计算出的地址加载可恢复该值)。还可以通过让值按调用约定传递,然后选择调用约定来指定更细粒度的约束。可用的调用约定之一是 anyreg,这是一个非常轻量级的约束——值总是通过寄存器可用,但 LLVM 可以选择使用哪些寄存器。

    WebKit 还必须选择机器码片段的大小(以字节为单位)以及返回类型。

  2. LLVM 发出一个空操作雪橇——一系列空操作指令——其长度是 WebKit 选择的大小。LLVM 使用一个单独的数据段(由 LLVM 交给 WebKit),描述空操作雪橇在代码中出现的位置,以及 WebKit 可以用来查找传递给修补点的所有参数的位置。位置可以是常量、带加数的寄存器或内存位置。

    LLVM 还会报告每个修补点确切正在使用的寄存器。WebKit 可以使用任何未使用的寄存器,而无需担心保存它们的值。

  3. WebKit 可以在每个修补点的空操作雪橇内发出任何它想要的机器码。WebKit 稍后可以以任何它想要的方式重新修补该机器码。

除了修补点,我们还有一个更受限的内置函数,名为 llvm.experimental.stackmap。这个内置函数作为反优化的优化很有用。它向 LLVM 保证,我们只会用一个无条件跳转到外部代码来覆盖空操作雪橇。栈图要么未被修补,此时它不执行任何操作;要么被修补,此时执行将不会落入栈图之后的指令。这可以允许 LLVM 完全优化掉空操作雪橇。如果 WebKit 覆盖了栈图,它将覆盖栈图本应所在位置之后紧随的机器码。我们设想“无直通”规则在未来将有其他潜在用途,随着我们继续与 LLVM 项目合作完善 stackmap 内置函数。

拥有修补点和栈图,FTL 可以使用 DFG 和 Baseline JIT 已经用于处理完全多态代码的所有自修改代码技巧。这保证了从 DFG 升级到 FTL 永远不会导致性能下降。

FTL 特定高级优化

截至目前,本文详细介绍了我们如何与 LLVM 集成,并成功利用其低级优化能力,同时又不失去 DFG JIT 的功能。但添加更高层级的 JIT 也使我们能够进行一些优化,如果我们的分层策略以 DFG 结束,这些优化将是不可能的。以下部分将展示第四层级所实现的两个功能,它们并非 LLVM 特有。

多态变体去虚拟化

图 9. JavaScript 惯用语,例如 inheritance.jsPrototype,会将执行通过辅助函数(如本图中的对象构造函数)导流。这使得辅助函数表现出多态性。请注意,如果它被内联,则不会是多态的:在 new Point(...) 调用点内联 constructor 会导致对 initialize 的调用总是调用 Pointinitialize 方法。

为了理解多态变体的强大功能,我们只需考虑以下在 JavaScript 中创建类的常见惯用语:

var Class = {
    create: function() {
        function constructor() {
            this.initialize.apply(this, arguments);
        }
        return constructor;
    }
}

Prototype 等框架使用了这种惯用语的一种更复杂的变体。它允许通过调用 Class.create() 来创建类对象。返回的“类”随后可以用作构造函数。例如

var Point = Class.create();
Point.prototype = {
    initialize: function(x, y) {
        this.x = x;
        this.y = y;
    }
};
var p = new Point(1, 2); // Creates a new object p such that p.x == 1, p.y == 2

使用此惯用语实例化的每个对象都将涉及对 constructor 的调用,并且构造函数对 this.initialize 的使用将显得是多态的。它可能调用 Point 类的 initialize 方法,也可能调用以这种惯用语创建的任何其他类的 initialize 方法。图 9 说明了这个问题。对 initialize 的调用显得是多态的,从而阻止我们在调用 new Point(1, 2) 的位置完全内联 Point 构造函数的主体。DFG 将成功内联 constructor() 的主体,但会止步于此。它将使用虚调用来调用 this.initialize

但考虑一下,如果我们接着要求 DFG 以与 Baseline JIT 相同的方式分析其虚调用,会发生什么。在这种情况下,DFG 将能够报告,如果 constructor 被内联到一个表示 new Point() 的表达式中,那么对 this.initialize 的调用总是会调用 Point.prototype.initialize。我们称之为多态变体分析,这也是我们保留 DFG JIT 的原因之一,尽管 FTL JIT 更强大——DFG JIT 通过在内联后运行分析来充当多态变体分析器。这意味着 FTL JIT 不仅仅比 DFG 进行更多的传统编译器优化。它还看到了更丰富的 JavaScript 代码配置文件,这使得它能够进行更多的去虚拟化和内联。

值得注意的是,拥有 DFG 和 FTL 这样两个不同的编译器层级并非严格必要以实现多态变体分析。例如,我们可以完全取消 DFG 编译器,只让 FTL 在收集额外分析信息后“自行升级”。我们甚至可以使用相同的优化编译器进行无限次重编译,其中任何多态惯用语都通过分析工具进行编译。一旦该工具观察到任何额外优化的机会,我们就可以再次编译代码。这可能有效,但它依赖于多态代码始终进行分析,以防万一它最终被发现是单态的。分析可能非常廉价——例如在内联缓存的情况下——但没有免费的午餐。下一节展示了积极“锁定”多态访问的所有路径并允许 LLVM 将其视为普通代码的好处。这就是为什么我们更倾向于采用一种方法:在一定数量的执行之后,函数不再对其任何代码路径进行分析,并且我们使用低延迟优化编译器(DFG)作为分析的“最后机会”。

图 10. Raytrace 基准测试中,有无多态变体去虚拟化时的执行时间(毫秒)(越低越好)

图 10 展示了我们从多态变体去虚拟化获得的一个基准测试加速。该基准测试使用 Prototype 风格的类系统,并对构造函数进行多次调用,如果我们可以通过构造函数辅助函数进行去虚拟化,这些调用可以轻松地内联。多态变体去虚拟化在此基准测试中带来了 38% 的加速。

多态内联

DFG 有两种优化堆访问的策略。要么访问被认为是完全单态的,在这种情况下,访问的代码被内联;要么访问被转换为 Baseline JIT 会使用的相同类型的内联缓存。内联访问是有利的,因为 DFG IR 可以明确表示访问所需的所有步骤,例如类型检查、任何中间指针(如 butterfly,参见图 8)的加载、任何 GC 屏障,以及最终的加载或存储。但是,如果根据可用的分析信息,访问被认为是多态的,那么 DFG 最好使用内联缓存,原因有二:

  • 访问可能被认为是多态的,但实际上最终可能是单态的。如果访问最终是单态的,内联缓存可以非常快,这可能是由于不精确的分析。如前一节所述,DFG 有时会看到不精确的分析,因为 Baseline JIT 不了解调用上下文。
  • FTL 希望对多态访问进行额外的分析,以便执行多态变体去虚拟化。多态内联缓存是一种自然的分析形式——我们只在需要时才惰性地发出我们知道所需的代码,以便处理我们观察到的类型,因此枚举已见的类型集就像查看内联缓存最终发出了哪些情况一样简单。如果我们内联了多态访问,我们将失去这些信息,因此多态变体分析将更加困难。

出于这些原因,DFG 将多态操作——即使是那些被认为传入类型数量少且有限的操作——表示为内联缓存。但 FTL 可以很好地内联多态操作。事实上,这样做会在 LLVM 中带来一些有趣的优化机会。

我们将多态堆访问表示为 DFG IR 中的单个指令。这些指令携带元数据,总结了如果该指令执行可能发生的所有情况,但多态操作的实际控制流和数据流并未揭示。这使得 DFG 能够廉价地对多态堆访问执行“宏”优化。例如,多态堆加载在 DFG 中被认为是纯粹的,因此可以将其从循环中提升出来,而无需考虑其组成控制流。但是,当我们将 DFG IR 降低到 LLVM IR 时,我们将多态堆访问表示为基于预测类型的 switch,并为每种类型创建一个基本块。LLVM 随后可以对这个 switch 进行优化。它可以运用其众多技术之一来高效地为 switch 发出代码。如果我们对同一个对象进行连续多次访问,LLVM 的控制流简化可以串联控制流并消除冗余的 switch。更常见的情况是,LLVM 会在不同的 switch case 中找到公共代码。考虑一个像 o.f 这样的访问,我们知道 o 要么是类型 T 且“f”在偏移量 24 处,要么是类型 U 且“f”在偏移量 32 处。我们已经看到 LLVM 足够聪明,它将访问转换为类似以下内容:

o + 24 + ((o->type == U) < < 3)

换句话说,我们编译器认为是控制流的部分,LLVM 可以将其转换为数据流,从而生成更快、更简单的代码。

图 11. Deltablue 基准测试中,有无多态内联时的执行时间(毫秒)(越低越好)

Deltablue 是受益于我们的多态内联的基准测试之一,如图 11 所示。该程序对具有不同结构的对象执行多次堆访问。揭示这种多态的本质以及每个字段上所有不同访问模式的特定代码路径,使得 LLVM 能够执行额外的优化,从而实现 18% 的加速。

结论

统一 WebKit 和 LLVM 的编译器基础设施是一段非凡的旅程,我们很高兴最终能够在 WebKit 主干中启用 FTL,从 r167958 版本开始。在许多方面,FTL 的工作才刚刚开始——我们仍然需要增加 FTL 可以编译的 JavaScript 操作集,并且我们仍有未探索的性能机会。在未来的文章中,我将展示 FTL 实现的更多细节以及可改进的开放机会。目前,请随意在 WebKit nightly 版本中亲自试用。和往常一样,务必提交错误报告!