使用 JIT 编译器
PHP 8 引入了期待已久的 JIT 编译器。这是重要的一步,对 PHP 语言的长期生命力具有重要影响。尽管 PHP 已经具备了生成和 缓存字节码 的能力,但在引入 JIT 编译器之前,PHP 还不具备直接缓存 机器码 的能力。
实际上,早在 2011 年就有人尝试为 PHP 添加 JIT 编译器功能。PHP 7 的性能提升就是这些早期努力的直接结果。由于早期的 JIT 编译器并没有显著提高性能,因此都没有作为 RFC(征求意见稿)提出。现在,核心团队认为只有使用 JIT 才能进一步提高性能。这样做的另一个好处是,PHP 有可能被用作非网络环境下的语言。另一个好处是,JIT 编译器为使用 C 语言以外的语言开发 PHP 扩展程序提供了可能。
正确使用新的 JIT 编译器有可能大大提高 PHP 应用程序的性能,因此密切关注本章的细节极为重要。在了解实现细节之前,首先有必要解释一下在没有 JIT 编译器的情况下,PHP 是如何执行字节码的。然后,我们将向你展示 JIT 编译器是如何工作的。在此之后,您将能更好地理解各种设置,以及如何对它们进行微调,以便为您的程序代码提供最佳性能。
现在我们来看看在没有 JIT 编译器的情况下 PHP 是如何工作的。
探索 PHP 在没有 JIT 的情况下如何工作
当 PHP 安装在服务器(或 Docker 容器)上时,除了核心扩展之外,安装的主要组件实际上是一个虚拟机(VM),通常称为 Zend Engine。该虚拟机的运行方式与 VMware 或 Docker 等虚拟化技术截然不同。Zend Engine 在本质上更接近 Java 虚拟机(JVM),因为它接受字节码并生成机器代码。
这就引出了一个问题:什么是字节码,什么是机器码?现在就让我们来看看这个问题。
了解字节码和机器码
机器码或 机器语言 是一组 CPU 可直接理解的硬件指令。每一段机器码都是一条指令,可使 CPU 执行特定操作。这些低级操作包括在寄存器之间移动信息、将一定数量的字节移入或移出内存、加法、减法等。
通过使用 汇编语言,机器代码通常可以在一定程度上实现人类可读性。下面是一个用汇编语言编写机器码的例子:
JIT$Mandelbrot::iterate: ;
sub $0x10, %esp
cmp $0x1, 0x1c(%esi)
jb .L14
jmp .L1
.ENTRY1:
sub $0x10, %esp
.L1:
cmp $0x2, 0x1c(%esi)
jb .)L15
mov $0xec3800f0, %edi
jmp .L2
.ENTRY2:
sub $0x10, %esp
.L2:
cmp $0x5, 0x48(%esi)
jnz .L16
vmovsd 0x40(%esi), %xmm1
vsubsd 0xec380068, %xmm1, %xmm1
虽然大部分指令不容易理解,但从汇编语言表示法中可以看出,这些指令包括比较指令(cmp
)、在寄存器和/或内存之间移动信息的指令(mov
)以及跳转到指令集中另一点的指令(jmp
)。
字节码(Bytecode),也称为操作码(opcode),是原始程序代码的大幅缩减符号表示。字节码是由解析过程(通常称为 解释器)产生的,该过程将人类可读的程序代码分解为称为 tokens 的符号以及值。值包括程序代码中使用的任何字符串、整数、浮点数和布尔数据。
下面是根据用于创建 Mandelbrot 的示例代码(稍后显示)生成的字节码片段示例:

现在让我们看一下 PHP 程序的常规执行流程。
了解常规 PHP 程序执行
在传统的 PHP 程序运行周期中,PHP 程序代码会被评估并通过称为 解析 的操作分解成字节码。然后将字节码传递给 Zend 引擎,再由 Zend 引擎将字节码转换成机器码。
当 PHP 首次安装到服务器上时,安装过程会启动必要的逻辑,使 Zend 引擎适应特定服务器的 CPU 和硬件(或虚拟 CPU 和硬件)。因此,当你编写 PHP 代码时,你并不知道最终运行你的代码的实际 CPU 的具体情况。Zend 引擎提供了硬件特定意识。
下图 10.2 展示了传统的 PHP 执行方式:

