PHP(本文所述案例PHP版本均为7.1.3)做为一门动态脚本语言,其在zend虚拟机执行过程为:读入脚本程序字符串,经由词法分析器将其转换为单词符号,接着语法分析器从中发现语法结构后生成抽象语法树,再经静态编译器生成opcode,最后经解释器模拟机器指令来执行每一条opcode。php
在上述整个环节中,生成的opcode能够应用编译优化技术如死代码删除、条件常量传播、函数内联等各类优化来精简opcode,达到提升代码的执行性能的目的。前端
PHP扩展opcache,针对生成的opcode基于共享内存支持了缓存优化。在此基础上又加入了opcode的静态编译优化。这里所述优化一般采用优化器(Optimizer)来管理,编译原理中,通常用优化遍(Opt pass)来描述每个优化。算法
总体上说,优化遍分两种:后端
本文基于编译原理,结合opcache扩展提供的优化器,以PHP编译基本单位op_array、PHP执行最小单位opcode为出发点。介绍编译优化技术在Zend虚拟机中的应用,梳理各个优化遍是如何一步步优化opcode来提升代码执行性能的。最后结合PHP语言虚拟机执行给出几点展望。数组
静态编译(static compilation),也称事前编译(ahead-of-time compilation),简称AOT。即把源代码编译成目标代码,执行时在支持目标代码的平台上运行。缓存
动态编译(dynamic compilation),相对于静态编译而言,指”在运行时进行编译”。一般状况下采用解释器(interpreter)编译执行,它是指一条一条的解释执行源语言。框架
JIT编译(just-in-time compilation),即即时编译,狭义指某段代码即将第一次被执行时进行编译,然后则不用编译直接执行,它为动态编译的一种特例。less
上述三类不一样编译执行流程,可大致以下图来描述:函数
编译优化须要从程序中获取足够多的信息,这是全部编译优化的根基。工具
编译器前端产生的结果能够是语法树亦能够是某种低级中间代码。但不管结果什么形式,它对程序作什么、如何作仍然没有提供多少信息。编译器将发现每个过程内控制流层次结构的任务留给控制流分析,将肯定与数据处理有关的全局信息任务留给数据流分析。
控制流 是获取程序控制结构信息的形式化分析方法,它为数据流分析、依赖分析的基础。控制的一个基本模型是控制流图(Control Flow Graph,CFG)。单一过程的控制流分析有使用必经结点找循环、区间分析两种途径。
数据流 从程序代码中收集程序的语义信息,并经过代数的方法在编译时肯定变量的定义和使用。数据的一个基本模型是数据流图(Data Flow Graph,DFG)。一般的数据流分析是基于控制树的分析(Control-tree-based data-flow analysis),算法分为区间分析与结构分析两种。
相似于C语言的栈帧(stack frame)概念,即一个运行程序的基本单位(一帧),通常为一次函数调用的基本单位。此处,一个函数或方法、整个PHP脚本文件、传给eval表示PHP代码的字符串都会被编译成一个op_array。
实现上op_array为一个包含程序运行基本单位的全部信息的结构体,固然opcode数组为该结构最为重要的字段,不过除此以外还包含变量类型、注释信息、异常捕获信息、跳转信息等。
解释器执行(ZendVM)过程便是执行一个基本单位op_array内的最小优化opcode,按顺序遍历执行,执行当前opcode,会预取下一条opcode,直到最后一个RETRUN这个特殊的opcode返回退出。
这里的opcode某种程度也相似于静态编译器里的中间表示(相似于LLVM IR),一般也采用三地址码的形式,即包含一个操做符,两个操做数及一个运算结果。其中两个操做数均包含类型信息。此处类型信息有五种,分别为:
类型信息与操做符一块儿,供执行器匹配选择特定已编译好的C函数库模板,模拟生成机器指令来执行。
opcode在ZendVM中以zend_op结构体来表征,其主体结构以下:
PHP脚本通过词法分析、语法分析生成抽象语法树结构后,再经静态编译生成opcode。它做为向不一样的虚拟机执行指令的公共平台,依赖不一样的虚拟机具体实现(然对于PHP来讲,大部分是指ZendVM)。
在虚拟机执行opcode以前,若是对opcode进行优化可获得执行效率更高的代码,pass的做用就是优化opcode,它做用于opcde、处理opcode、分析opcode、寻找优化的机会并修改opcode产生更高执行效率的代码。
在Zend虚拟机(ZendVM)中,opcache的静态代码优化器即为zend opcode optimization。
为观察优化效果及便于调试,它也提供了优化与调试选项:
执行静态优化所需的脚本上下文信息则封装在结构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:
关于opcode缓存,也是opcode很是重要的优化。其基本应用原理是大致以下:
虽然PHP做为动态脚本语言,它并不会直接调用GCC/LLVM这样的整套编译器工具链,也不会调用Javac这样的纯前端编译器。但每次请求执行PHP脚本时,都经历过词法、语法、编译为opcode、VM执行的完整生命周期。
除去执行外的前三个步骤基本就是一个前端编译器的完整过程,然而这个编译过程并不会快。假如反复执行相同的脚本,前三个步骤编译耗时将严重制约运行效率,而每次编译生成的opcode则没有变化。所以可在第一次编译时把opcode缓存到某一个地方,opcache扩展便是将其缓存到共享内存(Java则是保存到文件中),下次执行相同脚本时直接从共享内存中获取opcode,从而省去编译时间。
opcache扩展的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。
一般在函数调用过程当中,因为须要进行不一样栈帧间切换,所以会有开辟栈空间、保存返回地址、跳转、返回到调用函数、返回值、回收栈空间等一系列函数调用开销。所以对于函数体适当大小状况下,把整个函数体嵌入到调用者(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一致。
根据以上描述,可见向当前优化器加入一个pass并不会太难,大致步骤以下:
如下是对基于动态的PHP脚本程序执行的一些见解,仅供参考。
因为LLVM从前端到后端,从静态编译到jit整个工具链框架的支持,使得许多语言虚拟机都尝试整合。当前PHP7时代的ZendVM官方还没采用,缘由之一虚拟机opcode承载着至关复杂的分析工做。相比于静态编译器的机器码每一条指令一般只干一件事情(一般是CPU指令时钟周期),opcode的操做数(operand)因为类型不固定,须要在运行期间作大量的类型检查、转换才能进行运算,这极度影响了执行效率。即便运行时采用jit,以byte code为单位编译,编译出的字节码也会与现有解释器一条一条opcode处理相似,类型须要处理、也不能把zval值直接存在寄存器。
以函数调用为例,比较现有的opcode执行与静态编译成机器码执行的区别,以下图:
在不改变现有opcode设计的前提下,增强类型推断能力,进而为opcode的执行提供更多的类型信息,是提升执行性能的可选方法之一。
既然opcode承担如此复杂的分析工做,可否将其分解成多层的opcode归一化中间表示( intermediate representation, IR)。各优化可选择应用哪一层中间表示,传统编译器的中间表示依据所携带信息量、从抽象的高级语言到贴近机器码,分红高级中间表示(HIR) 、中级中间表示(MIR)、低级中间表示(LIR)。
关于opcode的优化pass管理,如前文鲸书图所述,应该尚有改进空间。虽然当前分析依赖的有数据流/控制流分析,但仍缺乏诸如过程间的分析优化,pass管理如运行顺序、运行次数、注册管理、复杂pass分析的信息dump等相对于llvm等成熟框架仍有较大差距。
ZendVM实现大量的zval值、类型转换等操做,这些可借助LLVM编译成机器码用于运行时,但代价是编译时间极速膨胀。固然也可采用libjit。