V8编译器

V8 这个词,原意是 8 缸的发动机,换算成排量,大约是 4.0 排量,属于相当强劲的发动机了。

它的编译器,叫做 Ignition,是点火装置的意思。

而它最新的 JIT 编译器,叫做 TurboFan,是涡轮风扇发动机的意思。

2008 年 V8 发布时,就已经比当时的竞争对手快 10 倍了;到目前,它的速度又已经提升了 10 倍以上。从中你可以看到,编译技术有多大的潜力可挖掘!

 

对 JavaScript 编译器来说,它最大的挑战就在于,当我们打开一个页面的时候,源代码的下载、解析(Parse)、编译(Compile)和执行,都要在很短的时间内完成,否则就会影响到用户的体验。

https://v8.dev/docs/build


tools/dev/gm.py x64.debug

 

v8/src 目录下的,就是 V8 的源代码了。V8 是用 C++ 编写的

 

 

 

流处理节点(Stream)和预解析器(PreParser),这是 V8 编译过程中比较有特色的两个处理阶段。

 这是比较新的 V8 版本的架构。在更早的版本里,有时会用到两个 JIT 编译器,类似于 HotSpot 的 C1 和 C2,分别强调编译速度和优化效果。在更早的版本里,还没有字节码解释器。现在的架构,引入了字节码解释器,其速度够快,所以就取消了其中一级的 JIT 编译器。

 

超级快的解析过程(词法分析和语法分析)

V8 解析源代码的速度必须要非常快才行。源代码边下载边解析完毕,在这个过程中,用户几乎感觉不到停顿。

 

V8 解析速度快的原因:

第一个原因,是 V8 的整个解析过程是流(Stream)化的,也就是一边从网络下载源代码,一边解析。在下载后,各种不同的编码还被统一转化为 UTF-16 编码单位,这样词法解析器就不需要处理多种编码了。

 

第二个原因,是识别标识符时所做的优化,这也让 V8 的解析速度更快了一点。你应该知道,标识符的第一个字符(ID_START)只允许用字母、下划线和 $ 来表示,而之后的字符(ID_CONTINUE)还可以包括数字。所以,当词法解析器遇到一个字符的时候,我们首先要判断它是否是合法的 ID_START。

 

这样写,不完美
if(ch >= 'A' && ch <= 'Z' || ch >='a' && ch<='z' || ch == '$' || ch == '_'){
    return true;
}

V8 的作者们认为这太奢侈了。所以他们通过查表的方法,来识别每个 ASCII 字符是否是合法的标识符开头字符。

 

这相当于准备了一张大表,每个字符在里面对应一个位置,标明了该字符是否是合法的标识符开头字符。

这是典型的牺牲空间来换效率的方法。

虽然你在阅读代码的时候,会发现它调用了几层函数来实现这个功能,但这些函数其实是内联的,并且在编译优化以后,产生的指令要少很多,所以这个方法的性能更高。

 

第三个原因,是如何从标识符中挑出关键字。

 

与 Java 的编译器一样,JavaScript 的 Scanner,也是把标识符和关键字一起识别出来,然后再从中挑出关键字。所以,你可以认为这是一个最佳实践。那你应该也会想到,识别一个字符串是否是关键字的过程,使用的方法仍然是查表。查表用的技术是“完美哈希(perfect hashing)”,也就是每个关键字对应的哈希值都是不同的,不会发生碰撞。并且,计算哈希值只用了三个元素:前两个字符(ID_START、ID_CONTINUE),以及字符串的长度,不需要把每个字符都考虑进来,进一步降低了计算量。

https://v8.dev/blog/scanner

还有其他细节,比如通过缩窄对 Unicode 字符的处理范围来进行优化,等等。从中你能体会到 V8 的作者们在提升性能方面,无所不用其极的设计思路。

 

 

在语法分析方面,V8 也做了很多的优化来保证高性能。其中,最重要的是“懒解析”技术。

https://v8.dev/blog/preparser

一个页面中包含的代码,并不会马上被程序用到。如果在一开头就把它们全部解析成 AST 并编译成字节码,就会产生很多开销:占用了太多 CPU 时间;过早地占用内存;编译后的代码缓存到硬盘上,导致磁盘 IO 的时间很长,等等。

 

所以,所有浏览器中的 JavaScript 编译器,都采用了懒解析技术。在 V8 里,首先由预解析器,也就是 Preparser 粗略地解析一遍程序,在正式运行某个函数的时候,编译器才会按需解析这个函数。你要注意,Preparser 只检查语法的正确性,而基于上下文的检查则不是这个阶段的任务。

 

测试一下懒解析和完整解析的区别
function add(a,b){
    return a + b;
}

//add(1,2)    //一开始,先不调用add函数

 

./d8 – ast-print foo.js

 

面有一个没有名字的函数(也就是程序的顶层函数),并且它记录了一个 add 函数的声明,仅此而已。你可以看到,Preparser 的解析结果确实够粗略。

 

而如果你把 foo.js 中最后一行的注释去掉,调用一下 add 函数,再次让 d8 运行一下 foo.js,就会输出完整解析后的 AST,你可以看看二者相差有多大:

可以去看看正式的 Parser(在 parser.h、parser-base.h、parser.cc 代码中)

打开一看,你就能知道,这又是用手写的递归下降算法实现的。

 

看算法的过程中,我一般第一个就会去看它是如何处理二元表达式的。因为二元表达式看上去很简单,但它需要解决一系列难题,包括左递归、优先级和结合性。

V8 的 Parser 中,对于二元表达式的处理,采取的也是一种很常见的算法:操作符优先级解析器(Operator-precedence parser)。这跟 Java 的 Parser 也很像,它本质上是自底向上的一个 LR(1) 算法。所以我们可以得出结论,在手写语法解析器的时候,遇到二元表达式,采用操作符优先级的方法,算是最佳实践了!

 

编译成字节码

执行刚才的 foo.js 文件时,加上“–print-bytecode”参数,就能打印出生成的字节码了。

 

怎么理解这几行字节码呢?我来给你解释一下:Ldar a1:把参数 1 从寄存器加载到累加器(Ld=load,a=accumulator, r=register)。Add a0, [0]:把参数 0 加到累加器上。Return:返回(返回值在累加器上)。

这些字节码是由 Ignition 来解释执行的。

Ignition 是一个基于寄存器的解释器。它把函数的参数、变量等保存在寄存器里。不过,这里的寄存器并不是物理寄存器,而是指栈帧中的一个位置。

 

 这个栈帧里包含了执行函数所需要的所有信息:参数和本地变量。临时变量:它是在计算表达式的时候会用到的。比如,计算 2+3+4 的时候,就需要引入一个临时变量。上下文:用来在函数闭包之间维护状态。pc:调用者的代码地址。栈帧里的 a0、a1、r0、r1 这些都是寄存器的名称,可以在指令里引用。而在字节码里,会用一个操作数的值代替。整个栈帧的长度是在编译成字节码的时候就计算好了的。这就让 Ignition 的栈帧能适应不同架构对栈帧对齐的要求。比如 AMD64 架构的 CPU,它就要求栈帧是 16 位对齐的。

 

Ignition 也用到了一些物理寄存器,来提高运算的性能:累加器:在做算术运算的时候,一定会用到累加器作为指令的其中一个操作数,所以它就不用在指令里体现了;指令里只要指定另一个操作数(寄存器)就行了。字节码数组寄存器:指向当前正在解释执行的字节码数组开头的指针。字节码偏移量寄存器:当前正在执行的指令,在字节码数组中的偏移量(与 pc 寄存器的作用一样)。…

Ignition 会有什么特点呢

它在指令里会引用寄存器作为操作数,寄存器在进入函数时就被分配了存储位置,在函数运行时,栈帧的结构是不变的。而对比起来,栈机的指令从操作数栈里获取操作数,操作数栈随着函数的执行会动态伸缩。Ignition 还引入了累加器这个物理寄存器作为缺省的操作数。这样既降低了指令的长度,又能够加快执行速度。

 

当然,Ignition 没有像生成机器码那样,用一个寄存器分配算法,让本地变量、参数等也都尽量采用物理寄存器。这样做的原因,一方面是因为,寄存器分配算法会增加编译的时间;另一方面,这样不利于代码在解释器和 TurboFan 生成的机器代码之间来回切换(因为它要在调用约定之间做转换)。采用固定格式的栈帧,Ignition 就能够在从机器代码切换回来的时候,很容易地设置正确的解释器栈帧状态。我把更多的字节码指令列在了下面,你可以仔细看一看 Ignition 都有哪些指令,从而加深对 Ignition 解释运行机制的理解。

 

编译成机器码


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

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

 

 

 

可以用下面的命令,要求 V8 打印出优化过程、优化后的汇编代码、注释等信息。其中,“–turbo-filter=add”参数会告诉 V8,只优化 add 函数,否则的话,V8 会把 add 函数内联到外层函数中去

 


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

你用./d8 --help,就能列出 V8 可以使用的各种选项及其说明,我把上面几个选项的含义解释一下。
–trace-opt-verbose:跟踪优化过程,并输出详细信息
–trace-turbo:跟踪 TurboFan 的运行过程
–print-code:打印生成的代码
–print-opt-code:打印优化的代码
–code-comment:在汇编代码里输出注释

 

程序一开头是解释执行的。在循环了 24000 次以后,V8 认为这是热点代码,于是启动了 Turbofan 做即时编译。最后生成的汇编代码有好几十条指令。不过你可以看到,大部分指令是用于初始化栈帧,以及处理逆优化的情况。真正用于计算的指令,是下面几行指令:

 

总结:

首先,是编译速度。由于 JavaScript 是在浏览器下载完页面后马上编译并执行,它对编译速度有更高的要求。因此,V8 使用了一边下载一边编译的技术:懒解析技术。并且,在解析阶段,V8 也比其他编译器更加关注处理速度,你可以从中学到通过查表减少计算量的技术。其次,我们认识了一种新的解释器 Ignition,它是基于寄存器的解释器,或者叫寄存器机。Ignition 比起栈机来,更有性能优势。最后,我们初步使用了一下 V8 的即时编译器 TurboFan。

 

 

  • 0
    点赞
  • 3
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值