These three property declarations — the syntax the property hooks RFC added in 8.4 — do three different things at run time. The compiler emits the same opcode for all of them.
<?php
final class Plain { public int $v = 0; }
final class Backed { public int $v = 0 { get => $this->v; } }
final class Virt { public int $u = 0; public int $v { get => $this->u; } }
function readPlain(Plain $o): int { return $o->v; }
function readBacked(Backed $o): int { return $o->v; }
function readVirtual(Virt $o): int { return $o->v; }
readPlain:
0000 CV0($o) = RECV 1
0001 T1 = FETCH_OBJ_R CV0($o) string("v")
0002 RETURN T1
readBacked:
0000 CV0($o) = RECV 1
0001 T1 = FETCH_OBJ_R CV0($o) string("v")
0002 RETURN T1
readVirtual:
0000 CV0($o) = RECV 1
0001 T1 = FETCH_OBJ_R CV0($o) string("v")
0002 RETURN T1
One of those reads a slot. One calls a function that reads a slot. One calls a function that reads a different slot. The dump cannot tell them apart, and neither can a reviewer looking at the call site.
How these numbers were taken
PHP 8.5.10, NTS, arm64, Homebrew build, on an Apple M4 Pro laptop with 24 GB and
12 logical cores, running macOS and not quiesced. This build has OPcache compiled
in rather than as a shared extension, so --ri "Zend OPcache" answers where
--ri opcache does not.
The archive’s default is OPcache and the JIT off, and the headline table below
keeps it. Two extra configurations are labeled where they appear: OPcache on with
the JIT off, and the JIT in both of its modes. Opcode dumps come from
opcache.opt_debug_level=0x20000, which prints each function after the optimizer
has run — the same output
the request-lifecycle post reads a $_main from:
php -d opcache.enable_cli=1 -d opcache.opt_debug_level=0x20000 dump-access.php
Timings are the median of 15 interleaved runs with three warmup rounds
discarded, taken with hrtime() inside the process over 5,000,000 iterations,
with the range given. Every timing is reported net of an empty loop measured the
same way in the same configuration, because at these magnitudes the loop is most
of the number. Memory figures are exact memory_get_usage() deltas. Read the
timings relative to each other rather than as absolutes, for
the reasons the method post sets out.
The hook is a function, and the dump says so
The same dump that shows three identical call sites also shows what the hook actually is, further down its own output:
Backed::$v::get:
0000 T0 = FETCH_OBJ_R THIS string("v")
0001 RETURN T0
Virt::$v::get:
0000 T0 = FETCH_OBJ_R THIS string("u")
0001 RETURN T0
Each hook is compiled into a function with its own opcode array and its own name
— the property’s name with ::get after it. Nothing in the calling function
references them. The connection is made at run time, by the handler behind
FETCH_OBJ_R, which checks whether the property it resolved carries hooks and
calls one if it does.
That is the whole mechanism, and it is why the cost does not appear where the cost is incurred.
One detail inside Backed::$v::get is worth reading twice: $this->v compiled
to a plain FETCH_OBJ_R, not to another hook invocation. Inside its own hook, a
backed property refers to the backing store, which is what stops the obvious
infinite recursion.
The getter is the only one of the four shapes that looks different:
readGetter:
0000 CV0($o) = RECV 1
0001 INIT_METHOD_CALL 0 CV0($o) string("getV")
0002 V1 = DO_FCALL
0003 VERIFY_RETURN_TYPE V1
0004 RETURN V1
Four opcodes against one, and an explicit call. Hold onto that, because it is about to be the cheaper of the two.
A virtual property occupies no slot
The memory side matches the promise. A virtual property has no backing store, so it takes nothing from the property table this series measured at 40 bytes plus 16 a slot:
ThreeOnly 96 bytes
ThreePlusVirtual 96 bytes
Four 112 bytes
FourBackedHooks 112 bytes
Three declared properties and three-plus-one-virtual are the same object. Four declared properties and four properties with backing hooks are also the same object. So the memory question has a clean answer: a hook on a backed property costs nothing, and a virtual property saves you the slot a computed value would otherwise have taken if you had cached it.
What a read costs
Five million reads of one integer, net of a 2.986 ns empty loop:
| Read | Total | Net |
|---|---|---|
| Plain property | 4.323 ns (4.307–4.403) | 1.337 ns |
| Getter method | 11.807 ns (11.534–12.249) | 8.821 ns |
| Virtual hook | 14.314 ns (13.525–14.592) | 11.328 ns |
| Backed hook | 15.054 ns (14.566–15.344) | 12.068 ns |
| Virtual hook doing arithmetic | 15.825 ns (15.324–16.108) | 12.839 ns |
__get |
29.880 ns (28.048–30.541) | 26.894 ns |
The backed hook is 9.0 times the plain property it is written to look like, and 1.37 times the getter method it is written to replace. The arithmetic inside the third hook — a multiply and an add — cost 1.5 ns of the 12.8, so almost all of what a hook costs is the fact of being a hook.
__get is more than twice a hook, which is the comparison the hooks feature was
designed to win and does.
Running the same benchmark with OPcache enabled and the JIT off reproduced every row within its range, which is expected: OPcache changes what compilation costs, not what dispatch costs.
A hook you do not declare is free
The branch that runs all of this is in zend_std_read_property in
zend_object_handlers.c, and it is selected by
the property’s offset rather than by anything at the call site:
} else if (IS_HOOKED_PROPERTY_OFFSET(property_offset)) {
zend_function *get = prop_info->hooks[ZEND_PROPERTY_HOOK_GET];
if (!get) {
...
/* Cache the fact that this hook has trivial read. This only applies to
* BP_VAR_R and BP_VAR_IS fetches. */
ZEND_SET_PROPERTY_HOOK_SIMPLE_READ(cache_slot);
retval = OBJ_PROP(zobj, prop_info->offset);
A hooked property with no get hook reads its slot directly and records that
fact in the call site’s cache slot, so subsequent reads skip the check entirely.
That predicts something the table above does not contain, and the prediction
holds:
| Read | Total | Net |
|---|---|---|
| Plain property | 4.373 ns (4.346–4.516) | 1.347 ns |
Property with only a set hook |
4.569 ns (4.533–4.718) | 1.543 ns |
Property with a get hook |
15.012 ns (14.559–15.491) | 11.986 ns |
Declaring a set hook costs the read nothing measurable — 0.196 ns, which is
inside the run-to-run spread of the plain row. The cost belongs to the get
hook specifically, and validation on write, which is the case most hooks exist
for, does not have to buy it.
Writes cost more than reads
The set side is where the gap is widest:
| Write | Total | Net |
|---|---|---|
| Plain property | 4.291 ns (4.261–4.340) | 1.305 ns |
| Setter method | 12.798 ns (12.540–13.268) | 9.812 ns |
set hook |
23.892 ns (23.179–24.527) | 20.906 ns |
A set hook is 2.13 times its equivalent setter method and 16 times a plain
assignment. A hook that validates on write — the case the feature exists for —
starts from there and adds whatever the validation costs.
The JIT makes it worse
The obvious hope is that the JIT closes this. It does the opposite. The same benchmark under the tracing JIT, net of a 1.184 ns baseline in that configuration:
| Access | No JIT | Tracing JIT |
|---|---|---|
| Plain read | 1.337 ns | 0.388 ns |
| Getter method | 8.821 ns | 3.281 ns |
| Virtual hook read | 11.328 ns | 19.250 ns |
| Backed hook read | 12.068 ns | 25.484 ns |
| Plain write | 1.305 ns | 2.396 ns |
| Setter method | 9.812 ns | 4.771 ns |
set hook |
20.906 ns | 27.567 ns |
The JIT cut the plain read to under half a nanosecond and the method call to a third. The hooked read went up. Whatever the JIT is doing with the loop, the hook call is not part of it, and the two shapes end up 7.8 times apart in the direction opposite to the one the syntax suggests.
The function JIT gives the same shape with smaller numbers — 0.818 ns plain, 4.769 getter, 26.091 backed hook, net of a 2.941 ns baseline in that mode — so this is not a quirk of trace compilation. Both modes optimize the plain access and the method call, and neither reaches the hook.
This is behavior on 8.5.10, not a defect anyone has promised to keep. It is worth re-measuring on your own version before acting on it, which is a sentence I would write about any JIT number.
How many accesses before it shows
None of this decides anything on its own, because a nanosecond is a nanosecond. The number that decides it is the access count.
Without the JIT, hook minus getter is 3.247 ns, so about 308,000 accesses in a request buy one millisecond of difference. With the tracing JIT the difference is 22.203 ns, and 45,000 accesses buy the same millisecond.
Those are not absurd counts for a hydration path. An ORM result set of 5,000 entities with twelve accessed fields each is 60,000 reads before anything else happens, and a template that touches the same view model repeatedly multiplies it again.
The reference most advice traces to is Tideways’ 8.4 benchmark, which reports “only a 9% performance difference” between hooks and getter/setter methods and concludes that “performance should not be a concern” in the choice between them. The article’s body does not state its PHP version, iteration count or JIT configuration — the code is behind a link — so I cannot reconcile the two results, only report mine and its conditions. On this build the gap is 37% without the JIT and 5.5 to 7.8 times with it, and against a plain property, which that discussion describes as nearly identical, it is 9.0 times.
Where the two accounts agree is the part that matters most: at ordinary access counts, property access is a small share of a request, and this difference is noise. The disagreement is about what “ordinary” is, and that is a number in your own profile rather than in either of our benchmarks.
What to do with an accessor
Convert for the API, not for the speed. A hook removes a method from the public surface and lets a property become computed without breaking callers, and that is worth something the table cannot price. What the table says is that you are not also getting the plain property’s cost when you do it — you are getting slightly more than the method call you removed, and considerably more once a JIT is involved.
Leave the hot path alone until you have counted it. The place this shows up is not a class with a hook; it is a loop that reads one hooked property per row over a large result set. Count the accesses, multiply by the difference in the table, and compare it to the request time you are trying to protect.
Reach for virtual properties where a value is derived and small. They cost the same read as a backed hook and they save the slot, so a value object that computes rather than stores is smaller and no slower than one that caches into a property — which is the case where hooks give you something the getter never did.
And if the reason for the hook is validation, declare only the set hook. The
read stays at plain-property speed because the engine has nothing to call, and
the expensive half of the feature is the half you were not using.
Комментарии (0)
Пока нет комментариев — будьте первым.