Description
Title
JIT (opcache.jit=1205) miscompiles nested foreach with conditional break —
identical loop bodies produce different results
Description
Under PHP 8.2.28 with function JIT enabled (opcache.jit=1205, trigger kind 0 —
compile all functions on script load), two structurally equivalent nested
foreach loops executed back-to-back inside the same function return different
results on the same input.
The only behavioral difference between the two loops is a single info(...)
call placed in the else branch of the second loop. Removing that call breaks
both loops; keeping it "fixes" only the second one. This suggests the function
JIT mis-compiles a specific code shape (nested foreach + float-valued skip
condition + conditional break), and any user-function call in the body is
enough to perturb the JIT's code generation back onto a correct path.
Reproduces deterministically on every request — no warm-up required,
consistent with kind=0 (JIT compiles functions at script load, not after
hotness threshold).
Environment
- PHP 8.2.28, Linux, Swoole 5 (long-running CLI worker)
- opcache.enable=1, opcache.enable_cli=1
- opcache.jit=1205 (function JIT, trigger kind 0 — compile on script load)
- opcache.jit_buffer_size=128M
Runtime opcache_get_status()['jit']:
{"enabled":true,"on":true,"kind":0,"opt_level":5,"opt_flags":6,"buffer_size":1
34217712,"buffer_free":96368096}
Test script
info() is a user-defined logger (side-effectful function call). Any equivalent
user-function call in the same position reproduces the effect.
$topList = ['77110666' => 1000];
$rankPrize = [
[
'rank_start' => 1,
'rank_end' => 1,
'rate' => 10,
]
];
$rewardsOne = [];
$idx = 0;
foreach ($topList as $userId => $score) {
$idx++;
foreach ($rankPrize as $prize) {
$start = (int)($prize['rank_start'] ?? 0);
$end = (int)($prize['rank_end'] ?? 0);
$rate = (float)($prize['rate'] ?? 0);
if ($start <= 0 || $end <= 0 || $rate <= 0) {
continue;
}
if ($idx >= $start && $idx <= $end) {
$rewardsOne[$userId] = [
'index' => $idx,
'score' => $score,
'rate' => $rate,
];
break;
}
}
}
$rewardsTwo = [];
$idx = 0;
foreach ($topList as $userId => $score) {
$idx++;
foreach ($rankPrize as $prize) {
$start = (int)($prize['rank_start'] ?? 0);
$end = (int)($prize['rank_end'] ?? 0);
$rate = (float)($prize['rate'] ?? 0);
if ($start <= 0 || $end <= 0 || $rate <= 0) {
continue;
}
if ($idx >= $start && $idx <= $end) {
$rewardsTwo[$userId] = [
'index' => $idx,
'score' => $score,
'rate' => $rate,
];
break;
} else {
info('calcRewards.trace8.range_miss', ['idx' => $idx, 'start' => $start, 'end' => $end]);
}
}
}
$jitStatus = function_exists('opcache_get_status')
? (opcache_get_status(false)['jit'] ?? 'no_jit_key')
: 'opcache_get_status_unavailable';
return [
'rewardsOne' => $rewardsOne,
'rewardsTwo' => $rewardsTwo,
'jit' => [
'php_version' => PHP_VERSION,
'opcache_enable' => ini_get('opcache.enable'),
'opcache_enable_cli' => ini_get('opcache.enable_cli'),
'jit' => ini_get('opcache.jit'),
'jit_buffer_size' => ini_get('opcache.jit_buffer_size'),
'jit_hot_loop' => ini_get('opcache.jit_hot_loop'),
'jit_hot_func' => ini_get('opcache.jit_hot_func'),
'status' => $jitStatus,
],
];
The two blocks are structurally equivalent; the only difference is the
info(...) call in the else branch of the second block, which is never taken on
this input ($idx=1 always satisfies the range check).
Expected
{"rewardsOne":{"77110666":{"index":1,"score":1000,"rate":10}},
"rewardsTwo":{"77110666":{"index":1,"score":1000,"rate":10}}}
Actual
{
"rewardsOne": [],
"rewardsTwo": {
"77110666": {
"index": 1,
"score": 1000,
"rate": 10
}
},
"jit": {
"php_version": "8.2.28",
"opcache_enable": "1",
"opcache_enable_cli": "1",
"jit": "1205",
"jit_buffer_size": "128m",
"jit_hot_loop": "64",
"jit_hot_func": "127",
"status": {
"enabled": true,
"on": true,
"kind": 0,
"opt_level": 5,
"opt_flags": 6,
"buffer_size": 134217712,
"buffer_free": 96368096
}
}
}
The first loop's assignment is silently skipped despite the guard and range
check both being satisfied. Reproduces on every request (no warm-up;
consistent with kind=0).
Minimal trigger conditions
Applying any one of the following to the failing block restores correct
behavior:
- Remove $rate <= 0 from the disjunctive skip guard.
- Drop the (float) cast on $rate (so it stays long).
- Remove the range check and make break unconditional.
- Insert any user-function call (e.g. info(...)) into the inner loop body.
Removing the info(...) from the second block so both are textually identical →
both return empty, confirming the fault is latent in both loops.
Setting opcache.jit=off and restarting fixes it. Bytecode/OPcache optimizer is
not at fault; only JIT-generated code is.
Suspected area
Function JIT code generation around FE_FETCH_R / FE_FREE side-exits combined
with a type-specialized IS_DOUBLE comparison in the skip guard and a
conditional ZEND_JMPZ preceding the break. Happy to capture opcache.jit_debug
output if useful.
PHP Version
PHP 8.2.28 (cli) (built: Mar 11 2025 17:58:12) (NTS)
Copyright (c) The PHP Group
Zend Engine v4.2.28, Copyright (c) Zend Technologies
with Xdebug v3.4.1, Copyright (c) 2002-2025, by Derick Rethans
with Zend OPcache v8.2.28, Copyright (c), by Zend Technologies
Operating System
macOs 版本15.7.1 (24G231)
Description
Title
JIT (opcache.jit=1205) miscompiles nested foreach with conditional break —
identical loop bodies produce different results
Description
Under PHP 8.2.28 with function JIT enabled (opcache.jit=1205, trigger kind 0 —
compile all functions on script load), two structurally equivalent nested
foreach loops executed back-to-back inside the same function return different
results on the same input.
The only behavioral difference between the two loops is a single info(...)
call placed in the else branch of the second loop. Removing that call breaks
both loops; keeping it "fixes" only the second one. This suggests the function
JIT mis-compiles a specific code shape (nested foreach + float-valued skip
condition + conditional break), and any user-function call in the body is
enough to perturb the JIT's code generation back onto a correct path.
Reproduces deterministically on every request — no warm-up required,
consistent with kind=0 (JIT compiles functions at script load, not after
hotness threshold).
Environment
Runtime opcache_get_status()['jit']:
{"enabled":true,"on":true,"kind":0,"opt_level":5,"opt_flags":6,"buffer_size":1
34217712,"buffer_free":96368096}
Test script
info() is a user-defined logger (side-effectful function call). Any equivalent
user-function call in the same position reproduces the effect.
The two blocks are structurally equivalent; the only difference is the
info(...) call in the else branch of the second block, which is never taken on
this input ($idx=1 always satisfies the range check).
Expected
{"rewardsOne":{"77110666":{"index":1,"score":1000,"rate":10}},
"rewardsTwo":{"77110666":{"index":1,"score":1000,"rate":10}}}
Actual
{
"rewardsOne": [],
"rewardsTwo": {
"77110666": {
"index": 1,
"score": 1000,
"rate": 10
}
},
"jit": {
"php_version": "8.2.28",
"opcache_enable": "1",
"opcache_enable_cli": "1",
"jit": "1205",
"jit_buffer_size": "128m",
"jit_hot_loop": "64",
"jit_hot_func": "127",
"status": {
"enabled": true,
"on": true,
"kind": 0,
"opt_level": 5,
"opt_flags": 6,
"buffer_size": 134217712,
"buffer_free": 96368096
}
}
}
The first loop's assignment is silently skipped despite the guard and range
check both being satisfied. Reproduces on every request (no warm-up;
consistent with kind=0).
Minimal trigger conditions
Applying any one of the following to the failing block restores correct
behavior:
Removing the info(...) from the second block so both are textually identical →
both return empty, confirming the fault is latent in both loops.
Setting opcache.jit=off and restarting fixes it. Bytecode/OPcache optimizer is
not at fault; only JIT-generated code is.
Suspected area
Function JIT code generation around FE_FETCH_R / FE_FREE side-exits combined
with a type-specialized IS_DOUBLE comparison in the skip guard and a
conditional ZEND_JMPZ preceding the break. Happy to capture opcache.jit_debug
output if useful.
PHP Version
Operating System
macOs 版本15.7.1 (24G231)