Skip to content

Free and look up less: native fast paths, shared scope services, reasonless result sharing - #6768

Merged
ondrejmirtes merged 13 commits into
2.3.xfrom
native-fast-paths
Oct 20, 2026
Merged

ondrejmirtes merged 13 commits into
2.3.xfrom
native-fast-paths

Conversation

@ondrejmirtes

Copy link
Copy Markdown
Member

Twelve changes that make the analysis itself do less work: freeing objects, by-name lookups and type traversals. They came out of profiling the turbo-codegen branch but stand without it. Each touches hand-written turbo code or plain PHP only.

Full parallel self-analysis vs 2.3.x (turbo on, ShipMonk DCD off in both legs, 5 alternating pairs): user CPU −4.37% (51.47 → 49.22 s, faster 5/5), wall −2.81% (8.34 → 8.11 s, faster 5/5), main-process peak memory −1.9%. Identical output (no errors in either leg).

Native only

Change Instructions*
Skip the static-type rewrites (prototype reflections, StaticType receivers) of types containing no StaticType. Whether a type contains one is computed once per type object and kept in its native header. −1.18%
TypeTraverser builds its two bound callables once per traversal instead of once per traversed type. −1.01%
One shared plain ObjectType per class name for native code, dropped with ObjectType::resetCaches(). −0.92%
resolveToBounds()/resolveToDefaults() skip types containing no TemplateType, using the same per-type cached answer. −0.59%
Node class infos looked up by class entry before the by-name table. −0.59%
While the garbage collector is disabled (bin/phpstan), stop the engine buffering possible cycle roots that nothing will ever collect. −0.58%, −10 MB
StaticType's callbacks as native closures instead of a holder object plus a Closure. −0.26%
TypeTraverser instances reused across traversals. −0.20%
ObjectType's guarded callbacks as native closures. −0.13%

PHP and native

  • ScopeServices: the thirteen services, the expression result storage stack and the node callback every scope carried in a property each now live in one object the scope factory builds once. MutatingScope goes from 37 property slots to 23, which saves copying and releasing them for every scope. −0.88% instructions, ~−3% cycles.
  • IsSuperTypeOfResult::createFromTrinaryLogic() (new, @api): returns the shared createYes()/createNo()/createMaybe() instance. The ~40 sites that spelled new IsSuperTypeOfResult($result, []) use it. and(), or(), extremeIdentity(), maxMin(), lazyMaxMin(), negate() and decorateReasons() return the shared instance when no operand has reasons, and skip merging empty arrays. −0.48% instructions.
  • FunctionReturnTypeCheck asks VerbosityLevel::getRecommendedLevelByType(), which traverses both types, only when it reports an error, instead of for every return. −0.29% instructions.

* Instructions retired on a single-process analysis of src/Analyser src/Rules src/Type, each measured on its own in alternating pairs on the turbo-codegen branch.

Verified on this branch: walk trace identical (664,909 lines), the whole suite with and without the extension (22,539 tests), make phpstan, phpcs, the shared core identical across PHP 8.3–8.6 NTS/ZTS, clang-tidy.

🤖 Generated with Claude Code

https://claude.ai/code/session_01MMNnG2Zo7wgvSZCzjdk17X

ondrejmirtes and others added 13 commits October 20, 2026 20:24
pt_object_type_new() without a subtracted type or a handed-in class
reflection hands back one ObjectType per class name: the value is the
same, and the instance's caches (ancestors, parent, interfaces, the
lazily resolved class reflection, the method cache) are computed once
instead of per instance. The reflection belongs to the container, so the
table is dropped with ObjectType::resetCaches(), which ContainerFactory
calls for every new container, and at request shutdown.

