Skip to content

Look up constant-scalar and enum-case members of a UnionType via an O(1) set in isSuperTypeOf/accepts#6072

Open
phpstan-bot wants to merge 7 commits into
phpstan:2.2.xfrom
phpstan-bot:create-pull-request/patch-j95pwrd
Open

Look up constant-scalar and enum-case members of a UnionType via an O(1) set in isSuperTypeOf/accepts#6072
phpstan-bot wants to merge 7 commits into
phpstan:2.2.xfrom
phpstan-bot:create-pull-request/patch-j95pwrd

Conversation

@phpstan-bot

Copy link
Copy Markdown
Collaborator

Summary

Analyzing Tempest's CatalogInitializer was very slow (~5s for a single small file, over a minute under a profiler). The method loops over $config->translationMessagePaths and writes into $catalog[$locale] where $locale = Locale::from($locale)->value. Locale is a backed enum with 782 cases, so $locale is a union of 782 constant strings, and $catalog becomes an array whose key type is that same 782-member union.

Every array-offset operation on $catalog (??=, $catalog[$locale] = …, isset, offset read) calls ArrayType::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 through UnionType::isSuperTypeOf(), which linearly scanned all members of the other (computing a describe() per member). Profiling showed ~9.8M ConstantStringType::isSuperTypeOf calls 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:
    • Added a lazily built finiteMemberLookup set, keyed by class + describe(VerbosityLevel::cache()) of every constant-scalar and enum-case member.
    • Added a fast path in isSuperTypeOf() that returns Yes when the other type is a constant scalar / enum case literally present in that set, before the linear member scan.
    • Added the same fast path in the analogous accepts() method (which previously never short-circuited inside its member loop).
    • isSubTypeOf() benefits transitively since it delegates to the other type's isSuperTypeOf().

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?" is Yes exactly 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 their VerbosityLevel::cache() description (the same identity TypeCombinator::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 guaranteed Yes and never changes any result.

Test

  • tests/PHPStan/Analyser/nsrt/bug-14978.php reproduces the Tempest pattern with a 4-case backed enum used as an array key inside nested foreach writes, 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).
  • Measured on a generated 782-case-enum reproduction of 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

… 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.
Comment thread src/Type/UnionType.php Outdated
private static function getFiniteMemberKey(Type $type): ?string
{
if ($type->isConstantScalarValue()->yes() || $type->getEnumCaseObject() !== null) {
return $type::class . "\0" . $type->describe(VerbosityLevel::cache());

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

should use get_class for PHP 7.4 compat

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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::class with get_class($type) in getFiniteMemberKey().
  • 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's CatalogInitializer pattern using a 782-case backed enum (the real-world size from the issue) with the nested foreach array-key writes. This is auto-discovered by RegressionBench::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.php as the correctness lock (its assertType() 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).
  • phpcs on src/Type/UnionType.php — clean.
  • NodeScopeResolverTest (globs nsrt/*.php) + UnionTypeTest — 1864 tests, OK.
  • The new bench file analyses cleanly with no errors (verified via a copy outside the excluded tests/bench/data path; 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.

Comment thread tests/PHPStan/Analyser/nsrt/bug-14978.php
phpstan-bot and others added 2 commits July 21, 2026 07:51
…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>

@staabm staabm left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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?

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>
@phpstan-bot

Copy link
Copy Markdown
Collaborator Author

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 (Locale::from($x)->value → 782 constant strings) still gets constructed and then propagated through every array-key operation on $catalog. The real fix is to never form that union for very large enums.

Change (commit a742c74b2)

Generalize name/value for enums above ObjectType::ENUM_CASES_LIMIT (512 cases):

  • src/Type/ObjectType.php — added the named ENUM_CASES_LIMIT = 512 constant. In both getUnresolvedPropertyPrototype() and getUnresolvedInstancePropertyPrototype(), the per-case expansion of name/value is now skipped when the enum has more than 512 cases, falling through to the (cached) naked property.
  • src/Reflection/Php/PhpClassReflectionExtension.php — that naked name/value property is generalized via generalize(GeneralizePrecision::lessSpecific()) when the case count exceeds the limit, so ->value/->name resolve to string (or the backing type) instead of a 782-member constant union.

Small enums are completely unaffected — ->value stays precise ('a'|'b'|'c'|'d').

Test

  • tests/PHPStan/Analyser/nsrt/bug-14978-large-enum.php — a 513-case backed enum and a 513-case pure enum, asserting ->value/->name generalize to string. Verified it fails without the fix (dumps the full ~513-member union) and passes with it.

Results

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).

@ondrejmirtes

Copy link
Copy Markdown
Member

I’d like to avoid the generalization where possible. Optimizing the root cause leads to better code and better outcomes.

@staabm staabm left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

lets revert the generalization. look for more root problems which can be optimized.

phpstan-bot and others added 3 commits July 21, 2026 09:48
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>
@phpstan-bot

Copy link
Copy Markdown
Collaborator Author

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 f2cb782f0)

git revert of the "Generalize name/value of enums with more than 512 cases" commit — this removes the ObjectType::ENUM_CASES_LIMIT cap, the PhpClassReflectionExtension generalization, and the 513-case test file. ->value/->name on large enums are once again the precise per-case constant union. The complementary O(1) finiteMemberLookup fast path from the earlier commits is untouched.

2. Found and fixed two more root-cause quadratic operations

Profiling the analyser worker on the 782-case reproducer (with excimer) showed the remaining hot type-ops were TypeCombinator::doUnion (driven by UnionType::toArrayKey) and UnionType::equals — both quadratic on the giant enum-value union that $catalog[$locale] carries as its key type.

Return $this from UnionType::unionTypes() when no member changed (commit 4c590e48c)
unionTypes() (used by toArrayKey(), toString(), toCoercedArgumentType(), …) always rebuilt its result with TypeCombinator::union(), even when every member mapped to itself — e.g. toArrayKey() on hundreds of non-numeric constant strings, where each ConstantStringType::toArrayKey() returns $this. Added the same $changed short-circuit traverse() and filterTypes() already use.

Compare finite UnionTypes for equality via their O(1) member-key sets (commit 349680783)
equals() did an O(n·m) multiset scan. Reused the existing finite-member lookup: when both unions consist entirely of constant-scalar/enum-case members (keys uniquely identify each member), equality becomes an O(n+m) set comparison. Also added a $this === $type short-circuit.

Results

Direct microbenchmark on a 782-member constant-string union (200 iterations each):

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 the bug-14978.php correctness lock.
  • make cs — clean.
  • UnionType is 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.

@staabm

staabm commented Jul 22, 2026

Copy link
Copy Markdown
Contributor

What I don’t like about this PR is, that it only marginally improves performance but need lots of code

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.

Analyzing Tempest CatalogInitializer is slow

3 participants