PHPのオペコードの最適化の深い理解

 

1.概要

Zendの仮想マシンの実装の過程にある動的スクリプト言語としてPHP(PHPのバージョンがケース7.1.3本明細書に記載されている):構文続いて、ワードシンボルに変換するために字句解析を介して、スクリプト文字列を読みます文法的構造の後に見つかった抽象構文木を生成するアナライザは、コンパイラは、静的オペコードを生成し、最後に各オペコードの実行にアナログマシン命令を解釈しました。

チェーン全体では、オペコードは、コンパイラの最適化技術は、このようなデッドコード削除、定数伝播条件、関数のインライン化を合理化するために、様々な最適化、等オペコード、コード実行の性能を向上させる目的として、適用することができる生成しました。

PHP拡張opcache、オペコードのための共有メモリキャッシュの最適化のサポートに基づいて生成されます。これに基づき、それは静的にコンパイルオペコードの最適化に参加しています。最適化は、一般的オプティマイザ(オプティマイザ)は、翻訳の原理、各最適化を説明するための最適パス(オプションパス)の一般的な使用を管理するために本明細書で使用されます。

全体的に、最適化は、2つの方法で渡します。

  • 渡す一方は補助情報変換情報パスのデータフロー、制御フロー解析を提供するために、分析です。
  • 一つは、それは通常前に、削除指示、変更指示あるいは、調整コマンド配列を含む、コード生成を変更し、各パス後に生成されたコードの変更をダンプすることができる、パス変換です。

出発点として、PHP基本単位op_arrayをコンパイルするために設けられオプティマイザopcache拡張、PHPオペコード実行の最小単位と組み合わせコンパイラ理論に基づきます。Zendの仮想マシンでのコンパイラの最適化技術を説明し、さまざまな最適化をコーミングすると、パフォーマンスを改善するために、コード実行のオペコードを最適化する方法をステップバイステップで渡します。最後に、PHP言語の仮想マシンの実行には、いくつかの期待を与えられました。

2.いくつかの概念を説明

1)静的にコンパイル/解釈/時間のコンパイル

スタティック(静的コンパイル)コンパイルされ、また、プリコンパイルされた(事前コンパイラ)として知られている、AOTと呼びます。すなわち、オブジェクトコードにソースコードをコンパイルオブジェクトコードを実行するサポートするプラットフォーム上で実行されています。

動的コンパイル(動的コンパイル)、静的コンパイラに比べて、「実行時にコンパイルする」という意味です。典型的に使用インタプリタ(インタプリタ)コンパイラの実装では、ソース言語の解釈ものをいいます。

JITコンパイラ(実行時コンパイラ)、すなわち時間のコンパイル、狭義には、コードのコンパイル時の部分を指し、最初に実行されようとしている、そして、あなたが直接実行をコンパイルする必要はありません、それは動的コンパイルの特殊なケースです。

異なるコンパイラの3種類の処理を実行し、以下に示すように、一般的に記述することができます。ALT

2)データ・フロー/制御フロー

コンパイラの最適化は、すべてのコンパイラの最適化の基礎であるプログラムからの十分な情報を取得する必要があります。

構文木のコンパイラのフロントエンドによって生成された結果はまた、いくつかの低い中間コードでもよいです。しかし、関係なく結果、何をすべきかのプログラムを形成するもの、まだそれを行うにはどのくらいの情報を提供しません。コンパイラは、各プロセス内業務フロー制御階層を見つけるフロー分析を制御するために左に、グローバル情報処理タスクデータフロー解析に関連するデータが残って決定します。

  • 制御フロー解析依存性の分析に基づいて、データフロー解析のために、正式な構成情報取得プログラム制御です。制御の基本的なモデルは、制御フローグラフ(制御フローグラフCFG)です。制御フロー解析は、単一の処理サイクルの使用は、ノード、間隔解析二つの方法を見つけることが必要であったです。

  • データは、プログラムコードから意味情報収集プログラムを流れ、および変数の定義は、代数的で使用してコンパイル。基本モデルは、データ(データフローグラフ、DFG)のデータフロー図です。通常のデータフロー解析は、解析ツリーコントロール(コントロールツリーベースのデータフロー解析)に基づいて、解析インターバル解析アルゴリズムは、二つの構造体に分割されています。

3)op_array

C言語のスタックフレーム(スタック・フレーム)の概念に似て、すなわち基本単位(a)のプログラムを実行して、基本的な単位は、一般に、関数呼び出しです。ここでは、PHPのコードを表すevalに渡された関数やメソッド、全体のPHPスクリプトファイルには、文字列op_arrayにコンパイルされます。

ユニットの基本的な動作はもちろんのプログラムのすべての情報が含まれている構造で、最も重要な分野の構造のためのオペコードの配列ですが、ほかにも変数の型、注釈、異常なキャプチャ情報情報をジャンプが含ま達成Op_array。

4)オペコード

最小最適オペコード基本単位op_array内で実行されるインタプリタ(ZendVM)プロセスによって実行される、現在のオペコードの順次実行、実行を横断し、最後RETRUN戻し出口この特定のオペコードまで、次のオペコードをプリフェッチします。

这里的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

オペコードは、このような複雑な分析作業を想定しているので、オペコードが正規化中間体(中間表現、IR)を示す複数の層に分解することができます。各アプリケーションは、中間表現を最適化するように選択することができる層、搬送される情報の量に基づいて、従来のコンパイラの中間表現、ハイレベルの中間表現(HIR)、中間体中間表現(MIR)に、高レベル言語に抽象マシンコード近い、より低い中間体表現(LIR)。

パス管理

先に図1で説明したように本をクジラ、オペコードパスの管理を最適化する、改善の余地があるべきです。データフロー解析/制御フロー解析の電流依存性は、最適化分析はまだこのようなプロセスは、そのような管理パスシーケンスの実行時に、実行時間、登録管理、複雑なパスダンプ解析情報と静止フレーム成熟LLVMに関して等と同様に、不足しているが、大きなギャップ。

産業大臣

ZendVMは、LLVM走行用のマシンコードにコンパイルすることができ、これにより、等、スイッチングの種類を多数zvalの値を達成するが、速度拡張を犠牲に時間をコンパイル。もちろん、libjit使用すること。

おすすめ

転載: www.cnblogs.com/shixiuxian/p/11230939.html