Skip to content

Tracing JIT reads a stale stack slot at a side exit #24634

Open

Description

@dixyes

Summary

opcache.jit=tracing miscompiles a function that reads a variable in one branch and assigns a constant to the same variable in another branch.
The else branch uses $exponent = 113 instead of the input exponent, so every normal input gives a wrong result.
0x0400 must give 0x38800000, but gives 0x70800000.
opcache.jit=function and opcache.jit=off give correct results.
The effect is silent wrong numeric results. PHP 8.5.11 NTS, 8.7.0-dev ZTS, and master 4a51e519 give the same wrong bits.

Environment

  • PHP 8.7.0-dev (cli) ZTS, commit 02207318726, ./configure --enable-zts --disable-all --enable-cli --with-ffi --enable-mbstring --without-pear
  • PHP 8.7.0-dev (cli) NTS, master 4a51e519 (2026-10-24), ./configure --disable-all --enable-cli
  • PHP 8.5.11 (cli) NTS (distribution package)
  • Linux 7.1.8, x86_64, AMD Ryzen 9 3900XT
  • INI: opcache.enable=1, opcache.enable_cli=1, opcache.jit=tracing, opcache.jit_buffer_size=64M
  • INI: opcache.jit_hot_loop=1, opcache.jit_hot_func=1, opcache.jit_hot_return=1, opcache.jit_hot_side_exit=1
  • INI: opcache.file_update_protection=0

Reproduce

  1. Save the code below as bug.php.
  2. Run the command below.
  3. Read the last line. With tracing it prints wrong results: 12.
  4. Set opcache.jit=function or opcache.jit=off. The script prints ok.
php -n -d opcache.enable=1 -d opcache.enable_cli=1 -d opcache.jit=tracing \
  -d opcache.jit_buffer_size=64M -d opcache.file_update_protection=0 \
  -d opcache.jit_hot_loop=1 -d opcache.jit_hot_func=1 \
  -d opcache.jit_hot_return=1 -d opcache.jit_hot_side_exit=1 bug.php
<?php
function f16(int $half): float
{
    $sign = ($half & 0x8000) << 16;
    $exponent = ($half >> 10) & 0x1F;
    $mantissa = $half & 0x3FF;
    if ($exponent === 0) {
        if ($mantissa === 0) {
            $bits = $sign;
        } else {
            $exponent = 127 - 15 + 1;
            while (($mantissa & 0x400) === 0) {
                $mantissa <<= 1;
                $exponent--;
            }
            $mantissa &= 0x3FF;
            $bits = $sign | ($exponent << 23) | ($mantissa << 13);
        }
    } elseif ($exponent === 31) {
        $bits = $sign | 0x7F800000 | ($mantissa << 13);
    } else {
        $bits = $sign | (($exponent - 15 + 127) << 23) | ($mantissa << 13);
    }
    return unpack('G', pack('N', $bits))[1];
}
$expected = [0x0400 => 0x38800000, 0x3C00 => 0x3F800000, 0x7C00 => 0x7F800000, 0xBC00 => 0xBF800000];
$wrong = 0;
for ($pass = 0; $pass < 3; $pass++) {
    for ($half = 0; $half < 65536; $half++) {
        $value = f16($half);
        if (isset($expected[$half]) && unpack('N', pack('G', $value))[1] !== $expected[$half]) {
            $wrong++;
        }
    }
}
echo $wrong === 0 ? "ok\n" : "wrong results: {$wrong}\n";

The attached bug.phpt uses the same function under run-tests.php. It fails with wrong results: 12, and passes with function or off.

Expected and actual

half expected tracing
0x0400 0x38800000 0x70800000
0x3C00 0x3F800000 0x70800000
0x7C00 0x7F800000 0x70800000
0xBC00 0xBF800000 0xF0800000

Analysis

  • 0x70800000 is (113 - 15 + 127) << 23. 113 is the constant that the $exponent === 0 branch assigns. The else branch reads it as if that branch ran.
  • The bug needs register allocation. opcache.jit=1054 (no register allocation) prints ok; opcache.jit=1255 prints wrong results: 12.
  • The bug needs the pack()/unpack() return path. Returning $bits as int prints ok.
  • A related driver also prints Warning: Undefined variable $exponent at the elseif line. The JIT marks the CV as undefined at that checkpoint.
  • opcache.file_update_protection=0 is required when the script is new. If opcache does not cache the script, the bug does not show.
  • The trigger depends on the statement layout. Renaming the variable, merging the branches, or changing the driver hides it.

Root cause: zend_jit_trace_allocate_registers() ends the live range of the CV at the if and emits no store. The false branch exits the trace. The interpreter then reads the stale CV slot. fix.patch forces ZREG_STORE for CV definitions, so the slot is current at every side exit.

Reference fix

fix.patch sets ZREG_STORE for CVs defined in a trace, so a side exit cannot read a stale CV slot. It applies cleanly to 4a51e519. On 02207318726, bug.phpt passes and ext/opcache/tests/jit stays green (518 passed, 11 skipped, 0 failed).

Related

Activity

  1. dixyes commented on Oct 24, 2026

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

Metadata

Metadata

Assignees

No one assigned

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions