了解一下PHP 8的 JIT 特性!
本篇文章给大家介绍一下PHP 8 的 JIT特性。有一定的参考价值,有需要的朋友可以参考一下,希望对大家有所帮助。
TL;DR
PHP 8 的 JIT(Just In Time)编译器将作为扩展集成到 php 中 Opcache 扩展 用于运行时将某些操作码直接转换为从 cpu 指令。
这意味着使用 JIT 后,Zend VM 不需要解释某些操作码,并且这些指令将直接作为 CPU 级指令执行。
PHP 8 Just In Time (JIT) 编译器带来的影响是毋庸置疑的。但是到目前为止,我发现关于 JIT 应该做什么却知之甚少。
经过多次研究和放弃,我决定亲自检查 PHP 源代码。结合我对 C 语言的一些知识和我目前收集到的所有零散信息,我提出了这篇文章,我希望它能帮助您更好地理解 PHP 的 JIT。
简单一点来说 : 当 JIT 按预期工作时,您的代码不会通过 Zend VM 执行,而是作为一组 CPU 级指令直接执行。
这就是全部的想法。
但是为了更好地理解它,我们需要考虑 php 如何在内部工作。不是很复杂,但需要一些介绍。
PHP 的代码是怎么执行的?
总所周知, PHP 是解释型语言,但这句话本身是什么意思呢?
每次执行 PHP 代码(命令行脚本或者 WEB 应用)时,都要经过 PHP 解释器。最常用的是 PHP-FPM 和 CLI 解释器。
解释器的工作很简单:接收 PHP 代码,对其进行解释,然后返回结果。
一般的解释型语言都是这个流程。有些语言可能会减少几个步骤,但总体的思路相同。在 PHP 中,这个流程如下:
读取 PHP 代码并将其解释为一组称为 Tokens 的关键字。这个过程让解释器知道各个程序都写了哪些代码。 这一步称为 Lexing 或 Tokenizing 。
拿到 Tokens 集合以后,PHP 解释器将尝试解析他们。通过称之为 Parsing 的过程生成抽象语法树(AST)。这里 AST 是一个节点集表示要执行哪些操作。比如,「 echo 1 + 1 」实际含义是 「打印 1 + 1 的结果」 或者更详细的说 「打印一个操作,这个操作是 1 + 1」。
有了 AST ,可以更轻松地理解操作和优先级。将抽象语法树转换成可以被 CPU 执行的操作需要一个用于过渡的表达式 (IR),在 PHP 中我们称之为 Opcodes 。将 AST 转换为 Opcodes 的过程称为 compilation 。
有了 Opcodes ,有趣的部分就来了: executing 代码! PHP 有一个称为 Zend VM 的引擎,该引擎能够接收一系列 Opcodes 并执行它们。执行所有 Opcodes 后, Zend VM 就会将该程序终止。
这个图可以让你更清楚:
一个简化版的 PHP 解释流程概述。
如你所见。这里有个问题:即使 PHP 代码没改变,每次执行还是会走此流程吗?
让我们看回 Opcodes 。对了!这就是 Opcache 扩展 存在的原因。
Opcache 扩展
Opcache 扩展是 PHP 附带的,通常没必要停用它。使用 PHP 最好打开 Opcache 。
它的作用是为 Opcodes 添加一个内存共享缓存层。它的工作是从 AST 中提取新生成的 Opcodes 并缓存它们,以便执行时
可以跳过 Lexing/Tokenizing 和 Parsing 步骤。
这是包含 Opcache 扩展的流程示意图:
PHP 使用 Opcache 的解释流程。如果文件已经被解析,则 PHP 会为其获取缓存的 Opcodes ,而不是再次解析。
完美的跳过了 Lexing/Tokenizing 、 Parsing 和 Compiling 步骤 。
旁注: 这是超赞的 PHP 7.4 预加载功能 RFC ! 允许你告诉 PHP FPM 解析代码库,将其转换为 Opcodes 并且在执行之前就将其缓存。
你想知道 JIT 是怎么参与这个解释流程的吗?这篇文章的将说明。
Just In Time 编译有什么效果?
听了 Zeev 在 PHP Internals News 发表的 PHP 和 JIT 广播 之后,我弄清了 JIT 实际做了什么事情。
如果说 Opcache 扩展可以更快的获取 Opcodes 将其直接转到 Zend VM,则 JIT 让它们完全不使用 Zend VM 即可运行。
Zend VM 是用 C 编写的程序,充当 Opcodes 和 CPU 之间的一层。 JIT 在运行时直接生成编译后的代码,因此 PHP 可以
跳过 Zend VM 并直接被 CPU 执行。 从理论上说,性能会更好。
这听起来很奇怪,因为在编译成机器码之前,需要为每种类型的结构体编写一个具体的实现。但实际上这也是合理的。
PHP 的 JIT 使用了名为 DynASM (Dynamic Assembler) 的库,该库将一种特定格式的一组 CPU 指令映射为许多不同 CPU 类型的汇编代码。因此,编译器只需要使用 DynASM 就可以将 Opcodes 转换为特定结构体的机器码。
但是,有一个问题困扰了我很久。
如果预加载能够在执行之前将 PHP 代码解析为 Opcodes,并且 DynASM 可以将 Opcodes 编译为机器码 (Just In Time 编译) ,为什么我们不立即使用运行前编译 (Ahead of Time 编译) 立即编译 PHP 呢?
通过收听 Zeev 的广播,我找到的原因之一就是 PHP 是弱类型语言,这意味着在 Zend VM 尝试执行某个操作码之前, PHP 通常不知道变量的类型。
可以查看 Zend_value 联合类型 得知,很多指针指向不同类型的变量。每当 Zend VM 尝试从 Zend_value 获取值时,它都会使用像 ZSTR_VAL 这样的宏,获取联合类型中字符串的指针。
例如,这个 Zend VM handler 是处理「小于或等于」(<=) 表达式。看看它编码这么多的 if else 分支,只是为了类型推断。
使用机器码执行类型推断逻辑是不可行的,并且可能变得更慢。
先求值再编译也不是一个好选择,因为编译为机器码是 CPU 密集型任务。因此,在运行时编译所有内容也不好。
那么 Just In Time 编译是怎么做的?
现在我们知道无法很好的推断类型来提前编译。我们也知道在运行时进行编译的运算成本很高。那么 JIT 对 PHP 有何好处呢?
为了寻求平衡, PHP 的 JIT 尝试只编译有价值的 Opcodes 。为此, JIT 会分析 Zend VM 要执行的 Opcodes 并检查可能编译的地方。(根据配置文件)
当某个 Opcode 编译后,它将把执行交给该编译后的代码,而不是交给 Zend VM 。看起来如下:
PHP 的 JIT 解释流程。如果已编译,则 Opcodes 不会通过 Zend VM 执行。
因此,在 Opcache 扩展中,有两条检测指令判断要不要编译 Opcode 。如果要,编译器将使用 DynASM 将此 Opcode 转换为机器码,并执行此机器码。
有趣的是,由于当前接口中编译的代码有 MB 的限制 (也是可配置的),所以代码执行必须能够在 JIT 和解释代码之间无缝切换。
顺便说一句,Benoit Jacquemont 在 php 的 JIT 上的这篇演讲帮助我理解了这整件事。
我仍然不确定编译部分什么时候有效进行,但我想现在我真的不想知道。
所以你的性能收益可能不会很大
我希望现在大家都很清楚为什么大多数 php 应用程序不会因为使用即时编译器而获得很大的性能收益。这也是为什么 Zeev 建议为你的应用程序分析和试验不同的 JIT 配置是最好的方法。
如果您使用的是 PHP FPM,则通常会在多个请求之间共享已编译的操作码,但这仍然不能改变游戏规则。
这是因为 JIT 优化了计算密集型的操作,而如今大多数 php 应用程序比其他任何东西都更受 I/O 约束。如果您无论如何都要访问磁盘或网络,则处理操作是否已编译则无关紧要。时间上将非常相似。
除非…
你正在做一些不受 I/O 约束的事情, 像图像处理或机器学习。 任何不接触 I/O 的东西都将受益于 JIT 编译器。
这也是为什么现在人们说我们更愿意用 PHP 编写原生功能而不是 C 编写的原因。 如果仍然要编译此功能,则开销将毫无表现力。
有趣的时光成为一个 PHP 程序员…
相关教程推荐:《PHP教程》