Repository navigation
Tracing JIT: local keeps a stale constant after a trace links into a loop trace #24683
Copy link
Copy link
Open
Description
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_debugset to trace start, stop and compiled, the log shows trace 1 as the
whileloop instep(), and trace 2 as a side trace of it. Trace 3 is
the caller's loop: it inlines the call intostep(), includingcase 1, then reports
stop (link to 1). After that,$ptrcomes back stale. So the overwritten$ptrdoesn't
seem to be stored back before entering trace 1, and trace 1 sees the earlier constant. - Only fails under tracing (
tracing,1254,1255).functionis fine. Still fails with
opcache.optimization_level=0, so the opcache optimizer is probably not involved.
opcache.jit_hot_loop=1hides it. - Very sensitive to the shape of the code. Each of these makes it go away:
- assigning the load to
$ptrdirectly instead of via$x - removing the unused
$t = $i !== $n;or$c += 6; - changing
$c += 6to$c++($c += 2still fails) - comparing
$ito a constant - removing either never-taken
goto trapcheck - making
trap:not read$ptr - replacing
$bus->read()with a constant - using a sparse array instead of
array_fill()for$ram
- assigning the load to
- 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 raisingopcache.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)
Reactions are currently unavailable
Metadata
Metadata
Assignees
Labels
No labels
Activity
ChrisOrlando commentedon Oct 25, 2026
A second, independent reproduction, which suggests this is broader than "stale constant after a trace links into the loop trace":
0x800E0000 | $zero, with$zeroread from an array at run time;forloop.Output with
opcache.jit=tracing(also1254,1255, and withopcache.optimization_level=0):With
opcache.jit=0oropcache.jit=function:wrong: 0.Notes:
$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.$bus->read8()fallback in the load it still fails, but only once (wrong: 1).PHP 8.5.12 (cli, NTS, Homebrew), macOS 27, Apple M1.
ChrisOrlando commentedon Oct 26, 2026
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):main.php:opcache.jit=functionalso 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 returns0xE10instead of0xA000E1EC.What happens, from the final IR (
opcache.jit_debugbit 25): the two$a = $r29 + 20become one value,ADD(...) # BIND(0x110), bound to$a's slot. The$a = $r14in 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 (sozend_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.aasul commentedon Oct 26, 2026
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.
aasul commentedon Oct 26, 2026
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.
aasul commentedon Oct 26, 2026
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.
aasul commentedon Oct 26, 2026
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.
ChrisOrlando commentedon Oct 26, 2026
@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:opcache.jit=0andopcache.jit=functiongive 0 wrong on all four builds.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.gh24683*.phpttests pass on the patched 8.4 build.I'm happy to test further revisions on arm64.