V8 引擎:基于类型推测的性能优化原理

2022-12-07 08:35:33 浏览数 (1)

来自团队 史四钰鹏 的内部分享。

介绍

本文的会介绍一些关于V8内基于推测的优化的技术,以此来告诉大家,为什么需要TypeScript。

我们将以一段函数的执行未展开,从函数执行的角度来看看,一段代码如何被执行,优化,再最后,你会了解,为什么TypeScript更好。

看完本文后,你不需要记住文章中出现的繁杂的指令和代码,只需要在你的脑海中存在一个印象,避免写出糟糕的代码,以及,尽量使用TypeScript。

如何执行代码?

作为介绍的第一部分,我们会用一段简短的篇幅带大家看看,你的代码如何被执行

当然,如果用简单的流程图表示,你可以把上面的过程理解为这样一个线性的执行过程,当然可能并不严谨,稍后我们会继续介绍。

下面让我们从一段具体的代码来看一下这个过程。

一段简单的代码

代码语言:javascript复制
function add(x, y) {
    return x   y;
} 
console.log(add(1, 2))

如果你在chrome的DevTools console中运行这段代码,你可以看到预期的输出值3。

根据上面的流程图,这段代码被执行的第一步,是被解析器解析为AST,这一步我们用d8 shell 的Debug版本中使用 –print-ast 命令来查看V8内部生成的AST。

代码语言:javascript复制
$ 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

很多人可能或多或少接触过AST的概念,这里不多赘述,只是用一张简单的图表示下上面的过程。

最开始,函数字面量add被解析为树形表示,其中一个子树用于参数声明,另外一个子树用于实际的的函数体。在解析阶段,不可能知道程序中名称和变量的绑定关系,这主要是因为“有趣的变量声明提升规则”以及JavaScript中的eval,此外还有其他原因。

一旦我们构建完成了AST,它便包含了从中生成可执行字节码的所有必要信息。AST随后被传递给BytecodeGenerator ,BytecodeGenerator 是属于Ignition 的一部分,它以函数为单位生成字节码(_其他引擎并不一定以函数为单位生成的_)。你也可以在d8中使用命令–print-bytecode来查看V8生成的字节码(或者用node端)

代码语言:javascript复制
$ 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),并且包含下面这四个字节码指令组成的序列

代码语言:javascript复制
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里的值,并把它加到累加器的值上。结果被再次放到累加器中。

为什么这条指令是这样的?以一条JS 语句为例

代码语言:javascript复制
var dest = src1   src2 // op dest, src1,src2

 var dest  = src; //op dest, src

  src; // op src

分别表示三地址指令,二地址指令,一地址指令,我在后面分别标注了转换后的机器指令。三地址和二地址指令都指定了运算后储存结果的位置。

但在一地址指令中,没有指定目标源。实际上,它会被默认存在一个累加器”(accumulator)的专用寄存器,保存计算结果。

其中Add运算符的[0]操作数指向一个「反馈向量槽( feedback vector slot)」,它是解释器用来储存有关函数执行期间看到的值的分析信息。在后面讲解TurboFan 如何优化函数的时候会再次回到这。

  • Return 结束当前函数的执行,并把控制权交给调用者。返回值是累加器中的当前值。

当最终生成了上面这段字节码后,会被送入的VM ,一般会由解释器进行执行,这种执行方式是最原始也是效率最低的。我们可以在下一部分了解到,这种原始的执行会经历什么。

关于字节码的解释,这里不会做过多的赘述,如果你感兴趣,可以扩展阅读 「Understanding V8’s Bytecode」 (https://medium.com/dailyjs/understanding-v8s-bytecode-317d46c94775) 一文,这篇字节码对V8的字节码的工作原理提供了一些深入的了解。

为什么需要优化

现在,我相信你已经对V8如何执行一段代码有了一个简单的认识。在正式进入我们的主题之前,还需要解释一个很关键的问题,为什么我们需要优化。为了回答这个问题,我们需要先看下下规范

关于规范的更多了解,可以去这里查找 https://tc39.es/ecma262/#sec-toprimitive

我们再以看最常见的 ToPrimitive 为例,需要经过非常繁琐的求值过程,而这些过程都是为了解决操作类型的动态性

在JavaScript中的“ ”运算符已经是一个相当复杂的操作了,在最终执行一个数值相加之前必须进行大量的检查

而如果引擎要想让这些步骤是能够在几个机器指令内完成以达到峰值性能(与C 相媲美),这里有一个关键能力—-推测优化,通过假设可能的输入。例如,当我们知道表达式x y中,x和y都是数字,那么我们就不需要处理任何一个是字符串或者其他更糟糕的情况—-操作数是任意类型的JavaScript对象,也就不需要对所有参数调用一遍 ToPrimitive 了。

换句话说,如果我们能够确定x,y 都是数字类型,我们自然就很容易对这个函数执行进行优化,消除冗余的IR指令。

「而从执行的角度来说,动态类型性能瓶颈很大程度是因为它的动态的类型系统,与静态类型的语言相比,JavaScript 程序需要额外的操作来处理类型的动态性,所以执行效率比较低。」

那么如何确认x,y都是数字,我们又如何优化呢?

基于推测的优化

因为 JavaScript 动态语言的特性,我们通常直到运行时才知道值的确切类型,仅仅观察源代码,往往不可能知道某个操作的可能输入值。所以这就是为什么我们需要推测,根据之前运行收集到的值的反馈,然后假设将来总会看到类似的值。这种方法听起来可能作用相当有限,但它已被证明适用于JavaScript这样的动态语言。

你可能会联想到CPU的分支预测能力,如果是这样,那么恭喜你,你并没有想错。

我们再回到这段代码

代码语言:javascript复制
function add(x, y) {
    return x   y;
}

你可能已经想到了,作为一个动态类型的语言,推测的第一步,就是要收集到足够多的信息,来预测 add 在今后的执行中会遇到的类型。

所以,首先向你介绍反馈向量(Feedback Vector),它是我们执行预测最核心的成员之一:负责储存我们收集到的信息。

反馈向量(Feedback Vector)

当一段代码被初次执行时,它所执行的往往是解释器产生的字节码。当这段字节码每次的执行后,都会会产生一些反馈信息,这些反馈信息会被储存在「反馈向量」(过去叫类型反馈向量) 中,这个特殊的数据结构会被链接在闭包上。如果从对象结构的角度来看,反馈向量和其他相关的内容会是这样。

其中 SharedFunctionInfo,它包含了函数的一般信息,比如源位置,字节码,严格或一般模式。除此之外,还有一个指向上下文的指针,其中包含自由变量的值以及对全局对象的访问。

关于自由变量和约束变量的概念, 闭包 (计算机科学)

反馈向量的大致结构如下,slot是一个槽,表示向量表里面的一项,包含了操作类型和传入的值类型,

IC Slot

IC Type

Value

1

Call

UNINIT

2

BinaryOp

SignedSmall

比如,第二个是一个 BinaryOp 槽,二元操作符类似“ ,-”等能够记录迄今为止看到的输入和输出的反馈。先不用纠结它的含义,后面我们会具体介绍。

如果你想查看你的函数所对应的反馈向量,可以在你的代码中加上专门的内部函数 �bugPrint ,并且在d8中加上命令 –allow-natives-syntax 来检查特定闭包的反馈向量的内容。

源代码:

代码语言:javascript复制
function add(x, y) {
return x   y;
} 
console.log(add(1, 2));
�bugPrint(add);

在d8 使用这个命令 –allow-natives-syntax 运行,我们看到 :

代码语言:javascript复制
$ 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)。反馈向量的长度为1,说明里面只有一个槽,就是我们上面说到的二元操作符槽(BinaryOp Slot),当前反馈为 SignedSmall。

这个反馈SignedSmall代表什么?这表明指令Add只看到了SignedSmall类型的输入,并且直到现在也只产生了SignedSmall类型的输出。

但是什么是SignedSmall类型?JavaScript里面并不存在这种类型。实际上,SignedSmall来自己V8中的一种优化策略,它表示在程序中经常使用的小的有符号整数(V8将高位的32位表示整数,低位的全部置0来表示SignedSmall),这种类型能够获得特殊处理(其他JavaScript引擎也有类似的优化策略)。

「值的表示」

V8通常使用一种叫做指针标记(Pointer Tagging)的技术来表示值,应用这种技术,V8在每个值里面都设置一个标识。我们处理的大部分值都分配在JavaScript堆上,并且由垃圾回收器(GC)来管理。但是对某些值来说,总是将它们分配在内存里开销会很大。尤其是对于小整数,它们通常会用在数组索引和一些临时计算结果。

在V8中存在两种指针标识类型:分别是是Smi(即 Small Integer的缩写)和堆对象( HeapObject,就是JavaScript的引用类型),其中堆对象是分配在内存的堆中,图中的地址即指向堆中的某块地方。

我们用最低有效位来区分堆对象(标志是1)和小整数(标志是0)。对于64位结构上的Smi,至少有32位有效位(低半部)是一直被置为0。另外32位,也就是Word的上半部,是被用来储存32位有符号小整数的值。


仅仅是一次的执行,还不足以让引擎这么快下定决心,相信add 函数随后的执行都是Smi 类型。那么我们先来看看,如果在随后的执行中,我们传入不一样的类型会怎么样。

反馈向量的变化

反馈类型SignedSmall是指所有能用小整数表示的值。对于add操作而言,这意味着目前为止它只能看到输入类型为Smi,并且所产生的输出值也都是Smi(也就是说,所有的值都没有超过32位整数的范围)。下面我们来看看,当我们调用add的时候传入一个不是Smi的值会发生什么。

代码语言:javascript复制
function add(x, y) {
return x   y;
}
console.log(add(1, 2));
console.log(add(1.1, 2.2));
//调用100ci
�bugPrint(add);

在d8加入命令 –allow-natives-syntax ,然后看到下面结果

代码语言:javascript复制
$ 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

首先,我们看到调用次数现在是2,因为运行了两次函数add。然后发现BinaryOp 槽的值现在变成了Number,这表明对于这个加法已经有传入了任意类型的数值(即非整数)。此外,这有一个反馈向量的状态迁移图,大致如下所示:

反馈状态从 None 开始,这表明目前还没有看到任何输入,所以什么都不知道。状态Any表明我们看到了不兼容的(比如number和string)输入和输出的组合。状态Any意味着Add(字节码中的)是多态。相比之下,其余的状态表明Add都是单态(monomorphic),因为看到的输入和产生的都是相同类型的值。下面是图中名词解释:

  • SignedSmall 表示所有的值都是小整数(有效数值为是32位或者31位,取决于Word的在不同架构上的大小),均表示为Smi。
  • Number 表明所有的值都常规数字 (这包括小整数).
  • NumberOrOddball 包括其他能被转换成 Numberundefinednulltruefalse
  • String :所有输入值都是字符串
  • BigInt 表示输入都是大整数。

需要注意一点,反馈只能在这个图中前进(从 NoneAny),不能回退。如果真的那样做,那么我们就会有陷入去优化循环的风险。那样情况下,优化编译器发现输入值与之前得到反馈内容不同,比如之前解释器生成的反馈是 Number,但现在输入值出现了 String,这时候已经生成的反馈和优化代码就会失效,并回退到解释器生成的字节码版本。当下一次函数再次变热(hot,多次运行),我们将再次优化它,如果允许回退,这时候优化编译器会再次生成相同的代码,这意味着会再次回到 Number 的情况。如果这样无限制的回退去优化,再优化,编译器将会忙于优化和去优化,而不是高速运行 JavaScript 代码。

优化管道(The Optimization Pipeline)

现在我们知道了解释器Ignition 是如何为函数add收集反馈,下面来看看优化编译器如何利用反馈生成最小的代码,因为_越小的机器指令代码块,意味着更快的速度_。为了观察,我将使用一个特殊的内部函数OptimizeFunctionOnNextCall()在特定的时间点触发V8对函数的优化。我们经常使用这些内部函数以非常特定的方式对引擎进行测试。

代码语言:javascript复制
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传递两个整数型值来明确call site “x y”的反馈会被预热为小整数(表示_这个call site全部传递的都是小整数,对于优化引擎来说将来得到的输入也会是小整数_),并且结果也是属于小整数范围。然后我们告诉V8应该在下次调用函数add的时候去优化它(用TurboFan ),最终再次调用add,触发优化编译器运行生成机器码。

TurboFan 拿到之前为函数add生成的字节码,并从函数add的反馈向量表里提取出相关的反馈。优化编译器将这些信息转换成一个图表示,再将这个图表示传递给前端,优化以及后端的各个阶段(见上图)。在本文不会详细展开这部分内容,这是另一个系列的内容了。我们要了解的是最终生成的机器码,并看看优化推测是如何工作的。你可以在d8中加上命令 –print-opt-code来查看由TurboFan 生成的优化代码。

这是由TurboFan 在x64架构上生成的机器码,这里省略了一些无关紧要的技术细节(,下面就来看看这些代码做了什么。

代码语言:javascript复制
# 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“。一旦我们知道这段代码仍然有效,就会建立一个栈帧并且检查堆栈上是否有足够的空间来执行代码。

代码语言:javascript复制
# 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的代码(即字节码)。

代码语言:javascript复制
# 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 。

我们现在可以看到生成的代码是高度优化的,并且适用于专门的反馈。它完全不去处理其他数字,字符串,大整数或任意JavaScript对象,只关注目前为止我们所看到的那种类型的值。这是使许多JavaScript应用程序达到最佳性能的关键因素。

为什么需要TypeScript

在上面的介绍中,我们竭力避免了对JavaScript 对象的访问,如果有对象加入,这将会变成一个很复杂的话题。但为了更好的展开这个话题,我们还是需要提一下,关于对象的优化是V8中极其重要的一部分。例如,以下面这个对象为例

代码语言:javascript复制
var o = {
    x: ''
}

var o1 = {
    x: ''
    y
}

//o1. o2

对于像 o.x这样的属性访问,若o始终具有相同的形状(形状同结构,即相同的属性以及属性是相同的顺序,例如o的结构一直是{x:v},其中v的类型是String),我们会把如何获得o.x的过程信息缓存起来,构造成一个隐藏类( Hidden Class)。在随后执行相同的字节码时,不需要再次搜索对象o中x的位置。这种底层实现被称为内联缓存– inline cache (IC)。

你可以在Vyacheslav Egoro写的这篇文章 “What’s up with monomorphism?” 中了解更多关于ICs和属性访问的细节。

总而言之,你现在应该了解到,作为一门弱类型的语言,从最早的SELF和smalltalk 语言开始,研究者就在不断去优化这种弱类型语言的执行效率。

「从执行的角度来说,动态类型性能瓶颈很大程度是因为它的动态的类型系统,与静态类型的语言相比, JavaScript 程序需要额外的操作来处理类型的动态性,所以执行效率比较低。」

说了这么多,最关键的一点

「确定你的代码将要看到的类型很重要」

再加上另外一句话:

「作为动态语言,你的程序可能在90%的时间里,都在处理和代码逻辑无关的事情。即:确认你的代码是什么形状」

从传统的JavaScript 角度来说,

代码语言:javascript复制
function add(x, y) {
    return x   y;
}

你无法很好的保证 add 函数将要看到的类型,哪怕你确实想要这么做。但在一个大型的系统中,维护每一个函数和对象的形状,极其困难。

你可能在前99次都保证了add 看到的都是Smi 类型,但是在第100次,add 看到了一个String,而在这之前,优化编辑器,即TurboFan,已经大胆的推测了你的函数只会看到Smi,那么这时候

Ops!

优化编辑器将不得不认为自己做了个错误的预测,它会立即把之前的优化丢掉。从字节码开始重新执行。

而如果你的代码一直陷入优化<->去优化的怪圈,那么程序执行将会变慢,慢到还不如不优化。

大多数的浏览器都做了限制,当优化/去优化循环发生的时候会尝试跳出这种循环。比如,如果 JIT 做了 10 次以上的优化并且又丢弃的操作,那么就不继续尝试去优化这段代码。

所以,到这里你应该明白了,有两点准则:

  1. 「确保你的代码是什么形状很重要」

但比第一条更重要的是:

  1. 「确保你的代码固定在某个形状上」

而编写TypeScript ,从工程和语言的层面上帮助你解决了这两个准则,你可以畅快的使用TypeScript,而无需担心你是否不小心违背了上面两条准则。

推荐阅读

  • https://en.wikipedia.org/wiki/Inline_caching
  • https://v8.dev/blog/v8-lite
  • https://v8project.blogspot.com/2017/10/lazy-unlinking.html
  • https://v8.dev/docs/d8
  • https://mrale.ph/blog/2012/06/03/explaining-js-vms-in-js-inline-caches.html
  • https://bibliography.selflanguage.org/_static/pics.pdf
  • https://benediktmeurer.de/2017/12/13/an-introduction-to-speculative-optimization-in-v8/

0 人点赞