深入理解PHP8 JIT

PHP 8的Just In Time是Opcache扩展的一部分,旨在在运行时将某些操作码编译为CPU指令。这意味着使用JIT时,Zend VM不需要解释某些操作码,而这些指令将直接作为CPU级指令执行。

PHP 8 JIT

PHP 8将带来的最受好评的功能之一是Just In Time(JIT)编译器。许多博客和社区都在谈论它,并且肯定会引起很大的轰动,但是到目前为止,我发现关于JIT内部的细节很少。

经过多次研究和放弃后,我决定亲自检查PHP源代码。结合我对C语言的一点了解以及到目前为止所收集的所有分散信息,我提出了这篇文章,希望它也能帮助您更好地了解PHP的JIT。

简单来说: 当JIT按预期工作时,您的代码将不会通过Zend VM执行,而是直接作为一组CPU级指令执行。

这就是JIT核心思想。

但是为了更好地理解它,我们需要考虑php在内部如何工作。不是很复杂,但是需要一些介绍。

PHP代码如何执行?

我们都知道php是一种解释语言。但这到底是什么意思?

每当您要执行PHP代码(例如代码段或整个Web应用程序)时,都必须经过php解释器。
最常用的是PHP FPM和CLI解释器。

他们的工作非常简单:接收php代码,对其进行解释并向后吐出结果。
这通常发生在每种解释语言上。有些语言可能会删除一些步骤,但总体思路是相同的。
在PHP中,它是这样的:

  1. PHP代码被读取并转换为一组关键字,即标记(Tokens)。这个过程允许解释器理解在程序的哪一部分中写了哪段代码。这第一步叫做词法分析或符号化

  2. 有了Tokens后,PHP解释器将分析这个Tokens集合,并尝试理解它们。结果通过一个称为解析的过程生成了一个抽象语法树(AST)。这个AST是一组指示应该执行哪些操作的节点。例如,“echo 1 + 1”实际上应该表示“打印1 + 1的结果”,或者更实际一些“打印一个操作,操作是1 + 1”。

    3 . 有了AST,理解操作和优先级就容易得多了。将这个树转换成可以执行的东西需要一个中间表示(IR),在PHP中我们称之为操作码。将AST转换为操作码的过程称为编译。

  3. 现在,有了操作码,剩下就是执行代码。PHP有一个名为Zend VM的引擎,它能够接收操作码列表并执行它们。在执行了所有操作码之后,Zend VM就存在了,程序就终止了。

执行的过程使用下图标识就更加清楚了。

The PHP's interpreting flow.

非常直接,正如你们所能理解的。但这里有一个瓶颈:如果php代码变化不是那么频繁,那么每次执行代码时对其进行词法分析又有什么意义呢?

最后我们只关心操作码,对吧? 没错! 这就是为什么存在Opcache扩展。

Opcache扩展

Opcache扩展是随PHP附带的,通常没有什么理由禁用它。如果使用PHP,应该打开Opcache。

它的作用是为操作码在内存中添加一个共享缓存层。它的工作是从AST中新生成的操作码并缓存它们,以便进一步执行可以轻松跳过词法分析和解析阶段。

Opcache扩展的流程示意图:

The PHP's interpreting flow with Opcache

PHP使用Opcache的解释流程。如果文件已经被解析,则php会为其获取缓存的操作码,而不是再次解析。

opcache完美地跳过了词法分析,语法解析和编译步骤。

注意:这就是PHP 7.4的预加载功能的亮点!它使您可以告诉PHP FPM解析代码库,将其转换为操作码并甚至在执行任何操作之前就对其进行缓存。

JIT即时编译器有效地做什么?

如果Opcache可以更快地获取操作码,这样它们就可以直接转到Zend VM,那么JIT应该让它们在跳过Zend VM的情况下运行。

Zend VM是一个用C编写的程序,充当操作码和CPU本身之间的一个层。JIT所做的是在运行时生成编译后的代码,这样php就可以跳过Zend VM直接转到CPU。理论上讲,我们应该从中获得性能提升。

起初,这对我来说很奇怪,因为为了编译机器代码,您需要为每种类型的体系结构编写一个非常具体的实现。但事实上,它是相当合理的。

PHP的JIT实现使用名为DynASM (Dynamic Assembler)的库,该库将一组特定格式的CPU指令映射为许多不同CPU类型的汇编代码。因此,Just In Time编译器使用DynASM将操作码转换为特定于架构的机器码。

不过,有一个想法困扰了我很长一段时间……

如果预加载能够在执行前将php代码解析为操作码,而DynASM可以将操作码编译为机器码(正好是及时编译),那么为什么我们不使用提前编译的方法直接编译php呢?!

我从收听Zeev的那集中得到的一个线索是PHP是弱类型的,这意味着PHP通常不知道变量的类型,直到Zend VM尝试执行某个操作码。

这可以通过查看zend_value联合类型看出,它有许多指针指向一个变量的不同类型表示。无论何时Zend VM尝试从zend_value中获取值,它都会使用像ZSTR_VAL这样的宏来尝试从值联合中访问字符串指针。

例如,这个Zend VM处理程序应该处理一个“小于或等于”(<=)表达式。看看它是如何分支到许多不同的代码路径的,只是为了猜测操作数类型。

用机器码复制这种类型推断逻辑是不可行的,可能会使事情变得更慢。

在计算类型之后编译所有内容也不是一个好选择,因为编译成机器码是一项CPU密集型任务。所以在运行时编译所有东西也是不好的。

JIT即时编译器是如何工作的?

现在我们知道我们不能在编译前推断出足够好的类型。我们还知道,在运行时进行编译是昂贵的。JIT对PHP有什么好处?

为了平衡这个等式,PHP的JIT只尝试编译一些它认为可以得到回报的操作码。为此,它对Zend VM正在执行的操作码进行概要分析,并检查哪些操作码可以编译。(根据您的配置)

当编译某个操作码时,它将把执行委托给编译后的代码,而不是委托给Zend VM。看起来如下:

The PHP's interpreting flow with JIT

PHP的JIT解释流程。如果已编译,则操作码不会通过Zend VM执行。

所以在Opcache扩展中有一些指令检测某个操作码是否应该编译。如果是,编译器然后使用DynASM将该操作码转换为机器码,并执行新生成的机器码。

有趣的是,由于当前实现中编译的代码有兆字节限制(也是可配置的),因此代码执行必须能够在JIT和解释代码之间无缝切换。

我仍然不确定编译部分什么时候有效地发生,但我想我现在真的不想知道。

因此性能的提高可能不会很大

为什么每个人都说大多数php应用程序不会从使用Just In Time编译器中获得很大的性能好处,我希望现在能够清楚地知道这一点。以及为什么Zeev建议对应用程序进行分析并试验不同的JIT配置是最好的方法。

如果您使用的是PHP FPM,编译后的操作码通常会在多个请求之间共享,但这仍然不能改变游戏规则。

这是因为JIT优化了cpu绑定操作,而现在大多数php应用程序都是I/O绑定的。如果你必须访问磁盘或网络,不管处理操作是否被编译。计时也非常类似。

文章翻译自https://thephp.website/en/issue/php-8-jit/

每日一句话科技资讯 | 20200630
PHP 8将有多快?