Skip to content

Dispatch native Type calls directly and build the extension profile-guided - #6508

Merged
ondrejmirtes merged 8 commits into
2.3.xfrom
turbo-type-kernel-dispatch
Sep 22, 2026
Merged

ondrejmirtes merged 8 commits into
2.3.xfrom
turbo-type-kernel-dispatch

Conversation

@ondrejmirtes

Copy link
Copy Markdown
Member

Removes the remaining crossings between the native Type kernel and PHP, and builds the distributed binaries profile-guided.

Native reads of PHP-owned state

  • ClassReflectionAccess.cpp answers getName(), isGeneric(), getCacheKey(), hasMethod(), isEnum() and a few other getters from an exact ClassReflection's private slots. MutatingScope::isInClass() and getClassReflection() are answered from the native ScopeContext; subclasses that inherit those two getters unchanged, such as NodeCallbackScope, qualify too. Anything a slot can't answer still calls the PHP method.
  • ReflectionAccess.cpp answers ReflectionProviderStaticAccessor::getInstance(), and MemoizingReflectionProvider::hasClass()/getClass() from its memo slots.
  • LruCache, UnresolvableTypeHelper, NativeParameterReflection and the four called-on-type/callback prototype reflections are shadowed natively.

On a self-analysis run, this cuts Type-kernel→PHP calls from 12.4M to under 1M.

Direct native-to-native dispatch

A native body calling equals(), isSuperTypeOf(), traverse() or a similar method on another native object used to go through the engine: method lookup, a call frame, argument copies and the handler's parameter parsing. That was 34M calls per self-analysis, each landing in a one-line delegation.

  • TypeOps.h defines about 50 hot operations with their argument contracts, and each native class registers direct C++ entries for them. pt_type_op() calls an entry only when the receiver is exactly a registered native class and the arguments satisfy the contract. A PHP subclass or an unexpected zval takes the engine path as before.
  • Name-delegating wrappers (StaticType, ClosureType, the late-resolvable trait) enter their inner type's entry directly. The native callback holders are called without a frame.

turbo-ext/Makefile now rebuilds every object when any header changes, because a stale op-table layout otherwise links silently.

Profile-guided release builds

make pgo runs three stages: an instrumented build, a training run of PHPStan analysing its own sources with that build loaded, and a final build using the recorded profile. The phar workflow's compile jobs install the Composer dependencies and build with make pgo; the Windows job is unchanged. Measured with the same workload in 8 ABBA pairs, it's 0.72% less user CPU (paired t −7.7) with identical output and a 20% smaller binary.

Verification

On this commit, with the extension loaded and active:

  • side-by-side.php, signature-parity.php and smoke.php pass.
  • make tests, make phpstan, make cs and make lint are clean.
  • A strict clang build and a strict g++-15 build (-Wall -Wextra -Werror) both pass.

🤖 Generated with Claude Code

https://claude.ai/code/session_017MvPby652L7wUqGAHEiEcN

- name: "Compile phpstan_turbo with strict warnings"
# The training run of `make pgo` (bin/pgo-train.sh) runs bin/phpstan on
# the checkout, so the dependencies must be in place before the build.
- uses: "ramsey/composer-install@65e4f84970763564f46a70b8a54b90d033b3bdda" # v4.0.0
ondrejmirtes and others added 8 commits September 22, 2026 09:23
…uCache

ClassReflectionAccess.cpp answers getName()/isGeneric()/getCacheKey()/
hasMethod()/hasFinalByKeywordOverride()/getNativeReflection()/isEnum() on
an exact ClassReflection from the twin's private slots (offsets resolved
once per class entry, forgotten at rinit) and MutatingScope::isInClass()/
getClassReflection() from a native ScopeContext's slot when the scope is
exactly a MutatingScope; anything the slot cannot answer still calls the
PHP method. Class-map keys classReflection, mutatingScope and the vendored
reflectionEnum are appended (looked up without autoloading, like
instanceof); the lruCache key is gone: PHPStan\Internal\LruCache is
shadowed by LruCache.cpp (symtable keys, count/weight eviction with the
floor, the int-overflow TypeError) with pt_lru_cache_* helpers and a
smoke.php differential. reg.h gains a typed int property builder and
mixedArg(). Self-analysis census: Type-kernel->PHP calls 12.43M -> 5.84M
(ClassReflection 5.28M -> 0.58M, MutatingScope 1.99M -> 0.70M, LruCache
0.59M -> 0).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MShHKdUB19w38vboJLPKXy
NodeCallbackScope inherits isInClass() and getClassReflection() from
MutatingScope unchanged, so the native slot read applies to it too; a
subclass qualifies when both methods resolve to MutatingScope's own
declarations, and the last approved class entry is remembered.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MShHKdUB19w38vboJLPKXy
A native body calling another native object's equals()/isSuperTypeOf()/
traverse()/... went through the engine (pt_type_call: method lookup,
zend_call_function frame, argument copies, the handler's zpp) — 34.2M
such calls per self-analysis, each landing in a one-line delegation.

- TypeOps.h/.cpp: a pt_type_op_id enum of 27 hot operations with their
  argument contracts, a per-class table of direct C++ entries (`zv::Val
  (*)(zend_object *self, zend_class_entry *scope, uint32_t argc, zval
  *argv)`), and pt_type_op()/pt_type_op_trinary(): the entry is called
  when the receiver is EXACTLY a registered native class that registered
  the op and the arguments satisfy the contract; anything else (a PHP
  subclass, a reference, a wrong zval kind) takes pt_type_call() as
  before. The ops of a class entry are found in a direct-mapped
  open-addressing table keyed by the zend_class_entry pointer (the engine
  leaves no CE field free for a runtime-linked user class), filled at
  activation in Shadow.cpp; a native subclass inherits the parent's entry
  (with the parent's scope) for every method it does not declare itself.
- reg::Class::op()/traitOp() register the entries next to the
  cls.method()/cls.traitMethod() lines they mirror, an entry that only
  delegates to a handle member is generated from it (cls.op<PT_OP_X,
  &Handle::member>(): reg::detail::BoundOp derives the argv conversions
  from the member's parameter types and the result conversion from its
  return type; a member may ignore trailing op arguments but never take
  more than the op passes), any other is a one-line lambda; a type op read
  as a bool is pt_type_op_bool(); traitOp() binds to the immediately
  preceding traitMethod() and only when that call added the method, so the
  class-body/first-trait precedence is kept.
- The callback holders (PHPStanTurbo\NativeCallback, ObjectTypeCallback)
  and TypeTraverser::mapInternal()/traverseInternal() are entered without
  a frame by pt_call_fci() and pt_type_call_callable() when a resolved
  callable is one of them (pt_direct_invoke); their PHP-visible __invoke()
  and methods stay.
- pt_type_call() resolves the name to an op (length-bucketed memcmp, only
  for a receiver with an ops table), so the per-file by-name wrappers take
  the direct path too; 467 literal call sites use pt_type_op() outright;
  pt_call_type_equals()/pt_type_describe_precise() and ScopeOps' type
  queries likewise; the multi-statement JustNullable/ConstantScalar trait
  handlers share their bodies with the entries.

Skipped on the direct path, and why that is safe: the execute_data frame
(no observer/backtrace entry — pure queries), the handler's zpp (replaced
by the op's argc/zval-kind pre-check with engine fallback; zpp checks no
class either), the frame's argument/receiver addrefs (borrowed from the
caller for the call — immutable Type/result objects), and the internal
return-type check (never done at run time in release builds).

Call census (self-analysis of src/Analyser, src/Rules, src/Type): native ->
native calls through the engine 34.2M -> 7.2M; user CPU -2.3% over three
interleaved pairs against the base extension; analysis output identical.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MShHKdUB19w38vboJLPKXy
The first round left 7.2M native -> native calls per self-analysis on
the engine path; the big items were name-delegating wrappers, the
callback holders and getters outside the op set.

- StaticType, ClosureType, the LateResolvableTypeTrait and
  UnresolvedTemplateArgumentType forward to their inner type by name
  (`$this->getStaticObjectType()->x()`, `$this->resolve()->x()`); each
  gets a delegateOp() that enters the inner native type's direct entry,
  and the classes register direct entries for every delegated op.
- The PHPStanTurbo\StaticTypeCallbacks holder (transform/map/guard/
  toArgument) and IdentityCallback::identity() are recognised by
  pt_direct_invoke() like the NativeCallback/ObjectTypeCallback
  holders, so the TypeTraverser and RecursionGuard enter their C++
  bodies without a frame.
- 24 ops added to pt_type_op_id for the large getters and member
  queries: getEnumCaseObject, isComplete, isUnsealed, getKeyTypes/
  getValueTypes/getOptionalKeys, getValue, getClassReflection,
  getTypes, getSubtractedType, getTypeWithoutSubtractedType,
  getItemType, getObjectClassReflections, getReferencedClasses,
  hasOffsetValueType/getOffsetValueType, getAncestorWithClassName,
  hasMethod, hasInstanceProperty, changeBaseClass,
  getUnresolved{InstanceProperty,Method}Prototype, or, isTypeOnly —
  registered next to the cls.method() lines they mirror (string
  arguments are contract-checked as IS_STRING, class-typed ones with
  an instanceof guard falling back to the engine).
- IsSuperTypeOfResult / AcceptsResult ::lazyMaxMin(), ::extremeIdentity()
  and the variadic and()/or() get direct C++ entry points used by the
  IntersectionType / UnionType / ConstantArrayType / IntegerRangeType
  bodies that called them by name; TypeCombinator takes the
  VerbosityLevel singletons instead of calling the factories by name;
  the UnionType / IntersectionType describe() bodies read the level
  through pt_verbosity_level_value_of().

Skipped on the new direct paths, and why that is safe: the same as the
first round — no frame (pure queries), the handler's zpp replaced by the
op contract with engine fallback, borrowed arguments on immutable
objects. The delegateOp() fallback for a resolved type without the
method reports pt_find_method()'s error text instead of the
delegateNamed() one; every Type implements the interface methods, so
that path is unreachable.

Call census (self-analysis of src/Analyser, src/Rules, src/Type): native
-> native calls through the engine 7.17M -> 2.03M; user CPU unchanged within noise over six interleaved
pairs against the base extension (paired mean +0.8s, sd 2.3s, t=0.9;
the removed frames are worth ~0.2s); analysis output identical.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MShHKdUB19w38vboJLPKXy
The object rule listed only support.h, zv.h and reg.h, so a change to
TypeOps.h or TypeTraits.h left objects built against the old op-table
layout in place; the linked extension then jumped into the wrong entry and
spun at activation. Every object now depends on every header.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MShHKdUB19w38vboJLPKXy
ReflectionAccess.cpp answers ReflectionProviderStaticAccessor::getInstance()
from the twin's static slot and MemoizingReflectionProvider::hasClass()/
getClass() from its $knownClasses/$unknownClasses/$classes memo slots
(exact class entry, the method on a miss); every native provider call site
goes through these readers. Six reflection value classes the kernel builds
and calls per member lookup are shadowed natively: UnresolvableTypeHelper
(a DI service; its TypeTraverser closure is a native callback holder, so
the walk never leaves C++), NativeParameterReflection, and the four
CalledOnType/Callback unresolved method/property prototype reflections —
their transformMethodWithStaticType()/transformPropertyWithStaticType()
share pt_prototype_resolved_method()/_property() in TypeTraits.cpp, the
CalledOnType transformStaticType() closures run as native traverser
callbacks. Class-map keys calledOnType*/callback*PrototypeReflection and
nativeParameterReflection are gone (pt_*_new helpers replace them);
memoizingReflectionProvider, unresolvableTypeResult, extendedDummyParameter,
extendedFunctionVariant, resolved*/changedType* reflections are appended.
Differential coverage under the real names in tests/type-family.php over
a new PrototypeFixture. Self-analysis census: Type-kernel->PHP calls
5.70M -> 3.50M (MemoizingReflectionProvider 459K -> 46K,
ReflectionProviderStaticAccessor 236K -> 0, UnresolvableTypeHelper
369K -> 0, CalledOnTypeUnresolvedMethodPrototypeReflection 430K ->
0, NativeParameterReflection 315K -> 0).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MShHKdUB19w38vboJLPKXy
`make pgo` runs the three stages locally or in CI: an instrumented build,
a training run (bin/pgo-train.sh: PHPStan analysing its own analyser, rule
and type sources with that build loaded through an ini scan directory,
which survives the CLI's OPcache restart), and the final build using the
recorded profile — clang's .profraw files merged by llvm-profdata, GCC's
.gcda files read back from beside the objects. phar.yml's compile jobs
install the Composer dependencies first and build with `make pgo`; the
Windows job is unchanged.

Measured on the current head (macOS, clang, 8 ABBA pairs of the same
workload): -0.72 % user CPU, paired t -7.7, identical output, a 20 %
smaller binary.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MShHKdUB19w38vboJLPKXy
@ondrejmirtes
ondrejmirtes force-pushed the turbo-type-kernel-dispatch branch from bc41bdd to cad5a85 Compare September 22, 2026 07:24
@ondrejmirtes
ondrejmirtes merged commit cad5a85 into 2.3.x Sep 22, 2026
203 of 206 checks passed
@ondrejmirtes
ondrejmirtes deleted the turbo-type-kernel-dispatch branch September 22, 2026 07:24
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants