[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)