首頁  >  文章  >  後端開發  >  php中關於opcode優化的深入理解(圖文)

php中關於opcode優化的深入理解(圖文)

黄舟
黄舟原創
2017-07-18 15:33:162184瀏覽

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

上述三類不同編譯執行流程,可大體如下圖來描述:php中關於opcode優化的深入理解(圖文)

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結構體來表徵,其主體結構如下:
php中關於opcode優化的深入理解(圖文)

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=0xFFFFFFFFFF )最佳化級別,預設開啟大部分最佳化遍,使用者也透過傳入命令列參數控制關閉

  • #optdebuglevel (opcache.opt# debuglevel=-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 快取流程大致如下:
php中關於opcode優化的深入理解(圖文)由於本文主要集中討論靜態最佳化遍,關於快取最佳化的具體實作此處不展開。

2)ZendVM最佳化器原理

依「鯨書」(《高階編譯器設計與實作》)所述,一個最佳化編譯器較為合理的最佳化遍順序如下:php中關於opcode優化的深入理解(圖文)
上圖中涉及的最佳化從簡單的常數、死程式碼到循環、分支跳轉,從函數呼叫到過程間優化,從預取、快取到軟流水、暫存器分配,當然也包含資料流、控制流分析。

當然,目前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执行与静态编译成机器码执行的区别,如下图:php中關於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中關於opcode優化的深入理解(圖文)的詳細內容。更多資訊請關注PHP中文網其他相關文章!

陳述:
本文內容由網友自願投稿,版權歸原作者所有。本站不承擔相應的法律責任。如發現涉嫌抄襲或侵權的內容,請聯絡admin@php.cn