目前,JavaScript的实现已经成为编译领域的一个重要组成部分。主要包括Google的V8, Mozilla的SpiderMonkey, 还有Webkit使用的JavaScriptCore。
jsc:WebKit的JS引擎( js for webkit)
JavaScriptCore (JSC)正是WebKit的JavaScript实现。
起初,JavaScriptCore是一个基于的树的简单解释器(tree-based interpreter). 但在2008年6月,几位Apple的牛人为JSC重新写一个编译器(compiler)和一个字节码解释器(bytecode interpreter),将原先的实现抛弃了, 这个新的实现被称为SquirrelFish(金鳞鱼). 在苹果内部的产品代号是"Nitro"。
JSC的字节码解释器(bytecode interpreter)很棒,令人着迷. 我将在下面说说更多的细节。
2008年后, WebKit的伙计们新增了Inline caches, 一个基于正则表达式(regular expression)的JIT, 和一个简单方法(simple method)JIT, 随后新的版本被称为SquirrelFish Extreme(Nitro Extreme),可以简称为SFX。正式的名称仍然是JavaScriptCore。
JSC的伙计们做得很棒,以至于Mozilla SpiderMondkey的骇客们(hackers)也直接采用了JSC的基于正则表达的JIT(regexp JIT)和原生汇编程序(native-code assembler)。
对于JSC的2009年和2010年都花在了它的巩固(consolidation)上了. JSC既有一个JIT,又有一个字节码解释器(bytecode interpreter), 他们想要同时维护它们,所以为他们的协作,需要做大量的重构和调整。在这阶段SFX在x86架构上得以加强,同时也增加了ARM和其它架构下的实现。
但随着2010后期V8 Crankshaft(曲轴)的发布, JSC性能又变得不足了。JSC的那些人又开始开发他们称为的DFG JIT (data-flow graph JIT), 使JSC更接近于Crankshaft.
JSC可以由三个引擎组成: 解释器(interpreter), 简单方法JIT(simple method JIT), 和DFG JIT. 三种形式有一个层次化的编译过程:初始的解析和编译生成字节码(byte-code), 再由simple method JIT加以优化, 最后再由DFG JIT加以优化。在实践中, 多数平台下并没有解释器,所有的代码都是通过method JIT运行。DFG JIT随着Mac OS X Lions的Safari浏览器一起发布,但并没有在除了64-bit Mac OS以外的系统上使用。
寄存器式的虚拟机(a register vm)
以下register VM或register machine都是指基于寄存器的虚拟机或寄存器式虚拟机,而stack machine或stack VM都是指基于堆栈的虚拟机或堆栈式虚拟机。网上有专门的论文讨论两者的细节。
解释器有很多有趣的东西,但重要的是字节码的定义。字节码实际上是JSC的高层次的中间表示(intermediate representation, IR)。
在V8中,高层次的中间表示是JS代码本身。当V8第一次看到一段代码,它先预解析(pre-parses)并报告语法错误。然后当它需要分析源代码,无论是在full-codegen编译器或在Hydrogen中,它都重新解析代码为AST(抽象语法树,abstract syntax tree),然后基于AST运行。
相比之下,JSC首先就将代码完全解析为AST,然后再将AST编译为字节码。这时源代码就不再需要了,所以会被抛弃。解释器直接从字节码解释。简单方法JIT编译器也是直接编译字节码。在进一步优化和生成原生代码前,DFG JIT必须重新将字节码转成SSA(static single assignment)风格的中间表示(IR)。这个过程开销较大,但对于频繁使用的hot code是值得的。
正如您可以看到的,字节码是所有JSC引擎的通用语言,所以了解它很重要。
真正进入正题之前,我要说一个关于术语的题外话。以我的经验,一个虚拟机传统上被认为是一个解释虚拟指令序列的软件。相对而言,实体机是在硬件上解释机器码或原生指令序列。
最近这些事变得更复杂了。几年前,一个常见的问题是“JavaScript是解释类语言还编译类语言?“ 这个问题其实很奇怪,因为“解释”或“编译”是属性,而不是语言的实现。再说了,实现可以是编译为字节码,然后解释那些字节码, JSC就是这样做的。
但是最后,如果将所有代码编译为字节码, 那虚拟机的意义又体现在哪里呢?即使V8已经没有解释器了, 但V8的骇客们仍然自称为"虚拟机工程师(virtual machine engineers)"。(ARM的模拟器不算在内的话,qemu下运行的程序又如何呢?).
总之,仍然可以称JSC的高层次的中间表示是基于寄存器的虚拟机,并有一系列的虚拟指令集,就像是解释器和简单方法JIT实现的那样。
"基于寄存器的虚拟机(register machine)", 是相对"基于堆栈的虚拟机(stack machine)"而言的. 它们间的差异主要是前者中所有的临时变量都有名字且存储在一组stack frame中。而后者临时结果被压入堆栈,并且绝大多数指令都是从堆栈中弹出它们的操作符。
(顺便提一下, V8的full-codegen编译器是使用类似stack-machine的方式执行AST的。 V8中有不少Bug都是来源于从full-codegen到Crankshaft转换时使用的堆栈状态模型(accurately modelling the state of the stack)。)
对于一个解释器来说,我相信基于寄存器的虚拟机才是正确的方式。这里我要说明一些理由。
首先,stack machines不利于临时变量的命名。例如下面的代码(Lisp):
(lambda (x) (* (+ x 2) (+ x 2)))
我们可以删除公共表达式(common sub-expression elimation)来优化它:
(lambda (x) (let ((y (+ x 2))) (* y y)))
以stack machine能否胜出呢? 考虑上面第一段代码的指令序列::
; stack machine, 未优化 0: local-ref 0 ; x 1: make-int8 2 2: add 3: local-ref 0 ; x 4: make-int8 2 5: add 6: mul 7: return
上面第二段代码的指令序列:
; stack machine, optimized 0: local-ref 0 ; push x 1: make-int8 2 ; push 2 2: add ; pop x and 2, add, and push sum 3: local-set 1 ; pop and set y 4: local-ref 1 ; push y 5: local-ref 1 ; push y 6: mul ; pop y and y, multiply, and push product 7: return ; pop and return
两种方式下并没有什么改善, 因为存储到本地变量以及将它们压回堆栈使用是不同的指令集,并且一个过程的时间开销同它所执行的指令数之间是一个线性关系。
而在register machine中,事情变得简单多了,而CSE最终获胜:
0: add 1 0 0 ; add x to x and store in y 1: mul 2 1 1 ; multiply y and y and store in z 2: return 2 ; return z
在register machine中,变量命令没有任何妨碍。使用register machine可以减少push/pop的干扰,而专注于要做的事。
并且因为在指令中包含了操作符的名称(或者说是位置),register machine可以使用更少的指令来完成同一件工作。这减少了调度成本。
此外,register VM中调用帧(call frame)的大小在调用前都是可知的, 这样你就可以在压入数据时检查以避免溢出。(一些stack machine也有这个属性,比如JVM)。
选择register machine的最大的优势是你可以从传统编译器优化中获益,如CSE和寄存器分配(register allocation)。在上面的例子中,我们使用了三个虚拟寄存器, 但在现实中我们只需要一个。生成的代码也更接近真正的机器码, 因此很容易用于JIT。
不利的一面是, register machine通常占用更多的内存。JSC有一个特别的情况,操作码(opcode)和每个操作数(operand)占满了整个机器字。这样做是为了实现“direct threading”, 这个操作码不是在跳转表(jump tables)中索引, 而实际上是相应地址的标签。这在JS不会将字节码序列化到外部磁盘的情况下还免强可以接受。但对于其它需要的重定位的情况就可能会造成丢失。这个功能默认是关闭的。
解释器的stack frame包含一个6字节的帧(six-word frame), 参数,最后是局部变量。一个程序调用会预留一个stack frame的空间,然后将参数压入堆栈 (或者说将它们设置到stack frame中n + 6位置的寄存器中)——然后调整帧的指针。JSC中的堆栈被称为“寄存器文件(register file)”, 帧指针(frame pointer)则被称为“寄存器窗口(register window)”。这些名字和堆栈世界(stack world)里的“激活记录(activation records)”一样难以理解。
什么是method JIT? 什么是DFG JIT? JSC的DFG JIT与V8 Crankshaft相比有何优劣?
JIT:一个方法式的JIT(a method jit)
*关于method JIT,可以参考另一份档案。
关于解释器(interpreter)和堆栈(stack,也就是寄存器文件,register file)都是应用了方法式JIT(method JIT). 所谓简单方法JIT(确实没什么特别的名字)做得事和字节码解释器完全一样,只不过它的结果是机器码,而不是虚拟的机令集。
也没什么好说的,JIT的结果可以减少调度的开销,特别是允许对特定上下文进行编译时(原文:jitting the code has the result you would expect, reducing dispatching overhead, while at the same time allowing some context-specific compilation), 就比如当给一个变量赋个整数常量。这个JIT只能算是凑合(quick-and dirty), 所以传统的Method JIT,像HotSopt C1及C2, 并没有明显的好处。相对而言,寄存器式的VM字节码却保证可以为JSC带来显著的提升,不过到目前为止,JSC在这个方向上并没做什么。
再思考一下,我认为对于JavaScript而言,CSE只有在你知道类型时才可能有作用,毕竟JS的valueOf()并不那么靠谱!
DFG:JSC的Crankshaft? (dfg: a new crankshaft for jsc)?
由Gavin Barraclough和Filip Pizlo开发的数据流图JIT(data flow graph,DFG JIT)是一种推测性优化技术。比如,当有如下JS代码:
a[i++] = 0.7*x;
那么a很可能是一个浮点型数组,而i则很可能是一个整数。你应该可以想到,如果使用原始的数组和整数操作,就能达到很好的性能,于是尝试着将这份代码按照这个假设编译出了一个新的版本。如果这个方法最终失败了(有不符先前假设的情况发生了),就把这份代码中全身而退(bail out),再回到原先的method JIT。
事实上,解释器(interpreter)和simple method JIT有一个清晰的字节码语义模型来确保在必要时从DFG JIT中轻易地退出来。只需要重建虚拟寄存器和Register Windows(奇怪的名字,和register file一样. ^_^)的状态,然后跳转回原先的代码即可。 (V8 称之为"逆优化(deoptimization)"; DFG则称之为"投机不成(speculation failure)".)
还有另一种从simple JIT跳转到DFG JIT的方法,叫栈上替换(on-stack replacement, OSR). DFG JIT就是这样做的。我就听说用了OSR就可以在Kraken(一个JS性能测试器)测试中胜出,这个测试器会使用大量紧凑的循环,所以你必须能够在不依赖函数的反复进入的情况下优化你的代码(Method JIT就是这么干的!)。
当DFG JIT启用后,解释器(如果有的话)和简单方法(simple method) JIT会对性能数据进行判断,它会记录代码各部分的流转类型(flow types)。如果一个循环被执行相当次数(目前是大于1000次),或者一个函数会调用了很多次(目前是70次), DFG JIT就会行动了。它将相应的字节码解析为SSA(Static Single Assignment)形式, 然后沿着执行路径收集类型信息。这个过程很像我在另一篇文章中所说明的情形。
JSC和Crankshaft的区别在于Crankshaft从Inline Caches中直接获得类型信息,而不是从代码中检测。我认为Crankshaft的实现更为优雅一些,但当GC(垃圾回收,garbage collection)释放了缓存(cache)后,它就会得很弱了。
我以前提到过inlining, DFG JIT就会使用它,并且像HotSpot一样在解析时使用。类型分析(type profiling) (也称为value profiling)组合运用一些简易的静态分析技术,使得DFG可以使int32和双精度数据类型区分开来。
有一件事DFG JIT目前还做不到,就是代码移动中一些情况(code motion,又是一个编译领域的名词)。它虽然可以做去除死代码和公共表达式,但它的前提条件是必须能做类型分析(value profile)。而针对循环不变量的code motion,这是做不到的(因为值不变嘛)。
另外,DFG的寄存器分配器(register allocator)不及Crankshaft的好。这是由于JSC汇编造成的的阻碍。在构造良好的、健壮的代码片段的同时,JSC的汇编程序使用双地址的接口(interface)而不是三址的。这意味着,不需要像add(dest,op1,op2)的方法,而是add(op1,op2),其结果被默认存在第一个操作数中。它是针对x86指令集的,但对于有更多寄存器的系统(如x86 - 64),这并不算什么。
对于计算结果,需要更多代码的基于计数的优化触发方式并不是绝对必要, 但这种策略确实有个很好的特性:DFG的性能是可预见的且可衡量的。而另一方面的Crankshaft则使用统计方式触发,其性能是一种统计式的分布。
对于性能,因为DFG仅在Mac OS 64位版本可用,所以可以使用AWFY on the mac进行测试。你一定要使用正确的工具进行性能评测。
看看结果就知道,JSC对V8基准(benchmark)的表现很不错。有趣的是,对于SunSpider测试,JSC打败了V8。不过V8一旦热身后,仍然是表现最好的. JSC做得很不错,而且正在逐渐改进中。
未来(future)
这就是JavaScriptCore. 当前团队的三个人(是的),正专注在DFG JIT上。其中最关键的是什么时候可以将DFG JIT运行到其它系统上。
JSC的另一个工作是新一代的垃圾回收器(new generational garbage collector)。它确实在做,但很慢。虽然有了Card-Marking write barriers(这是一垃圾回收领域的专业名词)的预留函数(stubs),但目前为止并没有相应的实现。不过,至少JSC提供了Handle API, 盖过了SpiderMonkey。