前言
随着互联网的蓬勃发展,越来越多的脚本语言被应用于网站的构建与运维中。由于这类语言一般具有丰富的扩展库、动态类型机制以及对多态(polymorphism,即同一个函数能够接受多种参数)的良好支持,因此大大加快了项目开发、测试、上线的循环,非常适合互联网公司“快速迭代”的思想。但是为了支持动态类型这样的机制,脚本语言通常以解释器的形式来实现,这带来了非常严重的性能负担。
对于PHP这样被广泛应用在各个场合的语言而言,这种开销的代价尤为巨大。为了有效提升PHP脚本的运行性能,Facebook公司先后开发了HipHop编译器以及HHVM,前者用于将PHP代码转换为C++代码,后者用于以JIT的方式运行PHP代码,提升运行效率。本文将系统梳理PHP的主要性能瓶颈,并分析HipHop以及HHVM是如何分别解决这些问题,提升运行效率的。
动态语言的优势与缺陷
相对于传统的C、C++这样的静态编译型语言,脚本语言最大的优势在于它使得程序员的开发效率大大提升。这主要得益于以下几点:
- 脚本语言通常有着非常丰富且成熟的扩展库以及功能强大的内建函数(built-in funciton),能够满足绝大多数应用场景下的需求;
- 这类语言动态类型的特点带来了极大的灵活性,减轻了编程时所需要考虑的约束;
- 最后也是最重要的一点是,脚本语言一般是动态翻译、动态执行的。这也就意味着如果源代码发生了更改,不需要经过繁琐的编译、链接等过程,就能够直接看到效果。
但正如上文所述,各类脚本语言所共有的最大弊病就是其运行效率问题,因为多数情况下不得不采用解释器的方式来实现其动态特性。这导致相对于实现同样功能的编译型语言,脚本语言的效率甚至可以低一个数量级。下面,本文就以PHP语言为例,来分析脚本语言通常需要实现哪些动态特性,这些特性为什么会带来严重的性能开销。
PHP的性能瓶颈
动态类型
这个特性简而言之就是说,程序在运行时,同一个变量可以引用不同类型的数据。如下面这段代码所示:
<?php
function foo($x) {
echo "foo: " . $x . "\n";
}
foo("Hello"); // prints: "foo: Hello"
foo(10); // prints: "foo: 10"
这样方便的特性为什么会影响性能呢?事实上在PHP官方实现(Zend)的内核中,变量是这样实现的:
typedef unsigned int zend_uint;
typedef unsigned char zend_uchar;
struct _zval_struct {
zvalue_value value; /* 变量的值 */
zend_uint refcount__gc; /* 变量的值 */
zend_uchar type; /* 变量当前的数据类型 */
zend_uchar is_ref__gc;
};
typedef union _zvalue_value {
long lval; /* long value */
double dval; /* double value */
struct { /* string value */
char *val;
int len;
} str;
HashTable *ht; /* hash table value */
zend_object_value obj; /* object pointer */
} zvalue_value;
也就是说,为了实现同一个变量在运行时能够被赋予不同类型的值这样的动态特性,解释器的实现等于说把各种类型的值(整数、浮点、字符串、对象),都塞进了同一种容器(union _zvalue_value)中。这种设计导致运行时必须先做类型检查,看操作数属于哪种类型,然后才能真正执行对应的操作。但很多时候类型检查的开销要远远大于实际操作,比如说两个数相乘,就是那么就是两次访存、一个乘法指令再加一次内存写回;但是要做类型检查的话,就会复杂得多,正如下面这这张图所示,由此带来了巨大的额外开销。

