[PHP8.5] [JIT] Tracing JIT wrong optimization for LazyProxy instances
Nobody has claimed this yet.
- 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(): theprop_info != NULL
(known property) branch computesprop_addr = obj + prop_info->offsetand guards only
onIS_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_infowhenever 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 aFETCH_OBJ_IS
deopt whose value isIS_UNDEF, theZREG_ZVAL_COPYbranch sets the result to
NULL(undefined-property semantics), which is wrong when the slot is
IS_UNDEF | IS_PROP_LAZYon 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
First steps
- Read the whole issue, then the project's contributing guide.
- Comment on the issue to say you are picking it up — it saves two people doing the same work.
- Fork the repository and make your change on a branch.
- 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