Skip to content

PHP JIT bug #21834

@holla-fuguoqing

Description

@holla-fuguoqing

Description


Title

JIT (opcache.jit=1205) miscompiles nested foreach with conditional break —
identical loop bodies produce different results


Description

Under PHP 8.2.28 with function JIT enabled (opcache.jit=1205, trigger kind 0 —
compile all functions on script load), two structurally equivalent nested
foreach loops executed back-to-back inside the same function return different
results on the same input.

The only behavioral difference between the two loops is a single info(...)
call placed in the else branch of the second loop. Removing that call breaks
both loops; keeping it "fixes" only the second one. This suggests the function
JIT mis-compiles a specific code shape (nested foreach + float-valued skip
condition + conditional break), and any user-function call in the body is
enough to perturb the JIT's code generation back onto a correct path.

Reproduces deterministically on every request — no warm-up required,
consistent with kind=0 (JIT compiles functions at script load, not after
hotness threshold).


Environment

  • PHP 8.2.28, Linux, Swoole 5 (long-running CLI worker)
  • opcache.enable=1, opcache.enable_cli=1
  • opcache.jit=1205 (function JIT, trigger kind 0 — compile on script load)
  • opcache.jit_buffer_size=128M

Runtime opcache_get_status()['jit']:
{"enabled":true,"on":true,"kind":0,"opt_level":5,"opt_flags":6,"buffer_size":1
34217712,"buffer_free":96368096}

Test script

info() is a user-defined logger (side-effectful function call). Any equivalent
user-function call in the same position reproduces the effect.

        $topList = ['77110666' => 1000];
        $rankPrize = [
            [
                'rank_start' => 1,
                'rank_end' => 1,
                'rate' => 10,
            ]
        ];

        $rewardsOne = [];
        $idx = 0;
        foreach ($topList as $userId => $score) {
            $idx++;
            foreach ($rankPrize as $prize) {
                $start = (int)($prize['rank_start'] ?? 0);
                $end = (int)($prize['rank_end'] ?? 0);
                $rate = (float)($prize['rate'] ?? 0);

                if ($start <= 0 || $end <= 0 || $rate <= 0) {
                    continue;
                }
                if ($idx >= $start && $idx <= $end) {
                    $rewardsOne[$userId] = [
                        'index' => $idx,
                        'score' => $score,
                        'rate' => $rate,
                    ];
                    break;
                }
            }
        }

        $rewardsTwo = [];
        $idx = 0;
        foreach ($topList as $userId => $score) {
            $idx++;

            foreach ($rankPrize as $prize) {
                $start = (int)($prize['rank_start'] ?? 0);
                $end = (int)($prize['rank_end'] ?? 0);
                $rate = (float)($prize['rate'] ?? 0);

                if ($start <= 0 || $end <= 0 || $rate <= 0) {
                    continue;
                }
                if ($idx >= $start && $idx <= $end) {
                    $rewardsTwo[$userId] = [
                        'index' => $idx,
                        'score' => $score,
                        'rate' => $rate,
                    ];
                    break;
                } else {
                    info('calcRewards.trace8.range_miss', ['idx' => $idx, 'start' => $start, 'end' => $end]);
                }
            }
        }

        $jitStatus = function_exists('opcache_get_status')
            ? (opcache_get_status(false)['jit'] ?? 'no_jit_key')
            : 'opcache_get_status_unavailable';

        return [
            'rewardsOne' => $rewardsOne,
            'rewardsTwo' => $rewardsTwo,
            'jit' => [
                'php_version'     => PHP_VERSION,
                'opcache_enable'  => ini_get('opcache.enable'),
                'opcache_enable_cli' => ini_get('opcache.enable_cli'),
                'jit'             => ini_get('opcache.jit'),
                'jit_buffer_size' => ini_get('opcache.jit_buffer_size'),
                'jit_hot_loop'    => ini_get('opcache.jit_hot_loop'),
                'jit_hot_func'    => ini_get('opcache.jit_hot_func'),
                'status'          => $jitStatus,
            ],
        ];        

The two blocks are structurally equivalent; the only difference is the
info(...) call in the else branch of the second block, which is never taken on
this input ($idx=1 always satisfies the range check).

Expected

{"rewardsOne":{"77110666":{"index":1,"score":1000,"rate":10}},
"rewardsTwo":{"77110666":{"index":1,"score":1000,"rate":10}}}

Actual

{
"rewardsOne": [],
"rewardsTwo": {
"77110666": {
"index": 1,
"score": 1000,
"rate": 10
}
},
"jit": {
"php_version": "8.2.28",
"opcache_enable": "1",
"opcache_enable_cli": "1",
"jit": "1205",
"jit_buffer_size": "128m",
"jit_hot_loop": "64",
"jit_hot_func": "127",
"status": {
"enabled": true,
"on": true,
"kind": 0,
"opt_level": 5,
"opt_flags": 6,
"buffer_size": 134217712,
"buffer_free": 96368096
}
}
}

The first loop's assignment is silently skipped despite the guard and range
check both being satisfied. Reproduces on every request (no warm-up;
consistent with kind=0).

Minimal trigger conditions

Applying any one of the following to the failing block restores correct
behavior:

  1. Remove $rate <= 0 from the disjunctive skip guard.
  2. Drop the (float) cast on $rate (so it stays long).
  3. Remove the range check and make break unconditional.
  4. Insert any user-function call (e.g. info(...)) into the inner loop body.

Removing the info(...) from the second block so both are textually identical →
both return empty, confirming the fault is latent in both loops.

Setting opcache.jit=off and restarting fixes it. Bytecode/OPcache optimizer is
not at fault; only JIT-generated code is.

Suspected area

Function JIT code generation around FE_FETCH_R / FE_FREE side-exits combined
with a type-specialized IS_DOUBLE comparison in the skip guard and a
conditional ZEND_JMPZ preceding the break. Happy to capture opcache.jit_debug
output if useful.


PHP Version

PHP 8.2.28 (cli) (built: Mar 11 2025 17:58:12) (NTS)
Copyright (c) The PHP Group
Zend Engine v4.2.28, Copyright (c) Zend Technologies
    with Xdebug v3.4.1, Copyright (c) 2002-2025, by Derick Rethans
    with Zend OPcache v8.2.28, Copyright (c), by Zend Technologies

Operating System

macOs 版本15.7.1 (24G231)

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