Look up constant-scalar and enum-case members of a UnionType via an O(1) set in isSuperTypeOf/accepts#6072
Conversation
… O(1) set in `isSuperTypeOf`/`accepts` - Add a lazily built `finiteMemberLookup` set on `UnionType` keyed by class + `describe(VerbosityLevel::cache())` of every constant-scalar and enum-case member. - In `UnionType::isSuperTypeOf()`, before the linear member scan, return `Yes` immediately when the other type is a constant scalar / enum case that is literally one of the union's members. This turns union-against-union comparisons of large constant-value unions from O(n*m) into O(n+m): `isSubTypeOf()` maps each of the other union's members through `isSuperTypeOf()`, so each lookup is now O(1) instead of an n-member scan that also computes a `describe()` per member. - Apply the same fast path in the analogous `UnionType::accepts()`, which previously never short-circuited inside its member loop. - `UnionType::isSubTypeOf()` benefits transitively because it delegates to the other type's `isSuperTypeOf()`. - Equal cache keys imply equal types (constant-scalar and enum-case cache descriptions fold in value/subtracted-type), so the fast path only ever short-circuits a guaranteed `Yes` and never changes a result. Analyzing Tempest's `CatalogInitializer` (a `foreach` over `Locale::from($locale)->value`, a union of 782 enum backing values, used as an array key) dropped from ~9.8M `ConstantStringType::isSuperTypeOf` calls to a linear number.
| private static function getFiniteMemberKey(Type $type): ?string | ||
| { | ||
| if ($type->isConstantScalarValue()->yes() || $type->getEnumCaseObject() !== null) { | ||
| return $type::class . "\0" . $type->describe(VerbosityLevel::cache()); |
There was a problem hiding this comment.
should use get_class for PHP 7.4 compat
There was a problem hiding this comment.
I've addressed both of staabm's review comments and pushed the changes.
Changes made
1. get_class() for PHP 7.4 compat (commit 2c4476585)
- Replaced
$type::classwithget_class($type)ingetFiniteMemberKey(). - Added
use function get_class;to satisfy the coding standard (ReferenceViaFallbackGlobalName).
2. Performance test moved into tests/bench/data (commit a1762f41b)
- Added
tests/bench/data/bug-14978.php, a faithful reproduction of Tempest'sCatalogInitializerpattern using a 782-case backed enum (the real-world size from the issue) with the nestedforeacharray-key writes. This is auto-discovered byRegressionBench::provideFiles(), so the optimization is now exercised by the benchmark suite — where a 4-case enum couldn't surface any quadratic blowup. - I kept the 4-case
tests/PHPStan/Analyser/nsrt/bug-14978.phpas the correctness lock (itsassertType()calls verify the optimization leaves inferred types unchanged) — that's a correctness test, not a perf test, so it belongs in nsrt.
Verification
make phpstan— No errors (self-analysis).phpcsonsrc/Type/UnionType.php— clean.NodeScopeResolverTest(globsnsrt/*.php) +UnionTypeTest— 1864 tests, OK.- The new bench file analyses cleanly with no errors (verified via a copy outside the excluded
tests/bench/datapath; the original values collided at first, regenerated with guaranteed-unique enum values).
One note for the reviewer: I interpreted "move" as relocating the performance reproduction to tests/bench/data while retaining the correctness lock in nsrt. If you'd prefer the nsrt file removed entirely, let me know and I'll drop it.
…r key Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Reproduces the Tempest CatalogInitializer pattern with an 782-case backed enum so the performance fix is exercised by the benchmark suite. The nsrt bug-14978.php remains as the correctness lock (asserting output is unchanged). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
An enum's `name`/`value` property was always expanded into a union of one constant member per case. For enums with hundreds of cases (e.g. Tempest's 782-case `Locale`) that union then propagates through every type operation — as array keys, in comparisons, etc. — making analysis of otherwise trivial code take seconds. Above `ObjectType::ENUM_CASES_LIMIT` (512) cases, generalize the `name`/`value` property to the backing type / a string instead of carrying the per-case constant union. The precise per-case literals are traded for a manageable general type only once the enum is far too large for that precision to be practical. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
|
Pushed. Here's a summary of how I addressed staabm's review. Review: "even after the fix the origin reproducer is still slow (~4s). could we generalize the union of enum cases for enums with more than 512 values instead?"Done. The O(1) lookup from the previous commits made comparisons of large constant-value unions cheap, but the reproducer was still slow because the giant union itself ( Change (commit
|
| time | |
|---|---|
| 782-case reproducer before | ~3.8s |
| after | ~1.9s (baseline PHPStan startup alone is ~0.85s, so the analysis portion roughly halved on top of the earlier O(1) win) |
Verification
make phpstan— No errors.make cs— clean.- Full suite via paratest — 17608 tests, 89993 assertions, OK (97 version-gated skips).
One note: I kept the O(1) finiteMemberLookup fast path from the earlier commits — it's complementary and still helps genuinely large constant-value unions that legitimately form (large @param 'a'|'b'|… literals, enums between the two sizes).
|
I’d like to avoid the generalization where possible. Optimizing the root cause leads to better code and better outcomes. |
staabm
left a comment
There was a problem hiding this comment.
lets revert the generalization. look for more root problems which can be optimized.
This reverts commit a742c74.
unionTypes() maps every member through a callback (toArrayKey(), toString(), toCoercedArgumentType(), …) and then always rebuilt the result with TypeCombinator::union(). For a large union whose members map to themselves — e.g. toArrayKey() on a union of hundreds of non-numeric constant strings, where every ConstantStringType::toArrayKey() returns $this — that rebuild is a pure waste: it re-normalizes an already-normalized union and computes a describe(VerbosityLevel::cache()) per member. Short-circuit to $this when no member's mapped type differs by identity, exactly as traverse() and filterTypes() already do. This removes the dominant TypeCombinator::doUnion() cost when large constant-value unions are used as array keys (phpstan/phpstan#14978). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
UnionType::equals() matched members with an O(n*m) multiset scan (an inner equals() call per pair). Comparing two large constant-value unions — as happens when a hundreds-of-members enum-value union is carried as an array-key type and scopes/array types are compared — was quadratic. Reuse the finite-member lookup already built for isSuperTypeOf()/accepts(): when every member of both unions is a constant scalar or enum case (the lookup covers all members), a member's key uniquely identifies it, so the two equal-sized unions are equal exactly when one key set contains the other. Also short-circuit the trivial $this === $type case. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
|
I've addressed the review. Here's a summary of what I did. Review: "lets revert the generalization. look for more root problems which can be optimized."1. Reverted the generalization (commit
|
| operation | before | after |
|---|---|---|
toArrayKey |
166.5 ms | 24.0 ms (~7×) |
equals |
291.1 ms | 6.2 ms (~47×) |
These are the operations that multiply across CatalogInitializer's nested loops and per-iteration scope merges. No caps, no generalization — the operations are now genuinely O(n)/O(n+m) instead of O(n²).
Verification
make phpstan— No errors.make tests— 17606 tests, 89989 assertions, OK (97 version-gated skips), including thebug-14978.phpcorrectness lock.make cs— clean.UnionTypeis not#[ShadowedByTurboExtension], so no C++ mirror to port.
One note: the wall-clock on the single-file --no-progress run moves only ~5% because a single small file is dominated by fixed DI/parse/hash startup; the algorithmic win shows up per-operation (above) and compounds on the real, loop-heavy Tempest file.
|
What I don’t like about this PR is, that it only marginally improves performance but need lots of code |
Summary
Analyzing Tempest's
CatalogInitializerwas very slow (~5s for a single small file, over a minute under a profiler). The method loops over$config->translationMessagePathsand writes into$catalog[$locale]where$locale = Locale::from($locale)->value.Localeis a backed enum with 782 cases, so$localeis a union of 782 constant strings, and$catalogbecomes an array whose key type is that same 782-member union.Every array-offset operation on
$catalog(??=,$catalog[$locale] = …,isset, offset read) callsArrayType::hasOffsetValueType()/getOffsetValueType(), which do$keyType->isSuperTypeOf($offsetType). With both sides being 782-member constant-string unions, that comparison was O(n·m) —UnionType::isSubTypeOf()maps every member of one union throughUnionType::isSuperTypeOf(), which linearly scanned all members of the other (computing adescribe()per member). Profiling showed ~9.8MConstantStringType::isSuperTypeOfcalls dominating the run.The fix makes "is this literal value one of my members?" an O(1) set lookup, turning those union-against-union comparisons into O(n+m).
Changes
src/Type/UnionType.php:finiteMemberLookupset, keyed by class +describe(VerbosityLevel::cache())of every constant-scalar and enum-case member.isSuperTypeOf()that returnsYeswhen the other type is a constant scalar / enum case literally present in that set, before the linear member scan.accepts()method (which previously never short-circuited inside its member loop).isSubTypeOf()benefits transitively since it delegates to the other type'sisSuperTypeOf().Root cause
UnionType::isSuperTypeOf()/accepts()answered membership by scanning all members. For a constant scalar or enum case, "is it a subtype of / accepted by this union?" isYesexactly when the value is literally one of the members — a set-membership question. Doing it with a linear scan made comparisons between two large constant-value unions quadratic. Keying members by theirVerbosityLevel::cache()description (the same identityTypeCombinator::doUnion()already relies on for deduplication) gives an O(1) lookup. Cache descriptions of constant scalars and enum cases fold in the value (and subtracted type), so equal keys imply equal types; the fast path therefore only short-circuits a guaranteedYesand never changes any result.Test
tests/PHPStan/Analyser/nsrt/bug-14978.phpreproduces the Tempest pattern with a 4-case backed enum used as an array key inside nestedforeachwrites, plus a direct union-vs-union===comparison, asserting the inferred types are unchanged. This is a correctness lock for the optimization (the change is purely a performance optimization and does not alter output).CatalogInitializer: analysis time dropped from ~2.76s to ~1.75s (the type-inference portion roughly halved). The full test suite, PHPStan self-analysis, and coding-standard checks all pass.Fixes phpstan/phpstan#14978