[GIT-PULLS] [php-src] PR #22709: Make property resolution direction-aware; amortize asymmetric visibility set checks into the runtime cache

[email protected] (hollyschilling)
Newsgroups php.git-pulls
Message-ID <[email protected]>
Pull Request: https://github.com/php/php-src/pull/22709
Author: hollyschilling

### 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).

<details>
<summary>Benchmark script</summary>

```php
<?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)));
```

</details>

🤖 Generated with [Claude Code](https://claude.com/claude-code)
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.