Skip to content

Function JIT: after $prev = $x; $x -= $step; an internal call makes $prev read the new $x (int|float $step, x86-64 Linux) #25169

Open

Description

@Pal9yni4bi

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: an int|float parameter, 1 / $n, or
    $n % 2 ? 1 : 0.5. With float $step or 1.0 / $n the result is correct.
  • Between the subtraction and the read of $prev there 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 % 2 with $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

  1. self-assigned this
    on Nov 3, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Labels

No labels
No labels

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions