Skip to content

Tracing JIT: local keeps a stale constant after a trace links into a loop trace #24683

Open

Description

@ChrisOrlando

Tracing JIT: local keeps a stale constant after a trace links into a loop trace

Description

With the tracing JIT, a local variable that is set to a constant and then overwritten by a
loaded value keeps the constant after another trace links into the function's loop trace.
The function then reads through the wrong pointer and returns the wrong value.

In step() below, case 1 sets $ptr = 0x80080000, uses it to compute an address, loads
the real pointer (0x1F8010F4) and assigns it to $ptr, then falls through to case 2.
Once the JIT has compiled the loop, step() sometimes returns with $ptr still 0x80080000.

The following code:

<?php
// php -d opcache.enable_cli=1 -d opcache.jit=tracing -d opcache.jit_buffer_size=64M repro.php
//
// step() is a small state machine. Case 1 sets $ptr to a constant, uses it to
// load the real pointer, and stores that in $ptr; it then falls through to case 2,
// which reads through $ptr. After the tracing JIT has compiled the loop in
// step(), $ptr sometimes still holds the constant when step() returns.

final class Bus
{
    public int $value = 0;
    public function read(int $address): int { return $this->value; }
}

function step(Bus $bus, array &$state, array &$ram, int $pc): int
{
    $ptr = $state['ptr']; $i = $state['i']; $n = $state['n']; $ret = $state['ret'];
    $c = 0;
    while (true) {
        switch ($pc) {
            case 1:
                $ptr = 0x80080000;
                $a = $ptr - 0x6260;
                if (($a & 3) !== 0) { goto trap; }
                $p = $a & 0x1FFFFFFF;
                $inRam = $p < 0x00800000;
                $x = $inRam ? $ram[$p >> 2] : $bus->read($a);
                $ptr = $x;              // 0x1F8010F4
                $t = $i !== $n;         // unused, but needed to reproduce
                $c += 6;                // unused, but needed to reproduce
                // fall through
            case 2:
                $a = $ptr;
                $p = $a & 0x1FFFFFFF;
                $inRam = $p < 0x00800000;
                $y = $inRam ? $ram[$p >> 2] : $bus->read($a);
                $pc = ($y & 0x7F000000) !== 0 ? 3 : 4;
                continue 2;
            case 3:
                if ($pc === 99) { goto trap; }
                $ret = 1;
                $pc = $ram[0];          // 5
                // fall through
            default:
                goto leave;
        }
    }
    leave:
    $state['ptr'] = $ptr; $state['ret'] = $ret;
    return $pc;
    trap:
    $state['ptr'] = $ptr;
}

$bus = new Bus();
$ram = array_fill(0, 0x80000, 0);
$ram[0] = 5;
$ram[0x79DA0 >> 2] = 0x1F8010F4;

$wrong = 0;
for ($k = 0; $k < 10; $k++) {
    $bus->value = ($k % 128) << 24;
    $state = ['ptr' => 0x1F8010F4, 'i' => 0, 'n' => 0, 'ret' => 0];
    $pc = 2;
    for ($m = 0; $m < 100; $m++) {
        $pc = step($bus, $state, $ram, $pc);
        if ($state['ptr'] !== 0x1F8010F4) {
            printf("k=%d m=%d: \$ptr is 0x%X, expected 0x1F8010F4\n", $k, $m, $state['ptr']);
            $wrong++;
        }
        if ($pc !== 5) {
            break;
        }
        $pc = $state['ret'];
    }
}
echo "wrong: $wrong\n";

Resulted in this output:

k=1 m=29: $ptr is 0x80080000, expected 0x1F8010F4
k=2 m=0: $ptr is 0x80080000, expected 0x1F8010F4
... (one line for each k up to 9)
wrong: 9

But I expected this output instead (as with opcache.jit=0 or opcache.jit=function):

wrong: 0

Observations

  • With opcache.jit_debug set to trace start, stop and compiled, the log shows trace 1 as the
    while loop in step(), and trace 2 as a side trace of it. Trace 3 is
    the caller's loop: it inlines the call into step(), including case 1, then reports
    stop (link to 1). After that, $ptr comes back stale. So the overwritten $ptr doesn't
    seem to be stored back before entering trace 1, and trace 1 sees the earlier constant.
  • Only fails under tracing (tracing, 1254, 1255). function is fine. Still fails with
    opcache.optimization_level=0, so the opcache optimizer is probably not involved.
    opcache.jit_hot_loop=1 hides it.
  • Very sensitive to the shape of the code. Each of these makes it go away:
    • assigning the load to $ptr directly instead of via $x
    • removing the unused $t = $i !== $n; or $c += 6;
    • changing $c += 6 to $c++ ($c += 2 still fails)
    • comparing $i to a constant
    • removing either never-taken goto trap check
    • making trap: not read $ptr
    • replacing $bus->read() with a constant
    • using a sparse array instead of array_fill() for $ram
  • Found in a PHP emulator that compiles guest machine code to PHP. Booting a console BIOS
    hung, because the generated interrupt dispatcher polled the wrong address. There it only
    appeared after raising opcache.jit_max_exit_counters, because at the default limit that
    trace was rejected for running out of exit counters. The standalone script fails with
    default settings.
  • Fresh files need opcache.file_update_protection=0 (or a few seconds' wait) to be cached
    and JIT-compiled at all.

PHP Version

PHP 8.5.12 (cli) (built: Oct 19 2026 17:02:46) (NTS)   [Homebrew]

Operating System

macOS (Darwin 27.0.0), Apple M1 (arm64)

Activity

  1. ChrisOrlando commented on Oct 25, 2026

    @ChrisOrlando
    Author

    A second, independent reproduction, which suggests this is broader than "stale constant after a trace links into the loop trace":

    • the value that's kept doesn't have to be a literal: here it's 0x800E0000 | $zero, with $zero read from an array at run time;
    • there's no caller loop trace linking into the function's loop: the calls come from a plain for loop.
    <?php
    // php -d opcache.enable_cli=1 -d opcache.jit=tracing -d opcache.jit_buffer_size=64M repro.php
    //
    // Case 1 stores to $ram and moves on to case 2. Case 2 sets $x to a value it
    // only knows at run time (0x800E0000 | $zero, where $zero is 0), uses it as an
    // address, and overwrites $x with the byte loaded from there (4). Once the loop
    // trace is compiled, $x sometimes comes back as 0x800E0000 instead.
    
    final class Bus
    {
        public function read8(int $address): int { return 0; }
    }
    
    $fn = static function (Bus $bus, array &$r, array &$ram, array &$sp, int $pc): int {
        $value = $r[4];
        $base = $r[15];
        $zero = $r[0];
        while (true) {
            switch ($pc) {
                case 1:
                    $a = ($base + 2) & 0xFFFFFFFF;
                    {
                        $p = $a & 0x1FFFFFFF;
                        $inRam = $a < 0xC0000000 && $p < 0x00800000;
                        if ($inRam) { $k = ($p & 0x1FFFFF) >> 2; $ram[$k] = ($ram[$k] & ~(65535 << (($p & 3) << 3))) | (($value & 65535) << (($p & 3) << 3)); }
                    }
                    $pc = 2;
                    continue 2;
                case 2:
                    $x = 0x800E0000 | $zero;
                    $a = ($x + 0xFFFFEDED) & 0xFFFFFFFF;
                    $p = $a & 0x1FFFFFFF;
                    $byte = ((($inRam ? ($ram[($p & 0x1FFFFF) >> 2] >> (($p & 3) << 3)) & 0xFF : ($inSp ? ($sp[($p - 0x1F800000) >> 2] >> (($p & 3) << 3)) & 0xFF : $bus->read8($a))) ^ 0x80) - 0x80) & 0xFFFFFFFF;
                    $x = $byte;
                    $pc = 3;
                    continue 2;
                default:
                    goto leave;
            }
        }
        leave:
        $r[12] = $x;
        return $pc;
    };
    
    $bus = new Bus();
    $ram = array_fill(0, 0x80000, 0);
    $ram[0xDEDED >> 2] = 0x0003047F; // the byte at 0xDEDED is 4
    $sp = [];
    $wrong = 0;
    for ($k = 0; $k < 300; $k++) {
        $r = array_fill(0, 32, 0);
        $r[4] = 2;
        $fn($bus, $r, $ram, $sp, 1);
        if ($r[12] !== 4) {
            if ($wrong++ === 0) {
                printf("call %d: \$x is 0x%X, expected 4\n", $k, $r[12]);
            }
        }
    }
    echo "wrong: $wrong\n";

    Output with opcache.jit=tracing (also 1254, 1255, and with opcache.optimization_level=0):

    call 40: $x is 0x800E0000, expected 4
    wrong: 252
    

    With opcache.jit=0 or opcache.jit=function: wrong: 0.

    Notes:

    • With a plain constant ($x = 0x800E0000;) this particular script passes, while the original reproduction above fails with one. So the trigger seems to be "a local assigned, used for an address, then overwritten by a loaded value", rather than constants specifically.
    • Without the $bus->read8() fallback in the load it still fails, but only once (wrong: 1).
    • Found the same way as the original report: the stale value showed up in a different local of a much larger generated function (it was carried into an unrelated variable there), which was reduced to this.

    PHP 8.5.12 (cli, NTS, Homebrew), macOS 27, Apple M1.

  2. ChrisOrlando commented on Oct 26, 2026

    @ChrisOrlando
    Author

    A third reproduction, this time with no loop and default JIT settings: straight-line code where the second of two identical expressions is merged with the first, whose value is bound to a CV's slot that has been reassigned in between.

    f.php (it has to be a separate, included file: inlined into the main script, it compiles differently and doesn't fail):

    <?php
    return function (array &$r, array &$ram, int $budget): int {
        $r4 = $r[4]; $r5 = $r[5]; $r14 = $r[14]; $r15 = $r[15]; $r24 = $r[24]; $r25 = $r[25]; $r29 = $r[29]; $r31 = $r[31];
        $flag = $r[0];
        $p = 0x108;
        $r14 = $ram[$p >> 2];
        $r29 = ($r29 - 24) & 0xFFFFFFFF;
        $a = $r29 + 20;
        $ram[$a >> 2] = $r31;          // a different slot
        $a = $r14;
        if (($a & 0x7F800003) === 0) {
        }
        $r4 = $r14;
        if ($budget === 0) {
            return 1;
        }
        if (($a & 0x7F800003) === 0) {
        } else {
            if (!$flag) {
            }
        }
        $a = $r29 + 20;
        if (($a & 0x7F800003) === 0) {
            $r31 = $ram[$p >> 2];          // should read slot 0x108 >> 2 again
        }
        $r[4] = $r4; $r[5] = $r5; $r[14] = $r14; $r[15] = $r15; $r[24] = $r24; $r[25] = $r25; $r[29] = $r29; $r[31] = $r31;
        return $r31;
    };

    main.php:

    <?php
    $fn = require __DIR__ . '/f.php';
    $wrong = 0;
    for ($i = 0; $i < 1000; $i++) {
        $ram = array_fill(0, 0x80000, 0);
        $ram[0x108 >> 2] = 0xA000E1EC;
        $regs = array_fill(0, 32, 0);
        $regs[29] = 0x85D8;
        $regs[31] = 0xE10;
        if ($fn($regs, $ram, 58) !== 0xA000E1EC) { $wrong++; }
    }
    echo "$wrong of 1000 calls wrong\n";
    $ php -n -d opcache.enable_cli=1 -d opcache.jit=tracing -d opcache.jit_buffer_size=64M main.php
    939 of 1000 calls wrong
    $ php -n main.php
    0 of 1000 calls wrong
    

    opcache.jit=function also gives 0. (The wrong count depends on when the trace gets compiled; I've seen 873 from another directory.) PHP 8.5.12 (Homebrew and a build from the release tarball), arm64 (Apple M1). Once the trace is compiled, every call returns 0xE10 instead of 0xA000E1EC.

    What happens, from the final IR (opcache.jit_debug bit 25): the two $a = $r29 + 20 become one value, ADD(...) # BIND(0x110), bound to $a's slot. The $a = $r14 in between is also bound to slot 0x110 and overwrites it. When the first value is reloaded for the second ($a & 0x7F800003) === 0, it comes back as $r14's value, so the test fails, the trace exits at that guard, and the VM resumes past the load, returning $r31's old value.

    It's the same root cause as the earlier repros: making zend_jit_spilling_may_cause_conflict() return 1 for every CV (so zend_jit_def_reg() never binds a value to a CV's slot) fixes this one too. The difference is that a common subexpression, rather than loop peeling or hoisting, keeps the bound value alive after its CV is reassigned.

  3. aasul commented on Oct 26, 2026

    @aasul
    Contributor

    i've been investigating this and wanted to share the progress so far.

    confirmed findings: i reproduced the original issue on Linux x86_64 with PHP 8.5. the JIT creates a PHI/COPY for identical constant assignment results, and the copy gets hoisted and spilled into a live CV slot before the relevant guard. debugger checks confirmed that this overwrites the incoming value.

    i have a local fix that preserves the constant without creating the unnecessary COPY. the original reproducer now passes on both PHP 8.4 and 8.5, with the affected loop trace and caller link still compiling and executing. the full JIT suites passed with 497 tests on 8.4 and 516 on 8.5, with no failures. i also confirmed that the current patch in #24484 doesn't fix this particular case.

    still investigating: the two additional reproductions may involve a broader CV spill-slot lifetime issue, but i haven't established that they share the same root cause. B encounters JIT compilation errors on my x86_64 setup, so its incorrect runtime behavior hasn't been reproduced locally. C compiles and produces correct output here, although its IR shows overlapping bindings to the same CV slot. this could explain the reporter's ARM64 failure, but it still needs validation.

    the local fix currently addresses the original reproducer only. i'm investigating C further before deciding how to submit the changes.

  4. aasul commented on Oct 26, 2026

    @aasul
    Contributor

    quick update since my last comment.

    i've made some more progress on the CSE case from the second follow-up. the issue seems to be that an IR expression can stay live after its original CV has been reassigned, while the expression's spill is still bound to that CV's slot.

    i now have a local patch that keeps reusable expressions separate from their assignment copies. i also found a floating-point CSE variant that reproduces the same kind of stale-value problem naturally on my x86_64 setup. it reports 725 wrong results before the C correction and 0 afterward.

    the original constant-hoisting fix is still working too. during review i also caught a regression where the extra copies increased spill usage enough to prevent some otherwise valid traces from compiling. i've revised the allocator handling so storage is shared only when the final live ranges make that safe. the affected workload now compiles both traces without errors, and the original A and C tracing paths still compile.

    the latest full JIT test results are 519 passes and 10 skips on PHP 8.5, and 500 passes and 10 skips on PHP 8.4, with no failures. i also tested the changes together with the patches from #24484 and #24635, and that build passed 522 JIT tests with 10 skips.

    there are still two limitations. the original C reproducer already passes naturally on my x86_64 system, so i haven't confirmed the fix on ARM64 yet. i did reproduce the conflicting spill behavior using a controlled local allocation diagnostic, which changes the result from 939 wrong calls before the fix to 0 afterward, but that isn't a substitute for testing the original case on Apple Silicon.

    the first follow-up reproducer (B) is also still unresolved. on my build its intended JIT trace hits the existing fixed spill-frame limit and falls back to the interpreter.

    i'm finishing the final review and preparing the patch against PHP 8.4. once it's available, i'd appreciate it if @ChrisOrlando could test the original two-file CSE reproducer on the Apple M1 setup and confirm whether the failure is gone with the tracing JIT still active.

    thanks again for the detailed reproductions, they were very helpful for narrowing this down.

  5. aasul commented on Oct 26, 2026

    @aasul
    Contributor

    this one's been pretty tough to get right. the JIT can reuse an expression while its value is stored in a local variable's stack slot, but if that variable gets reassigned, the original value can be overwritten even though the JIT still needs it. fixing that is one thing, but making sure the fix doesn't increase register pressure or cause other traces to fail compilation has been another challenge. making progress though.

  6. aasul commented on Oct 26, 2026

    @aasul
    Contributor

    this has been quite a challenging one. every time i resolve one issue, another regression seems to surface. it's taking a lot of iteration to get the fix right without affecting other parts of the JIT.

  7. ChrisOrlando commented on Oct 26, 2026

    @ChrisOrlando
    Author

    @aasul thanks for working on this. I tested #24762 on Apple M1 (arm64), using release NTS builds of the PHP-8.4 branch (59f8e1d) with and without the PR. I also applied the PR's two JIT files to the PHP 8.5.12 release tarball, where the patch applies cleanly. Each run used -n -d opcache.enable_cli=1 -d opcache.jit=tracing -d opcache.jit_buffer_size=64M -d opcache.file_update_protection=0, and every result was the same across 3 runs:

    Repro 8.4 8.4 + PR 8.5.12 8.5.12 + PR
    A (original) 9 wrong 0 9 wrong 0
    B (first follow-up) 0 0 252 wrong 252 wrong
    C (two-file CSE) 936 / 1000 wrong 0 939 / 1000 wrong 0

    opcache.jit=0 and opcache.jit=function give 0 wrong on all four builds.

    • C: this confirms the fix on arm64. Unpatched, it fails without any diagnostics, and with the PR it passes while its trace still compiles (1 trace, no compilation errors).
    • A: fixed on both branches. Its traces still compile (6), with no errors.
    • B: still fails on 8.5 with the PR applied. On arm64 its tracing path compiles: 6 traces, no compilation errors with opcache.jit_debug=0x7000. So this is a natural miscompile here, not a fallback to the interpreter, which might help since it doesn't compile on your x86_64 setup. On the 8.4 branch it doesn't reproduce even without the PR, so I can't tell whether 8.4 is affected.
    • The four new gh24683*.phpt tests pass on the patched 8.4 build.

    I'm happy to test further revisions on arm64.

  8. aasul commented on Oct 27, 2026

    @aasul
    Contributor

    thanks a lot for testing this on arm64, this is really helpful!

    it's good to finally have confirmation that both A and C are fixed on the architecture where the original issues occurred, especially with the affected traces still compiling and executing.

    B is interesting though. on my x86_64 setup it never reaches the problematic compiled path because of the spill-frame limit, so your results confirm that there's still a separate miscompilation to investigate on 8.5.

    i think i'll keep the current patch focused on A and C rather than risk introducing more regressions while trying to address B. i'll look into that one separately.

    really appreciate the detailed testing and your offer to help with further revisions!

  9. aasul commented on Oct 27, 2026

    @aasul
    Contributor

    @ChrisOrlando thanks again for the ARM64 testing!

    i've made some progress on B as well. i found that a CV-bound assignment can be hoisted before the loop guards and overwrite the incoming value that a side exit still needs.

    i have a separate local candidate that keeps the assignment's store control-dependent while allowing the arithmetic itself to remain optimized.

    i managed to reproduce the same kind of incorrect behavior naturally on x86_64 with a smaller test (501 wrong before, 0 after), and a varied-input test also went from 2335 wrong to 0. the relevant traces still compile, and the complete JIT suites passed across five configurations.

    i've prepared a small testing package with the patch, original B reproducer, regression tests and instructions for PHP 8.5.12.

    would you be willing to test it on your M1? i'm particularly interested in whether the original B reproduction goes from 252 wrong to 0 while the case-2 trace still compiles.

    i'd like to confirm that before submitting anything upstream.

  10. ChrisOrlando commented on Oct 27, 2026

    @ChrisOrlando
    Author

    @aasul sure I can test any patch you provide

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    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