换句话说,如果编译器能够得知变量的类型信息,就可以直接针对对应的类型来生成指令,从而降低了类型检查与转换的额外开销;如果不能,那么就会导致PHP这种情况的性能损失。
动态名称绑定
在PHP语言中,函数和类的具体实现在运行时才会被绑定到对应的名称上面。举个例子,在PHP中我们可以写出这样的代码:
<?php
if ($cond) {
function foo($x) { return $x + 1; }
} else {
function foo($x) { return $x - 1; }
}
$y = foo($x);
即根据分支条件的不同,来为foo()函数选择不同的实现。为了支持这样的特性,导致解释器必须在运行时生成一些额外的数据结构,来记录函数的名称与其接口、实现的对应关系。这样如果发生函数调用,就必须在运行时动态查找这个函数的接口,进行参数检查,然后再找到其实现并执行。毫无疑问,这种运行时的查找会带来明显的性能损失,特别是当函数体本身很短的情况下会更严重。
动态名称创建/引用/查询
动态名称创建是指可以用extract()这样的函数,从数组中把变量导入到当前的符号表中,也就是为当前的代码上下文增加可以用标识符来引用的变量。动态名称引用是指可以在运行时使用变量的值(字符串值)作为标识符,来引用函数、类、变量等等。最后一个就不用说了,既然允许动态创建,那么当然也允许动态查找某个类、函数是否已经被定义。
<?php
// 创建
function f($vars) {
$name = 'some_constant';
// ...
extract($vars);
// ...
other_function($name);
}
// 引用
$a = 'f';
$b = 'c';
$c = 'oo';
$func = $a . $$b;
$func();
$obj = new $c;
// 查询
if (function_exists('foo')) {
...
}
if (class_exists($c)) {
...
}
这些动态特性用起来肯定是相当爽的,但导致的后果也是显然的:程序需要在运行时动态地查找对应的函数、类、变量的符号表,造成额外的性能开销。事实上对于C++这样的编译型语言,运行时栈上面开辟多少空间,创建了多少个变量都是固定的,因此生成的机器码极为精简,只针对内存位置而非标识符进行操作,从而获得很好的性能。但像PHP这样随时可以引入新的变量的设计,不仅令变量访问操作变得复杂,并且也使得很多代码优化手段无法进行。
动态成员变量
在C++语言中,类的声明一旦固定便无法修改;但是对于PHP这样的动态语言,却支持运行时增加成员变量。例如下面这段代码所示:
<?php
class C {
public $declProp = 1;
}
$obj = new C;
$obj->dynProp = 2;
echo $obj->declProp . "\n";
echo $obj->dynProp . "\n";
事实上,诸如C++之类语言的编译器会为类的实例对象中每一个预先声明的成员变量分配内存,并且内存位置是相对于对象起始地址的固定偏移位置处,也就是说成员变量在对象的内存布局中,位置是固定的。这使得访问这些成员变量时更加高效:仅需要使用几条机器指令以及内存操作。
但与之相反的是,访问PHP代码中对象的动态成员需要进行哈希表查找,因此导致了更多的性能开销。更严重的是,这种动态的新增特性,在实现重写或者增加类方法时,不仅要处理当前这个类,还要沿着继承关系一直处理到最顶层的基类,来检查操作是否合法,这带来的性能负担就会更加可怕。
动态执行代码
eval()这个函数恐怕是所有解释型语言所“标配”的特性了,它的功能是传入一段字符串表示的代码并执行。像这样的特性非常容易被解释器所支持,但由此带来的后果就是完全破坏掉程序运行时状态的确定性——只要从外部传递进来一个字符串,执行什么操作、带来什么后效性都有可能,还谈何代码优化呢。总之一句话:动态语言一时爽,类型推导火葬场!
究其根本,动态语言之所以慢,表面原因是其动态特性导致无法对代码进行类型推导,以及很多信息需要等到运行时才能被确定,因此带来了额外的查找开销等等;但是这些原因本质上都可以归结为在它的代码中包含的信息量少,用物理学的话来说就是——含有较多的熵。因为代码的执行与运算本质上就是一个减小熵的过程,当你的代码所包含的信息量不足的时候,必然会导致额外的运算量,来弥补这些缺失的信息。所以站在脑洞大开的物理学角度,动态语言的慢是很没办法的事情,因为当优化到一定程度以后,开发者不得不降低开发效率,来提供更多的信息,才能让它快起来。
Zend引擎的性能缺陷
作为PHP的官方实现(该语言没有通常意义上的“标准”,官方实现即为其标准),Zend引擎采用解释器的工作原理——确切地说,它是一种字节码解释器。它的工作流程是,每次当某个PHP文件被调用时,先将PHP代码语法分析后得出的抽象语法树(AST)转换为二进制中间指令(Zend Bytecode),然后再一条一条执行这些指令。
Zend引擎的缺陷就在于,对于上一节所述PHP的性能瓶颈,它不仅没有想办法规避,反而把所有可能导致性能问题的坑都给踩了。上文已经分析过,本身Zend的动态类型实现就已经导致了较高的性能开销,但它依然采用了“动态装载”的策略,即在运行时,只有当某个PHP文件被include时,Zend才会将其中的组件(函数、类、变量、常量等)载入各个运行时“查找表”。函数等东西还好说,但是对于类的动态载入是开销巨大的过程,因为这要求解释器不得不回溯继承关系树直至根节点,来得到与这个类所关联的所有方法、成员变量的信息,并检查其是否合法(比如在overwrite的时候是否修改了接口等等)。
另一方面,Zend引擎使用一种称为“动态查找”的特性来实现对变量的访问,程序运行过程中的所有标识符都被放到运行时构建的“查找表”中进行增加、修改、删除,在运行时会使用变量的标识符名称来索引得到其值,这个过程带来巨大的额外开销。尽管在中间指令的层面,这些查找操作是能够被优化掉一部分的,但实际所起到的性能提升非常有限。
HipHop Compiler
为了解决上述PHP语言的性能问题,Facebook公司开发了HipHop编译器,用于将PHP代码转换为C++代码后执行静态编译,从而获得性能提升。HipHop编译器设计的核心思想就是,尽可能通过语义分析,把PHP代码中的一切动态特征都转换为可以静态确定的,从而针对性地生成代码,提升性能。并且,当PHP代码转换成C++代码以后,也可以充分地利用C++语言现有的成熟优化手段,进一步提升性能。下面我们仅仅分析HipHop是如何降低动态特性所带来的负担,从而优化PHP性能的;至于HipHop如何实现与PHP语法的兼容,则可以参考相关论文[3]来详细了解。
动态装载
首先,由于HipHop把PHP都转换成了C++代码并进行编译、链接,所以自然没有动态装载所带来的开销。对于开发者必须要使用动态装载语义的地方,HipHop提供了额外的方法来确保兼容性——简而言之,就是模仿Zend引擎的实现而已。当然,这种做法必然会导致额外的开销,不过再怎么差也不至于比Zend还慢。
类型推导
其次,作为一个静态ahead-of-time编译器,HipHop可以执行远比运行时要复杂的语义分析,从而获得强大的类型推导能力。这样,对于那些可以推导出原始类型并且类型不变的变量,HipHop可以直接生成跳过类型检查,直接执行操作的高效代码,这些代码自然也会被C++编译器优化成高效的机器码。
这里值得一提的是HipHop所设计的类型层次关系,其示意图如下:

我们首先给出这样一段PHP代码:
<?php
define("confName", "OOPSLA");
define("firstYear", 1986);
function year($edition) {
return firstYear - 1 + $edition;
}
echo "Hello " . confName . "’" . year(27);
经过语法分析,上述这段代码所对应的抽象语法树如下:

基于上述类型继承关系,并进行常量内联与折叠(constant inlining and folding)、逻辑表达式化简、死代码消除(dead-code elimination)、函数内联等与类型无关的优化,再执行类型推导,最后得到的语法树如下:

从上图的语法树中我们可以看到,最右侧的常量表达式已经完全被合并成一个单一字符串,中间的表达式求值也进行了部分化简,并且所有表达式都被标注了类型信息。另外值得注意的是,尽管HipHop在编译时不知道传入的$edition变量的类型,但是由于算数操作符的左侧是Integer类型,因此也可以自动推导出求和之后的值——即函数返回值,是Numeric类型。
动态查找
最后,对于PHP的动态查找特性,由于HipHop尽可能对代码进行类型推导,因此多数情况下可以直接使用内存地址而非变量名称来引用变量,从而针对性地生成代码,节省了在符号表中查找标识符所带来的开销。但同样地,对于少数HipHop无法静态解析出标识符的情况,其生成的C++代码会构造一个运行时的符号表,来实现与Zend引擎类似的动态查找功能,代价当然也是带来一定的性能损失。
综上所述,我们可以看出HipHop编译器主要是实现了比较强大的语义分析(类型推导),这样如果代码中包含了足够的类型信息以及尽量少的动态特征,那么它就可以针对性地生成非常高效的C++代码,并被GCC编译器进一步优化成高效的机器码。
HipHop Virtual Machine
概述
经过对HipHop编译器的分析我们知道,基于静态编译的优化方法仅仅加速了PHP代码中能够被静态分析(类型推导)的部分;但是如果开发者一定要用PHP语言中的动态特性,那么就不得不忍受额外的性能损失。另一方面,复杂C++代码的编译往往会非常耗时,对局部代码所做的改动会导致耗时较长的编译、链接。这就导致开发环境与线上环境产生差异:为了快速地执行代码,在开发时使用解释器运行PHP代码,待开发完成后统一编译上线。这种做法虽然看起来可行,但在实际的生产环境中,解释器与编译器必然会存在细节差异,从而会将开发者的时间浪费在解决这些繁琐的兼容性问题上面。因此,为了兼顾PHP的开发与执行效率,Facebook公司推出了HHVM(HipHop Virtual Machine)虚拟机。
HHVM核心思想
上文已经提到,HipHop之所以能够起到加速的效果,绝大多数得益于其有效的类型推导,这使得很多需要动态调用的地方可以直接静态绑定所对应的函数。然而,由于PHP的动态特性,代码中能够被推导出类型的分支毕竟是有限的,因此这限制了HipHop的性能提升。但是我们注意到一个事实:尽管PHP代码中一些变量的类型是无法推导的,但它的可能性是有限的,这一特性可以被称作“类型一致性假设”。考虑一个简单的例子,PHP语言的内建函数strlen()在传入正常字符串时会返回整数,但是传入数组时会返回特殊值null。正常情况下,开发者不可能错误地把数组作为函数参数,因此尽管从静态分析的角度,这个函数返回的类型是可变的,但在实际运行中这个函数几乎总是会返回整数。也就是说,如果我们就直接猜测strlen()函数会返回整数,来继续做类型推导,那么在绝大多数情形下,猜测总是成功的。这种无法进行静态分析,但总的可能性有限的类型,被HHVM定义为“潜藏类型”(latent types)。如果我们能设计尽可能准的策略来猜测这些类型,并针对性地生成代码,那么便可以加速那些无法被静态分析所优化的PHP代码。
作为能够访问运行时类型信息的JIT虚拟机,HHVM的优越之处就在于能充分利用这些类型信息,从而在不知道类型的情况下,试探性地生成具有针对性的代码。为了猜测这些潜藏类型并起到加速效果,HHVM引入了一个称为“Tracelet”的概念,作为JIT优化的基本单位。Tracelet是一种对程序控制流的高度抽象,它本质上相当于是一段单入口、多出口的代码块,并且所有流入这段代码的变量类型信息都被明确标注。那么很显然,标注类型的工作不可能在静态分析的阶段完成,因为代码中根本没有包含相应的信息。事实上,HHVM会首先进行一遍“符号执行”——一种高级静态分析技术,然后尽可能推导出所有的输入与输出类型。然后,对于那些确实无法推导的变量,HHVM会在运行时观察变量的类型信息,生成对应的代码,并且做出假设:以后执行这段代码时,所观察到的各个变量的类型状态有很大概率与第一次执行时相同。换句话说,基于程序的类型一致性假设,HHVM会使用先前观察到的运行时类型信息,来预测以后执行时最有可能出现的类型状态。
Tracelet执行流程
接下来分析HHVM具体是怎样执行JIT优化过程的。首先,我们知道PHP代码的热点区域会被自动拆分成多个Tracelet,并为每一段Tracelet生成相应的机器码。那么在处理每个Tracelet时,JIT首先需要检查当前所有输入变量的类型信息是否满足符号执行时的约束条件。如果这个约束条件能够被满足,那么接下来JIT编译器就可以利用这些已有的类型信息来对Tracelet内部的代码进行优化。由于此时的类型信息已经被确定,因此HHVM可以针对性地生成高效的代码。通常情况下,待当前这个Tracelet执行完成后,控制流会转移到紧接着的下一个Tracelet继续执行。
如果在进入Tracelet时的类型约束条件没有被满足,那么HHVM会调用一个错误恢复函数,基于新的类型信息来重新为当前的Tracelet生成机器码,并将控制流转移到新生成的机器码处。因此,对于那些具有一定多态性的代码,这个过程可以理解为从一系列机器码中,线性查找一个匹配输入值类型的代码段。事实上,这个过程在绝大多数情况下仅仅会查找一两次。在极端情况下,如果连续转移12次都没能完成类型匹配,那么HHVM就会将控制流转移给解释器,将这段Tracelet解释执行。
Tracelet实例
Tracelet这个抽象概念相对而言比较令人难以理解。下面举一个具体的例子来简要说明。考虑下面这段PHP代码:
<?php
function max2($a, $b) {
return $a > $b ? $a : $b;
}
echo max2(2, 1) . "\n";
echo max2("wxy", "abc") . "\n";
HHVM前端生成的HHBC代码如下:
.function("max2")
a: CGetL $b
CGetL $a
Gt
JmpZ c
b: CGetL $a
RetC
c: CGetL $b
RetC
那么上述代码可以被分为三个Tracelet,标注有a, b, c的位置即为各个Tracelet的入口点。在PHP代码中第一次以参数(2, 1)调用max2()函数时,HHVM会生成Tracelet a_1这段代码并将其缓存,控制流转移到其入口位置,代码缓存如下图所示:

运行时,首先检查输入变量的类型是否符合约束条件——由于这是第一次调用,这段代码本身就是基于输入值是两个整型的情况来生成的,因此自然会满足约束条件。于是接下来就可以直接使用硬件的整数比较指令来实现这个Gt操作符,也就是所谓的针对性优化。另一方面,如果约束条件不满足的话,就会导致HHVM重新编译这个Tracelet。
接下来,控制流会根据比较结果来分为两个分支,即执行b或者c这两个Tracelet。这里由于输入参数2 > 1,因此会直接顺序往下执行(fall through),控制流转移到入口为b的这个Tracelet。但又由于这个Tracelet尚未被编译,于是HHVM会调用错误恢复函数,将这个Tracelet编译并缓存,于是得到的代码缓存示意图如下:

在Tracelet b_1中,由于第一次调用max2()函数时使用(2, 1)作为参数,导致两个局部变量均为整型,因此HHVM就会猜测在离开这个Tracelet时,$a和$b同样保持整型。如果这个约束条件不满足,就会导致当前这个Tracelet被重新编译;如果满足,就可以把返回值放在寄存器里面直接返回。此外,由于包含了一条return指令,因此尽管这个Tracelet仅仅用到了$a的值,但也需要对两个本地变量(local variables)都进行检查。这是因为当函数执行结束并返回时,需要销毁局部变量,此时根据不同的变量类型,可能需要执行相应的引用计数操作,从而实现正确的垃圾回收。
当第二次以两个字符串为参数来调用max2()函数时,代码缓存中Tracelet a_1这段代码的类型检查显然就会失败,导致a处的Tracelet被重新编译为Tracelet a_2,那么此时进行变量比较的时候就要调用底层的字符串比较函数了(可能是glibc之类?不确定……)。由于输入参数"wxy" > "abc",那么在JmpZ处便不会发生跳转,将继续执行标号b处的这个Tracelet。总而言之,函数运行结束后的代码缓存如下图所示:

这里比较有悖于正常思维的是,Tracelet a_2的结尾为什么没有直接指向Tracelet b_2,而是多了两次跳转?事实上,按照Tracelet机制的执行逻辑,执行到标号b处的Tracelet时,HHVM会发现,当前Tracelet所生成的代码已经存在于代码缓存中,因此会直接使用一条跳转指令将控制流转移到缓存中的Tracelet b_1。考虑到两个输入变量均为字符串,必然不满足约束条件,于是此时又会调用错误恢复函数,根据$a和$b的类型,重新编译Tracelet b_1得到Tracelet b_2,最后,经过类型检查,JIT会自动释放$b的引用计数,然后将$a返回。
总结
总结一下,HHVM所做的最大改进,就是解决了先前HipHop在静态分析时无法推导的情况。在不知道类型的情况下,HHVM可以收集运行时的类型信息,然后利用代码的类型一致性原理,来猜测可能的类型,并针对性地生成高效代码。如果猜测没有命中,就保留旧的代码,生成新的代码,形成一个上图所示的复杂链状结构。实践表明,这种猜测策略的命中率确实非常高:有91%的概率能够在第一次命中,且4次以内命中的概率高达99%以上。
PHP代码的优化策略
经过上述对HipHop Compiler和HipHop Virtual Machine主要优化策略的分析,我们可以认识到,PHP程序的效率提升,主要源于这些优化策略成功地降低了代码中的不确定性。因此,为了提升开发者所提交的PHP代码的运行效率,我们可以抽取出HHVM的语法分析前端,来对提交的PHP代码进行检查,依据类型推导的完成度进行打分,拒绝分数过低的代码提交,从而实现限制开发者使用动态特性的目的。并且,对于那些确实无法推导类型的部分,也可以要求其可能的类型分支数量尽量少,以提升Tracelet机制的命中率。这样,在PHP语言的范畴内,我们基本可以认为,得分高的代码已经达到了性能优化的极限。因为此时的代码已经包含了足够多的信息量,影响其运行效率的将会是外部函数调用以及系统调用的开销,而这些已经不是任何JIT所能优化的地方了,而是需要结合具体业务来进行优化。
附录
类型系统的定义
在阅读本文时,有必要了解类型系统一些基本的定义,以便于理解相关的内容。事实上,类型系统的一些概念,众说纷纭,使用上也比较乱。有些概念甚至不容易严格定义。以下是学术界的一种相对“严格”的说法。
首先定义一些基础概念:
Program Errors
trapped errors:导致程序终止执行,如除0,Java中的数组越界访问untrapped errors:出错后可以继续执行,但可能出现任意行为。如C语言的缓冲区溢出、Jump到错误地址等等
Forbidden Behaviours
设计编程语言时,可以定义一组forbidden behaviors。它们必须包括所有untrapped errors,但可能包含trapped errors。
Well behaved、ill behaved
well behaved:如果程序执行过程中不可能出现forbidden behaviors,则为well behavedill behaved:若不满足上述条件,则为ill behaved
下面是对于类型的定义:
强/弱类型
强类型(strongly typed):如果一种语言所有程序的行为都满足well behaved,即不可能出现forbidden behaviors,则该语言为strongly typed弱类型(weakly typed):如果不满足上述条件,则为weakly typed。比如C语言的缓冲区溢出,属于trapped errors,即属于forbidden behaviors,故C语言属于弱类型
简而言之,弱类型语言的类型检查更不严格,偏向于容忍隐式类型转换。例如,C语言的int可以自动提升为double,这样造成的结果是容易产生forbidden behaviours,所以它是弱类型的。
动态/静态类型
动态类型(dynamiclly typed):如果在运行时拒绝ill behaviors,则是动态类型静态类型(statically typed):如果在编译时拒绝ill behaved程序,则是静态类型
上述定义可能过于学术化,如果换用通俗但不那么严谨的说法就是,如果变量的类型能够在编译期间就唯一确定,那么属于静态类型;如果变量的类型在运行时才能确定,则为动态类型。
一些例子
- 无类型:汇编
- 弱类型、静态类型:C/C++
- 弱类型、动态类型检查:Perl/PHP
- 强类型、静态类型检查:Java/C#
- 强类型、动态类型检查:Python, Scheme
- 静态显式类型:Java/C
- 静态隐式类型:Ocaml, Haskell
参考文献
[1] http://www.zhihu.com/question/19918532
[2] 深入理解PHP内核. Thinking In PHP Internals[J].
[3] Zhao H, Proctor I, Yang M, et al. The HipHop compiler for PHP[C]//ACM SIGPLAN Notices. ACM, 2012, 47(10): 575-586.
[4] Adams K, Evans J, Maher B, et al. The hiphop virtual machine[C]//Proceedings of the 2014 ACM International Conference on Object Oriented Programming Systems Languages & Applications. ACM, 2014: 777-790.