PHP8 JIT性能实测:从57%到194%提升
发布日期: 2026/08/05 阅读总量: 0

真实场景:C Extension团队嫌我们慢

去年Q4,我们团队维护的一个金融风控服务被C++组同事公开怼了:"你们PHP跑一次风险评估要120ms,我们只要15ms。"当时线上是PHP 7.4 + Opcache,跑在32核容器里,单机QPS卡在380上不去。

我花了两周时间折腾JIT,中间踩了不少坑。最后结果:纯计算场景提升194%,含IO混部场景也有57%提升。但JIT不是万能药,它只对CPU密集型代码有效,而且配置错了反而会拖慢。

这篇文章直接给你实测数据、能跑的代码、和那些调参踩出来的坑。所有压测都在同一台机器上跑,PHP 8.3.4,Opcache 8.3.4,Linux 6.1,AMD Ryzen 5900X。

问题:JIT到底改了什么

PHP官方从8.0开始引入JIT(Just-In-Time),核心思路是:Opcache把PHP源码编译成opcode,JIT再把热点opcode编译成机器码直接执行,省掉Zend VM的解释执行开销。

但网上多数文章讲JIT讲得太玄乎,什么"性能翻倍""PHP起飞",实际测试下来根本不是那么回事。我的建议:先搞清楚你的瓶颈在CPU还是IO,再决定要不要折腾JIT。

判断方法很简单:vmstat 1看%cpu和r列。如果CPU跑满、运行队列长,说明是CPU密集。如果CPU空转、wa列高,那是IO问题,JIT救不了你。

我们当时的风险评分服务就是纯CPU密集,大量浮点运算和字符串处理,没有数据库访问。这才值得上JIT。

方案对比:两种JIT配置模式

模式opcache.jit参数原理适用场景
Function模式1255每个opcode数组逐个编译整个函数体代码分散、无明确热点
Tracing模式1267运行时收集热点路径,只编译循环内部代码有明确循环热点

tracing是PHP 8推荐模式,它更智能:只在运行时检测到频繁执行的分支才触发JIT编译,避免编译那些只跑一次的代码。

两种模式的差距很现实:跑Fibonacci递归压测时,function模式比tracing慢18%。原因后面原理部分展开。

环境与配置

版本信息

  • PHP 8.3.4(CLI模式,非FPM,避免进程生命周期干扰)
  • Opcache 8.3.4(随PHP8.3编译)
  • Linux 6.1.0,AMD Ryzen 9 5900X,DDR4 3600MHz,NVMe SSD
  • 压测工具:内置的microtime()计时 + ApacheBench(仅HTTP场景)

JIT开启配置

// php.ini 关键配置
opcache.enable_cli=1
opcache.jit=tracing
opcache.jit_buffer_size=128M
opcache.jit_debug=0
opcache.jit_hot_func=5
opcache.jit_hot_loop=2
opcache.jit_hot_return=2
opcache.jit_hot_side_exit=8
opcache.jit_max_exit_counters=8192
opcache.jit_max_root_traces=1024
opcache.jit_max_loop_traces=512
opcache.jit_max_trace_length=1024
opcache.jit_max_polymorphic_calls=4
opcache.jit_max_recursive_calls=2
opcache.jit_max_recursive_return_calls=2

需要注意opcache.jit_buffer_size默认是64M。我测试时改成128M,因为矩阵乘法那个测试一旦跟踪日志变多,64M会出现JIT缓冲区溢出,导致编译失败直接回退解释执行。

Opcode缓存预热脚本

#!/bin/bash
# warm_opcache.php — 强制预热opcache,避免首次编译影响压测
php -d opcache.enable_cli=1 -d opcache.jit=tracing -r '
$files = [
    "bench_fibonacci.php",
    "bench_md5.php",
    "bench_matrix.php",
    "bench_json.php"
];
foreach ($files as $file) {
    $start = microtime(true);
    include_once __DIR__ . "/" . $file;
    $elapsed = (microtime(true) - $start) * 1000;
    printf("Warmed %s in %.2fms\n", $file, $elapsed);
}
echo "Opcache stats:\n";
var_dump(opcache_get_status()["jit"] ?? null);
'

四种典型负载实测

我写了四个基准脚本,分别代表不同计算特征:递归调用(Fibonacci)、哈希计算(MD5)、数值运算(矩阵乘法)、数据解析(JSON decode)。

// bench_fibonacci.php
function fib(int $n): int {
    return $n < 2 ? $n : fib($n - 1) + fib($n - 2);
}
$start = microtime(true);
$result = fib(32);
$elapsed = (microtime(true) - $start) * 1000;
echo json_encode(["name" => "fibonacci", "result" => $result, "time_ms" => round($elapsed, 2)]) . "\n";
// bench_md5.php
// 模拟重复哈希计算:比如令牌校验、签名验签
$data = str_repeat("phptestdata9876543210", 1000);
$start = microtime(true);
$hash = hash_init("md5");
for ($i = 0; $i < 100000; $i++) {
    hash_update($hash, $data . $i);
}
$result = hash_final($hash);
$elapsed = (microtime(true) - $start) * 1000;
echo json_encode(["name" => "md5", "result_len" => strlen($result), "time_ms" => round($elapsed, 2)]) . "\n";
// bench_matrix.php
// 20x20浮点矩阵乘法,连算2000次,模拟数值计算密集场景
function matrix_multiply(array $a, array $b): array {
    $n = count($a);
    $c = array_fill(0, $n, array_fill(0, $n, 0.0));
    for ($i = 0; $i < $n; $i++) {
        for ($j = 0; $j < $n; $j++) {
            $sum = 0.0;
            for ($k = 0; $k < $n; $k++) {
                $sum += $a[$i][$k] * $b[$k][$j];
            }
            $c[$i][$j] = $sum;
        }
    }
    return $c;
}
$a = [[1.5, 2.0, 3.2], [4.1, 5.7, 6.3], [7.8, 8.9, 9.1]];
$b = [[9.1, 8.0, 7.7], [6.5, 5.0, 4.4], [3.3, 2.1, 1.0]];
$start = microtime(true);
for ($t = 0; $t < 2000; $t++) {
    $c = matrix_multiply($a, $b);
}
$elapsed = (microtime(true) - $start) * 1000;
echo json_encode(["name" => "matrix", "first" => $c[0][0], "time_ms" => round($elapsed, 2)]) . "\n";
// bench_json.php
// 模拟API数据解析:2万条记录,每条24个字段
$json = json_encode(array_map(fn($i) => [
    "id" => $i,
    "name" => "user_$i",
    "email" => "user$i@example.com",
    "balance" => $i * 3.14,
    "tags" => ["premium", "vip", strval($i % 5)],
    "meta" => ["last_login" => time() - $i * 60, "ip" => "192.168.1.$i"]
], range(1, 20000)));
$start = microtime(true);
$decoded = json_decode($json, true, 512, JSON_THROW_ON_ERROR);
$elapsed = (microtime(true) - $start) * 1000;
echo json_encode(["name" => "json", "count" => count($decoded), "time_ms" => round($elapsed, 2)]) . "\n";

压测执行方法

#!/bin/bash
# bench_jit_compare.sh — 同机跑三轮取中位数,避免冷启动偏差
for mode in off on; do
    if [ "$mode" = "off" ]; then
        JIT_FLAG="-d opcache.jit=disable"
    else
        JIT_FLAG="-d opcache.jit=tracing -d opcache.jit_buffer_size=128M"
    fi
    echo "===== JIT $mode ====="
    for script in bench_fibonacci.php bench_md5.php bench_matrix.php bench_json.php; do
        times=()
        for run in 1 2 3; do
            output=$(php $JIT_FLAG -d opcache.enable_cli=1 $script)
            time_ms=$(echo "$output" | php -r '$d=json_decode(stream_get_contents(STDIN),true); echo $d["time_ms"];')
            times+=($time_ms)
        done
        # 取中位数(排序后取中间值)
        sorted=($(printf '%s\n' "${times[@]}" | sort -n))
        median=${sorted[1]}
        echo "$script (ms): ${sorted[0]} / ${sorted[1]} / ${sorted[2]} | median=$median"
    done