652k ObjectType creations per single-process self-analysis were the
largest group of type objects in the allocation census. Measured −0.92%
instructions (sd 0.06%, n=4), user CPU −0.71% (noisy), identical output,
peak memory unchanged. Leaf types (MixedType, NullType, ...) are not
interned: that was measured at ~0 before and removed (#6355).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MMNnG2Zo7wgvSZCzjdk17X
TypeTraverser built a fresh [$this, 'mapInternal'] / [$this,
'traverseInternal'] array for every traversed type, and each native
Type::traverse() resolved it by name through zend_fcall_info_init().
The traversals in progress now keep their two callables on a small
stack, released when map() returns, and the traverse op fills the
fci/fcc of such an array directly. A callable kept or called after its
map() returned is built and resolved as before.

Instructions retired on self-analysis: -1.01% (3 ABBA pairs), identical
analysis output.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MMNnG2Zo7wgvSZCzjdk17X
bin/phpstan disables the garbage collector, but the engine still records
each array and object whose refcount drops to non-zero in the root buffer,
which then only grows, and every free of a buffered value searches it to
remove the entry (gc_remove_compressed once past 512K roots). Activation
protects the buffer when the collector is off, so nothing is recorded.

-0.58% instructions, -10MB peak memory on the single-process benchmark.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MMNnG2Zo7wgvSZCzjdk17X
Every printed, keyed or attribute-read node asked the by-name table, which
hashes and compares the class name; a direct-mapped table by class entry
in front of it answers them with one load.

-0.59% instructions (median) on the single-process benchmark.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MMNnG2Zo7wgvSZCzjdk17X
map() created an instance per traversal (1.6M per self-analysis subset)
and freed it when the traversal ended. An instance nothing outlived the
traversal with is now reset to the constructor's state and handed to the
next map().

-0.20% instructions on the single-process benchmark.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MMNnG2Zo7wgvSZCzjdk17X
Each transformStaticType() created a holder object and a Closure over one
of its methods (a copy of the function included). A native closure is one
object carrying the body and the captured values.

-0.26% instructions on the single-process benchmark.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MMNnG2Zo7wgvSZCzjdk17X
The RecursionGuard and TemplateTypeMap::map() callbacks were a holder
object plus a Closure over its __invoke(); a native closure replaces both,
and the by-reference capture carries the offsetSet() value type back.

-0.13% instructions on the single-process benchmark (within noise; it
removes the holder's invocation path).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MMNnG2Zo7wgvSZCzjdk17X
A self-analysis created 1.4M results, nearly all without reasons, and
and()/or()/extremeIdentity()/maxMin() merged three empty arrays per call
into yet another one. createFromTrinaryLogic() returns the createYes() /
createNo() / createMaybe() instance for a result, the call sites that
spelled new IsSuperTypeOfResult($result, []) use it, and the combinators
skip the merging when no operand has reasons and return a shared
instance.

-0.48% instructions, -0.60% cycles on the single-process benchmark.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MMNnG2Zo7wgvSZCzjdk17X
Every method call on a static receiver and every called-on prototype
mapped each signature type through a TypeTraverser callback that changes
StaticTypes only. Whether a type contains one is now asked once per type
object - by the same traversal, answered from the type's native header
afterwards - and a type without one is returned as is.

-1.18% instructions, -0.71% cycles on the single-process benchmark.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MMNnG2Zo7wgvSZCzjdk17X
checkReturnType() asked VerbosityLevel::getRecommendedLevelByType() - a
traversal of both types - for every return statement, and used the answer
only in the messages of the errors it reports.

-0.29% instructions on the single-process benchmark.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MMNnG2Zo7wgvSZCzjdk17X
resolveToBounds() and resolveToDefaults() traverse a type to replace its
TemplateTypes and change nothing else. The cached contains-a-StaticType
answer becomes a general one - whether a traversal reaches an instance of
a class, remembered per type object under its own fact bit - and a type
without a TemplateType is returned as is.

-0.59% instructions on the single-process benchmark.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MMNnG2Zo7wgvSZCzjdk17X
Every MutatingScope carried the thirteen services, the expression result
storage stack and the node callback in a property each - the same values
in every scope of a factory, copied into each of the ~900K scopes of a
self-analysis subset and released from each again. They now live in a
ScopeServices object the factory builds once, and a scope holds one
reference to it (37 property slots down to 23).

-0.88% instructions, ~-3% cycles on the single-process benchmark.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MMNnG2Zo7wgvSZCzjdk17X
@ondrejmirtes
ondrejmirtes merged commit a7e2b6a into 2.3.x Oct 20, 2026
536 of 537 checks passed
@ondrejmirtes
ondrejmirtes deleted the native-fast-paths branch October 20, 2026 18:53
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.

1 participant