Skip to content

Make the mixed types of a shared type alias strict once - #6645

Open
Kocal wants to merge 1 commit into
phpstan:2.3.xfrom
Kocal:perf/transform-common-type-shared-alias
Open

Kocal wants to merge 1 commit into
phpstan:2.3.xfrom
Kocal:perf/transform-common-type-shared-alias

Conversation

@Kocal

@Kocal Kocal commented Oct 1, 2026

Copy link
Copy Markdown

At levels 9 and 10, RuleLevelHelper::transformCommonType() makes the mixed types of a parameter type strict before each accepts() check, for both the accepting type and the accepted type. A type alias that many keys of an array shape refer to is one shared Type object, but the walk transformed it again from every key that referenced it and built a separate copy for each one, so the result ended up as large as the fully expanded shape, rebuilt for every argument checked.

The callback only depends on the type it receives, so each object is now transformed once per walk, and later visits reuse that same result object, which keeps the transformed type's sharing intact. Objects are keyed with spl_object_id() and kept in a cache for the duration of the walk, so an id can't be reused while the walk is in progress. This is independent from #6643, which does the same thing for MissingTypehintCheck.

Wall time below, 3 runs after a discarded warm-up. The first two rows use sf-ux-css-phpstan-reproducer, a small Symfony app whose phpstan-nested.neon analyses one method taking the symfony/ux-css CssStyles shape (847 keys, 144 of them referring to the nested copy) and passes it to one css() call. That config defaults to level 6, where this change does nothing, so the numbers come from vendor/bin/phpstan analyse -c phpstan-nested.neon -l 9, with the same errors reported before and after (none).

Case Before After
Reproducer, level 9, no turbo 29.84-29.96 s, 429-445 MB 23.12-23.31 s, 95 MB
Reproducer, level 9, turbo extension loaded 8.82-8.85 s 8.05-8.07 s
Script below (300 keys, 120 nested), 10 calls, level 9, no turbo 1.83-1.88 s 1.70-1.72 s

As a regression check, analysing PHPStan's own src/Rules at level 9 in a single process (--debug) over 3 alternating rounds stayed at 26.19-26.71 s CPU before and 26.15-26.24 s after, with the same errors reported, and the tests/PHPStan/Rules suite passes.

There's no phpbench scenario for this because the regression benchmark runs with the default configuration, where checkExplicitMixed is off and this walk never runs.

Reproduction script

Run with php generate.php > args.php && bin/phpstan analyse -l 9 args.php.

<?php

$keys = static fn (int $count, string $prefix, string $type): string => implode(', ', array_map(
	static fn (int $i): string => "{$prefix}{$i}?: {$type}",
	range(0, $count - 1),
));
$calls = '';
for ($i = 0; $i < 10; $i++) {
	$calls .= sprintf("\techo \$styles->css(['p%d' => 'a', 'c%d' => ['p%d' => 'b']]);\n", $i, $i, $i + 1);
}

printf(<<<'PHP'
<?php

/**
 * @phpstan-type Value = 'a'|'b'|'c'|'d'|string
 * @phpstan-type Inner = array{%s, extra?: array<string, mixed>}
 * @phpstan-type Outer = array{%s, %s}
 */
final class Styles
{
	/** @param Outer|null $styles */
	public function css(?array $styles): string
	{
		return '';
	}
}

function render(Styles $styles): void
{
%s}

PHP, $keys(300, 'p', 'Value'), $keys(300, 'p', 'Value'), $keys(120, 'c', 'Inner'), $calls);

At levels 9 and 10, `RuleLevelHelper::transformCommonType()` makes the mixed types of a parameter type strict before each `accepts()` check, for both the accepting type and the accepted type. A type alias that many keys of an array shape refer to is one shared `Type` object, but the walk transformed it again from every key that referenced it and built a separate copy for each one, so the result ended up as large as the fully expanded shape, rebuilt for every argument checked.

The callback only depends on the type it receives, so each object is now transformed once per walk, and later visits reuse that same result object, which keeps the transformed type's sharing intact. Objects are keyed with `spl_object_id()` and kept in a cache for the duration of the walk, so an id can't be reused while the walk is in progress. This is independent from phpstan#6643, which does the same thing for `MissingTypehintCheck`.

Wall time below, 3 runs after a discarded warm-up. The first two rows use [sf-ux-css-phpstan-reproducer](https://github.com/Kocal/sf-ux-css-phpstan-reproducer), a small Symfony app whose `phpstan-nested.neon` analyses one method taking the symfony/ux-css `CssStyles` shape (847 keys, 144 of them referring to the nested copy) and passes it to one `css()` call. That config defaults to level 6, where this change does nothing, so the numbers come from `vendor/bin/phpstan analyse -c phpstan-nested.neon -l 9`, with the same errors reported before and after (none).

| Case | Before | After |
| --- | --- | --- |
| Reproducer, level 9, no turbo | 29.84-29.96 s, 429-445 MB | 23.12-23.31 s, 95 MB |
| Reproducer, level 9, turbo extension loaded | 8.82-8.85 s | 8.05-8.07 s |
| Script below (300 keys, 120 nested), 10 calls, level 9, no turbo | 1.83-1.88 s | 1.70-1.72 s |

As a regression check, analysing PHPStan's own `src/Rules` at level 9 in a single process (`--debug`) over 3 alternating rounds stayed at 26.19-26.71 s CPU before and 26.15-26.24 s after, with the same errors reported, and the `tests/PHPStan/Rules` suite passes.

There's no phpbench scenario for this because the regression benchmark runs with the default configuration, where `checkExplicitMixed` is off and this walk never runs.

<details>
<summary>Reproduction script</summary>

Run with `php generate.php > args.php && bin/phpstan analyse -l 9 args.php`.

```php
<?php

$keys = static fn (int $count, string $prefix, string $type): string => implode(', ', array_map(
	static fn (int $i): string => "{$prefix}{$i}?: {$type}",
	range(0, $count - 1),
));
$calls = '';
for ($i = 0; $i < 10; $i++) {
	$calls .= sprintf("\techo \$styles->css(['p%d' => 'a', 'c%d' => ['p%d' => 'b']]);\n", $i, $i, $i + 1);
}

printf(<<<'PHP'
<?php

/**
 * @phpstan-type Value = 'a'|'b'|'c'|'d'|string
 * @phpstan-type Inner = array{%s, extra?: array<string, mixed>}
 * @phpstan-type Outer = array{%s, %s}
 */
final class Styles
{
	/** @PARAM Outer|null $styles */
	public function css(?array $styles): string
	{
		return '';
	}
}

function render(Styles $styles): void
{
%s}

PHP, $keys(300, 'p', 'Value'), $keys(300, 'p', 'Value'), $keys(120, 'c', 'Inner'), $calls);
```

</details>
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