深入理解 PHP opcode 優化

有贊技術團隊部落格發表於2017-06-02

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),即即時編譯,狹義指某段程式碼即將第一次被執行時進行編譯,而後則不用編譯直接執行,它為動態編譯的一種特例。

上述三類不同編譯執行流程,可大體如下圖來描述:

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;

上述三個內容資訊即作為輸入引數傳遞給優化器供其分析優化。當然與通常的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
    |清理資源

該部分主要呼叫了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執行與靜態編譯成機器碼執行的區別,如下圖:

alt

型別推斷

在不改變現有opcode設計的前提下,加強型別推斷能力,進而為opcode的執行提供更多的型別資訊,是提高執行效能的可選方法之一。

多層opcode

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

pass管理

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

JIT

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

相關文章