javascript局部变量-V8 拥有全新的超快非优化 JS 编译器,性能提升 5-15%

作者 | V8团队

译者| 王强

规划| 蔡芳芳

V8引入了新的非优化JS编译器:Sparkplug

如果你想编译一个高性能的JavaScript引擎,仅仅拥有一个高度优化的编译器(比如TurboFan)是不够的。 特别是对于短暂的会话(例如加载网站或命令行工具),在高优化编译器开始优化之前还有很多工作要做,更不用说生成优化的代码了。

正因为如此,从2016年开始,我们不再跟踪综合基准测试(例如Octane)的结果,而是判断真实场景中的性能。 从那时起,我们仍然在努力研究如何在高度优化的编译器无法实现的范围内提高 JavaScript 性能。 这意味着我们需要在解析器、流处理、对象模型、垃圾收集并发、缓存编译代码等方面一一解决问题……每个领域都有新鲜感。

当我们转向提高现实场景中原始 JavaScript 执行的性能时,我们在优化解析器时开始遇到许多限制。 V8 的解析器经过高度优化且速度极快,但解析器中始终存在一些我们无法摆脱的固有开销; 字节码解码开销或调度开销是解析器功能的固有部分。

基于我们目前的双编译器模型,我们很难更快地分层优化代码; 我们可以(并且正在)提高优化的功效,但在某些情况下,提高速度的唯一方法是删除一些优化,但这会增加峰值性能。 更糟糕的是,我们还没有领先于优化过程,因为我们还没有稳定的物体形状反馈。

今天我们向您介绍 Sparkplug:我们新的非优化 JavaScript 编译器,位于 Ignition 解析器和我们将随 V8 v9.1 发布的 TurboFan 优化编译器之间。

新的编译器管道

这是一个非常快的编译器

Sparkplug 旨在快速编译。 非常快,快到我们可以随时随地进行编译,因此我们可以比 TurboFan 代码更积极地升级到 Sparkplug 代码。

Sparkplug 编译器的速度来自于几个方法。 首先,它作弊; 它编译的函数已经被编译为字节码,并且字节码编译器已经完成了大部分艰苦的工作javascript局部变量,比如变量解析、判断括号是否实际上是箭头函数、消除结构体语句等等。 Sparkplug 从字节码而不是 JavaScript 源代码进行编译,因此您不必担心这个麻烦。

第二个技巧是 Sparkplug 不会像大多数编译器那样生成任何中间表示 (IR)。 相反,Sparkplug 通过线性传递字节码直接编译为机器代码,并发出与该字节码的执行相匹配的代码。 实际上,整个编译器是 for 循环内的 switch 语句,分派到固定的逐字节机器代码生成函数。

for (; !iterator.done(); iterator.Advance()) { VisitSingleBytecode()}

缺少 IR 意味着编译器的优化机会有限,只能进行特别原生的小优化。 这也意味着我们必须将整个实现单独移植到我们支持的每个架构,因为没有与架构无关的中间阶段。 但事实证明,这一切都不是问题:快速编译器是简单的编译器,因此代码很容易移植; Sparkplug 不需要大量优化,因为我们稍后会在管道中提供一个非常优化的编译器。

从技术上讲,我们目前对字节码进行了两次处理 - 一次用于检测循环,第二次用于生成实际代码。 不过,我们的最终计划是放弃第一个。

解析器兼容性框架

向现有成熟的 JavaScript VM 添加新的编译器是一项艰巨的任务。 除了标准执行之外,您还需要支持各种事物; V8 有一个调试器、一个堆栈遍历 CPU 分析器、异常堆栈跟踪、集成到升级中、用于优化热循环代码的堆栈替换......等等。

Sparkplug 通过维护“解析器兼容的堆栈框架”巧妙地简化了所有这些问题。

稍微解释一下。 堆栈帧是代码执行时存储函数状态的一种方式。 每当您调用新函数时,它都会为该函数的局部变量创建一个新的堆栈帧。 堆栈帧由帧指针(标记其开始)和堆栈指针(标记其结束)定义:

堆栈帧,带有堆栈和帧指针

看到这里,很多读者都会谴责:“这图不对,栈明显是面向反方向的!”。 别担心,我给你准备了一把钥匙:

当函数被调用时,返回地址被扔到这个堆栈上; 当函数返回时它会弹出,以知道返回到哪里。 然后javascript局部变量,当该函数创建新帧时,它将旧帧指针保存在堆栈上,并将新帧指针设置为指向其自己的堆栈帧的开头。 因此,堆栈有一系列帧指针,每个指针都指向前一帧的开头:

多次调用的堆栈帧

严格来说,这只是生成代码遵循的约定,不是必需的。 不过,这是一种相当常见的方法; 它真正中断的唯一时间是当堆栈帧被完全擦除时,或者当可以使用调试边表来遍历堆栈帧时。

这是所有函数类型的通用堆栈布局; 然后还有关于如何传递参数以及函数如何在其框架中存储值的约定。 在 V8 中,我们对 JavaScript 框架有一个约定,即在调用函数之前,参数(包括接收者)以相反的顺序放入堆栈中,堆栈上的前几个槽是:当前被调用的函数; 被调用的上下文; 以及传递的参数数量。 这是我们的“标准”JS 框架布局:

V8 JavaScript 堆栈框架

这种 JS 调用约定在优化框架和解析框架之间共享,因此当我们在调试器的性能窗格中调整代码时,我们可以以最小的开销遍历堆栈,等等。

对于 Ignition 解析器,契约变得更加明确。 Ignition 是一个基于寄存器的解析器,这意味着有一些虚拟寄存器(不要与机器寄存器混淆!)来存储解析器的当前状态 - 这包括 JavaScript 函数的本地变量(var/let/const 声明)和临时价值。 这些寄存器存储在解析器的堆栈帧中,以及要执行的字节码链表指针,以及当前字节码在该字段中的偏移量:

V8 解析器堆栈框架

Sparkplug 有意创建并维护与解析器框架相匹配的框架布局; 每当解析器存储寄存器值时,Sparkplug 也会存储一个值。 这样做有几个原因:

简化了Sparkplug的编译过程; Sparkplug 可以仅镜像解析器的行为,而无需保留从解析器寄存器到 Sparkplug 状态的某些映射。

它还提高了编译率,因为字节码编译器承担了分配寄存器的繁重工作。

它极大地简化了与系统其余部分的集成。 调试器、分析器、异常堆栈展开、堆栈跟踪复制,所有这些操作都将执行堆栈遍历以发现当前正在执行的函数堆栈,并且所有这些操作都不需要修改即可继续与 Sparkplug 一起使用,因为它们是换句话说,他们拥有的只是一个解析器框架。

它简化了堆栈替换(OSR)。 OSR是指在执行过程中替换当前正在执行的函数; 目前,当已解析的函数位于热循环内时(它被升级为优化代码),以及当优化代码被去优化时(它被降级并继续在解析器中执行函数时会发生这种情况)。 当使用 Sparkplug 框架镜像解析器框架时,任何适用于解析器的 OSR 逻辑都将适用于 Sparkplug; 更好的是,我们可以在解析器和 Sparkplug 代码之间切换,框架转换开销几乎为零。

我们对解析器堆栈框架做了一个小修改,在 Sparkplug 代码执行期间我们不保持字节码偏差最新。 相反,我们存储从 Sparkplug 代码地址范围到相应字节码偏移量的单向映射。 这是一种相对简单的编码映射,因为 Sparkplug 代码是在一次线性遍历中直接从字节码发出的。 每当堆栈帧访问想要知道 Sparkplug 帧的“字节码偏移量”时,我们都会在此映射中查找当前正在执行的指令并返回相应的字节码偏移量。 类似地,每当我们想要将 OSR 从解析器转换为 Sparkplug 时,我们可以在映射中查找当前字节码偏移量并跳转到相应的 Sparkplug 指令。

您可能会注意到,我们现在在堆栈帧上有一个未使用的套接字,并且字节码偏移量将全部位于该套接字上。 由于我们希望保持堆栈的其余部分不变,因此我们不能丢弃它。 我们重新调整了该堆栈插孔的功能,以缓存当前正在执行的函数的“反馈向量”。 这是用于存储对象状态数据的向量,大多数操作都需要加载它。 我们所要做的就是小心 OSR,并确保我们要么交换正确的字节码偏移量,要么交换该插孔的正确反馈向量。