尽管 PHP(尤其是 PHP 7)的运行速度相当快,但仍有必要提高运行速度。为此,大多数系统都启用了 PHP OPcache 扩展。在继续讨论 JIT 编译器之前,让我们快速了解一下 OPcache
。
了解 PHP OPcache 的运行
顾名思义,PHP OPcache 扩展会在 PHP 程序首次运行时缓存操作码(字节码)。在随后的程序运行中,字节码将从缓存中提取,从而省去了解析阶段。这可以节省大量时间,是生产网站非常需要的功能。PHP OPcache 扩展是核心扩展集的一部分,但默认情况下并未启用。
在启用此扩展之前,必须首先确认您的 PHP 版本在编译时使用了 --enable-opcache
配置选项。您可以在网络服务器上运行的 PHP 代码中执行 phpinfo()
命令来检查这一点。在命令行中输入 php -i
命令。下面是在本书使用的 Docker 容器中运行 php -i
的示例:
root@php8_tips_php8 [ /repo/ch10 ]# php -i
phpinfo()
PHP Version => 8.1.0-dev
System => Linux php8_tips_php8 5.8.0-53-generic
#60~20.04.1-Ubuntu SMP Thu May 6 09:52:46 UTC 2021 x86_64
Build Date => Dec 24 2020 00:11:29
Build System => Linux 9244ac997bc1 3.16.0-4-amd64 #1 SMP
Debian 3.16.7-ckt11-1 (2015-05-24) x86_64 GNU/Linux
Configure Command => './configure' '--prefix=/usr'
'--sysconfdir=/etc' '--localstatedir=/var' '--datadir=/usr/
share/php' '--mandir=/usr/share/man' '--enable-fpm'
'--with-fpm-user=apache' '--with-fpm-group=apache'
// not all options shown
'--with-jpeg' '--with-png' '--with-sodium=/usr'
'--enable-opcache-jit' '--with-pcre-jit' '--enable-opcache'
从输出中可以看到,该 PHP 安装的配置中包含了 OPcache。要启用 OPcache,请添加或取消以下 php.ini
文件设置:
-
zend_extension=opcache
-
opcache.enable=1
-
opcache.enable_cli=1
最后一项设置是可选的。它决定从命令行执行的 PHP 命令是否也由 OPcache 处理。启用 OPcache 后,还有其他一些影响性能的 php.ini
文件设置,不过这些不在本文讨论范围之内。
有关影响 OPcache 的 PHP php.ini 文件设置的更多信息,请访问: https://www.php.net/manual/en/opcache.configuration.php 。 |
现在让我们来看看 JIT 编译器是如何运行的,以及它与 OPcache 有何不同。
使用 JIT 编译器发现 PHP 程序执行
当前方法的问题在于,无论字节码是否被缓存,Zend Engine 仍有必要在每次程序请求时将字节码转换为机器码。JIT 编译器不仅能将字节码编译成机器码,还能缓存机器码。跟踪机制可创建请求跟踪,为这一过程提供便利。通过跟踪,JIT 编译器可以确定哪些机器代码块需要优化和缓存。图 10.3 概述了使用 JIT 编译器的执行流程:

