【V8引擎博客翻译186】Maglev - V8最快的JIT优化

414 阅读15分钟

发布时间05十二月2023·标记为JavaScript

在Chrome M117中,我们引入了一个新的优化编译器:Maglev。Maglev位于我们现有的Sparkplug和TurboFan编译器之间,并充当快速优化编译器的角色,生成足够好的代码,足够快。

背景

直到2021年,V8有两个主要的执行层:Ignition,interpreter(解释器);TurboFan,是V8中专注于峰值性能的优化编译器。所有的JavaScript代码首先被编译成Ignition字节码,然后通过解释它来执行。在执行过程中,V8跟踪程序的行为,包括跟踪对象的形状和类型。运行时执行元数据和字节码都被输入到优化编译器中,以生成高性能的、通常是推测性的机器代码,其运行速度明显快于解释器。

这些改进在JetStream这样的基准测试中清晰可见,JetStream是一组传统的纯JavaScript基准测试,用于测量启动、延迟和峰值性能。TurboFan帮助V8以4.35倍的速度运行套件!与过去的基准测试(如退休的Octane基准测试)相比,JetStream对稳定状态性能的重视程度有所降低,但由于许多行项目的简单性,优化的代码仍然是花费最多时间的地方。

Speedometer是一个不同于JetStream的基准套件。它旨在通过对模拟用户交互进行计时来衡量Web应用程序的响应能力。该套件由完整的网页组成,而不是较小的静态独立JavaScript应用程序,其中大部分是使用流行的框架构建的。与大多数网页加载过程一样,Speedometer行项目花费更少的时间运行紧密的JavaScript循环,而花费更多的时间执行与浏览器其余部分交互的大量代码。

TurboFan仍然对Speedometer有很大的影响:它运行速度超过1.5倍!但这种影响显然比JetStream要小得多。这种差异的部分原因是,在纯JavaScript中,完整页面只花费更少的时间。但在某种程度上,这是由于基准测试花费了大量时间在这些函数,但这些函数并没有被调用到足够频繁,也就未达到触发 TurboFan 优化所需的热度。

PixPin_2024-11-05_10-16-23.png

Web性能基准测试比较未优化和优化的执行

这篇文章中的所有基准得分都是在13”M2 Macbook Air上使用Chrome 117.0.5897.3测量的。

由于Ignition和TurboFan在执行速度和编译时间上的差异如此之大,我们在2021年引入了一个名为Sparkplug的新基准JIT。它被设计成几乎瞬间将字节码编译成等价的机器码。

在JetStream上,Sparkplug比Ignition(+45%)提高了性能。即使TurboFan也在图片中,我们仍然看到性能的稳步提升(+8%)。在速度表上,我们看到比Ignition提高了41%,使其接近TurboFan的性能,比Ignition+TurboFan提高了22%!由于Sparkplug的速度非常快,我们可以很容易地将其部署得非常广泛,并获得一致的加速。如果代码不完全依赖于容易优化、长时间运行、紧密的JavaScript循环,这是一个很好的补充。

image.png

添加Sparkplug的Web性能基准测试

Sparkplug的简单性对它可以提供的加速施加了相对较低的上限。Ignition + Sparkplug 和 Ignition + TurboFan之间的大间隙清楚地证明了这一点。

这就是Maglev的用武之地,我们新的优化JIT生成的代码比Sparkplug代码快得多,但也比TurboFan快得多。

Maglev:一个简单的基于SSA的JIT编译器

当我们开始这个项目时,我们看到了两条弥补Sparkplug和TurboFan之间差距的道路:要么尝试使用Sparkplug采用的单通道方法生成更好的代码,要么使用中间表示(IR)构建JIT。由于我们觉得在编译过程中完全没有IR可能会严重限制编译器,因此我们决定使用一种基于静态单赋值(SSA)的方法,使用CFG(控制流图)而不是TurboFan更灵活但缓存不友好的sea-of-nodes表示。

编译器本身被设计成快速且易于使用,它有一组最少的通道和一个简单的IR,用于编码专门的JavaScript语义。

预通过

首先,Maglev对字节码进行预传递以查找分支目标,包括循环以及对循环中变量的赋值。此传递还收集活性信息,编码哪些值,哪些变量在哪些表达式中仍然需要。此信息可以减少编译器稍后需要跟踪的状态量。

SSA

image.png

命令行上的Maglev SSA图形的解释

Maglev对框架状态进行抽象解释,创建代表表达式计算结果的SSA节点。通过将这些SSA节点存储在相应的抽象解释器寄存器中来模拟变量赋值。在分支和交换机的情况下,评估所有路径。

当多个路径合并时,抽象解释器寄存器中的值通过插入所谓的Phi节点来合并:值节点知道根据运行时采用的路径选择哪个值。

当变量在循环体中被赋值时,循环可以合并变量值,数据从循环结束流到循环头。这就是来自预通过的数据派上用场的地方:因为我们已经知道哪些变量在循环中被赋值,所以我们可以在开始处理循环体之前预先创建循环phis。在循环结束时,我们可以用正确的SSA节点填充phi输入。这允许SSA图生成是单个正向传递,而不需要“修复”循环变量,同时还最小化需要分配的Phi节点的数量。

已知节点信息

为了尽可能快,Maglev一次做尽可能多的事情。Maglev没有构建一个通用的JavaScript图,然后在后期的优化阶段降低它,这是一种理论上干净但计算代价高昂的方法,而是在图形构建过程中立即尽可能多地进行。

在图形构建期间,Maglev将查看在未优化执行期间收集的运行时反馈元数据,并为观察到的类型生成专门的SSA节点。如果Maglev看到o.x并且从运行时反馈中知道o总是具有一个特定的形状,则它将生成一个SSA节点以在运行时检查o仍然具有预期的形状,然后是一个廉价的LoadField节点,该节点通过偏移进行简单的访问。

此外,Maglev将制作一个侧节点,它现在知道o的形状,使得稍后不必再次检查形状。如果Maglev后来遇到了一个在o上的操作,由于某种原因没有反馈,那么在编译过程中学习到的这种信息可以用作第二个反馈源。

电子邮件信息可以以各种形式出现。有些信息需要在运行时检查,如前面描述的形状检查。通过将依赖项注册到运行时,可以在没有运行时检查的情况下使用其他信息。事实上是常量的全局变量(在初始化和Maglev看到它们的值之间没有改变)属于这一类:Maglev不需要生成代码来动态加载和检查它们的身份。Maglev可以在编译时加载该值,并将其直接嵌入到机器码中;如果运行时改变了该全局值,它也会注意使该机器码无效和反优化。

某些形式的信息是"不稳定的"。这些信息只能在编译器确定它不能改变的情况下使用。例如,如果我们刚刚分配了一个对象,我们知道它是一个新对象,我们可以完全跳过昂贵的写障碍。一旦有另一个潜在的分配,垃圾收集器就可以移动对象,现在我们需要发出这样的检查。其他的都是“稳定”的:如果我们从未见过任何对象从具有特定形状的转变,那么我们可以注册对该事件的依赖性(任何对象从该特定形状转变),并且不需要重新检查对象的形状,即使在调用具有未知副作用的未知函数之后。

去优化

鉴于Maglev可以使用它在运行时检查的推测性信息,Maglev代码需要能够去优化。为了实现这一点,Maglev将抽象解释器帧状态附加到可以去优化的节点上。此状态将解释器寄存器映射到SSA值。此状态在代码生成期间转换为元数据,提供从优化状态到未优化状态的映射。反优化器解释这些数据,从解释器帧和机器寄存器中阅读值,并将它们放入解释所需的位置。这建立在与TurboFan相同的去优化机制上,允许我们共享大部分逻辑并利用现有系统的测试。

代表个体选择

根据规范,JavaScript数字表示64位浮点值。这并不意味着引擎必须始终将它们存储为64位浮点数,特别是因为实际上许多数字都是小整数(例如数组索引)。V8尝试将数字编码为31位标记的整数(内部称为“小整数”或“Smi”),这既是为了节省内存(指针压缩为32位),也是为了提高性能(整数操作比浮点操作更快)。

为了使大量数字的JavaScript代码更快,为值节点选择最佳表示非常重要。与解释器和Sparkplug不同,优化编译器可以在知道值的类型后取消装箱,对原始数字而不是表示数字的JavaScript值进行操作,并且只有在严格必要时才重新装箱值。浮点数可以直接在浮点寄存器中传递,而不是分配包含浮点数的堆对象。

Maglev主要通过查看运行时反馈来了解SSA节点的表示,例如,二进制操作,并通过已知节点信息机制向前传播该信息。当具有特定表示的SSA值流入Phis时,需要选择支持所有输入的正确表示。循环phi也很棘手,因为循环中的输入是在为phi选择一个表示之后才被看到的-这是与图构建相同的“时间回溯”问题。这就是为什么Maglev在图形构建之后有一个单独的阶段来对循环phis进行表示选择。

寄存器分配

在图形构建和表示选择之后,Maglev基本上知道它想要生成什么样的代码,并且从经典优化的角度“完成”。但是为了能够生成代码,我们需要选择SSA值在执行机器代码时实际存在的位置;当它们在机器寄存器中时,以及当它们保存在堆栈上时。这是通过寄存器分配完成的。

每个Maglev节点都有输入和输出要求,包括所需临时设备的要求。寄存器分配器在图上进行单次向前行走,维护与在图构建期间维护的抽象解释状态没有太大不同的抽象机器寄存器状态,并且将满足这些要求,用实际位置替换对节点的要求。然后代码生成可以使用这些位置。