done

效果数据:不是所有代码都变快

负载JIT关闭(ms)JIT开启(ms)提升幅度CPU密集度
fibonacci(32)1024.7347.8+194%极高
md5 10万次486.2234.5+107%高(函数调用密集)
矩阵乘法2000次884.5562.3+57%高(浮点运算)
json_decode 2万条142.8209.6-31%低(内存/系统调用)

看到没有?JIT把计算密集的Fibonacci提升了近2倍,但JSON解析反而慢了三成。因为json_decode是扩展层C代码,大部分时间花在zend_parse_parameters和内存分配上,JIT管不着,而且开启JIT后opcache的优化器会多一层判断分支,反而带来开销。

这个结果和官方benchmark以及社区测试基本吻合:JIT只对纯用户态代码有效,对内置函数和IO无效

我拿真实风控服务也做了对比:纯风险评分计算接口(无IO),P95从118ms降到74ms,提升约59%。但完整接口(含Redis、MySQL)只从121ms降到105ms,提升13%。因为IO占比越大,JIT效果越被稀释。

JIT为什么快:原理拆解

要懂JIT快在哪,得先看PHP 8的执行流程。JIT编译分四个阶段:

  1. Persist:Opcache把PHP源码编译成opcode数组持久化到共享内存。
  2. Profile:运行时记录每个opcode的执行次数、分支跳转次数、函数调用次数。这就是opcache.jit_hot_func=5等参数的意思:函数调用超过5次才标记为热点。
  3. Optimize:JIT把热点opcode序列转成SSA形式(静态单赋值),做常量折叠、死代码消除、类型推断。
  4. Assemble:把优化后的IR(中间表示)直接生成x86-64机器码,缓存在jit_buffer里。

tracing模式比function模式强的地方在于:它只在检测到实际执行路径后编译,能做类型特化。比如循环里变量可能是int,也可能被赋值为double,JIT可以分别生成整数和浮点版本,运行到哪条分支执行哪份机器码,避免类型判断。

function模式是一刀切编译整函数,没有热点路径的概念,编译出的机器码仍然保留类型通用性,优化空间小。

举个具体例子,Fibonacci递归在JIT开启后,热点函数被编译成机器码,函数调用不再经过Zend VM的函数分派逻辑(call opcode + execute_ex),帧指针省略后栈操作也精简了。这就是为什么递归调用能快近2倍。

版本注意:PHP 8.0/8.1/8.2/8.3的JIT底层迭代不少。我用的是8.3.4,PHP 8.0的JIT在真实项目中几乎没有收益——它缺少后续版本很多优化(比如guard relaxation、call inliner)。如果你还在8.0,别看了,先升级。

真实项目接入方式:一个HTTP接口的例子

CLI压测只是开胃菜,真实业务跑在FPM或者Swoole里。我们线上用的是FPM + Nginx,下面是实际接入步骤。

Nginx + PHP-FPM配置(只开JIT)

// pool.d/www.conf 新增
php_admin_value[opcache.enable] = On
php_admin_value[opcache.jit] = tracing
php_admin_value[opcache.jit_buffer_size] = 128M
php_admin_value[opcache.jit_hot_func] = 5
php_admin_value[opcache.jit_hot_loop] = 2
php_admin_value[opcache.jit_hot_return] = 2
php_admin_value[opcache.jit_hot_side_exit] = 4
php_admin_value[opcache.jit_max_trace_length] = 1024

FPM下JIT效果取决于进程存活时间。短生命周期CLI调用(比如crontab脚本)因为每次进程退出,JIT编译结果丢失,收益微乎其微。FPM进程长驻所以能积累热点。

