You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

22 KiB

21 | JavaScript编译器V8的解释器和优化编译器

你好我是宫文学。通过前一讲的学习我们已经了解了V8的整个编译过程并重点探讨了一个问题就是**V8的编译速度为什么那么快**。

V8把解析过程做了重点的优化解析完毕以后就可以马上通过Ignition解释执行了。这就让JavaScript可以快速运行起来。

今天这一讲呢,我们重点来讨论一下,V8的运行速度为什么也这么快一起来看看V8所采用的优化技术。

上一讲我也提及过V8在2008年刚推出的时候它提供了一个快速编译成机器码的编译器虽然没做太多优化但性能已经是当时其他JavaScript引擎的10倍了。而现在V8的速度又是2008年刚发布时候的10倍。那么是什么技术造成了这么大的性能优化的呢

这其中,**一方面原因是TurboFan这个优化编译器采用了很多的优化技术。**那么它采用了什么优化算法采用了什么IR其优化思路跟Java的JIT编译器有什么相同点和不同点

**另一方面最新的Ignition解释器虽然只是做解释执行的功能但竟然也比一个基础的编译器生成的代码慢不了多少。**这又是什么原因呢?

所以今天我们就一起把这些问题都搞清楚这样你就能全面了解V8所采用的编译技术的特点了你对动态类型语言的编译也就能有更深入的了解并且这也有助于你编写更高效的JavaScript程序。

首先我们来了解一下TurboFan的优化编译技术。

TurboFan的优化编译技术

TurboFan是一个优化编译器。不过它跟Java的优化编译器要完成的任务是不太相同的。因为JavaScript是动态类型的语言所以如果它能够推断出准确的类型再来做优化就会带来巨大的性能提升。

同时TurboFan也会像Java的JIT编译器那样基于IR来运行各种优化算法以及在后端做指令选择、寄存器分配等优化。所有的这些因素加起来才使得TurboFan能达到很高的性能。

我们先来看看V8最特别的优化也就是通过对类型的推理所做的优化。

基于推理的优化Speculative Optimazition

对于基于推理的优化,我们其实并不陌生。在研究Java的JIT编译器你就发现了Graal会针对解释器收集的一些信息对于代码做一些推断从而做一些激进的优化比如说会跳过一些不必要的程序分支。

而JavaScript是动态类型的语言所以对于V8来说最重要的优化就是能够在运行时正确地做出类型推断。举个例子来说假设示例函数中的add函数在解释器里多次执行的时候接受的参数都是整型那么TurboFan就处理整型加法运算的代码就行了。这也就是上一讲中我们生成的汇编代码。

function add(a,b){
    return a+b;
}

for (i = 0; i<100000; i++){
    if (i%1000==0)
        console.log(i);
    
    add(i, i+1);
}

但是,如果不在解释器里执行直接要求TurboFan做编译会生成什么样的汇编代码呢

你可以在运行d8的时候加上“always-opt”参数这样V8在第一次遇到add函数的时候就会编译成机器码。

./d8 --trace-opt-verbose \
    --trace-turbo \
    --turbo-filter=add \
    --print-code \
    --print-opt-code \
    --code-comments \
    --always-opt \
    add.js

这一次生成的汇编代码跟上一讲生成的就不一样了。由于编译器不知道add函数的参数是什么类型的所以实际上编译器是去调用实现Add指令的内置函数来生成了汇编代码。

这个内置函数当然支持所有加法操作的语义,但是它也就无法启动基于推理的优化机制了。这样的代码,跟解释器直接解释执行,性能上没太大的差别,因为它们本质上都是调用一个全功能的内置函数。

而推理式优化的版本则不同,它直接生成了针对整型数字进行处理的汇编代码:

我来给你解释一下这几行指令的意思:

  • 第1行和第3行是把参数1和参数2分别拷贝到r8和r9寄存器。注意,这里是从物理寄存器里取值,而不是像前一个版本一样,在栈里取值。前一个版本遵循的是更加保守和安全的调用约定。
  • 第2行和第4行是把r8和r9寄存器的值向右移1位。
  • 第5行是把r8和r9相加。

看到这里,你可能就发现了一个问题:**只是做个简单的加法而已,为什么要做移位操作呢?**实际上,如果你熟悉汇编语言的话,要想实现上面的功能,其实只需要下面这两行代码就可以了:

movq rax, rdi   #把参数1拷贝到rax寄存器
addq rax, rcx   #把参数2加到rax寄存器上作为返回值

那么,多出来的移位操作是干什么的呢?

这就涉及到了V8的内存管理机制。原来V8对象都保存在堆中。在栈帧中保存的数值都是指向堆的指针。垃圾收集器可以通过这些指针知道哪些内存对象是不再被使用的从而把它们释放掉。我们前面学过Java的虚拟机和Python对于对象引用本质上也是这么处理的。

但是这种机制对于基础数据类型比如整型就不太合适了。因为你没有必要为一个简单的整型数据在堆中申请内存这样既浪费内存又降低了访问效率V8需要访问两次内存才能读到一个整型变量的值第一次读到地址第二次根据该地址到堆里读到值。你记得Python就是这么访问基础数据的。

V8显然不能忍受这种低效的方式。它采用的优化机制是一种被广泛采用的技术叫做标记指针Tagged Pointer或者标记值Tagged Value。《Pointer Compression in V8》这篇文章就介绍了V8中采用Tagged Pointer技术的细节。

比如说对于一个32位的指针值它的最低位是标记位。当标记位是0的时候前31位是一个短整数简写为Smi而当标记位是1的时候那么前31位是一个地址。这样V8就可以用指针来直接保存一个整数值用于计算从而降低了内存占用并提高了运行效率。

图1用标记指针技术来表示一个整数

好了现在你已经理解了V8的推理式编译的机制。那么还有什么手段能提升代码的性能呢

当然还有基于IR的各种优化算法。

IR和优化算法

在讲Java的JIT编译器时我就提过V8做优化编译时采用的IR也是基于Sea of Nodes的。

你可以回忆一下Sea of Nodes的特点合并了数据流图与控制流图是SSA形式没有把语句划分成基本块。

它的重要优点就是优化算法可以自由调整语句的顺序只要不破坏数据流图中的依赖关系。在Sea of Nodes中没有变量有时也叫做寄存器的概念只有一个个数据节点所以对于死代码删除等优化方法来说它也具备天然的优势。

说了这么多那么要如何查看TurboFan的IR呢一个好消息是V8也像GraalVm一样提供了一个图形化的工具来查看TurboFan的IR。这个工具是turbolizer它位于V8源代码的tools/turbolizer目录下。你可以按照该目录下的README.md文档构建一下该工具并运行它。

python -m SimpleHTTPServer 8000

它实际启动了一个简单的Web服务。你可以在浏览器中输入“0.0.0.0:8000”打开turbolizer的界面。

在运行d8的时候如果带上参数“trace-turbo”就会在当前目录下输出一个.json文件打开这个文件就能显示出TurboFan的IR来。比如上一讲的示例程序add.js所显示出的add函数的IR

图2在turbolizer中显示add函数的IR

界面中最左边一栏是源代码中间一栏是IR最右边一栏是生成的汇编代码。

上图中的IR只显示了控制节点。你可以在工具栏中找到一个按钮把所有节点都呈现出来。在左侧的Info标签页中还有一些命令的快捷键你最好熟悉一下它们以便于控制IR都显示哪些节点。这对于一个大的IR图来说很重要否则你会看得眼花缭乱

图3完整展开的add函数的IR

在这个图中,不同类型的节点用了不同的颜色来区分:

  • 黄色控制流的节点比如Start、End和Return
  • 淡蓝色:代表一个值的节点;
  • 红色JavaScript层级的操作比如JSEqual、JSToBoolean等
  • 深蓝色代表一种中间层次的操作介于JavaScript层和机器层级中间
  • 绿色:机器级别的语言,代表一些比较低层级的操作。

在turbolizer的界面上还有一个下拉菜单里面有多个优化步骤。你可以挨个点击看看经过不同的优化步骤以后IR的变化是什么样子的。

图4TurboFan对IR的处理步骤

你可以看到在第一步“v8.TFBytecodeGraphBuilder”阶段显示的IR中它显示的节点还是有点儿多。我们先隐藏掉与计算功能无关的节点得到了下面的主干。你要注意其中的绿色节点这里已经进行了类型推测因此它采用了一个整型计算节点SpeculativeSafeIntegerAdd。

这个节点的功能是:当两个参数都是整数的时候,那就符合类型推断,做整数加法操作;如果不符合类型推断,那么就执行逆优化。

图5v8.TFBytecodeGraphBuilder”阶段的IR

你可以再去点击其他的优化步骤,看看图形会有什么变化。

在v8.TFGenericLowering阶段我们得到了如下所示的IR图这个图只保留了计算过程的主干。里面增加了两个绿色节点这两个节点就是把标记指针转换成整数的还增加了一个深蓝色的节点这个节点是在函数返回之前把整数再转换成标记指针。

图6v8.TFGenericLowering阶段的IR

在v8.TFLateGraphTrimming阶段图中的节点增加了更多的细节它更接近于具体CPU架构的汇编代码。比如我们把前面图6中的标记指针转换成32位整数的操作就变成了两个节点

  • TruncateInt64ToInt32把64位整型截短为32位整型
  • Word32Sar32位整数的移位运算用于把标记指针转换为整数。

图7v8.TFLateGraphTrimming阶段的IR

这三个阶段就形象地展示出了TurboFan的IR是如何Lower的从比较抽象的、机器无关的节点逐步变成了与具体架构相关的操作。

所以基本上IR的节点可以分为四类顶层代表复杂操作的JavaScript节点、底层代表简单操作的机器节点、处于二者之间做了一定简化的节点以及可以被各个层次共享的节点。

刚才我们对V8做优化编译时所采用的IR的分析只关注了与加法计算有关的主干节点。你还可以用同样的方法来看看其他的节点。这些节点主要是针对异常情况的处理。比如如果发现参数类型不是整型那么就要去执行逆优化。

在做完了所有的优化之后,编译器就会进入指令排序、寄存器分配等步骤,最后生成机器码。

现在你就了解了TurboFan是如何借助Sea of Nodes来做优化和Lower的了。但我们还没有涉及具体的优化算法。那么什么优化算法会帮助V8提升性能呢

前面在研究Java的JIT编译器的时候我们重点关注了内联和逃逸分析的优化算法。那么对于JavaScript来说这两种优化也同样非常重要一样能带来巨大的优化效果。

我们先来看看内联优化。对于之前的示例程序由于我们使用了“turbo-filter=add”选项来运行代码因此TurboFan只会编译add方法这就避免了顶层函数把add函数给内联进去。而如果你去掉了这个选项就会发现TurboFan在编译完毕以后程序后面的运行速度会大大加快一闪而过。这是因为整个顶层函数都被优化编译了并且在这个过程中把add函数给内联进去了。

然后再说说逃逸分析。V8运用逃逸分析算法也可以像Java的编译器一样把从堆中申请的内存优化为从栈中申请甚至使用寄存器从而提升性能并避免垃圾收集带来的消耗。

不过JavaScript和Java的对象体系设计毕竟是不一样的。在Java的类里每个成员变量相对于对象指针的偏移量都是固定的而JavaScript则在内部用了隐藏类来表示对象的内存布局。这也引出V8的另一个有特色的优化主题内联缓存

那接下来我就带你详细了解一下V8的隐藏类和内联缓存机制。

隐藏类Shapes和内联缓存Inline Caching

隐藏类学术上一般叫做Hidden Class但不同的编译器的叫法也不一样比如Shapes、Maps等等。

**隐藏类有什么用呢?**你应该知道在JavaScript中你不需要先声明一个类才能创建一个对象。你可以随时创建对象比如下面的示例程序中就创建了几个表示坐标点的对象

point1 = {x:2, y:3};
point2 = {x:4, y:5};
point3 = {y:7, x:6};
point4 = {x:8, y:9, z:10};

那么V8在内部是怎么来存储x和y这些对象属性的呢

如果按照Java的内存布局方案一定是在对象头后面依次存放x和y的值而如果按照Python的方案那就需要用一个字典来保存不同属性的值。但显然用类似Java的方案更加节省内存访问速度也更快。

所以V8内部就采用了隐藏类的设计。如果两个对象有着相同的属性并且顺序也相同那么它们就对应相同的隐藏类。

在上面的程序中point1和point2就对应着同一个隐藏类而point3和point4要么是属性的顺序不同要么是属性的数量不同对应着另外的隐藏类。

所以在这里,你就会得到一个编写高性能程序的要点对于相同类型的对象一定要保证它们的属性数量和顺序完全一致这样才会被V8当作相同的类型从而那些基于类型推断的优化才会发挥作用。

此外V8还用到了一种叫做内联缓存的技术,来加快对象属性的访问时间。它的原理是这样的当V8第一次访问某个隐藏类的属性的时候它要到隐藏类里去查找某个属性的地址相对于对象指针的偏移量。但V8会把这个偏移量缓存下来这样下一次遇到同样的shape的时候直接使用这个缓存的偏移量就行了。

比如下面的示例代码如果对象o是上面的point1或point2属性x的地址偏移量就是相同的因为它们对应的隐藏类是一样的

function getX(o){
  return o.x;
}

有了内联优化技术那么V8只有在第一次访问某个隐藏类的属性时速度会慢一点之后的访问效率就跟Java的差不多了。因为Java这样的静态类型的代码在编译期就可以确定每个属性相对于对象地址的偏移量。

现在你已经了解了TurboFan做优化的一些关键思路。接下来我们再返回来重新探讨一下Ignition的运行速度问题。

提升Ignition的速度

最新版本的V8已经不需要多级的编译器了只需要一个解释器Ignition和一个优化编译器TurboFan就行。在公开的测试数据中Ignition的运行速度已经接近一个基线编译器生成的机器码的速度了也就是那种没有做太多优化的机器码。

