【译】修改 JavaScript 帧

1,298 阅读15分钟
原文链接: github.com

曾经有段时间我一直在开发 V8 中的一个项目,将类型反馈编码到简单的数据结构中,而不是将其嵌入到编译代码中。

V8 内联缓存系统通常编译一个 "dispatcher" 用于检查传入的对象是否映射到一个常量上。如果匹配上了,则将 control 发送给处理程序,该处理程序可能是一个 stock stub,也可能是为该对象专门编译产生的。内联缓存(IC)将此 dispatcher 代码粘贴到已编译函数中。dispatcher 起到了提高性能的作用,因为许多决策都被简化为相对于常量的映射比较(我们称之为映射检查)。我们还可以在以后对它的嵌入式映射进行检查,以确定它在创建优化代码时了解到的内容是否正确。

在简要阐述了 ICs 如何工作之后(这里是 进阶介绍),你可能会想,为什么要改变它呢?答案是,出于安全考虑,避免补丁代码是好的,但事实上它同时会导致指令缓存的刷新,而这会妨碍在某些平台上的性能。而将 Map 存储在数组中是很自然的,这使得我们能够更加容易地收集额外信息。例如,我们可能需要存储多态调用计数。当我们使用数据结构时,我们可以为每个 map 存储一个三元组: 映射,跳转到的 "处理程序",以及整数计数。这可以用于以后多态调用的排序。您甚至可以通过分流不常使用的 map 将这些数据合并到一个普通的处理程序中,从而减少多态。

这就是为什么在数据结构而不是代码中嵌入信息的原因。但 V8 IC 系统庞大、复杂、性能敏感。由于这种引入数据结构的反馈速度很慢。一年前,我开始使用“类型反馈向量”来记录从一个 JavaScript 函数到另一个函数调用的数据。现在我正在开发负载(比如 x = obj.foo 和 keyed 负载如 x = obj[h])的类型反馈向量使用,并且完全地避免补丁代码。

数据结构解决方案的难点在于无论如何分配都存在比嵌入方案更多的内存负载问题。此处我们来看类型向量的另一个潜在好处:它可以被用于那些只有部分类型反馈的优化代码中。通常情况下,如果 V8 开始运行一段在完整代码中从未运行过的部分,它将会反优化一个已优化的函数。这可能发生在一个函数被认为是“热门”,但其中有一个分支从未被执行时。对于类型反馈向量,我们可以在这些信息贫乏的位置安装基于向量的 ICs,允许他们学习一段时间,然后在得到一定量新信息后再进行优化。

对 V8 来说,反优化函数代价高昂,我稍后再讲 —— 这只是大量的工作和复杂度。在应用程序的生命周期中,类型向量提供了平滑和适度优化/不优化的转换曲线的可能性。

这就是我所希望的。正如前文所提,V8 使用类型向量调用 ICs,但是负载这一重要的情况必须解决,因为负载超荷的情况很多。如果这能实现,那么才允许我们完成剩下的工作,最终实现完全消除补丁。这是一个非常有趣的项目。

我写这篇文章的时候,我已经了解了这个领域,灵感来自于 Vyacheslav Egorov 解释内联缓存的文章,他以一种可读和有趣的方式来解释内联缓存。我喜欢他的示意图,因为它让我想起了我似乎能够将大多数概念内化的唯一方法:把它们画在纸上。Vyacheslav 构建了一个 工具 来创建具有吸引力的 ASCII “盒子和指针”示意图,我开始用它来思考过程中的步骤。创作这些照片在过去的几天里产生了很大的乐趣。

负载过多

我已经花了一些时间来微调数据驱动调度程序,它搜索 vector 以完成 map 的校验和 dispatch。那是另一篇文章的主题,但能在这儿说的是我正在考虑对数据进行 2 级预测读取,以确保避免崩溃,只是为了保证额外的读取操作... 这个工作已经进行到尾声了。

现在,来讨论在调用 dispatcher 之前需要读取的次数。类型向量是一个为 JavaScript 函数附加到 SharedFunctionInfo 的数组。它通过 "slot" 索引,这些 slot 在编译时分发给请求它们的编译节点。IC 接收一个指向向量的指针,并将整数索引指向向量(索引是从 slot 中派生出来的,而不是相同的东西)。

很好,但是我们如何为这次调用将这个向量加载到寄存器中呢?由于向量是个常量,可以嵌入到代码中,但实验表明,这改变了代码的大小,甚至只是在调用时使用它的时候,如果我对所有的 IC 类型都这样做,会让代码变得难以忍受。它摆脱了 profiler 计算,同时暴露了一个弱点,即 profiler 是基于代码的字节大小,而不是抽象语法树节点的数量(当然,应该处理和转换成语法树)。这证明了使用一系列的负载是更好的适用于生产的解决方案。与此功能相关的 "JSFunction" 在堆栈帧中可用。加载后,遍历 vector 找到目标向量并挂在到 SharedFunctionInfo 上。看起来负载并不大,因为数据都在缓存中。

但是对于更广泛的类型向量概念的应用,负载变得难以支持。来看函数 foo

function foo(obj, x) {
  for (var i = 0; i < x.length; i++) {
    x[i] = x[i] * i + obj.foo;
    check(i);
  }
}

表达式 x.length,第二个 x[i]obj.foocheck(i) 均需要类型向量。仅考虑需要 3 个负载的向量情况,就有 3 * 4 * x.length 个负载。

理想情况下,通过从循环中提升向量负载,我们会只有 3 个负载。但这更多地涉及到架构,而不是我们想要专注的完整代码。在优化的代码中使用类型向量并不会很重,但是在这些编译中引入向量作为节点,这个部分将得到类型变量提升。但是我可以通过将反馈向量存储在帧中来减少负载的数量,意味着我们将有 4 * x.length 个负载。(或者至少在 profiler 确定函数足够常用,并通过堆栈替换(OSR)在一个优化的版本中减少长度,这是一个奇妙的事情)。更重要的是,这些负载都来自帧中的堆栈地址,应该保留在缓存中。

这意味着我必须改变帧布局。

未优化的 JavaScript 帧获得向量

首先,为什么只将向量添加到未优化的 JavaScript 帧中?一个优化的 JavaScript 帧实际上包含了很多向量,每一个函数都有一个内联向量。表面上优化的函数的向量只是部分有用,不能被任何内联函数引用。当然,在内联调用中可能会有一个加载/恢复步骤,但是在代码中似乎有很多工作应该是紧凑的,所以理想情况下,不应该使用类型向量。理想情况下,我们已经了解了迄今为止看到的所有 IC。另外,如果我们需要在优化的代码中引用类型反馈向量,我们可以让诸如 GVN 和寄存器分配器之类的先进技术决定在何处放置常量向量地址以及何时加载它。

因此,下图演示了一个 V8 JavaScript 帧,它在 JSFunction 之后添加了一个类型向量字段。此堆栈在调用另一个函数之前就被定位了:

优化后的帧看起来有点不同。没有向量,但 32 位平台上有一个对齐字,表示堆栈是否已对齐。这里有一个不发生对齐的情况,即在调用另一个函数之前:

对齐方式引入了一些复杂度。当我们要将之前的 $ebp 保存到堆栈时,检查 $ebp 是否对齐。如果对齐了,正常运行,将 0 保存在帧的对齐 slot 中。否则,我们将把接收方、参数和返回地址在堆栈中下移 1 word,并将 “zap 值”(0x12345678)放入接收方所在的位置。然后当它需要删除帧的时候,在对齐 slot 中存入 2 作为一个信号。当我们在返回时遇到这个值时,我们知道我们需要从堆栈中再清除一个字(“zap值”)。在删除帧之前,我们必须先阅读对齐 slot,然后在删除帧之后,我们就必须处理接收方和参数。下面的示例是带有一个参数的函数和一个接收方。优化后的帧只有一个真实的溢出 slot,另一个为对齐字保留。

在设置帧之前,需要对一个优化帧进行对齐。插入一个“zap值”,堆栈下移 1 word。在步骤(3)中,已经构建了优化的帧,而对齐字包含了 2 值,提示返回时 zap 值也需要从堆栈中弹出。

逆优化

如果一个优化的函数需要还原,那么对应帧需要被转译成几个输出帧,因为一个优化的函数也可能包含许多内联函数。我们最后得到一个 InputFrame 和几个 outputframe

我们来看看一个没有参数的优化函数逆优化。该函数有两个溢出 slot,一个用于对齐字。逆优化进程开始时调用一个函数,该进程推送一个 Bailout ID,然后,逆优化函数将寄存器推送到堆栈,并准备创建一个“逆优化”对象。

该函数已被逆优化,并且正在准备创建逆优化对象。所有必要的信息都在堆栈上。这些信息用于构建逆优化器。然后我们展开整个堆栈,复制所有寄存器,然后在创建逆优化器时分配给输入 "FrameDescription" 对象的帧。这时我们使用 C++ 并计算所有输出帧。在此之后,我们检查对齐字,并弹出对齐的 "zap 值",如果它还存在(不在上面的例子中)。我们最终得到一个全空的堆栈,无法执行任何操作或跳转到别处,因为返回地址已经弹出堆栈。

循环所有的输出帧,将其内容从较高(最深)地址推到较低(最浅)的地址:

计算出 OutputFrame,并将其复制到堆栈的适当位置中。最后,后续数据和寄存器状态继续送入堆栈。我们将寄存器的值弹出,返回到后续地址,最后把状态和 pc 完美地放入 N - 1 帧中。

使用 popad 指令,将保存的寄存器恢复到 CPU 中,然后执行一个 ret 指令从堆栈中弹出后续地址,然后跳转到对应的代码。读取状态和 pc 地址以适当地在正确的位置上输入未优化的代码。堆栈将逐渐正确地展开。

由于在完整的 JavaScript 代码帧中添加了额外的类型反馈向量,因此输出帧的固定大小不同。这是一个参数,非对齐例子的底层帧一对一转换示意图,输出帧没有局部变量。

另外,如果最底层的优化帧是对齐的,我们必须删除对齐 zap 值并把值转移到堆栈高地址区(原谅我这么关注对齐... 这是最基本的):

一个对齐的,已优化的 InputFrame 在如上堆栈中会被替换。注意,输出帧与之前未对齐的情况相同。

栈替换(OSR)

如果运行一个紧凑的循环,我们可能想在结束之前优化和替换代码。这意味着在当前帧上优化和安装已优化帧。实际上,我们只是简单地将新帧的新增部分追加到现有的 JavaScriptFrame 的末尾。已优化帧有溢出 slot。这些将会在已经存在的帧中进行。进入优化代码的第一个任务(中等长度的循环, 多兴奋!)是把那些局部变量复制到寄存器分配程序可以跟踪它们的溢出 slot 中。

我修改了 OSR 入口点,将这些局部变量移到堆栈上,重写未优化代码中的向量 slot。我的第一个方法以测试失败告终,具体方法是将这个向量放在适当的位置,并尝试让优化编译器将它当作一个“额外的”溢出 slot 来处理。这很复杂。首先,逆优化器必须弄清楚它是否对 OSR 条目的函数进行了逆优化,并在前一种情况下使用“额外”的字来继续处理。此外,Crankshaft 优化函数与 OSR 条目可以在一开始就输入,而这个输入会使堆栈多一个额外的虚拟值,以便使局部偏移量和 OSR 入口点创建的溢出 slot 保持同步。当我放弃这种方法的时候,人生都亮了!

因为要考虑优化帧的对齐,所以替换使用 OSR 的代码也意味着要移动对应帧的现有部分。这里有一个示例,左侧显示未优化的堆栈,右侧显示已优化的堆栈。在优化前后对比图中,请注意,在优化帧中,删除向量后,固定部分变得更小:

虚拟逆优化调试器

我们有一个测试 debug-evaluate-locals-optimized.js 来验证调试器可以解释堆栈上所有函数的局部变量和参数,即便是经过优化的函数。这个示例设置了一系列从函数 f 到函数 h 的调用,并调用函数 h 中的调试器来验证预期值。

Function  Locals           Notes
f         a4 = 9, b4 = 10  call g1 (inlined in f, argument adapted)
g1        a3 = 7, b3 = 8   call g2 (inlined in f, constructor frame and
                                    argument adapted)
g2        a2 = 5, b2 = 6   call g3 (inlined in f)
g3        a1 = 3, b1 = 4   call h (not inlined)
h         a0 = 1, b0 = 2   breakpoint

调试器使用逆优化基础方法来计算并在数据结构中储存这些局部值以供以后使用。我们在没有实际操作的情况下 “逆优化” 函数 f,但仅仅是为了从这个步骤中获取一个缓冲区中创建的输出帧。函数 f 分解为 7 个输出帧。这是在堆栈上调用 f(4,11,12) 的输入帧“,右侧是代表未优化的函数 f 的最底层的输出帧:

根据已知的帧结构,可以查询函数 f 完整代码帧的局部变量和参数。

注意字面上的 g1,它在堆栈中,但不是局部变量的一部分,而是在调用 g1 之前简单保存的表达式。下面是其他有趣的 OutputFrame 数据结构,分别为函数g1、g2和g3\。在函数 g1 的帧中,我期望看到在堆栈上调用 g2 的字面表达式,最初担心有 bug。但是 g2 作为 new g2(…) 调用, 在调用之前,构造函数调用不会将表达式推入堆栈上。

g2 有3个参数,但它只接受一个,因此插入了一个参数适配器帧(此处没有显示)。g3 按照预期的三个参数调用,因此不会插入适配器帧。总共有7个输出帧:

  1. f
  2. arguments adaptor
  3. g1
  4. constructor frame
  5. arguments adaptor
  6. g2
  7. g3

Now we start copying this information into a data structure for debugging. First we examine the frame for g3.

现在我们开始将这些信息复制到数据结构中进行调试。首先,我们来检查 g3 的对应帧。

虽然我在逆优化器中的更改产生了正确的 OutputFrame,但破坏了代码解释。我必须修改 FrameDescription 类,根据它描述的是 已优化 帧还是 JAVA_SCRIPT帧来正确地返回局部偏移量。这将正确地反映我引入的类型反馈向量的变化。通过这些变化,测试通过,并找到所有带有正确值的局部变量。

结语

下图展示了系统成型后的结构图:

性能如何?大多数 benchmark 表现都很好,但是一些 SunSpider 测试很短,我们无法运行优化代码,这是一个净损失,因为我们的未优化帧是 1 word 的大小。我必须为此付出代价。在做优化工作之前,我需要验证处理成类型向量的整个过程代价是值得的。总的来说,我很乐观。

所有平台上的工作的变更列表在这里。感谢您通过深入 V8 内部,阅读完本复杂曲折的课程。


掘金翻译计划 是一个翻译优质互联网技术文章的社区,文章来源为 掘金 上的英文分享文章。内容覆盖 AndroidiOSReact前端后端产品设计 等领域,想要查看更多优质译文请持续关注 掘金翻译计划官方微博知乎专栏