首先,一个prepass在图上运行以找到节点的线性活动范围,这样一旦不再需要SSA节点,我们就可以释放寄存器。这个预传递还跟踪使用链。知道一个值在未来的多远会被需要,这对于在我们用完寄存器时决定哪些值优先,哪些值被丢弃是有用的。

在预传递之后,运行寄存器分配。寄存器分配遵循一些简单的局部规则:如果一个值已经在寄存器中,则尽可能使用该寄存器。节点在图遍历期间跟踪它们被存储到什么寄存器中。如果节点还没有寄存器,但有一个寄存器是空闲的,那么就选择它。节点被更新以指示它在寄存器中,并且抽象寄存器状态被更新以知道它包含该节点。如果没有空闲寄存器,但需要一个寄存器,则会将另一个值从寄存器中推出。理想情况下,我们有一个已经在不同寄存器中的节点,并且可以“免费”删除它;否则我们选择一个长时间不需要的值,并将其溢出到堆栈上。

在分支合并时,合并来自传入分支的抽象寄存器状态。我们试图在寄存器中保留尽可能多的值。这可能意味着我们需要引入寄存器到寄存器的移动,或者可能需要使用称为“间隙移动”的移动从堆栈中释放值。如果一个分支合并有一个phi节点,寄存器分配将把输出寄存器分配给phi。Maglev倾向于将phi输出到与其输入相同的寄存器,以尽量减少移动。

如果更多的SSA值比我们拥有的寄存器还多,我们需要将一些值溢出到堆栈上,然后再将它们解溢出。本着Maglev的精神,我们保持简单:如果一个值需要溢出,它会被追溯性地告知在定义时立即溢出(就在值创建之后),代码生成将处理溢出代码的发出。该定义保证“主导”该值的所有用途(为了达到用途,我们必须传递该定义,因此也传递溢出代码)。这也意味着溢出值在整个代码持续时间内只有一个溢出槽;具有重叠生命期的值因此将具有不重叠的分配溢出槽。

由于表示选择,Maglev帧中的一些值将是标记指针,V8的GC理解并需要考虑的指针;而一些值将是未标记的,GC不应该查看的值。TurboFan通过精确跟踪哪些堆栈插槽包含标记值,哪些包含未标记值来处理此问题,这些值在执行期间会随着插槽被重新用于不同的值而发生变化。对于Maglev,我们决定让事情变得更简单,以减少跟踪这一点所需的内存:我们将堆栈帧分割为标记和未标记的区域,只存储这个分割点。

代码生成

一旦我们知道我们想要为哪些表达式生成代码,以及我们想要将它们的输出和输入放在哪里,Maglev就准备好生成代码了。

Maglev nodes直接知道如何使用“宏汇编程序”生成汇编代码。例如,CheckMap节点知道如何发出汇编指令,将输入对象的形状(内部称为“map”)与已知值进行比较,并在对象具有错误形状时对代码进行反优化。

处理间隙移动的代码中有一点小技巧:寄存器分配器创建的请求移动知道某个值存在于某个地方,需要移动到其他地方。如果有一系列这样的移动,那么前面的移动可能会破坏后续移动所需的输入。并行移动解析器计算如何安全地执行移动,以便所有值最终都在正确的位置。

结果

因此,我们刚才介绍的编译器显然比Sparkplug复杂得多,也比TurboFan简单得多。怎么样?

在编译速度方面,我们已经成功地构建了一个JIT,大约比Sparkplug慢10倍,比TurboFan快10倍。

compile-time.svg

针对在JetStream中编译的所有函数,对编译层进行编译时比较

这使我们能够比部署TurboFan更早地部署Maglev。如果它所依赖的反馈最终不是很稳定,那么以后去优化和重新编译也不会有太大的代价。它还允许我们稍后使用TurboFan:我们的运行速度比使用Sparkplug快得多。

在Sparkplug和TurboFan之间插入Maglev会导致明显的基准改进:

image.png

Maglev的Web性能基准测试

我们还在真实数据上验证了Maglev,并在Core Web Vitals上看到了良好的改进。

由于Maglev的编译速度更快,而且我们现在可以在使用TurboFan编译函数之前等待更长的时间,这会带来表面上看不出来的第二个好处。基准测试集中在主线程延迟上,但Maglev也通过使用更少的线程外CPU时间来显著降低V8的整体资源消耗。在基于M1或M2的MacBook上使用taskinfo可以轻松测量过程的能耗。

Benchmark基准Energy Consumption能耗
JetStreamJetstream-3.5%
Speedometer速度计-10%-10%左右

Maglev无论如何都不完整。我们还有很多工作要做,更多的想法要尝试,更多的低挂水果要摘-随着Maglev越来越完整,我们将期待看到更高的分数,更多的能源消耗减少。

Maglev现在已经可以在桌面Chrome上使用了,很快就会推广到移动的设备上。