Skip to content

Add fast path to TypeCombinator::union() for trivial 2-type cases#5325

Merged
ondrejmirtes merged 3 commits intophpstan:2.1.xfrom
SanderMuller:perf/typecombinator-union-fast-path
Apr 1, 2026
Merged

Add fast path to TypeCombinator::union() for trivial 2-type cases#5325
ondrejmirtes merged 3 commits intophpstan:2.1.xfrom
SanderMuller:perf/typecombinator-union-fast-path

Conversation

@SanderMuller
Copy link
Copy Markdown
Contributor

Problem

TypeCombinator::union() is one of the most called methods in PHPStan. Profiling shows 30k-66k calls per file on heavy benchmarks. Every call goes through the full normalization pipeline: flattening nested unions, classifying scalars/arrays/enums, sorting integer ranges, O(n^2) deduplication, and array processing.

Many of these calls are trivial 2-type cases where the result is immediately known:

  • union(NeverType, X) — result is X (never is the identity element)
  • union(X, MixedType) — result is MixedType (mixed absorbs everything)
  • union(X, X) — result is X (same object passed twice)

These patterns are common in type narrowing, scope merging, and conditional type resolution.

Solution

Add a fast path before the main normalization loop that handles exactly these three cases when $typesCount === 2. Each check is a cheap instanceof or identity comparison.

The checks mirror logic that already exists deeper in the method (never removal at line 199, mixed short-circuit at line 191, dedup at line 350) but avoids all the setup work.

Impact

Benchmarked on the 5 slowest files from tests/bench/data/, measured in isolation (without other optimizations):

9,363ms → 7,893ms (-15.7%)

All files benefit roughly equally since union() is called pervasively.

@staabm
Copy link
Copy Markdown
Contributor

staabm commented Mar 29, 2026

I tried similar things in the past. in the end I did not propose the patch, because - while it seems useful in microbenchmarks - I was not able to prove on real world code-bases that it makes a meaningful difference.

did you run this change on your projects and did it make a difference?

@SanderMuller
Copy link
Copy Markdown
Contributor Author

I tried similar things in the past. in the end I did not propose the patch, because - while it seems useful in microbenchmarks - I was not able to prove on real world code-bases that it makes a meaningful difference.

did you run this change on your projects and did it make a difference?

I haven't run it yet on my own projects. This is the result from the benchmarks in the CI of this PR (https://github.com/phpstan/phpstan-src/actions/runs/23704938111/job/69055142257?pr=5325 and
https://github.com/phpstan/phpstan-src/actions/runs/23704938111/job/69055142260?pr=5325 ):

Benchmark PR Baseline Change
bug-10772 383ms 511ms -25.0%
bug-6948 35ms 39ms -8.8%
bug-6936 64ms 70ms -8.3%
bug-4308 2.8ms 3.1ms -8.3%
bug-1388 92ms 100ms -7.3%
bug-7903 1.859s 2.000s -7.0%
bug-5390 1.3ms 1.4ms -6.6%
bug-8503 218ms 233ms -6.3%
bug-7214 2.5ms 2.7ms -6.0%
bug-6442 3.0ms 3.2ms -5.0%
bug-4300 5.9ms 6.2ms -3.7%
bug-13685 17ms 17ms -3.7%
bug-7140 30ms 31ms -2.5%
bug-10538 1.103s 1.133s -2.6%
bug-7637 20ms 21ms -2.5%
bug-3686 12ms 12ms -2.4%
bug-11913 168ms 172ms -2.4%
bug-1447 22ms 22ms -2.2%
bug-7901 841ms 858ms -2.0%
bug-13310 51ms 52ms -2.0%
bug-12159 215ms 218ms -1.7%
bug-5231_2 15ms 15ms -1.5%
bug-13933 369ms 373ms -1.2%
bug-11283 908ms 919ms -1.2%
bug-12671 558ms 564ms -1.2%
bug-8146a 131ms 132ms -0.8%
bug-8215 812ms 819ms -0.8%
bug-13218 23ms 23ms -0.7%
bug-10979 817ms 822ms -0.6%
bug-13352 2.990s 3.008s -0.6%
bug-8146b 1.010s 1.015s -0.5%
bug-5081 2.598s 2.609s -0.4%
bug-11297 1.618s 1.625s -0.4%
bug-7581 345ms 346ms -0.4%
bug-6265 182ms 182ms -0.3%
bug-5231 16ms 16ms -0.2%
bug-14207 3.230s 3.235s -0.2%
bug-14207-and 914ms 912ms +0.3%
bug-8147 660ms 657ms +0.4%
bug-12800 1.351s 1.338s +1.0%
bug-14319 832ms 831ms +0.1%
union-fast-path 159ms new file

No benchmark regressed beyond noise. Consistent small improvements across the board, with bug-10772 showing a notable -25%.

I will see if I can run this change on 2 of the bigger projects I work on

@SanderMuller
Copy link
Copy Markdown
Contributor Author

I tried similar things in the past. in the end I did not propose the patch, because - while it seems useful in microbenchmarks - I was not able to prove on real world code-bases that it makes a meaningful difference.

did you run this change on your projects and did it make a difference?

I saw a 2.6% and 2.3% speed up on my 2 bigger projects. So I think it will mainly benefit specific setups like we see in the benchmarks, some bugs receiving 5-25% performance improvement while others show noise level changes.

@ondrejmirtes
Copy link
Copy Markdown
Member

Thank you! This reminded me of something I noticed when I was profiling TypeCombinator some time a go.

When I do this:

diff --git a/src/Type/TypeCombinator.php b/src/Type/TypeCombinator.php
index 46dad7268a..b7ee1190c2 100644
--- a/src/Type/TypeCombinator.php
+++ b/src/Type/TypeCombinator.php
@@ -180,6 +180,12 @@ final class TypeCombinator
 			}
 		}
 
+		foreach ($types as $type) {
+			var_dump($type->describe(VerbosityLevel::precise()));
+		}
+
+		var_dump('------');
+
 		$alreadyNormalized = [];
 		$alreadyNormalizedCounter = 0;
 

The output is full of cases like this:

string(7) "array{}"
string(7) "*NEVER*"
string(7) "*NEVER*"
string(7) "*NEVER*"
string(7) "*NEVER*"
string(7) "*NEVER*"
string(7) "*NEVER*"

So a lot of unions with many NeverType instances.

I'll try to optimize your branch further with this knowledge.

@ondrejmirtes ondrejmirtes force-pushed the perf/typecombinator-union-fast-path branch from f2b7b6c to fe1df1a Compare April 1, 2026 07:28
@ondrejmirtes ondrejmirtes merged commit 3c61234 into phpstan:2.1.x Apr 1, 2026
649 of 654 checks passed
@ondrejmirtes
Copy link
Copy Markdown
Member

Thank you!

@SanderMuller
Copy link
Copy Markdown
Contributor Author

Thank you! This reminded me of something I noticed when I was profiling TypeCombinator some time a go.

When I do this:

diff --git a/src/Type/TypeCombinator.php b/src/Type/TypeCombinator.php
index 46dad7268a..b7ee1190c2 100644
--- a/src/Type/TypeCombinator.php
+++ b/src/Type/TypeCombinator.php
@@ -180,6 +180,12 @@ final class TypeCombinator
 			}
 		}
 
+		foreach ($types as $type) {
+			var_dump($type->describe(VerbosityLevel::precise()));
+		}
+
+		var_dump('------');
+
 		$alreadyNormalized = [];
 		$alreadyNormalizedCounter = 0;
 

The output is full of cases like this:

string(7) "array{}"
string(7) "*NEVER*"
string(7) "*NEVER*"
string(7) "*NEVER*"
string(7) "*NEVER*"
string(7) "*NEVER*"
string(7) "*NEVER*"

So a lot of unions with many NeverType instances.

I'll try to optimize your branch further with this knowledge.

Seems like a nice improvement, it made one bug benchmark case over 90% faster! However, it also made a lot of benchmarks slightly slower.

I've made an iteration on it that should reduce this overhead. However, I don't understand the implications not remotely as well as you do, so I'm looking forward to your review of #5374

Benchmark PR Baseline Change
bug-1388 95ms 100ms -4.7%
bug-7637 21ms 22ms -3.6%
bug-6265 181ms 187ms -3.0%
bug-1447 23ms 23ms -2.7%
bug-12787 6.8ms 7.0ms -2.3%
bug-8503 221ms 226ms -2.2%
bug-10538 1.136s 1.158s -1.9%
bug-9690 314ms 320ms -1.8%
conditional-expression 7.2ms 7.3ms -1.6%
union-fast-path 159ms 161ms -1.5%
bug-10147 0.75ms 0.76ms -1.4%
bug-12671 568ms 576ms -1.4%
bug-5081 2.614s 2.649s -1.3%
bug-11283 989ms 1.002s -1.3%
bug-5390 1.4ms 1.4ms -1.2%
bug-3686 12ms 12ms -1.2%
bug-6936 74ms 75ms -1.1%
bug-11297 1.577s 1.594s -1.1%
bug-13933 373ms 377ms -1.1%
bug-10772 399ms 403ms -1.0%
bug-12159 219ms 221ms -1.0%
bug-7901 865ms 873ms -0.9%
bug-14207 1.554s 1.566s -0.8%
bug-7903 1.886s 1.901s -0.8%
bug-13685 17ms 17ms -0.8%
bug-8146a 133ms 134ms -0.8%
bug-12800 1.351s 1.361s -0.8%
bug-5231_2 15ms 15ms -0.8%
bug-7581 349ms 351ms -0.7%
bug-8146b 1.032s 1.039s -0.7%
bug-10979 820ms 825ms -0.6%
bug-14319 67ms 67ms -0.5%
bug-8147 684ms 679ms +0.7%
bug-4300 6.1ms 6.2ms -0.4%
bug-11913 171ms 171ms -0.4%
bug-13310 52ms 52ms -0.3%
bug-6948 35ms 36ms -0.3%
bug-7140 30ms 30ms -0.2%
bug-11263 654ms 655ms -0.1%
bug-5231 17ms 17ms -0.0%
bug-14207-and 916ms 916ms -0.0%
bug-6442 3.1ms 3.1ms +0.0%
bug-13218 23ms 23ms +0.0%
bug-13352 3.107s 3.105s +0.1%
bug-8215 831ms 830ms +0.2%
bug-4308 3.0ms 3.0ms +0.5%
process-called-method 15ms 15ms +2.7%
bug-7214 2.7ms 2.6ms +3.6%

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.

3 participants