1 | ┌─────────────────────────────┐ |
PHP内核对字面量的优化
前几天,我发现PHP
内核在处理字面量的时候是比较简单粗暴的,编译出一个常量,就直接把它放到literals
里面了。那么这就会导致同一个常量会被存储多份,这显然是没有必要的。然后我对这部分代码优化好几个小时后发现,opcache
已经对这个进行了优化,在函数zend_optimizer_compact_literals
里面,会对等价的字面量进行合并。
PHP内核对符号的处理
PHP
内核在编译PHP
脚本的过程中,会把符号的名字转化为符号对应的数据存储空间的地址(注意,不是符号的地址,而是符号对应的数据存储空间的地址)。
我们知道,opline
的结构如下:
1 | typedef union _znode_op { |
znode_op
这个结构,它是一个uint32_t
类型的数字,可以用来存放和操作数地址有关的东西。这也就意味着,编译完PHP
脚本之后,可以丢弃这些符号的名字,都转换成地址即可。
而名字到地址的转换,核心函数是lookup_cv
:
1 | static int lookup_cv(zend_string *name) /* {{{ */{ |
这段代码,就是用来确定一个个CV
变量在栈中的存储地址。也就意味着,栈的大小,在编译期间就确定好了。
PHP内核的op_array由编译时转化为运行时
PHP
内核在pass_two
这个函数里面,会对op_array
进行一个编译时到运行时的转化。
主要体现在以下几个地方:
重新分配literals
让literals
和opcodes
由原来分散存储的内存合并为连续的一块内存。这么做除了内存连续带来的性能提升之外,另一个好处是,在执行opline
的时候,直接通过偏移量就可以拿到对应的字面量了,不需要传递op_array
,相当于少传递了一个参数(之前需要通过op_array->literals
的方式来获取)。
重新设置常量的constant值
znode_op::constant最终是要存储这个常量相对这条
opline
的偏移量
在编译完AST
生成完opcode
之后,znode_op::constant
存储的是这个常量在literals
数组的索引。
znode_op::constant
在从编译期转运行期之后,变成了相对这条opline
的偏移量。
重新设置临时变量的var值
znode_op::var最终是要存储这个变量相对
execute_data
的偏移量
我们知道,IS_CV
变量它相对execute_data
的偏移量在编译这个变量的时候就已经通过EX_NUM_TO_VAR
确定了。但是,IS_TMP
类型的变量,它的znode_op::var
里面只存了这个临时变量是第几个,还没有确定这个临时变量相对execute_data
的偏移量。所以,在编译时转化为运行时的阶段,需要确定好。
那么为什么只有IS_TMP
需要做转化呢?而IS_CV
不需要呢?这是和PHP
栈帧的设计有关的,PHP
的栈帧结构如下:
1 | /* |
可以发现,前面是IS_CV
类型的变量,IS_TMP
类型的变量在IS_CV
变量的后面。所以,我们在编译出IS_TMP
的时候,还无法确定IS_CV
变量的个数,所以,也就无法确定IS_TMP
相对于execute_data
的偏移量。所以,得把IS_TMP
的转化放在后面进行。
PHP内核如何确定一个opcode有几个操作数
首先,PHP
内核包含的所有zend_ast
节点类型在文件zend_ast.h
里面:
1 |
|
我们发现,有几个子节点,那么就从子节点个数 << ZEND_AST_NUM_CHILDREN_SHIFT
开始。所以,对应的,我们可以通过反过来拿到子节点的个数:
1 | zend_ast_kind >> ZEND_AST_NUM_CHILDREN_SHIFT |
移进规约冲突
今天写了一个移进规约冲突的文法。文法规则如下:
1 | %{ |
执行生成代码的命令:
1 | bison -d -Wcounterexamples zend_language_parser.y |
可以看到,警告说是有4
个地方有移进规约的冲突。
那么,什么是移进规约冲突呢?意思就是说,当我们预读了词素的时候,既可以对分析栈里面已有的词素进行规约也可以对预读的词素进行移进,这就是已经规约冲突。
OK
,我们来看看上面的报错。可以看到,当有字符串:
1 | $a = 1 + 1 |
输入时,会发生移进规约的冲突。
我们来看看如果是优先移进的话,状态是如何变化的:
1 | +----------------------------------+ +-----+ +----------------------------------+ |
对应的AST
如下:
1 | +------------+ |
计算这个AST
,我们会得到$a
的最终值为2
。这是符合主流语言的预期的。
我们来看看如果是优先规约的话,状态是如何变化的:
1 | +----------------------------------+ +-----+ +----------------------------------+ |
对应的AST
如下:
1 | +-----------------+ |
计算这个AST
,我们会得到$a
的最终值为1
。这和我们想的不太一样。
所以,移进规约的冲突,会导致一些执行的顺序不一致。如果我们学习过bison
官方文档经典的if ... else
的移进规约冲突问题的话,我们知道,解决它的办法是修改文法,进而避免冲突(因为这个例子有一点绕,所以我没有用那个例子)。那我们这种情况呢,就可以通过设置词素的优先级来解决掉,我们设置=
的优先级低于+
即可:
1 | %left '=' |
这样的话,当我们的分析栈为如下情况的时候:
1 | +----------------------------------+ +-----+ +----------------------------------+ |
我们预读一个+
,因为+
号的优先级更高一点,所以,此时不会选择规约,而是把+
移进。这样,我们可以保证在后续规约的时候,先规约1 + 1
,进而也保证了运算符的优先级。
《手把手教你编写PHP编译器》-执行opcode
上一篇文章,我们成功的把AST
翻译成了opcode
,这样有一个好处,就是它是线性的,连续的,这和我们的CPU
去一条一条的执行机器指令是保持一致的,非常便于人类理解。但是,我们还没有去设置这些opcode
对应的handler
。
这篇文章,我们来实现对这些opcode
的执行,这一节还是比较难的。
首先,我们来捋一捋opcode
和handler
的关系。我们参考PHP
的实现。首先是我们的_zend_op
:
1 | struct _zend_op { |
这种结构实际上是一种三地址码的组织形式,这种结构可以方便我们后续进行数据流分析。
我们知道,变量和字面量等等是有类型的,既然有类型,我们的操作数1
和操作数2
就可能多种组合。所以,这实际上就是一种笛卡尔积的表现形式了。再加上opcode
的种类也不止一种,所以,我们有如下笛卡尔积:
1 | opcode × op1 × op2 |
举个例子画个图:
1 | +----------------------+ +----------------------+ +----------------------+ |
那么,我们就会有4 * 2 * 2
种spec handler
:
1 | ZEND_ADD_IS_CONST_IS_CONST |
假设,我们的opcode
是按照顺序从0
开始编号的,并且操作数的类型也是从0
开始进行编号,并且,我们的spec handler
也是严格按照顺序在内存中进行排序的。那我,我们就可以通过opcode
、op1_type
、op2_type
找到spec handler
的位置了,这个有点像一个三维的数组。对应的算法如下:
1 | opcode * op1_type的数量 * op2_type的数量 + opt_type的编号 * op2_type的数量 + op2_type的编号 |
我们的实现都是围绕着这个算法来进行的。
首先,我们来定义一下操作数的类型:
1 |
|
接着,我们可以来编写我们的spec handler
了。从上面可以看出,我们的操作数有好几个。但是,实际上,对于同一个opcode
,它要执行的动作是一样的,只不过操作数的类型不同,获取操作数的方式需要改变。如果我们手写每一种opcode
对应的所有handler
,那么这个维护成本是非常的高的,所以,我们应该是有一个代码生成的机制,写好通用的模板代码,然后直接生成即可。
下面,我们来给出模板代码:
1 | ZEND_VM_HANDLER(0, ZEND_NOP, CONST|TMPVAR, CONST|TMPVAR) |
可以看到,非常的简单。其中,这里的数字0, 1, 2, 3, 4, 136
是这个opcode
的编号。
接着,我们来用PHP
代码来完成这个代码生成的脚本:
1 |
|
接着,我们执行这个脚本,就会生成文件zend_vm_opcodes.h
、zend_vm_opcodes.cc
、zend_vm_execute.h
。
这里面有两个核心的函数zend_vm_init
、zend_execute
。
其中zend_vm_init
会用一块内存来存放我们的spec handler
的地址,这样,我们就可以通过上面所说的算法,来找到spec handler
了。
zend_execute
就非常的简单了,执行opline
就好了。
接下来,我们只需要设置好每一个opline
对应的handler
即可。代码如下:
1 | // set opcode spec handler |
最后,我们在文件zend_language_parser.y
里面调用zend_vm_init
、pass_two
、zend_execute
即可。
如何准确的查看opline对应的handler名字
我们在分析opcode
对应的handler
的时候,往往会根据opcode
的命名规则来推断具体的handler
。然而,如果我们使用PHP8
的话,我们可以利用jit
的debug
功能来快速的看到opcode
对应的handler
。我举个例子:
有如下代码:
1 | $a = [1, 2, 3]; |
像这个$a[2]
对应的handler
还是非常的长的,我们很难一口气推断出来。我们只需要配置一下php.ini
就可以方便的拿到handler
:
1 | zend_extension=opcache.so |
执行结果如下:
1 | JIT$/Users/hantaohuang/codeDir/cCode/php-src/test.php: ; (/Users/hantaohuang/codeDir/cCode/php-src/test.php) |
可以看到,handler
是ZEND_FETCH_DIM_R_INDEX_SPEC_CV_CONST_HANDLER
。
PHP内核生成zend_vm_opcodes.h
本文基于的PHP8 commit为:14806e0824ecd598df74cac855868422e44aea53
首先,zend_vm_opcodes.h
这个文件是通过脚本Zend/zend_vm_gen.php
来生成的。而zend_vm_gen.php
这个脚本依赖zend_vm_def.h
和zend_vm_execute.skl
来生成文件zend_vm_execute.h
和zend_vm_opcodes.h
:
1 | +--------------------+ +--------------------+ |
我们以文件zend_vm_gen.php
分析的起点,来看看生成zend_vm_opcodes.h
的zend_vm_execute.h
的关键步骤。
首先,是函数gen_vm
。这个函数会逐行扫描zend_vm_def.h
里面的代码。
当扫描到ZEND_VM_HELPER
的时候,就会执行下面的代码:
1 | if (strpos($line,"ZEND_VM_HELPER(") === 0 || |
这段代码具体的细节我们不去深究,总结起来就是去正则匹配zend_vm_def.h
里面当前行的ZEND_VM_HELPER
,然后把相关的信息存在全局变量$helpers
里面。例如:
1 | ZEND_VM_HELPER(zend_add_helper, ANY, ANY, zval *op_1, zval *op_2) |
然后
1 | else if ($handler !== null) { |
就是去拼接zend_vm_def.h
里面的代码。如果是ZEND_VM_HELPER
类型的代码,就执行$helpers[$helper]["code"] .= $line;
。例如,当拼接完毕的时候,就会得到下面的信息:
1 | ZEND_VM_HELPER(zend_add_helper, ANY, ANY, zval *op_1, zval *op_2) |
当扫描到ZEND_VM_HANDLER
的代码之后,就会执行下面的代码:
1 | if (strpos($line,"ZEND_VM_HANDLER(") === 0 || |
这段代码具体的细节我们不去深究,总结起来就是去正则匹配zend_vm_def.h
里面当前行的ZEND_VM_HANDLER
,然后把相关的信息存在全局变量$opcodes
里面。例如:
1 | ZEND_VM_HOT_NOCONSTCONST_HANDLER(1, ZEND_ADD, CONST|TMPVARCV, CONST|TMPVARCV) |
其中
1 | 1 => [ |
实际上就是ZEND_VM_HOT_NOCONSTCONST_HANDLER(1, ZEND_ADD, CONST|TMPVARCV, CONST|TMPVARCV)
里面的1
和ZEND_ADD
,这会用来定义opcode
,对应zend_vm_opcodes.h
文件里面的:
1 |
1 | "CONST" => 0, |
代表CONST|TMPVARCV
的序号。实际上就是:
1 | array_flip(explode("|", CONST|TMPVARCV)) |
之后的结果。
1 | "flags" => 2827 |
计算方法是(CONST|TMPVARCV) | ((CONST|TMPVARCV) << 8)
。至于CONST
和TMPVARCV
的值,我们可以在文件zend_vm_gen.php
的变量$vm_op_decode
里面找到。
接着,对于ZEND_VM_HANDLER
就会执行$opcodes[$handler]["code"] .= $line;
了,和ZEND_VM_HELPER
的类似。
1 | // Generate opcode #defines (zend_vm_opcodes.h) |
这段代码就很简单了,直接往zend_vm_opcodes.h
文件里面写这些内容。
1 | foreach($vm_op_flags as $name => $val) { |
这段代码是把zend_vm_gen.php
文件里面的$vm_op_flags
内容以16
进制的格式写在zend_vm_opcodes.h
文件里面:
1 | $vm_op_flags = array( |
接着
1 | foreach ($opcodes as $code => $dsc) { |
会去用我们上面搜集好的$opcodes
来定义我们的opcode
,例如:
1 |
|
接着
1 | $code = str_pad((string)$max_opcode,$code_len," ",STR_PAD_LEFT); |
会去定义PHP
内核一共有多少个opcode
,例如:
1 |
至此,我们的zend_vm_opcodes.h
文件生成完毕了。接着,开始生成zend_vm_opcodes.c
文件。
其中:
1 | fputs($f,"static const char *zend_vm_opcodes_names[".($max_opcode + 1)."] = {\n"); |
用来定义我们所有opcode
对应的名字,例如:
1 | static const char *zend_vm_opcodes_names[200] = { |
这个zend_vm_opcodes_names
数组的索引实际上就是opcode
对应的id
。所以,如果我们要得到一个opcode
的名字,那么可以通过以下方式拿到:
1 | zend_vm_opcodes_names[ZEND_ADD] |
接着
1 | fputs($f,"static uint32_t zend_vm_opcodes_flags[".($max_opcode + 1)."] = {\n"); |
用来定义opcode
对应的flags
。例如:
1 | static uint32_t zend_vm_opcodes_flags[200] = { |
flags
的值的算法我们已经在上面介绍过了,这里再总结下:
1 | $flags = $flags1 | ($flags2 << 8); |
接着:
1 | fputs($f, "ZEND_API const char* ZEND_FASTCALL zend_get_opcode_name(zend_uchar opcode) {\n"); |
定义一个获取opcode name
的函数。生成的结果如下:
1 | ZEND_API const char* ZEND_FASTCALL zend_get_opcode_name(zend_uchar opcode) { |
首先是判断一下是否有这个opcode
,有的话返回它的name
,没有的话返回NULL
。
接着:
1 | puts($f, "ZEND_API uint32_t ZEND_FASTCALL zend_get_opcode_flags(zend_uchar opcode) {\n"); |
定义一个获取opcode flags
的函数。生成的结果如下:
1 | ZEND_API uint32_t ZEND_FASTCALL zend_get_opcode_flags(zend_uchar opcode) { |
首先是判断一下是否有这个opcode
,有的话返回它的flags
,没有的话返回ZEND_NOP
的flags
(也就是0
)。
至此,我们的zend_vm_opcodes.c
文件生成完毕了。接着,开始生成zend_vm_execute.h
文件。
1 | // Support for ZEND_USER_OPCODE |
用来定义一个zend_user_opcode_handlers
数组,这个数组初始的时候全都是NULL
。生成结果如下:
1 | static user_opcode_handler_t zend_user_opcode_handlers[256] = { |
接着:
1 | out($f, "static zend_uchar zend_user_opcodes[256] = {"); |
用来定义我们的zend_user_opcodes
,生成结果如下:
1 | static zend_uchar zend_user_opcodes[256] = {0, |
说明一共支持256
个zend_user_opcodes
。
接着,开始调用gen_executor
来按照模板文件Zend/zend_vm_execute.skl
生成代码。这个函数也是逐行扫描zend_vm_execute.skl
文件。
其中zend_vm_execute.skl
文件的第一行是:
1 | {%DEFINES%} |
意味着我们在zend_vm_execute.h
里面需要生成一些定义。具体的生成过程如下:
1 | out($f,"#define SPEC_START_MASK 0x0000ffff\n"); |
这是一些opcode
对应的操作数的规则,例如SPEC_RULE_OP1
意味着需要用到操作数1
,并且支持的类型至少是2
种。对应的代码如下:
1 | if (isset($dsc["op1"]) && !isset($dsc["op1"]["ANY"])) { |
接着:
1 | out($f,"static const uint32_t *zend_spec_handlers;\n"); |
这个是根据ZEND_VM_KIND
来定义一些变量和函数,生成结果如下:
1 | static const uint32_t *zend_spec_handlers; |
zend_vm_gen.php
默认是ZEND_VM_KIND_HYBRID
模式。
接着,会有一大段的代码来定义一些如下宏:
1 | HYBRID_NEXT() |
接着,会调用gen_executor_code
来生成opcode
的详细handler
。例如,我们的操作数有如下类型:
1 | $op_types_ex = array( |
那么,就最大就会有op1_type * op1_type
个handler
。所以,就会有如下代码:
1 | // Produce specialized executor |
对于这段代码,$list
里面存放了所有的helper
的名字和opcode
的值,例如:
1 | "helper" => "zend_add_helper", |
如果是helper
,那么我们从$helpers
里面获取到这个helper
函数的信息。
如果是handler
,那么我们从$opcodes
里面获取到这个opcode
的信息。
其中:
1 | $opcodes[$num]["op1"] |
里面存放的就是这个opcode
对应的操作数1
和操作数2
支持的所有类型,我们在前面解析的时候就拿到了这些信息。
无论是是helper
还是opcode
类型的handler
,都会调用extra_spec_handler
来生成spec
函数。在生成spec
的时候,会将zend_vm_def.h
里面对应的handler
的code
进行替换,替换的规则在函数gen_code
里面。
生成了handler
对应的specs
之后,就完成了模板文件里面{%DEFINES%}
的替换了。
接着,开始替换模板文件里面的{%EXECUTOR_NAME%}
,也就是开始生成我们的zend_execute
函数了:
1 | case "EXECUTOR_NAME": |
这里是名字是execute
。
接着替换模板文件的{%HELPER_VARS%}
:
1 | case "HELPER_VARS": |
生成结果如下:
1 |
|
接着替换模板文件的{%INTERNAL_LABELS%}
:
1 | out($f,$prolog."if (UNEXPECTED(execute_data == NULL)) {\n"); |
这里定义了一个名字叫做labels
的静态变量,也就意味着每次调用zend_execute
是共享的。生成的代码如下:
1 | if (UNEXPECTED(execute_data == NULL)) { |
也就意味着,当第一次调用zend_execute
的时候,会初始化这个labels
变量。
接着,我们会生成一堆的HYBRID_SWITCH
和HYBRID_CASE
。这个和labels
变量里面的指针是对应的,并且和我们生成的handler
是对应的。我们后面会写一个小demo
来解释下这个switch ... case
的原理。
接着,会生成$specs
:
1 | static const uint32_t specs[] = { |
其中,SPEC_RULE_OP1
和SPEC_RULE_OP2
解释过了。那么它们前面的数字是什么呢?实际上,前面的数字是第一个当前opcode
的第一个spec handler
在labels
变量的索引。这么说比较抽象,我用下面的图来解释一下:
1 | +-------------+ +-------------+ |
至此,zend_vm_gen.php
生成代码的过程结束了。
PHP内核pass_two源码分析
本文基于的PHP8 commit为:14806e0824ecd598df74cac855868422e44aea53
我们先来看一下PHP
脚本到opcode
的生成流程,在函数zend_compile
里面:
1 | // 删除了部分代码 |
总结起来如下:
1 | 1. 调用zendparse完成词法分析、语法分析从而生成AST。 |
我们来看看具体的代码:
1 | ZEND_API void pass_two(zend_op_array *op_array) |
其中:
1 |
|
是在32
位的机器上面进行设置的,此时,会重新分配opcodes
和literals
,可以避免内存的浪费。
1 | op_array->opcodes = (zend_op *) erealloc(op_array->opcodes, |
是在64
位的机器上面进行设置的,此时,会重新分配opcodes
,大小是opline
的条数加上字面量的个数,然后把literals
拷贝到opcodes
的最后面。这样,使得opcodes
和literals
是在一块连续的内存上面。
1 | while (opline < end) { |
调用ZEND_PASS_TWO_UPDATE_CONSTANT
来完成常量编译时到运行时的转换。我们来看看这个宏:
1 | /* constant-time constant */ |
在32
位的机器上,走的逻辑是:
1 |
|
我们知道,在编译的时候,(node).constant
存的是字面量在(op_array)->literals
的索引,也就是1
,2
,3
等等。
而进行编译时到运行时的转换后,(node).constant
存的就是字面量在(op_array)->literals
的绝对地址了。
我们再来看看64
位的机器上,走的逻辑是:
1 |
|
我们发现,进行编译时到运行时的转换后,(node).constant
存的就是字面量相对当前opline
的相对地址了。因为在64
位的机器上,opcodes
和literals
是在一块连续的内存上面,所以可以存一个相对地址。如下图:
1 | +----------------------------+ |
1 | ZEND_VM_SET_OPCODE_HANDLER(opline); |
这一步就是设置我们opcode
对应的handler
了。