0%

V8中Speculative Optimization简介

一篇关于v8 TurboFan文章的翻译,原文地址:An Introduction to Speculative Optimization in V8

原文地址:An Introduction to Speculative Optimization in V8

作者:Benedikt Meurer

2017年11月28日

我们希望撰写一篇引人入胜的底层文章,以期让您对V8的优化有所了解。

在JS Kongress上我的演讲“Turbo Tan的故事”(slides)之后,我想提供一些其他背景信息来介绍V8的优化编译器TurboFan的工作方式以及V8如何将JavaScript变成高度优化的机器代码。 对于演讲来说必须要求简短,导致省略了一些细节。 因此,我将利用这个机会来填补空白,尤其是V8如何收集和使用配置信息来执行推测性优化。

Overview

在深入探讨TurboFan的工作原理之前,我将简要介绍V8的工作原理。 让我们看一下V8的工作流程简要过程(摘自我的同事Addy Osmani的“JavaScript Start-up Performance”博客文章):

How V8 Works

每当Chrome或Node.js必须执行一段JavaScript时,它将源代码传递给V8。 V8接收该JavaScript源代码并将其提供给所谓的Parser,后者将为源代码创建一个抽象语法树(AST)表示形式。 演讲“Parsing JavaScript — better lazy than eager?” 来自我的同事MarjaHölttä的文章,其中包含有关V8如何工作的一些详细信息。 然后将AST传递到最近引入的Ignition解释器,在Ignition解释器中将其转换为字节码序列,然后由Ignition执行字节码。

在执行期间,Ignition收集与某些特定操作相关的输入的性能分析信息(profiling information)或反馈(feedback)。反馈中的一些会被Ignition用来加快字节码的后续解释。比如,对于属性的访问(o.x),其中o始终具有相同的形状(即,对于v为字符串的o,您始终传递值{x:v}),我们会缓存有关如何获取x值的信息。在随后执行相同的字节码时,我们无需再次在o中搜索x。此处的基础机制称为内联缓存(IC)。 您可以在我的同事维亚切斯拉夫·埃格罗夫(Vyacheslav Egorov)的博客文章“What’s up with monomorphism?”中找到许多有关此方法如何用于属性访问的详细信息。

甚至更重要的是-取决于您的工作量-Ignition解释器收集的反馈将由TurboFan JavaScript编译器以一种称为“Speculative Optimization”的技术来利用生成高度优化的机器代码。优化编译器在这里查看过去遇到的值类型,并假定接下来将遇到相同类型的值。这使TurboFan可以省去很多不需要处理的情况,这对于以最佳性能执行JavaScript极为重要。

The Basic Execution Pipeline

再考虑一下我演讲中的示例的简化版本,仅关注函数add以及V8如何执行该函数。

1
2
3
4
5
function add(x, y) {
return x + y;
}

console.log(add(1, 2));

如果您在Chrome DevTools控制台中运行此命令,则会看到它输出了预期的结果3:

Chrome DevTools

我们来看看在V8引擎下发生了什么才真正获得这些结果。我们将针对add函数一步步看V8做了什么。如前所述,我们首先需要解析函数源代码,并将其转换为抽象语法树(AST)。这是由解析器完成的,您可以在d8 shell的Debug版本中使用--print-ast命令行标志查看V8内部生成的AST。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
$ out/Debug/d8 --print-ast add.js

--- AST ---
FUNC at 12
. KIND 0
. SUSPEND COUNT 0
. NAME "add"
. PARAMS
. . VAR (0x7fbd5e818210) (mode = VAR) "x"
. . VAR (0x7fbd5e818240) (mode = VAR) "y"
. RETURN at 23
. . ADD at 32
. . . VAR PROXY parameter[0] (0x7fbd5e818210) (mode = VAR) "x"
. . . VAR PROXY parameter[1] (0x7fbd5e818240) (mode = VAR) "y"

这种格式不是很容易理解,因此我们对其进行可视化。

Abstract Syntax Tree

最开始,add函数的代码被解析为树表示形式,一个子树用于参数声明,一个子树用于实际函数主体。在解析过程中,不可能分辨出哪个名称对应于程序中的哪个变量,这主要是由于有趣的var提升规则和JavaScript中的eval,以及其他一些原因。因此,对于每个名称,解析器最初都会创建所谓的VAR PROXY节点。后续的范围解析步骤将这些VAR PROXY节点连接到声明的VAR节点,或将其标记为全局或动态查找(具体取决于解析器是否已在周围范围之一中看到了eval表达式)。

完成此操作后,我们将拥有一个完整的AST,其中包含从中生成可执行字节码的所有必要信息。然后将AST传递给BytecodeGenerator,它是Ignition解释器的一部分,该解释器将按功能生成字节码。 您还可以使用带有d8 shell(或带有节点)的标志--print-bytecode来查看V8生成的字节码。

1
2
3
4
5
6
7
8
9
10
11
$ out/Debug/d8 --print-bytecode add.js

[generated bytecode for function: add]
Parameter count 3
Frame size 0
12 E> 0x37738712a02a @ 0 : 94 StackCheck
23 S> 0x37738712a02b @ 1 : 1d 02 Ldar a1
32 E> 0x37738712a02d @ 3 : 29 03 00 Add a0, [0]
36 S> 0x37738712a030 @ 6 : 98 Return
Constant pool (size = 0)
Handler Table (size = 16)

这告诉我们为函数add生成了一个新的字节码对象,该对象接受三个参数:隐式接收者this和显式形式参数x和y 该函数不需要任何局部变量(帧大小为零),并且包含四个字节码的序列:

1
2
3
4
StackCheck
Ldar a1
Add a0, [0]
Return

为了解释这一点,我们首先需要了解解释器是如何工作在高级层面。Ignition使用了一个被称为register machine的技术(与早期V8版本中FullCodegen编译器使用的stack machine不同)。它会再解释寄存器中保存它的本地状态,其中一些会被映射到真实的CPU寄存器,另外一些则会映射到本机堆栈内存中的特定插槽。

Interpreter overview

特殊寄存器a0a1与机器栈上的形参相对应(这个例子中有两个形参)。形参指的是源代码中定义的参数,可能与运行时传递给函数的参数数量不同。每个字节码最后被计算的值通常会被保留在一个特殊的寄存器accumulator,当前的stack frame或者activation record通过stack pointer识别,program counter指向字节码中当前执行指令。我们检查下再这个例子中独立的字节码做了什么:

  • StackCheckstack pointer与一些已知的上限(实际上是下限因为在V8支持的所有架构中栈都是向下增长)。如果栈增长超过了阈值,我们中止执行函数并且抛出RangeError表示栈溢出。
  • Ldar a1将寄存器a1的值加载进accumulator寄存器(名称表示LoaD Accumulator Register)。
  • Add a0, [0]a0寄存器加载值,将其与accumulator相加,并将结果放回accumulator。注意这里的addition也可以是字符串拼接,并且这个操作可以执行任意的javascript,主要取决于操作数类型。javascript中的+操作真的很复杂,许多人尝试在演讲中阐述其复杂性。add操作符的[0]操作数涉及到feedback vector slot,Ignition在其中保存关于值在之前已经见到的执行中的性能信息。我们后面观察 TurboFan优化函数时会回到这一点。
  • Return结束当前函数的执行并将控制返回给调用者。返回值是当前accumulator寄存器中保存的值。

Speculative Optimization

现在你已经对V8在基准情况下如何执行JavaScript有了一个大概的了解,是时候开始研究TurboFan如何融入这个框架,以及如何将JavaScript代码转换为高度优化的机器代码了。 在JavaScript中,+运算符已经是一个非常复杂的操作,在最终对输入进行加号之前,必须进行大量检查。

Runtime Semantics of the + operator

如何仅仅通过几条机器指令就可以达到高性能(相比javac++代码)还不是很明显。这里的关键是Speculative Optimization,它利用了有关可能输入的假设。例如,当我们知道x + y中,x和y都是数字,则无需处理其中两个都是字符串甚至更糟的情况-操作数可以是任意JavaScript对象,这时我们必须先执行抽象操作ToPrimitive

ToPrimitive operation

知道x和y都是数字,这也意味着我们可以排除明显的副作用-例如,我们知道它无法关闭计算机或写入文件或导航到其他页面。此外,我们知道该操作不会引发异常。两者对于优化都是很重要的,因为优化编译器只有在确定该表达式不会引起任何可观察到的副作用并且不会引发异常的情况下,才能消除该表达式。

由于JavaScript的动态性质,通常在运行时我们才知道值的确切类型,即仅通过查看源代码,通常就无法确定操作输入的可能值。因此,我们需要根据以前收集的有关到目前为止所见值的反馈来进行推测,然后假设我们将来始终看到相似的值。这听起来可能相当有限,但是事实证明,它对于像JavaScript这样的动态语言都适用。

1
2
3
function add(x, y) {
return x + y;
}

在这个特殊例子下,我们收集有关输入操作数和+运算符的结果值(add字节码)的信息。当我们使用TurboFan优化此代码时,到目前为止,我们只看到数字,我们进行了检查以确保x和y均为数字(在这种情况下,我们知道结果也将是数字)。如果这些检查中的任何一项失败,我们都将转回解释字节码,即称为”Deoptimization”的过程。因此,TurboFan不必担心+运算符的所有其他情况,甚至不需要机器代码来处理这些情况,而是可以专注于数字的情况,这可以很好地转化为机器指令。

Closure structure

由Ignition收集的反馈存储在所谓的Feedback Vector(以前称为Type Feedback Vector)中。这种特殊的数据结构从闭包开始链接,并包含一些slot用于存储不同类型的反馈,即位集,闭包或隐藏类,取决于具体的内联缓存(IC)。该闭包还链接到SharedFunctionInfo,其中包含有关该功能的常规信息(例如源代码位置,字节码,strict/sloppy模式等),并且还有一个指向上下文的链接,其中包含函数中自由变量的值,并提供对全局对象(即<iframe>特定数据结构)的访问。

在使用add函数的情况下,Feedback Vector恰好有一个有趣的slot(除了通常的调用计数器slot外),叫做BinaryOp slot,二进制操作如+ - *等可以存放之前见到的关于输入与输出的feedback。可以通过在运行时使用--allow-natives-syntax命令行标识使用特殊的%DebugPrint()内部函数来查看feedback的内部有什么(在构建的Debug版本的d8中)。

1
2
3
4
5
6
function add(x, y) {
return x + y;
}

console.log(add(1, 2));
%DebugPrint(add);

在d8中运行并使用--allow-natives-syntax标识可以观察到:

1
2
3
4
5
6
7
8
9
10
11
$ out/Debug/d8 --allow-natives-syntax add.js
DebugPrint: 0xb5101ea9d89: [Function] in OldSpace

- feedback vector: 0xb5101eaa091: [FeedbackVector] in OldSpace
- length: 1
SharedFunctionInfo: 0xb5101ea99c9 <SharedFunctionInfo add>
Optimized Code: 0
Invocation Count: 1
Profiler Ticks: 0
Slot #0 BinaryOp BinaryOp:SignedSmall

可以看到invocation count是1,因为我们只运行了add函数一次。当然目前也没有优化代码(Optimized Code: 0可以看出)。但是feedback vector中有一个slot,是一个BinaryOp slot,其当前feedbackSignedSmall。这意味着什么?涉及到feedback slot 0Add的字节码目前只看到了输入类型为SignedSmall并且只产生了SignedSmall类型的输出。

但是SignedSmall类型是什么?Javascript并没有这个名字的类型。这个名字来自于当表示small signed integer值时V8中所做的优化,这个在程序出现的足够频繁值得被特殊对待(其他的javascript引擎有相似的优化)。

Excurse: Value Representation

我们简单的探究下javascript值是如何在V8中表示以更好的理解后面的概念。大体上V8使用了一个称为Pointer Tagging的技术去表示值。我们处理的大多数值存在于Javascript堆中,并由garbage collector (GC)管理。但是对于一些值,总是在内存中申请他们会非常浪费。特别是经常用于数组索引的小整形值和暂时的计算结果。

Tagging Scheme

在V8中,有两个可能的tagged representations:Smi(Small Integer)和指向内存管理堆的HeapObject。我们基于一个事实:所有申请的对象都是word对齐的(64-bit或者32-bit取决于架构),这意味着最低的2或者3位有效位总是0。我们使用最低有效位区分HeapObject(位为1)和Smi(位为0)。对于64位架构的Smi最低的32位总是0,带符号的32-bit值被存放在word的高位那半部分。这使得可以使用单一机器指令高效的获取内存的32-bit值而不用将其加载或者移位,也因为32位算术对于JavaScript中的按位运算很常见。

在32位体系结构上,Smi表示的最低有效位设置为0,带符号的31位值向左移一位,该值存储在该字的高31位中。

Feedback Lattice

SignedSmall反馈类型涉及到所有拥有Smi表示的值。对于Add操作它意味着目前仅见到输入被表示为Smi,并且产生的输出也能被表示为Smi(即值不会溢出32位整型值得范围)。我们看下如果我们调用add并传入其他不能被表示为Smi的数会发生什么。

1
2
3
4
5
6
7
function add(x, y) {
return x + y;
}

console.log(add(1, 2));
console.log(add(1.1, 2.2));
%DebugPrint(add);

再次使用--allow-natives-syntax在d8运行可以观察到:

1
2
3
4
5
6
7
8
9
10
11
$ out/Debug/d8 --allow-natives-syntax add.js
DebugPrint: 0xb5101ea9d89: [Function] in OldSpace

- feedback vector: 0x3fd6ea9ef9: [FeedbackVector] in OldSpace
- length: 1
SharedFunctionInfo: 0x3fd6ea9989 <SharedFunctionInfo add>
Optimized Code: 0
Invocation Count: 2
Profiler Ticks: 0
Slot #0 BinaryOp BinaryOp:Number

首先,我们可以看到invocation count现在是2,因为我们运行了函数两次。并且可以看到BinaryOp slot转为Number,这表示我们看到了操作数为任意数(即非整型值)。此外,还有一些可能的反馈状态,大致如下所示:

Feedback Lattice

反馈状态以None开始,这表示我们目前没见到任何东西,所有什么都不知道。Any状态表示我们已经见到不兼容的输入或输出的组合。Any状态也表示Add应当被考虑为多态(polymorphic)。相反的,保留状态表示Add是单态的(monomorphic),因为它已经看到并且产生几乎相同的值。

  • SignedSmall表示所有的值都是小整型(有符号32-bit或31-bit,取决于架构),并且所有的值都可以被表示为Smi
  • Number表示所有的值都是常规数(这也包括了SignedSmall)。
  • NumberOrOddball表示所有的值都来自Number加上undefined,null,truefalse
  • String表示所有的输入都是字符串值。
  • BigInt表示所有的输入都是BigInts

重要的是要注意,反馈只能在这个网格中进行,永远都不会回头。如果回头,那就有可能进入所谓的非优化循环deoptimization loop,在该循环中,优化编译器会消耗反馈,并在发现与反馈不一致的值时退出优化代码(返回解释器)。下一次函数变成热点后最终将对其进行再次优化。因此,如果我们不在网格中继续前进,那么TurboFan将再次生成相同的代码,这实际上意味着它将再次接受相同类型的输入。因此,引擎将忙于仅优化和取消优化代码,而不是高速运行JavaScript代码。

The Optimization Pipeline

现在我们知道了Ignition如何收集add函数的反馈,下面我们看下TurboFan如何使用feedback去生成最小化代码。我将使用内部函数%OptimizeFunctionOnNextCall()去在一个特殊的时间点触发函数优化。我们经常使用这些内部函数来通过特殊的方式去写测试来测试机器。

1
2
3
4
5
6
7
function add(x, y) {
return x + y;
}

add(1, 2); // Warm up with SignedSmall feedback.
%OptimizeFunctionOnNextCall(add);
add(1, 2); // Optimize and run generated code.

这里我们先给x+y传入两个整型值,其和也在小整型范围,以此得到SignedSmall反馈。然后我们告诉V8下一次调用时应当优化add函数(使用TurboFan),最后我们调用add,这将会触发TurboFan并执行生成的机器码。

TurboFan

TurboFan接受之前生成的add的字节码并从add的反馈向量中解w析出反馈。它将这些转化为图表示,并将图在前端、优化以及后端各个过程中传递。我不打算深入传递的细节,这是一个独立的博客主题。我们看一下生成的机器码并观察speculative optimization如何工作。你可以通过在d8中加入--print-opt-code查看TurboFan生成的代码。

Generated assembly code

这是TurboFan生成的x64机器码,并带有我的注释,忽略了一些不重要的技术细节(如Deoptimizer的精确调用序列)。我们看下代码做了什么:

1
2
3
4
5
6
7
8
9
10
11
# Prologue
leaq rcx,[rip+0x0]
movq rcx,[rcx-0x37]
testb [rcx+0xf],0x1
jnz CompileLazyDeoptimizedCode
push rbp
movq rbp,rsp
push rsi
push rdi
cmpq rsp,[r13+0xdb0]
jna StackCheck

Prologue检查代码对象是否仍然有效或者某些条件是否已经改变使得我们必须丢弃掉代码对象。一旦我们发现代码仍然有效,就构建stack frame并检查栈上是否有足够的剩余空间来执行代码。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
# Check x is a small integer
movq rax,[rbp+0x18]
test al,0x1
jnz Deoptimize
# Check y is a small integer
movq rbx,[rbp+0x10]
testb rbx,0x1
jnz Deoptimize
# Convert y from Smi to Word32
movq rdx,rbx
shrq rdx, 32
# Convert x from Smi to Word32
movq rcx,rax
shrq rcx, 32

然后我们开始执行函数体。我们从栈上加载参数x和y的值(与帧指针rbp相似)并且检测两个值是否都是用Smi表示(因为+操作的反馈告诉我们之前的所有的输入总是Smi)。这可以通过测试最低有效位来完成。一旦发现他们都是用Smi表示,我们就需要将他们转换为32表示(通过将值右移32位)。

如果x和y中有一个不是Smi,优化代码会立即停止执行,然后Deoptimizer会在Add之前在解释器中恢复函数的状态。

注意:我们也可以在此处对Smi表示进行加操作; 这就是我们之前优化的编译器Crankshaft所做的。 这样可以节省我们的时间,但是目前TurboFan尚无法很好地确定在Smi上进行操作是否有益,因为这并不总是理想的选择,并且在很大程度上取决于使用此操作的环境。

1
2
3
4
5
6
7
8
9
10
# Add x and y (incl. overflow check)
addl rdx,rcx
jo Deoptimize
# Convert result to Smi
shlq rdx, 32
movq rax,rdx
# Epilogue
movq rsp,rbp
pop rbp
ret 0x18

然后我们继续对输入整型进行加操作。我们需要明确地检查溢出,因为相加的结果可能会超出32位整型范围,这时我们就要返回到解释器,解释器会学习到AddNumber反馈。最后我们通过移位操作传出Smi表示的结果,并将结果返回到rax

如前所述,对于这种情况,这不是完美的代码,因为在这里直接在Smi表示上执行加法更好,而不是使用Word32,这将为我们节省三个移位指令。但是,抛开了这一次要方面,您可以看到生成的代码是经过高度优化的,并且专门用于性能分析反馈。在这里,它甚至不尝试处理其他数字,字符串,大整数或任意JavaScript对象,而仅关注到目前为止我们所看到类型的值。这是使得许多JavaScript应用程序达到最佳性能的关键因素。

Making progress

那么如果你突然改变主意想将两个数字相加呢?我们改变一下例子:

1
2
3
4
5
6
7
8
function add(x, y) {
return x + y;
}

add(1, 2); // Warm up with SignedSmall feedback.
%OptimizeFunctionOnNextCall(add);
add(1, 2); // Optimize and run generated code.
add(1.1, 2.2); // Oops?!

--allow-natives-syntax--trace-deopt参数运行,观测如下:

Deoptimization example

输出很多,我们提取一下重要的部分。首先,我们打印出必须进行deoptimize的原因,在这种情况下,它不是Smi,这意味着我们假设值是Smi,但现在我们看到了HeapObject。 确实,rax中的值应该是Smi,但结果是数字1.1。因此,我们在第一次检查x参数时失败了,我们需要取消优化以返回到解释字节码。不过这是另一篇文章的主题。

Takeaway

我希望你喜欢本文关于V8中Speculative Optimization如何工作以及它如何帮助我们达到JavaScript应用程序的最佳性能的挖掘。不过,不要太担心这些细节。在用JavaScript编写应用程序时,应着重于应用程序设计,并确保使用适当的数据结构和算法。尽管编写惯用的JavaScript,让我们去担心底层的JavaScript性能。如果你发现某件事不该慢的代码太慢,请提交错误报告,以便我们有机会对此进行调查。