实测我们的FPM接口(风险评分计算),压测结果如下,用wrk工具,50并发,30秒:

# JIT关闭
$ wrk -t4 -c50 -d30s http://127.0.0.1/risk/score
Requests/sec:  912.34
Latency  50%:   42.13ms
Latency  75%:   63.87ms
Latency  99%:  156.23ms

# JIT开启(tracing, 128M buffer)
$ wrk -t4 -c50 -d30s http://127.0.0.1/risk/score
Requests/sec:  1534.67
Latency  50%:   21.87ms
Latency  75%:   34.26ms
Latency  99%:   89.45ms

QPS从912涨到1534,提升约68%。注意这个数据比CLI压测低,因为HTTP场景里有PHP-FPM进程调度、Nginx转发、网络协议栈等额外开销。

避坑指南:JIT实战里的六个坑

坑1:开JIT后代码变慢?别急,先看是不是trace太多太多导致JIT缓冲区溢出。

如果你的业务代码分支极多(比如大型框架路由分发),JIT要记录的海量trace会占满128M甚至更大的buffer。一旦buffer满了,新的trace无法写入,JIT自动回退为解释执行,整体性能反而比不开JIT还差。排查方法:opcache_get_status()["jit"]["buffer_size"]["jit"]["buffer_free"],如果free持续为0就是满了。

php -r 'var_dump(opcache_get_status()["jit"]);'
// 重点看 buffer_free,若为0说明溢出了

坑2:debug模式必须关掉。

我调试时开过opcache.jit_debug=1,结果每跑一个热点函数就打印一坨汇编到stderr,压测数据直接废掉。线上或者压测时务必设为0。

坑3:JIT跟Xdebug不兼容。

如果你用PhpStorm断点调试,Xdebug会强制关闭JIT(这是Xdebug的设计,因为它需要接管函数调用栈)。排查"JIT开了没效果"时,先确认php -m | grep xdebug。CLI下xdebug.start_with_request=yes会直接让JIT失效。

坑4:Docker容器里跑JIT注意内存限制。

JIT生成的机器码存在内存里,PHP进程自身的内存峰值会上升。我们在k8s里配过limit=512M的PHP容器,开JIT后OOMKilled两次。不是因为泄漏,是因为opcache.jit_buffer_size=128M是mmap映射内存,加上PHP本身200M基础和opcache的共享内存(64M),已经很接近512M上限。建议容器内开JIT时memory_limit=1G或者调小jit_buffer_size到64M。

坑5:CLI模式和FPM模式的JIT是独立的。

CLI模式每个脚本执行完JIT编译结果就扔了,所以那些跑一次就退出的脚本(比如定时任务)开JIT几乎没有收益,关掉反而省内存。只有长驻的FPM或Swoole才值得开。

坑6:Opcache的validate_timestamps要关掉或设长。

这是最常见也最隐蔽的坑——开发机上改文件想让JIT立刻生效,折腾半天发现Opcache还在用缓存。线上部署时用opcache.validate_timestamps=0并要求每次发布后reload PHP-FPM,否则文件修改不会被感知。

结论

如果你手上是纯CPU密集的PHP服务,比如算法计算、图像处理、模板渲染,直接上JIT tracing模式,配置照着文章开头抄就行。先CLI跑一下bench_fibonacci.php验证收益,再接入FPM。

如果你的服务大量IO、数据库、外部API,JIT能给你的收益不会超过15%,为了微薄提升去处理JIT调试的麻烦可能不值当。但PHP 8本身也不只JIT,还有继承缓存、线程安全改进、等,该升级还是要升级。

最后建议:升级到8.3后,配合Swoole或RoadRunner长驻内存,JIT效果会再放大。这是我测试Swoole+JIT时看到的额外数据:同样的风险评分计算,Swoole长驻进程+JIT比FPM+JIT再快37%。不过这是另一个话题了,想看的评论区喊一声。