Skip to content

Add microbenchmarks of internals and restore the performance they found regressed - #7066

Merged
ondrejmirtes merged 18 commits into
2.3.xfrom
microbenchmarks-2026-performance
Nov 14, 2026
Merged

ondrejmirtes merged 18 commits into
2.3.xfrom
microbenchmarks-2026-performance

Conversation

@ondrejmirtes

@ondrejmirtes ondrejmirtes commented Nov 14, 2026 •

Copy link
Copy Markdown
Member

Adds microbenchmarks of PHPStan internals to tests/bench, run by Tally next to AnalysisBench, and fixes the regressions they found.

Microbenchmarks. 357 cases in about 90 classes call internals directly: TypeCombinator, ConstantArrayType, UnionType, MutatingScope, ScopeOps, TypeSpecifier, TypeNodeResolver, reflection, rules and return type extensions. BenchContainer boots the container in a #[Before] method, so only the call itself is counted. Some cases declare how they grow (#[Constant], #[Logarithmic], #[Linear], #[Linearithmic], #[Quadratic], #[Polynomial], #[Exponential], #[Complexity]). The others have #[Expected]/#[ExpectedFor] numbers accepted from this PR's CI run.

Most cases reproduce a PR from a "Performance" section of the 2026 release notes. Each was run on the release before the fix, on every release since, and on 2.3.x. Where an older release counted fewer instructions and the code still exists, 2.3.x is brought back toward it.

Optimizations (instructions on 2.3.x → this branch, best older release in brackets):

Change Before After
PHP 8 stubs get their AST cache back (lost in c7c28d4): parsing PDO.php again 105M 13K
range(1, 100000) returns its one array without union() 3.68M 494K (2.2.15: 294K)
Union of 256 constant arrays skips pairs compareTypesInUnion() leaves as they are 106.5M 21.3M
ConstantStringType/ConstantIntegerType::describe() without VerbosityLevel::handle() closures (#6160): union of 16 constant strings 168K 110K (2.2.5: 132K)
ConstantArrayType::isCallable() returns early: VerbosityLevel at 256 8.08M 705K (681K)
NullsafeOperatorHelper marks only the new level: every level of a 64-deep chain 4.87M 263K (2.2.6: 1.92M)
ScopeOps::createConditionalExpressions() describes each part once 5.84M 2.95M
Restricted usage rules skip the intersection with object for an object 590K 364K
TypeNodeResolver enters a declaration once per PHPDoc type: 32 items 626K 518K (2.3.5: 515K)
MutatingScope::getVariableType(), mergeWith(), getNodeKey() fast paths 4.2K / 2.0K / 188K 2.1K / 1.1K / 122K
IsSuperTypeOfResult shared instances: negate 2.54M 1.95M

Each optimization returns what the code returned before. Where a class has a turbo mirror, the C++ was changed to match and checked with the turbo-ext smoke, signature-parity and side-by-side checks.

The headers of the AnalysisBench files from #7059 are accepted from the same run, now that Tally counts memory above the start of the analysis.

Amp thread: https://ampcode.com/threads/T-01a1d525-9c4c-73cf-8edc-000aa8e78378

Each benchmark declares the complexity class of the code it counts:
constant-key lookups in ConstantArrayType, unions and intersections in
TypeCombinator, describing a UnionType, building an array shape, the
binary search in Patcher, and the combinations of CombinationsHelper.
A regression to a worse class fails without numbers to keep up to date.

Amp-Thread-ID: https://ampcode.com/threads/T-01a1d525-9c4c-73cf-8edc-000aa8e78378
mergeWith() returns the scope itself for no other scope or the same one,
getVariableType() answers a certainly defined variable from its holder,
and specifyExpressionType() checks for a scalar before the noop helper.
Each returns what it returned before. The microbenchmarks reproduce the
optimizations of #4640, #4663, #4726 and the other scope PRs of 2026.
They reproduce the optimizations of ArgumentsNormalizer, the expression
printer, TypeUtils, specifying an offset of an array, and the unions and
intersections of #6623. BenchContainer takes extra configuration files
for benchmarks that need parameters.
Each level of a chain is resolved innermost first, and each marked every
level below it again. When the level right below is already marked, only
this one needs marking: asking about every level of a 64-deep chain goes
from 4.87M to 263K instructions.
The nested nodes of a type belong to the declaration resolve() entered,
so they are resolved through resolveType(). A nested enter and leave of
the same declaration did nothing besides costing the calls.
…ection

GenericParametersAcceptorResolver::resolve() infers predicate template
types only for a callable parameter with asserts, which is rare, and
removeFinalByKeywordOverrides() returns a constant scalar without
traversing it. MemoizingReflectionProvider records a found class only
when the context changed. The microbenchmarks reproduce the reflection
performance fixes of 2026.
The union of a single constant array normalizes it once more, which
costs more than computing it: range(1, 100000) goes from 3.68M to 494K
instructions. Adds the microbenchmarks of the rule and return type
extension performance fixes of 2026.
… not an object

The intersection removes what is not an object, such as the null of a
nullable type. For a type that is already an object it returned the
same type and cost the most of the rule.
ConstantStringType and ConstantIntegerType describe themselves without
the closures of VerbosityLevel::handle(), which made a union of 16
constant strings 28 % slower than on 2.2.5. TypeCombinator skips pairs of
plain constant arrays compareTypesInUnion() leaves as they are, and stops
asking a general scalar once one holds every value. UnionType,
IntersectionType, ObjectType, HasMethodType and ConstantArrayType::
isCallable() return earlier or memoize. The C++ mirrors do the same. The
microbenchmarks reproduce the type system performance fixes of 2026.
…::union()

Most unions flatten one union of a few members, which is cheaper to scan
than to index by object id, and most have no float range to sort.
DependencyResolver tests for a class reflection first, the most common.
The microbenchmarks reproduce the infrastructure performance fixes of
2026.
ScopeOps::createConditionalExpressions() keys each holder from guard and
target parts described once each instead of once per pair, and returns
early when no differing key is certain in both branches. getNodeKey()
gets back the fast path for a variable that #6200 removed. The C++
mirror does the same. The microbenchmarks reproduce the remaining scope
and TypeSpecifier performance fixes of 2026.

Amp-Thread-ID: https://ampcode.com/threads/T-01a1d525-9c4c-73cf-8edc-000aa8e78378
IsSuperTypeOfResult returns its shared instances without create(),
ObjectType::isSuperTypeOf() skips the LRU touch of the description it
touched last, UnionTypeHelper::sortTypes() asks for FloatRangeType after
the enum branch, ConstantArrayType::isSuperTypeOf() keys its value pairs
by nested object ids, and FiniteTypeSet::key() checks for a compound
type before its exclusions. Each returns what it returned before. The
microbenchmarks reproduce the remaining type system performance fixes
of 2026.

Amp-Thread-ID: https://ampcode.com/threads/T-01a1d525-9c4c-73cf-8edc-000aa8e78378
Since c7c28d4 moved the cache behind the path routing, only the rich
and the simple parser had one, so reflection parsed a php-8-stubs file
again for each symbol it declares. The stub parser gets its own cache,
which keeps its ASTs apart from those of the other parsers. Parsing the
PDO stub again goes from 105M to 13K instructions.

Amp-Thread-ID: https://ampcode.com/threads/T-01a1d525-9c4c-73cf-8edc-000aa8e78378
The downgrade does not rewrite match, throw expressions and ::class on
objects. BenchEnum cannot be downgraded and is left out of lint, like the
other enum files.

Amp-Thread-ID: https://ampcode.com/threads/T-01a1d525-9c4c-73cf-8edc-000aa8e78378
…rror

The differential test compares the class constants of both
implementations.

Amp-Thread-ID: https://ampcode.com/threads/T-01a1d525-9c4c-73cf-8edc-000aa8e78378
The microbenchmarks get their first numbers. The analysis benchmarks
get numbers measured with Tally, which counts memory above the start of
the analysis.

Amp-Thread-ID: https://ampcode.com/threads/T-01a1d525-9c4c-73cf-8edc-000aa8e78378
@ondrejmirtes
ondrejmirtes merged commit 20c433f into 2.3.x Nov 14, 2026
895 of 907 checks passed
@ondrejmirtes
ondrejmirtes deleted the microbenchmarks-2026-performance branch November 14, 2026 07:49
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