Sparkplug 堆栈框架为:

V8 Sparkplug 堆栈框架

到外部代码

变量局部化_javascript局部变量_变量局部对称法是什么

在实践中,Sparkplug 很少生成自己的代码。 JavaScript 语义很复杂,即使执行最简单的操作也需要大量代码。 强制 Sparkplug 在每次编译时重新生成内联代码是不好的,原因如下:

由于需要生成大量代码,这将显着减少编译时间,

这减少了 Sparkplug 代码的内存消耗,并且

我们必须重新实现 Sparkplug 中使用的一堆 JavaScript 函数的源代码,这可能意味着更多的错误和更大的攻击面。

因此,大多数 Sparkplug 代码只是调用“内置代码”,即嵌入二进制文件中的小段机器代码,来完成脏工作。 这些外部代码要么是解析器使用的,要么至少与解析器的字节码处理程序共享大部分代码。

事实上,Sparkplug 代码基本上只是对外部代码的调用和控制流:

您现在可能会想,“那么,这一切有什么意义呢?Sparkplug 不是在做与解析器相同的工作吗?” - 你的疑问是对的。 在很多方面,Sparkplug 只是解析器执行的序列化,它调用相同的内置函数并维护相同的堆栈帧。 但这也是值得的,因为它清除(或更准确地说是预编译)这些不可通信的解析器开销,例如操作数解码和下一个字节码分派。

事实证明,解析器破坏了许多 CPU 优化工作:解析器从视频内存中动态读取静态操作数,导致 CPU 停止运行或猜测该值可能是什么。 调度到下一个字节码需要成功的分支预测才能保持高性能,即使猜测和预测正确,您仍然必须执行所有解码和调度代码,并且仍然会浪费各种缓冲区中的宝贵空间和缓存。 CPU其实本身就是一个解析器,只不过是机器码的解析器。 这样,Sparkplug 就是一个从 Ignition 字节码到 CPU 字节码的“翻译器”,将您的函数从“模拟器”中运行转移到“本机”运行。

表现

那么,Sparkplug 在现实场景中的表现如何呢? 我们使用 Chrome M91 运行了一些基准测试,使用了几个启用和禁用 Sparkplug 的性能机器人来查看影响。

剧透警报:我们非常满意。

以下基准测试列举了运行多个操作系统的机器人。 尽管系统和机器人的名称相似,但我们认为这不会对结果产生太大影响。 此外,不同的机器也有不同的CPU和显存配置,我们认为这是差异的主要来源。

车速表

Speedometer 是一个基准测试,它构建一个 TODO 列表来跟踪使用一些流行框架的 Web 应用程序,并通过添加和删除 TODO 来模拟真实网站框架的使用情况来对应用程序的性能进行压力测试。 我们发现它很好地反映了现实世界的负载和交互,并且我们一次又一次地看到速度计分数的改进反映在我们的现实世界指标中。

使用 Sparkplug,速度计得分提高了 5-10%,具体取决于我们观察到的机器人。

使用 Sparkplug 改进了多个性能机器人的中位车速表分数。 误差线代表四分位数宽度。

浏览基准

车速表是一个很好的基准,但它只能说明部分情况。 此外,我们还有一组“浏览基准”,记录了一组真实的网站,我们可以重播这些网站,编写一些交互脚本,并更真实地了解我们的各种指标在现实世界中的表现。

在此基准测试中,我们选择查看“V8 主线程时间”指标,该指标测量主线程在 V8 中花费的总时间(编译和执行)(不包括流解析或后台优化编译)。 这是在不排除其他基线噪声源的情况下查看 Sparkplug 自身返回的最佳方式。

结果各不相同,并且完全取决于机器和站点,但总的来说,它们看起来不错:我们已经看到了大约 5-15% 的改进。

在我们的浏览基准测试中,V8 主线程时间平均提高了 10%。 误差线代表四分位数宽度。

结论:V8 有一个新的超快非优化编译器,可在实际基准测试中将 V8 的性能提高 5-15%。 这已经在 V8 v9.1 中与 --sparkplug 标志一起使用,随着 M91 的发布,我们将在 Chrome 中推出该编译器。

原文链接: