本文介绍,V8中的优化编译器TurboFan,是如何将JavaScript代码变成高度优化的机器码。包括,V8是如何通过收集运行期间的分析信息来进行推测优化,本人翻译自Benedikt Meurer的文章,并获得原作者许可。
在我们深入了解TurboFan的工作细节之前。我们先通过下面这幅图从一个较高的层面简单了解下V8在各个阶段的工作过程。(图片来自 JavaScript Start-up Performance)
无论何时,chrome或者node.js需要执行某段JavaScript,这段源代码都会被传递给V8。V8收到代码后首先会传给一个叫做解析器(Parser)的组件,通过它将你的源代码转换成抽象语法树(Abstract Syntax Tree (AST) )。关于这一步,可以通过Marja Hölttä的介绍:“Parsing JavaScript — better lazy than eager?” 了解更多的细节。然后AST传递给解释器Ignition (解释器也是作为一个全新的组成被加入到新版V8的架构中),在那里生成一组可供解释器运行的字节码(这种字节码的方式对于加速那些大型页面加载有很大的帮助)。
在执行过程中,Ignition 会收集某些操作输入的分析信息或者反馈(主要是输入的类型信息)。部分的反馈被Ignition 用来加速随后对字节码的解释运行。例如,对于像o.x这样的属性访问,若o始终具有相同的形状(形状同结构,即相同的属性以及属性是相同的顺序,例如o的结构一直是{x:v},其中v的类型是String),我们会把如何获得o.x的过程信息缓存起来。在随后执行相同的字节码时,不需要再次搜索对象o中x的位置。这种底层实现被称为内联缓存– inline cache (IC)。你可以在Vyacheslav Egoro写的这篇文章 “What’s up with monomorphism?” 中了解更多关于ICs和属性访问的细节。
除此之外,收集过程还有更重要的作用—取决于你的工作量(译注:这里应该是指运行越多,推测优化的结果越可靠 ),解释器Ignition 收集到的反馈会给到JavaScript的优化编译器TurboFan,TurboFan使用一种叫做推测优化的技术生成高度优化的机器码。在这里,优化编译器会查看过去都看到了哪些值,并且假设在后面的运行中会看到同样类型的值。这种方式允许TurboFan省去很多不需要处理的情况,这一点对于JavaScript以最佳性能运行极其重要。
让我们看下这个简单例子,只关注函数add以及V8如何执行这个函数。
function add(x, y) {
return x + y;
} c
console.log(add(1, 2))
如果你在chrome的DevTools console中运行这段代码,你可以看到预期的输出值3。
下面,让我们从V8的内部,一步步地来看看函数add从输入到获得结果发生了什么。如前所述,我们首先要解析函数源代码并将其转换为抽象语法树 Abstract Syntax Tree (AST)。这一步由解析器完成。开发者可以在d8 shell的Debug版本中使用 –print-ast 命令来查看V8内部生成的AST。
$ 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
最开始,函数字面量add被解析为树形表示,其中一个子树用于参数声明,另外一个子树用于实际的的函数体。在解析阶段,不可能知道程序中名称和变量的绑定关系,这主要是因为“有趣的变量声明提升规则”以及JavaScript中的eval,此外还有其他原因。所以对于每一个标识符,解析器都会初始的创建一个被称为VAR PROXY 的节点。在随后的作用域分析阶段会把VAR PROXY 节点和声明的VAR节点绑定或者是把它标记为全局或者动态查找,这取决于是否在作用域范围内找到eval语法(译注:可以说with也属于动态查找,这部分内容请参考扩展阅读一)。
一旦完成,我们就得到了一个完整的AST,它包含了从中生成可执行字节码的所有必要信息。AST随后被传递给BytecodeGenerator ,BytecodeGenerator 是属于Ignition 的一部分,它以函数为单位生成字节码(译注:其他引擎并不一定以函数为单位生成的)。你也可以在d8中使用命令–print-bytecode来查看V8生成的字节码(或者用node端)
$ 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。该函数不需要任何的局部变量(所以栈帧大小为0),并且包含下面这四个字节码指令组成的序列
StackCheck
Ldar a1
Add a0, [0]
Return
为了解释这段字节码,我们首先需要从较高的层面来认知解释器如何工作。V8的解释器是基于寄存器架构(register machine)的(相对的是基于栈架构,也是早期V8版本中使用的 FullCodegen 编译器)。Ignition 会把指令序列都保存在解释器自身的(虚拟)寄存器中,这些寄存器部分被映射到实际CPU的寄存器中,而另外一部分会用实际机器的栈内存来模拟(译注:这部分可以参考扩展阅读二)。
有两个特殊寄存器a0和a1对应着函数在机器栈(即内存栈)上的形式参数(在函数add这个例子中,有两个形参)。形参是在源代码中声名的参数,它可能与在运行时传递给函数的实际参数数量不同。每个字节码指令执行得到的最终值通常被保存在一个称作累加器(accumulator)的特殊寄存器中。堆栈指针(stack pointer )指向当前的栈帧或者说激活记录(译注:参考扩展阅读二),程序计数器( program counter)指向指令字节码中当前正在执行的指令。下面我们看看这个例子中每条字节码指令都做了什么。
StackCheck 会将堆栈指针与一些已知的上限比较(实际上在V8中应该称作下限,因为栈是从高地址到低地址向下生长的)。如果栈的增长超过了某个阈值,就会放弃函数的执行,同时抛出一个 RangeError 来告诉我们栈溢出了。
Ldar a1将寄存器a1的值加载到累加器寄存器中(Ladr 表示 LoaD Accumulator Register)
Add a0, [0] 读取寄存器a0里的值(译注:下面会用Add这个形式特指这里的字节码指令中首字母大写的Add),并把它加到累加器的值上。结果被再次放到累加器中(译注:一个一地址指令)。请注意,这里”+”操作还能表示字符串连接,除此之外,JavaScript中”+”操作的操作数可以是任意类型的值。JavaScript中的“+”操作非常复杂,有许多人在会谈中去阐明这种复杂性,比如 Emily Freeman 最近在 JS Kongress 中有一个题目为”JavaScript’s “+” Operator and Decision Fatigue” 的主题演讲。Add运算符的[0]操作数指向一个反馈向量槽( feedback vector slot),它是解释器用来储存有关函数执行期间看到的值的分析信息。在后面讲解TurboFan 如何优化函数的时候会再次回到这。
Return 结束当前函数的执行,并把控制权交给调用者。返回值是累加器中的当前值。
我的同事 Franziska Hinkelmann 前段时间写了一篇名为 “Understanding V8’s Bytecode” 的文章,这篇字节码对V8的字节码的工作原理提供了一些深入的了解。
现在,你已经对普通情况下V8如何执行你的代码有了一个大概的了解。现在是时候来研究最开始那张图片中TurboFan扮演的角色,其中包括如何将你的JavaScript代码转换为高度优化的机器码。我们知道JavaScript中的“+”运算符已经是一个相当复杂的操作了,在最终执行一个数值相加之前必须进行大量的检查,看下图。
要想让这些步骤是能够在几个机器指令内完成以达到峰值性能(与Java或者C++相媲美),这里有一个神奇的关键字—-推测优化,通过假设可能的输入。例如,当我们知道表达式x+y中,x和y都是数字,那么我们就不需要处理任何一个是字符串或者其他更糟糕的情况—-操作数是任意类型的JavaScript对象,这时候我们首先要对这些对象运行抽象方法 ToPrimitive 。
知道x,y都是数字意味着能排除某些可观察到的副作用,比如我们知道它不能关掉计算机,不会写入文件或者导航到另一个页面(译注:原文如此)。除此之外,我们也知道这个操作不会抛出异常。这两者对于优化都非常重要,因为优化编译器只有在确定表达式不会导致任何可观察到的副作用并且不会抛出异常时候时候才能对表达式进行优化,消除冗余的表达式(译注:这里应该是指冗余的IR指令,由字节码生成)。
但是因为JavaScript动态语言的特性,我们通常直到运行时才知道值的确切类型,仅仅观察源代码,往往不可能知道某个操作的可能输入值。所以这就是为什么我们需要推测,根据之前运行收集到的值的反馈,然后假设将来总会看到类似的值。这种方法听起来可能作用相当有限,但它已被证明适用于JavaScript这样的动态语言。
function add(x, y) {
return x + y;
}
我们收集有关输入操作数的信息以及“+”操作得到的结果值(Add字节码)。接下来,当我们使用TurboFan优化这段代码,并且迄今为止只看到了数字时(这种情况我们知道包括结果也是数字),我们将在这段函数的入口添加一段检查代码,它的职责类似哨兵,负责检查传入的x和y是否都是数字。如果其中任何一个检查失败,就不会再往下运行优化后的机器码,而是转而解释字节码—-这个过程称为去优化。TurboFan并不需要关心操作符+所承载的所有其他情况,也不需要生成机器码去处理它们。它只需要专注于数字的情况,这样能够很好的转换为机器码。
由解释器收集到的反馈储存在所谓的反馈向量(过去叫类型反馈向量)中。这个特殊的数据结构链接在闭包上(译注:根据Urs Hölzle 最早提出对动态类型语言进行多态内联缓存的SmallTalk语言系统中,内联缓存存在于callsite的位置,用一个stub函数来代替原本的callsite,但在V8中,使用closures 来代替了stub)。Michael Stanton 之前在AmsterdamJS 有一篇名为 “V8 and How It Listens to You“的演讲,它详细阐释了反馈向量的一些概念。这个闭包同时也链接到了 SharedFunctionInfo,它包含了函数的一般信息,比如源位置,字节码,严格或一般模式。除此之外,还有一个指向上下文的链接,其中包含自由变量的值以及对全局对象的访问。
反馈向量的大致结构如下,slot是一个槽,表示向量表里面的一项,包含了操作类型和传入的值类型,
在函数add的例子中,反馈向量恰好有一个有趣的槽(slot)(除了一些普遍存在的槽,比如调用次数的槽),这是一个 BinaryOp 槽,二元操作符类似“+,-”等能够记录迄今为止看到的输入和输出的反馈。
在你的代码中加上专门的内部函数%DebugPrint() ,并且在d8中加上命令 –allow-natives-syntax来检查特定闭包的反馈向量的内容。
源代码:
function add(x, y) {
return x + y;
} c
onsole.log(add(1, 2));
%DebugPrint(add);
在d8 使用这个命令 –allow-natives-syntax 运行,我们看到 :
$ out/Debug/d8 --allow-natives-syntax add.js
DebugPrint: 0xb5101ea9d89: [Function] in OldSpace
- feedback vector: 0xb5101eaa091: [FeedbackVector] in OldSpace
- length: 1
SharedFunctionInfo: 0xb5101ea99c9 add>
Optimized Code: 0
Invocation Count: 1
Profiler Ticks: 0
Slot #0 BinaryOp BinaryOp:SignedSmall
…
我们看到调用次数(Invocation Count)是1,因为我们只调用了一次函数add。此时还没有优化代码(根据Optimized Code的值为0)。反馈向量的长度为1,说明里面只有一个槽,就是我们上面说到的二元操作符槽(BinaryOp Slot),当前反馈为 SignedSmall。这个反馈SignedSmall代表什么?这表明指令Add只看到了SignedSmall类型的输入,并且直到现在也只产生了SignedSmall类型的输出。
但是什么是SignedSmall类型?JavaScript里面并不存在这种类型。实际上,SignedSmall来自己V8中的一种优化策略,它表示在程序中经常使用的小的有符号整数(V8将高位的32位表示整数,低位的全部置0来表示SignedSmall),这种类型能够获得特殊处理(其他JavaScript引擎也有类似的优化策略)。
让我们简单探讨下JavaScript值在V8中的表示以帮助我们更好理解一些底层概念。V8通常使用一种叫做指针标记(Pointer Tagging)的技术来表示值,应用这种技术,V8在每个值里面都设置一个标识。我们处理的大部分值都分配在JavaScript堆上,并且由垃圾回收器(GC)来管理。但是对某些值来说,总是将它们分配在内存里开销会很大。尤其是对于小整数,它们通常会用在数组索引和一些临时计算结果。
在V8种存在两种指针标识类型:分别是是Smi(即 Small Integer的缩写)和堆对象( HeapObject,就是JavaScript的引用类型),其中堆对象是分配在内存的堆中,图中的地址即指向堆中的某块地方。我们要明确一点事实,所有被分配的对象都是对齐在Word(根据系统硬件不同,Word的大小也不同,如总线为32位 1 Word为32bit,64位,则Word为64bit)边界上(即内存对齐),这意味着2个或3个最低有效位始终为0。我们用最低有效位来区分堆对象(标志是1)和小整数(标志是0)。对于64位结构上的Smi,至少有32位有效位(低半部)是一直被置为0。另外32位,也就是Word的上半部,是被用来储存32位有符号小整数的值。这种方式允许使用单个机器指令访问内存中32位整数的值,而不必使用load和shift操作。同时,JavaScript中的位运算基本都是基于32位整数的。
反馈类型SignedSmall是指所有能用小整数表示的值。对于add操作而言,这意味着目前为止它只能看到输入类型为Smi,并且所产生的输出值也都是Smi(也就是说,所有的值都没有超过32位整数的范围)。下面我们来看看,当我们调用add的时候传入一个不是Smi的值会发生什么。
function add(x, y) {
return x + y;
} c
onsole.log(add(1, 2));
console.log(add(1.1, 2.2));
%DebugPrint(add);
在d8加入命令 –allow-natives-syntax ,然后看到下面结果
$ out/Debug/d8 --allow-natives-syntax add.js
DebugPrint: 0xb5101ea9d89: [Function] in OldSpace
…
- feedback vector: 0x3fd6ea9ef9: [FeedbackVector] in OldSpace
- length: 1
SharedFunctionInfo: 0x3fd6ea9989 add>
Optimized Code: 0
Invocation Count: 2
Profiler Ticks: 0
Slot #0 BinaryOp BinaryOp:Number
…
首先,我们看到调用次数现在是2,因为运行了两次函数add。然后发现BinaryOp 槽的值现在变成了Number,这表明对于这个加法已经有传入了任意类型的数值(即非整数)。此外,这有一个反馈格的状态迁移图,大致如下所示:
反馈状态从None开始,这表明目前还没有看到任何输入,所以什么都不知道。状态Any表明我们看到了不兼容的(比如number和string)输入和输出的组合。状态Any意味着Add(字节码中的)是多态。相比之下,其余的状态表明Add都是单态(monomorphic),因为看到的输入和产生的都是相同类型的值。
下面是图中名词解释:
SignedSmall 表示所有的值都是小整数(有效数值为是32位或者31位,取决于Word的在不同架构上的大小),均表示为Smi。
Number 表明所有的值都常规数字 (这包括小整数).
NumberOrOddball 包括其他能被转换成Number的undefined, null , true 和false .。
String :所有输入值都是字符串
BigInt 表示输入都是大整数, 请参阅第二阶段的提案了解更详情。
需要注意一点,反馈只能在这个图中前进(从None到Any),不能回退。如果真的那样做,那么我们就会有陷入去优化循环的风险。那样情况下,优化编译器发现输入值与之前得到反馈内容不同,比如之前解释器生成的反馈是Number,但现在输入值出现了String,这时候已经生成的反馈和优化代码就会失效,并回退到解释器生成的字节码版本。当下一次函数再次变热(hot,多次运行),我们将再次优化它,如果允许回退,这时候优化编译器会再次生成相同的代码,这意味着会再次回到Number的情况。如果这样无限制的回退去优化,再优化,编译器将会忙于优化和去优化,而不是高速运行JavaScript代码。
现在我们知道了解释器Ignition 是如何为函数add收集反馈,下面来看看优化编译器如何利用反馈生成最小的代码(译注:越小的机器指令代码块,意味着更快的速度)。为了观察,我将使用一个特殊的内部函数OptimizeFunctionOnNextCall()在特定的时间点触发V8对函数的优化。我们经常使用这些内部函数以非常特定的方式对引擎进行测试。
function add(x, y) {
return x + y;
} a
dd(1, 2); // Warm up with SignedSmall feedback.
%OptimizeFunctionOnNextCall(add);
add(1, 2); // Optimize and run generated code
在这里,给函数add传递两个整数型值来明确call site “x + y”的反馈会被预热为小整数(译注:这句话是指在这个call site全部传递的都是小整数,对于优化引擎来说将来得到的输入也会是小整数),并且结果也是属于小整数范围。然后我们告诉V8应该在下次调用函数add的时候去优化它(用TurboFan ),最终再次调用add,触发优化编译器运行生成机器码。
TurboFan 拿到之前为函数add生成的字节码,并从函数add的反馈向量表里提取出相关的反馈。优化编译器将这些信息转换成一个图表示,再将这个图表示传递给前端,优化以及后端的各个阶段(见上图)。在本文不会详细展开这部分内容,这是另一个blog系列的内容了。我们要了解的是最终生成的机器码,并看看优化推测是如何工作的。你可以在d8中加上命令 –print-opt-code来查看由TurboFan 生成的优化代码。
这是由TurboFan 在x64架构上生成的机器码,带有部分注释并省略了一些无关紧要的技术细节(即去优化的确切调用序列),下面就来看看这些代码做了什么。
# 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
第一段代码检查对象是否仍然有效(对象的形状是否符合之前生成机器码的那个),或者某些条件是否发生了改变,这就需要丢弃这个优化代码。这部分具体内容可以参考 Juliana Franco 的 “Internship on Laziness“。一旦我们知道这段代码仍然有效,就会建立一个栈帧并且检查堆栈上是否有足够的空间来执行代码。
# 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,比如rbp+1这样的地址,请参考栈帧概念),然后检查两个参数是否都是 Smi 类型(因为根据“+”得到的反馈,两个输入总是Smi)。这一步是通过测试最低有效位来完成。一旦确定了参数都是Smi,我们需要将它转换成32位表示,这是通过将值右移32位来完成的。
如果x或y不是Smi,则会立即终止执行优化代码,接着负责去优化的模块就会恢复到之前解释器生成的函数add的代码(即字节码)。
# 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位整数的范围,在这种情况下就要返回到解释器版本,并在随后将add的反馈类型提升为Number(之前说过,反馈类型的改变只能前进)。最后我们通过将带符号的32位值向上移动32位,将结果转换回Smi表示,并将结果返回存到累加器rax 。
如前所述,这还不是最完美的代码,因为我们可以对Smi表示进行加法,而不是先由Word32表示转到Smi(译注:前面几次提到,Word32与Smi的转换),这可以为我们节省3个位移指令(。但即使抛开这一个小点,也可以看到生成的代码是高度优化的,并且适用于专门的反馈。它完全不去处理其他数字,字符串,大整数或任意JavaScript对象,只关注目前为止我们所看到的那种类型的值。这是使许多JavaScript应用程序达到最佳性能的关键因素。
那么,如果你突然改变主意想要加普通数字呢?让我们把这个例子改成这样:
function add(x, y) {
return x + y;
} a
dd(1, 2); // Warm up with SignedSmall feedback.
%OptimizeFunctionOnNextCall(add);
add(1, 2); // Optimize and run generated code.
add(1.1, 2.2); // Oops?!
运行d8 –allow-natives-syntax –trace-deopt 我们会看到下面结果:
这是一个令人困惑的输出。但是我们先看重要的几个点。首先,我们打印出了要去优化的原因,因为这时输入不再是Smi。这意味着之前我们假设输出值是Smi,但现在却看到了一个HeapObject。我们把第一个输入值即参数x放入累加器进行检查,期望它是个Smi,但实际上是个Number类型的值1.1。所以最开始对参数x的检查就失败了,我们就需要去优化并回退到解释器生成的字节码版本,但这部分又是个单独的话题了。
扩展阅读: