HHVM 是 Facebook 开发的高性能 PHP 虚拟机,宣称比官方的快 9 倍,我很好奇,因而抽空简单了解了一下,并整理出这篇文章,但愿能回答清楚两方面的问题:php
在讨论 HHVM 实现原理前,咱们先设身处地想一想:假设你有个 PHP 写的网站遇到了性能问题,经分析后发现很大一部分资源就耗在 PHP 上,这时你会怎么优化 PHP 性能?html
好比能够有如下几种方式:前端
方案 1几乎不可行,十年前 Joel 就拿 Netscape 的例子警告过,你将放弃是多年的经验积累,尤为是像 Facebook 这种业务逻辑复杂的产品,PHP 代码实在太多了,据称有 2 千万行(引用自 [PHP on the Metal with HHVM]),修改起来的成本恐怕比写个虚拟机还大,并且对于一个上千人的团队,从头开始学习也是不可接受的。java
方案 2是最保险的方案,能够逐步迁移,事实上 Facebook 也在朝这方面努力了,并且还开发了 Thrift 这样的 RPC 解决方案,Facebook 内部主要使用的另外一个语言是 C++,从早期的 Thrift 代码就能看出来,由于其它语言的实现都很简陋,无法在生产环境下使用。node
目前在 Facebook 中据称 PHP:C++ 已经从 9:1 增长到 7:3 了,加上有 Andrei Alexandrescu 的存在,C++ 在 Facebook 中愈来愈流行,但这只能解决部分问题,毕竟 C++ 开发成本比 PHP 高得多,不适合用在常常修改的地方,并且太多 RPC 的调用也会严重影响性能。c++
方案 3看起来美好,实际执行起来却很难,通常来讲性能瓶颈并不会很显著,大可能是不断累加的结果,加上 PHP 扩展开发成本高,这种方案通常只用在公共且变化不大的基础库上,因此这种方案解决不了多少问题。git
能够看到,前面 3 个方案并不能很好地解决问题,因此 Facebook 其实没有选择的余地,只能去考虑 PHP 自己的优化了。程序员
既然要优化 PHP,那如何去优化呢?在我看来能够有如下几种方法:github
PHP 语言层面的优化是最简单可行的,Facebook 固然想到了,并且还开发了 XHProf 这样的性能分析工具,对于定位性能瓶颈是颇有帮助的。web
不过 XHProf 仍是没能很好解决 Facebook 的问题,因此咱们继续看,接下来是方案 2,简单来看,Zend 的执行过程能够分为两部分:将 PHP 编译为 opcode、执行 opcode,因此优化 Zend 能够从这两方面来考虑。
优化 opcode 是一种常见的作法,能够避免重复解析 PHP,并且还能作一些静态的编译优化,好比 Zend Optimizer Plus,但因为 PHP 语言的动态性,这种优化方法是有局限性的,乐观估计也只能提高 20%的性能。另外一种考虑是优化 opcode 架构自己,如基于寄存器的方式,但这种作法修改起来工做量太大,性能提高也不会特别明显(可能 30%?),因此投入产出比不高。
另外一个方法是优化 opcode 的执行,首先简单提一下 Zend 是如何执行的,Zend 的 interpreter(也叫解释器)在读到 opcode 后,会根据不一样的 opcode 调用不一样函数(其实有些是 switch,不过为了描述方便我简化了),而后在这个函数中执行各类语言相关的操做(感兴趣的话可看看深刻理解 PHP 内核这本书),因此 Zend 中并无什么复杂封装和间接调用,做为一个解释器来讲已经作得很好了。
想要提高 Zend 的执行性能,就须要对程序的底层执行有所解,好比函数调用实际上是有开销的,因此能经过 Inline threading 来优化掉,它的原理就像 C 语言中的 inline 关键字那样,但它是在运行时将相关的函数展开,而后依次执行(只是打个比方,实际实现不太同样),同时还避免了 CPU 流水线预测失败致使的浪费。
另外还能够像 JavaScriptCore 和 LuaJIT 那样使用汇编来实现 interpreter,具体细节建议看看 Mike 的解释
但这两种作法修改代价太大,甚至比重写一个还难,尤为是要保证向下兼容,后面提到 PHP 的特色时你就知道了。
开发一个高性能的虚拟机不是件简单的事情,JVM 花了 10 多年才达到如今的性能,那是否能直接利用这些高性能的虚拟机来优化 PHP 的性能呢?这就是方案 3 的思路。
其实这种方案早就有人尝试过了,好比 Quercus 和 IBM 的 P8,Quercus 几乎没见有人使用,而 P8 也已经死掉了。Facebook 也曾经调研过这种方式,甚至还出现过不靠谱的传闻 ,但其实 Facebook 在 2011 年就放弃了。
由于方案 3 看起来美好,但实际效果却不理想,按照不少大牛的说法(好比 Mike),VM 老是为某个语言优化的,其它语言在上面实现会遇到不少瓶颈,好比动态的方法调用,关于这点在 Dart 的文档中有过介绍,并且听说 Quercus 的性能与 Zend+APC 比差不了太多([来自 The HipHop Compiler for PHP]),因此没太大意义。
不过 OpenJDK 这几年也在努力,最近的 Grall 项目看起来还不错,也有语言在上面取得了显著的效果,但我还没空研究 Grall,因此这里没法判断。
接下来是方案 4,它正是 HPHPc(HHVM 的前身)的作法,原理是将 PHP 代码转成 C++,而后编译为本地文件,能够认为是一种 AOT(ahead of time)的方式,关于其中代码转换的技术细节能够参考 The HipHop Compiler for PHP 这篇论文,如下是该论文中的一个截图,能够经过它来大概了解:
这种作法的最大优势是实现简单(相对于一个 VM 来讲),并且能作不少编译优化(由于是离线的,慢点也没事),好比上面的例子就将- 1
优化掉了,但它很难支持 PHP 中的不少动态的方法,如 eval()
、create_function()
,由于这就得再内嵌一个 interpreter,成本不小,因此 HPHPc 干脆就直接不支持这些语法。
除了 HPHPc,还有两个相似的项目,一个是 Roadsend,另外一个是 phc ,phc 的作法是将 PHP 转成了 C 再编译,如下是它将 file_get_contents($f)
转成 C 代码的例子:
static php_fcall_info fgc_info; php_fcall_info_init ("file_get_contents", &fgc_info); php_hash_find (LOCAL_ST, "f", 5863275, &fgc_info.params); php_call_function (&fgc_info);
话说 phc 做者曾经在博客上哭诉,说他两年前就去 Facebook 演示过 phc 了,还和那里的工程师交流过,结果人家一发布就火了,而本身忙活了 4 年却默默无闻,如今前途渺茫。。。
Roadsend 也已经不维护了,对于 PHP 这样的动态语言来讲,这种作法有不少的局限性,因为没法动态 include,Facebook 将全部文件都编译到了一块儿,上线时的文件部署竟然达到了 1G,愈来愈不可接受了。
另外有还有一个叫 PHP QB 的项目,因为时间关系我没有看,感受多是相似的东东。
因此就只剩下一条路了,那就是写一个更快的 PHP 虚拟机,将一条黑路走到底,或许你和我同样,一开始听到 Facebook 要作一个虚拟机是以为太离谱,但若是仔细分析就会发现其实也只有这样了。
HHVM 为何更快?在各类新闻报道中都提到了 JIT 这个关键技术,但其实远没有那么简单,JIT 不是什么神奇的魔法棒,用它轻轻一挥就能提高性能,并且 JIT 这个操做自己也是会耗时的,对于简单的程序没准还比 interpreter 慢,最极端的例子是 LuaJIT 2 的 Interpreter 就稍微比 V8 的 JIT 快,因此并不存在绝对的事情,更多仍是在细节问题的处理上,HHVM 的发展历史就是不断优化的历史,你能够从下图看到它是如何一点点超过 HPHPc 的:
值得一提的是在 Android 4.4 中新的虚拟机 ART 就采用的是 AOT 方案(还记得么?前面提到的 HPHPc 就是这种),结果比以前使用 JIT 的 Dalvik 快了一倍,因此说 JIT 也不必定比 AOT 快。
所以这个项目是有很大风险的,若是没有强大的心里和毅力,极有可能半途而废,Google 就曾经想用 JIT 提高 Python 的性能,但最终失败了,对于 Google 来讲用到 Python 的地方其实并没什么性能问题(好吧,之前 Google 是用 Python 写过 crawl [参考 In The Plex],但那都是 1996 年的事情了)。
比起 Google,Facebook 显然有更大的动力和决心,PHP 是 Facebook 最重要的语言,咱们来看看 Facebook 都投入了哪些大牛到这个项目中(不全):
虽然没有像 Lars Bak、Mike Pall 这样在虚拟机领域的顶级专家,但若是这些大牛能齐心合力,写个虚拟机仍是问题不大的,那么他们将面临什么样的挑战呢?接下来咱们一一讨论。
本身写 PHP 虚拟机要面临的第一个问题就是 PHP 没有语言规范,不少版本间的语法还会不兼容(甚至是小版本号,好比 5.2.1 和 5.2.3),PHP 语言规范究竟如何定义呢?来看一篇来自 IEEE 的说法:
The PHP group claim that they have the fi nal say in the speci fi cation of (the language) PHP. This groups speci fi cation is an implementation, and there is no prose speci fi cation or agreed validation suite.
因此惟一的途径就是老老实实去看 Zend 的实现,好在 HPHPc 中已经痛苦过一次了,因此 HHVM 能直接利用现成,所以这个问题并不算太大。
实现 PHP 语言不只仅只是实现一个虚拟机那么简单,PHP 语言自己还包括了各类扩展,这些扩展和语言是一体的,Zend 不辞辛劳地实现了各类你可能会用到的功能。若是分析过 PHP 的代码,就会发现它的 C 代码除去空行注释后竟然还有 80+ 万行,而你猜其中 Zend 引擎部分有多少?只有不到 10 万行。
对于开发者来讲这不是什么坏事,但对于引擎实现者来讲就很悲剧了,咱们能够拿 Java 来进行对比,写个 Java 的虚拟机只需实现字节码解释及一些基础的 JNI 调用,Java 绝大部份内置库都是用 Java 实现的,因此若是不考虑性能优化,单从工做量看,实现 PHP 虚拟机比 JVM 要可贵多,好比就有人用 8 千行的 TypeScript 实现了一个 JVM Doppio。
而对于这个问题,HHVM 的解决办法很简单,那就是只实现 Facebook 中用到的,并且一样能够先用 HPHPc 中以前写过的,因此问题也不大。
接下来是 Interpreter 的实现,在解析完 PHP 后会生成 HHVM 本身设计的一种 Bytecode,存储在 ~/.hhvm.hhbc
(SQLite 文件) 中以便重用,在执行 Bytecode 时和 Zend 相似,也是将不一样的字节码放到不一样的函数中去实现(这种方式在虚拟机中有个专门的称呼:Subroutine threading)
Interpreter 的主体实如今 bytecode.cpp 中,好比 VMExecutionContext::iopAdd
这样的方法,最终执行会根据不一样类型来区分,好比 add 操做的实现是在 tv-arith.cpp 中,下面摘抄其中的一小段
if (c2.m_type == KindOfInt64) return o(c1.m_data.num, c2.m_data.num); if (c2.m_type == KindOfDouble) return o(c1.m_data.num, c2.m_data.dbl);
正是由于有了 Interpreter,HHVM 在对于 PHP 语法的支持上比 HPHPc 有明显改进,理论上作到彻底兼容官方 PHP,但仅这么作在性能并不会比 Zend 好多少,因为没法肯定变量类型,因此须要加上相似上面的条件判断语句,但这样的代码不利于现代 CPU 的执行优化,另外一个问题是数据都是 boxed 的,每次读取都须要经过相似 m_data.num
和 m_data.dbl
的方法来间接获取。
对于这样的问题,就得靠 JIT 来优化了。
首先值得一提的是 PHP 的 JIT 以前并不是没人尝试过:
那么究竟什么是 JIT?如何实现一个 JIT?
在动态语言中基本上都会有个 eval 方法,能够传给它一段字符串来执行,JIT 作的就是相似的事情,只不过它要拼接不是字符串,而是不一样平台下的机器码,而后进行执行,但如何用 C 来实现呢?能够参考 Eli 写的这个入门例子,如下是文中的一段代码:
unsigned char code[] = { 0x48, 0x89, 0xf8, // mov %rdi, %rax 0x48, 0x83, 0xc0, 0x04, // add $4, %rax 0xc3 // ret }; memcpy(m, code, sizeof(code));
然而手工编写机器码很容易出错,因此最好的有一个辅助的库,好比的 Mozilla 的 Nanojit 以及 LuaJIT 的 DynASM,但 HHVM 并无使用这些,而是本身实现了一个只支持 x64 的(另外还在尝试用 VIXL 来生成 ARM 64 位的),经过 mprotect 的方式来让代码可执行。
但为何 JIT 代码会更快?你能够想一想其实用 C++ 编写的代码最终编译出来也是机器码,若是只是将一样的代码手动转成了机器码,那和 GCC 生成出来的有什么区别呢?虽然前面咱们提到了一些针对 CPU 实现原理来优化的技巧,但在 JIT 中更重要的优化是根据类型来生成特定的指令,从而大幅减小指令数和条件判断,下面这张来自 TraceMonkey 的图对此进行了很直观的对比,后面咱们将看到 HHVM 中的具体例子:
HHVM 首先经过 interpeter 来执行,那它会在时候使用 JIT 呢?常见的 JIT 触发条件有 2 种:
关于这两种方法哪一种更好在 Lambada 上有个帖子引来了各路大神的讨论,尤为是 Mike Pall(LuaJIT 做者) 、Andreas Gal(Mozilla VP) 和 Brendan Eich(Mozilla CTO)都发表了不少本身的观点,推荐你们围观,我这里就不献丑了。
它们之间的区别不只仅是编译范围,还有不少细节问题,好比对局部变量的处理,在这里就不展开了
但 HHVM 并无采用这两种方式,而是自创了一个叫 tracelet 的作法,它是根据类型来划分的,看下面这张图
能够看到它将一个函数划分为了 3 部分,上面 2 部分是用于处理 $k
为整数或字符串两种不一样状况的,下面的部分是返回值,因此看起来它主要是根据类型的变化状况来划分 JIT 区域的,具体是如何分析和拆解 Tracelet 的细节能够查看 Translator.cpp 中的 Translator::analyze
方法,我还没空看,这里就不讨论了。
固然,要实现高性能的 JIT 还需进行各类尝试和优化,好比最初 HHVM 新增的 tracelet 会放到前面,也就是将上图的 A 和 C 调换位置,后来尝试了一下放到后面,结果性能提示了 14%,由于测试发现这样更容易提早命中响应的类型
JIT 的执行过程是首先将 HHBC 转成 SSA (hhbc-translator.cpp),而后对 SSA 上作优化(好比 Copy propagation),再生成本地机器码,好比在 X64 下是由 translator-x64.cpp 实现的。
咱们用一个简单的例子来看看 HHVM 最终生成的机器码是怎样的,好比下面这个 PHP 函数:
<?php function a($b){ echo $b + 2; }
编译后是这个样子:
mov rcx,0x7200000 mov rdi,rbp mov rsi,rbx mov rdx,0x20 call 0x2651dfb <HPHP::Transl::traceCallback(HPHP::ActRec*, HPHP::TypedValue*, long, void*)> cmp BYTE PTR [rbp-0x8],0xa jne 0xae00306 ; 前面是检查参数是否有效 mov rcx,QWORD PTR [rbp-0x10] ; 这里将 %rcx 被赋值为 1 了 mov edi,0x2 ; 将 %edi(也就是 %rdi 的低 32 位)赋值为 2 add rdi,rcx ; 加上 %rcx call 0x2131f1b <HPHP::print_int(long)> ; 调用 print_int 函数,这时第一个参数 %rdi 的值已是 3 了 ; 后面暂不讨论 mov BYTE PTR [rbp+0x28],0x8 lea rbx,[rbp+0x20] test BYTE PTR [r12],0xff jne 0xae0032a push QWORD PTR [rbp+0x8] mov rbp,QWORD PTR [rbp+0x0] mov rdi,rbp mov rsi,rbx mov rdx,QWORD PTR [rsp] call 0x236b70e <HPHP::JIT::traceRet(HPHP::ActRec*, HPHP::TypedValue*, void*)> ret
而 HPHP::print_int 函数的实现是这样的:
void print_int(int64_t i) { char buf[256]; snprintf(buf, 256, "%" PRId64, i); echo(buf); TRACE(1, "t-x64 output(int): %" PRId64 "\n", i); }
能够看到 HHVM 编译出来的代码直接使用了 int64_t
,避免了 interpreter 中须要判断参数和间接取数据的问题,从而明显提高了性能,最终甚至作到了和 C 编译出来的代码区别不大。
须要注意:HHVM 在 server mode 下,只有超过 12 个请求就才会触发 JIT,启动过 HHVM 时能够经过加上以下参数来让它首次请求就使用 JIT:
-v Eval.JitWarmupRequests=0
因此在测试性能时须要注意,运行一两次就拿来对比是看不出效果的。
JIT 的关键是猜想类型,所以某个变量的类型要是老变就很难优化,因而 HHVM 的工程师开始考虑在 PHP 语法上作手脚,加上类型的支持,推出了一个新语言 - Hack(吐槽一下这名字真不利于 SEO),它的样子以下:
<?hh class Point2 { public float $x, $y; function __construct(float $x, float $y) { $this->x = $x; $this->y = $y; } } //来自:https://raw.github.com/strangeloop/StrangeLoop2013/master/slides/sessions/Adams-TakingPHPSeriously.pdf
注意到 float
关键字了么?有了静态类型可让 HHVM 更好地优化性能,但这也意味着和 PHP 语法不兼容,只能使用 HHVM。
其实我我的认为这样作最大的优势是让代码更加易懂,减小无心的犯错,就像 Dart 中的可选类型也是这个初衷,同时还方便了 IDE 识别,听说 Facebook 还在开发一个基于 Web 的 IDE,能协同编辑代码,能够期待一下。
总的来讲,比起以前的 HPHPc,我认为 HHVM 是值得一试的,它是真正的虚拟机,可以更好地支持各类 PHP 的语法,因此改动成本不会更高,并且由于能无缝切换到官方 PHP 版本,因此能够同时启动 FPM 来随时待命,HHVM 还有 FastCGI 接口方便调用,只要作好应急备案,风险是可控的,从长远来看是颇有但愿的。
性能究竟能提高多少我没法肯定,须要拿本身的业务代码来进行真实测试,这样才能真正清楚 HHVM 能带来多少收益,尤为是对总体性能提高到底有多少,只有拿到这个数据才能作决策。
最后整理一下可能会遇到的问题,有计划使用的能够参考:
P.S. 其实我只了解基本的虚拟机知识,也没写过几行 PHP 代码,不少东西都是写这篇文章时临时去找资料的,因为时间仓促水平有限,必然会有不正确的地方,欢迎你们评论赐教 :)
2014 年 1 月补充:目前 HHVM 在鄙厂的推广势头很不错,推荐你们在 2014 年尝试一下,尤为是如今兼容性测试已经达到 98.58%了,修改为本进一步减少。
2014 年 4 月补充:配图来自 @reeze,是某超大流量产品的效果。