[php-src] Issue #24691: Tracing JIT: interpreter loop gives different results than without the JIT

From: Date: Sun, 25 Oct 2026 15:59:08 +0000
Subject: [php-src] Issue #24691: Tracing JIT: interpreter loop gives different results than without the JIT
Groups: php.bugs 
Request: Send a blank email to php-bugs+get-253185@lists.php.net to get a copy of this message
Issue: https://github.com/php/php-src/issues/24691 Author: ChrisOrlando # Tracing JIT: interpreter loop gives different results than without the JIT ### Description A MIPS CPU interpreter from a PlayStation emulator (phpsx), cut down to the code below, gives different results with the tracing JIT than with the JIT off or the function JIT. run() executes a batch of instructions and then continues to the end of the current basic block. With the tracing JIT the instruction count, the pc and the registers all come out different. The following code: ```php <?php // php -d opcache.enable_cli=1 -d opcache.jit=tracing -d opcache.jit_buffer_size=64M repro.php // // A cut-down MIPS CPU interpreter (from a PlayStation emulator) runs a tiny // program in batches: each run() call executes $count instructions, then keeps // going until the end of a basic block (a branch's delay slot). The result must // not depend on the JIT, but with the tracing JIT the instruction count and // registers come out different. // // It's fragile: the never-taken branches and unused switch cases below are needed // to reproduce it, and so is the namespace. namespace Phpsx; final class Memory { public array $words; public function __construct(int $size, ?string $data = null) { $this->words = array_fill(0, $size >> 2, 0); } } final class Bus { public const REGION_MASK = [0xFFFFFFFF, 0xFFFFFFFF, 0xFFFFFFFF, 0xFFFFFFFF, 0x7FFFFFFF, 0x1FFFFFFF, 0xFFFFFFFF, 0xFFFFFFFF]; public function __construct(public Memory $ram, public Memory $bios) { } } final class Cpu { private const MASK = 0xFFFFFFFF; public array $regs; public int $pc = 0xBFC00000; public int $nextPc = 0xBFC00004; public int $hi = 0; public int $lo = 0; public int $sr = 0; private int $loadReg = 0; private int $loadVal = 0; private bool $branch = false; public function __construct(private readonly Bus $bus) { $this->regs = array_fill(0, 32, 0); } public function run(int $count, bool $toBlockEnd = false): int { $start = $count; $wasSlot = false; $bus = $this->bus; $bios = $bus->bios->words; $ram = []; $region = Bus::REGION_MASK; $regs = $this->regs; $pc = $this->pc; $next = $this->nextPc; $hi = $this->hi; $lo = $this->lo; $sr = $this->sr; $loadReg = $this->loadReg; $loadVal = $this->loadVal; $branch = $this->branch; for (; $count > 0 || ($toBlockEnd && !$wasSlot); $count--) { $delayReg = $loadReg; $delayVal = $loadVal; $loadReg = 0; $wasSlot = $branch; $branch = false; $dst = 0; if (($sr & 1) !== 0) { } else { $p = $pc & $region[$pc >> 29]; $i = $p < 0x00800000 ? $ram[($p & 0x1FFFFF) >> 2] : ($p >= 0x1FC00000 && $p < 0x1FC80000 ? $bios[($p - 0x1FC00000) >> 2] : $bus->read32($pc)); $pc = $next; $next = ($next + 4) & self::MASK; $rs = ($i >> 21) & 0x1F; $rt = ($i >> 16) & 0x1F; $imm = $i & 0xFFFF; $simm = (($imm ^ 0x8000) - 0x8000) & self::MASK; switch ($i >> 26) { case 0x00: $rd = ($i >> 11) & 0x1F; $a = $regs[$rs]; $b = $regs[$rt]; switch ($i & 0x3F) { case 0x00: $regs[$dst = $rd] = ($b << (($i >> 6) & 0x1F)) & self::MASK; break; case 0x02: $regs[$dst = $rd] = $b >> (($i >> 6) & 0x1F); break; case 0x03: case 0x04: $regs[$dst = $rd] = ($b << ($a & 0x1F)) & self::MASK; break; case 0x06: case 0x07: $regs[$dst = $rd] = ((($b ^ 0x80000000) - 0x80000000) >> ($a & 0x1F)) & self::MASK; break; case 0x08: break; case 0x09: case 0x0C: break; case 0x0D: case 0x10: $regs[$dst = $rd] = $hi; break; case 0x11: case 0x12: $regs[$dst = $rd] = $lo; break; case 0x13: case 0x18: break; case 0x19: break; case 0x1A: if ($b === 0) { } else { $lo = intdiv($a, $b); $hi = $a % $b; } break; case 0x2B: $regs[$dst = $rd] = $a < $b ? 1 : 0; } break; case 0x02: $branch = true; $next = ($pc & 0xF0000000) | (($i & 0x3FFFFFF) << 2); break; case 0x05: $branch = true; if ($regs[$rs] !== $regs[$rt]) { $next = ($pc + ($simm << 2)) & self::MASK; } break; case 0x08: case 0x0F: $regs[$dst = $rt] = $imm << 16; break; case 0x2B: $ram[(($regs[$rs] + $simm) & 0x1FFFFF) >> 2] = $regs[$rt]; break; } } if ($delayReg !== 0 && $delayReg !== $dst) { $regs[$delayReg] = $delayVal; } } $this->regs = $regs; $this->pc = $pc; $this->nextPc = $next; return $start - $count; } } // The program: $nops NOPs, a few register loads, then a loop storing to memory. // (The interpreter is reduced, so ADDI behaves like LUI and the loop never ends; // that doesn't matter, the JIT and the interpreter should still agree.) $nops = (int) ($argv[1] ?? 61); $rom = array_fill(0, $nops, 0); array_push($rom, 0x3C02A001, 0x24429000, 0x3C03A001, 0x2463C160, 0xAC400000, 0x20420004, 0x0043082B, 0x1420FFFC, 0x00000000, 0x0BF00000 | ($nops + 9), 0x00000000, ); $bios = new Memory(512 * 1024); foreach ($rom as $k => $w) { $bios->words[$k] = $w; } $bus = new Bus(new Memory(4), $bios); $cpu = new Cpu($bus); $done = 0; for ($k = 0; $k < 300; $k++) { $done += $cpu->run((int) ($argv[2] ?? 64), toBlockEnd: true); } // Without the JIT (or with opcache.jit=function): 19505 bfc00104 cae6d5d3... printf("%d %08x %s\n", $done, $cpu->pc, md5(serialize($cpu->regs))); ``` Resulted in this output: ``` $ php -d opcache.enable_cli=1 -d opcache.jit=tracing -d opcache.jit_buffer_size=64M repro.php 19206 bfc00118 d7320cbb0278222e512b25e13f6ec5de ``` But I expected this output instead (as with opcache.jit=0 or opcache.jit=function): ``` 19505 bfc00104 cae6d5d338ae782a777b9318570216f2 ``` ### Observations - Only fails under tracing (tracing, 1254, 1255). function and 0 agree with each other. - opcache.optimization_level=0 hides it. - Other arguments fail too (repro.php <nops> <batch size>). For example, 49 and every <nops> from 61 to 80 fail with batch sizes 64, 63, 50, 100 and 128: 105 of 405 combinations tried. - Very sensitive to the shape of the code. Each of these alone makes every combination agree again: - removing the empty, never-taken if (($sr & 1) !== 0) {} branch - removing the delayed-load bookkeeping ($delayReg/$delayVal) or the $dst tracking - emptying the 0x1A case (never executed) - reading the instruction from $bios directly instead of through the nested ternary - removing the namespace line Unreachable bits stay because removing them changes the outcome: $bus->read32() is never called (the method doesn't exist), and the $ram read in the fetch is never taken. - In the full emulator, the visible effect was a lost register update: an $regs[$rt] = ... for one instruction near the end of a batch didn't stick, so the guest program needed an extra loop iteration and drifted out of step. The BIOS boot then crashed later on. - With opcache.jit_debug showing trace starts, the side traces involved start mostly at the for condition ($count > 0 || ($toBlockEnd && !$wasSlot)), where the loop either ends or continues into a delay slot. - Possibly related to #24683 (also a stale value in a loop trace in the same project), but this one still fails with no caller loop trace linking into run(). - Fresh files need opcache.file_update_protection=0 (or a few seconds' wait) to be cached and JIT-compiled. ### 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)

« previous php.bugs (#253185) next »