Skip to content

Skip the parent-chain walk in instanceof for a final or enum RHS - #24370

Closed
GromNaN wants to merge 1 commit into
php:masterfrom
GromNaN:instanceof-final-fast-path
Closed

GromNaN wants to merge 1 commit into
php:masterfrom
GromNaN:instanceof-final-fast-path

Conversation

@GromNaN

@GromNaN GromNaN commented Oct 16, 2026 •

Copy link
Copy Markdown
Member

$x instanceof FinalClass walks the whole parent chain of $x before it can return false. Because a final class cannot be extended, and an enum is always final, such a right hand side can never be an ancestor of the left hand side, so the walk can stop immediately.

The check is added in instanceof_function_slow(), in the branch that walks the parent chain. The interface branch is left untouched, since interfaces can never be final. The mask tests ZEND_ACC_FINAL|ZEND_ACC_ENUM because internal enums created by zend_register_internal_enum() set only ZEND_ACC_ENUM, for example RoundingMode and SortDirection.

The inline helper instanceof_function() was not used for the check, because ZEND_ACC_FINAL is defined in zend_compile.h, which zend_operators.h does not include, and adding it there would create an include cycle through zend_globals.h. Putting the check in the ZEND_INSTANCEOF opcode handler was also tried, but it slows the shared handler on the non final paths by about 6 to 8 percent.

Benchmarks

PHP 8.7.0-dev on macOS arm64, opcache CLI on, JIT off, inline loops, 30M iterations per case, median of 12 paired runs. The two binaries run back to back inside the same pair, and the reported number is the ratio of the medians.

case baseline patched ratio
final hit 104.4 Mops/s 103.6 Mops/s 0.997x
final miss, depth 1 96.5 100.1 1.023x
final miss, depth 3 92.7 99.9 1.078x
final miss, depth 6 87.1 100.7 1.147x
enum miss 97.9 99.8 1.027x
interface miss 96.9 95.3 0.998x
non final hit 103.3 103.2 1.003x
non final miss 97.2 95.9 0.983x

The final misses become flat at about 100 Mops/s whatever the depth, close to the hit cost. The win grows with the depth of the left hand side hierarchy, which is the realistic case in frameworks. At depth 1 the only saving is a single pointer load, so the gain is about 2 percent.

Under JIT tracing the same direction holds, about 10 percent at depth 3 and 15 percent at depth 6, but that mode is much noisier and has no dedicated ZEND_INSTANCEOF codegen.

Overhead on the non final paths

The new test runs before the walk, so it also runs for every non final right hand side that reaches instanceof_function_slow(), both on a miss and on a hit found further up the chain. That is the common shape in general PHP code, so it was measured on its own.

A control run of the baseline against itself showed that the second binary of a pair is consistently about 1 percent faster, on all four cases. To cancel that ordering effect, each comparison was run in both orders, 12 pairs in one order and 10 in the other, and the two results were combined.

case patched / baseline
non final miss, depth 1 0.986x
non final hit through an ancestor, depth 2 0.985x
non final miss, depth 6 0.991x
non final hit through an ancestor, depth 6 0.986x

The cost is about 1 to 1.5 percent, or 0.1 to 0.15 ns per operation. It has the same sign on all four cases and in both orders, so it is above the noise. The identity hit, the interface right hand side, and the final or enum right hand side do not pay it.

Trade-off

The gain applies to a final or enum right hand side on a left hand side deep enough for the walk to be visible, about 1.5 ns per operation at depth 6. The cost applies to every non final right hand side that reaches the slow function, about 0.15 ns per operation. In code where the right hand side is mostly non final, the patch is roughly neutral, and slightly negative at shallow depth. This is why the change stays small and does not touch the shared opcode handler.

Tests

The new test Zend/tests/instanceof_final.phpt covers a final class hit and miss, a final class that implements an interface, a userland enum, an internal enum, a readonly final class, non object left operands, an undefined right operand, a variable right operand, and self, parent and static. It produces the same output on the patched and unpatched binaries. The whole Zend/tests suite passes with 0 failures.

A final class cannot be extended, and an enum is always final, so a final or
enum class entry can never be an ancestor of the left hand side. Return false
immediately instead of walking the parent chain.

Internal enums created by zend_register_internal_enum() only set
ZEND_ACC_ENUM, so the check tests both ZEND_ACC_FINAL and ZEND_ACC_ENUM. The
interface branch is left untouched, since interfaces can never be final.
@GromNaN
GromNaN force-pushed the instanceof-final-fast-path branch from 61cdef3 to 0df488e Compare October 16, 2026 08:55
@GromNaN
GromNaN marked this pull request as ready for review October 16, 2026 10:47
@GromNaN
GromNaN marked this pull request as draft October 16, 2026 10:49
@GromNaN

GromNaN commented Oct 16, 2026

Copy link
Copy Markdown
Member Author

Closing this one. The change is correct and the test is solid, but the win does not survive a realistic look at PHP code, so it is not worth adding a branch to the common path.

What was measured

PHP 8.7.0-dev, macOS arm64, opcache CLI on, JIT off, 30M iterations per case, median of 12 paired runs. Each comparison was also run in both orders, to cancel a one percent ordering effect found with a baseline against baseline control.

Per operation, the patch saves:

case gain or cost per operation
final miss, no parent class +0.37 ns
final miss, 3 ancestors +0.78 ns
final miss, 6 ancestors +1.55 ns
non final miss -0.14 ns
interface right hand side neutral

Why it does not pay off

The gain grows with the depth of the left hand side hierarchy. The cost is fixed, one extra flag test before the walk, and it lands on every non final right hand side that reaches the slow function.

Real projects use class hierarchies 0 to 2 levels deep, so the gain stays in the 0.37 to 0.6 ns range while the cost stays at 0.14 ns. The break-even point is about one final or enum right hand side for every three non final ones. The net effect is under a nanosecond per operation in either direction, and the sign depends on the mix. The cases that gain the most, a deep hierarchy with a final class on the right, are the least common, and interface right hand sides, which are the most common in frameworks, are unaffected.

The test

Zend/tests/instanceof_final.phpt was reviewed adversarially. The expected output is byte for byte correct on both the patched and the unpatched binary, and the test passes on both. It catches every behavior changing bug that was tried: checking the left hand side instead of the right, placing the check above the interface branch, returning true instead of false, and breaking the walk for a non final right hand side.

It cannot catch dropping the ZEND_ACC_ENUM half of the condition, because that half is not observable. An enum can never be an ancestor of anything, so the walk returns false anyway. That half is a pure performance detail.

The test stays in the branch and can be reused as is, if this is ever approached again from the opcode handler side, where the check could skip the call to instanceof_function_slow() entirely instead of only the walk inside it. That variant was tried here and measured about 6 to 8 percent slower on the non final paths, so it needs a different construction, but the ceiling is higher.

@GromNaN GromNaN closed this Oct 16, 2026
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.

1 participant