这听上去似乎不符合常理,毕竟,解释执行怎么能赶得上运行机器码呢?所以,这里一定有一些值得探究的技术原理。

让我们再来看看Ignition解释执行的原理吧。

在上一讲中你已经了解到V8的字节码是很精简的。比如对于各种加法操作它只有一个Add指令。

但是我们知道Add指令的语义是很复杂的比如在ECMAScript标准中就对加法的语义有很多的规定如数字相加该怎么做、字符串连接该怎么做等等。

图8ECMAScript标准中加法操作的语义规则

这样的话,V8在解释执行Add指令的时候就要跳到一个内置的函数去执行其他指令也是如此。这些内置函数的实现质量就会大大影响解释器的运行速度。

那么如果换做你,你会怎么实现这些内置函数呢?

选择1用汇编语言去实现。这样我们可以针对每种情况写出最优化的代码。但问题是这样做的工作量很大。因为V8现在已经支持了9种架构的CPU而要为每种架构编写这些内置功能都需要敲几万行的汇编代码。

选择2用C++去实现。这是一个不错的选择因为C++代码最后编译的结果也是很优化的。不过这里也有一个问题C++有它自己的调用约定跟V8中JavaScript的调用约定是不同的。

比如在调用C++的内置函数之前解释器要把自己所使用的物理寄存器保护起来避免被C++程序破坏在调用完毕以后还要恢复。这使得从解释器到内置函数以及从内置函数回到解释器都要做不少的转换工作。你还要写专门的代码来对标记指针进行转换。而如果要使用V8的对象那要处理的事情就更多了比如它要去隐藏类中查找信息以及能否通过优化实现栈上内存分配等等。

那么,我们还有别的选择吗?

有的。你看V8已经有了一个不错的优化编译器TurboFan。**既然它能产生很高效的代码那么我们为什么不直接用TurboFan来生成机器码呢**这个思路其实是可行的。这可以看做是V8编译器的一种自举能力用自己的编译器来生成自己内部要使用的内置函数的目标代码。

毕竟TurboFan本来就是要处理标记指针、JavaScript对象的内存表示等这些问题。这个方案还省去了做调用约定的转换的工作因为本来V8执行的过程中就要不断在解释执行和运行机器码之间切换V8内部对栈桢和调用约定的设计就是要确保这种切换的代价最低。

在具体实现的时候编写这些内置函数是用JavaScript调用TurboFan提供的一些宏。这些宏可以转化为TurboFan的IR节点从而跟TurboFan的优化编译功能无缝衔接。

好了分析到这里你就知道为什么Ignition的运行速度会这么快了**它采用了高度优化过的内置函数的实现,并且没有调用约定转换的负担。**而一个基线编译器生成的机器码,因为没有经过充分的优化,反倒并没有那么大的优势。

再补充一点V8对字节码也提供了一些优化算法。比如通过优化可以减少对临时变量的使用使得代码可以更多地让累加器起到临时变量的作用从而减少内存访问次数提高运行效率。如果你有兴趣在这个话题上去做深入研究可以参考我在文末链接中给出的一篇论文。

课程小结

本讲我围绕运行速度这个主题给你讲解了V8在TurboFan和Ignition上所采用的优化技术。你需要记住以下几个要点

  • 第一由于JavaScript是动态类型的语言所以优化的要点就是推断出函数参数的类型并形成有针对性的优化代码。
  • 第二同Graal一样V8也使用了Sea of Nodes的IR而且对V8来说内联和逃逸优化算法仍然非常重要。我在解析Graal编译器的时候已经给你介绍过了,所以这一讲并没有详细展开,你可以自己去回顾复习一下。
  • 第三V8所采用的内联缓存技术能够在运行期提高对象属性访问的性能。另外你要注意的是在编写代码的时候一定要避免对于相同的对象生成不同的隐藏类。
  • 第四Ignition采用了TurboFan来编译内置函数这种技术非常聪明既省了工作量又简化了系统的结构。实际上在Graal编译器里也有类似的技术它叫做Snippet也是用自身的中后端功能来编译内置函数。所以你会再次发现多个编译器之间所采用的编译技术是可以互相印证的。

这节课的思维导图我同样帮你整理出来了,供你参考和复习:

一课一思

我们已经学了两种动态类型的语言的编译技术Python和JavaScript。那我现在问一个开脑洞的问题如果你要给Python加一个JIT编译器那么你可以从JavaScript这里借鉴哪些技术呢在哪些方面编译器会得到巨大的性能提升

参考资料

  1. V8的指针压缩技术Pointer Compression in V8
  2. 介绍V8基于推理的优化机制An Introduction to Speculative Optimization in V8
  3. 对Ignition字节码做优化的论文Register equivalence optimization我在GitHub上也放了一份拷贝