从图中可以看出,包含 OPcache 的正常执行流程仍然存在。主要区别在于,一个请求可能会调用一个跟踪,导致程序流程立即转移到 JIT 编译器,不仅有效地绕过了解析过程,也绕过了 Zend 引擎。JIT 编译器和 Zend 引擎都能生成可直接执行的机器代码。
JIT 编译器并不是凭空产生的。PHP 核心团队选择移植性能卓越、久经考验的 DynASM 预处理汇编器。虽然 DynASM 主要是为 Lua 编程语言使用的 JIT 编译器开发的,但它的设计完全适合作为任何基于 C 语言(如 PHP)的 JIT 编译器的基础。
PHP JIT 实现的另一个优点是它不产生任何中间表示(IR)代码。相比之下,使用 JIT 编译器技术运行 Python 代码的 PyPy VM 在生成实际的机器代码之前,必须首先生成 图结构 中的 IR 代码,用于流程分析和优化。而 PHP JIT 中的 DynASM 内核不需要这一额外步骤,因此比其他解释型编程语言的性能更高。
有关 DynASM 的更多信息,请访问以下网站: https://luajit.org/dynasm.html 。以下是关于 PHP 8 JIT 运行方式的精彩概述: https://www.zend.com/blog/exploringnew-php-jit-compiler 。您还可以阅读官方的 JIT RFC: https://wiki.php.net/rfc/jit 。 |
现在你已经了解了 JIT 编译器如何融入 PHP 程序执行周期的总体流程,是时候学习如何启用它了。
启用 JIT 编译器
由于 JIT 编译器的主要功能是缓存机器代码,因此它作为 OPcache 扩展的一个独立部分运行。OPcache 既是启用 JIT 功能的网关,也是为 JIT 编译器分配内存的网关。因此,要启用 JIT 编译器,必须首先启用 OPcache(参见上一节,了解 PHP OPcache 的运行)。
要启用 JIT 编译器,必须首先使用 --enable-opcache-jit
配置选项确认 PHP 已经编译。然后,只需在 php.ini
文件的 opcache.jit_buffer_size
指令中指定一个非零值,即可启用或禁用 JIT 编译器。
数值可以指定为一个整数(在这种情况下,数值代表字节数)、一个 0 值(默认值)(禁用 JIT 编译器),也可以指定一个数字,后面跟上以下任意字母:
-
K: Kilobytes
-
M: Megabytes
-
G: Gigabytes
为 JIT 编译器缓冲区大小指定的值必须小于分配给 OPcache 的内存分配,因为 JIT 缓冲区是从 OPcache 缓冲区中取出的。
下面的示例将 OPcache 内存消耗量设置为 256 M,将 JIT 缓冲区设置为 64 M。这些值可以放置在 php.ini
文件中的任何位置:
opcache.memory_consumption=256
opcache.jit_buffer_size=64M
现在,您已经了解了 JIT 编译器的工作原理以及如何启用它,因此了解如何正确设置跟踪模式极为重要。
配置跟踪模式
php.ini
设置 opcache.jit
可控制 JIT 跟踪运行。为方便起见,可使用以下四种预设字符串之一:
- opcache.jit=disable
-
完全禁用 JIT 编译器(与其他设置无关)。
- opcache.jit=off
-
禁用 JIT 编译器,但(在大多数情况下)可以在运行时使用
ini_set()
启用它。 - opcache.jit=function
-
将 JIT 编译器跟踪器设置为函数模式。该模式对应于 CPU 寄存器触发优化(CRTO)1205 位数(下一步将解释)。
- opcache.jit=tracing
-
将 JIT 编译器跟踪器设置为跟踪模式。该模式对应于 CRTO 数字 1254(下一步将解释)。在大多数情况下,该设置能带来最佳性能。
- opcache.jit=on
-
这是跟踪模式的别名。
依赖运行时 JIT 激活是有风险的,可能会产生不一致的应用程序行为。最佳做法是使用跟踪或函数设置。 |
这四个方便字符串实际上是一个四位数。每个数字对应 JIT 编译器跟踪器的不同方面。与其他 php.ini
文件设置不同,这四位数字不是位掩码,而是按以下顺序指定的:CRTO
。以下是四位数字的摘要。
C(CPU 选项标志)
第一位数字表示 CPU 优化设置。如果将该位设置为 0,则不进行 CPU 优化。如果设置为 1,则可以生成高级矢量扩展(AVX)指令。AVX 是英特尔和 AMD 微处理器 x86 指令集架构的扩展。英特尔和 AMD 处理器从 2011 年开始支持 AVX。AVX2 可用于大多数服务器类型的处理器,如 Intel Xeon。
R(寄存器分配)
第二位数字控制 JIT 编译器如何处理 寄存器。寄存器与 RAM 类似,只是直接位于 CPU 本身内部。CPU 会不断将信息移入或移出寄存器,以便执行操作(例如,加法、减法、执行逻辑 AND、OR 和 NOT 操作等)。通过与此设置相关的选项,可以禁用寄存器分配优化,也可以在本地或全局层面允许寄存器分配优化。
T(JIT 触发器)
第三位数字决定 JIT 编译器何时触发。选项包括让 JIT 编译器在脚本首次加载或首次执行时运行。另外,您还可以指示 JIT 何时编译 常用函数。常用函数是调用频率最高的函数。还有一种设置可以告诉 JIT 只编译标有 @jit docblock
注解的函数。
O(优化级别)
第四位数字对应优化级别。选项包括禁用优化、最小优化和选择性优化。您还可以指示 JIT 编译器根据单个函数、调用树或内部存储过程分析的结果进行优化。
有关四种 JIT 编译器跟踪器设置的完整细目,请参阅此文档参考页面: https://www.php.net/manual/en/opcache.configuration.php#ini.opcache.jit.php#ini.opcache.jit.php 。 |
现在让我们看看 JIT 编译器的运行情况。
使用 JIT 编译器
在这个示例中,我们使用一个经典的基准程序来生成 Mandelbrot。这是一个非常好的测试,因为它的计算量非常大。我们在这里使用的实现来自 PHP 核心开发团队成员之一 Dmitry Stogov 的实现代码。您可以在这里查看原始实现: https://gist.github.com/dstogov/12323ad13d3240aee8f1 :
-
我们首先定义曼德布罗特参数。尤其重要的是迭代次数(
MAX_LOOPS
)。迭代次数越多,计算量就越大,也会减慢整体生成速度。我们还要捕捉开始时间:// /repo/ch10/php8_jit_mandelbrot.php define('BAILOUT', 16); define('MAX_LOOPS', 10000); define('EDGE', 40.0); $d1 = microtime(1);
-
为了方便程序的多次运行,我们增加了一个选项来捕获命令行参数
-n
。如果存在该参数,Mandelbrot 输出将被抑制:$time_only = (bool) ($argv[1] ?? $_GET['time'] ?? FALSE);
-
然后,我们定义了一个函数
iterate()
,它直接来自 Dmitry Stogov 的 Mandelbrot 实现。实际代码(此处未显示)可在前面提到的 URL 上查看。 -
接下来,我们通过运行由
EDGE
确定的 X/Y 坐标来生成 ASCII 图像:$out = ''; $f = EDGE - 1; for ($y = -$f; $y < $f; $y++) { for ($x = -$f; $x < $f; $x++) { $out .= (iterate($x/EDGE,$y/EDGE) == 0) ? '*' : ' '; } $out .= "\n"; }
-
最后,我们输出结果。如果通过网络请求运行,输出结果将用
<pre>
标记封装。如果使用-n
标志,则只显示经过的时间:if (!empty($_SERVER['REQUEST_URI'])) { $out = '<pre>' . $out . '</pre>'; } if (!$time_only) echo $out; $d2 = microtime(1); $diff = $d2 - $d1; printf("\nPHP Elapsed %0.3f\n", $diff);
-
我们首先使用
-n
标志在 PHP 7 Docker 容器中运行程序三次。结果如下。请注意,在与本书配套使用的演示 Docker 容器中,运行时间很容易就超过了 10 秒:root@php8_tips_php7 [ /repo/ch10 ]# php php8_jit_mandelbrot.php -n PHP Elapsed 10.320 root@php8_tips_php7 [ /repo/ch10 ]# php php8_jit_mandelbrot.php -n PHP Elapsed 10.134 root@php8_tips_php7 [ /repo/ch10 ]# php php8_jit_mandelbrot.php -n PHP Elapsed 11.806
-
现在我们来看看 PHP 8 Docker 容器。首先,我们调整
php.ini
文件,禁用 JIT 编译器。以下是设置:opcache.jit=off opcache.jit_buffer_size=0
-
下面是使用
-n
标志在 PHP 8 中运行程序三次的结果:root@php8_tips_php8 [ /repo/ch10 ]# php php8_jit_mandelbrot.php -n PHP Elapsed 1.183 root@php8_tips_php8 [ /repo/ch10 ]# php php8_jit_mandelbrot.php -n PHP Elapsed 1.192 root@php8_tips_php8 [ /repo/ch10 ]# php php8_jit_mandelbrot.php -n PHP Elapsed 1.210
-
马上,你就会发现改用 PHP 8 的一个很好的理由!即使不使用 JIT 编译器,PHP 8 也能在 1 秒多一点的时间内执行相同的程序:时间的 1/10!
-
接下来,我们修改
php.ini
文件设置以使用 JIT 编译器函数跟踪模式。以下是使用的设置:opcache.jit=function opcache.jit_buffer_size=64M
-
然后使用
-n
标志再次运行相同的程序。下面是在 PHP 8 中使用 JIT 编译器函数跟踪模式运行的结果:root@php8_tips_php8 [ /repo/ch10 ]# php php8_jit_mandelbrot.php -n PHP Elapsed 0.323 root@php8_tips_php8 [ /repo/ch10 ]# php php8_jit_mandelbrot.php -n PHP Elapsed 0.322 root@php8_tips_php8 [ /repo/ch10 ]# php php8_jit_mandelbrot.php -n PHP Elapsed 0.324
-
哇 我们成功地将处理速度提高了 3 倍,现在不到 1/3 秒!但是,如果我们尝试推荐的 JIT 编译器跟踪模式会怎样呢?下面是调用该模式的设置:
opcache.jit=tracing opcache.jit_buffer_size=64M
-
以下是我们最后一组程序运行的结果:
root@php8_tips_php8 [ /repo/ch10 ]# php php8_jit_mandelbrot.php -n PHP Elapsed 0.132 root@php8_tips_php8 [ /repo/ch10 ]# php php8_jit_mandelbrot.php -n PHP Elapsed 0.132 root@php8_tips_php8 [ /repo/ch10 ]# php php8_jit_mandelbrot.php -n PHP Elapsed 0.131
如输出所示,最后的结果确实令人吃惊。在不使用 JIT 编译器的情况下,我们运行相同程序的速度不仅比 PHP 8 快 10 倍,而且比 PHP 7 快 100 倍!
需要注意的是,时间会因运行本书相关 Docker 容器的主机而异。您不会看到与此处所示完全相同的时间。 |
现在让我们来看看 JIT 编译器调试。
使用 JIT 编译器进行调试
在使用 JIT 编译器时,使用 XDebug 或其他工具进行普通调试将无法有效工作。因此,PHP 核心团队添加了一个额外的 php.ini
文件选项 opcache.jit_debug
,用于生成额外的调试信息。在这种情况下,可用的设置采用位标志的形式,这意味着可以使用 AND
、OR
、XOR
等位运算符将它们组合起来。
表 10.1 总结了可以分配给 opcache.jit_debug
设置的值。请注意,标有 内部常量 的一列不显示 PHP 预定义常量。这些值是内部 C 代码引用:
内部常量 | 值 | 描述 |
---|---|---|
ZEND_JIT_DEBUG_ASM |
1 |
转储汇编代码 |
ZEND_JIT_DEBUG_SSA |
2 |
转储静态单一分配 |
ZEND_JIT_DEBUG_REG_ALLOC |
4 |
寄存器分配信息 |
ZEND_JIT_DEBUG_ASM_STUBS |
8 |
汇编器存根 |
ZEND_JIT_DEBUG_PERF |
16 |
为 Linux 性能创建 perf.map |
ZEND_JIT_DEBUG_PERF_DUMP |
32 |
为 Linux perf 创建 perf.dump |
ZEND_JIT_DEBUG_OPROFILE |
64 |
Linux Oprofile 的调试信息 |
ZEND_JIT_DEBUG_VTUNE |
128 |
Intel VTune 的调试信息 |
ZEND_JIT_DEBUG_GDB |
256 |
使用 GNU 调试器进行调试 |
ZEND_JIT_DEBUG_SIZE |
512 |
内存大小 |
ZEND_JIT_DEBUG_ASM_ADDR |
1024 |
汇编器寻址 |
ZEND_JIT_DEBUG_TRACE_START |
4096 |
跟踪开始 |
ZEND_JIT_DEBUG_TRACE_STOP |
8192 |
跟踪停止 |
ZEND_JIT_DEBUG_TRACE_COMPILED |
16384 |
跟踪编译 |
ZEND_JIT_DEBUG_TRACE_EXIT |
32768 |
跟踪退出 |
ZEND_JIT_DEBUG_TRACE_ABORT |
65536 |
跟踪中止 |
ZEND_JIT_DEBUG_TRACE_BLACKLIST |
131072 |
追踪黑名单 |
ZEND_JIT_DEBUG_TRACE_BYTECODE |
262144 |
跟踪字节码参考 |
ZEND_JIT_DEBUG_TRACE_TSSA |
524288 |
跟踪静态单一赋值 |
ZEND_JIT_DEBUG_TRACE_EXIT_INFO |
1048576 |
Tracee 退出信息 |
例如,如果希望启用 ZEND_JIT_DEBUG_ASM
、ZEND_JIT_DEBUG_PERF
和 ZEND_JIT_DEBUG_EXIT
的调试,可以在 php.ini
文件中进行如下分配:
-
首先,您需要将要设置的值相加。在此示例中,我们将添加:
1 + 16 + 32768
-
然后将总和应用于
php.ini
设置:opcache.jit_debug=32725
-
或者,使用按位 OR 表示值:
opcache.jit_debug=1|16|32768
根据调试设置,您现在可以使用 Linux perf
命令或 Intel VTune
等工具调试 JIT 编译器。
下面是运行上一节讨论的 Mandelbrot 测试程序时调试输出的部分示例。为便于说明,我们使用了 php.ini
文件中的 opcache.jit_debug=32725
设置:
root@php8_tips_php8 [ /repo/ch10 ]#
php php8_jit_mandelbrot.php -n
---- TRACE 1 start (loop) iterate()
/repo/ch10/php8_jit_mandelbrot.php:34
---- TRACE 1 stop (loop)
---- TRACE 1 Live Ranges
#15.CV6($i): 0-0 last_use
#19.CV6($i): 0-20 hint=#15.CV6($i)
... not all output is shown
---- TRACE 1 compiled
---- TRACE 2 start (side trace 1/7) iterate()
/repo/ch10/php8_jit_mandelbrot.php:41
---- TRACE 2 stop (return)
TRACE-2$iterate$41: ; (unknown)
mov $0x2, EG(jit_trace_num)
mov 0x10(%r14), %rcx
test %rcx, %rcx
jz .L1
mov 0xb0(%r14), %rdx
mov %rdx, (%rcx)
mov $0x4, 0x8(%rcx)
... not all output is shown
输出显示的是以汇编语言呈现的机器代码。如果您在使用 JIT 编译器时遇到程序代码问题,汇编语言转储可能会帮助您找到错误源。
不过,请注意汇编语言是不可移植的,它完全面向所使用的 CPU。因此,您可能需要获取该 CPU 的硬件参考手册,并查找所使用的汇编语言代码。
现在我们来看看影响 JIT 编译器运行的其他 php.ini
文件设置。
发现其他 JIT 编译器设置
表 10.2 概述了 php.ini
文件中尚未涉及的所有其他 opcache.jit*
设置:

opcache.*Setting | 类型 | 描述 |
---|---|---|
jit_bisect_limit |
int |
很像断点,这告诉 JIT 在达到此限制后停止。 使用它来隔离导致中断的进程。 |
jit_prof_threshold |
float |
|
jit_max_root_traces |
int |
|
jit_max_side_traces |
int |
|
jit_max_exit_counters |
int |
|
jit_hot_loop |
int |
|
jit_hot_func |
int |
|
jit_hot_return |
int |
|
jit_hot_side_exit |
int |
|
jit_blacklist_root_trace |
int |
|
jit_blacklist_side_trace |
int |
|
jit_max_loop_unrolls |
int |
|
jit_max_recursive_calls |
int |
|
jit_max_recursive_returns |
int |
|
jit_max_polymorphic_calls |
int |
从表中可以看出,您可以高度控制 JIT 编译器的运行方式。总的来说,这些设置代表了控制 JIT 编译器决策的阈值。如果配置得当,这些设置可以让 JIT 编译器忽略不常用的循环和函数调用。现在我们将离开令人兴奋的 JIT 编译器世界,看看如何提高数组性能。