php / php/php-src

[PHP8.5] [JIT] Tracing JIT wrong optimization for LazyProxy instances

Open
#23,628 0 comments 0 reactions 0 assignees View on GitHub

Nobody has claimed this yet.

Bug Status: Needs Triage
Dominant language
C
Stars
40.4k
Forks
8.1k
Avg merge
2d 13h
Merged PRs (30d)
96

Description

When $this is an initialized lazy proxy created by ReflectionClass::newLazyProxy(), the
proxy object's own declared-property slots are permanently IS_UNDEF | IS_PROP_LAZY
(set in zend_lazy_object_init_proxy()), and every property access is meant to be
forwarded to the real backing instance through read_property.

The tracing JIT compiles a hot loop that reads such a property in isset()
context (FETCH_OBJ_IS on $this) using the known-property fast path from
zend_jit_fetch_obj(). That fast path reads the property directly at
prop_info->offset and only guards Z_TYPE != IS_UNDEF; it does not account for
the object being a lazy proxy, so it never forwards to read_property. It reads the
proxy's own dead slot (IS_UNDEF), takes the deopt exit, and in
zend_jit_trace_exit() the ZREG_ZVAL_COPY handler converts the IS_UNDEF value of a
FETCH_OBJ_IS into NULL (treating it as an undefined property). The result is that
isset($this->prop['k']) evaluates to false and property reads see a missing value,
even though the backing instance holds the data.

opcache.jit=function and opcache.jit=0 are correct; only tracing JIT (1254/1255/tracing) is affected.
Lazy ghosts (newLazyGhost) and eager objects are unaffected, because a ghost
initializes in place so its own slots become real values.

Reproducer (self-contained)
<?php
final class Table {
    protected array $map = ['start' => ['next' => 1]];
    public function parse(int $n): int {
        $ok = 0;
        for ($i = 0; $i < $n; $i++) {
            if (isset($this->map['start']['next'])) {
                $ok++;
            } else {
                throw new RuntimeException('isset false at ' . $i);
            }
        }
        return $ok;
    }
}
$proxy = (new ReflectionClass(Table::class))->newLazyProxy(fn() => new Table());
echo $proxy->parse(1000), "\n";
$ php -d opcache.enable_cli=1 -d opcache.jit=tracing -d opcache.jit_buffer_size=64M script.php
PHP Fatal error:  Uncaught RuntimeException: isset false at 61   # trace compiles at jit_hot_loop=61

$ php -d opcache.enable_cli=1 -d opcache.jit=tracing -d opcache.jit_buffer_size=64M -d opcache.jit_hot_loop=1 script.php
PHP Fatal error:  Uncaught RuntimeException: isset false at 1    # deterministic: fails on the first traced iteration

$ php -d opcache.jit=function script.php   # 1000  (OK)
$ php -d opcache.jit=0        script.php   # 1000  (OK)

Note: as a tiny standalone script the failure is JIT trace-layout sensitive (byte-identical
files can reproduce or not depending only on their path), because it depends on which trace
compiles first. Forcing opcache.jit_hot_loop to a small value makes it deterministic on the
affected file. The behaviour is 100% reproducible in a real workload (see below).

Confirmed on
  • PHP 8.5.10 (release) and PHP-8.5 branch head (8.5.12-dev), NTS, x86_64, Zend OPcache, tracing JIT.
Root cause (code)
  • ext/opcache/jit/zend_jit_ir.c, zend_jit_fetch_obj(): the prop_info != NULL
    (known property) branch computes prop_addr = obj + prop_info->offset and guards only
    on IS_UNDEF; there is no lazy-object check, so a lazy proxy's slot is read directly.
  • ext/opcache/jit/zend_jit.c, zend_get_known_property_info(): returns a usable
    prop_info whenever the class is not immutable but is declared in the same file as the
    accessing op_array (ce->info.user.filename == op_array->filename) — which is the common
    case for a class and its own method — enabling that fast path.
  • ext/opcache/jit/zend_jit_trace.c, zend_jit_trace_exit(): for a FETCH_OBJ_IS
    deopt whose value is IS_UNDEF, the ZREG_ZVAL_COPY branch sets the result to
    NULL (undefined-property semantics), which is wrong when the slot is
    IS_UNDEF | IS_PROP_LAZY on a lazy proxy.

The interpreter and function JIT reach zend_std_read_property(), whose uninit_error
path checks zend_lazy_object_must_init() / IS_PROP_LAZY and forwards to the backing
instance. The tracing JIT fast path skips this.

Real-world reproducer (100% deterministic)

goaop/framework wraps its PointcutParser (subclass of Dissect\Parser\LALR1\Parser,
which reads $this->parseTable[...][...][...] via isset() in a hot loop) in a
ReflectionClass::newLazyProxy() via its service container. Under the container image's
default CLI JIT settings this makes every pointcut fail to parse:

git clone https://github.com/goaop/framework.git && cd framework && composer install
rm -rf tests/Fixtures/project/var/cache/aspect
php -d opcache.enable_cli=1 -d opcache.jit=tracing -d opcache.jit_buffer_size=64M \
    tests/Fixtures/project/bin/console --no-ansi cache:warmup:aop tests/Fixtures/project/web/index.php
# -> "Unexpected token ... Expected one of: ..." where the rejected token IS in the expected list

See https://github.com/goaop/framework/issues/661 for the full context.

PHP Version
PHP 8.5.10 (cli) (built: Aug 28 2026 06:46:18) (NTS)
Copyright (c) The PHP Group
Built by Ubuntu
Zend Engine v4.5.10, Copyright (c) Zend Technologies
    with Zend OPcache v8.5.10, Copyright (c), by Zend Technologies
Operating System

No response

Contributor guide

Open the contributing guide

First steps

  1. Read the whole issue, then the project's contributing guide.
  2. Comment on the issue to say you are picking it up — it saves two people doing the same work.
  3. Fork the repository and make your change on a branch.
  4. Open a pull request that references the issue number.

Research direction

Run the self-contained proxy script with tracing JIT, then compare it with function JIT and JIT disabled. Read zend_jit_fetch_obj() in ext/opcache/jit/zend_jit_ir.c, zend_get_known_property_info() in zend_jit.c, and zend_jit_trace_exit() in zend_jit_trace.c; done means the tracing configuration preserves the expected 1000 result for the reproducer.

Written by the indexing model from the issue text.

Assessment

Tech stack
c, php
Domain
compilers, performance
Issue type
Bug
Difficulty
4/5
Estimated time
3-5 days
Activity status
Active
Clarity
Clearly specified
Newbie friendliness
45/100

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.