深入理解 PHP opcode 優化
1.概述
PHP(本文所述案例PHP版本均為7.1.3)作為一門動態指令碼語言,其在zend虛擬機器執行過程為:讀入指令碼程式字串,經由詞法分析器將其轉換為單詞符號,接著語法分析器從中發現語法結構後生成抽象語法樹,再經靜態編譯器生成opcode,最後經直譯器模擬機器指令來執行每一條opcode。
在上述整個環節中,生成的opcode可以應用編譯優化技術如死程式碼刪除、條件常量傳播、函式內聯等各種優化來精簡opcode,達到提高程式碼的執行效能的目的。
PHP擴充套件opcache,針對生成的opcode基於共享記憶體支援了快取優化。在此基礎上又加入了opcode的靜態編譯優化。這裡所述優化通常採用優化器(Optimizer)來管理,編譯原理中,一般用優化遍(Opt pass)來描述每一個優化。
整體上說,優化遍分兩種:
- 一種是分析pass,是提供資料流、控制流分析資訊為轉換pass提供輔助資訊;
- 一種是轉換pass,它會改變生成程式碼,包括增刪指令、改變替換指令、調整指令順序等,通常每一個pass前後可dump出生成程式碼的變化。
本文基於編譯原理,結合opcache擴充套件提供的優化器,以PHP編譯基本單位op_array、PHP執行最小單位opcode為出發點。介紹編譯優化技術在Zend虛擬機器中的應用,梳理各個優化遍是如何一步步優化opcode來提高程式碼執行效能的。最後結合PHP語言虛擬機器執行給出幾點展望。
2.幾個概念說明
1)靜態編譯/解釋執行/即時編譯
靜態編譯(static compilation),也稱事前編譯(ahead-of-time compilation),簡稱AOT。即把原始碼編譯成目的碼,執行時在支援目的碼的平臺上執行。
動態編譯(dynamic compilation),相對於靜態編譯而言,指”在執行時進行編譯”。通常情況下采用直譯器(interpreter)編譯執行,它是指一條一條的解釋執行源語言。
JIT編譯(just-in-time compilation),即即時編譯,狹義指某段程式碼即將第一次被執行時進行編譯,而後則不用編譯直接執行,它為動態編譯的一種特例。
上述三類不同編譯執行流程,可大體如下圖來描述:
2)資料流/控制流
編譯優化需要從程式中獲取足夠多的資訊,這是所有編譯優化的根基。
編譯器前端產生的結果可以是語法樹亦可以是某種低階中間程式碼。但無論結果什麼形式,它對程式做什麼、如何做仍然沒有提供多少資訊。編譯器將發現每一個過程內控制流層次結構的任務留給控制流分析,將確定與資料處理有關的全域性資訊任務留給資料流分析。
- 控制流 是獲取程式控制結構資訊的形式化分析方法,它為資料流分析、依賴分析的基礎。控制的一個基本模型是控制流圖(Control Flow Graph,CFG)。單一過程的控制流分析有使用必經結點找迴圈、區間分析兩種途徑。
- 資料流 從程式程式碼中收集程式的語義資訊,並通過代數的方法在編譯時確定變數的定義和使用。資料的一個基本模型是資料流圖(Data Flow Graph,DFG)。通常的資料流分析是基於控制樹的分析(Control-tree-based data-flow analysis),演算法分為區間分析與結構分析兩種。
3)op_array
類似於C語言的棧幀(stack frame)概念,即一個執行程式的基本單位(一幀),一般為一次函式呼叫的基本單位。此處,一個函式或方法、整個PHP指令碼檔案、傳給eval表示PHP程式碼的字串都會被編譯成一個op_array。
實現上op_array為一個包含程式執行基本單位的所有資訊的結構體,當然opcode陣列為該結構最為重要的欄位,不過除此之外還包含變數型別、註釋資訊、異常捕獲資訊、跳轉資訊等。
4)opcode
直譯器執行(ZendVM)過程即是執行一個基本單位op_array內的最小優化opcode,按順序遍歷執行,執行當前opcode,會預取下一條opcode,直到最後一個RETRUN這個特殊的opcode返回退出。
這裡的opcode某種程度也類似於靜態編譯器裡的中間表示(類似於LLVM IR),通常也採用三地址碼的形式,即包含一個操作符,兩個運算元及一個運算結果。其中兩個運算元均包含型別資訊。此處型別資訊有五種,分別為:
- 編譯變數(Compiled Variable,簡稱CV),編譯時變數即為php指令碼中定義的變數。
- 內部可重用變數(VAR),供ZendVM使用的臨時變數,可與其它opcode共用。
- 內部不可重用變數(TMP_VAR),供ZendVM使用的臨時變數,不可與其它opcode共用。
- 常量(CONST),只讀常量,值不可被更改。
- 無用變數(UNUSED)。由於opcode採用三地址碼,不是每一個opcode均有運算元欄位,預設時用該變數補齊欄位。
型別資訊與操作符一起,供執行器匹配選擇特定已編譯好的C函式庫模板,模擬生成機器指令來執行。
opcode在ZendVM中以zend_op結構體來表徵,其主體結構如下:
3.opcache optimizer優化器
PHP指令碼經過詞法分析、語法分析生成抽象語法樹結構後,再經靜態編譯生成opcode。它作為向不同的虛擬機器執行指令的公共平臺,依賴不同的虛擬機器具體實現(然對於PHP來說,大部分是指ZendVM)。
在虛擬機器執行opcode之前,如果對opcode進行優化可得到執行效率更高的程式碼,pass的作用就是優化opcode,它作用於opcde、處理opcode、分析opcode、尋找優化的機會並修改opcode產生更高執行效率的程式碼。
1)ZendVM優化器簡介
在Zend虛擬機器(ZendVM)中,opcache的靜態程式碼優化器即為zend opcode optimization。
為觀察優化效果及便於除錯,它也提供了優化與除錯選項:
- optimizationlevel (opcache.optimizationlevel=0xFFFFFFFF) 優化級別,預設開啟大部分優化遍,使用者亦通過傳入命令列引數控制關閉
- optdebuglevel (opcache.optdebuglevel=-1) 除錯級別,預設不開啟,但提供了各優化前後opcode的變換過程
執行靜態優化所需的指令碼上下文資訊則封裝在結構zend_script中,如下:
typedef struct _zend_script { zend_string *filename; //檔名 zend_op_array main_op_array; //棧幀 HashTable function_table; //函式單位符號表資訊 HashTable class_table; //類單位符號表資訊 } zend_script;
上述三個內容資訊即作為輸入引數傳遞給優化器供其分析優化。當然與通常的PHP擴充套件類似,它與opcode快取模組一起(zend_accel)構成了opcache擴充套件。其在快取加速器內嵌入了三個內部API:
- zendoptimizerstartup 啟動優化器
- zendoptimizescript 優化器實現優化的主邏輯
- zendoptimizershutdown 優化器產生的資源清理
關於opcode快取,也是opcode非常重要的優化。其基本應用原理是大體如下:
雖然PHP作為動態指令碼語言,它並不會直接呼叫GCC/LLVM這樣的整套編譯器工具鏈,也不會呼叫Javac這樣的純前端編譯器。但每次請求執行PHP指令碼時,都經歷過詞法、語法、編譯為opcode、VM執行的完整生命週期。
除去執行外的前三個步驟基本就是一個前端編譯器的完整過程,然而這個編譯過程並不會快。假如反覆執行相同的指令碼,前三個步驟編譯耗時將嚴重製約執行效率,而每次編譯生成的opcode則沒有變化。因此可在第一次編譯時把opcode快取到某一個地方,opcache擴充套件即是將其快取到共享記憶體(Java則是儲存到檔案中),下次執行相同指令碼時直接從共享記憶體中獲取opcode,從而省去編譯時間。
opcache擴充套件的opcode 快取流程大致如下:
由於本文主要集中討論靜態優化遍,關於快取優化的具體實現此處不展開。
2)ZendVM優化器原理
依“鯨書”(《高階編譯器設計與實現》)所述,一個優化編譯器較為合理的優化遍順序如下:
上圖中涉及的優化從簡單的常量、死程式碼到迴圈、分支跳轉,從函式呼叫到過程間優化,從預取、快取到軟流水、暫存器分配,當然也包含資料流、控制流分析。
當然,當前opcode優化器並沒有實現上述所有優化遍,而且也沒有必要實現機器相關的低層中間表示優化如暫存器分配。
opcache優化器接收到上述指令碼引數資訊後,找到最小編譯單位。以此為基礎,根據優化pass巨集及其對應的優化級別巨集,即可實現對某一個pass的註冊控制。
註冊的優化中,按一定順序組織串聯各優化,包含常量優化、冗餘nop刪除、函式呼叫優化的轉換pass,及資料流分析、控制流分析、呼叫關係分析等分析pass。
zendoptimizescript及實際的優化註冊zend_optimize流程如下:
zend_optimize_script(zend_script *script, zend_long optimization_level, zend_long debug_level) |zend_optimize_op_array(&script->main_op_array, &ctx); 遍歷二元操作符的常量運算元,由執行時轉化為編譯時(反向pass2) 實際優化pass,zend_optimize 遍歷二元操作符的常量運算元,由編譯時轉化為執行時(pass2) |遍歷op_array內函式zend_optimize_op_array(op_array, &ctx); |遍歷類內非使用者函式zend_optimize_op_array(op_array, &ctx); (使用者函式設static_variables) |若使用DFA pass & 呼叫圖pass & 構建呼叫圖成功 遍歷二元操作符的常量運算元,由執行時轉化為編譯時(反向pass2) 設定函式返回值資訊,供SSA資料流分析使用 遍歷呼叫圖的op_array,做DFA分析zend_dfa_analyze_op_array 遍歷呼叫圖的op_array,做DFA優化zend_dfa_optimize_op_array 若開除錯,遍歷dump呼叫圖的每一個op_array(優化變換後) 若開棧矯正優化,矯正棧大小adjust_fcall_stack_size_graph 再次遍歷呼叫圖內的的所有op_array, 針對DFA pass變換後新產生的常量場景,常量優化pass2再跑一遍 呼叫圖op_array資源清理 |若開棧矯正優化 矯正棧大小main_op_array 遍歷矯正棧大小op_array |清理資源
該部分主要呼叫了SSA/DFA/CFG這幾類用於opcode分析pass,涉及的pass有BB塊、CFG、DFA(CFG、DOMINATORS、LIVENESS、PHI-NODE、SSA)。
用於opcode轉換的pass則集中在函式zend_optimize內,如下:
zend_optimize |op_array型別為ZEND_EVAL_CODE,不做優化 |開debug, 可dump優化前內容 |優化pass1, 常量替換、編譯時常量操作變換、簡單操作轉換 |優化pass2 常量操作轉換、條件跳轉指令優化 |優化pass3 跳轉指令優化、自增轉換 |優化pass4 函式呼叫優化(主要為函式呼叫優化) |優化pass5 控制流圖(CFG)優化 |構建流圖 |計算資料依賴 |劃分BB塊(basic block,簡稱BB,資料流分析基本單位) |BB塊內基於資料流分析優化 |BB塊間跳轉優化 |不可到達BB塊刪除 |BB塊合併 |BB塊外變數檢查 |重新構建優化後的op_array(基於CFG) |析構CFG |優化pass6/7 資料流分析優化 |資料流分析(基於靜態單賦值SSA) |構建SSA |構建CFG 需要找到對應BB塊序號、管理BB塊陣列、計算BB塊後繼BB、標記可到達BB塊、計算BB塊前驅BB |計算Dominator樹 |標識迴圈是否可簡化(主要依賴於迴圈回邊) |基於phi節點構建完SSA def集、phi節點位置、SSA構造重新命名 |計算use-def鏈 |尋找不當依賴、後繼、型別及值範圍值推斷 |資料流優化 基於SSA資訊,一系列BB塊內opcode優化 |析構SSA |優化pass9 臨時變數優化 |優化pass10 冗餘nop指令刪除 |優化pass11 壓縮常量表優化
還有其他一些優化遍如下:
優化pass12 矯正棧大小 優化pass15 收集常量資訊 優化pass16 函式呼叫優化,主要是函式內聯優化
除此之外,pass 8/13/14可能為預留pass id。由此可看出當前提供給使用者選項控制的opcode轉換pass有13個。但是這並不計入其依賴的資料流/控制流的分析pass。
3)函式內聯pass的實現
通常在函式呼叫過程中,由於需要進行不同棧幀間切換,因此會有開闢棧空間、儲存返回地址、跳轉、返回到呼叫函式、返回值、回收棧空間等一系列函式呼叫開銷。因此對於函式體適當大小情況下,把整個函式體嵌入到呼叫者(Caller)內部,從而不實際呼叫被呼叫者(Callee)是一個提升效能的利器。
由於函式呼叫與目標機的應用二進位制介面(ABI)強相關,靜態編譯器如GCC/LLVM的函式內聯優化基本是在指令生成之前完成。
ZendVM的內聯則發生在opcode生成後的FCALL指令的替換優化,pass id為16,其原理大致如下:
| 遍歷op_array中的opcode,找到DO_XCALL四個opcode之一 | opcode ZEND_INIT_FCALL | opcode ZEND_INIT_FCALL_BY_NAMEZ | 新建opcode,操作碼置為ZEND_INIT_FCALL,計算棧大小, 更新快取槽位,析構常量池字面量,替換當前opline的opcode | opcode ZEND_INIT_NS_FCALL_BY_NAME | 新建opcode,操作碼置為ZEND_INIT_FCALL,計算棧大小, 更新快取槽位,析構常量池字面量,替換當前opline的opcode | 嘗試函式內聯 | 優化條件過濾 (每個優化pass通常有較多限制條件,某些場景下 由於缺乏足夠資訊不能優化或出於代價考慮而排除) | 方法呼叫ZEND_INIT_METHOD_CALL,直接返回不內聯 | 引用傳參,直接返回不內聯 | 預設引數為命名常量,直接返回不內聯 | 被呼叫函式有返回值,新增一條ZEND_QM_ASSIGN賦值opcode | 被呼叫函式無返回值,插入一條ZEND_NOP空opcode | 刪除呼叫被行內函數的call opcode(即當前online的前一條opcode)
如下示例程式碼,當呼叫fname()時,使用字串變數名fname來動態呼叫函式foo,而沒有使用直接呼叫的方式。此時可通過VLD擴充套件檢視其生成的opcode,或開啟opcache除錯選項(opcache.optdebuglevel=0xFFFFFFFF)亦可檢視。
function foo() { } $fname = 'foo';
開啟debug後dump可看出,發生函式呼叫優化前opcode序列(僅擷取片段)為:
ASSIGN CV0($fname) string("foo") INIT_FCALL_BY_NAME 0 CV0($fname) DO_FCALL_BY_NAME
INIT_FCALL_BY_NAME這條opcode執行邏輯較為複雜,當開啟激進內聯優化後,可將上述指令序列直接合併成一條DO_FCALL string(“foo”)指令,省去間接呼叫的開銷。這樣也恰好與直接呼叫生成的opcode一致。
4)如何為opcache opt新增一個優化pass
根據以上描述,可見向當前優化器加入一個pass並不會太難,大體步驟如下:
- 先向zend_optimize優化器註冊一個pass巨集(例如新增pass17),並決定其優化級別。
- 在優化管理器某個優化pass前後呼叫加入的pass(例如新增一個尾遞迴優化pass),建議在DFA/SSA分析pass之後新增,因為此時獲得的優化資訊更多。
- 實現新加入的pass,進行定製程式碼轉換(例如zendoptimizefunc_calls實現一個尾遞迴優化)。針對當前已有pass,主要新增轉換pass,這裡一般也可利用SSA/DFA的資訊。不同於靜態編譯優化一般是在貼近於機器相關的低層中間表示優化,這裡主要是在opcode層的opcode/operand相應的一些轉換。
- 實現pass前,與函式內聯類似,通常首先收集優化所需資訊,然後排除掉不適用該優化的一些場景(如非真正的尾不遞迴呼叫、引數問題無法做優化等)。實現優化後,可dump優化前後生成opcode結構的變化是否優化正確、是否符合預期(如尾遞迴優化最終的效果是變換函式呼叫為forloop的形式)。
4.一點思考
以下是對基於動態的PHP指令碼程式執行的一些看法,僅供參考。
由於LLVM從前端到後端,從靜態編譯到jit整個工具鏈框架的支援,使得許多語言虛擬機器都嘗試整合。當前PHP7時代的ZendVM官方還沒采用,原因之一虛擬機器opcode承載著相當複雜的分析工作。相比於靜態編譯器的機器碼每一條指令通常只幹一件事情(通常是CPU指令時鐘週期),opcode的運算元(operand)由於型別不固定,需要在執行期間做大量的型別檢查、轉換才能進行運算,這極度影響了執行效率。即使執行時採用jit,以byte code為單位編譯,編譯出的位元組碼也會與現有直譯器一條一條opcode處理類似,型別需要處理、也不能把zval值直接存在暫存器。
以函式呼叫為例,比較現有的opcode執行與靜態編譯成機器碼執行的區別,如下圖:
型別推斷
在不改變現有opcode設計的前提下,加強型別推斷能力,進而為opcode的執行提供更多的型別資訊,是提高執行效能的可選方法之一。
多層opcode
既然opcode承擔如此複雜的分析工作,能否將其分解成多層的opcode歸一化中間表示( intermediate representation, IR)。各優化可選擇應用哪一層中間表示,傳統編譯器的中間表示依據所攜帶資訊量、從抽象的高階語言到貼近機器碼,分成高階中間表示(HIR) 、中級中間表示(MIR)、低階中間表示(LIR)。
pass管理
關於opcode的優化pass管理,如前文鯨書圖所述,應該尚有改進空間。雖然當前分析依賴的有資料流/控制流分析,但仍缺少諸如過程間的分析優化,pass管理如執行順序、執行次數、註冊管理、複雜pass分析的資訊dump等相對於llvm等成熟框架仍有較大差距。
JIT
ZendVM實現大量的zval值、型別轉換等操作,這些可藉助LLVM編譯成機器碼用於執行時,但代價是編譯時間極速膨脹。當然也可採用libjit。
相關文章
- php核心分析(六)-opcodePHP
- PHP快取之Opcode快取PHP快取
- 深入理解JVM(③)Java的鎖優化JVMJava優化
- 深入理解 Activty 載入速度優化優化
- 深入理解圖優化與g2o:圖優化篇優化
- 深入理解PHP物件注入PHP物件
- 深入理解flutter的編譯原理與優化Flutter編譯原理優化
- 深入理解PHP之strposPHP
- 深入理解PHP之foreachPHP
- php優化PHP優化
- 深入理解oracle優化器統計資料(Optimizer Statistics)Oracle優化
- 深入理解JVM效能調優JVM
- jvm優化理解JVM優化
- 深入理解 Linux 核心--jemalloc 引起的 TLB shootdown 及優化Linux優化
- php效能優化PHP優化
- PHP 效能優化PHP優化
- 深入理解css優先順序CSS
- 深入理解PHP中賦值與引用PHP賦值
- 深入理解JVM讀書筆記四: (早期)編譯器優化JVM筆記編譯優化
- 深入理解 Java 序列化Java
- 深入理解redis的持久化Redis持久化
- 理解索引:索引優化索引優化
- PHP 效能優化 - OPcachePHP優化opcache
- php-fpm優化PHP優化
- PHP優化雜燴PHP優化
- PHP效能優化技巧PHP優化
- [深入理解Java虛擬機器]第十三章 執行緒安全與鎖優化-鎖優化Java虛擬機執行緒優化
- PHP 效能優化 - php.ini 配置PHP優化
- 深入理解Java虛擬機器(程式編譯與程式碼優化)Java虛擬機編譯優化
- 深入理解圖優化與g2o:g2o篇優化
- 深入理解JVM(③)Java的模組化JVMJava
- 深入理解模組化程式設計程式設計
- 深入理解Java物件序列化Java物件
- iOS-效能優化深入探究iOS優化
- 深入理解 PHP 的 7 個預定義介面PHP
- PHP記憶體優化PHP記憶體優化
- PHP.ini效能優化PHP優化
- [深入理解Java虛擬機器]第十一章 程式編譯與程式碼優化-晚期(執行期)優化Java虛擬機編譯優化