深刻理解PHP opcode優化

 

1.概述

PHP(本文所述案例PHP版本均爲7.1.3)做爲一門動態腳本語言,其在zend虛擬機執行過程爲:讀入腳本程序字符串,經由詞法分析器將其轉換爲單詞符號,接着語法分析器從中發現語法結構後生成抽象語法樹,再經靜態編譯器生成opcode,最後經解釋器模擬機器指令來執行每一條opcode。php

在上述整個環節中,生成的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),即即時編譯,狹義指某段代碼即將第一次被執行時進行編譯,然後則不用編譯直接執行,它爲動態編譯的一種特例。less

上述三類不一樣編譯執行流程,可大致以下圖來描述:alt函數

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結構體來表徵,其主體結構以下: 
alt

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; 
123456

上述三個內容信息即做爲輸入參數傳遞給優化器供其分析優化。固然與一般的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 緩存流程大體以下: 
alt因爲本文主要集中討論靜態優化遍,關於緩存優化的具體實現此處不展開。

2)ZendVM優化器原理

依「鯨書」(《高級編譯器設計與實現》)所述,一個優化編譯器較爲合理的優化遍順序以下:alt
上圖中涉及的優化從簡單的常量、死代碼到循環、分支跳轉,從函數調用到過程間優化,從預取、緩存到軟流水、寄存器分配,固然也包含數據流、控制流分析。

固然,當前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 |清理資源 
1234567891011121314151617181920212223

該部分主要調用了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 壓縮常量表優化 
1234567891011121314151617181920212223242526272829303132

還有其餘一些優化遍以下:

優化pass12   矯正棧大小
優化pass15   收集常量信息
優化pass16   函數調用優化,主要是函數內聯優化
123

除此以外,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) 
1234567891011121314151617

以下示例代碼,當調用fname()時,使用字符串變量名fname來動態調用函數foo,而沒有使用直接調用的方式。此時可經過VLD擴展查看其生成的opcode,或打開opcache調試選項(opcache.optdebuglevel=0xFFFFFFFF)亦可查看。

function foo() { } $fname = 'foo'; 
12

開啓debug後dump可看出,發生函數調用優化前opcode序列(僅截取片斷)爲:

ASSIGN CV0($fname) string("foo") INIT_FCALL_BY_NAME 0 CV0($fname) DO_FCALL_BY_NAME 
123

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執行與靜態編譯成機器碼執行的區別,以下圖:alt

類型推斷

在不改變現有opcode設計的前提下,增強類型推斷能力,進而爲opcode的執行提供更多的類型信息,是提升執行性能的可選方法之一。

多層opcode

既然opcode承擔如此複雜的分析工做,可否將其分解成多層的opcode歸一化中間表示( intermediate representation, IR)。各優化可選擇應用哪一層中間表示,傳統編譯器的中間表示依據所攜帶信息量、從抽象的高級語言到貼近機器碼,分紅高級中間表示(HIR) 、中級中間表示(MIR)、低級中間表示(LIR)。

pass管理

關於opcode的優化pass管理,如前文鯨書圖所述,應該尚有改進空間。雖然當前分析依賴的有數據流/控制流分析,但仍缺乏諸如過程間的分析優化,pass管理如運行順序、運行次數、註冊管理、複雜pass分析的信息dump等相對於llvm等成熟框架仍有較大差距。

JIT

ZendVM實現大量的zval值、類型轉換等操做,這些可藉助LLVM編譯成機器碼用於運行時,但代價是編譯時間極速膨脹。固然也可採用libjit。

相關文章
相關標籤/搜索