FTL:基于 LLVM 的 WebKit JIT
在过去的一年里,WebKit 项目在优化 JavaScript 应用程序的能力方面取得了巨大的进步。这项工作的主要部分是引入了第四层 LLVM(FTL)JIT。第四层 JIT 针对长时间运行的 JavaScript 内容,并执行超越 WebKit 的解释器、基线 JIT 和高级优化 JIT 的优化级别。有关 WebKit 分层优化的更多信息,请参见下面的 FTL 优化策略部分。Filip Pizlo 在 Surfin' Safari 博客文章中描述了使 FTL 成为可能的 WebKit 工程进步,Introducing the WebKit FTL JIT。2014 年 4 月 29 日,WebKit 团队在 trunk 上默认启用了 FTL:r167958.这项成就也代表了 LLVM 社区的一个重要里程碑。FTL 明确表明,LLVM 可以用来加速动态类型检查语言,使其在有竞争力的生产环境中使用。这本身就是一个巨大的成功故事,表明了 LLVM 高度模块化和灵活设计的优势。这是 LLVM 基础设施首次支持自我修改代码,也是首次在 LLVM JIT 中使用配置文件引导信息。尽管这个项目为 LLVM 开辟了新的领域,但它绝不是学术练习。为了成功,FTL 必须在各种工作负载中至少与当今使用的非 FTL JavaScript 引擎一样好,并且不能影响可靠性。这篇文章描述了与 LLVM 相关的成就的技术方面以及 LLVM 在未来改进 JIT 编译和 LLVM 基础设施整体方面的机会。
FTL 性能
JavaScript 页面无处不在,用户期望快速加载时间,WebKit 的架构非常适合此。但是,一些 JavaScript 应用程序需要非平凡的计算,并且可能运行的时间超过一百毫秒。这些应用程序需要积极的编译器优化和针对目标 CPU 调整的代码生成。FTL 将全套编译器技术应用于该问题。与任何高级语言一样,高级优化必须首先进行。将优化编译器后端移植到不成熟的前端是徒劳的。WebKit 的 JIT 与 LLVM 的优化器和代码生成的结合,有两个关键原因。
- 在转换为 LLVM IR 之前,WebKit 的优化 JIT 在一个明确表达 JavaScript 语义的 IR 上运行。通过类型推断和配置文件驱动的推测,WebKit 尽可能地消除了 JavaScript 抽象的惩罚。
- LLVM IR 现在已经采用了特性来支持推测性、配置文件驱动的优化,并避免与抽象相关的性能损失,因为当它们无法消除时。
Asm.js 是 JavaScript 的一个子集,它避免了抽象开销,允许 JIT 直接从低级性能优化中获益。因此,FTL 的性能优势在 asm.js 基准测试中可能非常明显。但尽管 FTL 在 asm.js 上表现良好,但它并没有针对标准进行任何定制。实际上,使用 FTL,以类似于 asm.js 的风格编写的常规 JavaScript 代码将获得相同的优势。此外,随着 WebKit 的高级优化变得更加先进,FTL 的优势将扩展到更广泛的惯用 JavaScript 代码集。
通过运行已通过 emscripten 编译为 asm.js 代码的 C/C++ 基准测试,可以方便地衡量 LLVM 优化对 JavaScript 代码的影响。这使我们能够比较原生 C/C++ 性能与 WebKit 的第三层(DFG)编译器以及 WebKit FTL。
[1] gcc-loops 目前是一个异常值,因为 clang 的性能最近从尚未在 FTL 中启用的自动矢量化中大幅提升。
FTL 优化策略
WebKit 的分层架构在平衡响应能力、配置文件收集和编译器优化方面提供了灵活性。第一层是低级解释器 (LLInt)。第二层是基线 JIT - 从 JavaScript 到机器代码的直接转换。WebKit 的第三层称为数据流图 (DFG) JIT。DFG 有自己的高级 IR,允许它根据在早期层收集的配置文件数据执行积极的特定于 JavaScript 的优化。在作为第三层运行时,DFG 会快速发出带有额外配置文件挂钩的代码。它可以再次作为第四层调用,但这次它会为传统的编译器优化生成 LLVM IR。DFG JIT 前端以适合传统使用 C 代码执行的相同优化的方式生成 LLVM IR。最显着的差异总结在 FTL-Style LLVM IR 中。
LLVM 修补点
修补点是 LLVM 的关键特性,它允许动态类型检查、内联缓存和运行时安全检查,而不会影响性能。2013 年 10 月,我们提交了一个 提议,通过修补点修改 LLVM IR 到 LLVM 开发者列表。从那时起,我们已经成功地为多个架构实现了修补点,并且它们的性能影响已在各种用例中得到验证,包括分支到失败的安全检查、内联缓存和代码失效点。当前设计的详细信息在 LLVM 的堆栈映射和修补点内在函数规范 中解释。修补点实际上是一个内在函数中的两个特性。第一个特性是能够识别特定值在内在函数的最终指令地址的位置。在代码发出期间,LLVM 会将该信息作为元数据与目标代码一起记录,我们称之为“堆栈映射”。堆栈映射向运行时传达重要值的位置。这是一个轻微的误称,因为位置可能指的是寄存器名称。通常,运行时将在需要重建堆栈帧时从堆栈映射位置中读取值。这通常发生在“反优化”期间 - 将 FTL 堆栈帧替换为较低层级的帧的过程。
修补点的第二个特性是运行时能够在特定指令地址修补编译的代码。为了允许这样做,内在函数保留了一定的指令编码空间,并记录了该空间的指令地址以及堆栈映射。因为运行时需要在修补代码时准确地了解值的位置,所以这两个特性必须组合到一个内在函数中。
LLVM 传递会像未知调用站点一样看待修补点。它们设计的一个重要方面是能够指定有效调用约定。例如,代码失效点几乎从不执行,调用站点不应该覆盖任何寄存器,否则寄存器分配可能会受到频繁的运行时检查的严重限制。堆栈映射的一个可选特性是能够记录在每个调用站点编译代码中实际处于活动状态的寄存器。这样,JIT 可以声明调用保留所有寄存器以最大限度地提高编译器自由度,但同时运行时可以避免在实际执行“冷”调用时执行不必要的保存和恢复操作。
为了更好地支持内联缓存优化,LLVM 现在有一个特殊的“anyregcc”调用约定。此约定允许将任意数量的参数强制进入寄存器,而无需固定寄存器的名称。因此,编译器不必将参数放在特定的寄存器或堆栈位置中,也不必在调用站点周围发出额外的复制和溢出,并且运行时可以发出直接在寄存器上运行的有效修补代码序列。
当前的修补点设计被标记为实验性,以便它可以在不保留位码兼容性的情况下继续发展。LLVM 应该很快准备好采用修补点内在函数的最终形式。但是,当前的设计应该首先扩展以捕获高级语言运行时检查的语义。参见扩展修补点。
FTL 风格的 LLVM IR
FTL 试图生成与优化器期望从其他典型编译器前端看到的代码非常相似的 LLVM IR。但是,将 JavaScript 语义降低到 LLVM 操作往往会导致 IR 与静态编译的 C 代码具有不同的特性。本节总结了这些差异。更多细节和示例将在随后的博客文章中提供。IR 中补丁点的普遍存在意味着值往往具有更多用途,并且可以实时应用于大量的补丁点调用点。FTL 在几种不同的情况下发出补丁点。首先,当 FTL 前端 (DFG) 无法消除类型检查或边界检查时,它会在 IR 中发出显式的比较和分支操作。分支目标位于补丁点内联函数之后,然后是不可达代码。这会导致比 LLVM 通常使用 C 基准测试处理的代码更加分支。幸运的是,LLVM 对分支概率的认识意味着分支到失败的习惯用法不会过度阻碍优化和代码生成。堆访问和多态调用也使用补丁点,但这些是直接内联到热路径中发出的。这允许运行时使用特定的指令序列实现内联缓存,这些序列可以随着程序行为的演变而被修补。最后,运行时调用可能充当代码失效点。运行时事件,例如对象布局的潜在更改,可能会使推测性优化的代码失效。在这种情况下,WebKit 会发出 nop 补丁点,这些补丁点可以在失效事件时被覆盖为单独的运行时调用。这有效地使所有跟随原始运行时调用的代码失效。
一些类型检查会导致多个快速路径。例如,WebKit 可以检查数值是浮点表示还是定点表示,并为两个路径都发出 LLVM IR。这可能会导致一系列冗余检查与控制流合并交织在一起。
为了支持整数溢出检查,当无法通过优化移除它们时,FTL 会在普通加法指令的位置发出 llvm.sadd.with.overflow 内联函数。这些内联函数确保代码生成器为溢出检查生成最佳的代码序列。它们也被其他活跃的 LLVM 项目使用,并逐渐在 LLVM 优化传递中获得支持。
LLVM 启发式方法通常足以猜测分支概率。但是,FTL 通过直接发出基于分析的 LLVM 分支权重元数据来简化工作。这在从内部循环开始部分编译方法时尤其重要。此类编译可以压缩嵌套循环,以便 LLVM 的启发式方法不再能够从 CFG 结构推断出循环深度。
FTL 建立了一个内部模型,该模型根据分析确定 JavaScript 程序的类型系统。它通过基于类型的别名分析 (tbaa) 元数据将此信息传达给 LLVM。在 FTL tbaa 中,每个对象字段都有一个唯一的标签。这是一种非常有效的内存去歧义方法,比 clang 目前使用的访问路径方案简单得多。
FTL 偏离常态的另一种方式是它使用 inttoptr 指令。这些指令用于具体化运行时对象的地址,包括来自当前编译单元 (目前一次一个方法) 之外的所有数据和代码。inttoptr 也用于将无类型的 JS 值转换为指针。偶尔,指针算术在非指针类型上执行,而不是使用 getelementptr 指令。这主要是一种便利,并没有被证明会阻碍优化。FTL 对 tbaa 的使用足以避免在基地址已经是未知对象时分析 getelementptr 的必要性。
FTL 的 LLVM IR 中出现的一个重要模式是重复使用相同的常量,这些常量用作掩码以区分标记值,或者几个表示全局地址的常量,这些常量往往彼此相差很小的偏移量。LLVM 当前一次一个基本块的代码生成方法会导致在每个基本块中冗余地重新具体化相同的常量。FTL 即使在大量基本块中创建,也进一步加剧了这个问题。LLVM 代码生成器已经增强,可以避免这些昂贵的常量值的重复重新具体化。
MCJIT 和 LLVM C API
FTL JIT 成功利用了 LLVM 现有的 MCJIT 框架 进行运行时编译。MCJIT 被设计为一个低级工具包,允许通过尽可能多地重复使用静态编译器的机制来构建运行时编译器。这种方法提高了 LLVM 方面的可维护性。它与现有的编译器工具链集成,并允许开发人员测试运行时编译器的功能,而无需了解特定的 JIT 客户端。然而,当前的 API 并没有为可移植 JIT 提供开箱即用的简单抽象。克服 WebKit 目标与低级 MCJIT API 之间的阻抗失配需要 WebKit 和 LLVM 工程师之间的紧密合作。随着 LLVM 作为 JIT 平台变得越来越重要,它应该提供一个更完整的 C API,以提高与 JIT 客户端的互操作性,并降低客户端代码库中的脆弱性和维护负担。通过在现有 MCJIT 框架周围提供更多便利包装器,并添加用于对象代码解析和自省的更丰富的 C API,可以弥合 LLVM 内部和可移植 JIT 之间的差距。理想情况下,像 WebKit 这样的跨平台 JIT 客户端不应该需要在客户端侧嵌入关于 LLVM 代码生成的特定于目标的详细信息。JIT 应该能够请求 LLVM 为当前主机进程生成代码,而无需了解 LLVM 的目标三元组和 CPU 特性的语言。LLVM 通常可以为延迟调用运行时编译提供一个更明显的 C API。沿着这些思路,JIT 应该能够在多个模块中重复使用 MCJIT 执行引擎,而不会每次都重新初始化传递管理器实例的开销。还需要添加一个 API 用于配置代码生成传递管理器。现在,JIT 和 LLVM 之间的大部分协调都是通过内存管理器 API 直接进行的,这对 JIT 客户端来说可能很麻烦。例如,WebKit 在分配节内存时会查找特定于平台的节名称,以便找到帧元数据和调试信息。对 WebKit 来说,更好的接口是能够传达对象代码元数据(包括帧信息和堆栈映射)的可移植 API。总的来说,JIT 代码库不应该需要为特定于平台的对象文件格式提供自己的支持。LLVM 已经有了这种支持,只需要通过 C API 公开即可。类似地,JIT 应该能够查找行号,而无需实现自己的 DWARF 解析器。用于通用调试信息解析和对象代码自省的附加功能层不特定于 JIT 编译,可以使各种 LLVM 客户端受益。
将 WebKit 与 LLVM 链接
FTL 说明了 LLVM 的一个重要用例:将 LLVM 优化和代码生成库干净地嵌入到运行在同一进程中的较大应用程序中。理想的解决方案是将一组 LLVM 组件构建为共享库,该共享库只导出有限的 C API。- LLVM 定义的静态初始化器的动态链接时间初始化开销在程序启动时是不可接受的,尤其是当仅使用库的一部分或根本不使用库时。
- LLVM 初始化需要运行退出时析构函数的全局变量。这会导致试图正常退出的多线程父应用程序崩溃。
- 与静态初始化器一样,弱 vtable 引入了不必要且不可接受的动态链接时间开销。
- 一般来说,只有一组有限的方法(即 LLVM API)应该从共享库中导出。
- LLVM 霸占了进程级别的 API 调用,如 assert、raise 和 abort。
- 从静态库中简单构建的 LLVM 共享库的最终大小大于它需要的。应该添加构建逻辑和条件编译,以确保最终只将 JIT 客户端所需的传递和平台支持链接到共享库中。
FTL 效率
LLVM 优化器和代码生成器由通用的、可重定位的组件组成,这些组件旨在跨非常广泛的平台生成最佳代码。此基础设施的编译时间成本非常高,可能比定制构建的 JIT 高一个数量级。幸运的是,WebKit 的并发、分层编译架构很大程度上规避了这种惩罚。尽管如此,仍然有很大的机会重新设计 LLVM 以用作 JIT,这将减少 FTL 的 CPU 占用率,并扩大从 FTL 中受益的 JavaScript 应用程序的范围。在 JIT 环境中运行时,LLVM 存在一个机会,可以在编译时间和优化强度之间取得更好的平衡。为此,应该将备选的“快速编译”优化传递管道标准化,以便 LLVM 社区可以共同维护一个理想的轻量级传递序列。运行时间长、迭代的 IR 优化传递(如 GVN)应该被改造成可选地在较少的迭代中运行。像 InstCombine 这样的杂七杂八的传递多次运行,应该可选地被分解,以便一部分功能可以在不同的时间运行:例如,首先规范化,然后优化。
还有很大的机会提高代码生成效率,这将使 JIT 和静态编译器都受益。LLVM 机器 IR 应该直接从 LLVM IR 生成,而无需生成选择 DAG,正如 Jakob Olesen 在他的 全局指令选择器提案 中提出的那样。这种改进的好处将是巨大而广泛的。更具体地说,针对高级语言,代码生成传递应该针对更高效地处理分支代码进行调整。例如,寄存器分配器可以被训练在代码中预期不会执行分支的点跳过昂贵的分析。
上述改进后将保留的一个开销仅仅是将 WebKit 的 DFG IR 桥接到 LLVM IR 的成本。这包括降低到 SSA 形式和构建 LLVM 指令,目前这与 DFG 的非 LLVM 代码生成路径相比花费了大量时间。仔细检查后,这很可能可以提高效率。
优化改进
在不造成大量编译时间增加的情况下,LLVM 优化可以进一步改进,以处理 JavaScript 程序中的常见习语。一种简单的 LLVM IR 增强方法是在调用点上关联基于类型的别名信息。这将提高运行时调用和补丁点之间冗余指令消除的效果。另一个改进领域是更好地处理分支和合并习语。这些在 FTL 生成的 IR 中非常常见,可以通过 CFG 简化、跳转线程或尾部复制来改进。通过仔细的传递管道管理,可以启用循环优化,例如自动矢量化。一旦 LLVM 开始分析循环,边界和溢出检查消除优化也可以实现。为了做到这一点,需要使用新的语义扩展补丁点。扩展修补点
在 JavaScript 和其他高级语言等环境中,补丁点将用于在推测性优化失败时将控制权转移到运行时,也就是说,程序的行为与预测的不同。始终可以安全地假设预测错误,并将控制权交回运行时,因为运行时始终知道如何恢复。因此,补丁点可以选择性地与检查条件关联,并赋予以下语义:补丁点代码序列必须在条件成立时执行,但在条件的任何超集下,也可以安全地在当前位置执行。当与 LLVM 循环优化结合使用时,条件补丁点语义将允许对运行时检查进行强大的优化。特别是,边界和溢出检查可以安全地提升到循环之外。例如,以下简化的 IR
%a = cmp <TrapConditionA>
call @patchpoint(1, %a, <state-before-loop>)
Loop:
%b = cmp <TrapConditionB>
@patchpoint(2, %b, <state-inside-loop>)
<do something...>
Could be safely optimized into:
%c = cmp <TrapConditionC> // where C implies both A and B
@patchpoint(1, %c, <state-before-loop>)
Loop:
do something...
请注意,第一个补丁点操作数是标识符,它告诉运行时内联函数的程序位置,使其能够找到该位置程序状态的正确堆栈映射记录。在上述优化之后,LLVM 不仅避免在循环中执行重复检查,而且还避免在整个循环体中维护额外的运行时状态。通常,需要语言特定语义的高级优化最好在更高层次的 IR 上执行。但在这种情况下,用高级语义的一个方面扩展 LLVM 允许 LLVM 的循环和表达式分析直接利用,并自然地扩展到一类新的优化中。