[php-src] Issue #21834: PHP JIT bug

[email protected] (holla-fuguoqing)
Newsgroups php.bugs
Message-ID <[email protected]>
Issue: https://github.com/php/php-src/issues/21834
Author: 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

```plain
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)
lmpx.com only provides a reader for public news (NNTP) servers. It is not affiliated with the servers or forums shown here and is not responsible for the content of articles, which is written by their respective authors.