Skip to content

V8之FullCompiler

全局架构

V8将所有javascript代码编译为原生代码执行,其中没有任何的解释器以及字节码参与。编译以函数为单位,一次编译一个(这与FireFox VM原有的TraceMonkey引擎相反,TraceMonkey为追踪式编译,并不以函数为单位)。通常,函数在初次调用之前是不会背编译的,因此如果你引用了一个大型的脚本,VM并不会花大量的时间去编译那些根本没有用到的部分

V8实际上有2个不同的javascript编译器。我个人喜欢将其看作一个简单编译器及一个辅助编译器(机制简单的编译器,后者指使用频度低的编译器)。Full Compiler(对应简单编译器)是一个不含优化的编译器,其工作就是尽快生成原生代码,以保持页面始终快速运转,Crankshaft(对应辅助编译器)则是一个带有优化能力的编译器。V8会将任何初次遇到的代码使用FC编译,之后再使用内置的性能分析器挑选频度高的函数,使用Crankshaft优化。由于V8基本上是单线程的,任何一个编译器运行时,都会打断脚本的执行。在V8未来的版本中,Crankshaft( 或者其中一部分)将会在一个单独的线程中运行,与JavaScript的执行并发,以便进行更多昂贵的优化。

为何没有字节码

大多出VM都有一个字节码解释器,但V8却没有。你可能会好奇为何原本应当先编译为字节码再执行的过程,被FC替换掉了,原因是,编译为原生代码并不会比编译为字节码耗去太多。考虑如下两个过程

字节码编译:原生代码编译
语法分析(解析)语法分析(解析)
作用域分析作用域分析
将语法树转换为字节码将语法树转换为原生代码

在上述两个过程中,我们都需要解析源码以及生成抽象语法树(AST), 我们都需要进行作用域分析,以便得出每个符号所代码的是局部变量,上下文变量(闭包相关)或全局属性。唯独转换的过程是不同的。你可以在这一步做一些非常细致的工作,但你也同事希望编译器越快越好,甚至很想来一个“直译”:语法树的每个节点都转化为一串相应的字节码或原生代码指令(汇编指令)

现在思考一下你会如何去做一个字节码解释器。一个朴素的实现可能就是一个循环,其中会不端获取字节码,然后进入一个大的switch语句,逐一执行其事先准备好的指令。有些途径对这个过程进行改进,但最终还会落到相近的结构上。

如果我们此时不去生成字节码、使用解释器的那个循环,而是直接触发相应的原生代码呢?无需如果,V8的FC就是这样做的,这样做便不再需要解释器,并大大简化了未优化代码域优化代码之前的切换

一般来说,字节码发挥用武之地的最佳时机,是编译器有充分的准备时间的时候。但这并不是浏览器中所能允许的,因此FC对于V8来说更加应景。

内联缓存:加速未优化代码

如果你看过ECMAScript标准,你会发现其中有很多操作异常复杂。以+操作来说,如果操作都为数字,则它演绎为加法;如果其中一个操作数是字符串,则它演绎为字符串拼接;如果操作数不是数字也不是字符串,其将经过某些复杂的(可能是用户定义的)过程,转化为原语(指JavaScript的数字,字符串、布尔、undefined以及null),最终为数字加减法或字符串拼接。仅仅是查看脚本源码,我们无法得知那种操作最终应当执行。属性的读取(比如 o.x)是另一个潜在复杂操作的例子。只通过源码,你将无法得知你要的是读取一个对象自己的属性(对象本身具有的属性),还是原型对象的属性(来自原型链上的原型的属性),还是一个getter方法,亦或是浏览器的某些自定义回调。这个属性还可能根本不存在。如果你要在FC编译的代码中处理所有这些情况,及时一个简单的操作也会引发上百条指令。

内联缓存(inline caches, ICS)提供了一个优雅的方案来解决这个问题。内联缓存大致就是一个包含多种可能的实现(通常运行时生成)来处理某个操作函数(提供了多个处理问题的方案,这些方案的性能由优至次,)我之前曾写过函数的多态内联缓存的文章。V8使用IC处理了大量的操作: FC使用IC来实现读取、存储、函数调用、二元运算符、一元运算符、比较运算符以及ToBoolean隐操作符。

IC的实现称为Stub。Stub在使用层面上像函数:调用、返回。但它不必初始化一个调用栈来完成调用约定。Stub常常在运行时动态生成,但在通常情况下都可被缓存,并被多个IC重用。Stub一般会含有已优化的代码,来处理某个IC之前所碰到的特定类型操作。一旦Stub碰到了优化代码无法解决的操作,它会调用C++运行时代码来处理。运行时代码处理这个操作后,会生成一个新的Stub,包含解决这个操作方案(当然也包含之前的其他解决方案)。对原有Stub的调用随即变为了新Stub的调用,脚本的执行也将继续进行,变得和Stub正常的调用流程一样。

我们来看一段简单的例子,读取属性

js
function f(o) {
    return o.x;
}

当FC初次生成代码时,它会使用一个IC来演绎这个读取,IC以uninitialized状态(初态)初始,调用一个不包含任何优化代码的简易Stub。下面是FC生成的调用Stub的代码:

text
;; FC调用

ldr r0, [fp, #+8] ;从栈中读取参数“o”
ldr r2,[pc, #+84] ; 从固定位置读取“x”
ldr ip, [pc, #+84] ; 从固定位置载入uninitialized态的sub
blx ip。           ;  调用stub
...
dd 0xabcdef01      ; 上面拿到的stub地址
                    ; 当stub出现处理不了的操作时,这里的stub会被换成新的sub

这是处于uninitialized 态的stub

text
;; uninitialized stub
ldr ip, [pc, +8] ;读取C++运行时的函数来处理
bx ip; 尾调
...

当stub第一次被调用时,stub注定无法处理它所面对的操作,运行时代码会替换stub来解决。在V8中,最常见的存储属性的方法就是将其放在对象中一个固定偏移量的地方,我们以此为例。每个对象都有一个指向Map的指针,也即一个描述对象布局的一个不变结构。负责读取对象自身属性的stub会将对象的布局图与已知的Map(也就是运行时所生成的map)相比较,来快速确定对象是否在相应的位置存放着该属性。这个Map的检查使我们能够避开一次麻烦的Hash表查询

text
;; monomorphic 态的对象自身读取stub
tst r0, #1 ; 检查目标是否时一个对象
beq miss; 不是就说明处理不了
ldr r1, [r0, #-1]; 读取对象的Map
ldr ip,[pc, #+24]; 读取已知的Map
cmp r1,ip; 他们相同否?
bne miss ; 不同说明处理不了
ldr r0,[r0, #+11] ; 读取属性
bx lr; 返回
miss:
ldr ip,[pc, #+8]; 调用C++运行时来解决
bx ip; 尾调

译注:V8中对32bits长的值做了进一步分类,其中最低位作为区分,如果为0则表示该值为31bits长的整数;如果为1则表示该值为30bits长的指针。由于V8中的对象以4Bytes为单位对齐,指针的最低2位恰好空闲。

只要该表达式只负责对区对象自身的属性,则读取可以无附加的快速完成。由于IC只处理了一种情况,它处于monomorphic态(单态)。如果在后续的运行中,这个IC又遇到了无法处理的情况,则更加常见的megamorphic态(复态)stub会被生成

待续

如上所述,FC圆满完成了它快速生成优质代码的任务。由于IC易于扩展的特点,FC生成的代码也非常通用,这使得FC非常简单;而IC则使代码非常灵活,能够处理任何情况

在接下来的额文章中,我们将看到V8内部如何表达JavaScript对象,来做到在大多数场景下以O(1)的时间访问这些程序员为做任何结构定义工作(类似于类定义)的对象。

https://jayconrod.com/posts/52/a-tour-of-v8--object-representationhttp://newhtml.net/v8-crankshaft-the-optimizing-compiler/