Crankshaft由两部分组成,第一部分是Hydrogen,它负责把js的ast翻译成ssa形式,并且对Hydrogen图进行优化。接下来Hydrogen会被翻译成与机器相关的Lithium的低级表示,这将有助于寄存器分配和机器码生成。
Hydrogen翻译由三部分组成:
1. 内联。内联是所有优化的基础,它使得更多的优化成为可能。
2. 临时值表达为untagged integer或者double。在js里,使用类型反馈来做这件事是一种行之有效的方法。unboxing减少了内存压力,同时使得crankshaft可以使用更有效的机器指令。只是,unboxing一定要谨慎。
3. 循环不变量外移和公共子表达式消除。
本文重点关注这些步骤是如何执行的。
SSA:
class HGraph : {
ZoneList<HBasicBlock*> blocks_;
ZoneList<HValue*> values_;
ZoneList<HPhi*>* phi_list_;
}
Hydrogen由值-指令和包含phi函数的基本块构成,这些都包含在graph里。
class HValue {
int id_;
Representation representation_;
HType type_;
HUseListNode * use_list_;
HBasicBlock * block_;
int flags;
}
标准的SSA记法,会将一个命名值与表达式相关联,例如:
t1 = x + y;
t2 = 3;
t3 = t1 + t2;
…
这样写当然是很有用,但其实这些临时的名字并不是必须的。既然每一条指令定义了一个临时值,我们可以使用每条指令的identity做为它的名字,这样也是符合SSA的记法定义的。
class HInstruction : HValue {
HInstruction * next, * prev;
}
接下来的一遍会为所有的临时值分配存储,可能是在栈上,也可能是在堆上,或者是在各种类型的寄存器里的未封包的值。一开始我对于“指令”这个术语感到很困惑,因为其实在其他文献上更倾向于使用“表达式”这个词,但其实很快也就适应了,尤其是当你使用“指令”这个词来指代表达式所产生的“name definition”
class HPhi : HValue {
ZoneList<HValue*> inputs_;
int merged_index_ = 0;
int phi_id_ = -1;
}
一个指令也是一种value,因为value的本质就是一个可以定义名称的实体而已。还有一种特殊的类型的value是phi函数。phi函数都出现在控制流的结点处,而且在逻辑上是与一个基本块的开头相联系的。
所有的values都有一个指针指向包含自己的块,还有一个指针指向使用它们的use。一个use是指一个后来的指令使用了由以前的指令计算出来的值。values还有一个指向本块中下一条指令的指针。在控制流和数据流中,向前遍历指令比向后遍历所花费的代价要小。Values还有一些域表示他们的类型,表示(representation),flags bitfield,以及一个指向range structure的指针。我们稍后会详细介绍。
class HBasicBlock {
int block_id_;
HGraph* graph_;
ZoneList<HPhi*> phis_;
HInstruction* first_;
HInstruction* last_;
HControlInstruction* end_;
HLoopInformation* loop_information_;
ZoneList<HBasicBlock*> predecessors_;
HBasicBlock* dominator_;
ZoneList<HBasicBlock*> dominated_blocks_;
}
每个基本块的最后一条指令是一个control instruction. 没有人会使用由control expression定义的value,因为control expression存在的意义只是控制转移到其他的基本块。要搞清楚后序,前序,支配。
crankshaft还有一个很有趣的功能。在优化的时候,会采用一种很有用的技术叫做栈上替换(on-stack replacement OSR)来优化一个很长的循环,而不用退出循环。
在实现上,crankshaft也是通过遍历ast来产生Hydrogen code,这一点和full compiler是一样的。它需要模拟full compiler的行为:哪些东西在栈上,哪些变量在堆上,还有一个命名的局部变量,以及哪个HValue与这个变量是绑定的。这种模拟会被保存在一个environment中。
这个AST-to-Hyd的翻译过程也会处理phi函数的插入。所以不像教科书上的dominance-frontier算法,HGraphBuilder只是简单地在那些有多个前序结点的基本块里,并且出现了变量不止被一个HValue所限定的情况时,插入到这些基本块的开始位置。对于循环,会为environment中存活的每一个变量都加上phi函数。然后再对不必要的phi函数进行裁减。
类型反馈
传给crankshaft的AST中的每一个结点都有一个整数标记。这些标记都是预先赋值的(assigned predictably),所以他们与full codegen compiler那里看到的一样的,而且他们也会嵌入到inline cache中去。如果一个值的类型在full codegen的call site中已经被确认了,那么这种对应关系就使得crankshaft可以看到这些类型。
这些信息会被graph builder用来初始化HValue中的type这个域,进而帮助进行类型推导。另外,访问对象属性以及函数调用可以记录被访问的对象的准确类型,或者是被调用的函数,所以crankshaft就有可能把这些函数和方法的调用进行内联。
内联
当graph builder遇到一个调用的时候,它会尝试进行内联。要注意的是,这一步会尽可能早地去做,早于任何的其他优化。这就使得优化器可以看到更多的代码,也就有了更多的代码移动。(一般来说,V8不会跨调用边界移动指令)
由于进行内联的方法是启发式的,有很多条件可以影响,所以这些方法大多是不稳定的。要进行内联操作,必须被满足的条件有以下几条:
1. 内联的函数不能超过600个字符(什么鬼?)
2. 无论是内部的还是外部的函数都不能包含堆里分配的变量。(这实际上就是意味着函数也不能包含闭包)
3. 对于for-in with还有一些其他的表达式类型是不被支持的。
4. 最大的内联深度是有限制的。
5. 递归调用自己的函数是不能被内联的。
6. 加到AST中的结点数目是有限制的。
源到源的优化步骤
crankshaft生成特定的HInstruction结点并且把它们打包进HBasicBlock。有大约120个指令,这使得crankshaft有能力处理各种不同的操作和他们的表达。
当内联完成了以后,crankshaft就会把注意力集中到临时变量的表达上。其目的是尽可能地解包更多的值,以达到可以使用本地机器指令的目的,以及减少tagged double的分配比例。Hydrogen SSA直接使得control-flow和data-flow分析成为可能。
接下来的几节会讨论不同的s-t-s步骤,这些步骤都是发生在Hydrogen IR上的。
基本块的重排
Crankshaft会重新排列基本块,使用逆后序遍历,也就相当于是拓朴排序。使用这种方式,从头到尾遍历基本块的数组也就是在数据流里遍历了一遍。
确定支配节点
上一步的逆后序排列完成之后,就可以对每个块的支配节点进行计算了。一个entry block支配它的后继。其他的块也会按顺序被全部遍历,把它们的前序结点的支配节点做为它们的支配结点。如果有多个支配结点,就找他的所有前序结点的公共支配结点做为它的公共结点。
标记死子图
经常会有这种情况:一个正在被优化的函数没有完整的类型信息。一些在原始函数里的分枝从未被执行过,然后这些分支上的代码就没有任何类型反馈信息了。如果parser到达了这样的一个分支,它取不到类型信息,就能够知道这个分支是没有被执行的了。然后它就会插入一个非条件的(绝对的?)软退优化,以强制收集更多的类型信息。
然而,这并不会使得这个分支变成终结去优化块,而是会使得编译继续进行,大概是因为crankshaft需要ast的抽象解释才能继续。。。。所以,这个去优化就会出现在指令流中,而不是基本块的最后一条指令。
为了避免编译器浪费时间来做那些从来没有走到的分支的优化,这一步会把一个包含了非条件退优化的块所支配的所有块上都做一个标记。这个分析是由SSA的支配关系来进行的。
冗余phi函数消除
上文所说的那种简单的phi函数插入算法会把phi函数插入到那些不需要的地方。这一步会把那些输入永远都是相同的phi函数消除掉,而直接变成对值的使用。
对于一些phi-centered优化,一次phi函数的消除会暴露出更多可以消除的phi函数。基本这个原因,这个算法和其他很多算法都使用worklist
在这种情况下,所有的phi函数都被放到一个worklist上。然后,当worklist上有很多phi函数的时候,当一个phi函数被从清单上拿下来的时候,如果它是可以被消除的,那就把它替换掉。如果一个phi被其他的phi所使用的话,而这些phi函数都还没有出现在worklist上,那么这些phi就会被放到list上,这个过程可以一直进行下去直到worklist成空的。
为了使这个操作的复杂度变为O(1)的,对于每个phi,都需要一个标志来指示这个phi是否在清单上。这个位要么就在那些已经被遍历过的对象中,要么就在一个独立的bitvector上。在这种情况下,目前还有这样的一个位,所以算法可能会消耗更多的时间。
死phi函数消除,phi回收
一些phi值是死的,没有被真正地使用,无论是直接的还是间接的。这一步的目的就是消除那些死的phi函数。这个有点像垃圾回收算法,同样也会使用一个worklist
一旦这一步骤开始执行了,我们就可以使得phi函数集合最小化,这样我们就可以把他们收集到一个数组中,并且给它们赋值序号。在未来,这些序号可以被用于bitvector,以便于包含worklist-driven的算法。
phi函数被回收以后,如果发现一些未定义的值会到达一个phi,或者某一些特定的参数会使得到达一个phi,那么全部的优化就会被放弃。
表达推断
每一个HValue都有一个类型属性,记录着从ast传过来的对类型的粗略估计,以及一个表达属性,代表了一个如何表达value的特定选择。目标就是把临时值表达成untagged int 32或者double值。类型和表达是相关联的,但crankshaft仍然会把它们单独处理。
首先,crankshaft会查看所有的phi函数,然后检查哪些是相互关联的。这是一个O(n^2)的遍历。然后,对于每一个phi,如果它的某个操作数,或者与它关联的某一个phi不能被转成一个32位整型,crankshaft就会保守地把一个phi标记成不可转换的。这一步会尽可能阻止过多的phi value的表达变换。
最后,对于所有的带有弹性表达的值,例如phi函数,Math.abs,以及一些二元的位操作和算术操作,crankshaft决定