Make property resolution direction-aware; amortize asymmetric visibility set checks into the runtime cache#22709
Conversation
…cks into the runtime cache zend_get_property_offset() gains a write_access parameter (constant-folded at every call site of the always-inline function). Write-kind resolution (write/unset/get_property_ptr_ptr) verifies asymmetric set visibility at population time: when the running scope lacks set access, the property still resolves - the caller's state-dependent slow path keeps deciding between the visibility error and the __set fallback - but the runtime cache slot is left unpopulated (and its key invalidated, since the hooked simple-write marking assumes resolution populated the slot). A populated write-site cache slot therefore guarantees set access. This lets the ZEND_ASSIGN_OBJ cache-hit path use a new zend_assign_to_typed_prop_granted() that skips the per-write zend_asymmetric_property_has_set_access() scope walk (a debug assert enforces the invariant). Writes to private(set)/protected(set) properties from scopes with set access become identical to public typed property writes after the first resolution at each call site. Out of scope for this first cut, by design: static properties, compound assignment (+=), incdec, references and the JIT's helper paths keep their existing per-operation checks. Read paths are untouched. Behavior-neutral: full Zend suite plus the asymmetric-visibility, readonly and property-hooks suites pass unchanged (plain, opcache+protect_memory, tracing JIT). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
|
Basically, the problem is that the cache slot is shared for read and write opcodes. See Zend/Optimizer/compact_literals.c (which runs only when opcache is enabled), where we find opcodes that fetch the same property and assign it the same cache slot. This way, the first opcode execution will warm the cache for all of them. I have not actually tested your PR, but if we have a read followed by a write to the same property, the write check would always succeed because the cache is already warmed. To fix this, Zend/Optimizer/compact_literals.c will need to stop folding cache slots for reads and writes. |
I can't speak to exactly that scenario, but from what I've seen it never can be "warmed" if it's asymmetric.
That's pretty much what this PR does. |
class P {
public private(set) string $prop = 'default';
}
class C extends P {
public function test() {
var_dump($this->prop);
$this->prop = 'overwritten';
var_dump($this->prop);
}
}
$c = new C;
$c->test();Run this example without opcache, and you'll get the expected result. Run it with opcache (and hence with Zend/Optimizer/compact_literals.c), and you'll run into the new assertion in |
The literal-compaction pass shares one runtime cache slot among all $this property opcodes referencing the same property name. That breaks the populated-write-slot-implies-set-access invariant: a FETCH_OBJ_R could populate the slot with an unchecked (read-kind) resolution, and a subsequent ASSIGN_OBJ hitting it would skip the asymmetric visibility check, letting e.g. a subclass silently write a parent's private(set) property when opcache is enabled. Give ZEND_ASSIGN_OBJ its own slot-sharing pool: those oplines populate exclusively through the set-checked write handler, so sharing among them preserves the invariant. All other property opcodes keep the common pool unchanged; their fast paths still perform per-operation checks. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
|
A behavior change was reported against this branch: with opcache enabled, a subclass could silently write a parent's class P { public private(set) string $prop = 'default'; }
class C extends P {
public function test() {
var_dump($this->prop); // read
$this->prop = 'overwritten'; // must throw — silently succeeded with opcache
}
}Confirmed real, and now fixed in 495d68d. Root cause. The soundness argument for the cached fast path was "cache slots are per-opline, and oplines are direction-specific." That's true of the compiler's output but not of the optimizer's: the literal-compaction pass shares one runtime cache slot among all Fix. Also audited and ruled out as other routes to the same bypass: rebound closures (per-instance runtime caches — verified granted-from- Added a regression test ( 🤖 Generated with Claude Code |
|
@iluuu1994 Your diagnosis was exactly right, and thank you for it — One deliberate difference from "stop folding cache slots for reads and writes" generally: the fix only moves The general read-pool/write-pool split you describe is the right end state though: it's what would let the follow-up remove the per-operation checks from the compound-assign/incdec paths too, and it's a precondition for teaching the JIT to inline granted asymmetric stores. One boundary worth noting for that design: Also audited for other routes to the same bypass: rebound closures (per-instance runtime caches — verified granted-from-parent/denied-from-child on the same closure source), trait method copies (per-class op_arrays), and 🤖 Generated with Claude Code |
Summary
Writes to
private(set)/protected(set)properties currently pay a per-write visibility re-check —zend_asymmetric_property_has_set_access()walks to the executing scope on every assignment — because set-visibility cannot be amortized into the runtime cache the way get-visibility is:zend_get_property_offset()doesn't know whether it is resolving for a read or a write.This PR makes property resolution direction-aware and moves the asymmetric set check to cache-population time. After the first resolution at a call site, writes to asymmetric properties become identical in cost to public typed-property writes.
Mechanics
zend_get_property_offset()gains awrite_accessflag (it isalways_inlineand every caller passes a constant, so the parameter folds away). The write handler,unset, andget_property_ptr_ptrresolve with write access; read/isset resolve without.ZEND_ACC_PPP_SET_MASKcheck at population. When the running scope lacks set access, the property still resolves exactly as today — the caller's state-dependent slow path keeps deciding between the visibility error and the__setfallback — but the runtime cache slot is left unpopulated, and its key is invalidated (the hooked simple-write marking ORs a flag into the slot assuming resolution populated it; without invalidation a stale polymorphic entry could be corrupted).ZEND_ASSIGN_OBJcache slot guarantees set access. TheZEND_ASSIGN_OBJcache-hit path therefore uses a newzend_assign_to_typed_prop_granted()that skips the per-write scope walk, with a debugZEND_ASSERTenforcing the invariant.495d68dfba): the literal-compaction pass shares one runtime cache slot among all$thisproperty opcodes with the same property name, which would let a read-kind (unchecked) resolution warm the slot anASSIGN_OBJthen trusts — the bypass found by @iluuu1994 below.ZEND_ASSIGN_OBJnow has its own slot-sharing pool: its misses populate exclusively through the set-checked write handler, so sharing among assign oplines preserves the invariant. All other property opcodes keep the common pool; their fast paths still check per operation.__set-fallback semantics are preserved structurally: the fast path only runs when the slot value is defined (Z_TYPE != IS_UNDEF); every unset-state write already goes to the handler, which keeps the full state-dependent handling.Soundness rests on: per-opline cache slots are scope-stable (an opline belongs to one op_array; rebound closures get fresh per-instance runtime caches), and after
495d68dfba,ASSIGN_OBJslots are populated only by set-checked write resolutions — including under the optimizer's slot sharing.Performance
Microbenchmark: release NTS
--disable-all, Apple Silicon, best-of-9 over 20M tight method-scope writes, loop overhead subtracted (re-verified at head495d68dfba):public intprivate(set) intprotected(set) intThe same collapse holds under opcache (raw:
private(set)5.46 → 3.40 ns,protected(set)6.22 → 3.44 ns, public 3.46 ns). Read paths are unchanged within noise in every mode, andZend/bench.phpis identical on both builds — the change is invisible outside asymmetric writes.Scope of this first cut
In scope: direct assignment (
ZEND_ASSIGN_OBJcached path) and write-kind population for write/unset/ptr_ptr. Deliberately unchanged, keeping their existing per-operation checks: static properties, compound assignment / incdec, writes through references, per-direction hook caching, and the JIT's helper paths. Under the tracing JIT asymmetric writes are therefore unchanged for now; teaching the JIT to inline granted asymmetric stores is the follow-up with the largest remaining win, and — together with extending the optimization to compound assignment/incdec — would motivate widening the optimizer change into a full read-pool/write-pool split (withZEND_FETCH_OBJ_FUNC_ARGremaining read-pooled, as it is bimodal at runtime).Beyond performance, population-time set checking gives per-direction visibility rules a single enforcement seam instead of scattered per-site checks, which future work can build on.
Verification
Behavior-neutral by test: the full Zend suite plus the asymmetric-visibility, readonly, and property-hooks suites pass with zero .phpt expectation changes in plain, opcache +
protect_memory, and tracing-JIT modes. A regression test for the review-found optimizer interaction is included (Zend/tests/asymmetric_visibility/optimizer_shared_cache_slot.phpt) — that read-then-denied-write shape was previously uncovered by the test suite.Benchmark script
🤖 Generated with Claude Code