Skip to content

Make property resolution direction-aware; amortize asymmetric visibility set checks into the runtime cache#22709

Draft
hollyschilling wants to merge 1 commit into
php:masterfrom
hollyschilling:property-access-refactor
Draft

Make property resolution direction-aware; amortize asymmetric visibility set checks into the runtime cache#22709
hollyschilling wants to merge 1 commit into
php:masterfrom
hollyschilling:property-access-refactor

Conversation

@hollyschilling

Copy link
Copy Markdown

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 a write_access flag (it is always_inline and every caller passes a constant, so the parameter folds away). The write handler, unset, and get_property_ptr_ptr resolve with write access; read/isset resolve without.
  • Write-kind resolution performs the ZEND_ACC_PPP_SET_MASK check 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 __set fallback — 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).
  • Invariant: a populated write-site cache slot guarantees set access. The ZEND_ASSIGN_OBJ cache-hit path therefore uses a new zend_assign_to_typed_prop_granted() that skips the per-write scope walk, with a debug ZEND_ASSERT enforcing the invariant.
  • The __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 the fact that per-opline cache slots were direction-specific all along (FETCH_OBJ_R and ASSIGN_OBJ are different oplines) and scope-stable (an opline belongs to one op_array; rebound closures get fresh runtime caches).

Performance

Microbenchmark: release NTS --disable-all, Apple Silicon, best-of-9 over 20M tight method-scope writes, loop overhead subtracted:

net ns per write (interpreter) master this PR
public int 1.01 1.02
private(set) int 3.18 1.02
protected(set) int 4.01 1.01

The same collapse holds under opcache (raw: private(set) 5.57 → 3.42 ns, protected(set) 6.39 → 3.42 ns, public 3.46 ns). Read paths are unchanged within noise in every mode, and Zend/bench.php is identical on both builds (0.131–0.133 s) — the change is invisible outside asymmetric writes.

Scope of this first cut

In scope: direct assignment (ZEND_ASSIGN_OBJ cached 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 (~7 ns vs ~2.5 ns for public); teaching the JIT to inline granted asymmetric stores on the same populated-slot invariant is the follow-up with the largest remaining win.

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. The diff is 64 insertions / 11 deletions across zend_object_handlers.c, zend_execute.c, and zend_vm_def.h (plus VM regeneration).

Benchmark script
<?php
const N = 20_000_000;
const REPEAT = 9;

class PubProp {
    public int $v = 0;
    public function fill(int $n): void { for ($i = 0; $i < $n; $i++) { $this->v = $i; } }
}
class PrivSetProp {
    public private(set) int $v = 0;
    public function fill(int $n): void { for ($i = 0; $i < $n; $i++) { $this->v = $i; } }
}
class ProtSetBase { public protected(set) int $v = 0; }
class ProtSetChild extends ProtSetBase {
    public function fill(int $n): void { for ($i = 0; $i < $n; $i++) { $this->v = $i; } }
}

function best(callable $f): float {
    $best = INF;
    for ($r = 0; $r < REPEAT; $r++) {
        $t0 = hrtime(true);
        $f();
        $best = min($best, hrtime(true) - $t0);
    }
    return $best / N;
}

$pub = new PubProp(); $priv = new PrivSetProp(); $prot = new ProtSetChild();
$pub->fill(1000); $priv->fill(1000); $prot->fill(1000);
printf("public         %.3f ns/op\n", best(fn() => $pub->fill(N)));
printf("private(set)   %.3f ns/op\n", best(fn() => $priv->fill(N)));
printf("protected(set) %.3f ns/op\n", best(fn() => $prot->fill(N)));

🤖 Generated with Claude Code

…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>
@iluuu1994

iluuu1994 commented Jul 13, 2026

Copy link
Copy Markdown
Member

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.

@hollyschilling

hollyschilling commented Jul 13, 2026

Copy link
Copy Markdown
Author

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.

I can't speak to exactly that scenario, but from what I've seen it never can be "warmed" if it's asymmetric.

To fix this, Zend/Optimizer/compact_literals.c will need to stop folding cache slots for reads and writes.

That's pretty much what this PR does.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants