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,是某超大流量產品的效果。