Repository navigation
Description
Description
The following code:
<?php
function test(float $x, int|float $step): float {
$prev = $x;
$x -= $step;
sin(0.0);
return $prev;
}
var_dump(test(1.0, 0.25));php -d opcache.enable_cli=1 -d opcache.jit=function -d opcache.jit_buffer_size=64M test.php
Resulted in this output:
float(0.75)
But I expected this output instead:
float(1)
$prev gets the value that $x has after the subtraction.
Loop version (hot-counter JIT)
This is the shape we hit in real code. opcache.jit=1235 is what shivammathur/setup-php puts in
its ini on GitHub Actions, so the test suite started failing there:
<?php
function test(int $n): float {
$step = 1 / $n; // int|float
$x = 1.0;
$sum = 0.0;
for ($i = 1; $i <= $n; $i++) {
$prev = $x;
$x -= $step;
if ($i === $n) { // $i is inferred as [long, double], so this is a helper call
$sum += 1;
}
$sum += $prev;
}
return $sum;
}
var_dump(test(64), test(64));opcache.jit |
output |
|---|---|
disable, tracing (1254, 1255) |
float(33.5) float(33.5) (correct) |
1235 |
float(33.4375) float(32.5) (wrong once the loop is hot) |
function (1205) |
float(32.5) float(32.5) (wrong from the first call) |
Every iteration adds the decremented $x instead of $prev, so the sum is short by $step per
compiled iteration. The original code was a loan overpayment calculator: 1.2716275 instead of
1.2835440 for 36 months at 17.65 %.
What is needed to reproduce
Each condition was checked with variations of the scripts above:
- The subtrahend is inferred as
int|float: anint|floatparameter,1 / $n, or
$n % 2 ? 1 : 0.5. Withfloat $stepor1.0 / $nthe result is correct. - Between the subtraction and the read of
$prevthere is a call out of the JIT code that is not a
user function call: an internal function (sin(0.0)), or$i === $n/$i % 2with$i
inferred as[long, double]. With a user function call (g()) in that place, with no call, or
with the copy read before the call, the result is correct. - Optimization level 3 or higher with register allocation. Wrong: 1203, 1204, 1205, 1105, 0205,
1233–1235. Correct: 1201, 1202, 1005, 1215, tracing (1254, 1255).
What the IR shows
Final IR of test() from the first script (8.5.4, opcache.jit=function,
opcache.jit_debug=0xA000000). The CV $x lives at offset 0x50, $prev at 0x70:
double d_69 {R7} {%xmm0:store}, l_69 = LOAD(l_67, d_68); # BIND(0x50); RULE(LOAD_FP); ; old $x
...
double d_78 {R8} {%xmm1} = SUB(d_69 {R7} {%xmm0}, d_77); # RULE(BINOP_AVX); ; $x - $step (double branch)
uintptr_t d_79 = ADD(d_75 {%r14}, c_8); ; c_8 = 0x50
l_80 = STORE(l_77, d_79, d_78 {R8} {%xmm1}); # RULE(STORE_FP); ; new $x written to 0x50
... ; (same in the int branch: INT2FP, SUB, STORE to 0x50)
l_140 = CALL/2(l_139, c_36, d_130 {%r15}, d_137 {R16} {%rsi}); # RULE(CALL); ; sin()
...
double d_172 {R20} {%xmm0} = COPY(d_69 {R7}, 1); # BIND(0x70); RULE(COPY_FP); ; $prev = old $x
The copy into $prev is materialised only after the call, so d_69 (the old $x) is live across
it and has to leave the caller-saved XMM register. Its spill slot is the one it is bound to, 0x50,
which is $x's own slot, but the subtraction has already stored the new $x there. The reload
after the call reads 0.75.
This matches what we see on Windows: the same scripts print correct values on PHP 8.5.1 NTS x64
with the same JIT settings, presumably because XMM6–XMM15 are callee-saved in the Windows x64 ABI
and d_69 survives the call in a register.
It looks like the same class of problem as GH-24683 (case C there: a value staying bound to a CV's
spill slot after the CV is reassigned), which is about the tracing JIT. #25136 does not fix this
one, though: PHP 8.4.27 built from the release tarball with the #25136 diff applied still prints
float(0.75) for the first script and float(32.5) for the loop with opcache.jit=function.
Versions tested
| Version | Build | Result |
|---|---|---|
| 8.2.35, 8.3.36 | docker php:8.2-cli, php:8.3.36-cli |
correct in all modes |
| 8.4.27 | docker php:8.4.27-cli |
wrong (function, 1235) |
| 8.4.27 | release tarball, --disable-all --enable-opcache, with and without the #25136 diff |
wrong (function, 1235) |
| 8.5.0, 8.5.1, 8.5.12 | docker php:8.5.x-cli |
wrong (function, 1235) |
| 8.5.4 | Ubuntu 26.04 package | wrong (function, 1235) |
| 8.5.12 | GitHub Actions ubuntu-latest, setup-php |
wrong (1235, original code) |
| 8.6.0RC4 | docker php:8.6.0RC4-cli |
wrong (function, 1235) |
| 8.5.1 NTS x64 | Windows 11 (windows.php.net build) | correct |
So it seems to come with the IR-based JIT in 8.4.
PHP Version
PHP 8.5.4 (cli) (built: Oct 19 2026 14:35:18) (NTS)
Copyright (c) The PHP Group
Built by Ubuntu
Zend Engine v4.5.4, Copyright (c) Zend Technologies
with Zend OPcache v8.5.4, Copyright (c), by Zend Technologies
Also 8.4.27, 8.5.0, 8.5.1, 8.5.12 and 8.6.0RC4 (official docker images), see the table above.
INI: only opcache.enable_cli=1, opcache.jit=function (or 1235), opcache.jit_buffer_size=64M
on the command line; everything else default.
Operating System
Ubuntu 26.04 LTS x86_64 (kernel 7.0.0); also Debian x86_64 (official docker images) and GitHub
Actions ubuntu-latest.
Activity