From 641422c0f7b0502aaed7fdaf08ab9dd48f39dbff Mon Sep 17 00:00:00 2001 From: "ondrej@mirtes.cz" Date: Fri, 13 Nov 2026 23:28:33 +0000 Subject: [PATCH 01/18] Add microbenchmarks that check how internals scale 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 --- CLAUDE.md | 2 +- build/phpstan.neon | 2 +- build/tally-pre-8.1.neon | 4 +- tally.php | 2 +- tests/bench/BenchContainer.php | 27 ++++ tests/bench/CombinationsHelperBench.php | 84 +++++++++++++ tests/bench/ConstantArrayTypeBench.php | 109 ++++++++++++++++ tests/bench/ConstantArrayTypeBuilderBench.php | 73 +++++++++++ .../ConstantArrayTypeGetAllArraysBench.php | 54 ++++++++ tests/bench/PatcherBench.php | 77 ++++++++++++ tests/bench/TypeCombinatorBench.php | 117 ++++++++++++++++++ tests/bench/UnionTypeBench.php | 93 ++++++++++++++ 12 files changed, 638 insertions(+), 6 deletions(-) create mode 100644 tests/bench/BenchContainer.php create mode 100644 tests/bench/CombinationsHelperBench.php create mode 100644 tests/bench/ConstantArrayTypeBench.php create mode 100644 tests/bench/ConstantArrayTypeBuilderBench.php create mode 100644 tests/bench/ConstantArrayTypeGetAllArraysBench.php create mode 100644 tests/bench/PatcherBench.php create mode 100644 tests/bench/TypeCombinatorBench.php create mode 100644 tests/bench/UnionTypeBench.php diff --git a/CLAUDE.md b/CLAUDE.md index 36263a6c57a..bcf4a40b887 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -294,7 +294,7 @@ New introduced classes in these categories should be shadowed with a native C++ - **Always verify the test fails before the fix.** Stating "tests pass" is not enough — that only proves the fix doesn't break things. `git stash` the source change, run the test, see it fail for the right reason; `git stash pop`, run again, see it pass. Only then is the fix confirmed. The `regression-test` skill bakes this in for bug-issue workflows. - **Reproducers come from the OP's playground link** in the issue body, not from later comments. Copy the snippet as close to verbatim as practical. - **Integration tests**: `AnalyserIntegrationTest` runs full analysis on test files and checks error output. -- **Benchmarks**: every file in `tests/bench/data` is analysed by [Tally](https://github.com/phpstan/tally) in the Benchmarks workflow, under CodSpeed's CPU simulation, which counts instructions instead of measuring time, so the numbers are the same on every runner. `tests/bench/AnalysisBench.php` boots PHPStan and analyses `tests/bench/Warmup.php` uncounted in a `#[Before]` method, so only the analysis of the benchmark file itself is counted. The expected instructions, cycles and memory are in the comment at the top of each data file (`tests/bench/AnalysedFileHeader.php`); a new file without the comment passes and gets its numbers from CI. When a change moves them on purpose or adds a benchmark, download the workflow's `tally-results` artifact and run `vendor/bin/tally accept `. The configuration is `tally.php`. +- **Benchmarks**: every file in `tests/bench/data` is analysed by [Tally](https://github.com/phpstan/tally) in the Benchmarks workflow, under CodSpeed's CPU simulation, which counts instructions instead of measuring time, so the numbers are the same on every runner. `tests/bench/AnalysisBench.php` boots PHPStan and analyses `tests/bench/Warmup.php` uncounted in a `#[Before]` method, so only the analysis of the benchmark file itself is counted. The expected instructions, cycles and memory are in the comment at the top of each data file (`tests/bench/AnalysedFileHeader.php`); a new file without the comment passes and gets its numbers from CI. When a change moves them on purpose or adds a benchmark, download the workflow's `tally-results` artifact and run `vendor/bin/tally accept `. The configuration is `tally.php`. The other `tests/bench/*Bench.php` classes are microbenchmarks of internals such as `TypeCombinator` and `ConstantArrayType`: they boot the container with `BenchContainer` in a `#[Before]` method, build their inputs there too, and declare how the instructions grow with the input (`#[Linear]`, `#[Quadratic]`, `#[Complexity]`, …) instead of expected numbers. Warm up on different inputs than the counted call, because the type system memoizes results. ### Adding support for new PHP versions diff --git a/build/phpstan.neon b/build/phpstan.neon index 3347041d2d8..2cc53976b0e 100644 --- a/build/phpstan.neon +++ b/build/phpstan.neon @@ -112,7 +112,7 @@ parameters: - # Tally calls the benchmark methods, their data providers and #[Before] methods identifier: shipmonk.deadMethod - path: ../tests/bench/AnalysisBench.php + path: ../tests/bench/*Bench.php reportUnmatched: false - identifier: shipmonk.deadConstant diff --git a/build/tally-pre-8.1.neon b/build/tally-pre-8.1.neon index e1e01e4f694..1cc733fb084 100644 --- a/build/tally-pre-8.1.neon +++ b/build/tally-pre-8.1.neon @@ -2,6 +2,4 @@ parameters: excludePaths: # Tally's sources use enums and readonly properties, which PHPStan does not parse for PHP < 8.1 - ../tally.php - - ../tests/bench/AnalysedFileExpectations.php - - ../tests/bench/AnalysedFileHeader.php - - ../tests/bench/AnalysisBench.php + - ../tests/bench/* diff --git a/tally.php b/tally.php index 586e8c54b82..c739f54e7d1 100644 --- a/tally.php +++ b/tally.php @@ -6,7 +6,7 @@ use Tally\Configuration; return Configuration::create() - ->withPaths([__DIR__ . '/tests/bench/AnalysisBench.php']) + ->withPaths([__DIR__ . '/tests/bench']) // bin/phpstan restarts PHP with these OPcache settings, the benchmarks run without the restart ->withIniEntries([...OpcacheSettings::getIniEntries(), 'memory_limit=-1']) // The slowest benchmark takes a few seconds natively and about 13 minutes under the simulation. diff --git a/tests/bench/BenchContainer.php b/tests/bench/BenchContainer.php new file mode 100644 index 00000000000..7f839913e5a --- /dev/null +++ b/tests/bench/BenchContainer.php @@ -0,0 +1,27 @@ +getFileName(), 3)); + + return $containerFactory->create($scratch->path(), [$containerFactory->getConfigDirectory() . '/config.level8.neon'], []); + } + +} diff --git a/tests/bench/CombinationsHelperBench.php b/tests/bench/CombinationsHelperBench.php new file mode 100644 index 00000000000..3cca7526d12 --- /dev/null +++ b/tests/bench/CombinationsHelperBench.php @@ -0,0 +1,84 @@ +>}> + */ + public static function pairs(): iterable + { + foreach ([4, 6, 8, 10] as $lists) { + yield 'lists-' . $lists => [array_fill(0, $lists, [1, 2])]; + } + } + + /** + * @return iterable>}> + */ + public static function triples(): iterable + { + foreach ([3, 4, 5, 6] as $lists) { + yield 'lists-' . $lists => [array_fill(0, $lists, [1, 2, 3])]; + } + } + + /** + * @param list> $lists + */ + #[Before] + public function boot(array $lists): void + { + BenchContainer::create($this->scratch); + self::count([[1, 2], [3, 4]]); + } + + /** + * @param list> $lists + */ + #[DataProvider(method: 'pairs')] + #[Exponential(size: 'lists')] + public function benchPairs(array $lists): int + { + return self::count($lists); + } + + /** + * @param list> $lists + */ + #[DataProvider(method: 'triples')] + #[Complexity(size: 'lists', formula: '3^n')] + public function benchTriples(array $lists): int + { + return self::count($lists); + } + + /** + * @param list> $lists + */ + private static function count(array $lists): int + { + $combinations = 0; + foreach (CombinationsHelper::combinations($lists) as $combination) { + $combinations += count($combination); + } + + return $combinations; + } + +} diff --git a/tests/bench/ConstantArrayTypeBench.php b/tests/bench/ConstantArrayTypeBench.php new file mode 100644 index 00000000000..e4c19890b6d --- /dev/null +++ b/tests/bench/ConstantArrayTypeBench.php @@ -0,0 +1,109 @@ + + */ + public static function keys(): iterable + { + foreach ([16, 64, 256] as $keys) { + yield 'keys-' . $keys => [$keys]; + } + } + + #[Before] + public function boot(int $keys): void + { + BenchContainer::create($this->scratch); + + // the counted calls do not autoload, and do not hit what the warm-up memoized + $warmup = self::shape('warmup', 4, true); + $warmup->getOffsetValueType(new ConstantStringType('warmup1')); + $warmup->isSuperTypeOf(self::shape('warmup', 4, true)); + + $this->shape = self::shape('key', $keys, false); + // builds the key index + $this->shape->getOffsetValueType(new ConstantStringType('key0')); + $this->sameShape = self::shape('key', $keys, false); + $this->key = new ConstantStringType('key' . intdiv($keys, 2)); + } + + /** + * A constant key is looked up in the key index, so the size of the array does not matter. + * The index is built on the first lookup, which boot() makes with another key. + */ + #[DataProvider(method: 'keys')] + #[Constant(size: 'keys')] + public function benchGetOffsetValueType(int $keys): Type + { + if ($this->key === null) { + throw new LogicException('boot() did not run.'); + } + + return $this->subject()->getOffsetValueType($this->key); + } + + #[DataProvider(method: 'keys')] + #[Linear(size: 'keys')] + public function benchIsSuperTypeOf(int $keys): bool + { + if ($this->sameShape === null) { + throw new LogicException('boot() did not run.'); + } + + return $this->subject()->isSuperTypeOf($this->sameShape)->yes(); + } + + private function subject(): ConstantArrayType + { + if ($this->shape === null) { + throw new LogicException('boot() did not run.'); + } + + return $this->shape; + } + + public static function shape(string $prefix, int $keys, bool $optional): ConstantArrayType + { + $builder = ConstantArrayTypeBuilder::createEmpty(); + for ($i = 0; $i < $keys; $i++) { + $builder->setOffsetValueType(new ConstantStringType($prefix . $i), new IntegerType(), $optional); + } + + $arrays = $builder->getArray()->getConstantArrays(); + if (count($arrays) !== 1) { + throw new LogicException('The builder did not build one constant array.'); + } + + return $arrays[0]; + } + +} diff --git a/tests/bench/ConstantArrayTypeBuilderBench.php b/tests/bench/ConstantArrayTypeBuilderBench.php new file mode 100644 index 00000000000..f8be3819bd0 --- /dev/null +++ b/tests/bench/ConstantArrayTypeBuilderBench.php @@ -0,0 +1,73 @@ + */ + private array $keys = []; + + public function __construct(private ScratchDirectory $scratch) + { + } + + /** + * @return iterable + */ + public static function keys(): iterable + { + foreach ([16, 64, 256] as $keys) { + yield 'keys-' . $keys => [$keys]; + } + } + + #[Before] + public function boot(int $keys): void + { + BenchContainer::create($this->scratch); + self::build([new ConstantStringType('warmup0'), new ConstantStringType('warmup1')]); + for ($i = 0; $i < $keys; $i++) { + $this->keys[] = new ConstantStringType('key' . $i); + } + } + + /** + * Setting a new key compares it with every key set before, so building an array key by key + * is quadratic in its size. + */ + #[DataProvider(method: 'keys')] + #[Quadratic(size: 'keys')] + public function benchSetOffsetValueType(int $keys): Type + { + if ($this->keys === []) { + throw new LogicException('boot() did not run.'); + } + + return self::build($this->keys); + } + + /** + * @param list $keys + */ + private static function build(array $keys): Type + { + $builder = ConstantArrayTypeBuilder::createEmpty(); + foreach ($keys as $key) { + $builder->setOffsetValueType($key, new IntegerType()); + } + + return $builder->getArray(); + } + +} diff --git a/tests/bench/ConstantArrayTypeGetAllArraysBench.php b/tests/bench/ConstantArrayTypeGetAllArraysBench.php new file mode 100644 index 00000000000..b304e1e2b0b --- /dev/null +++ b/tests/bench/ConstantArrayTypeGetAllArraysBench.php @@ -0,0 +1,54 @@ + + */ + public static function optionalKeys(): iterable + { + foreach ([4, 6, 8, 10] as $keys) { + yield 'optional-keys-' . $keys => [$keys]; + } + } + + #[Before] + public function boot(int $keys): void + { + BenchContainer::create($this->scratch); + ConstantArrayTypeBench::shape('warmup', 4, true)->getAllArrays(); + $this->shape = ConstantArrayTypeBench::shape('optional', $keys, true); + } + + /** + * Each optional key doubles the arrays, and each array has up to all the keys. + */ + #[DataProvider(method: 'optionalKeys')] + #[Complexity(size: 'keys', formula: 'n * 2^n')] + public function benchGetAllArrays(int $keys): int + { + if ($this->shape === null) { + throw new LogicException('boot() did not run.'); + } + + return count($this->shape->getAllArrays()); + } + +} diff --git a/tests/bench/PatcherBench.php b/tests/bench/PatcherBench.php new file mode 100644 index 00000000000..6141d99a30d --- /dev/null +++ b/tests/bench/PatcherBench.php @@ -0,0 +1,77 @@ +}>, array{int, int, list}): ?int)|null */ + private ?Closure $findInsertionIndex = null; + + public function __construct(private ScratchDirectory $scratch) + { + } + + /** + * @return iterable}>}> + */ + public static function acceptedRuns(): iterable + { + foreach ([16, 64, 256, 1024] as $runs) { + yield 'runs-' . $runs => [self::runs($runs)]; + } + } + + /** + * @param list}> $runs + */ + #[Before] + public function boot(array $runs): void + { + BenchContainer::create($this->scratch); + // private, Patcher::apply() calls it for each change + $this->findInsertionIndex = (new ReflectionMethod(Patcher::class, 'findInsertionIndex'))->getClosure(); + ($this->findInsertionIndex)(self::runs(4), [15, 1, ['warmup']]); + } + + /** + * Applying a fix finds where its lines go among the changes accepted so far by binary search. + * + * @param list}> $runs + */ + #[DataProvider(method: 'acceptedRuns')] + #[Logarithmic(size: 'runs')] + public function benchFindInsertionIndex(array $runs): ?int + { + if ($this->findInsertionIndex === null) { + throw new LogicException('boot() did not run.'); + } + + // between two runs in the middle + return ($this->findInsertionIndex)($runs, [count($runs) * 5 + 3, 1, ['new']]); + } + + /** + * @return list}> one changed line every 10 lines + */ + private static function runs(int $runs): array + { + $accepted = []; + for ($i = 0; $i < $runs; $i++) { + $accepted[] = [$i * 10, 1, ['changed']]; + } + + return $accepted; + } + +} diff --git a/tests/bench/TypeCombinatorBench.php b/tests/bench/TypeCombinatorBench.php new file mode 100644 index 00000000000..31183779797 --- /dev/null +++ b/tests/bench/TypeCombinatorBench.php @@ -0,0 +1,117 @@ + */ + private array $strings = []; + + /** @var list */ + private array $integers = []; + + /** @var list */ + private array $arrays = []; + + /** @var list */ + private array $unions = []; + + public function __construct(private ScratchDirectory $scratch) + { + } + + /** + * @return iterable + */ + public static function types(): iterable + { + foreach ([16, 64, 256] as $types) { + yield 'types-' . $types => [$types]; + } + } + + #[Before] + public function boot(int $types): void + { + BenchContainer::create($this->scratch); + + // TypeCombinator memoizes its results, so the warm-up uses other types than the benchmarks + $this->createTypes('warmup', 4); + TypeCombinator::union(...$this->strings); + TypeCombinator::union(...$this->integers); + TypeCombinator::union(...$this->arrays); + TypeCombinator::intersect(...$this->unions); + TypeCombinator::remove(...$this->unions); + + $this->createTypes('type', $types); + } + + #[DataProvider(method: 'types')] + #[Linear(size: 'types')] + public function benchUnionConstantStrings(int $types): Type + { + return TypeCombinator::union(...$this->strings); + } + + #[DataProvider(method: 'types')] + #[Linear(size: 'types')] + public function benchUnionConstantIntegers(int $types): Type + { + return TypeCombinator::union(...$this->integers); + } + + /** + * Each array is compared with the arrays already in the union, to merge the ones that differ + * only in their values. + */ + #[DataProvider(method: 'types')] + #[Polynomial(size: 'types', degree: 2)] + public function benchUnionConstantArrays(int $types): Type + { + return TypeCombinator::union(...$this->arrays); + } + + #[DataProvider(method: 'types')] + #[Linear(size: 'types')] + public function benchIntersectUnions(int $types): Type + { + return TypeCombinator::intersect(...$this->unions); + } + + #[DataProvider(method: 'types')] + #[Linear(size: 'types')] + public function benchRemoveFromUnion(int $types): Type + { + return TypeCombinator::remove(...$this->unions); + } + + private function createTypes(string $prefix, int $types): void + { + $this->strings = []; + $this->integers = []; + $this->arrays = []; + $others = []; + for ($i = 0; $i < $types; $i++) { + $this->strings[] = new ConstantStringType($prefix . $i); + $this->integers[] = new ConstantIntegerType($i * 3); + $this->arrays[] = new ConstantArrayType([new ConstantStringType($prefix . $i)], [new ConstantIntegerType($i)]); + // half of them in both unions + $others[] = new ConstantStringType($prefix . ($i * 2)); + } + $this->unions = [new UnionType($this->strings), new UnionType($others)]; + } + +} diff --git a/tests/bench/UnionTypeBench.php b/tests/bench/UnionTypeBench.php new file mode 100644 index 00000000000..24f1411cd42 --- /dev/null +++ b/tests/bench/UnionTypeBench.php @@ -0,0 +1,93 @@ + + */ + public static function members(): iterable + { + foreach ([16, 64, 256] as $members) { + yield 'members-' . $members => [$members]; + } + } + + #[Before] + public function boot(int $members): void + { + BenchContainer::create($this->scratch); + + [$union, $other] = self::unions('warmup', 4); + $union->describe(VerbosityLevel::precise()); + $union->isSuperTypeOf($other); + + [$this->union, $this->other] = self::unions('member', $members); + } + + /** + * The members are described in a sorted order. + */ + #[DataProvider(method: 'members')] + #[Linearithmic(size: 'members')] + public function benchDescribe(int $members): string + { + return $this->union()->describe(VerbosityLevel::precise()); + } + + #[DataProvider(method: 'members')] + #[Linear(size: 'members')] + public function benchIsSuperTypeOf(int $members): bool + { + if ($this->other === null) { + throw new LogicException('boot() did not run.'); + } + + return $this->union()->isSuperTypeOf($this->other)->yes(); + } + + private function union(): UnionType + { + if ($this->union === null) { + throw new LogicException('boot() did not run.'); + } + + return $this->union; + } + + /** + * @return array{UnionType, UnionType} the other has half of the members of the first + */ + private static function unions(string $prefix, int $members): array + { + $types = []; + $others = []; + for ($i = 0; $i < $members; $i++) { + $types[] = new ConstantStringType($prefix . $i); + $others[] = new ConstantStringType($prefix . ($i * 2)); + } + + return [new UnionType($types), new UnionType($others)]; + } + +} From 21a88e93504b731b293ae4c95a2ef7f34b8ff118 Mon Sep 17 00:00:00 2001 From: "ondrej@mirtes.cz" Date: Sat, 14 Nov 2026 00:29:27 +0000 Subject: [PATCH 02/18] Skip work in MutatingScope's hottest methods when the answer is known 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. --- src/Analyser/MutatingScope.php | 17 +- tests/bench/BenchScope.php | 129 +++++++++++ tests/bench/ExpressionTypeHolderBench.php | 87 ++++++++ tests/bench/MutatingScopeBench.php | 260 ++++++++++++++++++++++ tests/bench/MutatingScopeMergeBench.php | 108 +++++++++ tests/bench/NodeScopeResolverBench.php | 145 ++++++++++++ 6 files changed, 742 insertions(+), 4 deletions(-) create mode 100644 tests/bench/BenchScope.php create mode 100644 tests/bench/ExpressionTypeHolderBench.php create mode 100644 tests/bench/MutatingScopeBench.php create mode 100644 tests/bench/MutatingScopeMergeBench.php create mode 100644 tests/bench/NodeScopeResolverBench.php diff --git a/src/Analyser/MutatingScope.php b/src/Analyser/MutatingScope.php index fddd7bae801..97092f2d501 100644 --- a/src/Analyser/MutatingScope.php +++ b/src/Analyser/MutatingScope.php @@ -778,6 +778,12 @@ public function hasVariableType(string $variableName): TrinaryLogic /** @api */ public function getVariableType(string $variableName): Type { + // a certainly defined variable, the common case, does not need the rest + $holder = $this->expressionTypes['$' . $variableName] ?? null; + if ($holder !== null && $holder->getCertainty()->yes()) { + return $holder->getType(); + } + $hasVariableType = $this->hasVariableType($variableName); if ($hasVariableType->maybe()) { @@ -3562,7 +3568,7 @@ private function resolveScopeStateType(Expr $expr, bool $native): Type public function specifyExpressionType(Expr $expr, Type $type, Type $nativeType, TrinaryLogic $certainty): self { - if ($this->isSpecifyExpressionTypeNoop($expr, $type)) { + if ($expr instanceof Scalar || $this->isSpecifyExpressionTypeNoop($expr, $type)) { return $this; } @@ -4335,10 +4341,13 @@ public function isInFirstLevelStatement(): bool public function mergeWith(?self $otherScope, bool $preserveVacuousConditionals = false): self { - $merged = $this->mergeWithVariableState($otherScope, $preserveVacuousConditionals)->addTemplateArgumentConstraints($otherScope?->getTemplateArgumentConstraints()); + if ($otherScope === null || $otherScope === $this) { + return $this; + } + + $merged = $this->mergeWithVariableState($otherScope, $preserveVacuousConditionals)->addTemplateArgumentConstraints($otherScope->getTemplateArgumentConstraints()); if ( - $otherScope === null - || $merged->templateArgumentConstraints === null + $merged->templateArgumentConstraints === null || $merged->templateArgumentFrame === null || !$merged->templateArgumentFrame->isObservingClosures() ) { diff --git a/tests/bench/BenchScope.php b/tests/bench/BenchScope.php new file mode 100644 index 00000000000..e49037d348b --- /dev/null +++ b/tests/bench/BenchScope.php @@ -0,0 +1,129 @@ + $scopes + */ + private function __construct(private array $scopes) + { + } + + /** + * @param string $name the file name, each analysis needs its own because the parser caches by file contents + */ + public static function analyse(Container $container, ScratchDirectory $scratch, string $name, string $code): self + { + [$file, $nodes] = self::parse($container, $scratch, $name, $code); + $markers = []; + foreach (self::markers($nodes) as $marker => [$stmt]) { + $markers[$marker] = $stmt; + } + + $scopes = []; + $resolver = $container->getByType(NodeScopeResolver::class); + $resolver->setAnalysedFiles([$file]); + $scope = $container->getByType(ScopeFactory::class)->create(ScopeContext::create($file)); + $resolver->processNodes($nodes, $scope, static function (Node $node, Scope $scope) use ($markers, &$scopes): void { + foreach ($markers as $marker => $stmt) { + if ($node !== $stmt) { + continue; + } + // the rules get a FiberScope from 2.1.34 and a NodeCallbackScope from 2.3, + // the walk itself uses the MutatingScope behind them + if (method_exists($scope, 'toWalkScope')) { // @phpstan-ignore function.alreadyNarrowedType (not before 2.3) + $scope = $scope->toWalkScope(); + } elseif (method_exists($scope, 'toMutatingScope')) { + $scope = $scope->toMutatingScope(); // @phpstan-ignore method.deprecated (what 2.1.34 to 2.2 have) + } + if (!$scope instanceof MutatingScope) { + throw new LogicException('Not a MutatingScope.'); + } + $scopes[$marker] = $scope; + } + }); + + foreach (array_keys($markers) as $marker) { + if (!isset($scopes[$marker])) { + throw new LogicException($marker . '() was not reached.'); + } + } + + return new self($scopes); + } + + /** + * Parses without analysing, for statements no scope has seen yet. + * + * @return array{string, Node[]} + */ + public static function parse(Container $container, ScratchDirectory $scratch, string $name, string $code): array + { + $file = $scratch->path() . '/' . $name . '.php'; + // different contents for each name, the parser caches by them + file_put_contents($file, $code . "\n// " . $name . "\n"); + + /** @var Parser $parser */ + $parser = $container->getService('currentPhpVersionRichParser'); + + return [$file, $parser->parseFile($file)]; + } + + /** + * The `marker*();` statements in functions and methods, with the function and the statements after them. + * + * @param Node[] $nodes + * @return array}> + */ + public static function markers(array $nodes): array + { + $markers = []; + foreach ((new NodeFinder())->find($nodes, static fn (Node $node): bool => $node instanceof Node\Stmt\Function_ || $node instanceof Node\Stmt\ClassMethod) as $function) { + if (!$function instanceof Node\Stmt\Function_ && !$function instanceof Node\Stmt\ClassMethod) { + continue; + } + $stmts = $function->stmts ?? []; + foreach ($stmts as $i => $stmt) { + if ( + !$stmt instanceof Node\Stmt\Expression + || !$stmt->expr instanceof Node\Expr\FuncCall + || !$stmt->expr->name instanceof Node\Name + ) { + continue; + } + $markers[$stmt->expr->name->getLast()] = [$stmt, $function, array_slice($stmts, $i + 1)]; + } + } + + return $markers; + } + + public function scope(string $marker): MutatingScope + { + return $this->scopes[$marker] ?? throw new LogicException('No ' . $marker . '().'); + } + +} diff --git a/tests/bench/ExpressionTypeHolderBench.php b/tests/bench/ExpressionTypeHolderBench.php new file mode 100644 index 00000000000..1e6bb765e8b --- /dev/null +++ b/tests/bench/ExpressionTypeHolderBench.php @@ -0,0 +1,87 @@ +scratch); + + $warmup = self::union('warmup'); + $holder = ExpressionTypeHolder::createYes(new Variable('a'), $warmup); + $holder->equals(ExpressionTypeHolder::createYes(new Variable('a'), $warmup)); + $holder->and(ExpressionTypeHolder::createMaybe(new Variable('a'), $warmup)); + + $type = self::union('member'); + $this->holder = ExpressionTypeHolder::createYes(new Variable('a'), $type); + $this->sameType = ExpressionTypeHolder::createMaybe(new Variable('a'), $type); + } + + /** + * The same holder (#4777). + */ + public function benchEqualsSameHolder(): bool + { + $holder = $this->holder(); + + return $holder->equals($holder); + } + + /** + * Another holder of the same type object (#4800). + */ + public function benchAndSameType(): ExpressionTypeHolder + { + if ($this->sameType === null) { + throw new LogicException('boot() did not run.'); + } + + return $this->holder()->and($this->sameType); + } + + private function holder(): ExpressionTypeHolder + { + if ($this->holder === null) { + throw new LogicException('boot() did not run.'); + } + + return $this->holder; + } + + private static function union(string $prefix): UnionType + { + $types = []; + for ($i = 0; $i < self::MEMBERS; $i++) { + $types[] = new ConstantStringType($prefix . $i); + } + + return new UnionType($types); + } + +} diff --git a/tests/bench/MutatingScopeBench.php b/tests/bench/MutatingScopeBench.php new file mode 100644 index 00000000000..c0b647c56d0 --- /dev/null +++ b/tests/bench/MutatingScopeBench.php @@ -0,0 +1,260 @@ +scratch); + $warmup = BenchScope::analyse($container, $this->scratch, 'warmup', self::code()); + $this->warmupScope = $warmup->scope('marker'); + $warmup->scope('markerConditional')->filterByTruthyValue(self::notNull()); + $counted = BenchScope::analyse($container, $this->scratch, 'counted', self::code()); + $this->scope = $counted->scope('marker'); + $this->conditionalScope = $counted->scope('markerConditional'); + + foreach ([$this->warmupScope, $this->scope] as $i => $scope) { + // the counted scope has resolved what pushInFunctionCall() is to remember + $scope->getType(self::resolvedExpr()); + if ($i !== 0) { + continue; + } + $scope->getVariableType('i'); + $scope->getType(self::coalesce()); + $scope->getKeepVoidType(new Expr\Variable('str')); + $scope->getKeepVoidType(self::funcCall('trim')); + $scope->getType(self::funcCall('ucfirst')); + $scope->pushInFunctionCall(null, null, true)->getType(self::resolvedExpr()); + $scope->specifyExpressionType(new String_('x'), new StringType(), new StringType(), TrinaryLogic::createYes()); + $scope->filterByTruthyValue(self::condition()); + $scope->assignExpression(self::propertyFetch(0), new IntegerType(), new IntegerType()); + $scope->restoreThis($scope); + } + } + + /** + * The certainty is looked up once (#4726). + */ + public function benchGetVariableType(): Type + { + return $this->scope()->getVariableType('i'); + } + + /** + * Only the cases with a left side that may be set narrow the scope by isset() (#4661). + */ + public function benchGetTypeOfCoalesceWithNullLeft(): Type + { + return $this->scope()->getType(self::coalesce()); + } + + /** + * @return iterable + */ + public static function keepVoidExprs(): iterable + { + // not a call, and a call whose type has no null in it: the type without void is the same (#4735) + yield 'variable' => ['variable']; + yield 'func-call' => ['func-call']; + } + + #[DataProvider(method: 'keepVoidExprs')] + public function benchGetKeepVoidType(string $expr): Type + { + return $this->scope()->getKeepVoidType($expr === 'variable' ? new Expr\Variable('str') : self::funcCall('trim')); + } + + /** + * A function without a dynamic return type extension: the extensions for it are remembered (#4644). + */ + public function benchGetTypeOfFuncCall(): Type + { + return $this->scope()->getType(self::funcCall('ucfirst')); + } + + /** + * Creates a scope, without getting the parser from the container each time (#4672). + */ + public function benchPushInFunctionCall(): MutatingScope + { + return $this->scope()->pushInFunctionCall(null, null, true); + } + + /** + * An argument's type resolved before is not resolved again (#4648). + */ + public function benchPushInFunctionCallGetType(): Type + { + return $this->scope()->pushInFunctionCall(null, null, true)->getType(self::resolvedExpr()); + } + + /** + * A scalar's type is not stored (#4663). + */ + public function benchSpecifyScalarType(): MutatingScope + { + return $this->scope()->specifyExpressionType(new String_('x'), new StringType(), new StringType(), TrinaryLogic::createYes()); + } + + /** + * The keys of the narrowed expressions are not printed again (#4691). + */ + public function benchFilterByTruthyValue(): MutatingScope + { + return $this->scope()->filterByTruthyValue(self::condition()); + } + + /** + * The scope remembers what the properties assigned in `if ($s !== null)` are when it holds, + * but not what they are otherwise, so they are not intersected with anything (#4690). + */ + public function benchFilterByConditionOfConditionalExpressions(): MutatingScope + { + if ($this->conditionalScope === null) { + throw new LogicException('boot() did not run.'); + } + + return $this->conditionalScope->filterByTruthyValue(self::notNull()); + } + + /** + * Invalidates the method calls on $this without searching each tracked expression (#4692). + */ + public function benchAssignPropertyFetch(): MutatingScope + { + return $this->scope()->assignExpression(self::propertyFetch(0), new IntegerType(), new IntegerType()); + } + + /** + * Finds the expressions on $this by their keys, not by searching them (#4697). + */ + public function benchRestoreThis(): MutatingScope + { + $scope = $this->scope(); + + return $scope->restoreThis($scope); + } + + private function scope(): MutatingScope + { + if ($this->scope === null) { + throw new LogicException('boot() did not run.'); + } + + return $this->scope; + } + + private static function code(): string + { + $properties = []; + $assigns = []; + $conditionalAssigns = []; + for ($i = 0; $i < self::PROPERTIES; $i++) { + $properties[] = sprintf('public int $p%d;', $i); + $assigns[] = sprintf('$this->p%d = $i;', $i); + $conditionalAssigns[] = sprintf('$this->p%d = %d;', $i, $i); + } + + return sprintf(<<<'PHP' + + */ + public static function merges(): iterable + { + // a scope merged with itself (#4640) + yield 'same-scope' => ['same-scope']; + // variables only one side defines become maybe-defined, unless they are super-globals (#4662) + yield 'variables-on-one-side' => ['variables-on-one-side']; + // other expressions only one side tracks are forgotten (#4719, #4720) + yield 'property-fetches-on-one-side' => ['property-fetches-on-one-side']; + } + + #[Before] + public function boot(string $merge): void + { + $container = BenchContainer::create($this->scratch); + $scopes = BenchScope::analyse($container, $this->scratch, 'merge', self::code()); + [$this->ours, $this->theirs] = match ($merge) { + 'same-scope' => [$scopes->scope('markerVariables'), $scopes->scope('markerVariables')], + 'variables-on-one-side' => [$scopes->scope('markerVariables'), $scopes->scope('markerVariablesBefore')], + 'property-fetches-on-one-side' => [$scopes->scope('markerProperties'), $scopes->scope('markerPropertiesBefore')], + default => throw new LogicException('Unknown merge ' . $merge . '.'), + }; + + // the analysis merges the same expressions again and again, the first merge may remember what it learned about them + $this->ours->mergeWith($this->theirs); + } + + #[DataProvider(method: 'merges')] + public function benchMergeWith(string $merge): MutatingScope + { + if ($this->ours === null || $this->theirs === null) { + throw new LogicException('boot() did not run.'); + } + + return $this->ours->mergeWith($this->theirs); + } + + private static function code(): string + { + $properties = []; + $propertyAssigns = []; + $variableAssigns = []; + for ($i = 0; $i < self::ASSIGNS; $i++) { + $properties[] = sprintf('public int $p%d;', $i); + $propertyAssigns[] = sprintf('$this->p%d = $i;', $i); + $variableAssigns[] = sprintf('$v%d = $i;', $i); + } + + return sprintf(<<<'PHP' + */ + private array $stmts = []; + + public function __construct(private ScratchDirectory $scratch) + { + } + + /** + * @return iterable + */ + public static function statements(): iterable + { + // the assigned types have no falsey part, so there is nothing to remember for if ($v) (#4705, #4716) + yield 'assign-non-falsy' => [<<<'PHP' + $v0 = 'a'; $v1 = 'b'; $v2 = 'c'; $v3 = 'd'; $v4 = 'e'; $v5 = 'f'; $v6 = 'g'; $v7 = 'h'; + $v8 = [1]; $v9 = [2]; $v10 = [3]; $v11 = [4]; $v12 = [5]; $v13 = [6]; $v14 = [7]; $v15 = [8]; + PHP]; + // arguments of a user function, none by reference or callable (#4708, #4709, #4710, #4711) + yield 'func-call-args' => [<<<'PHP' + take($i, $j, $s, $a, $i, $j, $s, $a); + take($j, $i, $s, $a, $j, $i, $s, $a); + take($i, $i, $s, $a, $j, $j, $s, $a); + take($j, $j, $s, $a, $i, $i, $s, $a); + PHP]; + // a loop in a loop is not at the top level until the outer one converged (#4706) + yield 'nested-while' => [<<<'PHP' + while ($i > 0) { + while ($j > 0) { + $j--; + } + do { + $j++; + } while ($j < 10); + $i--; + } + PHP]; + // the storage is duplicated only at the top level (#4728) + yield 'nested-foreach' => [<<<'PHP' + foreach ($a as $k => $v) { + foreach ($a as $k2 => $v2) { + $i = $k2; + } + } + PHP]; + // without conditions there is nothing to process in a duplicated storage (#4729) + yield 'for-without-condition' => [<<<'PHP' + for (;;) { + for (;;) { + $j = 1; + break; + } + $i = 1; + break; + } + PHP]; + // with a default arm the match cannot throw UnhandledMatchError (#4739) + yield 'match-with-default' => [<<<'PHP' + $r0 = match ($i) { 1 => 'a', 2 => 'b', 3 => 'c', default => 'd' }; + $r1 = match ($j) { 1 => 'a', 2 => 'b', 3 => 'c', default => 'd' }; + $r2 = match ($s) { 'a' => 1, 'b' => 2, 'c' => 3, default => 4 }; + $r3 = match (true) { $i > 1 => 1, $j > 1 => 2, default => 3 }; + PHP]; + } + + #[Before] + public function boot(string $statements): void + { + $container = BenchContainer::create($this->scratch); + $this->resolver = $container->getByType(NodeScopeResolver::class); + $code = self::code($statements); + $this->scope = BenchScope::analyse($container, $this->scratch, 'analysed', $code)->scope('marker'); + + [, $warmupNodes] = BenchScope::parse($container, $this->scratch, 'warmup', $code); + [, $warmupFunction, $warmupStmts] = BenchScope::markers($warmupNodes)['marker']; + $this->resolver->processStmtNodes($warmupFunction, $warmupStmts, $this->scope, static function (): void { + }, StatementContext::createTopLevel()); + + [, $nodes] = BenchScope::parse($container, $this->scratch, 'counted', $code); + [, $this->function, $this->stmts] = BenchScope::markers($nodes)['marker']; + } + + #[DataProvider(method: 'statements')] + public function benchProcessStmtNodes(string $statements): StatementResult + { + if ($this->resolver === null || $this->scope === null || $this->function === null) { + throw new LogicException('boot() did not run.'); + } + + return $this->resolver->processStmtNodes($this->function, $this->stmts, $this->scope, static function (Node $node, Scope $scope): void { + }, StatementContext::createTopLevel()); + } + + private static function code(string $statements): string + { + return sprintf(<<<'PHP' + $a + */ + function doFoo(int $i, int $j, string $s, array $a): void + { + marker(); + %s + } + PHP, $statements); + } + +} From a9aae1e7ebe00ed500a2d6288d3c8af47e05821d Mon Sep 17 00:00:00 2001 From: "ondrej@mirtes.cz" Date: Sat, 14 Nov 2026 00:33:43 +0000 Subject: [PATCH 03/18] Add microbenchmarks of the analyser and PHPDoc performance fixes 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. --- tests/bench/ArgumentsNormalizerBench.php | 80 +++++++++++++++++ tests/bench/BenchContainer.php | 7 +- tests/bench/ExprPrinterBench.php | 85 ++++++++++++++++++ .../bench/MutatingScopeArrayDimFetchBench.php | 88 +++++++++++++++++++ tests/bench/TypeCombinatorBench.php | 32 +++++++ tests/bench/TypeUtilsBench.php | 68 ++++++++++++++ 6 files changed, 358 insertions(+), 2 deletions(-) create mode 100644 tests/bench/ArgumentsNormalizerBench.php create mode 100644 tests/bench/ExprPrinterBench.php create mode 100644 tests/bench/MutatingScopeArrayDimFetchBench.php create mode 100644 tests/bench/TypeUtilsBench.php diff --git a/tests/bench/ArgumentsNormalizerBench.php b/tests/bench/ArgumentsNormalizerBench.php new file mode 100644 index 00000000000..477f35b7397 --- /dev/null +++ b/tests/bench/ArgumentsNormalizerBench.php @@ -0,0 +1,80 @@ + + */ + public static function calls(): iterable + { + yield 'positional' => [false]; + yield 'named' => [true]; + } + + #[Before] + public function boot(bool $named): void + { + $container = BenchContainer::create($this->scratch); + $this->acceptor = $container->getByType(ReflectionProvider::class)->getFunction(new Name('str_replace'), null)->getVariants()[0]; + + ArgumentsNormalizer::reorderFuncArguments($this->acceptor, self::call(false)); + ArgumentsNormalizer::reorderFuncArguments($this->acceptor, self::call(true)); + + $this->call = self::call($named); + } + + /** + * A call without named arguments is returned as it is, a call with them is rebuilt in the order of the parameters. + */ + #[DataProvider(method: 'calls')] + public function benchReorderFuncArguments(bool $named): ?FuncCall + { + if ($this->acceptor === null || $this->call === null) { + throw new LogicException('boot() did not run.'); + } + + return ArgumentsNormalizer::reorderFuncArguments($this->acceptor, $this->call); + } + + private static function call(bool $named): FuncCall + { + $search = new String_('a'); + $replace = new String_('b'); + $subject = new Variable('subject'); + if (!$named) { + return new FuncCall(new Name('str_replace'), [new Arg($search), new Arg($replace), new Arg($subject)]); + } + + return new FuncCall(new Name('str_replace'), [ + new Arg($subject, name: new Identifier('subject')), + new Arg($search, name: new Identifier('search')), + new Arg($replace, name: new Identifier('replace')), + ]); + } + +} diff --git a/tests/bench/BenchContainer.php b/tests/bench/BenchContainer.php index 7f839913e5a..25aac5c8ed7 100644 --- a/tests/bench/BenchContainer.php +++ b/tests/bench/BenchContainer.php @@ -16,12 +16,15 @@ final class BenchContainer { - public static function create(ScratchDirectory $scratch): Container + /** + * @param list $additionalConfigFiles such as one with the parameters a benchmark needs + */ + public static function create(ScratchDirectory $scratch, array $additionalConfigFiles = []): Container { // the root of the PHPStan that is loaded, so the benchmarks also run against other versions $containerFactory = new ContainerFactory(dirname((string) (new ReflectionClass(ContainerFactory::class))->getFileName(), 3)); - return $containerFactory->create($scratch->path(), [$containerFactory->getConfigDirectory() . '/config.level8.neon'], []); + return $containerFactory->create($scratch->path(), [$containerFactory->getConfigDirectory() . '/config.level8.neon', ...$additionalConfigFiles], []); } } diff --git a/tests/bench/ExprPrinterBench.php b/tests/bench/ExprPrinterBench.php new file mode 100644 index 00000000000..887bda0c6b3 --- /dev/null +++ b/tests/bench/ExprPrinterBench.php @@ -0,0 +1,85 @@ + */ + private array $levels = []; + + public function __construct(private ScratchDirectory $scratch) + { + } + + /** + * @return iterable + */ + public static function depths(): iterable + { + foreach ([16, 64, 256] as $depth) { + yield 'depth-' . $depth => [$depth]; + } + } + + #[Before] + public function boot(int $depth): void + { + $container = BenchContainer::create($this->scratch); + $this->printer = $container->getByType(ExprPrinter::class); + foreach (self::levels(4) as $level) { + $this->printer->printExpr($level); + } + + $this->levels = self::levels($depth); + } + + /** + * The analyser asks for the key of each level of $a->p1->p2…, innermost first, and printing + * a level prints the levels below it. + */ + #[DataProvider(method: 'depths')] + #[Linear(size: 'depth')] + public function benchPrintEveryLevelOfChain(int $depth): int + { + if ($this->printer === null) { + throw new LogicException('boot() did not run.'); + } + + $length = 0; + foreach ($this->levels as $level) { + $length += strlen($this->printer->printExpr($level)); + } + + return $length; + } + + /** + * @return list innermost first + */ + private static function levels(int $depth): array + { + $expr = new Variable('a'); + $levels = []; + for ($i = 1; $i <= $depth; $i++) { + $expr = new PropertyFetch($expr, 'p' . $i); + $levels[] = $expr; + } + + return $levels; + } + +} diff --git a/tests/bench/MutatingScopeArrayDimFetchBench.php b/tests/bench/MutatingScopeArrayDimFetchBench.php new file mode 100644 index 00000000000..3faeceb4173 --- /dev/null +++ b/tests/bench/MutatingScopeArrayDimFetchBench.php @@ -0,0 +1,88 @@ + + */ + public static function arrays(): iterable + { + // 0 is array, otherwise a shape with that many keys + yield 'general-array' => [0]; + yield 'shape-32' => [32]; + } + + #[Before] + public function boot(int $keys): void + { + $container = BenchContainer::create($this->scratch); + $scope = $container->getByType(ScopeFactory::class)->create(ScopeContext::create($this->scratch->path() . '/file.php')); + foreach ([0, 4] as $warmupKeys) { + $warmup = $scope->assignVariable('warmup', self::arrayType($warmupKeys), self::arrayType($warmupKeys), TrinaryLogic::createYes()); + $warmup->specifyExpressionType(self::dimFetch('warmup'), new ConstantIntegerType(1), new IntegerType(), TrinaryLogic::createYes()); + } + + $this->scope = $scope->assignVariable('a', self::arrayType($keys), self::arrayType($keys), TrinaryLogic::createYes()); + } + + /** + * Specifying an offset also specifies the array: an array is offset-accessible already. + */ + #[DataProvider(method: 'arrays')] + public function benchSpecifyArrayDimFetchType(int $keys): MutatingScope + { + if ($this->scope === null) { + throw new LogicException('boot() did not run.'); + } + + return $this->scope->specifyExpressionType(self::dimFetch('a'), new ConstantIntegerType(1), new IntegerType(), TrinaryLogic::createYes()); + } + + private static function dimFetch(string $variable): ArrayDimFetch + { + return new ArrayDimFetch(new Variable($variable), new String_('k0')); + } + + private static function arrayType(int $keys): Type + { + if ($keys === 0) { + return new ArrayType(new StringType(), new IntegerType()); + } + + $builder = ConstantArrayTypeBuilder::createEmpty(); + for ($i = 0; $i < $keys; $i++) { + $builder->setOffsetValueType(new ConstantStringType('k' . $i), new IntegerType()); + } + + return $builder->getArray(); + } + +} diff --git a/tests/bench/TypeCombinatorBench.php b/tests/bench/TypeCombinatorBench.php index 31183779797..4cfffaadee0 100644 --- a/tests/bench/TypeCombinatorBench.php +++ b/tests/bench/TypeCombinatorBench.php @@ -2,6 +2,7 @@ namespace PHPStan\Benchmark; +use PHPStan\Type\BenevolentUnionType; use PHPStan\Type\Constant\ConstantArrayType; use PHPStan\Type\Constant\ConstantIntegerType; use PHPStan\Type\Constant\ConstantStringType; @@ -29,6 +30,12 @@ final class TypeCombinatorBench /** @var list */ private array $unions = []; + /** @var list */ + private array $taggedArrays = []; + + /** @var list */ + private array $benevolentUnions = []; + public function __construct(private ScratchDirectory $scratch) { } @@ -55,6 +62,8 @@ public function boot(int $types): void TypeCombinator::union(...$this->arrays); TypeCombinator::intersect(...$this->unions); TypeCombinator::remove(...$this->unions); + TypeCombinator::union(...$this->taggedArrays); + TypeCombinator::intersect(...$this->benevolentUnions); $this->createTypes('type', $types); } @@ -98,6 +107,21 @@ public function benchRemoveFromUnion(int $types): Type return TypeCombinator::remove(...$this->unions); } + /** + * Arrays of the same keys that differ in two constant values cannot be keys-supersets of each other. + */ + #[DataProvider(method: 'types')] + public function benchUnionTaggedConstantArrays(int $types): Type + { + return TypeCombinator::union(...$this->taggedArrays); + } + + #[DataProvider(method: 'types')] + public function benchIntersectBenevolentUnion(int $types): Type + { + return TypeCombinator::intersect(...$this->benevolentUnions); + } + private function createTypes(string $prefix, int $types): void { $this->strings = []; @@ -112,6 +136,14 @@ private function createTypes(string $prefix, int $types): void $others[] = new ConstantStringType($prefix . ($i * 2)); } $this->unions = [new UnionType($this->strings), new UnionType($others)]; + $this->benevolentUnions = [new BenevolentUnionType($this->strings), new UnionType($others)]; + $this->taggedArrays = []; + for ($i = 0; $i < $types; $i++) { + $this->taggedArrays[] = new ConstantArrayType( + [new ConstantStringType('kind'), new ConstantStringType('id')], + [new ConstantStringType($prefix . $i), new ConstantIntegerType($i)], + ); + } } } diff --git a/tests/bench/TypeUtilsBench.php b/tests/bench/TypeUtilsBench.php new file mode 100644 index 00000000000..73edd04f798 --- /dev/null +++ b/tests/bench/TypeUtilsBench.php @@ -0,0 +1,68 @@ + + */ + public static function members(): iterable + { + foreach ([16, 128] as $members) { + yield 'members-' . $members => [$members]; + } + } + + #[Before] + public function boot(int $members): void + { + BenchContainer::create($this->scratch); + TypeUtils::resolveLateResolvableTypes(self::union('warmup', 4)); + + $this->type = self::union('member', $members); + } + + /** + * The traverser visits every member of a union that holds a conditional type to resolve it. + */ + #[DataProvider(method: 'members')] + public function benchResolveLateResolvableTypes(int $members): Type + { + if ($this->type === null) { + throw new LogicException('boot() did not run.'); + } + + return TypeUtils::resolveLateResolvableTypes($this->type); + } + + private static function union(string $prefix, int $members): Type + { + $types = [new ConditionalType(new IntegerType(), new IntegerType(), new StringType(), new IntegerType(), false)]; + for ($i = 1; $i < $members; $i++) { + $types[] = new ConstantStringType($prefix . $i); + } + + return new UnionType($types); + } + +} From db0419953cc755676e6e34f67e820cf9769c0c74 Mon Sep 17 00:00:00 2001 From: "ondrej@mirtes.cz" Date: Sat, 14 Nov 2026 00:33:43 +0000 Subject: [PATCH 04/18] Mark only the new level of a chain that has no nullsafe operator 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. --- src/Analyser/NullsafeOperatorHelper.php | 14 +- tests/bench/NullsafeOperatorHelperBench.php | 138 ++++++++++++++++++++ turbo-ext/src/NullsafeOperatorHelper.cpp | 9 +- 3 files changed, 152 insertions(+), 9 deletions(-) create mode 100644 tests/bench/NullsafeOperatorHelperBench.php diff --git a/src/Analyser/NullsafeOperatorHelper.php b/src/Analyser/NullsafeOperatorHelper.php index c7d5d292820..4a49bb361b8 100644 --- a/src/Analyser/NullsafeOperatorHelper.php +++ b/src/Analyser/NullsafeOperatorHelper.php @@ -50,12 +50,14 @@ public static function getNullsafeShortcircuitedExpr(Expr $expr): Expr && !$current instanceof Expr\NullsafePropertyFetch ) { $next = self::chainedInto($current); - if ($next === null) { - // Every level of a chain is resolved in turn and each one used to - // walk down to the root again, so a chain of depth N cost O(N^2) - // walk steps — with no nullsafe operator in it at all. Marking - // the levels on the way out makes that O(N) per chain. - for ($level = $expr; $level !== null; $level = self::chainedInto($level)) { + if ($next === null || ($current === $expr && $next->getAttribute(self::SHORTCIRCUITED_ATTRIBUTE) === true)) { + // Every level of a chain is resolved in turn, innermost first, and + // each one used to walk down to the root again, so a chain of depth N + // cost O(N^2) walk steps — with no nullsafe operator in it at all. + // A marked level has none at or below it: when the level right below + // is marked, as it is when the chain is resolved innermost first, only + // this level is marked, which makes that O(N) per chain. + for ($level = $expr; $level !== null && $level !== $next; $level = self::chainedInto($level)) { $level->setAttribute(self::SHORTCIRCUITED_ATTRIBUTE, true); } diff --git a/tests/bench/NullsafeOperatorHelperBench.php b/tests/bench/NullsafeOperatorHelperBench.php new file mode 100644 index 00000000000..552c254dd86 --- /dev/null +++ b/tests/bench/NullsafeOperatorHelperBench.php @@ -0,0 +1,138 @@ + */ + private array $levels = []; + + public function __construct(private ScratchDirectory $scratch) + { + } + + /** + * @return iterable + */ + public static function chains(): iterable + { + yield 'innermost-nullsafe-64' => [64, true]; + yield 'without-nullsafe-64' => [64, false]; + } + + /** + * @return iterable + */ + public static function chainsWithoutNullsafe(): iterable + { + yield 'without-nullsafe-8' => [8, false]; + } + + /** + * @return iterable + */ + public static function levelsWithoutNullsafe(): iterable + { + foreach ([16, 64] as $depth) { + yield 'levels-' . $depth => [$depth, false]; + } + } + + #[Before] + public function boot(int $depth, bool $nullsafe): void + { + $container = BenchContainer::create($this->scratch); + $scope = $container->getByType(ScopeFactory::class)->create(ScopeContext::create($this->scratch->path() . '/file.php')); + $scope = $scope->assignVariable('w', new MixedType(true), new MixedType(true), TrinaryLogic::createYes()); + $this->scope = $scope->assignVariable('a', new MixedType(true), new MixedType(true), TrinaryLogic::createYes()); + + NullsafeOperatorHelper::getNullsafeShortcircuitedExpr(self::chain('w', 4, true)); + NullsafeOperatorHelper::getNullsafeShortcircuitedExprRespectingScope($this->scope, self::chain('w', 4, false)); + + $this->chain = self::chain('a', $depth, $nullsafe); + $this->levels = []; + for ($level = $this->chain; $level instanceof PropertyFetch || $level instanceof NullsafePropertyFetch; $level = $level->var) { + $this->levels[] = $level; + } + $this->levels = array_reverse($this->levels); + } + + /** + * The analyser asks about each level of a chain, innermost first. + */ + #[DataProvider(method: 'levelsWithoutNullsafe')] + public function benchGetNullsafeShortcircuitedExprOfEveryLevel(int $depth, bool $nullsafe): int + { + $unchanged = 0; + foreach ($this->levels as $level) { + if (NullsafeOperatorHelper::getNullsafeShortcircuitedExpr($level) !== $level) { + continue; + } + + $unchanged++; + } + + return $unchanged; + } + + /** + * Every link above a nullsafe one is rebuilt, a chain without one is returned as it is. + */ + #[DataProvider(method: 'chains')] + public function benchGetNullsafeShortcircuitedExpr(int $depth, bool $nullsafe): Expr + { + if ($this->chain === null) { + throw new LogicException('boot() did not run.'); + } + + return NullsafeOperatorHelper::getNullsafeShortcircuitedExpr($this->chain); + } + + /** + * A chain without a nullsafe link needs no type from the scope. + */ + #[DataProvider(method: 'chainsWithoutNullsafe')] + public function benchGetNullsafeShortcircuitedExprRespectingScope(int $depth, bool $nullsafe): Expr + { + if ($this->scope === null || $this->chain === null) { + throw new LogicException('boot() did not run.'); + } + + return NullsafeOperatorHelper::getNullsafeShortcircuitedExprRespectingScope($this->scope, $this->chain); + } + + /** + * @return Expr $variable->p0->p1…, with ?-> as the innermost link when $nullsafe + */ + private static function chain(string $variable, int $depth, bool $nullsafe): Expr + { + $expr = new Variable($variable); + for ($i = 0; $i < $depth; $i++) { + $expr = $nullsafe && $i === 0 ? new NullsafePropertyFetch($expr, 'p' . $i) : new PropertyFetch($expr, 'p' . $i); + } + + return $expr; + } + +} diff --git a/turbo-ext/src/NullsafeOperatorHelper.cpp b/turbo-ext/src/NullsafeOperatorHelper.cpp index 6f0ce7b56f7..ef8a71eb7f7 100644 --- a/turbo-ext/src/NullsafeOperatorHelper.cpp +++ b/turbo-ext/src/NullsafeOperatorHelper.cpp @@ -195,11 +195,14 @@ class NullsafeOperatorHelper zv::Val rvHold = zv::Val::adopt(rv); if (UNEXPECTED(failed)) return zv::Val(); if (next != NULL && UNEXPECTED(!requireExprOrNull(next))) return zv::Val(); - if (next == NULL || Z_TYPE_P(next) == IS_NULL) { - /* mark the levels on the way out */ + bool nextMarked = false; + if (current == expr && next != NULL && Z_TYPE_P(next) == IS_OBJECT && UNEXPECTED(!isMarked(Z_OBJ_P(next), nextMarked))) return zv::Val(); + if (next == NULL || Z_TYPE_P(next) == IS_NULL || nextMarked) { + /* mark the levels on the way out, down to a level marked already */ + zend_object *stop = nextMarked ? Z_OBJ_P(next) : NULL; zend_object *level = expr; zv::Val levelHold; - while (level != NULL) { + while (level != NULL && level != stop) { if (UNEXPECTED(!mark(level))) return zv::Val(); zval levelRv; ZVAL_UNDEF(&levelRv); From c7053e2bf494fb9ce81b27e616040af80129444b Mon Sep 17 00:00:00 2001 From: "ondrej@mirtes.cz" Date: Sat, 14 Nov 2026 00:33:43 +0000 Subject: [PATCH 05/18] Enter the declaration once per PHPDoc type, not once per node 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. --- src/PhpDoc/TypeNodeResolver.php | 49 ++++++++--------- tests/bench/TypeNodeResolverBench.php | 76 +++++++++++++++++++++++++++ 2 files changed, 101 insertions(+), 24 deletions(-) create mode 100644 tests/bench/TypeNodeResolverBench.php diff --git a/src/PhpDoc/TypeNodeResolver.php b/src/PhpDoc/TypeNodeResolver.php index 409afced88f..0c02c5d3249 100644 --- a/src/PhpDoc/TypeNodeResolver.php +++ b/src/PhpDoc/TypeNodeResolver.php @@ -180,6 +180,7 @@ public function resolve(TypeNode $typeNode, NameScope $nameScope): Type } } + /** The nested nodes of a type are resolved in the declaration resolve() entered, with a name scope of the same declaration. */ private function resolveType(TypeNode $typeNode, NameScope $nameScope): Type { foreach ($this->extensionRegistryProvider->getRegistry()->getExtensions() as $extension) { @@ -647,7 +648,7 @@ private function resolveThisTypeNode(NameScope $nameScope): Type private function resolveNullableTypeNode(NullableTypeNode $typeNode, NameScope $nameScope): Type { - return TypeCombinator::union($this->resolve($typeNode->type, $nameScope), new NullType()); + return TypeCombinator::union($this->resolveType($typeNode->type, $nameScope), new NullType()); } private function resolveUnionTypeNode(UnionTypeNode $typeNode, NameScope $nameScope): Type @@ -710,10 +711,10 @@ private function resolveIntersectionTypeNode(IntersectionTypeNode $typeNode, Nam private function resolveConditionalTypeNode(ConditionalTypeNode $typeNode, NameScope $nameScope): Type { return new ConditionalType( - $this->resolve($typeNode->subjectType, $nameScope), - $this->resolve($typeNode->targetType, $nameScope), - $this->resolve($typeNode->if, $nameScope), - $this->resolve($typeNode->else, $nameScope), + $this->resolveType($typeNode->subjectType, $nameScope), + $this->resolveType($typeNode->targetType, $nameScope), + $this->resolveType($typeNode->if, $nameScope), + $this->resolveType($typeNode->else, $nameScope), $typeNode->negated, ); } @@ -722,16 +723,16 @@ private function resolveConditionalTypeForParameterNode(ConditionalTypeForParame { return new ConditionalTypeForParameter( $typeNode->parameterName, - $this->resolve($typeNode->targetType, $nameScope), - $this->resolve($typeNode->if, $nameScope), - $this->resolve($typeNode->else, $nameScope), + $this->resolveType($typeNode->targetType, $nameScope), + $this->resolveType($typeNode->if, $nameScope), + $this->resolveType($typeNode->else, $nameScope), $typeNode->negated, ); } private function resolveArrayTypeNode(ArrayTypeNode $typeNode, NameScope $nameScope): Type { - $itemType = $this->resolve($typeNode->type, $nameScope); + $itemType = $this->resolveType($typeNode->type, $nameScope); return new ArrayType((new BenevolentUnionType([new IntegerType(), new StringType()]))->toArrayKey(), $itemType); } @@ -1084,10 +1085,10 @@ private function resolveCallableTypeNode(CallableTypeNode $typeNode, NameScope $ $templateTags[$templateType->name] = new TemplateTag( $templateType->name, $templateType->bound !== null - ? $this->resolve($templateType->bound, $nameScope) + ? $this->resolveType($templateType->bound, $nameScope) : new MixedType(), $templateType->default !== null - ? $this->resolve($templateType->default, $nameScope) + ? $this->resolveType($templateType->default, $nameScope) : null, TemplateTypeVariance::createInvariant(), ); @@ -1104,7 +1105,7 @@ private function resolveCallableTypeNode(CallableTypeNode $typeNode, NameScope $ $templateTypeMap = TemplateTypeMap::createEmpty(); } - $mainType = $this->resolve($typeNode->identifier, $nameScope); + $mainType = $this->resolveType($typeNode->identifier, $nameScope); $isVariadic = false; $parameters = array_values(array_map( @@ -1118,7 +1119,7 @@ function (CallableTypeParameterNode $parameterNode) use ($nameScope, &$isVariadi return new NativeParameterReflection( $parameterName, $parameterNode->isOptional || $parameterNode->isVariadic, - $this->resolve($parameterNode->type, $nameScope), + $this->resolveType($parameterNode->type, $nameScope), $parameterNode->isReference ? PassedByReference::createCreatesNewVariable() : PassedByReference::createNo(), $parameterNode->isVariadic, defaultValue: null, @@ -1131,7 +1132,7 @@ function (CallableTypeParameterNode $parameterNode) use ($nameScope, &$isVariadi if ($assertions !== null) { $returnType = new BooleanType(); } else { - $returnType = $this->resolve($typeNode->returnType, $nameScope); + $returnType = $this->resolveType($typeNode->returnType, $nameScope); } if ($mainType instanceof CallableType) { @@ -1185,10 +1186,10 @@ private function resolveCallableReturnTypeAssertions(CallableTypeNode $typeNode, return CallableAssertionsHelper::createAssertionsFromConditional( $returnTypeNode->parameterName, - $this->resolve($returnTypeNode->targetType, $nameScope), + $this->resolveType($returnTypeNode->targetType, $nameScope), $returnTypeNode->negated, - $this->resolve($returnTypeNode->if, $nameScope), - $this->resolve($returnTypeNode->else, $nameScope), + $this->resolveType($returnTypeNode->if, $nameScope), + $this->resolveType($returnTypeNode->else, $nameScope), ); } @@ -1201,7 +1202,7 @@ private function resolveArrayShapeNode(ArrayShapeNode $typeNode, NameScope $name foreach ($typeNode->items as $itemNode) { $items[] = [ $this->resolveArrayShapeOffsetType($itemNode, $nameScope), - $this->resolve($itemNode->valueType, $nameScope), + $this->resolveType($itemNode->valueType, $nameScope), $itemNode->optional, ]; } @@ -1212,11 +1213,11 @@ private function resolveArrayShapeNode(ArrayShapeNode $typeNode, NameScope $name // when the shape gets built, so that it can be printed back as `...`. $unsealedKeyType = $typeNode->unsealedType === null || $typeNode->unsealedType->keyType === null ? null - : $this->transformUnsafeArrayKey($this->resolve($typeNode->unsealedType->keyType, $nameScope)); + : $this->transformUnsafeArrayKey($this->resolveType($typeNode->unsealedType->keyType, $nameScope)); $unsealedValueType = $typeNode->unsealedType === null ? new MixedType() - : $this->resolve($typeNode->unsealedType->valueType, $nameScope); + : $this->resolveType($typeNode->unsealedType->valueType, $nameScope); $unsealed = [$unsealedKeyType, $unsealedValueType]; } @@ -1324,7 +1325,7 @@ private function resolveObjectShapeNode(ObjectShapeNode $typeNode, NameScope $na $keyType = new ConstantStringType($itemNode->keyName->value); } - $items[] = [$keyType, $this->resolve($itemNode->valueType, $nameScope), $itemNode->optional]; + $items[] = [$keyType, $this->resolveType($itemNode->valueType, $nameScope), $itemNode->optional]; } return LateResolvableObjectShapeType::create($items); @@ -1492,8 +1493,8 @@ private function resolveConstTypeNode(ConstTypeNode $typeNode, NameScope $nameSc private function resolveOffsetAccessNode(OffsetAccessTypeNode $typeNode, NameScope $nameScope): Type { - $type = $this->resolve($typeNode->type, $nameScope); - $offset = $this->resolve($typeNode->offset, $nameScope); + $type = $this->resolveType($typeNode->type, $nameScope); + $offset = $this->resolveType($typeNode->offset, $nameScope); if ($type->isOffsetAccessible()->no() || $type->hasOffsetValueType($offset)->no()) { return new ErrorType(); @@ -1564,7 +1565,7 @@ public function resolveMultiple(array $typeNodes, NameScope $nameScope): array { $types = []; foreach ($typeNodes as $typeNode) { - $types[] = $this->resolve($typeNode, $nameScope); + $types[] = $this->resolveType($typeNode, $nameScope); } return $types; diff --git a/tests/bench/TypeNodeResolverBench.php b/tests/bench/TypeNodeResolverBench.php new file mode 100644 index 00000000000..8aa8ea36370 --- /dev/null +++ b/tests/bench/TypeNodeResolverBench.php @@ -0,0 +1,76 @@ + + */ + public static function items(): iterable + { + foreach ([4, 32] as $items) { + yield 'items-' . $items => [$items]; + } + } + + #[Before] + public function boot(int $items): void + { + $container = BenchContainer::create($this->scratch); + $this->resolver = $container->getByType(TypeNodeResolver::class); + $lexer = $container->getByType(Lexer::class); + $typeParser = $container->getByType(TypeParser::class); + + $parse = static fn (string $type): TypeNode => $typeParser->parse(new TokenIterator($lexer->tokenize($type))); + $this->resolver->resolve($parse(self::shape(3, 'float')), new NameScope(null, [])); + + $this->typeNode = $parse(self::shape($items, 'int')); + } + + /** + * A non-empty-list shape gets both the list and the non-empty accessory. + */ + #[DataProvider(method: 'items')] + public function benchResolveNonEmptyListShape(int $items): Type + { + if ($this->resolver === null || $this->typeNode === null) { + throw new LogicException('boot() did not run.'); + } + + return $this->resolver->resolve($this->typeNode, new NameScope(null, [])); + } + + private static function shape(int $items, string $valueType): string + { + $values = []; + for ($i = 0; $i < $items; $i++) { + $values[] = $valueType; + } + + return 'non-empty-list{' . implode(', ', $values) . '}'; + } + +} From a5cd2ebc046b097c815325339d74c9457f16fe2c Mon Sep 17 00:00:00 2001 From: "ondrej@mirtes.cz" Date: Sat, 14 Nov 2026 00:33:44 +0000 Subject: [PATCH 06/18] Create UnmatchedIgnoredErrorJudge only for an entry that is reported --- .../Ignore/IgnoredErrorHelperResult.php | 21 ++-- tests/bench/IgnoredErrorHelperBench.php | 97 +++++++++++++++++++ 2 files changed, 108 insertions(+), 10 deletions(-) create mode 100644 tests/bench/IgnoredErrorHelperBench.php diff --git a/src/Analyser/Ignore/IgnoredErrorHelperResult.php b/src/Analyser/Ignore/IgnoredErrorHelperResult.php index b1858ac8d8a..46c2245a5f5 100644 --- a/src/Analyser/Ignore/IgnoredErrorHelperResult.php +++ b/src/Analyser/Ignore/IgnoredErrorHelperResult.php @@ -368,16 +368,7 @@ public function process( $analysedFilesKeys = array_fill_keys($analysedFiles, true); if (!$hasInternalErrors && !$collectedDataRuleSkipped) { - // asked only about an entry that would be reported; only an entry with `paths` has a part that differs from the entry - $judge = new UnmatchedIgnoredErrorJudge( - $this->fileHelper, - $this->analyseFileExcluder, - $analysedFiles, - $this->analysedPaths, - $this->analysedPathsFromConfig, - $collectedData, - $onlyFiles, - ); + $judge = null; foreach ($unmatchedIgnoredErrors as $i => $unmatchedIgnoredError) { $reportUnmatched = is_array($unmatchedIgnoredError) ? ($unmatchedIgnoredError['reportUnmatched'] ?? $this->reportUnmatchedIgnoredErrors) @@ -385,6 +376,16 @@ public function process( if ($reportUnmatched === false) { continue; } + // asked only about an entry that would be reported; only an entry with `paths` has a part that differs from the entry + $judge ??= new UnmatchedIgnoredErrorJudge( + $this->fileHelper, + $this->analyseFileExcluder, + $analysedFiles, + $this->analysedPaths, + $this->analysedPathsFromConfig, + $collectedData, + $onlyFiles, + ); $realCount = $realCounts[$i] ?? null; if ( isset($unmatchedIgnoredError['count']) diff --git a/tests/bench/IgnoredErrorHelperBench.php b/tests/bench/IgnoredErrorHelperBench.php new file mode 100644 index 00000000000..73d2823cdcf --- /dev/null +++ b/tests/bench/IgnoredErrorHelperBench.php @@ -0,0 +1,97 @@ + */ + private array $errors = []; + + /** @var list */ + private array $files = []; + + public function __construct(private ScratchDirectory $scratch) + { + } + + /** + * @return iterable + */ + public static function ignoreErrors(): iterable + { + // entries with the identifier of the errors and several paths each, whose message does not match + yield 'paths-8' => [8, false]; + // entries with other identifiers than the errors + yield 'other-identifiers' => [1, true]; + } + + #[Before] + public function boot(int $paths, bool $otherIdentifiers): void + { + $entries = []; + for ($i = 0; $i < self::ENTRIES; $i++) { + $entryPaths = []; + for ($j = 0; $j < $paths; $j++) { + $entryPaths[] = '*/ignored' . $j . '/*'; + } + $entries[] = [ + 'message' => '#^Never reported ' . $i . '$#', + 'identifier' => $otherIdentifiers ? 'bench.other' . $i : 'bench.reported', + 'paths' => $entryPaths, + ]; + } + + $config = $this->scratch->path() . '/ignore-errors.neon'; + file_put_contents($config, Neon::encode([ + 'parameters' => [ + 'ignoreErrors' => $entries, + 'reportUnmatchedIgnoredErrors' => false, + ], + ], true)); + + $container = BenchContainer::create($this->scratch, [$config]); + $this->result = $container->getByType(IgnoredErrorHelper::class)->initialize(); + + for ($i = 0; $i < 8; $i++) { + $this->files[] = '/bench/src/File' . $i . '.php'; + } + for ($i = 0; $i < self::ERRORS; $i++) { + $file = $this->files[$i % 8]; + $this->errors[] = new Error('Reported ' . $i, $file, $i + 1, true, $file, null, null, null, null, 'bench.reported'); + } + + $this->result->process([$this->errors[0]], false, [$this->files[0]], false); + } + + /** + * Each error is matched against each ignoreErrors entry. + */ + #[DataProvider(method: 'ignoreErrors')] + public function benchProcess(int $paths, bool $otherIdentifiers): IgnoredErrorHelperProcessedResult + { + if ($this->result === null) { + throw new LogicException('boot() did not run.'); + } + + return $this->result->process($this->errors, false, $this->files, false); + } + +} From 221ce134e0d7c2da812a75cc5d30b2626d5cbb17 Mon Sep 17 00:00:00 2001 From: "ondrej@mirtes.cz" Date: Sat, 14 Nov 2026 01:19:06 +0000 Subject: [PATCH 07/18] Skip the predicate template pass and repeated class recording in reflection 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. --- .../GenericParametersAcceptorResolver.php | 42 +++-- .../MemoizingReflectionProvider.php | 19 +-- src/Type/Generic/TemplateTypeHelper.php | 5 + tests/bench/BenchFixtures.php | 23 +++ tests/bench/BetterReflectionProviderBench.php | 61 ++++++++ ...terReflectionSourceLocatorFactoryBench.php | 38 +++++ tests/bench/ClassReflectionBench.php | 148 ++++++++++++++++++ tests/bench/FileCacheStorageMissBench.php | 45 ++++++ ...GenericParametersAcceptorResolverBench.php | 83 ++++++++++ .../InitializerExprTypeResolverBench.php | 106 +++++++++++++ .../IntersectionTypeMethodReflectionBench.php | 69 ++++++++ .../MemoizingReflectionProviderBench.php | 63 ++++++++ .../bench/ParametersAcceptorSelectorBench.php | 93 +++++++++++ tests/bench/Php8SignatureMapProviderBench.php | 67 ++++++++ .../bench/PhpDocInheritanceResolverBench.php | 91 +++++++++++ tests/bench/PhpFileCleanerBench.php | 62 ++++++++ ...pFunctionFromParserNodeReflectionBench.php | 81 ++++++++++ tests/bench/PhpMethodReflectionBench.php | 49 ++++++ 18 files changed, 1119 insertions(+), 26 deletions(-) create mode 100644 tests/bench/BenchFixtures.php create mode 100644 tests/bench/BetterReflectionProviderBench.php create mode 100644 tests/bench/BetterReflectionSourceLocatorFactoryBench.php create mode 100644 tests/bench/ClassReflectionBench.php create mode 100644 tests/bench/FileCacheStorageMissBench.php create mode 100644 tests/bench/GenericParametersAcceptorResolverBench.php create mode 100644 tests/bench/InitializerExprTypeResolverBench.php create mode 100644 tests/bench/IntersectionTypeMethodReflectionBench.php create mode 100644 tests/bench/MemoizingReflectionProviderBench.php create mode 100644 tests/bench/ParametersAcceptorSelectorBench.php create mode 100644 tests/bench/Php8SignatureMapProviderBench.php create mode 100644 tests/bench/PhpDocInheritanceResolverBench.php create mode 100644 tests/bench/PhpFileCleanerBench.php create mode 100644 tests/bench/PhpFunctionFromParserNodeReflectionBench.php create mode 100644 tests/bench/PhpMethodReflectionBench.php diff --git a/src/Reflection/GenericParametersAcceptorResolver.php b/src/Reflection/GenericParametersAcceptorResolver.php index f6434612be8..231e4b3ae5a 100644 --- a/src/Reflection/GenericParametersAcceptorResolver.php +++ b/src/Reflection/GenericParametersAcceptorResolver.php @@ -69,20 +69,26 @@ public static function resolve(array $argTypes, ParametersAcceptor $parametersAc // the remaining template types are then inferred from what is left over, // e.g. in partition(iterable $values, callable(T0|T1): ($value is T0 ? true : false) $predicate) // called with iterable and is_int(...), T0 becomes int and T1 string - $predicateTypeMap = TemplateTypeMap::createEmpty(); + // null when no passed callable has a type predicate, which is almost always + $predicateTypeMap = null; foreach ($parameters as $param) { - if (!isset($namedArgTypes[$param->getName()])) { + $paramName = $param->getName(); + if (!isset($namedArgTypes[$paramName])) { continue; } - $predicateTypeMap = $predicateTypeMap->union( - self::inferPredicateTemplateTypes($param->getType(), $namedArgTypes[$param->getName()]), - ); + $paramPredicateTypeMap = self::inferPredicateTemplateTypes($param->getType(), $namedArgTypes[$paramName]); + if ($paramPredicateTypeMap === null || $paramPredicateTypeMap->isEmpty()) { + continue; + } + + $predicateTypeMap = $predicateTypeMap === null ? $paramPredicateTypeMap : $predicateTypeMap->union($paramPredicateTypeMap); } foreach ($parameters as $param) { - if (isset($namedArgTypes[$param->getName()])) { - $argType = $namedArgTypes[$param->getName()]; + $paramName = $param->getName(); + if (isset($namedArgTypes[$paramName])) { + $argType = $namedArgTypes[$paramName]; } elseif ($param->getDefaultValue() !== null) { $argType = $param->getDefaultValue(); } elseif ($param->isVariadic()) { @@ -91,12 +97,14 @@ public static function resolve(array $argTypes, ParametersAcceptor $parametersAc continue; } - $paramType = self::resolvePredicateTemplateTypes($param->getType(), $predicateTypeMap); + $paramType = $predicateTypeMap === null ? $param->getType() : self::resolvePredicateTemplateTypes($param->getType(), $predicateTypeMap); $typeMap = $typeMap->union($paramType->inferTemplateTypes($argType)); - $passedArgs['$' . $param->getName()] = $argType; + $passedArgs['$' . $paramName] = $argType; } - $typeMap = $typeMap->union($predicateTypeMap); + if ($predicateTypeMap !== null) { + $typeMap = $typeMap->union($predicateTypeMap); + } $returnType = $parametersAcceptor->getReturnType(); if ( @@ -104,7 +112,7 @@ public static function resolve(array $argTypes, ParametersAcceptor $parametersAc && !$returnType->isNegated() && array_key_exists($returnType->getParameterName(), $passedArgs) ) { - $paramType = self::resolvePredicateTemplateTypes($returnType->getTarget(), $predicateTypeMap); + $paramType = $predicateTypeMap === null ? $returnType->getTarget() : self::resolvePredicateTemplateTypes($returnType->getTarget(), $predicateTypeMap); $argType = $passedArgs[$returnType->getParameterName()]; $typeMap = $typeMap->union($paramType->inferTemplateTypes($argType)); } @@ -169,9 +177,12 @@ public static function resolve(array $argTypes, ParametersAcceptor $parametersAc return $result; } - private static function inferPredicateTemplateTypes(Type $paramType, Type $argType): TemplateTypeMap + /** + * @return TemplateTypeMap|null null when the parameter type is no callable with a type predicate + */ + private static function inferPredicateTemplateTypes(Type $paramType, Type $argType): ?TemplateTypeMap { - $typeMap = TemplateTypeMap::createEmpty(); + $typeMap = null; foreach ($paramType instanceof UnionType ? $paramType->getTypes() : [$paramType] as $innerType) { if (!$innerType instanceof CallableParametersAcceptor) { continue; @@ -182,6 +193,7 @@ private static function inferPredicateTemplateTypes(Type $paramType, Type $argTy // asked only for parameters with asserts - it reflects the class named by // a callable-like argument + $typeMap ??= TemplateTypeMap::createEmpty(); if (!$argType->isCallable()->yes()) { return $typeMap; } @@ -196,10 +208,6 @@ private static function inferPredicateTemplateTypes(Type $paramType, Type $argTy private static function resolvePredicateTemplateTypes(Type $type, TemplateTypeMap $predicateTypeMap): Type { - if ($predicateTypeMap->isEmpty()) { - return $type; - } - return TemplateTypeHelper::resolveTemplateTypes( $type, $predicateTypeMap, diff --git a/src/Reflection/ReflectionProvider/MemoizingReflectionProvider.php b/src/Reflection/ReflectionProvider/MemoizingReflectionProvider.php index 3cd4bc862dc..c35885ba08a 100644 --- a/src/Reflection/ReflectionProvider/MemoizingReflectionProvider.php +++ b/src/Reflection/ReflectionProvider/MemoizingReflectionProvider.php @@ -82,7 +82,11 @@ public function getClass(string $className): ClassReflection { $lowerClassName = strtolower($className); $classReflection = $this->classes[$lowerClassName] ??= $this->provider->getClass($className); - $this->recordFoundClass($className, $lowerClassName); + // read after the class was found: building its reflection resolves declarations in other contexts + $context = ValueDependencyCollector::getContext(); + if (($this->knownClasses[$lowerClassName] ?? null) !== $context) { + $this->recordFoundClass($className, $lowerClassName, $context); + } return $classReflection; } @@ -91,7 +95,10 @@ public function getClassName(string $className): string { $lowerClassName = strtolower($className); $name = $this->classNames[$lowerClassName] ??= $this->provider->getClassName($className); - $this->recordFoundClass($className, $lowerClassName); + $context = ValueDependencyCollector::getContext(); + if (($this->knownClasses[$lowerClassName] ?? null) !== $context) { + $this->recordFoundClass($className, $lowerClassName, $context); + } return $name; } @@ -99,14 +106,8 @@ public function getClassName(string $className): string /** * @param lowercase-string $lowerClassName */ - private function recordFoundClass(string $className, string $lowerClassName): void + private function recordFoundClass(string $className, string $lowerClassName, int $context): void { - // read after the class was found: building its reflection resolves declarations in other contexts - $context = ValueDependencyCollector::getContext(); - if (($this->knownClasses[$lowerClassName] ?? null) === $context) { - return; - } - $this->knownClasses[$lowerClassName] = $context; $this->valueDependencyCollector->recordFoundSymbol(ValueDependencyCollector::KIND_CLASS, $className); } diff --git a/src/Type/Generic/TemplateTypeHelper.php b/src/Type/Generic/TemplateTypeHelper.php index a92b77d39b5..b4ab2efa03b 100644 --- a/src/Type/Generic/TemplateTypeHelper.php +++ b/src/Type/Generic/TemplateTypeHelper.php @@ -174,6 +174,11 @@ public static function toArgument(Type $type): Type */ public static function removeFinalByKeywordOverrides(Type $type): Type { + // holds no object type, and the traversal would return it as it is + if ($type->isConstantScalarValue()->yes()) { + return $type; + } + return TypeTraverser::map($type, static function (Type $type, callable $traverse): Type { if ($type instanceof ObjectType) { $type = $type->withoutFinalByKeywordOverride(); diff --git a/tests/bench/BenchFixtures.php b/tests/bench/BenchFixtures.php new file mode 100644 index 00000000000..d27f12f4dc2 --- /dev/null +++ b/tests/bench/BenchFixtures.php @@ -0,0 +1,23 @@ +path() . '/fixtures-' . md5($code) . '.php'; + file_put_contents($file, "scratch); + $this->reflectionProvider = $container->getByType(ReflectionProvider::class); + + $file = $this->scratch->path() . '/anonymous-class.php'; + $code = "findInstanceOf((new ParserFactory())->createForHostVersion()->parse($code) ?? [], Node\Stmt\Class_::class); + $this->scope = $container->getByType(ScopeFactory::class)->create(ScopeContext::create($file)); + + $this->reflectionProvider->getAnonymousClassReflection($classes[0], $this->scope); + $this->reflectionProvider->getAnonymousClassReflection($classes[0], $this->scope); + $this->class = $classes[1]; + $this->reflectionProvider->getAnonymousClassReflection($this->class, $this->scope); + } + + /** + * Every scope that enters an anonymous class asks for its reflection again. + */ + public function benchGetAnonymousClassReflectionAgain(): ClassReflection + { + if ($this->reflectionProvider === null || $this->class === null || $this->scope === null) { + throw new LogicException('boot() did not run.'); + } + + return $this->reflectionProvider->getAnonymousClassReflection($this->class, $this->scope); + } + +} diff --git a/tests/bench/BetterReflectionSourceLocatorFactoryBench.php b/tests/bench/BetterReflectionSourceLocatorFactoryBench.php new file mode 100644 index 00000000000..a2a2f0c2c34 --- /dev/null +++ b/tests/bench/BetterReflectionSourceLocatorFactoryBench.php @@ -0,0 +1,38 @@ +factory = BenchContainer::create($this->scratch)->getByType(BetterReflectionSourceLocatorFactory::class); + } + + /** + * Every process creates the source locator when it boots, also one that never locates a class. + */ + public function benchCreate(): SourceLocator + { + if ($this->factory === null) { + throw new LogicException('boot() did not run.'); + } + + return $this->factory->create(); + } + +} diff --git a/tests/bench/ClassReflectionBench.php b/tests/bench/ClassReflectionBench.php new file mode 100644 index 00000000000..7c80762eb43 --- /dev/null +++ b/tests/bench/ClassReflectionBench.php @@ -0,0 +1,148 @@ + + */ + public static function depths(): iterable + { + foreach ([3, 6, 9] as $depth) { + yield 'depth-' . $depth => [$depth]; + } + } + + #[Before] + public function boot(): void + { + $this->reflectionProvider = BenchContainer::create($this->scratch)->getByType(ReflectionProvider::class); + BenchFixtures::declare($this->scratch, <<<'PHP' + namespace ClassReflectionBench; + + class Member + { + + /** @var list */ + public array $values = []; + + /** + * @param list $values + * @return list + */ + public function doubled(array $values): array + { + return $values; + } + + } + PHP); + $this->class = $this->reflectionProvider->getClass('ClassReflectionBench\Member'); + $scope = new OutOfClassScope(); + $this->class->getMethod('doubled', $scope); + $this->class->getProperty('values', $scope); // @phpstan-ignore method.deprecated (the method the cache early return is in) + $this->class->getName(); + } + + /** + * Asking again for a method answers from the cache filled by the first call. + */ + public function benchGetMethodAgain(): ExtendedMethodReflection + { + return $this->class()->getMethod('doubled', new OutOfClassScope()); + } + + public function benchGetPropertyAgain(): ExtendedPropertyReflection + { + // @phpstan-ignore method.deprecated (the method the cache early return is in) + return $this->class()->getProperty('values', new OutOfClassScope()); + } + + /** + * The hottest call on the class, memoized since 2.2.14. + */ + public function benchGetName(): string + { + return $this->class()->getName(); + } + + /** + * A class implementing a lattice of interfaces in which each interface extends both interfaces + * of the level below, so walking it recursively visits the lower levels exponentially often. + * + * @return array + */ + #[DataProvider(method: 'depths')] + public function benchGetInterfacesOfLattice(int $depth): array + { + $class = $this->reflectionProvider()->getClass(sprintf('ClassReflectionBench\Lattice%d\Implementation', $depth)); + + return $class->getInterfaces(); + } + + #[Before] + public function declareLattices(): void + { + foreach ([2, 3, 6, 9] as $depth) { + BenchFixtures::declare($this->scratch, self::lattice($depth)); + } + + // autoloads and warms up getInterfaces() on a lattice no case uses + $this->reflectionProvider()->getClass('ClassReflectionBench\Lattice2\Implementation')->getInterfaces(); + foreach ([3, 6, 9] as $depth) { + $this->reflectionProvider()->getClass(sprintf('ClassReflectionBench\Lattice%d\Implementation', $depth)); + } + } + + private static function lattice(int $depth): string + { + $code = sprintf("namespace ClassReflectionBench\\Lattice%d;\n\ninterface A0 {}\ninterface B0 {}\n", $depth); + for ($level = 1; $level <= $depth; $level++) { + $below = sprintf('A%d, B%d', $level - 1, $level - 1); + $code .= sprintf("interface A%d extends %s {}\ninterface B%d extends %s {}\n", $level, $below, $level, $below); + } + + return $code . sprintf("class Implementation implements %s {}\n", implode(', ', ['A' . $depth, 'B' . $depth])); + } + + private function class(): ClassReflection + { + if ($this->class === null) { + throw new LogicException('boot() did not run.'); + } + + return $this->class; + } + + private function reflectionProvider(): ReflectionProvider + { + if ($this->reflectionProvider === null) { + throw new LogicException('boot() did not run.'); + } + + return $this->reflectionProvider; + } + +} diff --git a/tests/bench/FileCacheStorageMissBench.php b/tests/bench/FileCacheStorageMissBench.php new file mode 100644 index 00000000000..60d5b27560d --- /dev/null +++ b/tests/bench/FileCacheStorageMissBench.php @@ -0,0 +1,45 @@ +storage = new FileCacheStorage($this->scratch->path() . '/cache'); + $this->storage->save('written', 'v1', 'value'); + $this->storage->load('written', 'v1'); + $this->storage->load('warmup', 'v1'); + } + + /** + * The reflection cache in front of the PHP-internal locator is asked for every class name the + * locators before it do not find, and most were never written. + */ + public function benchLoadMissing(): mixed + { + if ($this->storage === null) { + throw new LogicException('boot() did not run.'); + } + + return $this->storage->load('missing', 'v1'); + } + +} diff --git a/tests/bench/GenericParametersAcceptorResolverBench.php b/tests/bench/GenericParametersAcceptorResolverBench.php new file mode 100644 index 00000000000..3a19c399e92 --- /dev/null +++ b/tests/bench/GenericParametersAcceptorResolverBench.php @@ -0,0 +1,83 @@ + */ + private array $argTypes = []; + + public function __construct(private ScratchDirectory $scratch) + { + } + + /** + * @return iterable + */ + public static function parameters(): iterable + { + foreach ([2, 16] as $parameters) { + yield 'parameters-' . $parameters => [$parameters]; + } + } + + #[Before] + public function boot(int $parameters): void + { + BenchContainer::create($this->scratch); + [$acceptor, $argTypes] = self::call(2); + GenericParametersAcceptorResolver::resolve($argTypes, $acceptor); + + [$this->acceptor, $this->argTypes] = self::call($parameters); + } + + /** + * A variant that is not an ExtendedParametersAcceptor, such as one an extension returns, is + * resolved into an extended one with all of its parameters copied. + */ + #[DataProvider(method: 'parameters')] + public function benchResolve(int $parameters): ParametersAcceptor + { + if ($this->acceptor === null) { + throw new LogicException('boot() did not run.'); + } + + return GenericParametersAcceptorResolver::resolve($this->argTypes, $this->acceptor); + } + + /** + * @return array{ParametersAcceptor, list} function f(T $p0, T $p1, ...): T called with integers + */ + private static function call(int $parameters): array + { + $template = TemplateTypeFactory::create(TemplateTypeScope::createWithFunction('f'), 'T', null, TemplateTypeVariance::createInvariant()); + $params = []; + $argTypes = []; + for ($i = 0; $i < $parameters; $i++) { + $params[] = new NativeParameterReflection('p' . $i, false, $template, PassedByReference::createNo(), false, null); + $argTypes[] = new ConstantIntegerType($i); + } + + return [new FunctionVariant(new TemplateTypeMap(['T' => $template]), null, $params, false, $template), $argTypes]; + } + +} diff --git a/tests/bench/InitializerExprTypeResolverBench.php b/tests/bench/InitializerExprTypeResolverBench.php new file mode 100644 index 00000000000..d4de75405f7 --- /dev/null +++ b/tests/bench/InitializerExprTypeResolverBench.php @@ -0,0 +1,106 @@ + + */ + public static function items(): iterable + { + foreach ([8, 128] as $items) { + yield 'items-' . $items => [$items]; + } + } + + #[Before] + public function boot(int $items): void + { + $this->resolver = BenchContainer::create($this->scratch)->getByType(InitializerExprTypeResolver::class); + $this->resolver->getType(self::ternary(4), InitializerExprContext::createEmpty()); + $this->resolver->getType(self::unpack(4), InitializerExprContext::createEmpty()); + + $this->ternary = self::ternary($items); + $this->unpack = self::unpack($items); + } + + /** + * A ternary with both branches, `[...] ? 1 : 2`, does not need the type of its condition. + */ + #[DataProvider(method: 'items')] + public function benchTernary(int $items): Type + { + if ($this->ternary === null) { + throw new LogicException('boot() did not run.'); + } + + return $this->resolver()->getType($this->ternary, InitializerExprContext::createEmpty()); + } + + /** + * `[...['k0' => 0, 'k1' => 1, ...]]`, whose string keys are kept since PHP 8.1 + */ + #[DataProvider(method: 'items')] + public function benchUnpackStringKeys(int $items): Type + { + if ($this->unpack === null) { + throw new LogicException('boot() did not run.'); + } + + return $this->resolver()->getType($this->unpack, InitializerExprContext::createEmpty()); + } + + private function resolver(): InitializerExprTypeResolver + { + if ($this->resolver === null) { + throw new LogicException('boot() did not run.'); + } + + return $this->resolver; + } + + private static function ternary(int $items): Expr + { + return new Expr\Ternary(self::array($items, false), new Int_(1), new Int_(2)); + } + + private static function unpack(int $items): Expr + { + return new Expr\Array_([new ArrayItem(self::array($items, true), unpack: true)]); + } + + private static function array(int $items, bool $stringKeys): Expr\Array_ + { + $arrayItems = []; + for ($i = 0; $i < $items; $i++) { + $arrayItems[] = new ArrayItem(new Int_($i), $stringKeys ? new String_('k' . $i) : null); + } + + return new Expr\Array_($arrayItems); + } + +} diff --git a/tests/bench/IntersectionTypeMethodReflectionBench.php b/tests/bench/IntersectionTypeMethodReflectionBench.php new file mode 100644 index 00000000000..4fe7f1d264a --- /dev/null +++ b/tests/bench/IntersectionTypeMethodReflectionBench.php @@ -0,0 +1,69 @@ + + */ + public static function methods(): iterable + { + foreach ([2, 8] as $methods) { + yield 'methods-' . $methods => [$methods]; + } + } + + #[Before] + public function boot(int $methods): void + { + $reflectionProvider = BenchContainer::create($this->scratch)->getByType(ReflectionProvider::class); + $code = "namespace IntersectionTypeMethodReflectionBench;\n\n"; + for ($i = 0; $i < 8; $i++) { + $code .= sprintf("interface I%d\n{\n\n\t/** @return list */\n\tpublic function values(): array;\n\n}\n\n", $i); + } + BenchFixtures::declare($this->scratch, $code); + + $reflections = []; + for ($i = 0; $i < 8; $i++) { + $reflections[] = $reflectionProvider->getClass('IntersectionTypeMethodReflectionBench\I' . $i)->getNativeMethod('values'); + $reflections[$i]->getVariants(); + } + (new IntersectionTypeMethodReflection('values', [$reflections[6], $reflections[7]]))->getVariants(); + + $this->method = new IntersectionTypeMethodReflection('values', array_slice($reflections, 0, $methods)); + } + + /** + * The method of an intersection of interfaces that all declare it, such as `I0&I1`. + * + * @return list + */ + #[DataProvider(method: 'methods')] + public function benchGetVariants(int $methods): array + { + if ($this->method === null) { + throw new LogicException('boot() did not run.'); + } + + return $this->method->getVariants(); + } + +} diff --git a/tests/bench/MemoizingReflectionProviderBench.php b/tests/bench/MemoizingReflectionProviderBench.php new file mode 100644 index 00000000000..6eca10b00d3 --- /dev/null +++ b/tests/bench/MemoizingReflectionProviderBench.php @@ -0,0 +1,63 @@ +reflectionProvider = BenchContainer::create($this->scratch)->getByType(ReflectionProvider::class); + BenchFixtures::declare($this->scratch, <<<'PHP' + namespace MemoizingReflectionProviderBench; + + class Warmup {} + class Member {} + PHP); + $this->reflectionProvider->hasClass('MemoizingReflectionProviderBench\Warmup'); + $this->reflectionProvider->getClass('MemoizingReflectionProviderBench\Warmup'); + $this->reflectionProvider->hasClass('MemoizingReflectionProviderBench\Member'); + $this->reflectionProvider->getClass('MemoizingReflectionProviderBench\Member'); + } + + public function benchHasClassAgain(): bool + { + return $this->reflectionProvider()->hasClass('MemoizingReflectionProviderBench\Member'); + } + + /** + * The memo is case-insensitive since 2.1.34, like class names. + */ + public function benchHasClassInOtherCase(): bool + { + return $this->reflectionProvider()->hasClass('memoizingReflectionProviderBench\member'); + } + + public function benchGetClassAgain(): ClassReflection + { + return $this->reflectionProvider()->getClass('MemoizingReflectionProviderBench\Member'); + } + + private function reflectionProvider(): ReflectionProvider + { + if ($this->reflectionProvider === null) { + throw new LogicException('boot() did not run.'); + } + + return $this->reflectionProvider; + } + +} diff --git a/tests/bench/ParametersAcceptorSelectorBench.php b/tests/bench/ParametersAcceptorSelectorBench.php new file mode 100644 index 00000000000..7cfa7dc6647 --- /dev/null +++ b/tests/bench/ParametersAcceptorSelectorBench.php @@ -0,0 +1,93 @@ + + */ + public static function returnTypes(): iterable + { + yield 'template-first-16' => [16, true]; + yield 'template-first-256' => [256, true]; + yield 'no-template-256' => [256, false]; + } + + #[Before] + public function boot(int $items, bool $templateFirst): void + { + BenchContainer::create($this->scratch); + // private before 2.1.34, selectFromArgs() asks it for every call with a single variant + $this->hasTemplateOrLateResolvableType = (new ReflectionMethod(ParametersAcceptorSelector::class, 'hasAcceptorTemplateOrLateResolvableType'))->getClosure(); + ($this->hasTemplateOrLateResolvableType)(self::acceptor(4, true)); + ($this->hasTemplateOrLateResolvableType)(self::acceptor(4, false)); + + $this->acceptor = self::acceptor($items, $templateFirst); + } + + /** + * Whether a variant needs its template types resolved, for a function returning an array shape + * of nested shapes. The search can stop at a template type in the first item. + */ + #[DataProvider(method: 'returnTypes')] + public function benchHasAcceptorTemplateOrLateResolvableType(int $items, bool $templateFirst): bool + { + if ($this->hasTemplateOrLateResolvableType === null || $this->acceptor === null) { + throw new LogicException('boot() did not run.'); + } + + return ($this->hasTemplateOrLateResolvableType)($this->acceptor); + } + + private static function acceptor(int $items, bool $templateFirst): ParametersAcceptor + { + $keys = []; + $values = []; + for ($i = 0; $i < $items; $i++) { + $keys[] = new ConstantStringType('item' . $i); + $values[] = $i === 0 && $templateFirst + ? TemplateTypeFactory::create(TemplateTypeScope::createWithFunction('f'), 'T', null, TemplateTypeVariance::createInvariant()) + : self::shape($i); + } + + return new FunctionVariant(TemplateTypeMap::createEmpty(), null, [], false, new ConstantArrayType($keys, $values)); + } + + private static function shape(int $i): Type + { + return new ConstantArrayType( + [new ConstantStringType('id'), new ConstantStringType('name'), new ConstantStringType('position')], + [new ConstantIntegerType($i), new ConstantStringType('name' . $i), new ConstantIntegerType($i * 2)], + ); + } + +} diff --git a/tests/bench/Php8SignatureMapProviderBench.php b/tests/bench/Php8SignatureMapProviderBench.php new file mode 100644 index 00000000000..1455638ca6a --- /dev/null +++ b/tests/bench/Php8SignatureMapProviderBench.php @@ -0,0 +1,67 @@ +, list}> + */ + public static function members(): iterable + { + $constants = ['ATTR_AUTOCOMMIT', 'ATTR_CASE', 'ATTR_CLIENT_VERSION', 'ATTR_CONNECTION_STATUS', 'ATTR_CURSOR', 'ATTR_CURSOR_NAME', 'ATTR_DEFAULT_FETCH_MODE', 'ATTR_DRIVER_NAME']; + $methods = ['beginTransaction', 'commit', 'errorCode', 'errorInfo', 'exec', 'getAttribute', 'inTransaction', 'lastInsertId']; + yield 'constants-1' => [['ATTR_CASE'], []]; + yield 'constants-8' => [$constants, []]; + yield 'constants-8-methods-8' => [$constants, $methods]; + } + + #[Before] + public function boot(): void + { + $this->provider = BenchContainer::create($this->scratch)->getByType(Php8SignatureMapProvider::class); + $this->provider->hasClassConstantMetadata('ReflectionMethod', 'IS_PUBLIC'); + $this->provider->hasMethodSignature('ReflectionMethod', 'invoke'); + // reads PDO's stub, which every later question about its members is answered from + $this->provider->hasClassConstantMetadata('PDO', 'ATTR_ERRMODE'); + } + + /** + * Questions about further members of a class whose stub was read already, here PDO's. Each + * member looked for on its own used to walk the stub's AST again. + * + * @param list $constants + * @param list $methods + */ + #[DataProvider(method: 'members')] + public function benchFindMembers(array $constants, array $methods): int + { + if ($this->provider === null) { + throw new LogicException('boot() did not run.'); + } + + $found = 0; + foreach ($constants as $constant) { + $found += (int) $this->provider->hasClassConstantMetadata('PDO', $constant); + } + foreach ($methods as $method) { + $found += (int) $this->provider->hasMethodSignature('PDO', $method); + } + + return $found; + } + +} diff --git a/tests/bench/PhpDocInheritanceResolverBench.php b/tests/bench/PhpDocInheritanceResolverBench.php new file mode 100644 index 00000000000..c1f57faed91 --- /dev/null +++ b/tests/bench/PhpDocInheritanceResolverBench.php @@ -0,0 +1,91 @@ + + */ + public static function depths(): iterable + { + foreach ([4, 16] as $depth) { + yield 'depth-' . $depth => [$depth]; + } + } + + #[Before] + public function boot(int $depth): void + { + $reflectionProvider = BenchContainer::create($this->scratch)->getByType(ReflectionProvider::class); + foreach ([2, $depth] as $chain) { + BenchFixtures::declare($this->scratch, self::chain($chain)); + } + + $warmup = $reflectionProvider->getClass('PhpDocInheritanceResolverBench\Chain2\Level2'); + $warmup->getAncestors(); + $warmup->getNativeMethod('values')->getVariants()[0]->getReturnType(); + + // the class reflections of the chain, without their methods + $this->class = $reflectionProvider->getClass(sprintf('PhpDocInheritanceResolverBench\Chain%d\Level%d', $depth, $depth)); + $this->class->getAncestors(); + } + + /** + * A method overridden on every level of a class hierarchy, documented only at its root, inherits + * the PHPDoc of the root. + */ + #[DataProvider(method: 'depths')] + public function benchInheritedReturnType(int $depth): Type + { + if ($this->class === null) { + throw new LogicException('boot() did not run.'); + } + + return $this->class->getNativeMethod('values')->getVariants()[0]->getReturnType(); + } + + private static function chain(int $depth): string + { + $code = sprintf(<<<'PHP' + namespace PhpDocInheritanceResolverBench\Chain%d; + + class Level0 + { + + /** + * @param list $values + * @return list + */ + public function values(array $values): array + { + return $values; + } + + } + + PHP, $depth); + for ($level = 1; $level <= $depth; $level++) { + $code .= sprintf("class Level%d extends Level%d\n{\n\n\tpublic function values(array \$values): array\n\t{\n\t\treturn \$values;\n\t}\n\n}\n\n", $level, $level - 1); + } + + return $code; + } + +} diff --git a/tests/bench/PhpFileCleanerBench.php b/tests/bench/PhpFileCleanerBench.php new file mode 100644 index 00000000000..bfae413976e --- /dev/null +++ b/tests/bench/PhpFileCleanerBench.php @@ -0,0 +1,62 @@ + + */ + public static function files(): iterable + { + yield 'code' => [self::file(static fn (int $i): string => sprintf("\tpublic function method%d(int \$value): int\n\t{\n\t\treturn \$value + %d;\n\t}\n\n", $i, $i))]; + yield 'docblocks' => [self::file(static fn (int $i): string => sprintf("\t/**\n\t * Returns the value increased by %d.\n%s\t */\n\tpublic function method%d(int \$value): int\n\t{\n\t\treturn \$value + %d;\n\t}\n\n", $i, str_repeat("\t * Lorem ipsum dolor sit amet, consectetur adipiscing elit.\n", 4), $i, $i))]; + yield 'line-comments' => [self::file(static fn (int $i): string => sprintf("\tpublic function method%d(int \$value): int\n\t{\n%s\t\treturn \$value + %d;\n\t}\n\n", $i, str_repeat("\t\t// lorem ipsum dolor sit amet, consectetur adipiscing elit\n", 4), $i))]; + yield 'strings' => [self::file(static fn (int $i): string => sprintf("\tpublic function method%d(): string\n\t{\n\t\treturn 'lorem ipsum dolor sit amet' . \"consectetur adipiscing elit %d\";\n\t}\n\n", $i, $i))]; + } + + #[Before] + public function boot(): void + { + $this->cleaner = new PhpFileCleaner(); + $this->cleaner->clean("cleaner === null) { + throw new LogicException('boot() did not run.'); + } + + return $this->cleaner->clean($contents, 2); + } + + /** + * @param callable(int): string $method + */ + private static function file(callable $method): string + { + $code = " + */ + public static function statements(): iterable + { + foreach ([16, 256] as $statements) { + yield 'statements-' . $statements => [$statements]; + } + } + + #[Before] + public function boot(int $statements): void + { + BenchContainer::create($this->scratch); + self::reflect(4)->isGenerator(); + + $this->function = self::reflect($statements); + $this->function->isGenerator(); + } + + /** + * Whether a function is a generator, asked again for the same function, which scans the body for + * a yield unless the answer is memoized on its node. + */ + #[DataProvider(method: 'statements')] + public function benchIsGeneratorAgain(int $statements): bool + { + if ($this->function === null) { + throw new LogicException('boot() did not run.'); + } + + return $this->function->isGenerator(); + } + + /** + * A reflection of a function that is not a generator, with only the node isGenerator() reads, + * because the constructor differs between versions. + */ + private static function reflect(int $statements): PhpFunctionFromParserNodeReflection + { + $code = "createForHostVersion()->parse($code)[0] ?? null; + if (!$function instanceof Node\Stmt\Function_) { + throw new LogicException('Not parsed.'); + } + + $reflection = (new ReflectionClass(PhpFunctionFromParserNodeReflection::class))->newInstanceWithoutConstructor(); + (new ReflectionProperty(PhpFunctionFromParserNodeReflection::class, 'functionLike'))->setValue($reflection, $function); + + return $reflection; + } + +} diff --git a/tests/bench/PhpMethodReflectionBench.php b/tests/bench/PhpMethodReflectionBench.php new file mode 100644 index 00000000000..85a72a9523f --- /dev/null +++ b/tests/bench/PhpMethodReflectionBench.php @@ -0,0 +1,49 @@ +scratch)->getByType(ReflectionProvider::class); + BenchFixtures::declare($this->scratch, <<<'PHP' + namespace PhpMethodReflectionBench; + + class Member + { + + public function doubleValues(): void + { + } + + } + PHP); + $this->method = $reflectionProvider->getClass('PhpMethodReflectionBench\Member')->getNativeMethod('doubleValues'); + $this->method->getName(); + } + + public function benchGetNameAgain(): string + { + if ($this->method === null) { + throw new LogicException('boot() did not run.'); + } + + return $this->method->getName(); + } + +} From a1aea472b832f687d3ec14ecc4682d07a0cc6768 Mon Sep 17 00:00:00 2001 From: "ondrej@mirtes.cz" Date: Sat, 14 Nov 2026 01:19:20 +0000 Subject: [PATCH 08/18] Return the one array range() can return without unioning it again 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. --- .../Php/RangeFunctionReturnTypeExtension.php | 5 + tests/bench/AnalysedCode.php | 175 +++++++++++++++++ tests/bench/GenericObjectTypeCheckBench.php | 85 +++++++++ .../HashFunctionsReturnTypeExtensionBench.php | 26 +++ tests/bench/MutatingScopeImpureCallsBench.php | 55 ++++++ .../OperatorTypeSpecifyingExtensionBench.php | 94 ++++++++++ tests/bench/RegexGroupParserBench.php | 48 +++++ tests/bench/ReturnTypeExtensionBench.php | 112 +++++++++++ tests/bench/RuleBench.php | 176 ++++++++++++++++++ tests/bench/UnresolvableTypeHelperBench.php | 97 ++++++++++ tests/bench/VarTagTypeRuleHelperBench.php | 67 +++++++ 11 files changed, 940 insertions(+) create mode 100644 tests/bench/AnalysedCode.php create mode 100644 tests/bench/GenericObjectTypeCheckBench.php create mode 100644 tests/bench/HashFunctionsReturnTypeExtensionBench.php create mode 100644 tests/bench/MutatingScopeImpureCallsBench.php create mode 100644 tests/bench/OperatorTypeSpecifyingExtensionBench.php create mode 100644 tests/bench/RegexGroupParserBench.php create mode 100644 tests/bench/ReturnTypeExtensionBench.php create mode 100644 tests/bench/RuleBench.php create mode 100644 tests/bench/UnresolvableTypeHelperBench.php create mode 100644 tests/bench/VarTagTypeRuleHelperBench.php diff --git a/src/Type/Php/RangeFunctionReturnTypeExtension.php b/src/Type/Php/RangeFunctionReturnTypeExtension.php index 738005ad310..2c78439d7c2 100644 --- a/src/Type/Php/RangeFunctionReturnTypeExtension.php +++ b/src/Type/Php/RangeFunctionReturnTypeExtension.php @@ -150,6 +150,11 @@ public function getTypeFromFunctionCall(FunctionReflection $functionReflection, } if (!$hasUnknownCombination && !$hasSkippedCombination) { + if (count($constantReturnTypes) === 1) { + // the union of one array type normalizes it again, which costs more than computing it + return $constantReturnTypes[0]; + } + if (count($constantReturnTypes) > 0) { return TypeCombinator::union(...$constantReturnTypes); } diff --git a/tests/bench/AnalysedCode.php b/tests/bench/AnalysedCode.php new file mode 100644 index 00000000000..041f5d2ee7b --- /dev/null +++ b/tests/bench/AnalysedCode.php @@ -0,0 +1,175 @@ + */ + private array $nodes = []; + + private function __construct(private Container $container) + { + } + + /** + * The classes and functions the code declares are known to reflection, as in an analysed file. + */ + public static function analyse(ScratchDirectory $scratch, string $code): self + { + $file = $scratch->path() . '/analysed.php'; + file_put_contents($file, $code); + + // the root of the PHPStan that is loaded, so the benchmarks also run against other versions + $containerFactory = new ContainerFactory(dirname((string) (new ReflectionClass(ContainerFactory::class))->getFileName(), 3)); + $container = $containerFactory->create($scratch->path() . '/container', [$containerFactory->getConfigDirectory() . '/config.level8.neon'], [$file]); + + $analysed = new self($container); + + /** @var Parser $parser */ + $parser = $container->getService('currentPhpVersionRichParser'); + $nodeScopeResolver = $container->getByType(NodeScopeResolver::class); + $nodeScopeResolver->setAnalysedFiles([$file]); + $scope = $container->getByType(ScopeFactory::class)->create(ScopeContext::create($file)); + $nodeScopeResolver->processNodes($parser->parseFile($file), $scope, static function (Node $node, Scope $scope) use ($analysed): void { + // 2.1.34 to 2.2.9 give rules a FiberScope, which asks the fiber of the analysis for its types + if (is_a($scope, 'PHPStan\Analyser\Fiber\FiberScope')) { + // @phpstan-ignore class.notFound, method.deprecated (the MutatingScope a FiberScope of 2.1.34 to 2.2.9 resolves its types with) + $scope = $scope->toMutatingScope(); + } + if (!$scope instanceof MutatingScope) { + throw new LogicException('The analysis gave a ' . $scope::class . '.'); + } + $analysed->nodes[] = [$node, $scope]; + }); + + return $analysed; + } + + public function getContainer(): Container + { + return $this->container; + } + + /** + * @template T of Node + * @param class-string $nodeClass + * @return list in the order the analysis visited them, with the scopes rules get + */ + public function nodes(string $nodeClass): array + { + $nodes = []; + foreach ($this->nodes as [$node, $scope]) { + if (!$node instanceof $nodeClass) { + continue; + } + + $nodes[] = [$node, $scope]; + } + + if ($nodes === []) { + throw new LogicException('The code has no ' . $nodeClass . '.'); + } + + return $nodes; + } + + /** + * The rule or extension the container registers, or one created the way the container would, + * with what each version's constructor asks for. + * + * @template T of object + * @param class-string $class + * @param array $parameters values of constructor parameters, instead of the configured ones + * @return T + */ + public function get(string $class, array $parameters = []): object + { + if ($parameters === []) { + foreach ($this->container->getServicesByTag('phpstan.rules.rule') as $rule) { + if ($rule instanceof $class) { + return $rule; + } + } + if ($this->container->findServiceNamesByType($class) !== []) { + return $this->container->getByType($class); + } + } + + $constructor = (new ReflectionClass($class))->getConstructor(); + $arguments = []; + foreach ($constructor === null ? [] : $constructor->getParameters() as $parameter) { + $name = $parameter->getName(); + $type = $parameter->getType(); + $extensions = $parameter->getAttributes('PHPStan\DependencyInjection\AutowiredExtensions'); + $configured = $parameter->getAttributes('PHPStan\DependencyInjection\AutowiredParameter'); + if (array_key_exists($name, $parameters)) { + $arguments[] = $parameters[$name]; + } elseif ($extensions !== []) { + $arguments[] = $this->container->getExtensionsCollection($extensions[0]->newInstance()->of); + } elseif ($configured !== []) { + $arguments[] = $this->parameter($configured[0]->newInstance()->ref ?? '%' . $name . '%'); + } elseif ($type instanceof ReflectionNamedType && !$type->isBuiltin()) { + $typeName = $type->getName(); + if (is_a($this->container, $typeName)) { + $arguments[] = $this->container; + } elseif (class_exists($typeName) || interface_exists($typeName)) { + $arguments[] = $this->container->getByType($typeName); + } else { + throw new LogicException('Unknown type ' . $typeName . ' of the parameter $' . $name . ' of ' . $class . '.'); + } + } elseif ($parameter->isDefaultValueAvailable()) { + $arguments[] = $parameter->getDefaultValue(); + } elseif ($this->container->hasParameter($name)) { + $arguments[] = $this->container->getParameter($name); + } else { + throw new LogicException('Pass the parameter $' . $name . ' of ' . $class . '.'); + } + } + + return new $class(...$arguments); + } + + private function parameter(string $ref): mixed + { + if (str_starts_with($ref, '@')) { + return $this->container->getService(substr($ref, 1)); + } + + $value = $this->container->getParameters(); + foreach (explode('.', trim($ref, '%')) as $key) { + $value = $value[$key]; + } + + return $value; + } + +} diff --git a/tests/bench/GenericObjectTypeCheckBench.php b/tests/bench/GenericObjectTypeCheckBench.php new file mode 100644 index 00000000000..a4a1e76c6f8 --- /dev/null +++ b/tests/bench/GenericObjectTypeCheckBench.php @@ -0,0 +1,85 @@ + + */ + public static function keys(): iterable + { + foreach ([16, 64] as $keys) { + yield 'keys-' . $keys => [$keys]; + } + } + + #[Before] + public function boot(int $keys): void + { + $this->check = BenchContainer::create($this->scratch)->getByType(GenericObjectTypeCheck::class); + $this->check(self::shape(2)); + $this->type = self::shape($keys); + } + + /** + * A PHPDoc type that references no class cannot contain a generic object type. + * + * @return list + */ + #[DataProvider(method: 'keys')] + public function benchCheckTypeWithoutClasses(int $keys): array + { + if ($this->type === null) { + throw new LogicException('boot() did not run.'); + } + + return $this->check($this->type); + } + + /** + * @return list + */ + private function check(Type $type): array + { + if ($this->check === null) { + throw new LogicException('boot() did not run.'); + } + + return $this->check->check($type, 'a', 'b', 'c', 'd', 'e', 'f'); + } + + private static function shape(int $keys): ConstantArrayType + { + $keyTypes = []; + $valueTypes = []; + for ($i = 0; $i < $keys; $i++) { + $keyTypes[] = new ConstantStringType('key' . $i); + $valueTypes[] = new UnionType([new IntegerType(), new StringType()]); + } + + return new ConstantArrayType($keyTypes, $valueTypes); + } + +} diff --git a/tests/bench/HashFunctionsReturnTypeExtensionBench.php b/tests/bench/HashFunctionsReturnTypeExtensionBench.php new file mode 100644 index 00000000000..3df7e2f8cca --- /dev/null +++ b/tests/bench/HashFunctionsReturnTypeExtensionBench.php @@ -0,0 +1,26 @@ + */ + private string $class = HashFunctionsReturnTypeExtension::class; + + /** + * The container creates the extension for every analysis, also of code that calls no hash function. + * Since 2.3.0 its constructor takes no PHP version, PHP ignores the argument there. + */ + public function benchCreate(): HashFunctionsReturnTypeExtension + { + // @phpstan-ignore new.noConstructor (2.2.13 to 2.2.17 take the PHP version) + return new $this->class(new PhpVersion(80400)); + } + +} diff --git a/tests/bench/MutatingScopeImpureCallsBench.php b/tests/bench/MutatingScopeImpureCallsBench.php new file mode 100644 index 00000000000..c535ab87e27 --- /dev/null +++ b/tests/bench/MutatingScopeImpureCallsBench.php @@ -0,0 +1,55 @@ +value(); self::create(); helper(); ', 16); + $analysed = AnalysedCode::analyse( + $this->scratch, + 'nodes(Return_::class); + $scope = $returns[count($returns) - 2][1]; + $otherScope = $returns[count($returns) - 1][1]; + $this->scope = $scope; + $this->otherScope = $otherScope; + $scope->mergeWith($scope); + } + + /** + * Merging two branches after calls to user functions and methods that are possibly impure. + */ + public function benchMergeWithAfterPossiblyImpureCalls(): MutatingScope + { + if ($this->scope === null || $this->otherScope === null) { + throw new LogicException('boot() did not run.'); + } + + return $this->scope->mergeWith($this->otherScope); + } + +} diff --git a/tests/bench/OperatorTypeSpecifyingExtensionBench.php b/tests/bench/OperatorTypeSpecifyingExtensionBench.php new file mode 100644 index 00000000000..f1a4eced234 --- /dev/null +++ b/tests/bench/OperatorTypeSpecifyingExtensionBench.php @@ -0,0 +1,94 @@ + */ + private array $operands = []; + + public function __construct(private ScratchDirectory $scratch) + { + } + + /** + * @return iterable, string}> + */ + public static function extensions(): iterable + { + foreach (['gmp' => GmpOperatorTypeSpecifyingExtension::class, 'bcmath' => BcMathNumberOperatorTypeSpecifyingExtension::class] as $name => $extension) { + // most operations in the code have no operand of the extension's class + yield $name . '-integers' => [$extension, 'integers']; + yield $name . '-objects' => [$extension, 'objects']; + } + } + + /** + * @param class-string $extension + */ + #[Before] + public function boot(string $extension, string $operands): void + { + BenchContainer::create($this->scratch); + $this->extension = $extension === BcMathNumberOperatorTypeSpecifyingExtension::class + ? new BcMathNumberOperatorTypeSpecifyingExtension(new PhpVersion(80400)) + : new $extension(); + $class = $extension === BcMathNumberOperatorTypeSpecifyingExtension::class ? 'BcMath\Number' : 'GMP'; + // reflecting the class from its stub happens once per analysis + $this->extension->isOperatorSupported('+', new IntegerType(), new StringType()); + $this->extension->isOperatorSupported('+', new ObjectType($class), new IntegerType()); + $this->extension->specifyType('+', new ObjectType($class), new IntegerType()); + + for ($i = 0; $i < self::OPERATIONS; $i++) { + $this->operands[] = $operands === 'integers' + ? [new ConstantIntegerType($i), new IntegerType()] + : [new ObjectType($class), new ConstantIntegerType($i)]; + } + } + + /** + * @param class-string $extension + */ + #[DataProvider(method: 'extensions')] + public function benchSpecify(string $extension, string $operands): int + { + if ($this->extension === null) { + throw new LogicException('boot() did not run.'); + } + + $specified = 0; + foreach ($this->operands as [$left, $right]) { + if (!$this->extension->isOperatorSupported('+', $left, $right)) { + continue; + } + + $this->extension->specifyType('+', $left, $right); + $specified++; + } + + return $specified; + } + +} diff --git a/tests/bench/RegexGroupParserBench.php b/tests/bench/RegexGroupParserBench.php new file mode 100644 index 00000000000..d9775e6d8ba --- /dev/null +++ b/tests/bench/RegexGroupParserBench.php @@ -0,0 +1,48 @@ + + */ + public static function patterns(): iterable + { + yield 'date' => ['/^(?\d{4})-(?\d{2})-(?\d{2})(?:T(\d{2}):(\d{2})(?::(\d{2}))?)?$/']; + // a pattern with a modifier the parser does not support is not checked again either + yield 'unsupported-modifier' => ['/^(?\w+)\s+(\d+)$/J']; + } + + #[Before] + public function boot(string $pattern): void + { + $this->parser = BenchContainer::create($this->scratch)->getByType(RegexGroupParser::class); + // preg_match() and the other functions of the analysed code ask for the same pattern again + $this->parser->parseGroups($pattern); + } + + #[DataProvider(method: 'patterns')] + public function benchParseGroupsAgain(string $pattern): bool + { + if ($this->parser === null) { + throw new LogicException('boot() did not run.'); + } + + return $this->parser->parseGroups($pattern) !== null; + } + +} diff --git a/tests/bench/ReturnTypeExtensionBench.php b/tests/bench/ReturnTypeExtensionBench.php new file mode 100644 index 00000000000..68b0bfd9b82 --- /dev/null +++ b/tests/bench/ReturnTypeExtensionBench.php @@ -0,0 +1,112 @@ + */ + private array $calls = []; + + public function __construct(private ScratchDirectory $scratch) + { + } + + /** + * @return iterable, string}> + */ + public static function extensions(): iterable + { + // the extensions read the arguments of the call once + yield 'array-chunk' => [ArrayChunkFunctionReturnTypeExtension::class, self::functionBody('array $a', 'array_chunk($a, 2, true);')]; + yield 'array-column' => [ArrayColumnFunctionReturnTypeExtension::class, self::functionBody('array $a', 'array_column($a, \'id\', \'key\');')]; + + // a subject that is not mixed does not need the return type of the selected variant + yield 'str-replace-string-subject' => [ReplaceFunctionsDynamicReturnTypeExtension::class, self::functionBody('string $s', 'str_replace(\'a\', \'b\', $s);')]; + yield 'preg-replace-string-subject' => [ReplaceFunctionsDynamicReturnTypeExtension::class, self::functionBody('string $s', 'preg_replace(\'/a/\', \'b\', $s);')]; + + // a format that is not a constant string is not a union of the types of its constant strings + yield 'date-non-constant-format' => [DateFunctionReturnTypeExtension::class, self::functionBody('string $format', 'date($format);')]; + + // the length of a range longer than the array count limit is computed instead of creating the array + yield 'range-100000' => [RangeFunctionReturnTypeExtension::class, self::functionBody('', 'range(0, 100000);')]; + } + + /** + * @param class-string $extension + */ + #[Before] + public function boot(string $extension, string $code): void + { + $analysed = AnalysedCode::analyse($this->scratch, $code); + $this->extension = $analysed->get($extension); + $reflectionProvider = $analysed->getContainer()->getByType(ReflectionProvider::class); + foreach ($analysed->nodes(FuncCall::class) as [$call, $scope]) { + if (!$call->name instanceof Name) { + continue; + } + + $function = $reflectionProvider->getFunction($call->name, $scope); + if (!$this->extension->isFunctionSupported($function)) { + continue; + } + + $this->calls[] = [$function, $call, $scope]; + } + + if ($this->calls === []) { + throw new LogicException(sprintf('The code calls no function %s supports.', $extension)); + } + } + + /** + * @param class-string $extension + * @return list + */ + #[DataProvider(method: 'extensions')] + public function benchGetTypeFromFunctionCall(string $extension, string $code): array + { + if ($this->extension === null) { + throw new LogicException('boot() did not run.'); + } + + $types = []; + foreach ($this->calls as [$function, $call, $scope]) { + $types[] = $this->extension->getTypeFromFunctionCall($function, $call, $scope); + } + + return $types; + } + + private static function functionBody(string $parameters, string $statement): string + { + return sprintf('|null */ + private ?Rule $rule = null; + + /** @var list */ + private array $nodes = []; + + public function __construct(private ScratchDirectory $scratch) + { + } + + /** + * @return iterable the rule, the code, and the PHP version + */ + public static function rules(): iterable + { + // the rule returns early for constructors before it asks for the PHP version + yield 'final-private-method-constructors' => [FinalPrivateMethodRule::class, self::classes(static fn (int $i): string => sprintf('class Foo%d { public function __construct() {} }', $i)), null]; + + // a method name that is not an identifier ends the check before the PHP version is asked whether it supports #[NoDiscard] + yield 'call-to-method-statement-with-no-discard-dynamic-name' => [CallToMethodStatementWithNoDiscardRule::class, self::functionBody('\DateTime $date, string $name', '$date->$name();'), 80500]; + + yield 'call-to-static-method-statement-with-no-discard-dynamic-name' => [CallToStaticMethodStatementWithNoDiscardRule::class, self::functionBody('string $name', '\\DateTime::$name();'), 80500]; + + // a call without arguments cannot dump a type, so the rules do not resolve the function name + yield 'dump-type-no-arguments' => [DumpTypeRule::class, self::functionBody('', 'time();'), null]; + yield 'dump-native-type-no-arguments' => [DumpNativeTypeRule::class, self::functionBody('', 'time();'), null]; + yield 'dump-phpdoc-type-no-arguments' => [DumpPhpDocTypeRule::class, self::functionBody('', 'time();'), null]; + // the type assertions have two arguments + yield 'file-assert-one-argument' => [FileAssertRule::class, self::functionBody('string $s', 'strlen($s);'), null]; + + // in a final class, static:: is the class itself, so the rule does not look up the method + yield 'call-private-method-through-static-final-class' => [CallPrivateMethodThroughStaticRule::class, ' [RestrictedMethodUsageRule::class, self::functionBody('\DateTime $date', '$date->format(\'Y\');'), null]; + yield 'restricted-function-usage' => [RestrictedFunctionUsageRule::class, self::functionBody('string $s', 'strlen($s);'), null]; + yield 'instantiation-restricted-constructor' => [InstantiationRule::class, ' [OldPhpParser4ClassRule::class, self::functionBody('', '$a = PHP_EOL;'), null]; + + yield 'node-connecting-visitor-attributes-no-arguments' => [NodeConnectingVisitorAttributesRule::class, self::functionBody('\\SplObjectStorage $s', '$s->getAttribute();'), null]; + + // the classes and functions of the analysed code are grouped by name once, not searched for each declaration + yield 'duplicate-class-declaration' => [DuplicateClassDeclarationRule::class, self::classes(static fn (int $i): string => sprintf('class Foo%d {}', $i)), null]; + yield 'duplicate-function-declaration' => [DuplicateFunctionDeclarationRule::class, ' sprintf('function foo%d(): void {}', $i), range(1, 64))), null]; + + // the return type of the type-checking function is not resolved again + yield 'impossible-check-type-function-call' => [ImpossibleCheckTypeFunctionCallRule::class, self::functionBody('int|string $v', 'if (is_int($v)) {}'), null]; + + // only the values under keys both arrays have are compared as strings + yield 'parameter-castable-to-string-diff-assoc' => [ParameterCastableToStringRule::class, self::functionBody('', 'array_diff_assoc([' . self::keys('a', 'new \stdClass()') . '], [' . self::keys('b', '1') . ']);'), null]; + + // a parameter or return type without late-resolvable types cannot contain a conditional type + yield 'function-conditional-return-type-without-conditional-types' => [FunctionConditionalReturnTypeRule::class, ' [CallToFunctionParametersRule::class, 'scratch, $code); + $instance = $analysed->get($rule, $phpVersion === null ? [] : ['phpVersion' => new PhpVersion($phpVersion)]); + if (!$instance instanceof Rule) { + throw new LogicException($rule . ' is not a rule.'); + } + $this->rule = $instance; + $this->nodes = $analysed->nodes($instance->getNodeType()); + } + + /** + * @param class-string $rule + */ + #[DataProvider(method: 'rules')] + public function benchProcessNode(string $rule, string $code, ?int $phpVersion): int + { + if ($this->rule === null) { + throw new LogicException('boot() did not run.'); + } + + $errors = 0; + foreach ($this->nodes as [$node, $scope]) { + $errors += count($this->rule->processNode($node, $scope)); + } + + return $errors; + } + + /** + * @param callable(int): string $class + */ + private static function classes(callable $class): string + { + return ' sprintf("'%s%d' => %s", $prefix, $i, $value), range(1, 16))); + } + + /** + * Functions whose four parameters and return type are array shapes of 16 keys. + */ + private static function shapeFunctions(): string + { + $shape = 'array{' . implode(', ', array_map(static fn (int $i): string => sprintf('k%d: int|string', $i), range(1, 16))) . '}'; + $functions = []; + for ($i = 0; $i < 16; $i++) { + $functions[] = sprintf( + "/**\n * @param %1\$s \$a\n * @param %1\$s \$b\n * @param %1\$s \$c\n * @param %1\$s \$d\n * @return %1\$s\n */\nfunction shape%2\$d(array \$a, array \$b, array \$c, array \$d): array { return \$a; }", + $shape, + $i, + ); + } + + return implode(' ', $functions); + } + +} diff --git a/tests/bench/UnresolvableTypeHelperBench.php b/tests/bench/UnresolvableTypeHelperBench.php new file mode 100644 index 00000000000..97f44da7f2d --- /dev/null +++ b/tests/bench/UnresolvableTypeHelperBench.php @@ -0,0 +1,97 @@ + + */ + public static function keys(): iterable + { + foreach ([16, 64] as $keys) { + yield 'keys-' . $keys => [$keys]; + } + } + + #[Before] + public function boot(int $keys): void + { + $helper = BenchContainer::create($this->scratch)->getByType(UnresolvableTypeHelper::class); + $class = new ReflectionClass($helper); + $method = $class->hasMethod('getUnresolvableType') ? 'getUnresolvableType' : 'containsUnresolvableType'; + $this->find = $class->getMethod($method)->getClosure($helper); + $this->find(self::shape(2)); + $this->type = self::shape($keys); + } + + /** + * An array shape whose first value is unresolvable and whose other values are array shapes: + * once the first value is found, the rest of the type is not traversed. + */ + #[DataProvider(method: 'keys')] + public function benchUnresolvableFirst(int $keys): bool + { + if ($this->type === null) { + throw new LogicException('boot() did not run.'); + } + + return $this->find($this->type); + } + + private function find(Type $type): bool + { + if ($this->find === null) { + throw new LogicException('boot() did not run.'); + } + + $result = ($this->find)($type); + + return $result !== null && $result !== false; + } + + private static function shape(int $keys): ConstantArrayType + { + $innerKeys = []; + $innerValues = []; + for ($i = 0; $i < 16; $i++) { + $innerKeys[] = new ConstantStringType('inner' . $i); + $innerValues[] = new UnionType([new IntegerType(), new StringType()]); + } + + $outerKeys = []; + $outerValues = []; + for ($i = 0; $i < $keys; $i++) { + $outerKeys[] = new ConstantStringType('key' . $i); + $outerValues[] = $i === 0 ? new ErrorType() : new ConstantArrayType($innerKeys, $innerValues); + } + + return new ConstantArrayType($outerKeys, $outerValues); + } + +} diff --git a/tests/bench/VarTagTypeRuleHelperBench.php b/tests/bench/VarTagTypeRuleHelperBench.php new file mode 100644 index 00000000000..a4ccd635cdd --- /dev/null +++ b/tests/bench/VarTagTypeRuleHelperBench.php @@ -0,0 +1,67 @@ + */ + private array $assigned = []; + + public function __construct(private ScratchDirectory $scratch) + { + } + + #[Before] + public function boot(): void + { + $analysed = AnalysedCode::analyse( + $this->scratch, + ' $b */ function test($b): void { ' . str_repeat('/** @var array $a */ $a = $b; ', 16) . '}', + ); + $this->helper = $analysed->getContainer()->getByType(VarTagTypeRuleHelper::class); + $this->varTagType = new ArrayType(new StringType(), new UnionType([new IntegerType(), new StringType()])); + foreach ($analysed->nodes(Expr\Assign::class) as [$assign, $scope]) { + $this->assigned[] = [$assign->expr, $scope]; + } + } + + /** + * Without reportWrongPhpDocTypeInVarTag, only the native type of the expression is checked + * against a @var tag that is not a PHPStan type. + */ + public function benchCheckExprTypeWithoutPhpDocCheck(): int + { + if ($this->helper === null || $this->varTagType === null) { + throw new LogicException('boot() did not run.'); + } + + $errors = 0; + foreach ($this->assigned as [$expr, $scope]) { + $errors += count($this->helper->checkExprType($scope, $expr, $this->varTagType)); + } + + return $errors; + } + +} From 571e468e1bf0913845dc22138bf36284e8e85fa2 Mon Sep 17 00:00:00 2001 From: "ondrej@mirtes.cz" Date: Sat, 14 Nov 2026 01:19:20 +0000 Subject: [PATCH 09/18] Intersect with object in restricted usage rules only when the type is 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. --- .../RestrictedUsage/RestrictedMethodCallableUsageRule.php | 4 +++- src/Rules/RestrictedUsage/RestrictedMethodUsageRule.php | 4 +++- src/Rules/RestrictedUsage/RestrictedPropertyUsageRule.php | 4 +++- .../RestrictedUsageOfDeprecatedStringCastRule.php | 4 +++- 4 files changed, 12 insertions(+), 4 deletions(-) diff --git a/src/Rules/RestrictedUsage/RestrictedMethodCallableUsageRule.php b/src/Rules/RestrictedUsage/RestrictedMethodCallableUsageRule.php index 0356056da59..f0e8eb8cf5b 100644 --- a/src/Rules/RestrictedUsage/RestrictedMethodCallableUsageRule.php +++ b/src/Rules/RestrictedUsage/RestrictedMethodCallableUsageRule.php @@ -54,7 +54,9 @@ public function processNode(Node $node, Scope $scope): array $methodName = $node->getName()->name; $methodCalledOnType = $scope->getType($node->getVar()); - $referencedClasses = TypeCombinator::intersect($methodCalledOnType, new ObjectWithoutClassType())->getObjectClassNames(); + // the intersection only removes what is not an object, such as the null of a nullable type + $objectType = $methodCalledOnType->isObject()->yes() ? $methodCalledOnType : TypeCombinator::intersect($methodCalledOnType, new ObjectWithoutClassType()); + $referencedClasses = $objectType->getObjectClassNames(); $errors = []; diff --git a/src/Rules/RestrictedUsage/RestrictedMethodUsageRule.php b/src/Rules/RestrictedUsage/RestrictedMethodUsageRule.php index a0a200285b1..9581f5cc600 100644 --- a/src/Rules/RestrictedUsage/RestrictedMethodUsageRule.php +++ b/src/Rules/RestrictedUsage/RestrictedMethodUsageRule.php @@ -54,7 +54,9 @@ public function processNode(Node $node, Scope $scope): array $methodName = $node->name->name; $methodCalledOnType = $scope->getType($node->var); - $referencedClasses = TypeCombinator::intersect($methodCalledOnType, new ObjectWithoutClassType())->getObjectClassNames(); + // the intersection only removes what is not an object, such as the null of a nullable type + $objectType = $methodCalledOnType->isObject()->yes() ? $methodCalledOnType : TypeCombinator::intersect($methodCalledOnType, new ObjectWithoutClassType()); + $referencedClasses = $objectType->getObjectClassNames(); $errors = []; diff --git a/src/Rules/RestrictedUsage/RestrictedPropertyUsageRule.php b/src/Rules/RestrictedUsage/RestrictedPropertyUsageRule.php index dfc28e19836..b50768a5d95 100644 --- a/src/Rules/RestrictedUsage/RestrictedPropertyUsageRule.php +++ b/src/Rules/RestrictedUsage/RestrictedPropertyUsageRule.php @@ -53,7 +53,9 @@ public function processNode(Node $node, Scope $scope): array $propertyName = $node->name->name; $propertyCalledOnType = $scope->getType($node->var); - $referencedClasses = TypeCombinator::intersect($propertyCalledOnType, new ObjectWithoutClassType())->getObjectClassNames(); + // the intersection only removes what is not an object, such as the null of a nullable type + $objectType = $propertyCalledOnType->isObject()->yes() ? $propertyCalledOnType : TypeCombinator::intersect($propertyCalledOnType, new ObjectWithoutClassType()); + $referencedClasses = $objectType->getObjectClassNames(); $errors = []; diff --git a/src/Rules/RestrictedUsage/RestrictedUsageOfDeprecatedStringCastRule.php b/src/Rules/RestrictedUsage/RestrictedUsageOfDeprecatedStringCastRule.php index 289e4e622ea..1e306cc5e8d 100644 --- a/src/Rules/RestrictedUsage/RestrictedUsageOfDeprecatedStringCastRule.php +++ b/src/Rules/RestrictedUsage/RestrictedUsageOfDeprecatedStringCastRule.php @@ -45,7 +45,9 @@ public function processNode(Node $node, Scope $scope): array } $exprType = $scope->getType($node->expr); - $referencedClasses = TypeCombinator::intersect($exprType, new ObjectWithoutClassType())->getObjectClassNames(); + // the intersection only removes what is not an object, such as the null of a nullable type + $objectType = $exprType->isObject()->yes() ? $exprType : TypeCombinator::intersect($exprType, new ObjectWithoutClassType()); + $referencedClasses = $objectType->getObjectClassNames(); $errors = []; From e56ea96f71b0527bded6f40f98e9fbc197e9643b Mon Sep 17 00:00:00 2001 From: "ondrej@mirtes.cz" Date: Sat, 14 Nov 2026 01:24:36 +0000 Subject: [PATCH 10/18] Restore the type system performance of earlier releases 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. --- build/phpstan.neon | 4 + src/Type/Accessory/HasMethodType.php | 6 + src/Type/Constant/ConstantArrayType.php | 7 + src/Type/Constant/ConstantIntegerType.php | 7 +- src/Type/Constant/ConstantStringType.php | 33 ++- src/Type/IntersectionType.php | 31 +- src/Type/ObjectType.php | 17 +- src/Type/TypeCombinator.php | 50 +++- src/Type/UnionType.php | 18 +- tests/bench/AcceptsResultBench.php | 100 +++++++ tests/bench/BenchEnum.php | 268 ++++++++++++++++++ tests/bench/ConstantArrayTypeBuilderBench.php | 56 ++++ tests/bench/ConstantArrayTypePhp74Bench.php | 79 ++++++ tests/bench/ConstantStringTypeBench.php | 76 +++++ tests/bench/EnumTypesBench.php | 125 ++++++++ tests/bench/IntersectionTypeBench.php | 121 ++++++++ tests/bench/IsSuperTypeOfResultBench.php | 156 ++++++++++ tests/bench/ObjectTypeBench.php | 53 ++++ tests/bench/TemplateTypeBench.php | 128 +++++++++ tests/bench/TemplateTypeHelperBench.php | 80 ++++++ tests/bench/TypeCombinatorBench.php | 98 +++++++ tests/bench/UnionTypeBench.php | 65 +++++ tests/bench/VerbosityLevelBench.php | 79 ++++++ turbo-ext/src/ConstantArrayType.cpp | 12 + turbo-ext/src/EnumCaseObjectType.cpp | 5 +- turbo-ext/src/GenericObjectType.cpp | 9 +- turbo-ext/src/HasMethodType.cpp | 8 + turbo-ext/src/ObjectType.cpp | 11 +- turbo-ext/src/TypeCombinator.cpp | 14 +- turbo-ext/src/UnionType.cpp | 25 +- turbo-ext/src/generated/EnumCaseObjectType.h | 2 +- turbo-ext/src/generated/GenericObjectType.h | 6 +- turbo-ext/src/generated/ObjectType.h | 8 +- .../src/generated/TemplateGenericObjectType.h | 12 +- turbo-ext/src/generated/TemplateObjectType.h | 12 +- 35 files changed, 1679 insertions(+), 102 deletions(-) create mode 100644 tests/bench/AcceptsResultBench.php create mode 100644 tests/bench/BenchEnum.php create mode 100644 tests/bench/ConstantArrayTypePhp74Bench.php create mode 100644 tests/bench/ConstantStringTypeBench.php create mode 100644 tests/bench/EnumTypesBench.php create mode 100644 tests/bench/IntersectionTypeBench.php create mode 100644 tests/bench/IsSuperTypeOfResultBench.php create mode 100644 tests/bench/ObjectTypeBench.php create mode 100644 tests/bench/TemplateTypeBench.php create mode 100644 tests/bench/TemplateTypeHelperBench.php create mode 100644 tests/bench/VerbosityLevelBench.php diff --git a/build/phpstan.neon b/build/phpstan.neon index 2cc53976b0e..88ebf3b13ed 100644 --- a/build/phpstan.neon +++ b/build/phpstan.neon @@ -114,6 +114,10 @@ parameters: identifier: shipmonk.deadMethod path: ../tests/bench/*Bench.php reportUnmatched: false + - + # the benchmarks name the cases by string, C0 to C255 + identifier: shipmonk.deadEnumCase + path: ../tests/bench/BenchEnum.php - identifier: shipmonk.deadConstant paths: diff --git a/src/Type/Accessory/HasMethodType.php b/src/Type/Accessory/HasMethodType.php index ffae597200d..1e65e7c5a80 100644 --- a/src/Type/Accessory/HasMethodType.php +++ b/src/Type/Accessory/HasMethodType.php @@ -43,6 +43,7 @@ use PHPStan\Type\TypeCombinator; use PHPStan\Type\UnionType; use PHPStan\Type\VerbosityLevel; +use function get_class; use function sprintf; use function strtolower; @@ -105,6 +106,11 @@ public function accepts(Type $type, bool $strictTypes): AcceptsResult public function isSuperTypeOf(Type $type): IsSuperTypeOfResult { + // what $type->isSubTypeOf($this) answers for another HasMethodType, without its callable checks + if (get_class($type) === self::class) { + return IsSuperTypeOfResult::createFromTrinaryLogic($type->hasMethod($this->methodName)); + } + if ($type instanceof CompoundType) { return $type->isSubTypeOf($this); } diff --git a/src/Type/Constant/ConstantArrayType.php b/src/Type/Constant/ConstantArrayType.php index 461954fec6e..ff6921c69ee 100644 --- a/src/Type/Constant/ConstantArrayType.php +++ b/src/Type/Constant/ConstantArrayType.php @@ -1047,6 +1047,13 @@ public function equals(Type $type): bool public function isCallable(): TrinaryLogic { + // the shapes doFindTypeAndMethodNames() rejects by their keys alone, without + // describing the whole shape for the recursion guard + $keyTypesCount = count($this->keyTypes); + if ($keyTypesCount > 2 || ($keyTypesCount !== 2 && !$this->isUnsealed()->yes())) { + return TrinaryLogic::createNo(); + } + $result = RecursionGuard::run($this, function (): TrinaryLogic { $hasNonExistentMethod = false; $typeAndMethods = $this->doFindTypeAndMethodNames($hasNonExistentMethod); diff --git a/src/Type/Constant/ConstantIntegerType.php b/src/Type/Constant/ConstantIntegerType.php index b9230aa36ca..60dd03378c4 100644 --- a/src/Type/Constant/ConstantIntegerType.php +++ b/src/Type/Constant/ConstantIntegerType.php @@ -19,7 +19,6 @@ use PHPStan\Type\TypeCombinator; use PHPStan\Type\VerbosityLevel; use function abs; -use function sprintf; use const PHP_INT_MIN; /** @api */ @@ -76,10 +75,8 @@ public function isSuperTypeOf(Type $type): IsSuperTypeOfResult public function describe(VerbosityLevel $level): string { - return $level->handle( - static fn (): string => 'int', - fn (): string => sprintf('%s', $this->value), - ); + // what VerbosityLevel::handle() would pick, without creating its callbacks + return $level->isTypeOnly() ? 'int' : (string) $this->value; } public function toFloat(): Type diff --git a/src/Type/Constant/ConstantStringType.php b/src/Type/Constant/ConstantStringType.php index 3152bf39d5a..6f1ab225393 100644 --- a/src/Type/Constant/ConstantStringType.php +++ b/src/Type/Constant/ConstantStringType.php @@ -131,23 +131,26 @@ public function describe(VerbosityLevel $level): string return $this->cachedDescriptions[$levelValue]; } - return $level->handle( - static fn (): string => 'string', - function () use ($levelValue): string { - $value = $this->value; - - if (!$this->isClassString) { - try { - $value = Strings::truncate($value, self::DESCRIBE_LIMIT); - } catch (RegexpException) { - $value = substr($value, 0, self::DESCRIBE_LIMIT) . "\u{2026}"; - } + // what VerbosityLevel::handle() would pick, without creating its callbacks + if ($level->isTypeOnly()) { + return 'string'; + } + + if ($level->isValue()) { + $value = $this->value; + + if (!$this->isClassString) { + try { + $value = Strings::truncate($value, self::DESCRIBE_LIMIT); + } catch (RegexpException) { + $value = substr($value, 0, self::DESCRIBE_LIMIT) . "\u{2026}"; } + } + + return $this->cachedDescriptions[$levelValue] = self::export($value); + } - return $this->cachedDescriptions[$levelValue] = self::export($value); - }, - fn (): string => $this->cachedDescriptions[$levelValue] = self::export($this->value), - ); + return $this->cachedDescriptions[$levelValue] = self::export($this->value); } private function export(string $value): string diff --git a/src/Type/IntersectionType.php b/src/Type/IntersectionType.php index 2b17042ebc3..302ea307323 100644 --- a/src/Type/IntersectionType.php +++ b/src/Type/IntersectionType.php @@ -869,14 +869,12 @@ public function isIterable(): TrinaryLogic public function isIterableAtLeastOnce(): TrinaryLogic { - if ($this->isCallable()->yes() && $this->isArray()->yes()) { - return TrinaryLogic::createYes(); - } - - return $this->isIterableAtLeastOnce ??= $this->intersectResults( - static fn (Type $type): TrinaryLogic => $type->isIterableAtLeastOnce(), - static fn (Type $type): bool => !$type->isIterable()->no(), - ); + return $this->isIterableAtLeastOnce ??= $this->isCallable()->yes() && $this->isArray()->yes() + ? TrinaryLogic::createYes() + : $this->intersectResults( + static fn (Type $type): TrinaryLogic => $type->isIterableAtLeastOnce(), + static fn (Type $type): bool => !$type->isIterable()->no(), + ); } public function getArraySize(): Type @@ -957,10 +955,9 @@ public function isArray(): TrinaryLogic public function isConstantArray(): TrinaryLogic { - if ($this->isCallable()->yes() && $this->isArray()->yes()) { - return TrinaryLogic::createYes(); - } - return $this->isConstantArray ??= $this->intersectResults(static fn (Type $type): TrinaryLogic => $type->isConstantArray()); + return $this->isConstantArray ??= $this->isCallable()->yes() && $this->isArray()->yes() + ? TrinaryLogic::createYes() + : $this->intersectResults(static fn (Type $type): TrinaryLogic => $type->isConstantArray()); } public function isOversizedArray(): TrinaryLogic @@ -970,11 +967,9 @@ public function isOversizedArray(): TrinaryLogic public function isList(): TrinaryLogic { - if ($this->isCallable()->yes() && $this->isArray()->yes()) { - return TrinaryLogic::createYes(); - } - - return $this->isList ??= $this->intersectResults(static fn (Type $type): TrinaryLogic => $type->isList()); + return $this->isList ??= $this->isCallable()->yes() && $this->isArray()->yes() + ? TrinaryLogic::createYes() + : $this->intersectResults(static fn (Type $type): TrinaryLogic => $type->isList()); } public function isString(): TrinaryLogic @@ -1852,7 +1847,7 @@ public function getFiniteTypes(): array $result = []; foreach ($first as $key => $finiteType) { foreach ($others as $other) { - if (!self::hasFiniteType($other, is_string($key) ? $key : null, $finiteType)) { + if (is_string($key) ? !array_key_exists($key, $other) : !self::hasFiniteType($other, null, $finiteType)) { continue 2; } } diff --git a/src/Type/ObjectType.php b/src/Type/ObjectType.php index 4b96125e2d7..a16ff499f80 100644 --- a/src/Type/ObjectType.php +++ b/src/Type/ObjectType.php @@ -150,6 +150,13 @@ class ObjectType implements TypeWithClassName, SubtractableType */ private ?ClassReflection $lazyClassReflection = null; + /** + * What getClassReflection() returns, made once: for a generic class the reflection with its + * template types erased. Kept apart from the two above, which the other methods read with + * the template types in place. + */ + private ?ClassReflection $returnedClassReflection = null; + /** @var array> */ private static array $enumCases = []; @@ -1963,7 +1970,11 @@ public function getNakedClassReflection(): ?ClassReflection public function getClassReflection(): ?ClassReflection { - $classReflection = $this->resolvedClassReflection(); + if ($this->returnedClassReflection !== null) { + return $this->returnedClassReflection; + } + + $classReflection = $this->classReflection ?? $this->lazyClassReflection; if ($classReflection === null) { $reflectionProvider = ReflectionProviderStaticAccessor::getInstance(); if (!$reflectionProvider->hasClass($this->className)) { @@ -1974,10 +1985,10 @@ public function getClassReflection(): ?ClassReflection } if ($classReflection->isGeneric()) { - return $classReflection->withTypes(array_values($classReflection->getTemplateTypeMap()->map(static fn (): Type => new ErrorType())->getTypes())); + return $this->returnedClassReflection = $classReflection->withTypes(array_values($classReflection->getTemplateTypeMap()->map(static fn (): Type => new ErrorType())->getTypes())); } - return $classReflection; + return $this->returnedClassReflection = $classReflection; } /** The reflection already known, from either source — never triggers a fetch. */ diff --git a/src/Type/TypeCombinator.php b/src/Type/TypeCombinator.php index a6486beeb46..d0cc73c56fd 100644 --- a/src/Type/TypeCombinator.php +++ b/src/Type/TypeCombinator.php @@ -475,15 +475,15 @@ public static function doUnion(Type ...$types): Type // only a type containing every value of the scalar (not T of int, 1&T) // absorbs all the constants of that scalar if ($types[$i]->isBoolean()->yes()) { - if ($types[$i]->isSuperTypeOf(new BooleanType())->yes()) { + if (!isset($hasGenericScalarTypes[ConstantBooleanType::class]) && $types[$i]->isSuperTypeOf(new BooleanType())->yes()) { $hasGenericScalarTypes[ConstantBooleanType::class] = true; } } elseif ($types[$i]->isFloat()->yes()) { - if ($types[$i]->isSuperTypeOf(new FloatType())->yes()) { + if (!isset($hasGenericScalarTypes[ConstantFloatType::class]) && $types[$i]->isSuperTypeOf(new FloatType())->yes()) { $hasGenericScalarTypes[ConstantFloatType::class] = true; } } elseif ($types[$i]->isInteger()->yes() && !$types[$i] instanceof IntegerRangeType) { - if ($types[$i]->isSuperTypeOf(new IntegerType())->yes()) { + if (!isset($hasGenericScalarTypes[ConstantIntegerType::class]) && $types[$i]->isSuperTypeOf(new IntegerType())->yes()) { $hasGenericScalarTypes[ConstantIntegerType::class] = true; } } elseif ($types[$i]->isString()->yes() && $types[$i]->isClassString()->no() && TypeUtils::getAccessoryTypes($types[$i]) === []) { @@ -602,9 +602,22 @@ public static function doUnion(Type ...$types): Type $types = array_values($newTypes); } + // compareTypesInUnion() leaves a pair of constant arrays as it is unless + // both are intersections, so such pairs are skipped without calling it. A + // bare ConstantArrayType is the only type whose getConstantArrays() is + // itself. Every array went to processArrayTypes(), so only its results + // can be such. + $processedArrayTypes = self::processArrayTypes($arrayTypes); + $isPlainConstantArray = null; + if (count($processedArrayTypes) > 1) { + $isPlainConstantArray = array_fill(0, count($types), false); + foreach ($processedArrayTypes as $processedArrayType) { + $isPlainConstantArray[] = $processedArrayType->getConstantArrays() === [$processedArrayType]; + } + } $types = array_merge( $types, - self::processArrayTypes($arrayTypes), + $processedArrayTypes, ); $typesCount = count($types); @@ -630,6 +643,9 @@ public static function doUnion(Type ...$types): Type for ($i = 0; $i < $typesCount; $i++) { $iGroups = $normalizedGroupsByType[spl_object_id($types[$i])] ?? null; for ($j = $i + 1; $j < $typesCount; $j++) { + if ($isPlainConstantArray !== null && $isPlainConstantArray[$i] && $isPlainConstantArray[$j]) { + continue; + } if ($iGroups !== null && self::isAlreadyNormalized($iGroups, $normalizedGroupsByType, $normalizedTwice, $types[$i], $types[$j])) { continue; } @@ -642,16 +658,26 @@ public static function doUnion(Type ...$types): Type if ($a !== null) { $types[$i] = $a; $iGroups = $normalizedGroupsByType[spl_object_id($a)] ?? null; + if ($isPlainConstantArray !== null) { + $isPlainConstantArray[$i] = $a->getConstantArrays() === [$a]; + array_splice($isPlainConstantArray, $j, 1); + } array_splice($types, $j--, 1); $typesCount--; continue 1; } - if ($b !== null) { - $types[$j] = $b; - array_splice($types, $i--, 1); - $typesCount--; - continue 2; + if ($b === null) { + continue; + } + + $types[$j] = $b; + if ($isPlainConstantArray !== null) { + $isPlainConstantArray[$j] = $b->getConstantArrays() === [$b]; + array_splice($isPlainConstantArray, $i, 1); } + array_splice($types, $i--, 1); + $typesCount--; + continue 2; } } @@ -1002,17 +1028,17 @@ private static function compareTypesInUnion(Type $a, Type $b): ?array // numeric-string | non-decimal-int-string → string (preserving common accessories) // Works because decimal-int-string ⊂ numeric-string, so together they cover all strings + // A base that is the type itself was compared with the other one above. if ($a->isString()->yes() && $b->isString()->yes()) { - $decimalIntString = new IntersectionType([new StringType(), new AccessoryDecimalIntegerStringType()]); if ($b->isDecimalIntegerString()->no()) { $bBase = self::removeDecimalIntStringAccessory($b); - if ($bBase->isSuperTypeOf($a)->yes() && $a->isSuperTypeOf($decimalIntString)->yes()) { + if ($bBase !== $b && $bBase->isSuperTypeOf($a)->yes() && $a->isSuperTypeOf(new IntersectionType([new StringType(), new AccessoryDecimalIntegerStringType()]))->yes()) { return [null, $bBase]; } } if ($a->isDecimalIntegerString()->no()) { $aBase = self::removeDecimalIntStringAccessory($a); - if ($aBase->isSuperTypeOf($b)->yes() && $b->isSuperTypeOf($decimalIntString)->yes()) { + if ($aBase !== $a && $aBase->isSuperTypeOf($b)->yes() && $b->isSuperTypeOf(new IntersectionType([new StringType(), new AccessoryDecimalIntegerStringType()]))->yes()) { return [$aBase, null]; } } diff --git a/src/Type/UnionType.php b/src/Type/UnionType.php index e8d2d8528b3..99b46164c63 100644 --- a/src/Type/UnionType.php +++ b/src/Type/UnionType.php @@ -443,22 +443,23 @@ public function isAcceptedBy(Type $acceptingType, bool $strictTypes): AcceptsRes */ private function finiteTypeSetContainedIn(Type $otherType, bool $yesOnly): ?TrinaryLogic { - $finiteTypeSet = $this->getFiniteTypeSet(); - if ($finiteTypeSet === null || !$finiteTypeSet->isComplete()) { - return null; - } - if (!$otherType instanceof self || $otherType instanceof TemplateType) { // A single value covers a member iff they are the same value, and no member // stands for a value it is not keyed by - so one lookup answers for every member // at once. This is the shape TypeCombinator::remove() takes to ask whether // removing a value empties a union of values, and the only way a non-union other - // type reaches this helper at all. + // type reaches this helper at all. Its key comes first: without one, such as for + // string accepting a union of strings, this union's set is not built. $key = FiniteTypeSet::key($otherType); if ($key === null) { return null; } + $finiteTypeSet = $this->getFiniteTypeSet(); + if ($finiteTypeSet === null || !$finiteTypeSet->isComplete()) { + return null; + } + $containment = $finiteTypeSet->containedInKey($key); // No constant value accepts another one - not even a numeric string an int, in @@ -474,6 +475,11 @@ private function finiteTypeSetContainedIn(Type $otherType, bool $yesOnly): ?Trin return $containment; } + $finiteTypeSet = $this->getFiniteTypeSet(); + if ($finiteTypeSet === null || !$finiteTypeSet->isComplete()) { + return null; + } + $otherFiniteTypeSet = $otherType->getFiniteTypeSet(); if ($otherFiniteTypeSet === null) { return null; diff --git a/tests/bench/AcceptsResultBench.php b/tests/bench/AcceptsResultBench.php new file mode 100644 index 00000000000..b2e2afb736f --- /dev/null +++ b/tests/bench/AcceptsResultBench.php @@ -0,0 +1,100 @@ + */ + private array $results = []; + + private StringType $string; + + private UnionType $union; + + public function __construct(private ScratchDirectory $scratch) + { + $this->string = new StringType(); + $this->union = new UnionType([new ConstantStringType('a'), new ConstantStringType('b')]); + } + + /** + * @return iterable + */ + public static function results(): iterable + { + foreach ([16, 256] as $results) { + yield 'results-' . $results => [$results]; + } + } + + #[Before] + public function boot(int $results): void + { + BenchContainer::create($this->scratch); + + AcceptsResult::maxMin(...self::maybes(4)); + AcceptsResult::extremeIdentity(...self::maybes(4)); + $this->string->accepts(new UnionType(self::strings('warmup', 4)), true); + + $this->results = self::maybes($results); + $this->union = new UnionType(self::strings('string', $results)); + } + + #[DataProvider(method: 'results')] + public function benchMaxMin(int $results): AcceptsResult + { + return AcceptsResult::maxMin(...$this->results); + } + + #[DataProvider(method: 'results')] + public function benchExtremeIdentity(int $results): AcceptsResult + { + return AcceptsResult::extremeIdentity(...$this->results); + } + + /** + * StringType::accepts() accepts each constant string of the union. + */ + #[DataProvider(method: 'results')] + public function benchStringAcceptsUnion(int $results): AcceptsResult + { + return $this->string->accepts($this->union, true); + } + + /** + * @return list + */ + private static function maybes(int $count): array + { + $results = []; + for ($i = 0; $i < $count; $i++) { + $results[] = new AcceptsResult(TrinaryLogic::createMaybe(), []); + } + + return $results; + } + + /** + * @return list + */ + private static function strings(string $prefix, int $count): array + { + $strings = []; + for ($i = 0; $i < $count; $i++) { + $strings[] = new ConstantStringType($prefix . $i); + } + + return $strings; + } + +} diff --git a/tests/bench/BenchEnum.php b/tests/bench/BenchEnum.php new file mode 100644 index 00000000000..aa70336f55d --- /dev/null +++ b/tests/bench/BenchEnum.php @@ -0,0 +1,268 @@ + */ private array $keys = []; + /** @var list */ + private array $closures = []; + public function __construct(private ScratchDirectory $scratch) { } @@ -32,11 +37,23 @@ public static function keys(): iterable } } + /** + * @return iterable + */ + public static function closures(): iterable + { + foreach ([16, 64] as $closures) { + yield 'closures-' . $closures => [$closures]; + } + } + #[Before] public function boot(int $keys): void { BenchContainer::create($this->scratch); self::build([new ConstantStringType('warmup0'), new ConstantStringType('warmup1')]); + self::buildList(self::closureList(-4, 4))->getIterableValueType(); + $this->closures = self::closureList(0, $keys); for ($i = 0; $i < $keys; $i++) { $this->keys[] = new ConstantStringType('key' . $i); } @@ -57,6 +74,45 @@ public function benchSetOffsetValueType(int $keys): Type return self::build($this->keys); } + /** + * A list of closures that return 0, 1, 2, …, like an array of handlers, and the type of its values. + */ + #[DataProvider(method: 'closures')] + public function benchListOfClosures(int $closures): Type + { + if ($this->closures === []) { + throw new LogicException('boot() did not run.'); + } + + return self::buildList($this->closures)->getIterableValueType(); + } + + /** + * @param list $values + */ + private static function buildList(array $values): Type + { + $builder = ConstantArrayTypeBuilder::createEmpty(); + foreach ($values as $value) { + $builder->setOffsetValueType(null, $value); + } + + return $builder->getArray(); + } + + /** + * @return list + */ + private static function closureList(int $from, int $count): array + { + $closures = []; + for ($i = 0; $i < $count; $i++) { + $closures[] = new ClosureType([], new ConstantIntegerType($from + $i), false); + } + + return $closures; + } + /** * @param list $keys */ diff --git a/tests/bench/ConstantArrayTypePhp74Bench.php b/tests/bench/ConstantArrayTypePhp74Bench.php new file mode 100644 index 00000000000..700b9cb509b --- /dev/null +++ b/tests/bench/ConstantArrayTypePhp74Bench.php @@ -0,0 +1,79 @@ + */ + private array $arrays = []; + + public function __construct(private ScratchDirectory $scratch) + { + } + + #[Before] + public function boot(): void + { + $config = $this->scratch->path() . '/php-7.4.neon'; + file_put_contents($config, "parameters:\n\tphpVersion: 70400\n"); + $containerFactory = new ContainerFactory(dirname((string) (new ReflectionClass(ContainerFactory::class))->getFileName(), 3)); + $containerFactory->create($this->scratch->path(), [$containerFactory->getConfigDirectory() . '/config.level8.neon', $config], []); + + // reflects the methods + foreach (self::arrays() as $array) { + $array->findTypeAndMethodNames(); + } + $this->arrays = self::arrays(); + } + + /** + * [$dateTime, 'format'] and the other methods of DateTime, called on an object. + */ + public function benchFindTypeAndMethodNames(): int + { + $found = 0; + foreach ($this->arrays as $array) { + $found += count($array->findTypeAndMethodNames()); + } + + return $found; + } + + /** + * @return list + */ + private static function arrays(): array + { + $arrays = []; + foreach (self::METHODS as $method) { + $arrays[] = new ConstantArrayType( + [new ConstantIntegerType(0), new ConstantIntegerType(1)], + [new ObjectType(DateTime::class), new ConstantStringType($method)], + [2], + ); + } + + return $arrays; + } + +} diff --git a/tests/bench/ConstantStringTypeBench.php b/tests/bench/ConstantStringTypeBench.php new file mode 100644 index 00000000000..b476eb71ab6 --- /dev/null +++ b/tests/bench/ConstantStringTypeBench.php @@ -0,0 +1,76 @@ + */ + private array $keys = []; + + /** @var list */ + private array $callables = []; + + public function __construct(private ScratchDirectory $scratch) + { + } + + #[Before] + public function boot(): void + { + BenchContainer::create($this->scratch); + + (new ConstantStringType('warmup'))->toArrayKey(); + (new ConstantStringType('DateTimeImmutable::format'))->isCallable(); + foreach (self::DATE_TIME_METHODS as $method) { + (new ConstantStringType('DateTime::' . $method))->isCallable(); + } + + for ($i = 0; $i < self::KEYS; $i++) { + $this->keys[] = new ConstantStringType('key' . $i); + } + foreach (self::DATE_TIME_METHODS as $method) { + $this->callables[] = new ConstantStringType('DateTime::' . $method); + } + } + + /** + * Strings that stay strings as array keys. + * + * @return list + */ + public function benchToArrayKey(): array + { + $arrayKeys = []; + foreach ($this->keys as $key) { + $arrayKeys[] = $key->toArrayKey(); + } + + return $arrayKeys; + } + + /** + * 'DateTime::format' and other methods of DateTime, static or not. Since PHP 8 such a string + * may name an instance method, so the method is not looked into. + */ + public function benchIsCallable(): TrinaryLogic + { + $result = TrinaryLogic::createYes(); + foreach ($this->callables as $callable) { + $result = $result->and($callable->isCallable()); + } + + return $result; + } + +} diff --git a/tests/bench/EnumTypesBench.php b/tests/bench/EnumTypesBench.php new file mode 100644 index 00000000000..244c3c5d0b0 --- /dev/null +++ b/tests/bench/EnumTypesBench.php @@ -0,0 +1,125 @@ + */ + private array $unionMembers = []; + + private ?UnionType $union = null; + + private ?IntersectionType $intersection = null; + + public function __construct(private ScratchDirectory $scratch) + { + } + + #[Before] + public function boot(): void + { + BenchContainer::create($this->scratch); + + // reflects the enum and its cases; the types memoize their results, so the counted calls get new ones + $warmupCases = self::cases(4); + TypeCombinator::union(...self::unionMembers()); + (new UnionType($warmupCases))->getFiniteTypes(); + self::withoutFirstCase()->getFiniteTypes(); + + $this->unionMembers = self::unionMembers(); + $this->union = new UnionType(self::cases(self::CASES)); + $this->intersection = self::withoutFirstCase(); + } + + /** + * A member that is one enum case is set aside; whether the intersection with the enum is one + * does not need all of its cases. + */ + public function benchUnionWithIntersection(): Type + { + if ($this->unionMembers === []) { + throw new LogicException('boot() did not run.'); + } + + return TypeCombinator::union(...$this->unionMembers); + } + + /** + * @return list + */ + public function benchUnionTypeGetFiniteTypes(): array + { + if ($this->union === null) { + throw new LogicException('boot() did not run.'); + } + + return $this->union->getFiniteTypes(); + } + + /** + * The cases of the enum are matched with the cases of the enum without C0. + * + * @return list + */ + public function benchIntersectionTypeGetFiniteTypes(): array + { + if ($this->intersection === null) { + throw new LogicException('boot() did not run.'); + } + + return $this->intersection->getFiniteTypes(); + } + + /** + * @return list + */ + private static function cases(int $count): array + { + $cases = []; + for ($i = 0; $i < $count; $i++) { + $cases[] = new EnumCaseObjectType(BenchEnum::class, 'C' . $i); + } + + return $cases; + } + + /** + * @return list + */ + private static function unionMembers(): array + { + return [ + new IntersectionType([new ObjectType(BenchEnum::class), new HasPropertyType('name')]), + new IntegerType(), + new StringType(), + ]; + } + + private static function withoutFirstCase(): IntersectionType + { + return new IntersectionType([ + new ObjectType(BenchEnum::class), + new ObjectType(BenchEnum::class, new EnumCaseObjectType(BenchEnum::class, 'C0')), + ]); + } + +} diff --git a/tests/bench/IntersectionTypeBench.php b/tests/bench/IntersectionTypeBench.php new file mode 100644 index 00000000000..7838b40b79a --- /dev/null +++ b/tests/bench/IntersectionTypeBench.php @@ -0,0 +1,121 @@ + + */ + public static function members(): iterable + { + foreach ([16, 64] as $members) { + yield 'members-' . $members => [$members]; + } + } + + #[Before] + public function boot(int $members): void + { + BenchContainer::create($this->scratch); + + $warmup = self::intersection('warmup', 4); + $warmup->isVoid(); + self::askWhatItIs($warmup); + $warmup->isSubTypeOf(new ObjectType(stdClass::class)); + $warmup->isAcceptedBy(new ObjectType(stdClass::class), true); + + $this->intersection = self::intersection('m', $members); + // what the intersection is was asked before, the counted call asks again + self::askWhatItIs($this->intersection); + } + + #[DataProvider(method: 'members')] + #[Constant(size: 'members')] + public function benchIsVoid(int $members): TrinaryLogic + { + return $this->subject()->isVoid(); + } + + /** + * The scope asks a type what it is many times. + */ + #[DataProvider(method: 'members')] + #[Constant(size: 'members')] + public function benchAskAgainWhatItIs(int $members): TrinaryLogic + { + return self::askWhatItIs($this->subject()); + } + + /** + * stdClass, the first member, is already a subtype of stdClass. + */ + #[DataProvider(method: 'members')] + public function benchIsSubTypeOf(int $members): IsSuperTypeOfResult + { + return $this->subject()->isSubTypeOf(new ObjectType(stdClass::class)); + } + + #[DataProvider(method: 'members')] + public function benchIsAcceptedBy(int $members): AcceptsResult + { + return $this->subject()->isAcceptedBy(new ObjectType(stdClass::class), true); + } + + private function subject(): IntersectionType + { + if ($this->intersection === null) { + throw new LogicException('boot() did not run.'); + } + + return $this->intersection; + } + + private static function askWhatItIs(IntersectionType $type): TrinaryLogic + { + return $type->isBoolean() + ->or($type->isFloat()) + ->or($type->isInteger()) + ->or($type->isString()) + ->or($type->isArray()) + ->or($type->isList()) + ->or($type->isConstantArray()) + ->or($type->isOversizedArray()) + ->or($type->isIterableAtLeastOnce()) + ->or($type->isConstantScalarValue()); + } + + private static function intersection(string $prefix, int $members): IntersectionType + { + $types = [new ObjectType(stdClass::class)]; + for ($i = 1; $i < $members; $i++) { + $types[] = new HasMethodType($prefix . $i); + } + + return new IntersectionType($types); + } + +} diff --git a/tests/bench/IsSuperTypeOfResultBench.php b/tests/bench/IsSuperTypeOfResultBench.php new file mode 100644 index 00000000000..0f7cd61a527 --- /dev/null +++ b/tests/bench/IsSuperTypeOfResultBench.php @@ -0,0 +1,156 @@ + */ + private array $results = []; + + /** @var list */ + private array $strings = []; + + private StringType $string; + + private UnionType $union; + + public function __construct(private ScratchDirectory $scratch) + { + $this->string = new StringType(); + $this->union = new UnionType([new ConstantStringType('a'), new ConstantStringType('b')]); + } + + /** + * @return iterable + */ + public static function results(): iterable + { + foreach ([16, 256] as $results) { + yield 'results-' . $results => [$results]; + } + } + + #[Before] + public function boot(int $results): void + { + BenchContainer::create($this->scratch); + + IsSuperTypeOfResult::maxMin(...self::maybes(4)); + IsSuperTypeOfResult::extremeIdentity(...self::maybes(4)); + $this->string->isSuperTypeOf(new UnionType(self::strings('warmup', 4))); + self::accessoriesIsSuperTypeOf(self::accessories(), self::strings('warmup', 4)); + + $this->results = self::maybes($results); + $this->strings = self::strings('string', $results); + $this->union = new UnionType(self::strings('string', $results)); + } + + #[DataProvider(method: 'results')] + public function benchMaxMin(int $results): IsSuperTypeOfResult + { + return IsSuperTypeOfResult::maxMin(...$this->results); + } + + #[DataProvider(method: 'results')] + public function benchExtremeIdentity(int $results): IsSuperTypeOfResult + { + return IsSuperTypeOfResult::extremeIdentity(...$this->results); + } + + /** + * StringType::isSuperTypeOf() is asked about each constant string of the union. + */ + #[DataProvider(method: 'results')] + public function benchStringIsSuperTypeOfUnion(int $results): IsSuperTypeOfResult + { + return $this->string->isSuperTypeOf($this->union); + } + + /** + * Each accessory type is asked about each constant string. + */ + #[DataProvider(method: 'results')] + public function benchAccessoriesIsSuperTypeOf(int $results): int + { + return self::accessoriesIsSuperTypeOf(self::accessories(), $this->strings); + } + + /** + * @param list $accessories + * @param list $strings + */ + private static function accessoriesIsSuperTypeOf(array $accessories, array $strings): int + { + $yes = 0; + foreach ($accessories as $accessory) { + foreach ($strings as $string) { + if (!$accessory->isSuperTypeOf($string)->yes()) { + continue; + } + + $yes++; + } + } + + return $yes; + } + + /** + * @return list + */ + private static function accessories(): array + { + return [ + new AccessoryNonEmptyStringType(), + new AccessoryNonFalsyStringType(), + new AccessoryLowercaseStringType(), + new AccessoryUppercaseStringType(), + new AccessoryNumericStringType(), + new AccessoryLiteralStringType(), + ]; + } + + /** + * @return list + */ + private static function maybes(int $count): array + { + $results = []; + for ($i = 0; $i < $count; $i++) { + $results[] = new IsSuperTypeOfResult(TrinaryLogic::createMaybe(), []); + } + + return $results; + } + + /** + * @return list + */ + private static function strings(string $prefix, int $count): array + { + $strings = []; + for ($i = 0; $i < $count; $i++) { + $strings[] = new ConstantStringType($prefix . $i); + } + + return $strings; + } + +} diff --git a/tests/bench/ObjectTypeBench.php b/tests/bench/ObjectTypeBench.php new file mode 100644 index 00000000000..e027b9b059f --- /dev/null +++ b/tests/bench/ObjectTypeBench.php @@ -0,0 +1,53 @@ + + */ + public static function classes(): iterable + { + yield 'class' => [DateTime::class]; + yield 'generic-class' => [ArrayObject::class]; + } + + #[Before] + public function boot(string $className): void + { + BenchContainer::create($this->scratch); + $this->object = new ObjectType($className); + $this->object->getClassReflection(); + } + + /** + * The type was asked for its class before. + */ + #[DataProvider(method: 'classes')] + public function benchGetClassReflection(string $className): ?ClassReflection + { + if ($this->object === null) { + throw new LogicException('boot() did not run.'); + } + + return $this->object->getClassReflection(); + } + +} diff --git a/tests/bench/TemplateTypeBench.php b/tests/bench/TemplateTypeBench.php new file mode 100644 index 00000000000..2fdc6caf106 --- /dev/null +++ b/tests/bench/TemplateTypeBench.php @@ -0,0 +1,128 @@ + */ + private array $withDefaults = []; + + public function __construct(private ScratchDirectory $scratch) + { + } + + /** + * @return iterable + */ + public static function members(): iterable + { + foreach ([16, 256] as $members) { + yield 'members-' . $members => [$members]; + } + } + + #[Before] + public function boot(int $members): void + { + BenchContainer::create($this->scratch); + $this->template = TemplateTypeFactory::create( + TemplateTypeScope::createWithFunction('foo'), + 'T', + new IntegerType(), + TemplateTypeVariance::createInvariant(), + ); + $this->template->inferTemplateTypes(self::strings('warmup', 4)); + $this->strings = self::strings('string', $members); + + self::describeAll(self::withDefaults(4)); + $this->withDefaults = self::withDefaults($members); + } + + /** + * T0 of int = 0, T1 of int = 1, …: the description of a default is guarded against recursion. + */ + #[DataProvider(method: 'members')] + public function benchDescribeWithDefault(int $members): string + { + return self::describeAll($this->withDefaults); + } + + /** + * @param list $templates + */ + private static function describeAll(array $templates): string + { + $description = ''; + foreach ($templates as $template) { + $description .= $template->describe(VerbosityLevel::typeOnly()); + } + + return $description; + } + + /** + * @return list + */ + private static function withDefaults(int $members): array + { + $templates = []; + for ($i = 0; $i < $members; $i++) { + $templates[] = TemplateTypeFactory::create( + TemplateTypeScope::createWithFunction('foo'), + 'T' . $i, + new IntegerType(), + TemplateTypeVariance::createInvariant(), + default: new ConstantIntegerType($i), + ); + } + + return $templates; + } + + /** + * None of the strings is an int, so T is not inferred from any of them. + */ + #[DataProvider(method: 'members')] + public function benchInferTemplateTypesFromUnion(int $members): TemplateTypeMap + { + if ($this->template === null || $this->strings === null) { + throw new LogicException('boot() did not run.'); + } + + return $this->template->inferTemplateTypes($this->strings); + } + + private static function strings(string $prefix, int $members): UnionType + { + $strings = []; + for ($i = 0; $i < $members; $i++) { + $strings[] = new ConstantStringType($prefix . $i); + } + + return new UnionType($strings); + } + +} diff --git a/tests/bench/TemplateTypeHelperBench.php b/tests/bench/TemplateTypeHelperBench.php new file mode 100644 index 00000000000..847f0e0568a --- /dev/null +++ b/tests/bench/TemplateTypeHelperBench.php @@ -0,0 +1,80 @@ + + */ + public static function keys(): iterable + { + foreach ([16, 256] as $keys) { + yield 'keys-' . $keys => [$keys]; + } + } + + #[Before] + public function boot(int $keys): void + { + BenchContainer::create($this->scratch); + self::resolve(self::shape('warmup', 4)); + $this->shape = self::shape('key', $keys); + } + + /** + * An array shape without template types, such as the return type of most functions. + */ + #[DataProvider(method: 'keys')] + public function benchResolveTemplateTypes(int $keys): Type + { + if ($this->shape === null) { + throw new LogicException('boot() did not run.'); + } + + return self::resolve($this->shape); + } + + private static function resolve(Type $type): Type + { + return TemplateTypeHelper::resolveTemplateTypes( + $type, + TemplateTypeMap::createEmpty(), + TemplateTypeVarianceMap::createEmpty(), + TemplateTypeVariance::createInvariant(), + ); + } + + private static function shape(string $prefix, int $keys): Type + { + $keyTypes = []; + $valueTypes = []; + for ($i = 0; $i < $keys; $i++) { + $keyTypes[] = new ConstantStringType($prefix . $i); + $valueTypes[] = new IntegerType(); + } + + return new ConstantArrayType($keyTypes, $valueTypes); + } + +} diff --git a/tests/bench/TypeCombinatorBench.php b/tests/bench/TypeCombinatorBench.php index 4cfffaadee0..ecad638d506 100644 --- a/tests/bench/TypeCombinatorBench.php +++ b/tests/bench/TypeCombinatorBench.php @@ -2,13 +2,23 @@ namespace PHPStan\Benchmark; +use PHPStan\Type\Accessory\AccessoryNumericStringType; +use PHPStan\Type\Accessory\HasMethodType; +use PHPStan\Type\ArrayType; use PHPStan\Type\BenevolentUnionType; +use PHPStan\Type\BooleanType; use PHPStan\Type\Constant\ConstantArrayType; use PHPStan\Type\Constant\ConstantIntegerType; use PHPStan\Type\Constant\ConstantStringType; +use PHPStan\Type\FloatType; +use PHPStan\Type\IntegerType; +use PHPStan\Type\IntersectionType; +use PHPStan\Type\ObjectType; +use PHPStan\Type\StringType; use PHPStan\Type\Type; use PHPStan\Type\TypeCombinator; use PHPStan\Type\UnionType; +use stdClass; use Tally\Attribute\Before; use Tally\Attribute\DataProvider; use Tally\Attribute\Linear; @@ -36,6 +46,21 @@ final class TypeCombinatorBench /** @var list */ private array $benevolentUnions = []; + /** @var list */ + private array $generalScalars = []; + + /** @var list */ + private array $numericStringAndStrings = []; + + /** @var list */ + private array $arraysWithSameKeys = []; + + /** @var list */ + private array $arraysWithOwnKeys = []; + + /** @var list */ + private array $intersections = []; + public function __construct(private ScratchDirectory $scratch) { } @@ -64,6 +89,11 @@ public function boot(int $types): void TypeCombinator::remove(...$this->unions); TypeCombinator::union(...$this->taggedArrays); TypeCombinator::intersect(...$this->benevolentUnions); + TypeCombinator::union(...$this->generalScalars); + TypeCombinator::union(...$this->numericStringAndStrings); + TypeCombinator::union(...$this->arraysWithSameKeys); + TypeCombinator::union(...$this->arraysWithOwnKeys); + TypeCombinator::intersect(...$this->intersections); $this->createTypes('type', $types); } @@ -122,6 +152,52 @@ public function benchIntersectBenevolentUnion(int $types): Type return TypeCombinator::intersect(...$this->benevolentUnions); } + /** + * Each member is a general bool, float, int or string. + */ + #[DataProvider(method: 'types')] + public function benchUnionGeneralScalars(int $types): Type + { + return TypeCombinator::union(...$this->generalScalars); + } + + /** + * numeric-string is compared with each constant string. + */ + #[DataProvider(method: 'types')] + public function benchUnionNumericStringAndConstantStrings(int $types): Type + { + return TypeCombinator::union(...$this->numericStringAndStrings); + } + + /** + * The constant arrays have the same eight keys, which the general array gets once. + */ + #[DataProvider(method: 'types')] + public function benchUnionGeneralArrayAndConstantArrays(int $types): Type + { + return TypeCombinator::union(...$this->arraysWithSameKeys); + } + + /** + * Each constant array has eight keys of its own. More than 256 values in all are generalized + * to an oversized array. + */ + #[DataProvider(method: 'types')] + public function benchUnionConstantArraysWithOwnKeys(int $types): Type + { + return TypeCombinator::union(...$this->arraysWithOwnKeys); + } + + /** + * Intersections of stdClass with one method each are flattened into one. + */ + #[DataProvider(method: 'types')] + public function benchIntersectIntersections(int $types): Type + { + return TypeCombinator::intersect(...$this->intersections); + } + private function createTypes(string $prefix, int $types): void { $this->strings = []; @@ -144,6 +220,28 @@ private function createTypes(string $prefix, int $types): void [new ConstantStringType($prefix . $i), new ConstantIntegerType($i)], ); } + + $this->generalScalars = []; + $this->numericStringAndStrings = [new IntersectionType([new StringType(), new AccessoryNumericStringType()])]; + $this->arraysWithSameKeys = [new ArrayType(new IntegerType(), new StringType())]; + $this->arraysWithOwnKeys = []; + $this->intersections = []; + $generalScalars = [BooleanType::class, FloatType::class, IntegerType::class, StringType::class]; + for ($i = 0; $i < $types; $i++) { + $this->generalScalars[] = new $generalScalars[$i % 4](); + $this->numericStringAndStrings[] = new ConstantStringType($prefix . $i); + $sameKeys = []; + $ownKeys = []; + $values = []; + for ($j = 0; $j < 8; $j++) { + $sameKeys[] = new ConstantStringType($prefix . 'key' . $j); + $ownKeys[] = new ConstantStringType($prefix . $i . 'key' . $j); + $values[] = new ConstantIntegerType($i * 8 + $j); + } + $this->arraysWithSameKeys[] = new ConstantArrayType($sameKeys, $values); + $this->arraysWithOwnKeys[] = new ConstantArrayType($ownKeys, $values); + $this->intersections[] = new IntersectionType([new ObjectType(stdClass::class), new HasMethodType($prefix . $i)]); + } } } diff --git a/tests/bench/UnionTypeBench.php b/tests/bench/UnionTypeBench.php index 24f1411cd42..fd234fa9deb 100644 --- a/tests/bench/UnionTypeBench.php +++ b/tests/bench/UnionTypeBench.php @@ -3,9 +3,17 @@ namespace PHPStan\Benchmark; use LogicException; +use PHPStan\TrinaryLogic; +use PHPStan\Type\Accessory\HasMethodType; +use PHPStan\Type\Constant\ConstantArrayType; +use PHPStan\Type\Constant\ConstantIntegerType; use PHPStan\Type\Constant\ConstantStringType; +use PHPStan\Type\IntersectionType; +use PHPStan\Type\ObjectType; +use PHPStan\Type\Type; use PHPStan\Type\UnionType; use PHPStan\Type\VerbosityLevel; +use stdClass; use Tally\Attribute\Before; use Tally\Attribute\DataProvider; use Tally\Attribute\Linear; @@ -19,6 +27,10 @@ final class UnionTypeBench private ?UnionType $other = null; + private ?UnionType $intersections = null; + + private ?UnionType $arrays = null; + public function __construct(private ScratchDirectory $scratch) { } @@ -41,8 +53,38 @@ public function boot(int $members): void [$union, $other] = self::unions('warmup', 4); $union->describe(VerbosityLevel::precise()); $union->isSuperTypeOf($other); + self::intersections('warmup', 4)->describe(VerbosityLevel::precise()); + self::arrays(4)->getIterableValueType(); [$this->union, $this->other] = self::unions('member', $members); + $this->intersections = self::intersections('member', $members); + $this->arrays = self::arrays($members); + } + + /** + * Each member is an intersection, described in parentheses. + */ + #[DataProvider(method: 'members')] + public function benchDescribeIntersections(int $members): string + { + if ($this->intersections === null) { + throw new LogicException('boot() did not run.'); + } + + return $this->intersections->describe(VerbosityLevel::precise()); + } + + /** + * The value types of the member arrays are unioned. + */ + #[DataProvider(method: 'members')] + public function benchGetIterableValueType(int $members): Type + { + if ($this->arrays === null) { + throw new LogicException('boot() did not run.'); + } + + return $this->arrays->getIterableValueType(); } /** @@ -75,6 +117,29 @@ private function union(): UnionType return $this->union; } + private static function intersections(string $prefix, int $members): UnionType + { + $types = []; + for ($i = 0; $i < $members; $i++) { + $types[] = new IntersectionType([new ObjectType(stdClass::class), new HasMethodType($prefix . $i)]); + } + + return new UnionType($types); + } + + /** + * Lists with one constant integer each. + */ + private static function arrays(int $members): UnionType + { + $types = []; + for ($i = 0; $i < $members; $i++) { + $types[] = new ConstantArrayType([new ConstantIntegerType(0)], [new ConstantIntegerType($i)], [1], [], TrinaryLogic::createYes()); + } + + return new UnionType($types); + } + /** * @return array{UnionType, UnionType} the other has half of the members of the first */ diff --git a/tests/bench/VerbosityLevelBench.php b/tests/bench/VerbosityLevelBench.php new file mode 100644 index 00000000000..003e6129db3 --- /dev/null +++ b/tests/bench/VerbosityLevelBench.php @@ -0,0 +1,79 @@ + + */ + public static function values(): iterable + { + foreach ([16, 256] as $values) { + yield 'values-' . $values => [$values]; + } + } + + #[Before] + public function boot(int $values): void + { + BenchContainer::create($this->scratch); + VerbosityLevel::getRecommendedLevelByType(self::shape(4)); + $this->shape = self::shape($values); + } + + /** + * The first value of the array shape, a lowercase-string, already needs the precise level, + * so the arrays in the other values are not looked into. + */ + #[DataProvider(method: 'values')] + public function benchGetRecommendedLevelByType(int $values): VerbosityLevel + { + if ($this->shape === null) { + throw new LogicException('boot() did not run.'); + } + + return VerbosityLevel::getRecommendedLevelByType($this->shape); + } + + /** + * array{lowercase-string, array{0, 1, 2, 3}, array{4, 5, 6, 7}, …} + */ + private static function shape(int $values): Type + { + $keyTypes = [new ConstantIntegerType(0)]; + $valueTypes = [new IntersectionType([new StringType(), new AccessoryLowercaseStringType()])]; + for ($i = 1; $i < $values; $i++) { + $keyTypes[] = new ConstantIntegerType($i); + $innerKeyTypes = []; + $innerValueTypes = []; + for ($j = 0; $j < 4; $j++) { + $innerKeyTypes[] = new ConstantIntegerType($j); + $innerValueTypes[] = new ConstantIntegerType($i * 4 + $j); + } + $valueTypes[] = new ConstantArrayType($innerKeyTypes, $innerValueTypes, [4]); + } + + return new ConstantArrayType($keyTypes, $valueTypes, [$values]); + } + +} diff --git a/turbo-ext/src/ConstantArrayType.cpp b/turbo-ext/src/ConstantArrayType.cpp index ac2dae0f237..82811c6ac44 100644 --- a/turbo-ext/src/ConstantArrayType.cpp +++ b/turbo-ext/src/ConstantArrayType.cpp @@ -2196,6 +2196,18 @@ class ConstantArrayType * -1 = pending exception */ [[nodiscard]] zend_long isCallable() const { + /* the shapes doFindTypeAndMethodNames() rejects by their keys alone, + * without describing the whole shape for the recursion guard */ + zval *k = keyTypes(); + if (UNEXPECTED(k == NULL)) return -1; + zend_long keyTypesCount = arrayCount(k); + if (keyTypesCount > 2) return PT_TRI_NO; + if (keyTypesCount != 2) { + zend_long unsealedness = thisIsUnsealed(); + if (UNEXPECTED(unsealedness < 0)) return -1; + if (unsealedness != PT_TRI_YES) return PT_TRI_NO; + } + zv::Val callback = pt_type_native_callback(isCallableCallback, thisZv(), NULL); if (UNEXPECTED(callback.isUndef())) return -1; zv::Val result = pt_type_recursion_guard_run(thisZv(), callback.raw()); diff --git a/turbo-ext/src/EnumCaseObjectType.cpp b/turbo-ext/src/EnumCaseObjectType.cpp index fa18fa0f6b2..79cdc13f14d 100644 --- a/turbo-ext/src/EnumCaseObjectType.cpp +++ b/turbo-ext/src/EnumCaseObjectType.cpp @@ -19,9 +19,8 @@ namespace sigs = ptdecl::EnumCaseObjectType::sig; zend_class_entry *pt_ce_enum_case_object_type = nullptr; -/* OBJ_PROP_NUM slots: the parent's ten first (ObjectType.cpp) */ -#define PT_OT_PROP_COUNT 10 -#define PT_ECOT_PROP_ENUM_CASE_NAME (PT_OT_PROP_COUNT + 0) +/* OBJ_PROP_NUM slots: the parent's first (ObjectType.cpp) */ +#define PT_ECOT_PROP_ENUM_CASE_NAME ptdecl::EnumCaseObjectType::slot::enumCaseName /* the handlers the $this-dispatch fast paths identify */ static void ZEND_FASTCALL ecotGetEnumCaseName(INTERNAL_FUNCTION_PARAMETERS); diff --git a/turbo-ext/src/GenericObjectType.cpp b/turbo-ext/src/GenericObjectType.cpp index 4cc4b2830df..97b108ecc76 100644 --- a/turbo-ext/src/GenericObjectType.cpp +++ b/turbo-ext/src/GenericObjectType.cpp @@ -41,12 +41,11 @@ zend_class_entry *pt_ce_generic_object_type = nullptr; return false; } -/* OBJ_PROP_NUM slots: the parent's ten first (ObjectType.cpp), then the +/* OBJ_PROP_NUM slots: the parent's first (ObjectType.cpp), then the * twin's promoted properties in parameter order */ -#define PT_OT_PROP_COUNT 10 -#define PT_GOT_PROP_TYPES (PT_OT_PROP_COUNT + 0) -#define PT_GOT_PROP_CLASS_REFLECTION (PT_OT_PROP_COUNT + 1) -#define PT_GOT_PROP_VARIANCES (PT_OT_PROP_COUNT + 2) +#define PT_GOT_PROP_TYPES ptdecl::GenericObjectType::slot::types +#define PT_GOT_PROP_CLASS_REFLECTION ptdecl::GenericObjectType::slot::classReflection +#define PT_GOT_PROP_VARIANCES ptdecl::GenericObjectType::slot::variances /* the handlers the $this-dispatch fast paths identify */ static void ZEND_FASTCALL gotGetClassReflection(INTERNAL_FUNCTION_PARAMETERS); diff --git a/turbo-ext/src/HasMethodType.cpp b/turbo-ext/src/HasMethodType.cpp index ce04732364e..068abb3edee 100644 --- a/turbo-ext/src/HasMethodType.cpp +++ b/turbo-ext/src/HasMethodType.cpp @@ -81,6 +81,14 @@ class HasMethodType * as a fresh result; UNDEF = pending exception */ zv::Val isSuperTypeOf(zval *type) const { + /* what $type->isSubTypeOf($this) answers for another HasMethodType, + * without its callable checks */ + if (Z_TYPE_P(type) == IS_OBJECT && Z_OBJCE_P(type) == pt_ce_has_method_type) { + zend_long has = otherHasMethod(type); + if (UNEXPECTED(has < 0)) return zv::Val(); + return pt_type_is_super_type_of_result(has); + } + bool compound; if (UNEXPECTED(!pt_type_instanceof(type, PT_CLASS_COMPOUND_TYPE, compound))) return zv::Val(); if (compound) { diff --git a/turbo-ext/src/ObjectType.cpp b/turbo-ext/src/ObjectType.cpp index 10f66302d3a..f14ed63b59b 100644 --- a/turbo-ext/src/ObjectType.cpp +++ b/turbo-ext/src/ObjectType.cpp @@ -2526,6 +2526,10 @@ class ObjectType * pending exception */ zv::Val getClassReflection() const { + /* $this->returnedClassReflection, once made */ + zval *returned = OBJ_PROP_NUM(self, slots::returnedClassReflection); + if (Z_TYPE_P(returned) == IS_OBJECT) return zv::Val::copyOf(zv::Ref(returned)); + zv::Val classReflection; zval *resolved = resolvedClassReflection(); if (UNEXPECTED(resolved == NULL)) return zv::Val(); @@ -2560,9 +2564,13 @@ class ObjectType for (zv::ArrayEntry entry : zv::ArrRef(types.raw())) { values.push(entry.value()); } - return pt_type_call(reflection, PT_LC("withtypes"), 1, values.raw()); + zv::Val withTypes = pt_type_call(reflection, PT_LC("withtypes"), 1, values.raw()); + if (UNEXPECTED(withTypes.isUndef())) return zv::Val(); + zv::ObjRef(self).propAtWrite(slots::returnedClassReflection, zv::Val::copyOf(zv::Ref(withTypes.raw()))); + return withTypes; } + zv::ObjRef(self).propAtWrite(slots::returnedClassReflection, zv::Val::copyOf(zv::Ref(classReflection.raw()))); return classReflection; } @@ -3653,6 +3661,7 @@ PT_MINIT_REGISTRATION(pt_register_object_type) cls.privateTypedPropertyDefaultNull("cachedDescription", MAY_BE_STRING); cls.privateTypedPropertyDefaultNull("cachedPreciseName", MAY_BE_STRING); cls.privateTypedClassPropertyDefaultNull("lazyClassReflection", "PHPStan\\Reflection\\ClassReflection"); + cls.privateTypedClassPropertyDefaultNull("returnedClassReflection", "PHPStan\\Reflection\\ClassReflection"); cls.privateStaticTypedArrayPropertyDefaultEmpty("enumCases"); cls.privateStaticTypedClassPropertyDefaultNull("descriptionCacheOrder", "PHPStan\\Internal\\LruCache"); cls.privateStaticTypedPropertyDefaultNull("lastTouchedDescription", MAY_BE_STRING); diff --git a/turbo-ext/src/TypeCombinator.cpp b/turbo-ext/src/TypeCombinator.cpp index 146b2ed61a3..9c217fe3323 100644 --- a/turbo-ext/src/TypeCombinator.cpp +++ b/turbo-ext/src/TypeCombinator.cpp @@ -1716,11 +1716,9 @@ class TypeCombinator zend_long bString = callTrinary(b, PT_LC("isstring")); if (UNEXPECTED(bString < 0)) return -1; if (bString == PT_TRI_YES) { - zv::Val decimalIntString = decimalIntStringType(); - if (UNEXPECTED(decimalIntString.isUndef())) return -1; - int result = compareStringsInUnion(a, b, decimalIntString.raw(), merged); + int result = compareStringsInUnion(a, b, merged); if (result != 0) return result < 0 ? -1 : 2; - result = compareStringsInUnion(b, a, decimalIntString.raw(), merged); + result = compareStringsInUnion(b, a, merged); if (result != 0) return result < 0 ? -1 : 1; } } @@ -1802,17 +1800,21 @@ class TypeCombinator * absorbing $type: $other without its decimal-int-string accessory * covers $type, and $type covers decimal-int-string; 1 with $merged * the base of $other, 0 for no answer; -1 = pending exception */ - static int compareStringsInUnion(zval *type, zval *other, zval *decimalIntString, zv::Val &merged) + static int compareStringsInUnion(zval *type, zval *other, zv::Val &merged) { zend_long isDecimal = callTrinary(other, PT_LC("isdecimalintegerstring")); if (UNEXPECTED(isDecimal < 0)) return -1; if (isDecimal != PT_TRI_NO) return 0; zv::Val base = removeDecimalIntStringAccessory(other); if (UNEXPECTED(base.isUndef())) return -1; + /* a base that is $other itself was compared with $type before */ + if (Z_TYPE_P(base.raw()) == IS_OBJECT && Z_TYPE_P(other) == IS_OBJECT && Z_OBJ_P(base.raw()) == Z_OBJ_P(other)) return 0; int baseCovers = isSuperTypeYes(base.raw(), type); if (UNEXPECTED(baseCovers < 0)) return -1; if (!baseCovers) return 0; - int coversDecimal = isSuperTypeYes(type, decimalIntString); + zv::Val decimalIntString = decimalIntStringType(); + if (UNEXPECTED(decimalIntString.isUndef())) return -1; + int coversDecimal = isSuperTypeYes(type, decimalIntString.raw()); if (UNEXPECTED(coversDecimal < 0)) return -1; if (!coversDecimal) return 0; merged = std::move(base); diff --git a/turbo-ext/src/UnionType.cpp b/turbo-ext/src/UnionType.cpp index 20de343a376..1e6c36967e6 100644 --- a/turbo-ext/src/UnionType.cpp +++ b/turbo-ext/src/UnionType.cpp @@ -2366,24 +2366,31 @@ class UnionType * pending exception */ zend_long finiteTypeSetContainedIn(zval *otherType, bool yesOnly) const { - zv::Val finiteTypeSet = thisGetFiniteTypeSet(); - if (UNEXPECTED(finiteTypeSet.isUndef())) return -1; - if (zv::Ref(finiteTypeSet.raw()).isNull()) return CONTAINMENT_UNSETTLED; - zend_object *set = Z_OBJ_P(finiteTypeSet.raw()); - int complete = pt_type_call_is_true(set, PT_LC("iscomplete"), 0, NULL); - if (UNEXPECTED(complete < 0)) return -1; - if (complete == 0) return CONTAINMENT_UNSETTLED; - bool otherIsUnion = zv::Ref(otherType).instanceOf(pt_ce_union_type); if (otherIsUnion) { bool isTemplate; if (UNEXPECTED(!isInstance(otherType, PT_CLASS_TEMPLATE_TYPE, isTemplate))) return -1; otherIsUnion = !isTemplate; } + + /* the key of a single other value comes first: without one this + * union's set is not built */ + zv::Val key; if (!otherIsUnion) { - zv::Val key = finiteKey(otherType); + key = finiteKey(otherType); if (UNEXPECTED(key.isUndef())) return -1; if (!zv::Ref(key.raw()).isString()) return CONTAINMENT_UNSETTLED; + } + + zv::Val finiteTypeSet = thisGetFiniteTypeSet(); + if (UNEXPECTED(finiteTypeSet.isUndef())) return -1; + if (zv::Ref(finiteTypeSet.raw()).isNull()) return CONTAINMENT_UNSETTLED; + zend_object *set = Z_OBJ_P(finiteTypeSet.raw()); + int complete = pt_type_call_is_true(set, PT_LC("iscomplete"), 0, NULL); + if (UNEXPECTED(complete < 0)) return -1; + if (complete == 0) return CONTAINMENT_UNSETTLED; + + if (!otherIsUnion) { zend_long containment = pt_type_call_trinary(set, PT_LC("containedinkey"), 1, key.raw()); if (UNEXPECTED(containment < 0)) return -1; if (containment != PT_TRI_YES && yesOnly) return CONTAINMENT_UNSETTLED; diff --git a/turbo-ext/src/generated/EnumCaseObjectType.h b/turbo-ext/src/generated/EnumCaseObjectType.h index 162e0d6d217..d007857fa52 100644 --- a/turbo-ext/src/generated/EnumCaseObjectType.h +++ b/turbo-ext/src/generated/EnumCaseObjectType.h @@ -10,7 +10,7 @@ namespace ptdecl::EnumCaseObjectType { /* the OBJ_PROP_NUM slots of the instance properties the class declares (the inherited ones come first) */ namespace slot { -inline constexpr uint32_t enumCaseName = 10; +inline constexpr uint32_t enumCaseName = 11; } // namespace slot inline void declareClass(reg::Class &cls) diff --git a/turbo-ext/src/generated/GenericObjectType.h b/turbo-ext/src/generated/GenericObjectType.h index cbf6a698e6b..f5061196f3a 100644 --- a/turbo-ext/src/generated/GenericObjectType.h +++ b/turbo-ext/src/generated/GenericObjectType.h @@ -10,9 +10,9 @@ namespace ptdecl::GenericObjectType { /* the OBJ_PROP_NUM slots of the instance properties the class declares (the inherited ones come first) */ namespace slot { -inline constexpr uint32_t types = 10; -inline constexpr uint32_t classReflection = 11; -inline constexpr uint32_t variances = 12; +inline constexpr uint32_t types = 11; +inline constexpr uint32_t classReflection = 12; +inline constexpr uint32_t variances = 13; } // namespace slot inline void declareClass(reg::Class &cls) diff --git a/turbo-ext/src/generated/ObjectType.h b/turbo-ext/src/generated/ObjectType.h index 7e0a8c6e061..790d15202e1 100644 --- a/turbo-ext/src/generated/ObjectType.h +++ b/turbo-ext/src/generated/ObjectType.h @@ -17,9 +17,10 @@ inline constexpr uint32_t currentAncestors = 3; inline constexpr uint32_t cachedDescription = 4; inline constexpr uint32_t cachedPreciseName = 5; inline constexpr uint32_t lazyClassReflection = 6; -inline constexpr uint32_t methodCache = 7; -inline constexpr uint32_t className = 8; -inline constexpr uint32_t classReflection = 9; +inline constexpr uint32_t returnedClassReflection = 7; +inline constexpr uint32_t methodCache = 8; +inline constexpr uint32_t className = 9; +inline constexpr uint32_t classReflection = 10; } // namespace slot inline void declareClass(reg::Class &cls) @@ -43,6 +44,7 @@ inline void declareProperties(reg::Class &cls) cls.property("cachedDescription", ZEND_ACC_PRIVATE, reg::PropertyKind::TypedNull, MAY_BE_NULL | MAY_BE_STRING); cls.property("cachedPreciseName", ZEND_ACC_PRIVATE, reg::PropertyKind::TypedNull, MAY_BE_NULL | MAY_BE_STRING); cls.property("lazyClassReflection", ZEND_ACC_PRIVATE, reg::PropertyKind::TypedNull, MAY_BE_NULL, "PHPStan\\Reflection\\ClassReflection"); + cls.property("returnedClassReflection", ZEND_ACC_PRIVATE, reg::PropertyKind::TypedNull, MAY_BE_NULL, "PHPStan\\Reflection\\ClassReflection"); cls.property("enumCases", ZEND_ACC_PRIVATE | ZEND_ACC_STATIC, reg::PropertyKind::TypedEmptyArray, MAY_BE_ARRAY); cls.property("descriptionCacheOrder", ZEND_ACC_PRIVATE | ZEND_ACC_STATIC, reg::PropertyKind::TypedNull, MAY_BE_NULL, "PHPStan\\Internal\\LruCache"); cls.property("lastTouchedDescription", ZEND_ACC_PRIVATE | ZEND_ACC_STATIC, reg::PropertyKind::TypedNull, MAY_BE_NULL | MAY_BE_STRING); diff --git a/turbo-ext/src/generated/TemplateGenericObjectType.h b/turbo-ext/src/generated/TemplateGenericObjectType.h index b0c1d3ce5da..b374faf6998 100644 --- a/turbo-ext/src/generated/TemplateGenericObjectType.h +++ b/turbo-ext/src/generated/TemplateGenericObjectType.h @@ -10,12 +10,12 @@ namespace ptdecl::TemplateGenericObjectType { /* the OBJ_PROP_NUM slots of the instance properties the class declares (the inherited ones come first) */ namespace slot { -inline constexpr uint32_t name = 13; -inline constexpr uint32_t scope = 14; -inline constexpr uint32_t strategy = 15; -inline constexpr uint32_t variance = 16; -inline constexpr uint32_t bound = 17; -inline constexpr uint32_t default_ = 18; +inline constexpr uint32_t name = 14; +inline constexpr uint32_t scope = 15; +inline constexpr uint32_t strategy = 16; +inline constexpr uint32_t variance = 17; +inline constexpr uint32_t bound = 18; +inline constexpr uint32_t default_ = 19; } // namespace slot inline void declareClass(reg::Class &cls) diff --git a/turbo-ext/src/generated/TemplateObjectType.h b/turbo-ext/src/generated/TemplateObjectType.h index fb1117c6dde..47debde35d4 100644 --- a/turbo-ext/src/generated/TemplateObjectType.h +++ b/turbo-ext/src/generated/TemplateObjectType.h @@ -10,12 +10,12 @@ namespace ptdecl::TemplateObjectType { /* the OBJ_PROP_NUM slots of the instance properties the class declares (the inherited ones come first) */ namespace slot { -inline constexpr uint32_t name = 10; -inline constexpr uint32_t scope = 11; -inline constexpr uint32_t strategy = 12; -inline constexpr uint32_t variance = 13; -inline constexpr uint32_t bound = 14; -inline constexpr uint32_t default_ = 15; +inline constexpr uint32_t name = 11; +inline constexpr uint32_t scope = 12; +inline constexpr uint32_t strategy = 13; +inline constexpr uint32_t variance = 14; +inline constexpr uint32_t bound = 15; +inline constexpr uint32_t default_ = 16; } // namespace slot inline void declareClass(reg::Class &cls) From 2a278a454c6b936f6a71dbbdc632a22702642f82 Mon Sep 17 00:00:00 2001 From: "ondrej@mirtes.cz" Date: Sat, 14 Nov 2026 01:24:36 +0000 Subject: [PATCH 11/18] Scan a small flattened union instead of indexing it in TypeCombinator::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. --- src/Dependency/DependencyResolver.php | 9 +- src/Type/TypeCombinator.php | 76 +++++++++++----- tests/bench/CollectorsRegistryBench.php | 43 +++++++++ tests/bench/ComposerHelperBench.php | 39 ++++++++ tests/bench/ConstantArrayTypeAcceptsBench.php | 74 +++++++++++++++ tests/bench/DependencyResolverBench.php | 91 +++++++++++++++++++ tests/bench/FileCacheStorageBench.php | 87 ++++++++++++++++++ tests/bench/FileExcluderBench.php | 72 +++++++++++++++ tests/bench/FileFinderBench.php | 71 +++++++++++++++ tests/bench/LoaderFactoryBench.php | 49 ++++++++++ tests/bench/NetteContainerBench.php | 44 +++++++++ .../bench/PackageDependencyResolverBench.php | 61 +++++++++++++ ...ParentDirectoryRelativePathHelperBench.php | 57 ++++++++++++ 13 files changed, 748 insertions(+), 25 deletions(-) create mode 100644 tests/bench/CollectorsRegistryBench.php create mode 100644 tests/bench/ComposerHelperBench.php create mode 100644 tests/bench/ConstantArrayTypeAcceptsBench.php create mode 100644 tests/bench/DependencyResolverBench.php create mode 100644 tests/bench/FileCacheStorageBench.php create mode 100644 tests/bench/FileExcluderBench.php create mode 100644 tests/bench/FileFinderBench.php create mode 100644 tests/bench/LoaderFactoryBench.php create mode 100644 tests/bench/NetteContainerBench.php create mode 100644 tests/bench/PackageDependencyResolverBench.php create mode 100644 tests/bench/ParentDirectoryRelativePathHelperBench.php diff --git a/src/Dependency/DependencyResolver.php b/src/Dependency/DependencyResolver.php index e4ee58b46ea..00ce15a0f05 100644 --- a/src/Dependency/DependencyResolver.php +++ b/src/Dependency/DependencyResolver.php @@ -177,15 +177,16 @@ public function resolveFileDependencies(?Dependencies $dependencies, array $anal $functionNames = []; foreach ($reflectionsByFile as $file => $reflections) { foreach ($reflections as $id => $reflection) { - if ($reflection instanceof FunctionReflection) { - $functionNames[$reflection->getName()] = $reflection->getName(); + // classes first, they are most of the reflections + if ($reflection instanceof ClassReflection) { + $classReflections[$id] = $reflection; continue; } - if (!$reflection instanceof ClassReflection) { + if (!$reflection instanceof FunctionReflection) { continue; } - $classReflections[$id] = $reflection; + $functionNames[$reflection->getName()] = $reflection->getName(); } $this->resolveFiles($file, $reflections, $analysedFiles, $fileDependencies, $packages); } diff --git a/src/Type/TypeCombinator.php b/src/Type/TypeCombinator.php index d0cc73c56fd..426964dc43f 100644 --- a/src/Type/TypeCombinator.php +++ b/src/Type/TypeCombinator.php @@ -63,6 +63,12 @@ final class TypeCombinator { + /** + * Up to this many members of the one union doUnion() flattens, finding out whether two of + * them were already compared by scanning it costs less than indexing it. + */ + private const ALREADY_NORMALIZED_SCAN_LIMIT = 4; + /** * Turned on by TurboExtensionEnabler when the native extension is active, so that * union(), intersect() and remove() route through the memoizing TypeCombinatorCache. @@ -526,27 +532,32 @@ public static function doUnion(Type ...$types): Type static fn (IntegerRangeType $a, IntegerRangeType $b): int => ($a->getMin() ?? PHP_INT_MIN) <=> ($b->getMin() ?? PHP_INT_MIN) ?: ($a->getMax() ?? PHP_INT_MAX) <=> ($b->getMax() ?? PHP_INT_MAX), ); - usort( - $floatRangeTypes, - static fn (FloatRangeType $a, FloatRangeType $b): int => $a->getMin() <=> $b->getMin() ?: $a->getMax() <=> $b->getMax(), - ); // array_merge() hands the first array back as it is when the second one // is empty, so the keys the bucketing above unset must be renumbered here - // sorted by min, touching float ranges are neighbours: merging them now lets a - // constant meet the merged range (float and NAN are float) - $mergedFloatRangeTypes = []; - foreach ($floatRangeTypes as $floatRangeType) { - $last = count($mergedFloatRangeTypes) - 1; - if ($last >= 0) { - $merged = $mergedFloatRangeTypes[$last]->tryUnion($floatRangeType); - if ($merged instanceof FloatRangeType) { - $mergedFloatRangeTypes[$last] = $merged; - continue; + if ($floatRangeTypes === []) { + // most unions have no float range, and sorting an empty list still creates the closure + $types = array_merge(array_values($types), $integerRangeTypes); + } else { + usort( + $floatRangeTypes, + static fn (FloatRangeType $a, FloatRangeType $b): int => $a->getMin() <=> $b->getMin() ?: $a->getMax() <=> $b->getMax(), + ); + // sorted by min, touching float ranges are neighbours: merging them now lets a + // constant meet the merged range (float and NAN are float) + $mergedFloatRangeTypes = []; + foreach ($floatRangeTypes as $floatRangeType) { + $last = count($mergedFloatRangeTypes) - 1; + if ($last >= 0) { + $merged = $mergedFloatRangeTypes[$last]->tryUnion($floatRangeType); + if ($merged instanceof FloatRangeType) { + $mergedFloatRangeTypes[$last] = $merged; + continue; + } } + $mergedFloatRangeTypes[] = $floatRangeType; } - $mergedFloatRangeTypes[] = $floatRangeType; + $types = array_merge(array_values($types), $integerRangeTypes, $mergedFloatRangeTypes); } - $types = array_merge(array_values($types), $integerRangeTypes, $mergedFloatRangeTypes); $typesCount = count($types); if (isset($scalarTypes[IntersectionType::class])) { @@ -625,7 +636,10 @@ public static function doUnion(Type ...$types): Type // unique while this lookup refers to them $normalizedGroupsByType = []; $normalizedTwice = []; - if ($typesCount > 1) { + // a single flattened union of a few members, as in most unions, is cheaper to scan - without + // new local variables, which every call of this method initializes + $scanAlreadyNormalized = $alreadyNormalizedCounter === 1 && count($alreadyNormalized[0]) <= self::ALREADY_NORMALIZED_SCAN_LIMIT; + if ($typesCount > 1 && !$scanAlreadyNormalized) { foreach ($alreadyNormalized as $group => $normalizedTypes) { foreach ($normalizedTypes as $normalizedType) { $normalizedTypeId = spl_object_id($normalizedType); @@ -641,13 +655,31 @@ public static function doUnion(Type ...$types): Type // transform A | A to A // transform A | never to A for ($i = 0; $i < $typesCount; $i++) { - $iGroups = $normalizedGroupsByType[spl_object_id($types[$i])] ?? null; + if ($scanAlreadyNormalized) { + // null for a type not in the flattened union, which skips the scan of its pairs below + $iGroups = in_array($types[$i], $alreadyNormalized[0], true) ? [] : null; + } else { + $iGroups = $normalizedGroupsByType[spl_object_id($types[$i])] ?? null; + } for ($j = $i + 1; $j < $typesCount; $j++) { if ($isPlainConstantArray !== null && $isPlainConstantArray[$i] && $isPlainConstantArray[$j]) { continue; } - if ($iGroups !== null && self::isAlreadyNormalized($iGroups, $normalizedGroupsByType, $normalizedTwice, $types[$i], $types[$j])) { - continue; + if ($iGroups !== null) { + if ($scanAlreadyNormalized) { + // what isAlreadyNormalized() answers: $types[$j] is another member of + // the flattened union, which holds $types[$i] + $normalizedTypes = $alreadyNormalized[0]; + if ( + $types[$i] !== $types[$j] + ? in_array($types[$j], $normalizedTypes, true) + : count(array_keys($normalizedTypes, $types[$j], true)) > 1 + ) { + continue; + } + } elseif (self::isAlreadyNormalized($iGroups, $normalizedGroupsByType, $normalizedTwice, $types[$i], $types[$j])) { + continue; + } } $compareResult = self::compareTypesInUnion($types[$i], $types[$j]); if ($compareResult === null) { @@ -657,7 +689,9 @@ public static function doUnion(Type ...$types): Type [$a, $b] = $compareResult; if ($a !== null) { $types[$i] = $a; - $iGroups = $normalizedGroupsByType[spl_object_id($a)] ?? null; + $iGroups = $scanAlreadyNormalized + ? (in_array($a, $alreadyNormalized[0], true) ? [] : null) + : ($normalizedGroupsByType[spl_object_id($a)] ?? null); if ($isPlainConstantArray !== null) { $isPlainConstantArray[$i] = $a->getConstantArrays() === [$a]; array_splice($isPlainConstantArray, $j, 1); diff --git a/tests/bench/CollectorsRegistryBench.php b/tests/bench/CollectorsRegistryBench.php new file mode 100644 index 00000000000..5e64bdf33f7 --- /dev/null +++ b/tests/bench/CollectorsRegistryBench.php @@ -0,0 +1,43 @@ +scratch); + // the rules registry asked about the node type first + (new Registry([]))->getCollectors(MethodCall::class); + $this->registry = new Registry([]); + } + + /** + * The first time the collectors registry is asked about a node type. + * + * @return mixed[] + */ + public function benchGetCollectors(): array + { + if ($this->registry === null) { + throw new LogicException('boot() did not run.'); + } + + return $this->registry->getCollectors(MethodCall::class); + } + +} diff --git a/tests/bench/ComposerHelperBench.php b/tests/bench/ComposerHelperBench.php new file mode 100644 index 00000000000..52a09935dcc --- /dev/null +++ b/tests/bench/ComposerHelperBench.php @@ -0,0 +1,39 @@ +scratch); + $this->root = dirname((string) (new ReflectionClass(ContainerFactory::class))->getFileName(), 3); + ComposerHelper::getComposerConfig($this->root); + } + + /** + * Several services read the same composer.json while the container is built. + * + * @return array|null + */ + public function benchGetComposerConfigAgain(): ?array + { + return ComposerHelper::getComposerConfig($this->root); + } + +} diff --git a/tests/bench/ConstantArrayTypeAcceptsBench.php b/tests/bench/ConstantArrayTypeAcceptsBench.php new file mode 100644 index 00000000000..ddc3d3517cb --- /dev/null +++ b/tests/bench/ConstantArrayTypeAcceptsBench.php @@ -0,0 +1,74 @@ + + */ + public static function keys(): iterable + { + foreach ([16, 128] as $keys) { + yield 'keys-' . $keys => [$keys]; + } + } + + #[Before] + public function boot(int $keys): void + { + BenchContainer::create($this->scratch); + + // the counted call does not autoload + self::shape('warmup', 2, new UnionType([new IntegerType(), new StringType(), new NullType()])) + ->accepts(self::shape('warmup', 2, new UnionType([new IntegerType(), new StringType()])), true); + + // every offset of a shape has the same value type, such as a type alias used for each of them + $this->accepting = self::shape('key', $keys, new UnionType([new IntegerType(), new StringType(), new NullType()])); + $this->accepted = self::shape('key', $keys, new UnionType([new IntegerType(), new StringType()])); + } + + #[DataProvider(method: 'keys')] + public function benchAcceptsSharedValueType(int $keys): AcceptsResult + { + if ($this->accepting === null || $this->accepted === null) { + throw new LogicException('boot() did not run.'); + } + + return $this->accepting->accepts($this->accepted, true); + } + + private static function shape(string $prefix, int $keys, Type $valueType): ConstantArrayType + { + $keyTypes = []; + for ($i = 0; $i < $keys; $i++) { + $keyTypes[] = new ConstantStringType($prefix . $i); + } + + return new ConstantArrayType($keyTypes, array_fill(0, $keys, $valueType)); + } + +} diff --git a/tests/bench/DependencyResolverBench.php b/tests/bench/DependencyResolverBench.php new file mode 100644 index 00000000000..25ac1154bee --- /dev/null +++ b/tests/bench/DependencyResolverBench.php @@ -0,0 +1,91 @@ + */ + private array $analysedFiles = []; + + public function __construct(private ScratchDirectory $scratch) + { + } + + /** + * @return iterable + */ + public static function references(): iterable + { + yield 'analysed' => ['analysed']; + yield 'not-analysed' => ['not-analysed']; + // the class file itself is among the dependencies + yield 'own-file' => ['own-file']; + } + + #[Before] + public function boot(string $case): void + { + $container = BenchContainer::create($this->scratch); + $this->resolver = $container->getByType(DependencyResolver::class); + $fileHelper = $container->getByType(FileHelper::class); + + $file = $fileHelper->normalizePath(__FILE__); + if ($case === 'own-file') { + $file = $fileHelper->normalizePath((string) $container->getByType(ReflectionProvider::class)->getClass(self::REFERENCED_CLASS)->getFileName()); + } + + $this->analysedFiles = []; + if ($case !== 'not-analysed') { + $src = dirname((string) (new ReflectionClass(ContainerFactory::class))->getFileName(), 2); + foreach (new RecursiveIteratorIterator(new RecursiveDirectoryIterator($src, FilesystemIterator::SKIP_DOTS)) as $srcFile) { + /** @var SplFileInfo $srcFile */ + $this->analysedFiles[$fileHelper->normalizePath($srcFile->getPathname())] = true; + } + } + + $this->dependencies = Dependencies::create($file, [], [self::REFERENCED_CLASS]); + + // what the analysis of the files before memoized + $this->benchResolveClassReference($case); + } + + /** + * The files and packages a reference to a class from an analysed file depends on. + */ + #[DataProvider(method: 'references')] + public function benchResolveClassReference(string $case): ResolvedDependencies + { + if ($this->resolver === null || $this->dependencies === null) { + throw new LogicException('boot() did not run.'); + } + + return $this->resolver->resolveFileDependencies($this->dependencies, $this->analysedFiles); + } + +} diff --git a/tests/bench/FileCacheStorageBench.php b/tests/bench/FileCacheStorageBench.php new file mode 100644 index 00000000000..805f4515929 --- /dev/null +++ b/tests/bench/FileCacheStorageBench.php @@ -0,0 +1,87 @@ +}> */ + private array $data = []; + + public function __construct(private ScratchDirectory $scratch) + { + } + + /** + * @return iterable + */ + public static function entries(): iterable + { + foreach ([100, 10000] as $entries) { + yield 'entries-' . $entries => [$entries]; + } + } + + /** + * @throws DirectoryCreatorException + */ + #[Before] + public function boot(int $entries): void + { + BenchContainer::create($this->scratch); + $this->storage = new FileCacheStorage($this->scratch->path() . '/file-cache-bench'); + + // the counted calls do not autoload + $this->storage->save('warmup', 'v1', self::data(2)); + $this->storage->load('warmup', 'v1'); + + $this->data = self::data($entries); + $this->storage->save('load-' . $entries, 'v1', $this->data); + } + + /** + * @throws DirectoryCreatorException + */ + #[DataProvider(method: 'entries')] + public function benchSave(int $entries): void + { + if ($this->storage === null) { + throw new LogicException('boot() did not run.'); + } + + $this->storage->save('save-' . $entries, 'v1', $this->data); + } + + #[DataProvider(method: 'entries')] + public function benchLoad(int $entries): mixed + { + if ($this->storage === null) { + throw new LogicException('boot() did not run.'); + } + + return $this->storage->load('load-' . $entries, 'v1'); + } + + /** + * @return list}> shaped like the cached PHPDoc and reflection data + */ + private static function data(int $entries): array + { + $data = []; + for ($i = 0; $i < $entries; $i++) { + $data[] = ['App\\Model\\Entity' . $i, $i, ['int', 'string|null', 'list']]; + } + + return $data; + } + +} diff --git a/tests/bench/FileExcluderBench.php b/tests/bench/FileExcluderBench.php new file mode 100644 index 00000000000..5c44f720d74 --- /dev/null +++ b/tests/bench/FileExcluderBench.php @@ -0,0 +1,72 @@ + + */ + public static function excludedFiles(): iterable + { + foreach ([16, 128, 1024] as $files) { + yield 'files-' . $files => [$files]; + } + } + + #[Before] + public function boot(int $files): void + { + BenchContainer::create($this->scratch); + $directory = $this->scratch->path() . '/file-excluder-bench'; + if (!is_dir($directory)) { + mkdir($directory); + } + + // excludes naming single files, as in excludePaths + $excludes = []; + for ($i = 0; $i < $files; $i++) { + $excludes[] = $directory . '/Excluded' . $i . '.php'; + touch($directory . '/Excluded' . $i . '.php'); + } + + $this->excluder = new FileExcluder(new FileHelper($directory), $excludes); + $this->file = $directory . '/Analysed.php'; + $this->excluder->isExcludedFromAnalysing($directory . '/Warmup.php'); + } + + /** + * Every found file is checked against the excludes. + */ + #[DataProvider(method: 'excludedFiles')] + #[Constant(size: 'files')] + public function benchIsNotExcluded(int $files): bool + { + if ($this->excluder === null) { + throw new LogicException('boot() did not run.'); + } + + return $this->excluder->isExcludedFromAnalysing($this->file); + } + +} diff --git a/tests/bench/FileFinderBench.php b/tests/bench/FileFinderBench.php new file mode 100644 index 00000000000..3bace4cc118 --- /dev/null +++ b/tests/bench/FileFinderBench.php @@ -0,0 +1,71 @@ + */ + private array $paths = []; + + public function __construct(private ScratchDirectory $scratch) + { + } + + #[Before] + public function boot(): void + { + $container = BenchContainer::create($this->scratch); + $directory = $this->scratch->path() . '/file-finder-bench'; + foreach ([$directory, $directory . '/src', $directory . '/excluded'] as $dir) { + if (is_dir($dir)) { + continue; + } + + mkdir($dir); + } + + // the paths given on the command line, half of them in an excluded directory + $this->paths = []; + for ($i = 0; $i < self::FILES; $i++) { + $path = $directory . '/' . ($i % 2 === 0 ? 'src' : 'excluded') . '/File' . $i . '.php'; + touch($path); + $this->paths[] = $path; + } + + $fileHelper = new FileHelper($directory); + $excluder = new FileExcluder($fileHelper, [$directory . '/excluded']); + $this->finder = class_exists(DirectoryWalker::class) + ? new FileFinder($excluder, $fileHelper, ['php'], $container->getByType(DirectoryWalker::class)) + // @phpstan-ignore arguments.count (before 2.2.14) + : new FileFinder($excluder, $fileHelper, ['php']); + $this->finder->findFiles([$this->paths[0], $this->paths[1]]); + } + + public function benchFindFiles(): FileFinderResult + { + if ($this->finder === null) { + throw new LogicException('boot() did not run.'); + } + + return $this->finder->findFiles($this->paths); + } + +} diff --git a/tests/bench/LoaderFactoryBench.php b/tests/bench/LoaderFactoryBench.php new file mode 100644 index 00000000000..e14e30f825f --- /dev/null +++ b/tests/bench/LoaderFactoryBench.php @@ -0,0 +1,49 @@ +scratch); + $root = dirname((string) (new ReflectionClass(ContainerFactory::class))->getFileName(), 3); + $this->loaderFactory = new LoaderFactory(new FileHelper($root), $root, $root, null, []); + $this->configFile = $root . '/conf/config.level8.neon'; + $this->loaderFactory->createLoader()->load($this->configFile); + } + + /** + * Building the container loads the same config files more than once, with all they include. + * + * @return mixed[] + */ + public function benchLoadConfigAgain(): array + { + if ($this->loaderFactory === null) { + throw new LogicException('boot() did not run.'); + } + + return $this->loaderFactory->createLoader()->load($this->configFile); + } + +} diff --git a/tests/bench/NetteContainerBench.php b/tests/bench/NetteContainerBench.php new file mode 100644 index 00000000000..c48e4442530 --- /dev/null +++ b/tests/bench/NetteContainerBench.php @@ -0,0 +1,44 @@ +container = BenchContainer::create($this->scratch); + $this->container->getParameter('tmpDir'); + } + + public function benchGetParameter(): mixed + { + if ($this->container === null) { + throw new LogicException('boot() did not run.'); + } + + return $this->container->getParameter('tmpDir'); + } + + public function benchHasParameter(): bool + { + if ($this->container === null) { + throw new LogicException('boot() did not run.'); + } + + return $this->container->hasParameter('tmpDir'); + } + +} diff --git a/tests/bench/PackageDependencyResolverBench.php b/tests/bench/PackageDependencyResolverBench.php new file mode 100644 index 00000000000..fe8c1da880a --- /dev/null +++ b/tests/bench/PackageDependencyResolverBench.php @@ -0,0 +1,61 @@ + + */ + public static function files(): iterable + { + yield 'package-file' => ['vendor/nikic/php-parser/lib/PhpParser/Node/Expr/Variable.php']; + // matches none of the installed packages + yield 'project-file' => ['src/Analyser/MutatingScope.php']; + } + + #[Before] + public function boot(string $file): void + { + BenchContainer::create($this->scratch); + $root = dirname((string) (new ReflectionClass(ContainerFactory::class))->getFileName(), 3); + $fileHelper = new FileHelper($root); + // the installed packages of PHPStan's own vendor directory + $this->resolver = new PackageDependencyResolver([$root], $fileHelper); + $this->file = $fileHelper->normalizePath($root . '/' . $file); + $this->resolver->resolvePackage($this->file); + } + + /** + * Every analysed file that depends on a file resolves it again. + */ + #[DataProvider(method: 'files')] + public function benchResolvePackageAgain(string $file): ?string + { + if ($this->resolver === null) { + throw new LogicException('boot() did not run.'); + } + + return $this->resolver->resolvePackage($this->file); + } + +} diff --git a/tests/bench/ParentDirectoryRelativePathHelperBench.php b/tests/bench/ParentDirectoryRelativePathHelperBench.php new file mode 100644 index 00000000000..2d800c99b8b --- /dev/null +++ b/tests/bench/ParentDirectoryRelativePathHelperBench.php @@ -0,0 +1,57 @@ + + */ + public static function depths(): iterable + { + foreach ([4, 16, 64] as $depth) { + yield 'depth-' . $depth => [$depth]; + } + } + + #[Before] + public function boot(int $depth): void + { + BenchContainer::create($this->scratch); + // the working directory and the file share $depth directories + $directory = '/' . implode('/', range(1, $depth)); + $this->helper = new ParentDirectoryRelativePathHelper($directory . '/project'); + $this->file = $directory . '/src/Foo.php'; + $this->helper->getRelativePath('/warmup/src/Foo.php'); + } + + #[DataProvider(method: 'depths')] + #[Linear(size: 'depth')] + public function benchGetRelativePath(int $depth): string + { + if ($this->helper === null) { + throw new LogicException('boot() did not run.'); + } + + return $this->helper->getRelativePath($this->file); + } + +} From 0dbc535ff6b68cc81765d26935da2b33c9a89087 Mon Sep 17 00:00:00 2001 From: "ondrej@mirtes.cz" Date: Sat, 14 Nov 2026 02:13:09 +0000 Subject: [PATCH 12/18] Describe each conditional expression part once when merging scopes 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 --- src/Analyser/ConditionalExpressionHolder.php | 1 + src/Analyser/MutatingScope.php | 6 + src/Analyser/ScopeOps.php | 79 ++-- tests/bench/LazyInternalScopeFactoryBench.php | 52 +++ tests/bench/MutatingScopeBranchMergeBench.php | 157 ++++++++ tests/bench/MutatingScopeExpressionsBench.php | 352 ++++++++++++++++++ tests/bench/NodeScopeResolverWalkBench.php | 148 ++++++++ tests/bench/TypeSpecifierBench.php | 225 +++++++++++ turbo-ext/src/ScopeOps.cpp | 72 +++- turbo-ext/src/support.cpp | 105 ++++-- turbo-ext/src/support.h | 3 + 11 files changed, 1134 insertions(+), 66 deletions(-) create mode 100644 tests/bench/LazyInternalScopeFactoryBench.php create mode 100644 tests/bench/MutatingScopeBranchMergeBench.php create mode 100644 tests/bench/MutatingScopeExpressionsBench.php create mode 100644 tests/bench/NodeScopeResolverWalkBench.php create mode 100644 tests/bench/TypeSpecifierBench.php diff --git a/src/Analyser/ConditionalExpressionHolder.php b/src/Analyser/ConditionalExpressionHolder.php index 107e5833140..73715ccf03d 100644 --- a/src/Analyser/ConditionalExpressionHolder.php +++ b/src/Analyser/ConditionalExpressionHolder.php @@ -39,6 +39,7 @@ public function getTypeHolder(): ExpressionTypeHolder return $this->typeHolder; } + /** ScopeOps::createConditionalExpressions() builds the same key from parts; keep the two in sync. */ public function getKey(): string { $parts = []; diff --git a/src/Analyser/MutatingScope.php b/src/Analyser/MutatingScope.php index 97092f2d501..7548fb28976 100644 --- a/src/Analyser/MutatingScope.php +++ b/src/Analyser/MutatingScope.php @@ -983,6 +983,12 @@ public function getScopeNativeType(Expr $expr): Type public function getNodeKey(Expr $node): string { + // ScopeOps::nodeKey() has the same fast path, but this method is hot enough + // that the static call and the services fetch before it are measurable + if ($node instanceof Variable && !$node->name instanceof Expr) { + return '$' . $node->name; + } + return ScopeOps::nodeKey($node, $this->services->exprPrinter); } diff --git a/src/Analyser/ScopeOps.php b/src/Analyser/ScopeOps.php index 26480258178..80c2511bb20 100644 --- a/src/Analyser/ScopeOps.php +++ b/src/Analyser/ScopeOps.php @@ -27,6 +27,7 @@ use PHPStan\Type\Type; use PHPStan\Type\TypeCombinator; use PHPStan\Type\UnionType; +use PHPStan\Type\VerbosityLevel; use function array_filter; use function array_key_exists; use function array_key_first; @@ -389,6 +390,26 @@ public static function createConditionalExpressions( ): array { $newVariableTypes = $ourExpressionTypes; + $differingExprStrings = array_keys($differingKeys); + + // A guard needs its expression certainly defined in both branches. Collect + // those keys once - the guard loop below walks only them, and without any + // neither target loop can add a holder, which skips the passes over every key. + $guardCandidates = []; + foreach ($differingExprStrings as $exprString) { + if ( + !array_key_exists($exprString, $newVariableTypes) + || !array_key_exists($exprString, $theirExpressionTypes) + || !$newVariableTypes[$exprString]->getCertainty()->yes() + || !$theirExpressionTypes[$exprString]->getCertainty()->yes() + ) { + continue; + } + $guardCandidates[] = $exprString; + } + if ($guardCandidates === []) { + return $conditionalExpressions; + } // A guard is only ever consumed paired with a target: a *different* key in // the first target loop below, any key in the second one. Deriving a guard @@ -399,7 +420,7 @@ public static function createConditionalExpressions( // set instead of repeating the checks. $targets = []; $hasUndefinedTarget = false; - foreach (array_keys($differingKeys) as $exprString) { + foreach ($differingExprStrings as $exprString) { if (!array_key_exists($exprString, $newVariableTypes)) { if ( array_key_exists($exprString, $mergedExpressionTypes) @@ -432,7 +453,7 @@ public static function createConditionalExpressions( // valid conditional *target*; the flag also lets the target loop skip // pairing these absorbed targets with constant-array guards. $guardsToExclude = []; - foreach (array_keys($differingKeys) as $exprString) { + foreach ($differingExprStrings as $exprString) { if (!array_key_exists($exprString, $theirExpressionTypes)) { continue; } @@ -457,10 +478,10 @@ public static function createConditionalExpressions( } $typeGuards = []; - foreach (array_keys($differingKeys) as $exprString) { - if (!array_key_exists($exprString, $newVariableTypes)) { - continue; - } + // with no their-branch entry the merged holder keeps our type with lowered + // certainty, so no later type assertion can tell the branches apart - the + // candidates are certain in both branches + foreach ($guardCandidates as $exprString) { $holder = $newVariableTypes[$exprString]; if ($holder->getExpr() instanceof VirtualNode) { continue; @@ -468,23 +489,11 @@ public static function createConditionalExpressions( if (!array_key_exists($exprString, $mergedExpressionTypes)) { continue; } - if (!$holder->getCertainty()->yes()) { - continue; - } if (array_key_exists($exprString, $guardsToExclude)) { continue; } - if (!array_key_exists($exprString, $theirExpressionTypes)) { - // with no their-branch entry the merged holder keeps our type with - // lowered certainty, so no later type assertion can tell the - // branches apart - continue; - } $theirHolder = $theirExpressionTypes[$exprString]; - if (!$theirHolder->getCertainty()->yes()) { - continue; - } if ($holder->equalTypes($theirHolder)) { continue; } @@ -530,6 +539,13 @@ public static function createConditionalExpressions( $guardIsSuperTypeOfTheirExprCache = []; $theirExprIsSuperTypeOfGuardCache = []; + // The holders below are keyed by ConditionalExpressionHolder::getKey(), + // which describes both the guard and the target type of each pair. Build + // the same key from the guard part described once per guard and the + // target part described once per target instead of once per pair - the + // descriptions of big constant arrays dominate the merge otherwise. + $guardKeyPrefixes = []; + foreach (array_keys($targets) as $exprString) { $holder = $newVariableTypes[$exprString]; @@ -540,6 +556,8 @@ public static function createConditionalExpressions( continue; } + $targetKeySuffix = null; + $exprIsGuardExcluded = array_key_exists($exprString, $guardsToExclude); foreach ($variableTypeGuards as $guardExprString => $guardHolder) { // A subtype-absorbed target (kept only for re-narrowing) paired with a @@ -578,12 +596,13 @@ public static function createConditionalExpressions( } } - $conditionalExpression = new ConditionalExpressionHolder([$guardExprString => $guardHolder], $holder); - $conditionalExpressions[$exprString][$conditionalExpression->getKey()] = $conditionalExpression; + $targetKeySuffix ??= self::conditionalExpressionKeySuffix($holder); + $guardKeyPrefixes[$guardExprString] ??= self::conditionalExpressionKeyPrefix($guardExprString, $guardHolder); + $conditionalExpressions[$exprString][$guardKeyPrefixes[$guardExprString] . $targetKeySuffix] = new ConditionalExpressionHolder([$guardExprString => $guardHolder], $holder); } } - foreach (array_keys($differingKeys) as $exprString) { + foreach ($differingExprStrings as $exprString) { if (!array_key_exists($exprString, $mergedExpressionTypes)) { continue; } @@ -595,15 +614,29 @@ public static function createConditionalExpressions( continue; } + $undefinedHolder = new ExpressionTypeHolder($mergedExprTypeHolder->getExpr(), new ErrorType(), TrinaryLogic::createNo()); + $targetKeySuffix = self::conditionalExpressionKeySuffix($undefinedHolder); foreach ($typeGuards as $guardExprString => $guardHolder) { - $conditionalExpression = new ConditionalExpressionHolder([$guardExprString => $guardHolder], new ExpressionTypeHolder($mergedExprTypeHolder->getExpr(), new ErrorType(), TrinaryLogic::createNo())); - $conditionalExpressions[$exprString][$conditionalExpression->getKey()] = $conditionalExpression; + $guardKeyPrefixes[$guardExprString] ??= self::conditionalExpressionKeyPrefix($guardExprString, $guardHolder); + $conditionalExpressions[$exprString][$guardKeyPrefixes[$guardExprString] . $targetKeySuffix] = new ConditionalExpressionHolder([$guardExprString => $guardHolder], $undefinedHolder); } } return $conditionalExpressions; } + /** The part of ConditionalExpressionHolder::getKey() a single-guard holder's guard contributes. */ + private static function conditionalExpressionKeyPrefix(string $guardExprString, ExpressionTypeHolder $guardHolder): string + { + return $guardExprString . '=' . $guardHolder->getType()->describe(VerbosityLevel::precise()); + } + + /** The part of ConditionalExpressionHolder::getKey() the target holder contributes. */ + private static function conditionalExpressionKeySuffix(ExpressionTypeHolder $typeHolder): string + { + return ' => ' . $typeHolder->getType()->describe(VerbosityLevel::precise()) . ' (' . $typeHolder->getCertainty()->describe() . ')'; + } + /** * The part of our branch type the other branch cannot produce. * diff --git a/tests/bench/LazyInternalScopeFactoryBench.php b/tests/bench/LazyInternalScopeFactoryBench.php new file mode 100644 index 00000000000..ba5bb1a2584 --- /dev/null +++ b/tests/bench/LazyInternalScopeFactoryBench.php @@ -0,0 +1,52 @@ +scratch); + $this->factory = $container->getByType(InternalScopeFactoryFactory::class)->create(null); + $this->context = ScopeContext::create(__FILE__); + $this->factory->create($this->context); + } + + public function benchCreate(): MutatingScope + { + if ($this->factory === null || $this->context === null) { + throw new LogicException('boot() did not run.'); + } + + $scope = $this->factory->create($this->context); + for ($i = 1; $i < self::SCOPES; $i++) { + $scope = $this->factory->create($this->context); + } + + return $scope; + } + +} diff --git a/tests/bench/MutatingScopeBranchMergeBench.php b/tests/bench/MutatingScopeBranchMergeBench.php new file mode 100644 index 00000000000..e27539278d8 --- /dev/null +++ b/tests/bench/MutatingScopeBranchMergeBench.php @@ -0,0 +1,157 @@ + + */ + public static function branches(): iterable + { + // many variables both branches share the same holders of, one variable differs + foreach ([16, 256] as $variables) { + yield 'shared-holders-' . $variables => ['shared-holders', $variables]; + } + // every variable has a different union in each branch: each is a guard of the others + foreach ([4, 16] as $variables) { + yield 'differing-unions-' . $variables => ['differing-unions', $variables]; + } + // the variables of one branch are subtypes of the other's, a constant array differs + yield 'absorbed-targets-16' => ['absorbed-targets', 16]; + // the same types, certainly defined in one branch and maybe defined in the other + yield 'maybe-defined-64' => ['maybe-defined', 64]; + // variables defined only in one branch + yield 'one-sided-64' => ['one-sided', 64]; + } + + #[Before] + public function boot(string $shape, int $variables): void + { + $container = BenchContainer::create($this->scratch); + $scope = $container->getByType(ScopeFactory::class)->create(ScopeContext::create(__FILE__)); + + // the warm-up merges other variables, so it fills no cache the benchmark reads + [$ours, $theirs] = self::createBranches($scope, $shape, 'warmup', 2); + $ours->mergeWith($theirs); + + [$this->ours, $this->theirs] = self::createBranches($scope, $shape, 'v', $variables); + } + + #[DataProvider(method: 'branches')] + public function benchMergeWith(string $shape, int $variables): MutatingScope + { + if ($this->ours === null || $this->theirs === null) { + throw new LogicException('boot() did not run.'); + } + + return $this->ours->mergeWith($this->theirs); + } + + /** + * @return array{MutatingScope, MutatingScope} + */ + private static function createBranches(MutatingScope $scope, string $shape, string $prefix, int $variables): array + { + $yes = TrinaryLogic::createYes(); + $ours = $scope; + $theirs = $scope; + switch ($shape) { + case 'shared-holders': + for ($i = 0; $i < $variables; $i++) { + $type = self::strings($prefix . $i . '_', 8); + $scope = $scope->assignVariable($prefix . $i, $type, $type, $yes); + } + $ours = self::assign($scope, $prefix . 'x', new ConstantIntegerType(1)); + $theirs = self::assign($scope, $prefix . 'x', new ConstantIntegerType(2)); + break; + case 'differing-unions': + for ($i = 0; $i < $variables; $i++) { + $ours = self::assign($ours, $prefix . $i, self::strings($prefix . $i . 'a', 16)); + $theirs = self::assign($theirs, $prefix . $i, self::strings($prefix . $i . 'b', 16)); + } + break; + case 'absorbed-targets': + for ($i = 0; $i < $variables; $i++) { + $ours = self::assign($ours, $prefix . $i, new ConstantIntegerType($i)); + $theirs = self::assign($theirs, $prefix . $i, new IntegerType()); + } + $ours = self::assign($ours, $prefix . 'array', self::constantArray($prefix . 'a', 16)); + $theirs = self::assign($theirs, $prefix . 'array', self::constantArray($prefix . 'b', 16)); + break; + case 'maybe-defined': + for ($i = 0; $i < $variables; $i++) { + // the same type object, as when one branch assigned what the other only might have + $type = self::strings($prefix . $i . '_', 8); + $ours = self::assign($ours, $prefix . $i, $type); + $theirs = $theirs->assignVariable($prefix . $i, $type, $type, TrinaryLogic::createMaybe()); + } + break; + case 'one-sided': + for ($i = 0; $i < $variables; $i++) { + $ours = self::assign($ours, $prefix . $i, new ConstantIntegerType($i)); + } + break; + default: + throw new LogicException($shape); + } + + return [$ours, $theirs]; + } + + private static function assign(MutatingScope $scope, string $name, Type $type): MutatingScope + { + return $scope->assignVariable($name, $type, $type, TrinaryLogic::createYes()); + } + + private static function strings(string $prefix, int $count): Type + { + $types = []; + for ($i = 0; $i < $count; $i++) { + $types[] = new ConstantStringType($prefix . $i); + } + + return TypeCombinator::union(...$types); + } + + private static function constantArray(string $prefix, int $count): ConstantArrayType + { + $keys = []; + $values = []; + for ($i = 0; $i < $count; $i++) { + $keys[] = new ConstantStringType($prefix . $i); + $values[] = new ConstantIntegerType($i); + } + + return new ConstantArrayType($keys, $values); + } + +} diff --git a/tests/bench/MutatingScopeExpressionsBench.php b/tests/bench/MutatingScopeExpressionsBench.php new file mode 100644 index 00000000000..23c190b4657 --- /dev/null +++ b/tests/bench/MutatingScopeExpressionsBench.php @@ -0,0 +1,352 @@ + + */ + public static function expressions(): iterable + { + foreach ([16, 128] as $expressions) { + yield 'expressions-' . $expressions => [$expressions]; + } + } + + #[Before] + public function boot(int $expressions): void + { + $container = BenchContainer::create($this->scratch); + $this->scope = $container->getByType(ScopeFactory::class)->create(ScopeContext::create(__FILE__)); + } + + /** + * Without openssl_error_string() or file functions remembered in the scope, there is + * nothing to forget. + */ + #[DataProvider(method: 'expressions')] + #[Linear(size: 'expressions')] + public function benchAfterOpenSslAndClearstatcacheCall(int $expressions): MutatingScope + { + $scope = $this->meter->exclude(fn (): MutatingScope => $this->scopeWithProperties($expressions)); + + return $scope->afterOpenSslCall('openssl_sign')->afterClearstatcacheCall(); + } + + /** + * Removing never from an expression changes nothing and does not need its type. + */ + #[DataProvider(method: 'expressions')] + #[Linear(size: 'expressions')] + public function benchRemoveNeverFromExpression(int $expressions): MutatingScope + { + [$scope, $exprs] = $this->meter->exclude(function () use ($expressions): array { + $exprs = []; + for ($i = 0; $i < $expressions; $i++) { + $exprs[] = new ArrayDimFetch(new Variable('v' . $i), new String_('key')); + } + + return [$this->scopeWithArrays($expressions), $exprs]; + }); + + $never = new NeverType(); + foreach ($exprs as $expr) { + $scope = $scope->removeTypeFromExpression($expr, $never); + } + + return $scope; + } + + /** + * Narrowing $array['key'] also narrows $array to the types that can be accessed by an offset. + */ + #[DataProvider(method: 'expressions')] + #[Linear(size: 'expressions')] + public function benchSpecifyArrayDimFetch(int $expressions): MutatingScope + { + [$scope, $exprs] = $this->meter->exclude(function () use ($expressions): array { + $exprs = []; + for ($i = 0; $i < $expressions; $i++) { + $exprs[] = new ArrayDimFetch(new Variable('v' . $i), $i % 2 === 0 ? new Int_($i) : new String_('k' . $i)); + } + + return [$this->scopeWithArrays($expressions), $exprs]; + }); + + $int = new IntegerType(); + foreach ($exprs as $expr) { + $scope = $scope->specifyExpressionType($expr, $int, $int, TrinaryLogic::createYes()); + } + + return $scope; + } + + /** + * A method called on a union of objects that all have it. + */ + #[DataProvider(method: 'expressions')] + #[Linear(size: 'expressions')] + public function benchGetMethodReflectionOnUnion(int $expressions): int + { + $scope = $this->scope(); + $union = $this->meter->exclude(static function () use ($scope): Type { + $types = []; + foreach (self::COUNTABLE_CLASSES as $class) { + $types[] = new ObjectType($class); + } + $union = TypeCombinator::union(...$types); + // fills the reflection caches + $scope->getMethodReflection($union, 'count'); + $scope->getMethodReflection($union, 'valid'); + + return $union; + }); + + $found = 0; + for ($i = 0; $i < $expressions; $i++) { + $found += $scope->getMethodReflection($union, 'count') !== null ? 1 : 0; + $found += $scope->getMethodReflection($union, 'valid') !== null ? 1 : 0; + } + + return $found; + } + + /** + * Assigning $x forgets the remembered expressions that contain it, after searching each + * one whose key contains "$x" for it. + */ + #[DataProvider(method: 'expressions')] + #[Linear(size: 'expressions')] + public function benchInvalidateExpression(int $expressions): MutatingScope + { + $scope = $this->meter->exclude(function () use ($expressions): MutatingScope { + $scope = $this->scope(); + $int = new IntegerType(); + for ($i = 0; $i < $expressions; $i++) { + // $o->m0($x, $y)->p0, every other one with $xx, which only passes the substring prefilter + $expr = new PropertyFetch( + new MethodCall(new Variable('o'), 'm' . $i, [new Arg(new Variable($i % 2 === 0 ? 'x' : 'xx')), new Arg(new Variable('y'))]), + 'p' . $i, + ); + $scope = $scope->specifyExpressionType($expr, $int, $int, TrinaryLogic::createYes()); + } + + return $scope; + }); + + return $scope->invalidateExpression(new Variable('x')); + } + + /** + * Variables are by far the most common expressions that get a key. + */ + #[DataProvider(method: 'expressions')] + #[Linear(size: 'expressions')] + public function benchGetNodeKeyOfVariables(int $expressions): int + { + [$getNodeKey, $exprs] = $this->meter->exclude(function () use ($expressions): array { + $exprs = []; + for ($i = 0; $i < $expressions; $i++) { + $exprs[] = new Variable('variable' . $i); + } + + // private before 2.2 + $getNodeKey = (new ReflectionMethod(MutatingScope::class, 'getNodeKey'))->getClosure($this->scope()); + $getNodeKey(new Variable('warmup')); + + return [$getNodeKey, $exprs]; + }); + + /** @var Closure(Expr): string $getNodeKey */ + $length = 0; + foreach ($exprs as $expr) { + $length += strlen($getNodeKey($expr)); + } + + return $length; + } + + /** + * $object[$dim] on an ArrayAccess object is the type of $object->offsetGet($dim). + */ + #[DataProvider(method: 'expressions')] + #[Linear(size: 'expressions')] + public function benchGetTypeOfArrayAccessDimFetch(int $expressions): int + { + [$scope, $exprs] = $this->meter->exclude(function () use ($expressions): array { + $type = new GenericObjectType(ArrayObject::class, [new StringType(), new IntegerType()]); + $scope = $this->scope() + ->assignVariable('object', $type, $type, TrinaryLogic::createYes()) + ->assignVariable('key', new StringType(), new StringType(), TrinaryLogic::createYes()); + $scope->getType(new ArrayDimFetch(new Variable('object'), new Concat(new Variable('key'), new String_('warmup')))); + $exprs = []; + for ($i = 0; $i < $expressions; $i++) { + $exprs[] = new ArrayDimFetch(new Variable('object'), new Concat(new Variable('key'), new String_('k' . $i))); + } + + return [$scope, $exprs]; + }); + + $integers = 0; + foreach ($exprs as $expr) { + $integers += $scope->getType($expr)->isInteger()->yes() ? 1 : 0; + } + + return $integers; + } + + /** + * The type of `new ArrayObject($array)` infers the template types of the class from the + * arguments of the constructor. + */ + #[DataProvider(method: 'expressions')] + #[Linear(size: 'expressions')] + public function benchGetTypeOfGenericNew(int $expressions): int + { + [$scope, $exprs] = $this->meter->exclude(function () use ($expressions): array { + $scope = $this->scopeWithArrays($expressions + 1); + $scope->getType(new New_(new FullyQualified(ArrayObject::class), [new Arg(new Variable('v' . $expressions))])); + $scope->getType(new New_(new FullyQualified(ArrayIterator::class), [new Arg(new Variable('v' . $expressions))])); + $exprs = []; + for ($i = 0; $i < $expressions; $i++) { + $exprs[] = new New_(new FullyQualified($i % 2 === 0 ? ArrayObject::class : ArrayIterator::class), [new Arg(new Variable('v' . $i))]); + } + + return [$scope, $exprs]; + }); + + $classes = 0; + foreach ($exprs as $expr) { + $classes += count($scope->getType($expr)->getObjectClassNames()); + } + + return $classes; + } + + /** + * Each expression is resolved by the handler of its class. + */ + #[DataProvider(method: 'expressions')] + #[Linear(size: 'expressions')] + public function benchGetTypeOfFreshExpressions(int $expressions): int + { + [$scope, $exprs] = $this->meter->exclude(function () use ($expressions): array { + $scope = $this->scope()->assignVariable('i', new IntegerType(), new IntegerType(), TrinaryLogic::createYes()); + $scope->getType(new Plus(new Variable('i'), new Int_(-1))); + $exprs = []; + for ($i = 0; $i < $expressions; $i++) { + $exprs[] = new Plus(new Variable('i'), new Int_($i)); + } + + return [$scope, $exprs]; + }); + + $integers = 0; + foreach ($exprs as $expr) { + $integers += $scope->getType($expr)->isInteger()->yes() ? 1 : 0; + } + + return $integers; + } + + private function scope(): MutatingScope + { + if ($this->scope === null) { + throw new LogicException('boot() did not run.'); + } + + return $this->scope; + } + + /** + * $v0 to $vN, lists of strings + */ + private function scopeWithArrays(int $variables): MutatingScope + { + $scope = $this->scope(); + $type = new ArrayType(new IntegerType(), new StringType()); + for ($i = 0; $i < $variables; $i++) { + $scope = $scope->assignVariable('v' . $i, $type, $type, TrinaryLogic::createYes()); + } + + return $scope; + } + + /** + * $o->p0 to $o->pN remembered as 1 + */ + private function scopeWithProperties(int $properties): MutatingScope + { + $scope = $this->scope(); + $type = new ConstantIntegerType(1); + for ($i = 0; $i < $properties; $i++) { + $scope = $scope->specifyExpressionType(new PropertyFetch(new Variable('o'), 'p' . $i), $type, $type, TrinaryLogic::createYes()); + } + + return $scope; + } + +} diff --git a/tests/bench/NodeScopeResolverWalkBench.php b/tests/bench/NodeScopeResolverWalkBench.php new file mode 100644 index 00000000000..1dd3450573a --- /dev/null +++ b/tests/bench/NodeScopeResolverWalkBench.php @@ -0,0 +1,148 @@ + + */ + public static function statements(): iterable + { + // $r = 'a0' === $t || 'a1' === $t || ...; + foreach ([16, 32] as $arms) { + yield 'or-chain-' . $arms => [sprintf('$r = %s;', self::orChain('a', $arms))]; + } + + // writes into nested arrays that are not constant arrays + $assigns = []; + for ($i = 0; $i < 16; $i++) { + $assigns[] = sprintf('$nested[\'k%d\'][\'x\'][%d] = $i;', $i, $i); + } + yield 'nested-dim-assign' => [implode("\n", $assigns)]; + + // loops converge after a few passes over the body + yield 'foreach' => ['foreach ($list as $k => $v) { if ($v > $max) { $max = $v; $maxKey = $k; } elseif ($v < 0) { $negative[] = $k; } }']; + yield 'while' => ['while ($i < 100) { if ($i % 3 === 0) { $s .= \'a\'; } elseif ($i % 3 === 1) { $max = $i; } $i++; }']; + } + + #[Before] + public function boot(string $code): void + { + $this->container = BenchContainer::create($this->scratch); + $int = new IntegerType(); + $string = new StringType(); + $scope = $this->container->getByType(ScopeFactory::class)->create(ScopeContext::create(__FILE__)); + $variables = [ + 't' => $string, + 'i' => $int, + 's' => $string, + 'max' => $int, + 'list' => new ArrayType($int, $int), + 'negative' => new ArrayType($int, $int), + 'nested' => new ArrayType($string, new ArrayType($string, new ArrayType($int, $int))), + ]; + foreach ($variables as $name => $type) { + $scope = $scope->assignVariable($name, $type, $type, TrinaryLogic::createYes()); + } + $this->scope = $scope; + + // the warm-up walks every kind of statement once, with other constants + foreach (self::statements() as [$warmupCode]) { + $this->process($this->parse(str_replace(['\'a', '\'k'], ['\'w', '\'w'], $warmupCode))); + } + } + + #[DataProvider(method: 'statements')] + public function benchProcessStmtNodes(string $code): Type + { + $stmts = $this->meter->exclude(fn (): array => $this->parse($code)); + + return $this->process($stmts); + } + + /** + * @param Stmt[] $stmts + */ + private function process(array $stmts): Type + { + if ($this->container === null || $this->scope === null) { + throw new LogicException('boot() did not run.'); + } + + $scope = $this->scope; + $result = $this->container->getByType(NodeScopeResolver::class)->processStmtNodes( + new Stmt\Namespace_(null, $stmts), + $stmts, + $scope, + static function (Node $node, Scope $scope): void { + }, + StatementContext::createTopLevel(), + ); + + return $result->getScope()->getType(new Node\Expr\Variable('max')); + } + + /** + * @return Stmt[] + */ + private function parse(string $code): array + { + if ($this->container === null) { + throw new LogicException('boot() did not run.'); + } + + /** @var Parser $parser */ + $parser = $this->container->getService('currentPhpVersionRichParser'); + + return $parser->parseString(' + */ + public static function chains(): iterable + { + foreach (['falsey', 'truthy'] as $context) { + foreach ([16, 64] as $arms) { + yield $context . '-' . $arms => [$context, $arms]; + } + } + } + + /** + * @return iterable + */ + public static function contexts(): iterable + { + yield 'truthy' => ['truthy']; + yield 'true' => ['true']; + } + + #[Before] + public function boot(): void + { + $container = BenchContainer::create($this->scratch); + $this->typeSpecifier = $container->getByType(TypeSpecifier::class); + $object = new ObjectType(SplObjectStorage::class); + $this->scope = $container->getByType(ScopeFactory::class)->create(ScopeContext::create(__FILE__)) + ->assignVariable('t', new StringType(), new StringType(), TrinaryLogic::createYes()) + ->assignVariable('i', new IntegerType(), new IntegerType(), TrinaryLogic::createYes()) + ->assignVariable('o', $object, $object, TrinaryLogic::createYes()); + + // the warm-up uses other expressions, the scope remembers the types it resolved + $this->specify(self::orChain('warmup', 4), TypeSpecifierContext::createFalsey()); + $this->specify(self::orChain('warmup', 4), TypeSpecifierContext::createTruthy()); + } + + /** + * 'a' === $t || 'b' === $t || ..., which PHP parses to a chain as deep as it is long. + */ + #[DataProvider(method: 'chains')] + public function benchSpecifyBooleanOrChain(string $context, int $arms): SpecifiedTypes + { + $chain = $this->meter->exclude(static fn (): Expr => self::orChain('arm', $arms)); + + return $this->specify($chain, $context === 'falsey' ? TypeSpecifierContext::createFalsey() : TypeSpecifierContext::createTruthy()); + } + + /** + * $i ?: $t + */ + public function benchSpecifyShortTernary(): int + { + $exprs = $this->meter->exclude(static function (): array { + $exprs = []; + for ($i = 0; $i < self::EXPRESSIONS; $i++) { + $exprs[] = new Ternary(new Variable('i'), null, new Identical(new Variable('t'), new String_('t' . $i))); + } + + return $exprs; + }); + + return $this->specifyAll($exprs, TypeSpecifierContext::createTruthy()); + } + + /** + * An int remembered by match() compared with a string is always false. + */ + public function benchSpecifyAlwaysFalseIdentical(): int + { + $exprs = $this->meter->exclude(static function (): array { + $exprs = []; + $int = new IntegerType(); + for ($i = 0; $i < self::EXPRESSIONS; $i++) { + $exprs[] = new Identical(new AlwaysRememberedExpr(new Variable('i'), $int, $int), new String_('s' . $i)); + } + + return $exprs; + }); + + return $this->specifyAll($exprs, TypeSpecifierContext::createFalse()); + } + + /** + * abs($i) > 0 is not one of the calls a comparison narrows, like count() or strlen(). + */ + public function benchSpecifyComparisonWithCall(): int + { + $exprs = $this->meter->exclude(function (): array { + // loads the reflection of abs() + $this->specify(new Greater(new FuncCall(new Name('abs'), [new Arg(new Variable('i'))]), new Int_(-1)), TypeSpecifierContext::createTruthy()); + $exprs = []; + for ($i = 0; $i < self::EXPRESSIONS; $i++) { + $exprs[] = new Greater(new FuncCall(new Name('abs'), [new Arg(new Variable('i'))]), new Int_($i)); + } + + return $exprs; + }); + + return $this->specifyAll($exprs, TypeSpecifierContext::createTruthy()); + } + + /** + * Creating the narrowing of an expression: only the true and false contexts need its key. + */ + #[DataProvider(method: 'contexts')] + public function benchCreate(string $context): int + { + [$exprs, $type] = $this->meter->exclude(function (): array { + // loads the reflection of the method + $this->typeSpecifier()->create(new MethodCall(new Variable('o'), 'contains', [new Arg(new Variable('warmup'))]), new StringType(), TypeSpecifierContext::createTrue(), $this->scope()); + $exprs = []; + for ($i = 0; $i < self::EXPRESSIONS; $i++) { + $exprs[] = new MethodCall(new Variable('o'), 'contains', [new Arg(new Variable('o' . $i))]); + } + + return [$exprs, new StringType()]; + }); + + $typeContext = $context === 'true' ? TypeSpecifierContext::createTrue() : TypeSpecifierContext::createTruthy(); + $typeSpecifier = $this->typeSpecifier(); + $scope = $this->scope(); + $sure = 0; + foreach ($exprs as $expr) { + $sure += count($typeSpecifier->create($expr, $type, $typeContext, $scope)->getSureTypes()); + } + + return $sure; + } + + /** + * @param list $exprs + */ + private function specifyAll(array $exprs, TypeSpecifierContext $context): int + { + $sure = 0; + foreach ($exprs as $expr) { + $sure += count($this->specify($expr, $context)->getSureNotTypes()); + } + + return $sure; + } + + private function specify(Expr $expr, TypeSpecifierContext $context): SpecifiedTypes + { + return $this->typeSpecifier()->specifyTypesInCondition($this->scope(), $expr, $context); + } + + private static function orChain(string $prefix, int $arms): Expr + { + $chain = new Identical(new String_($prefix . '0'), new Variable('t')); + for ($i = 1; $i < $arms; $i++) { + $chain = new BooleanOr($chain, new Identical(new String_($prefix . $i), new Variable('t'))); + } + + return $chain; + } + + private function scope(): MutatingScope + { + if ($this->scope === null) { + throw new LogicException('boot() did not run.'); + } + + return $this->scope; + } + + private function typeSpecifier(): TypeSpecifier + { + if ($this->typeSpecifier === null) { + throw new LogicException('boot() did not run.'); + } + + return $this->typeSpecifier; + } + +} diff --git a/turbo-ext/src/ScopeOps.cpp b/turbo-ext/src/ScopeOps.cpp index c19c2b3f766..a084402e9e6 100644 --- a/turbo-ext/src/ScopeOps.cpp +++ b/turbo-ext/src/ScopeOps.cpp @@ -477,6 +477,33 @@ class ScopeOps return zv::Val(); } + /* Both target loops below pair targets only with guards, and a guard + * needs its expression certainly defined in both branches - without + * such a key nothing below can add a holder, so skip the passes over + * every key. The twin also collects these keys for its guard loop to + * avoid repeating the PHP-level checks; the guard loop here repeats + * them on every differing key, which selects the same keys in the + * same order. */ + bool hasGuardCandidate = false; + for (auto diffEntry : differingKeys) { + zend_string *key = diffEntry.stringKeyOrNull(); + zend_ulong idx = diffEntry.indexKey(); + + zval *ourSlot = pt_ht_find(ours.table(), key, idx); + if (ourSlot == NULL) continue; + zval *theirSlot = pt_ht_find(theirs.table(), key, idx); + if (theirSlot == NULL) continue; + zv::Ref holder = zv::Ref(ourSlot).deref(); + if (UNEXPECTED(!pt_check_holder(holder.raw()))) return zv::Val(); + if (pt_holder_certainty_value(holder.asObject()) != PT_TRI_YES) continue; + zv::Ref theirHolder = zv::Ref(theirSlot).deref(); + if (UNEXPECTED(!pt_check_holder(theirHolder.raw()))) return zv::Val(); + if (pt_holder_certainty_value(theirHolder.asObject()) != PT_TRI_YES) continue; + hasGuardCandidate = true; + break; + } + if (!hasGuardCandidate) return zv::Arr::copyOfTable(conditional.table()); + /* A guard is only ever consumed paired with a target: a *different* key * in the first target loop below, any key in the second one. Deriving a * guard costs a TypeCombinator::remove() of the two branch types - the @@ -639,6 +666,11 @@ class ScopeOps zv::ScratchTable guardIsSuperTypeOfTheirExprCache(8); zv::ScratchTable theirExprIsSuperTypeOfGuardCache(8); zv::ScratchTable guardIsConstantArrayCache(8); + /* Holder keys describe both the guard and the target type: describe + * each guard once and each target once instead of once per pair - + * the descriptions of big constant arrays dominate the merge + * otherwise. Mirrors the twin. */ + zv::Arr guardKeyPrefixes = zv::Arr::create(8); zv::Arr result; /* stays UNDEF until the first append duplicates the input */ /* main loop: pair non-merged expressions with guards - the settled @@ -654,6 +686,7 @@ class ScopeOps bool hasSelfGuard = pt_ht_exists(typeGuards.table(), key, idx); if (typeGuards.size() - (hasSelfGuard ? 1 : 0) == 0) continue; bool exprIsGuardExcluded = pt_ht_exists(guardsToExclude.table(), key, idx); + zv::Str targetSuffix; for (auto guardEntry : zv::TableRef(typeGuards.table())) { zend_string *guardKey = guardEntry.stringKeyOrNull(); @@ -736,7 +769,13 @@ class ScopeOps } } - if (UNEXPECTED(!appendConditional(result, conditional, key, idx, guardKey, guardIdx, guardHolder, holder))) return zv::Val(); + if (targetSuffix.isNull()) { + targetSuffix = zv::Str::adopt(pt_ceh_key_target_part(holder.raw())); + if (UNEXPECTED(targetSuffix.isNull())) return zv::Val(); + } + zend_string *guardPrefix = guardKeyPrefix(guardKeyPrefixes, guardKey, guardIdx, guardHolder); + if (UNEXPECTED(guardPrefix == NULL)) return zv::Val(); + if (UNEXPECTED(!appendConditional(result, conditional, key, idx, guardKey, guardIdx, guardHolder, holder, guardPrefix, targetSuffix.get()))) return zv::Val(); } } @@ -754,10 +793,15 @@ class ScopeOps if (UNEXPECTED(!canBeUndefined(holderExpr(mergedHolder), &undefinable))) return zv::Val(); if (!undefinable) continue; + /* one undefined holder shared by every guard's pair, like the twin's */ + zv::Val noHolder = createNoErrorHolder(zv::ObjRef(mergedHolder.asObject()).propAt(PT_ETH_PROP_EXPR).raw()); + if (UNEXPECTED(noHolder.isUndef())) return zv::Val(); + zv::Str targetSuffix = zv::Str::adopt(pt_ceh_key_target_part(noHolder.raw())); + if (UNEXPECTED(targetSuffix.isNull())) return zv::Val(); for (auto guardEntry : zv::TableRef(typeGuards.table())) { - zv::Val noHolder = createNoErrorHolder(zv::ObjRef(mergedHolder.asObject()).propAt(PT_ETH_PROP_EXPR).raw()); - if (UNEXPECTED(noHolder.isUndef())) return zv::Val(); - if (UNEXPECTED(!appendConditional(result, conditional, key, idx, guardEntry.stringKeyOrNull(), guardEntry.indexKey(), guardEntry.value(), noHolder.ref()))) { + zend_string *guardPrefix = guardKeyPrefix(guardKeyPrefixes, guardEntry.stringKeyOrNull(), guardEntry.indexKey(), guardEntry.value()); + if (UNEXPECTED(guardPrefix == NULL)) return zv::Val(); + if (UNEXPECTED(!appendConditional(result, conditional, key, idx, guardEntry.stringKeyOrNull(), guardEntry.indexKey(), guardEntry.value(), noHolder.ref(), guardPrefix, targetSuffix.get()))) { return zv::Val(); } } @@ -1515,13 +1559,27 @@ class ScopeOps * under $result[$exprKey][getKey()]. Duplicates the input array into * $result lazily on the first append. */ - static bool appendConditional(zv::Arr &result, zv::TableRef input, zend_string *skey, zend_ulong idx, zend_string *guardKey, zend_ulong guardIdx, zv::Ref guardHolder, zv::Ref typeHolder) + /* the cached "=" key part of a guard (borrowed from prefixes); NULL = pending exception */ + static zend_string *guardKeyPrefix(zv::Arr &prefixes, zend_string *guardKey, zend_ulong guardIdx, zv::Ref guardHolder) + { + zval *cached = pt_ht_find(prefixes.table(), guardKey, guardIdx); + if (cached != NULL) return Z_STR_P(cached); + zend_string *prefix = pt_ceh_key_condition_part(guardKey, guardIdx, guardHolder.raw()); + if (UNEXPECTED(prefix == NULL)) return NULL; + zval prefixZv; + ZVAL_STR(&prefixZv, prefix); + pt_ht_update(prefixes.table(), guardKey, guardIdx, &prefixZv); + return prefix; + } + + /* cehKey: the holder's ConditionalExpressionHolder::getKey(), the guard's + * key part followed by the target's, each described once by the caller */ + static bool appendConditional(zv::Arr &result, zv::TableRef input, zend_string *skey, zend_ulong idx, zend_string *guardKey, zend_ulong guardIdx, zv::Ref guardHolder, zv::Ref typeHolder, zend_string *guardPrefix, zend_string *targetSuffix) { zv::Arr conditions = zv::Arr::create(1); tableAddNewCopy(conditions.table(), guardKey, guardIdx, guardHolder); - zv::Str cehKey = zv::Str::adopt(pt_ceh_key_build(conditions.table(), typeHolder.raw())); - if (UNEXPECTED(cehKey.isNull())) return false; + zv::Str cehKey = zv::Str::adopt(zend_string_concat2(ZSTR_VAL(guardPrefix), ZSTR_LEN(guardPrefix), ZSTR_VAL(targetSuffix), ZSTR_LEN(targetSuffix))); zval cehRaw; object_init_ex(&cehRaw, pt_ce_cond_expr_holder); diff --git a/turbo-ext/src/support.cpp b/turbo-ext/src/support.cpp index 9818e91b6ee..a9915e4aa8f 100644 --- a/turbo-ext/src/support.cpp +++ b/turbo-ext/src/support.cpp @@ -1403,6 +1403,52 @@ bool pt_holder_equal_types(zval *a, zval *b, bool *out) return !EG(exception); } +/* "=": one condition of a ConditionalExpressionHolder::getKey() */ +static bool pt_ceh_key_append_condition(smart_str *str, zend_string *key_str, zend_ulong kidx, zval *holder) +{ + zval described; + ZVAL_DEREF(holder); + if (key_str != NULL) { + smart_str_append(str, key_str); + } else { + smart_str_append_long(str, (zend_long) kidx); + } + smart_str_appendc(str, '='); + if (UNEXPECTED(!pt_type_describe_precise(OBJ_PROP_NUM(Z_OBJ_P(holder), PT_ETH_PROP_TYPE), &described))) return false; + if (EXPECTED(Z_TYPE(described) == IS_STRING)) { + smart_str_append(str, Z_STR(described)); + } + zval_ptr_dtor(&described); + return true; +} + +/* " => ()": the type holder's part of a ConditionalExpressionHolder::getKey() */ +static bool pt_ceh_key_append_target(smart_str *str, zval *type_holder) +{ + smart_str_appendl(str, " => ", 4); + { + zval described; + if (UNEXPECTED(!pt_type_describe_precise(OBJ_PROP_NUM(Z_OBJ_P(type_holder), PT_ETH_PROP_TYPE), &described))) return false; + if (EXPECTED(Z_TYPE(described) == IS_STRING)) { + smart_str_append(str, Z_STR(described)); + } + zval_ptr_dtor(&described); + } + smart_str_appendl(str, " (", 2); + { + zend_long certainty = pt_holder_certainty_value(Z_OBJ_P(type_holder)); + if (certainty == PT_TRI_YES) { + smart_str_appendl(str, "Yes", 3); + } else if (certainty == PT_TRI_MAYBE) { + smart_str_appendl(str, "Maybe", 5); + } else { + smart_str_appendl(str, "No", 2); + } + } + smart_str_appendc(str, ')'); + return true; +} + zend_string *pt_ceh_key_build(HashTable *conds, zval *type_holder) { smart_str str = {}; @@ -1412,54 +1458,41 @@ zend_string *pt_ceh_key_build(HashTable *conds, zval *type_holder) bool first = true; ZEND_HASH_FOREACH_KEY_VAL(conds, kidx, key_str, entry) { - zval described; - zval *entry_deref = entry; - ZVAL_DEREF(entry_deref); if (!first) { smart_str_appendl(&str, " && ", 4); } first = false; - if (key_str != NULL) { - smart_str_append(&str, key_str); - } else { - smart_str_append_long(&str, (zend_long) kidx); - } - smart_str_appendc(&str, '='); - if (UNEXPECTED(!pt_type_describe_precise(OBJ_PROP_NUM(Z_OBJ_P(entry_deref), PT_ETH_PROP_TYPE), &described))) { + if (UNEXPECTED(!pt_ceh_key_append_condition(&str, key_str, kidx, entry))) { smart_str_free(&str); return NULL; } - if (EXPECTED(Z_TYPE(described) == IS_STRING)) { - smart_str_append(&str, Z_STR(described)); - } - zval_ptr_dtor(&described); } ZEND_HASH_FOREACH_END(); - smart_str_appendl(&str, " => ", 4); - { - zval described; - if (UNEXPECTED(!pt_type_describe_precise(OBJ_PROP_NUM(Z_OBJ_P(type_holder), PT_ETH_PROP_TYPE), &described))) { - smart_str_free(&str); - return NULL; - } - if (EXPECTED(Z_TYPE(described) == IS_STRING)) { - smart_str_append(&str, Z_STR(described)); - } - zval_ptr_dtor(&described); + if (UNEXPECTED(!pt_ceh_key_append_target(&str, type_holder))) { + smart_str_free(&str); + return NULL; } - smart_str_appendl(&str, " (", 2); - { - zend_long certainty = pt_holder_certainty_value(Z_OBJ_P(type_holder)); - if (certainty == PT_TRI_YES) { - smart_str_appendl(&str, "Yes", 3); - } else if (certainty == PT_TRI_MAYBE) { - smart_str_appendl(&str, "Maybe", 5); - } else { - smart_str_appendl(&str, "No", 2); - } + + return smart_str_extract(&str); +} + +zend_string *pt_ceh_key_condition_part(zend_string *key_str, zend_ulong kidx, zval *holder) +{ + smart_str str = {}; + if (UNEXPECTED(!pt_ceh_key_append_condition(&str, key_str, kidx, holder))) { + smart_str_free(&str); + return NULL; } - smart_str_appendc(&str, ')'); + return smart_str_extract(&str); +} +zend_string *pt_ceh_key_target_part(zval *type_holder) +{ + smart_str str = {}; + if (UNEXPECTED(!pt_ceh_key_append_target(&str, type_holder))) { + smart_str_free(&str); + return NULL; + } return smart_str_extract(&str); } diff --git a/turbo-ext/src/support.h b/turbo-ext/src/support.h index c5a651d5ecf..0ebc55a2717 100644 --- a/turbo-ext/src/support.h +++ b/turbo-ext/src/support.h @@ -992,6 +992,9 @@ bool pt_holder_equal_types(zval *a, zval *b, bool *out); /* ConditionalExpressionHolder::getKey() builder, shared with ScopeOps */ zend_string *pt_ceh_key_build(HashTable *conds, zval *type_holder); +/* the two parts of a single-condition key, which concatenated equal pt_ceh_key_build(): "=" and " => ()" */ +zend_string *pt_ceh_key_condition_part(zend_string *key_str, zend_ulong kidx, zval *holder); +zend_string *pt_ceh_key_target_part(zval *type_holder); /* calls a (possibly private) method on the scope, coercing result to bool */ bool pt_call_scope_bool(zval *scope, const char *lcname, size_t len, uint32_t argc, zval *argv, bool *out); From f552f3a597b7e2dc6e3707881148e64988e1e012 Mon Sep 17 00:00:00 2001 From: "ondrej@mirtes.cz" Date: Sat, 14 Nov 2026 03:00:00 +0000 Subject: [PATCH 13/18] Skip repeated work in IsSuperTypeOfResult, ObjectType and union sorting 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 --- src/Type/Constant/ConstantArrayType.php | 20 +-- src/Type/FiniteTypeSet.php | 7 +- src/Type/IsSuperTypeOfResult.php | 35 +++-- src/Type/ObjectType.php | 5 +- src/Type/UnionTypeHelper.php | 20 +-- tests/bench/AccessoryArrayListTypeBench.php | 75 ++++++++++ tests/bench/ArrayTypeBench.php | 74 +++++++++ tests/bench/BenchEnumFile.php | 58 ++++++++ tests/bench/ConstantArrayTypeBench.php | 117 +++++++++++++++ .../bench/ConstantStringTypeDescribeBench.php | 49 ++++++ ...ynamicReturnTypeExtensionRegistryBench.php | 66 +++++++++ tests/bench/FiniteTypeSetBench.php | 55 +++++++ tests/bench/IntersectionTypeQueriesBench.php | 90 +++++++++++ .../IsSuperTypeOfResultOperationsBench.php | 71 +++++++++ tests/bench/ObjectTypeIsSuperTypeOfBench.php | 74 +++++++++ tests/bench/TrinaryLogicBench.php | 140 ++++++++++++++++++ tests/bench/TypeCombinatorBench.php | 68 +++++++++ tests/bench/UnionTypeBench.php | 57 +++++++ tests/bench/UnionTypeHelperBench.php | 57 +++++++ tests/bench/VerbosityLevelFactoriesBench.php | 38 +++++ 20 files changed, 1145 insertions(+), 31 deletions(-) create mode 100644 tests/bench/AccessoryArrayListTypeBench.php create mode 100644 tests/bench/ArrayTypeBench.php create mode 100644 tests/bench/BenchEnumFile.php create mode 100644 tests/bench/ConstantStringTypeDescribeBench.php create mode 100644 tests/bench/DynamicReturnTypeExtensionRegistryBench.php create mode 100644 tests/bench/FiniteTypeSetBench.php create mode 100644 tests/bench/IntersectionTypeQueriesBench.php create mode 100644 tests/bench/IsSuperTypeOfResultOperationsBench.php create mode 100644 tests/bench/ObjectTypeIsSuperTypeOfBench.php create mode 100644 tests/bench/TrinaryLogicBench.php create mode 100644 tests/bench/UnionTypeHelperBench.php create mode 100644 tests/bench/VerbosityLevelFactoriesBench.php diff --git a/src/Type/Constant/ConstantArrayType.php b/src/Type/Constant/ConstantArrayType.php index ff6921c69ee..741dcab08e2 100644 --- a/src/Type/Constant/ConstantArrayType.php +++ b/src/Type/Constant/ConstantArrayType.php @@ -823,6 +823,7 @@ public function isSuperTypeOf(Type $type): IsSuperTypeOfResult $results = []; $valueResults = []; + $otherKeyIndexMap = $type->getKeyIndexMap(); foreach ($this->keyTypes as $i => $keyType) { $hasOffset = $type->hasOffsetValueType($keyType); if ($bothDefinite && $hasOffset->no() && $typeUnsealedness->yes()) { @@ -848,13 +849,14 @@ public function isSuperTypeOf(Type $type): IsSuperTypeOfResult // The offset value at one of $type's explicit keys depends only on // the value type stored there, so keys sharing their value types on // both sides (keys written through a union offset) compare them once. - $otherIndex = $type->getKeyIndex($keyType); - $valuePairKey = $otherIndex !== null - ? spl_object_id($this->valueTypes[$i]) . ':' . spl_object_id($type->valueTypes[$otherIndex]) - : null; - if ($valuePairKey !== null && array_key_exists($valuePairKey, $valueResults)) { - $results[] = $valueResults[$valuePairKey]; - continue; + $otherIndex = $otherKeyIndexMap[$keyType->getValue()] ?? null; + if ($otherIndex !== null) { + $valueId = spl_object_id($this->valueTypes[$i]); + $otherValueId = spl_object_id($type->valueTypes[$otherIndex]); + if (isset($valueResults[$valueId][$otherValueId])) { + $results[] = $valueResults[$valueId][$otherValueId]; + continue; + } } $otherValueType = $type->getOffsetValueType($keyType); @@ -866,8 +868,8 @@ public function isSuperTypeOf(Type $type): IsSuperTypeOfResult if ($isValueSuperType->no()) { return $isValueSuperType->decorateReasons(static fn (string $reason) => sprintf('Offset %s: %s', $keyType->describe(VerbosityLevel::value()), $reason)); } - if ($valuePairKey !== null) { - $valueResults[$valuePairKey] = $isValueSuperType; + if ($otherIndex !== null) { + $valueResults[$valueId][$otherValueId] = $isValueSuperType; } $results[] = $isValueSuperType; } diff --git a/src/Type/FiniteTypeSet.php b/src/Type/FiniteTypeSet.php index 2cd299ac7bf..095b6e51fc6 100644 --- a/src/Type/FiniteTypeSet.php +++ b/src/Type/FiniteTypeSet.php @@ -103,7 +103,12 @@ public static function create(array $types): ?self */ public static function key(Type $type): ?string { - if ($type instanceof TemplateType || $type instanceof UnresolvedTemplateArgumentType || $type instanceof UnionType || $type instanceof IntersectionType) { + // all four are compound types; the values this is asked about are not, and skip the + // rest, which includes a class that is rarely loaded - instanceof looks it up every time + if ( + $type instanceof CompoundType + && ($type instanceof TemplateType || $type instanceof UnresolvedTemplateArgumentType || $type instanceof UnionType || $type instanceof IntersectionType) + ) { return null; } diff --git a/src/Type/IsSuperTypeOfResult.php b/src/Type/IsSuperTypeOfResult.php index 3b626ec8e94..342e060fd6a 100644 --- a/src/Type/IsSuperTypeOfResult.php +++ b/src/Type/IsSuperTypeOfResult.php @@ -141,12 +141,12 @@ public static function createFromBoolean(bool $value): self public static function createFromTrinaryLogic(TrinaryLogic $result): self { if ($result->yes()) { - return self::createYes(); + return self::$YES ??= new self($result, []); } if ($result->no()) { - return self::createNo(); + return self::$NO ??= new self($result, []); } - return self::createMaybe(); + return self::$MAYBE ??= new self($result, []); } /** @@ -258,7 +258,11 @@ public static function extremeIdentity(self ...$operands): self } } - return self::create(TrinaryLogic::extremeIdentity(...$results), array_values(array_unique($reasons)), $lazyReasons); + if ($reasons === [] && $lazyReasons === []) { + return self::createFromTrinaryLogic(TrinaryLogic::extremeIdentity(...$results)); + } + + return new self(TrinaryLogic::extremeIdentity(...$results), array_values(array_unique($reasons)), $lazyReasons); } /** @see TrinaryLogic::maxMin() */ @@ -281,7 +285,11 @@ public static function maxMin(self ...$operands): self } } - return self::create(TrinaryLogic::maxMin(...$results), array_values(array_unique($reasons)), $lazyReasons); + if ($reasons === [] && $lazyReasons === []) { + return self::createFromTrinaryLogic(TrinaryLogic::maxMin(...$results)); + } + + return new self(TrinaryLogic::maxMin(...$results), array_values(array_unique($reasons)), $lazyReasons); } /** @@ -313,16 +321,21 @@ public static function lazyMaxMin( } } - return self::create( - $hasNo ? TrinaryLogic::createNo() : TrinaryLogic::createMaybe(), - array_values(array_unique($reasons)), - $lazyReasons, - ); + $result = $hasNo ? TrinaryLogic::createNo() : TrinaryLogic::createMaybe(); + if ($reasons === [] && $lazyReasons === []) { + return self::createFromTrinaryLogic($result); + } + + return new self($result, array_values(array_unique($reasons)), $lazyReasons); } public function negate(): self { - return self::create($this->result->negate(), $this->reasons, $this->lazyReasons); + if ($this->reasons === [] && $this->lazyReasons === []) { + return self::createFromTrinaryLogic($this->result->negate()); + } + + return new self($this->result->negate(), $this->reasons, $this->lazyReasons); } public function describe(): string diff --git a/src/Type/ObjectType.php b/src/Type/ObjectType.php index a16ff499f80..4720606603d 100644 --- a/src/Type/ObjectType.php +++ b/src/Type/ObjectType.php @@ -603,7 +603,10 @@ public function isSuperTypeOf(Type $type): IsSuperTypeOfResult $description = $type->describe(VerbosityLevel::cache()); } - self::touchDescriptionCacheKey($thisDescription); + // the call is skipped while the same description is asked about again and again + if ($thisDescription !== self::$lastTouchedDescription) { + self::touchDescriptionCacheKey($thisDescription); + } if (isset(self::$superTypes[$thisDescription][$description])) { return self::$superTypes[$thisDescription][$description]; diff --git a/src/Type/UnionTypeHelper.php b/src/Type/UnionTypeHelper.php index bbd90c1970b..e55d2b4e9c3 100644 --- a/src/Type/UnionTypeHelper.php +++ b/src/Type/UnionTypeHelper.php @@ -32,11 +32,7 @@ public static function sortTypes(array $types): array return $types; } - // usort() compares a member with many others - an array shape, whose description - // is costly, is described once - $arrayDescriptions = []; - - usort($types, static function (Type $a, Type $b) use (&$arrayDescriptions): int { + usort($types, static function (Type $a, Type $b): int { if ($a instanceof NullType) { return 1; } elseif ($b instanceof NullType) { @@ -108,10 +104,6 @@ public static function sortTypes(array $types): array return -1; } - if ($a instanceof FloatRangeType && $b instanceof FloatRangeType) { - return $a->getMin() <=> $b->getMin(); - } - if ($a instanceof ConstantStringType && $b instanceof ConstantStringType) { return self::compareStrings($a->getValue(), $b->getValue()); } @@ -123,6 +115,10 @@ public static function sortTypes(array $types): array ); } + if ($a instanceof FloatRangeType && $b instanceof FloatRangeType) { + return $a->getMin() <=> $b->getMin(); + } + if ( ($a instanceof CallableType || $a instanceof ClosureType) && ($b instanceof CallableType || $b instanceof ClosureType) @@ -141,6 +137,12 @@ public static function sortTypes(array $types): array return 1; } + // usort() compares a member with many others - an array shape, whose description + // is costly, is described once. Each sortTypes() call creates the closure anew, + // with its own static variables, and binding them here instead of with use() + // spares the comparisons that do not reach this branch. + static $arrayDescriptions = []; + return self::compareStrings( $arrayDescriptions[spl_object_id($a)] ??= $a->describe(VerbosityLevel::value()), $arrayDescriptions[spl_object_id($b)] ??= $b->describe(VerbosityLevel::value()), diff --git a/tests/bench/AccessoryArrayListTypeBench.php b/tests/bench/AccessoryArrayListTypeBench.php new file mode 100644 index 00000000000..415a1eb4476 --- /dev/null +++ b/tests/bench/AccessoryArrayListTypeBench.php @@ -0,0 +1,75 @@ + is a list, asked a thousand times. + */ +final class AccessoryArrayListTypeBench +{ + + private const CALLS = 1000; + + private ?ArrayType $array = null; + + private ?AccessoryArrayListType $list = null; + + public function __construct(private ScratchDirectory $scratch) + { + } + + #[Before] + public function boot(): void + { + BenchContainer::create($this->scratch); + + $this->array = new ArrayType(new IntegerType(), new MixedType()); + $this->list = new AccessoryArrayListType(); + $this->list->isSuperTypeOf($this->array); + $this->list->accepts($this->array, true); + } + + public function benchIsSuperTypeOf(): IsSuperTypeOfResult + { + [$list, $array] = $this->types(); + $result = IsSuperTypeOfResult::createNo(); + for ($i = 0; $i < self::CALLS; $i++) { + $result = $list->isSuperTypeOf($array); + } + + return $result; + } + + public function benchAccepts(): bool + { + [$list, $array] = $this->types(); + $result = false; + for ($i = 0; $i < self::CALLS; $i++) { + $result = $list->accepts($array, true)->yes(); + } + + return $result; + } + + /** + * @return array{AccessoryArrayListType, ArrayType} + */ + private function types(): array + { + if ($this->list === null || $this->array === null) { + throw new LogicException('boot() did not run.'); + } + + return [$this->list, $this->array]; + } + +} diff --git a/tests/bench/ArrayTypeBench.php b/tests/bench/ArrayTypeBench.php new file mode 100644 index 00000000000..742f8fa7e49 --- /dev/null +++ b/tests/bench/ArrayTypeBench.php @@ -0,0 +1,74 @@ +scratch); + + $this->array = new ArrayType(new IntegerType(), new MixedType()); + $this->array->isList(); + new ArrayType(IntegerRangeType::fromInterval(0, 100), new MixedType()); + } + + /** + * array may be a list, which takes two comparisons of its key type to find out. + */ + public function benchIsList(): TrinaryLogic + { + $result = TrinaryLogic::createNo(); + for ($i = 0; $i < self::CALLS; $i++) { + $result = $this->array()->isList(); + } + + return $result; + } + + /** + * The constructor turns a benevolent (int|string) key into mixed, which other key types do not + * need to be described for. + */ + public function benchConstruct(): Type + { + $keyType = IntegerRangeType::fromInterval(0, 100); + $itemType = new MixedType(); + $array = $this->array(); + for ($i = 0; $i < self::CALLS; $i++) { + $array = new ArrayType($keyType, $itemType); + } + + return $array; + } + + private function array(): ArrayType + { + if ($this->array === null) { + throw new LogicException('boot() did not run.'); + } + + return $this->array; + } + +} diff --git a/tests/bench/BenchEnumFile.php b/tests/bench/BenchEnumFile.php new file mode 100644 index 00000000000..df066fc47d2 --- /dev/null +++ b/tests/bench/BenchEnumFile.php @@ -0,0 +1,58 @@ +path() . '/' . $name . '.php'; + $code = sprintf(" + */ + public static function cases(string $className, int $cases): array + { + $types = []; + for ($i = 0; $i < $cases; $i++) { + $types[] = new EnumCaseObjectType($className, 'C' . $i); + } + + return $types; + } + +} diff --git a/tests/bench/ConstantArrayTypeBench.php b/tests/bench/ConstantArrayTypeBench.php index e4c19890b6d..abf614771fb 100644 --- a/tests/bench/ConstantArrayTypeBench.php +++ b/tests/bench/ConstantArrayTypeBench.php @@ -6,13 +6,20 @@ use PHPStan\Type\Constant\ConstantArrayType; use PHPStan\Type\Constant\ConstantArrayTypeBuilder; use PHPStan\Type\Constant\ConstantStringType; +use PHPStan\Type\Generic\TemplateTypeReference; +use PHPStan\Type\Generic\TemplateTypeVariance; use PHPStan\Type\IntegerType; +use PHPStan\Type\NeverType; +use PHPStan\Type\ObjectType; use PHPStan\Type\Type; +use stdClass; use Tally\Attribute\Before; use Tally\Attribute\Constant; use Tally\Attribute\DataProvider; use Tally\Attribute\Linear; +use Tally\Attribute\Warmup; use Tally\ScratchDirectory; +use function array_slice; use function count; use function intdiv; @@ -25,6 +32,12 @@ final class ConstantArrayTypeBench private ?ConstantStringType $key = null; + private ?ConstantArrayType $sealed = null; + + private ?ConstantArrayType $sealedWithoutLastKey = null; + + private ?ConstantArrayType $nesting = null; + public function __construct(private ScratchDirectory $scratch) { } @@ -54,6 +67,21 @@ public function boot(int $keys): void $this->shape->getOffsetValueType(new ConstantStringType('key0')); $this->sameShape = self::shape('key', $keys, false); $this->key = new ConstantStringType('key' . intdiv($keys, 2)); + + $this->sealed = self::sealed($this->shape, $keys); + $this->sealedWithoutLastKey = self::sealed($this->shape, $keys - 1); + + // every offset holds the same shape, which the analysis gets from a type alias + $nested = self::shape('nested', 16, false); + $keyTypes = [new ConstantStringType('object')]; + $valueTypes = [new ObjectType(stdClass::class)]; + for ($i = 0; $i < $keys; $i++) { + $keyTypes[] = new ConstantStringType('key' . $i); + $valueTypes[] = $nested; + } + $this->nesting = new ConstantArrayType($keyTypes, $valueTypes); + $this->nesting->getReferencedClasses(); + $this->nesting->getReferencedTemplateTypes(TemplateTypeVariance::createCovariant()); } /** @@ -82,6 +110,95 @@ public function benchIsSuperTypeOf(int $keys): bool return $this->subject()->isSuperTypeOf($this->sameShape)->yes(); } + /** + * Two sealed shapes where one lacks a key of the other. Why they cannot be intersected is + * only described when it is reported. + */ + #[DataProvider(method: 'keys')] + public function benchIsSuperTypeOfSealedWithoutKey(int $keys): bool + { + if ($this->sealed === null || $this->sealedWithoutLastKey === null) { + throw new LogicException('boot() did not run.'); + } + + return $this->sealed->isSuperTypeOf($this->sealedWithoutLastKey)->no(); + } + + /** + * The verbosity of the reason is only chosen when an offset does not accept. + */ + #[DataProvider(method: 'keys')] + public function benchAcceptsSameShape(int $keys): bool + { + if ($this->sameShape === null) { + throw new LogicException('boot() did not run.'); + } + + return $this->subject()->accepts($this->sameShape, true)->yes(); + } + + /** + * Asked again of a shape whose offsets share one nested shape. + */ + /** + * @return list + */ + #[DataProvider(method: 'keys')] + #[Constant(size: 'keys')] + public function benchGetReferencedClassesAgain(int $keys): array + { + return $this->nesting()->getReferencedClasses(); + } + + /** + * @return list + */ + #[DataProvider(method: 'keys')] + #[Constant(size: 'keys')] + public function benchGetReferencedTemplateTypesAgain(int $keys): array + { + return $this->nesting()->getReferencedTemplateTypes(TemplateTypeVariance::createCovariant()); + } + + /** + * Fails before 2.1.34, which added the method, so the first call is a warm-up call of the + * benchmark instead of one in boot(). + */ + #[DataProvider(method: 'keys')] + #[Constant(size: 'keys')] + #[Warmup(calls: 1)] + public function benchHasTemplateOrLateResolvableTypeAgain(int $keys): bool + { + return $this->nesting()->hasTemplateOrLateResolvableType(); + } + + private function nesting(): ConstantArrayType + { + if ($this->nesting === null) { + throw new LogicException('boot() did not run.'); + } + + return $this->nesting; + } + + /** + * The first keys of the shape, sealed. Before 2.2.0 the constructor ignores the argument. + */ + private static function sealed(ConstantArrayType $shape, int $keys): ConstantArrayType + { + // positional, so the versions without the parameter ignore it instead of failing + $arguments = [ + array_slice($shape->getKeyTypes(), 0, $keys), + array_slice($shape->getValueTypes(), 0, $keys), + [0], + [], + null, + [new NeverType(true), new NeverType(true)], + ]; + + return new ConstantArrayType(...$arguments); + } + private function subject(): ConstantArrayType { if ($this->shape === null) { diff --git a/tests/bench/ConstantStringTypeDescribeBench.php b/tests/bench/ConstantStringTypeDescribeBench.php new file mode 100644 index 00000000000..60dfe478910 --- /dev/null +++ b/tests/bench/ConstantStringTypeDescribeBench.php @@ -0,0 +1,49 @@ + */ + private array $strings = []; + + public function __construct(private ScratchDirectory $scratch) + { + } + + #[Before] + public function boot(): void + { + BenchContainer::create($this->scratch); + + for ($i = 0; $i < self::STRINGS; $i++) { + $string = new ConstantStringType('The value of an enum case number ' . $i); + $string->describe(VerbosityLevel::value()); + $string->describe(VerbosityLevel::precise()); + $this->strings[] = $string; + } + } + + /** + * Strings already described, as the members of a union of an enum's values are again and again. + */ + public function benchDescribeAgain(): string + { + $description = ''; + foreach ($this->strings as $string) { + $string->describe(VerbosityLevel::value()); + $description = $string->describe(VerbosityLevel::precise()); + } + + return $description; + } + +} diff --git a/tests/bench/DynamicReturnTypeExtensionRegistryBench.php b/tests/bench/DynamicReturnTypeExtensionRegistryBench.php new file mode 100644 index 00000000000..fa75d4787d6 --- /dev/null +++ b/tests/bench/DynamicReturnTypeExtensionRegistryBench.php @@ -0,0 +1,66 @@ +registry = BenchContainer::create($this->scratch)->getByType(DynamicReturnTypeExtensionRegistry::class); + $this->registry->getDynamicMethodReturnTypeExtensionsForClass(RecursiveArrayIterator::class); + $this->registry->getDynamicStaticMethodReturnTypeExtensionsForClass(RecursiveArrayIterator::class); + } + + public function benchMethodExtensionsForClass(): int + { + $registry = $this->registry(); + $count = 0; + for ($i = 0; $i < self::CALLS; $i++) { + $count += count($registry->getDynamicMethodReturnTypeExtensionsForClass(RecursiveArrayIterator::class)); + } + + return $count; + } + + public function benchStaticMethodExtensionsForClass(): int + { + $registry = $this->registry(); + $count = 0; + for ($i = 0; $i < self::CALLS; $i++) { + $count += count($registry->getDynamicStaticMethodReturnTypeExtensionsForClass(RecursiveArrayIterator::class)); + } + + return $count; + } + + private function registry(): DynamicReturnTypeExtensionRegistry + { + if ($this->registry === null) { + throw new LogicException('boot() did not run.'); + } + + return $this->registry; + } + +} diff --git a/tests/bench/FiniteTypeSetBench.php b/tests/bench/FiniteTypeSetBench.php new file mode 100644 index 00000000000..255db0036e9 --- /dev/null +++ b/tests/bench/FiniteTypeSetBench.php @@ -0,0 +1,55 @@ + */ + private array $types = []; + + public function __construct(private ScratchDirectory $scratch) + { + } + + #[Before] + public function boot(): void + { + BenchContainer::create($this->scratch); + + $enum = BenchEnumFile::create($this->scratch, 'Cases' . self::TYPES, self::TYPES); + $this->types = [new NullType()]; + foreach (BenchEnumFile::cases($enum, self::TYPES) as $i => $case) { + $this->types[] = new ConstantStringType('value' . $i); + $this->types[] = new ConstantIntegerType($i); + $this->types[] = $case; + } + foreach ($this->types as $type) { + FiniteTypeSet::key($type); + } + } + + /** + * Every value a union is compared with is keyed. + */ + public function benchKey(): ?string + { + $key = null; + foreach ($this->types as $type) { + $key = FiniteTypeSet::key($type); + } + + return $key; + } + +} diff --git a/tests/bench/IntersectionTypeQueriesBench.php b/tests/bench/IntersectionTypeQueriesBench.php new file mode 100644 index 00000000000..8ecdb9eb361 --- /dev/null +++ b/tests/bench/IntersectionTypeQueriesBench.php @@ -0,0 +1,90 @@ +scratch); + + // non-empty-list with offset 0 + $this->array = new IntersectionType([ + new ArrayType(new IntegerType(), new MixedType()), + new NonEmptyArrayType(), + new AccessoryArrayListType(), + new HasOffsetType(new ConstantIntegerType(0)), + ]); + $this->string = new IntersectionType([ + new StringType(), + new AccessoryNonEmptyStringType(), + new AccessoryNumericStringType(), + new AccessoryLowercaseStringType(), + ]); + $this->array->isOffsetAccessible(); + $this->string->describe(VerbosityLevel::precise()); + } + + public function benchIsOffsetAccessible(): TrinaryLogic + { + if ($this->array === null) { + throw new LogicException('boot() did not run.'); + } + + $result = TrinaryLogic::createNo(); + for ($i = 0; $i < self::CALLS; $i++) { + $result = $this->array->isOffsetAccessible(); + } + + return $result; + } + + public function benchDescribe(): string + { + if ($this->string === null) { + throw new LogicException('boot() did not run.'); + } + + $description = ''; + for ($i = 0; $i < self::CALLS; $i++) { + $description = $this->string->describe(VerbosityLevel::precise()); + } + + return $description; + } + +} diff --git a/tests/bench/IsSuperTypeOfResultOperationsBench.php b/tests/bench/IsSuperTypeOfResultOperationsBench.php new file mode 100644 index 00000000000..5fa874eb2f1 --- /dev/null +++ b/tests/bench/IsSuperTypeOfResultOperationsBench.php @@ -0,0 +1,71 @@ +yes = new IsSuperTypeOfResult(TrinaryLogic::createYes(), []); + $this->maybe = new IsSuperTypeOfResult(TrinaryLogic::createMaybe(), []); + $this->no = new IsSuperTypeOfResult(TrinaryLogic::createNo(), []); + } + + public function benchAnd(): IsSuperTypeOfResult + { + $result = $this->yes; + for ($i = 0; $i < self::CALLS; $i++) { + $result = $this->yes->and($this->maybe); + } + + return $result; + } + + public function benchOr(): IsSuperTypeOfResult + { + $result = $this->no; + for ($i = 0; $i < self::CALLS; $i++) { + $result = $this->no->or($this->maybe); + } + + return $result; + } + + public function benchMaxMin(): IsSuperTypeOfResult + { + $result = $this->no; + for ($i = 0; $i < self::CALLS; $i++) { + $result = IsSuperTypeOfResult::maxMin($this->no, $this->maybe, $this->no); + } + + return $result; + } + + public function benchNegate(): IsSuperTypeOfResult + { + $result = $this->no; + for ($i = 0; $i < self::CALLS; $i++) { + $result = $this->maybe->negate(); + } + + return $result; + } + +} diff --git a/tests/bench/ObjectTypeIsSuperTypeOfBench.php b/tests/bench/ObjectTypeIsSuperTypeOfBench.php new file mode 100644 index 00000000000..f37a82f1f9e --- /dev/null +++ b/tests/bench/ObjectTypeIsSuperTypeOfBench.php @@ -0,0 +1,74 @@ +scratch); + + $this->dateTime = new ObjectType(DateTime::class); + $this->exception = new ObjectType(Exception::class); + $this->union = new UnionType([new ObjectType(Exception::class), new ObjectType(stdClass::class)]); + $this->dateTime->isSuperTypeOf($this->exception); + $this->dateTime->isSuperTypeOf($this->union); + } + + public function benchIsSuperTypeOfUnrelatedClass(): IsSuperTypeOfResult + { + if ($this->dateTime === null || $this->exception === null) { + throw new LogicException('boot() did not run.'); + } + + $result = IsSuperTypeOfResult::createYes(); + for ($i = 0; $i < self::CALLS; $i++) { + $result = $this->dateTime->isSuperTypeOf($this->exception); + } + + return $result; + } + + public function benchIsSuperTypeOfUnionOfUnrelatedClasses(): IsSuperTypeOfResult + { + if ($this->dateTime === null || $this->union === null) { + throw new LogicException('boot() did not run.'); + } + + $result = IsSuperTypeOfResult::createYes(); + for ($i = 0; $i < self::CALLS; $i++) { + $result = $this->dateTime->isSuperTypeOf($this->union); + } + + return $result; + } + +} diff --git a/tests/bench/TrinaryLogicBench.php b/tests/bench/TrinaryLogicBench.php new file mode 100644 index 00000000000..de67f8826c7 --- /dev/null +++ b/tests/bench/TrinaryLogicBench.php @@ -0,0 +1,140 @@ + */ + private array $operands = []; + + /** @var list */ + private array $objects = []; + + /** + * One operand is the common case, which and() and or() take without an array. + * + * @return iterable + */ + public static function operands(): iterable + { + yield 'operands-1' => [1]; + yield 'operands-4' => [4]; + } + + /** + * @return iterable + */ + public static function fourOperands(): iterable + { + yield 'operands-4' => [4]; + } + + /** + * @return iterable + */ + public static function objects(): iterable + { + yield 'objects-8' => [8]; + } + + #[Before] + public function boot(int $count): void + { + $values = [TrinaryLogic::createMaybe(), TrinaryLogic::createYes(), TrinaryLogic::createMaybe(), TrinaryLogic::createNo()]; + $this->operands = []; + $this->objects = []; + for ($i = 0; $i < $count; $i++) { + $this->operands[] = $values[$i % 4]; + $this->objects[] = $i; + } + } + + #[DataProvider(method: 'operands')] + public function benchAnd(int $operands): TrinaryLogic + { + $result = TrinaryLogic::createYes(); + $operandList = $this->operands; + for ($i = 0; $i < self::CALLS; $i++) { + $result = TrinaryLogic::createYes()->and(...$operandList); + } + + return $result; + } + + #[DataProvider(method: 'operands')] + public function benchOr(int $operands): TrinaryLogic + { + $result = TrinaryLogic::createNo(); + $operandList = $this->operands; + for ($i = 0; $i < self::CALLS; $i++) { + $result = TrinaryLogic::createNo()->or(...$operandList); + } + + return $result; + } + + #[DataProvider(method: 'fourOperands')] + public function benchMaxMin(int $operands): TrinaryLogic + { + $result = TrinaryLogic::createNo(); + $operandList = $this->operands; + for ($i = 0; $i < self::CALLS; $i++) { + $result = TrinaryLogic::maxMin(...$operandList); + } + + return $result; + } + + /** + * The lazy methods stop at the first no or yes, so every callback answers maybe. + */ + #[DataProvider(method: 'objects')] + public function benchLazyAnd(int $objects): TrinaryLogic + { + $result = TrinaryLogic::createYes(); + for ($i = 0; $i < self::CALLS; $i++) { + $result = TrinaryLogic::createYes()->lazyAnd($this->objects, static fn (int $object): TrinaryLogic => TrinaryLogic::createMaybe()); + } + + return $result; + } + + /** + * The lazy methods stop at the first no or yes, so every callback answers maybe. + */ + #[DataProvider(method: 'objects')] + public function benchLazyOr(int $objects): TrinaryLogic + { + $result = TrinaryLogic::createNo(); + for ($i = 0; $i < self::CALLS; $i++) { + $result = TrinaryLogic::createNo()->lazyOr($this->objects, static fn (int $object): TrinaryLogic => TrinaryLogic::createMaybe()); + } + + return $result; + } + + /** + * The lazy methods stop at the first no or yes, so every callback answers maybe. + */ + #[DataProvider(method: 'objects')] + public function benchLazyMaxMin(int $objects): TrinaryLogic + { + $result = TrinaryLogic::createNo(); + for ($i = 0; $i < self::CALLS; $i++) { + $result = TrinaryLogic::lazyMaxMin($this->objects, static fn (int $object): TrinaryLogic => TrinaryLogic::createMaybe()); + } + + return $result; + } + +} diff --git a/tests/bench/TypeCombinatorBench.php b/tests/bench/TypeCombinatorBench.php index ecad638d506..cb8e23f2be2 100644 --- a/tests/bench/TypeCombinatorBench.php +++ b/tests/bench/TypeCombinatorBench.php @@ -2,6 +2,9 @@ namespace PHPStan\Benchmark; +use LogicException; +use PHPStan\Type\Accessory\AccessoryLowercaseStringType; +use PHPStan\Type\Accessory\AccessoryNonEmptyStringType; use PHPStan\Type\Accessory\AccessoryNumericStringType; use PHPStan\Type\Accessory\HasMethodType; use PHPStan\Type\ArrayType; @@ -13,6 +16,8 @@ use PHPStan\Type\FloatType; use PHPStan\Type\IntegerType; use PHPStan\Type\IntersectionType; +use PHPStan\Type\NeverType; +use PHPStan\Type\NullType; use PHPStan\Type\ObjectType; use PHPStan\Type\StringType; use PHPStan\Type\Type; @@ -61,6 +66,13 @@ final class TypeCombinatorBench /** @var list */ private array $intersections = []; + /** @var list */ + private array $stringsAndNevers = []; + + private ?Type $enumOrNull = null; + + private ?UnionType $enumCases = null; + public function __construct(private ScratchDirectory $scratch) { } @@ -75,6 +87,14 @@ public static function types(): iterable } } + /** + * @return iterable + */ + public static function smallest(): iterable + { + yield 'types-16' => [16]; + } + #[Before] public function boot(int $types): void { @@ -94,8 +114,15 @@ public function boot(int $types): void TypeCombinator::union(...$this->arraysWithSameKeys); TypeCombinator::union(...$this->arraysWithOwnKeys); TypeCombinator::intersect(...$this->intersections); + TypeCombinator::union(...$this->stringsAndNevers); + TypeCombinator::intersect(new StringType(), new AccessoryNonEmptyStringType(), new AccessoryNumericStringType(), new AccessoryLowercaseStringType()); + $warmupEnum = BenchEnumFile::create($this->scratch, 'Warmup4', 4); + TypeCombinator::remove(new UnionType([new ObjectType($warmupEnum), new NullType()]), new UnionType(BenchEnumFile::cases($warmupEnum, 4))); $this->createTypes('type', $types); + $enum = BenchEnumFile::create($this->scratch, 'Cases' . $types, $types); + $this->enumOrNull = new UnionType([new ObjectType($enum), new NullType()]); + $this->enumCases = new UnionType(BenchEnumFile::cases($enum, $types)); } #[DataProvider(method: 'types')] @@ -198,13 +225,54 @@ public function benchIntersectIntersections(int $types): Type return TypeCombinator::intersect(...$this->intersections); } + /** + * The implicit never types an empty branch contributes are dropped from the union. + */ + #[DataProvider(method: 'types')] + #[Linear(size: 'types')] + public function benchUnionWithNevers(int $types): Type + { + return TypeCombinator::union(...$this->stringsAndNevers); + } + + /** + * Accessory types only, which need no sorting, a hundred times. + */ + #[DataProvider(method: 'smallest')] + public function benchIntersectAccessories(int $types): Type + { + $type = new StringType(); + for ($i = 0; $i < 100; $i++) { + $type = TypeCombinator::intersect(new StringType(), new AccessoryNonEmptyStringType(), new AccessoryNumericStringType(), new AccessoryLowercaseStringType()); + } + + return $type; + } + + /** + * Enum|null minus all the cases of the enum, as a match over all of them leaves it. + */ + #[DataProvider(method: 'types')] + #[Linear(size: 'types')] + public function benchRemoveEnumCases(int $types): Type + { + if ($this->enumOrNull === null || $this->enumCases === null) { + throw new LogicException('boot() did not run.'); + } + + return TypeCombinator::remove($this->enumOrNull, $this->enumCases); + } + private function createTypes(string $prefix, int $types): void { $this->strings = []; $this->integers = []; $this->arrays = []; + $this->stringsAndNevers = []; $others = []; for ($i = 0; $i < $types; $i++) { + $this->stringsAndNevers[] = new ConstantStringType($prefix . $i); + $this->stringsAndNevers[] = new NeverType(); $this->strings[] = new ConstantStringType($prefix . $i); $this->integers[] = new ConstantIntegerType($i * 3); $this->arrays[] = new ConstantArrayType([new ConstantStringType($prefix . $i)], [new ConstantIntegerType($i)]); diff --git a/tests/bench/UnionTypeBench.php b/tests/bench/UnionTypeBench.php index fd234fa9deb..214d4ab2f09 100644 --- a/tests/bench/UnionTypeBench.php +++ b/tests/bench/UnionTypeBench.php @@ -5,6 +5,7 @@ use LogicException; use PHPStan\TrinaryLogic; use PHPStan\Type\Accessory\HasMethodType; +use PHPStan\Type\BenevolentUnionType; use PHPStan\Type\Constant\ConstantArrayType; use PHPStan\Type\Constant\ConstantIntegerType; use PHPStan\Type\Constant\ConstantStringType; @@ -15,6 +16,7 @@ use PHPStan\Type\VerbosityLevel; use stdClass; use Tally\Attribute\Before; +use Tally\Attribute\Constant; use Tally\Attribute\DataProvider; use Tally\Attribute\Linear; use Tally\Attribute\Linearithmic; @@ -31,6 +33,8 @@ final class UnionTypeBench private ?UnionType $arrays = null; + private ?BenevolentUnionType $benevolent = null; + public function __construct(private ScratchDirectory $scratch) { } @@ -57,6 +61,12 @@ public function boot(int $members): void self::arrays(4)->getIterableValueType(); [$this->union, $this->other] = self::unions('member', $members); + $this->benevolent = new BenevolentUnionType($this->union->getTypes()); + + // the queries the union memoizes + $this->union->getFiniteTypes(); + $this->union->isNull(); + $this->union->isCallable(); $this->intersections = self::intersections('member', $members); $this->arrays = self::arrays($members); } @@ -108,6 +118,53 @@ public function benchIsSuperTypeOf(int $members): bool return $this->union()->isSuperTypeOf($this->other)->yes(); } + /** + * Every member stays the same, so the union does too. + */ + #[DataProvider(method: 'members')] + #[Linear(size: 'members')] + public function benchUnchangedToString(int $members): Type + { + return $this->union()->toString(); + } + + #[DataProvider(method: 'members')] + #[Linear(size: 'members')] + public function benchBenevolentUnchangedToString(int $members): Type + { + if ($this->benevolent === null) { + throw new LogicException('boot() did not run.'); + } + + return $this->benevolent->toString(); + } + + /** + * Asked again, as boot() asked it already. + * + * @return list + */ + #[DataProvider(method: 'members')] + #[Constant(size: 'members')] + public function benchGetFiniteTypesAgain(int $members): array + { + return $this->union()->getFiniteTypes(); + } + + #[DataProvider(method: 'members')] + #[Constant(size: 'members')] + public function benchIsNullAgain(int $members): TrinaryLogic + { + return $this->union()->isNull(); + } + + #[DataProvider(method: 'members')] + #[Constant(size: 'members')] + public function benchIsCallableAgain(int $members): TrinaryLogic + { + return $this->union()->isCallable(); + } + private function union(): UnionType { if ($this->union === null) { diff --git a/tests/bench/UnionTypeHelperBench.php b/tests/bench/UnionTypeHelperBench.php new file mode 100644 index 00000000000..a1ad7f97238 --- /dev/null +++ b/tests/bench/UnionTypeHelperBench.php @@ -0,0 +1,57 @@ + */ + private array $cases = []; + + public function __construct(private ScratchDirectory $scratch) + { + } + + /** + * @return iterable + */ + public static function cases(): iterable + { + foreach ([16, 64, 256] as $cases) { + yield 'cases-' . $cases => [$cases]; + } + } + + #[Before] + public function boot(int $cases): void + { + BenchContainer::create($this->scratch); + + $warmup = BenchEnumFile::create($this->scratch, 'Warmup4', 4); + UnionTypeHelper::sortTypes(array_reverse(BenchEnumFile::cases($warmup, 4))); + + $enum = BenchEnumFile::create($this->scratch, 'Cases' . $cases, $cases); + $this->cases = array_reverse(BenchEnumFile::cases($enum, $cases)); + } + + /** + * A match over a large enum builds a union of its remaining cases for every arm. + * + * @return list + */ + #[DataProvider(method: 'cases')] + #[Linearithmic(size: 'cases')] + public function benchSortEnumCases(int $cases): array + { + return UnionTypeHelper::sortTypes($this->cases); + } + +} diff --git a/tests/bench/VerbosityLevelFactoriesBench.php b/tests/bench/VerbosityLevelFactoriesBench.php new file mode 100644 index 00000000000..7435bcd8e27 --- /dev/null +++ b/tests/bench/VerbosityLevelFactoriesBench.php @@ -0,0 +1,38 @@ + Date: Sat, 14 Nov 2026 07:04:39 +0000 Subject: [PATCH 14/18] Cache the ASTs of PHP 8 stubs again Since c7c28d49f 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 --- conf/parsers.neon | 11 ++++- tests/bench/PathRoutingParserBench.php | 61 ++++++++++++++++++++++++++ 2 files changed, 71 insertions(+), 1 deletion(-) create mode 100644 tests/bench/PathRoutingParserBench.php diff --git a/conf/parsers.neon b/conf/parsers.neon index 05b1fabb788..e83b7959126 100644 --- a/conf/parsers.neon +++ b/conf/parsers.neon @@ -54,7 +54,7 @@ services: arguments: currentPhpVersionRichParser: @cachedRichParser currentPhpVersionSimpleParser: @cachedSimpleParser - php8Parser: @php8Parser + php8Parser: @cachedPhp8Parser singleReflectionFile: %singleReflectionFile% autowired: false @@ -83,6 +83,15 @@ services: cachedSourceBytesMax: %cache.nodesByStringSourceBytesMax% autowired: false + # reflection asks for the nodes of a stub again for each symbol it declares + cachedPhp8Parser: + class: PHPStan\Parser\CachedParser + arguments: + originalParser: @php8Parser + cachedNodesByStringCountMax: %cache.nodesByStringCountMax% + cachedSourceBytesMax: %cache.nodesByStringSourceBytesMax% + autowired: false + defaultAnalysisParser: factory: @pathRoutingParser autowired: false diff --git a/tests/bench/PathRoutingParserBench.php b/tests/bench/PathRoutingParserBench.php new file mode 100644 index 00000000000..968db4f2f86 --- /dev/null +++ b/tests/bench/PathRoutingParserBench.php @@ -0,0 +1,61 @@ + + */ + public static function stubs(): iterable + { + $stubs = dirname(__DIR__, 2) . '/vendor/phpstan/php-8-stubs/stubs'; + yield 'function' => [$stubs . '/Zend/strlen.php']; + yield 'class' => [$stubs . '/ext/pdo/PDO.php']; + } + + #[Before] + public function boot(string $file): void + { + $parser = BenchContainer::create($this->scratch)->getService('pathRoutingParser'); + if (!$parser instanceof Parser) { + throw new LogicException('pathRoutingParser is not a parser.'); + } + + // another stub loads the parser's classes, then the benchmark's stub is parsed the first time + $parser->parseFile(dirname(__DIR__, 2) . '/vendor/phpstan/php-8-stubs/stubs/Zend/ArrayAccess.php'); + $parser->parseFile($file); + $this->parser = $parser; + } + + /** + * Reflection asks for the nodes of a PHP 8 stub again for each symbol it declares. + * + * @return Node[] + */ + #[DataProvider(method: 'stubs')] + public function benchParsePhp8StubAgain(string $file): array + { + if ($this->parser === null) { + throw new LogicException('boot() did not run.'); + } + + return $this->parser->parseFile($file); + } + +} From 3ab77a59268badf5270c209dbf1ec50e6d350c55 Mon Sep 17 00:00:00 2001 From: "ondrej@mirtes.cz" Date: Sat, 14 Nov 2026 07:19:24 +0000 Subject: [PATCH 15/18] Do not report the unmatched BenchEnum ignore on PHP 8.1 The dead code detector is not loaded below PHP 8.2. Amp-Thread-ID: https://ampcode.com/threads/T-01a1d525-9c4c-73cf-8edc-000aa8e78378 --- build/phpstan.neon | 1 + 1 file changed, 1 insertion(+) diff --git a/build/phpstan.neon b/build/phpstan.neon index 88ebf3b13ed..4246f8980a0 100644 --- a/build/phpstan.neon +++ b/build/phpstan.neon @@ -118,6 +118,7 @@ parameters: # the benchmarks name the cases by string, C0 to C255 identifier: shipmonk.deadEnumCase path: ../tests/bench/BenchEnum.php + reportUnmatched: false # the dead code detector runs on PHP 8.2+ only - identifier: shipmonk.deadConstant paths: From 2c4bd3afa807db7f10cc4dfb1c1e2ee10122d9ad Mon Sep 17 00:00:00 2001 From: "ondrej@mirtes.cz" Date: Sat, 14 Nov 2026 07:26:16 +0000 Subject: [PATCH 16/18] Keep the microbenchmarks parseable on PHP 7.4 and 8.0 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 --- Makefile | 1 + tests/bench/AnalysedCode.php | 3 ++- tests/bench/BenchScope.php | 6 +++++- tests/bench/MutatingScopeMergeBench.php | 16 ++++++++++------ 4 files changed, 18 insertions(+), 8 deletions(-) diff --git a/Makefile b/Makefile index 1c773c96aad..e935b2ad1f9 100644 --- a/Makefile +++ b/Makefile @@ -16,6 +16,7 @@ lint: --exclude tests/PHPStan/Analyser/data \ --exclude tests/bench/data \ --exclude tests/bench/Warmup.php \ + --exclude tests/bench/BenchEnum.php \ --exclude tests/PHPStan/Analyser/nsrt \ --exclude tests/PHPStan/Rules/Methods/data \ --exclude tests/PHPStan/Rules/Functions/data \ diff --git a/tests/bench/AnalysedCode.php b/tests/bench/AnalysedCode.php index 041f5d2ee7b..52671e4005a 100644 --- a/tests/bench/AnalysedCode.php +++ b/tests/bench/AnalysedCode.php @@ -20,6 +20,7 @@ use function dirname; use function explode; use function file_put_contents; +use function get_class; use function interface_exists; use function is_a; use function str_starts_with; @@ -66,7 +67,7 @@ public static function analyse(ScratchDirectory $scratch, string $code): self $scope = $scope->toMutatingScope(); } if (!$scope instanceof MutatingScope) { - throw new LogicException('The analysis gave a ' . $scope::class . '.'); + throw new LogicException('The analysis gave a ' . get_class($scope) . '.'); } $analysed->nodes[] = [$node, $scope]; }); diff --git a/tests/bench/BenchScope.php b/tests/bench/BenchScope.php index e49037d348b..21d54138ed9 100644 --- a/tests/bench/BenchScope.php +++ b/tests/bench/BenchScope.php @@ -123,7 +123,11 @@ public static function markers(array $nodes): array public function scope(string $marker): MutatingScope { - return $this->scopes[$marker] ?? throw new LogicException('No ' . $marker . '().'); + if (!isset($this->scopes[$marker])) { + throw new LogicException('No ' . $marker . '().'); + } + + return $this->scopes[$marker]; } } diff --git a/tests/bench/MutatingScopeMergeBench.php b/tests/bench/MutatingScopeMergeBench.php index 11061e800d0..7380b6e1ea0 100644 --- a/tests/bench/MutatingScopeMergeBench.php +++ b/tests/bench/MutatingScopeMergeBench.php @@ -45,12 +45,16 @@ public function boot(string $merge): void { $container = BenchContainer::create($this->scratch); $scopes = BenchScope::analyse($container, $this->scratch, 'merge', self::code()); - [$this->ours, $this->theirs] = match ($merge) { - 'same-scope' => [$scopes->scope('markerVariables'), $scopes->scope('markerVariables')], - 'variables-on-one-side' => [$scopes->scope('markerVariables'), $scopes->scope('markerVariablesBefore')], - 'property-fetches-on-one-side' => [$scopes->scope('markerProperties'), $scopes->scope('markerPropertiesBefore')], - default => throw new LogicException('Unknown merge ' . $merge . '.'), - }; + $markers = [ + 'same-scope' => ['markerVariables', 'markerVariables'], + 'variables-on-one-side' => ['markerVariables', 'markerVariablesBefore'], + 'property-fetches-on-one-side' => ['markerProperties', 'markerPropertiesBefore'], + ]; + if (!isset($markers[$merge])) { + throw new LogicException('Unknown merge ' . $merge . '.'); + } + $this->ours = $scopes->scope($markers[$merge][0]); + $this->theirs = $scopes->scope($markers[$merge][1]); // the analysis merges the same expressions again and again, the first merge may remember what it learned about them $this->ours->mergeWith($this->theirs); From 0fc19eab14f1c065a2ea9a74b01b1c3ef0cd7cce Mon Sep 17 00:00:00 2001 From: "ondrej@mirtes.cz" Date: Sat, 14 Nov 2026 07:26:16 +0000 Subject: [PATCH 17/18] Declare TypeCombinator::ALREADY_NORMALIZED_SCAN_LIMIT in the turbo mirror The differential test compares the class constants of both implementations. Amp-Thread-ID: https://ampcode.com/threads/T-01a1d525-9c4c-73cf-8edc-000aa8e78378 --- turbo-ext/src/TypeCombinator.cpp | 2 ++ 1 file changed, 2 insertions(+) diff --git a/turbo-ext/src/TypeCombinator.cpp b/turbo-ext/src/TypeCombinator.cpp index 9c217fe3323..a63def8d15a 100644 --- a/turbo-ext/src/TypeCombinator.cpp +++ b/turbo-ext/src/TypeCombinator.cpp @@ -4983,6 +4983,8 @@ PT_MINIT_REGISTRATION(pt_register_type_combinator) reg::Class cls("PHPStan\\Type\\TypeCombinator"); ptdecl::TypeCombinator::declareClass(cls); + /* the twin's private const, which only its scan of a small flattened union reads */ + cls.privateClassConstantLong("ALREADY_NORMALIZED_SCAN_LIMIT", 4); cls.privateStaticTypedPropertyDefaultNull("cacheEnabled", MAY_BE_BOOL); cls.method(sigs::clearCache, [](INTERNAL_FUNCTION_PARAMETERS) { From 633c6d1bd683d63c645ecfef1d1a55f6370d0fda Mon Sep 17 00:00:00 2001 From: "ondrej@mirtes.cz" Date: Sat, 14 Nov 2026 07:29:03 +0000 Subject: [PATCH 18/18] Accept the benchmark numbers of CI 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 --- tests/bench/AcceptsResultBench.php | 7 ++++++ tests/bench/AccessoryArrayListTypeBench.php | 3 +++ tests/bench/ArgumentsNormalizerBench.php | 3 +++ tests/bench/ArrayTypeBench.php | 3 +++ tests/bench/BetterReflectionProviderBench.php | 2 ++ ...terReflectionSourceLocatorFactoryBench.php | 2 ++ tests/bench/ClassReflectionBench.php | 8 +++++++ tests/bench/CollectorsRegistryBench.php | 2 ++ tests/bench/ComposerHelperBench.php | 2 ++ tests/bench/ConstantArrayTypeAcceptsBench.php | 3 +++ tests/bench/ConstantArrayTypeBench.php | 7 ++++++ tests/bench/ConstantArrayTypeBuilderBench.php | 3 +++ tests/bench/ConstantArrayTypePhp74Bench.php | 2 ++ tests/bench/ConstantStringTypeBench.php | 3 +++ .../bench/ConstantStringTypeDescribeBench.php | 2 ++ tests/bench/DependencyResolverBench.php | 4 ++++ ...ynamicReturnTypeExtensionRegistryBench.php | 3 +++ tests/bench/EnumTypesBench.php | 4 ++++ tests/bench/ExpressionTypeHolderBench.php | 3 +++ tests/bench/FileCacheStorageBench.php | 5 ++++ tests/bench/FileCacheStorageMissBench.php | 2 ++ tests/bench/FileFinderBench.php | 2 ++ tests/bench/FiniteTypeSetBench.php | 2 ++ tests/bench/GenericObjectTypeCheckBench.php | 3 +++ ...GenericParametersAcceptorResolverBench.php | 3 +++ .../HashFunctionsReturnTypeExtensionBench.php | 2 ++ tests/bench/IgnoredErrorHelperBench.php | 3 +++ .../InitializerExprTypeResolverBench.php | 5 ++++ tests/bench/IntersectionTypeBench.php | 5 ++++ .../IntersectionTypeMethodReflectionBench.php | 3 +++ tests/bench/IntersectionTypeQueriesBench.php | 3 +++ tests/bench/IsSuperTypeOfResultBench.php | 9 ++++++++ .../IsSuperTypeOfResultOperationsBench.php | 5 ++++ tests/bench/LazyInternalScopeFactoryBench.php | 2 ++ tests/bench/LoaderFactoryBench.php | 2 ++ .../MemoizingReflectionProviderBench.php | 4 ++++ .../bench/MutatingScopeArrayDimFetchBench.php | 3 +++ tests/bench/MutatingScopeBench.php | 14 +++++++++++ tests/bench/MutatingScopeBranchMergeBench.php | 8 +++++++ tests/bench/MutatingScopeImpureCallsBench.php | 2 ++ tests/bench/MutatingScopeMergeBench.php | 4 ++++ tests/bench/NetteContainerBench.php | 3 +++ tests/bench/NodeScopeResolverBench.php | 7 ++++++ tests/bench/NodeScopeResolverWalkBench.php | 6 +++++ tests/bench/NullsafeOperatorHelperBench.php | 6 +++++ tests/bench/ObjectTypeBench.php | 3 +++ tests/bench/ObjectTypeIsSuperTypeOfBench.php | 3 +++ .../OperatorTypeSpecifyingExtensionBench.php | 5 ++++ .../bench/PackageDependencyResolverBench.php | 3 +++ .../bench/ParametersAcceptorSelectorBench.php | 4 ++++ tests/bench/PathRoutingParserBench.php | 3 +++ tests/bench/Php8SignatureMapProviderBench.php | 4 ++++ .../bench/PhpDocInheritanceResolverBench.php | 3 +++ tests/bench/PhpFileCleanerBench.php | 5 ++++ ...pFunctionFromParserNodeReflectionBench.php | 3 +++ tests/bench/PhpMethodReflectionBench.php | 2 ++ tests/bench/RegexGroupParserBench.php | 3 +++ tests/bench/ReturnTypeExtensionBench.php | 7 ++++++ tests/bench/RuleBench.php | 20 ++++++++++++++++ tests/bench/TemplateTypeBench.php | 5 ++++ tests/bench/TemplateTypeHelperBench.php | 3 +++ tests/bench/TrinaryLogicBench.php | 9 ++++++++ tests/bench/TypeCombinatorBench.php | 23 +++++++++++++++++++ tests/bench/TypeNodeResolverBench.php | 3 +++ tests/bench/TypeSpecifierBench.php | 11 +++++++++ tests/bench/TypeUtilsBench.php | 3 +++ tests/bench/UnionTypeBench.php | 7 ++++++ tests/bench/UnresolvableTypeHelperBench.php | 3 +++ tests/bench/VarTagTypeRuleHelperBench.php | 2 ++ tests/bench/VerbosityLevelBench.php | 3 +++ tests/bench/VerbosityLevelFactoriesBench.php | 2 ++ .../data/and-chain-resolve-type-blowup.php | 4 ++-- tests/bench/data/and-chain-truthy-blowup.php | 6 ++--- .../data/array-values-literal-keyed-map.php | 6 ++--- tests/bench/data/big-constant-int-union.php | 6 ++--- .../big-constant-string-union-operators.php | 6 ++--- .../bench/data/big-constant-string-union.php | 6 ++--- tests/bench/data/bug-10147.php | 6 ++--- tests/bench/data/bug-10538.php | 6 ++--- tests/bench/data/bug-10772.php | 6 ++--- tests/bench/data/bug-10979.php | 6 ++--- tests/bench/data/bug-11263.php | 6 ++--- tests/bench/data/bug-11283.php | 6 ++--- tests/bench/data/bug-11297.php | 6 ++--- tests/bench/data/bug-11913.php | 6 ++--- tests/bench/data/bug-12159.php | 6 ++--- tests/bench/data/bug-12671.php | 6 ++--- tests/bench/data/bug-12787.php | 6 ++--- tests/bench/data/bug-12800.php | 6 ++--- tests/bench/data/bug-13218.php | 6 ++--- tests/bench/data/bug-13310.php | 6 ++--- tests/bench/data/bug-13352.php | 6 ++--- tests/bench/data/bug-13685.php | 6 ++--- tests/bench/data/bug-1388.php | 6 ++--- tests/bench/data/bug-13933.php | 6 ++--- tests/bench/data/bug-14207-and.php | 6 ++--- tests/bench/data/bug-14207.php | 6 ++--- tests/bench/data/bug-14319.php | 6 ++--- tests/bench/data/bug-14452.php | 6 ++--- tests/bench/data/bug-14462.php | 6 ++--- tests/bench/data/bug-1447.php | 6 ++--- tests/bench/data/bug-14475.php | 6 ++--- tests/bench/data/bug-14590.php | 6 ++--- tests/bench/data/bug-14624.php | 6 ++--- tests/bench/data/bug-14674.php | 6 ++--- tests/bench/data/bug-14869-enum.php | 6 ++--- tests/bench/data/bug-14869.php | 6 ++--- tests/bench/data/bug-14972-concat.php | 6 ++--- tests/bench/data/bug-14972.php | 6 ++--- tests/bench/data/bug-14996.php | 6 ++--- tests/bench/data/bug-15061.php | 6 ++--- tests/bench/data/bug-15305.php | 6 ++--- tests/bench/data/bug-15348.php | 6 ++--- tests/bench/data/bug-15452.php | 6 ++--- tests/bench/data/bug-15498.php | 6 ++--- tests/bench/data/bug-15591.php | 6 ++--- tests/bench/data/bug-3686.php | 6 ++--- tests/bench/data/bug-4300.php | 6 ++--- tests/bench/data/bug-4308.php | 6 ++--- tests/bench/data/bug-5081.php | 6 ++--- tests/bench/data/bug-5231.php | 6 ++--- tests/bench/data/bug-5231_2.php | 6 ++--- tests/bench/data/bug-5390.php | 6 ++--- tests/bench/data/bug-6265.php | 6 ++--- tests/bench/data/bug-6442.php | 6 ++--- tests/bench/data/bug-6936.php | 6 ++--- tests/bench/data/bug-6948.php | 6 ++--- tests/bench/data/bug-7140.php | 6 ++--- tests/bench/data/bug-7214.php | 6 ++--- tests/bench/data/bug-7581.php | 6 ++--- tests/bench/data/bug-7637.php | 6 ++--- tests/bench/data/bug-7901.php | 6 ++--- tests/bench/data/bug-7903.php | 6 ++--- tests/bench/data/bug-8146a.php | 6 ++--- tests/bench/data/bug-8146b.php | 6 ++--- tests/bench/data/bug-8147.php | 6 ++--- tests/bench/data/bug-8215.php | 6 ++--- tests/bench/data/bug-8503.php | 6 ++--- tests/bench/data/bug-9690.php | 6 ++--- tests/bench/data/callable-union-describe.php | 6 ++--- .../data/closure-variable-invocations.php | 6 ++--- .../conditional-expression-infinite-loop.php | 6 ++--- tests/bench/data/enum-case-union-removal.php | 6 ++--- tests/bench/data/enum-value-keyed-map.php | 6 ++--- .../finite-types-optional-keys-blowup.php | 6 ++--- .../data/finite-types-optional-keys-count.php | 6 ++--- .../flatten-types-optional-keys-blowup.php | 6 ++--- tests/bench/data/hash-key-lookup.php | 6 ++--- .../data/implode-optional-keys-blowup.php | 6 ++--- tests/bench/data/impure-call-columns.php | 6 ++--- .../bench/data/in-array-intersect-blowup.php | 6 ++--- .../data/literal-key-union-offset-write.php | 6 ++--- tests/bench/data/long-statement-list.php | 6 ++--- .../data/loop-widening-optional-keys.php | 6 ++--- tests/bench/data/loose-string-comparison.php | 6 ++--- tests/bench/data/nested-context-snapshots.php | 6 ++--- .../data/nested-loops-wrapping-array.php | 6 ++--- tests/bench/data/nullsafe-chain-walk.php | 6 ++--- tests/bench/data/or-chain-falsey-blowup.php | 6 ++--- .../data/or-chain-resolve-type-blowup.php | 6 ++--- .../bench/data/phpdoc-inheritance-speedup.php | 6 ++--- .../bench/data/phpdoc-intersection-blowup.php | 6 ++--- .../process-called-method-infinite-loop.php | 6 ++--- tests/bench/data/sealed-shape-if-chain.php | 6 ++--- tests/bench/data/union-fast-path.php | 6 ++--- .../data/unrolled-foreach-list-guards.php | 6 ++--- tests/bench/data/wordpress-user.php | 6 ++--- 167 files changed, 608 insertions(+), 287 deletions(-) diff --git a/tests/bench/AcceptsResultBench.php b/tests/bench/AcceptsResultBench.php index b2e2afb736f..1c2d5f6db84 100644 --- a/tests/bench/AcceptsResultBench.php +++ b/tests/bench/AcceptsResultBench.php @@ -9,6 +9,7 @@ use PHPStan\Type\UnionType; use Tally\Attribute\Before; use Tally\Attribute\DataProvider; +use Tally\Attribute\ExpectedFor; use Tally\ScratchDirectory; final class AcceptsResultBench @@ -51,12 +52,16 @@ public function boot(int $results): void } #[DataProvider(method: 'results')] + #[ExpectedFor(dataSet: 'results-16', instructions: '16.91K', cycles: '70.34K', memory: '1.180KiB')] + #[ExpectedFor(dataSet: 'results-256', instructions: '212.4K', cycles: '362.0K', memory: '24.24KiB')] public function benchMaxMin(int $results): AcceptsResult { return AcceptsResult::maxMin(...$this->results); } #[DataProvider(method: 'results')] + #[ExpectedFor(dataSet: 'results-16', instructions: '18.73K', cycles: '80.59K', memory: '1.547KiB')] + #[ExpectedFor(dataSet: 'results-256', instructions: '227.3K', cycles: '392.4K', memory: '32.30KiB')] public function benchExtremeIdentity(int $results): AcceptsResult { return AcceptsResult::extremeIdentity(...$this->results); @@ -66,6 +71,8 @@ public function benchExtremeIdentity(int $results): AcceptsResult * StringType::accepts() accepts each constant string of the union. */ #[DataProvider(method: 'results')] + #[ExpectedFor(dataSet: 'results-16', instructions: '43.10K', cycles: '136.6K', memory: '1.547KiB')] + #[ExpectedFor(dataSet: 'results-256', instructions: '557.0K', cycles: '736.1K', memory: '32.30KiB')] public function benchStringAcceptsUnion(int $results): AcceptsResult { return $this->string->accepts($this->union, true); diff --git a/tests/bench/AccessoryArrayListTypeBench.php b/tests/bench/AccessoryArrayListTypeBench.php index 415a1eb4476..3b38ee4f3da 100644 --- a/tests/bench/AccessoryArrayListTypeBench.php +++ b/tests/bench/AccessoryArrayListTypeBench.php @@ -9,6 +9,7 @@ use PHPStan\Type\IsSuperTypeOfResult; use PHPStan\Type\MixedType; use Tally\Attribute\Before; +use Tally\Attribute\Expected; use Tally\ScratchDirectory; /** @@ -38,6 +39,7 @@ public function boot(): void $this->list->accepts($this->array, true); } + #[Expected(instructions: '1.805M', cycles: '1.869M', memory: '472B')] public function benchIsSuperTypeOf(): IsSuperTypeOfResult { [$list, $array] = $this->types(); @@ -49,6 +51,7 @@ public function benchIsSuperTypeOf(): IsSuperTypeOfResult return $result; } + #[Expected(instructions: '2.611M', cycles: '2.652M', memory: '216B')] public function benchAccepts(): bool { [$list, $array] = $this->types(); diff --git a/tests/bench/ArgumentsNormalizerBench.php b/tests/bench/ArgumentsNormalizerBench.php index 477f35b7397..5db49228a78 100644 --- a/tests/bench/ArgumentsNormalizerBench.php +++ b/tests/bench/ArgumentsNormalizerBench.php @@ -14,6 +14,7 @@ use PHPStan\Reflection\ReflectionProvider; use Tally\Attribute\Before; use Tally\Attribute\DataProvider; +use Tally\Attribute\ExpectedFor; use Tally\ScratchDirectory; final class ArgumentsNormalizerBench @@ -52,6 +53,8 @@ public function boot(bool $named): void * A call without named arguments is returned as it is, a call with them is rebuilt in the order of the parameters. */ #[DataProvider(method: 'calls')] + #[ExpectedFor(dataSet: 'named', instructions: '24.70K', cycles: '121.3K', memory: '2.516KiB')] + #[ExpectedFor(dataSet: 'positional', instructions: '2.670K', cycles: '27.86K', memory: '0B')] public function benchReorderFuncArguments(bool $named): ?FuncCall { if ($this->acceptor === null || $this->call === null) { diff --git a/tests/bench/ArrayTypeBench.php b/tests/bench/ArrayTypeBench.php index 742f8fa7e49..1d4e4e00b05 100644 --- a/tests/bench/ArrayTypeBench.php +++ b/tests/bench/ArrayTypeBench.php @@ -10,6 +10,7 @@ use PHPStan\Type\MixedType; use PHPStan\Type\Type; use Tally\Attribute\Before; +use Tally\Attribute\Expected; use Tally\ScratchDirectory; final class ArrayTypeBench @@ -36,6 +37,7 @@ public function boot(): void /** * array may be a list, which takes two comparisons of its key type to find out. */ + #[Expected(instructions: '616.4K', cycles: '654.3K', memory: '376B')] public function benchIsList(): TrinaryLogic { $result = TrinaryLogic::createNo(); @@ -50,6 +52,7 @@ public function benchIsList(): TrinaryLogic * The constructor turns a benevolent (int|string) key into mixed, which other key types do not * need to be described for. */ + #[Expected(instructions: '1.699M', cycles: '1.749M', memory: '416B')] public function benchConstruct(): Type { $keyType = IntegerRangeType::fromInterval(0, 100); diff --git a/tests/bench/BetterReflectionProviderBench.php b/tests/bench/BetterReflectionProviderBench.php index 8c0c6ed7a93..7ce706b505e 100644 --- a/tests/bench/BetterReflectionProviderBench.php +++ b/tests/bench/BetterReflectionProviderBench.php @@ -12,6 +12,7 @@ use PHPStan\Reflection\ClassReflection; use PHPStan\Reflection\ReflectionProvider; use Tally\Attribute\Before; +use Tally\Attribute\Expected; use Tally\ScratchDirectory; use function file_put_contents; @@ -49,6 +50,7 @@ public function boot(): void /** * Every scope that enters an anonymous class asks for its reflection again. */ + #[Expected(instructions: '10.83K', cycles: '92.06K', memory: '256B')] public function benchGetAnonymousClassReflectionAgain(): ClassReflection { if ($this->reflectionProvider === null || $this->class === null || $this->scope === null) { diff --git a/tests/bench/BetterReflectionSourceLocatorFactoryBench.php b/tests/bench/BetterReflectionSourceLocatorFactoryBench.php index a2a2f0c2c34..e28922808cd 100644 --- a/tests/bench/BetterReflectionSourceLocatorFactoryBench.php +++ b/tests/bench/BetterReflectionSourceLocatorFactoryBench.php @@ -6,6 +6,7 @@ use PHPStan\BetterReflection\SourceLocator\Type\SourceLocator; use PHPStan\Reflection\BetterReflection\BetterReflectionSourceLocatorFactory; use Tally\Attribute\Before; +use Tally\Attribute\Expected; use Tally\ScratchDirectory; final class BetterReflectionSourceLocatorFactoryBench @@ -26,6 +27,7 @@ public function boot(): void /** * Every process creates the source locator when it boots, also one that never locates a class. */ + #[Expected(instructions: '2.283K', cycles: '29.67K', memory: '1.328KiB')] public function benchCreate(): SourceLocator { if ($this->factory === null) { diff --git a/tests/bench/ClassReflectionBench.php b/tests/bench/ClassReflectionBench.php index 7c80762eb43..526afacc28c 100644 --- a/tests/bench/ClassReflectionBench.php +++ b/tests/bench/ClassReflectionBench.php @@ -10,6 +10,8 @@ use PHPStan\Reflection\ReflectionProvider; use Tally\Attribute\Before; use Tally\Attribute\DataProvider; +use Tally\Attribute\Expected; +use Tally\Attribute\ExpectedFor; use Tally\ScratchDirectory; use function implode; use function sprintf; @@ -69,11 +71,13 @@ public function doubled(array $values): array /** * Asking again for a method answers from the cache filled by the first call. */ + #[Expected(instructions: '2.194K', cycles: '28.37K', memory: '40B')] public function benchGetMethodAgain(): ExtendedMethodReflection { return $this->class()->getMethod('doubled', new OutOfClassScope()); } + #[Expected(instructions: '2.515K', cycles: '32.66K', memory: '40B')] public function benchGetPropertyAgain(): ExtendedPropertyReflection { // @phpstan-ignore method.deprecated (the method the cache early return is in) @@ -83,6 +87,7 @@ public function benchGetPropertyAgain(): ExtendedPropertyReflection /** * The hottest call on the class, memoized since 2.2.14. */ + #[Expected(instructions: '1.016K', cycles: '13.78K', memory: '0B')] public function benchGetName(): string { return $this->class()->getName(); @@ -95,6 +100,9 @@ public function benchGetName(): string * @return array */ #[DataProvider(method: 'depths')] + #[ExpectedFor(dataSet: 'depth-3', instructions: '2.531M', cycles: '3.906M', memory: '35.68KiB')] + #[ExpectedFor(dataSet: 'depth-6', instructions: '10.28M', cycles: '12.70M', memory: '66.72KiB')] + #[ExpectedFor(dataSet: 'depth-9', instructions: '55.29M', cycles: '61.48M', memory: '115.8KiB')] public function benchGetInterfacesOfLattice(int $depth): array { $class = $this->reflectionProvider()->getClass(sprintf('ClassReflectionBench\Lattice%d\Implementation', $depth)); diff --git a/tests/bench/CollectorsRegistryBench.php b/tests/bench/CollectorsRegistryBench.php index 5e64bdf33f7..ab7747f6fb3 100644 --- a/tests/bench/CollectorsRegistryBench.php +++ b/tests/bench/CollectorsRegistryBench.php @@ -6,6 +6,7 @@ use PhpParser\Node\Expr\MethodCall; use PHPStan\Collectors\Registry; use Tally\Attribute\Before; +use Tally\Attribute\Expected; use Tally\ScratchDirectory; final class CollectorsRegistryBench @@ -31,6 +32,7 @@ public function boot(): void * * @return mixed[] */ + #[Expected(instructions: '3.452K', cycles: '28.42K', memory: '376B')] public function benchGetCollectors(): array { if ($this->registry === null) { diff --git a/tests/bench/ComposerHelperBench.php b/tests/bench/ComposerHelperBench.php index 52a09935dcc..27b46d92894 100644 --- a/tests/bench/ComposerHelperBench.php +++ b/tests/bench/ComposerHelperBench.php @@ -6,6 +6,7 @@ use PHPStan\Internal\ComposerHelper; use ReflectionClass; use Tally\Attribute\Before; +use Tally\Attribute\Expected; use Tally\ScratchDirectory; use function dirname; @@ -31,6 +32,7 @@ public function boot(): void * * @return array|null */ + #[Expected(instructions: '1.174K', cycles: '15.80K', memory: '0B')] public function benchGetComposerConfigAgain(): ?array { return ComposerHelper::getComposerConfig($this->root); diff --git a/tests/bench/ConstantArrayTypeAcceptsBench.php b/tests/bench/ConstantArrayTypeAcceptsBench.php index ddc3d3517cb..9172794a1db 100644 --- a/tests/bench/ConstantArrayTypeAcceptsBench.php +++ b/tests/bench/ConstantArrayTypeAcceptsBench.php @@ -13,6 +13,7 @@ use PHPStan\Type\UnionType; use Tally\Attribute\Before; use Tally\Attribute\DataProvider; +use Tally\Attribute\ExpectedFor; use Tally\ScratchDirectory; use function array_fill; @@ -52,6 +53,8 @@ public function boot(int $keys): void } #[DataProvider(method: 'keys')] + #[ExpectedFor(dataSet: 'keys-16', instructions: '2.879M', cycles: '4.157M', memory: '18.22KiB')] + #[ExpectedFor(dataSet: 'keys-128', instructions: '22.85M', cycles: '30.77M', memory: '123.5KiB')] public function benchAcceptsSharedValueType(int $keys): AcceptsResult { if ($this->accepting === null || $this->accepted === null) { diff --git a/tests/bench/ConstantArrayTypeBench.php b/tests/bench/ConstantArrayTypeBench.php index abf614771fb..9acc3121ea7 100644 --- a/tests/bench/ConstantArrayTypeBench.php +++ b/tests/bench/ConstantArrayTypeBench.php @@ -16,6 +16,7 @@ use Tally\Attribute\Before; use Tally\Attribute\Constant; use Tally\Attribute\DataProvider; +use Tally\Attribute\ExpectedFor; use Tally\Attribute\Linear; use Tally\Attribute\Warmup; use Tally\ScratchDirectory; @@ -115,6 +116,9 @@ public function benchIsSuperTypeOf(int $keys): bool * only described when it is reported. */ #[DataProvider(method: 'keys')] + #[ExpectedFor(dataSet: 'keys-16', instructions: '49.30K', cycles: '158.1K', memory: '1.797KiB')] + #[ExpectedFor(dataSet: 'keys-64', instructions: '164.7K', cycles: '300.6K', memory: '4.070KiB')] + #[ExpectedFor(dataSet: 'keys-256', instructions: '625.6K', cycles: '871.5K', memory: '20.32KiB')] public function benchIsSuperTypeOfSealedWithoutKey(int $keys): bool { if ($this->sealed === null || $this->sealedWithoutLastKey === null) { @@ -128,6 +132,9 @@ public function benchIsSuperTypeOfSealedWithoutKey(int $keys): bool * The verbosity of the reason is only chosen when an offset does not accept. */ #[DataProvider(method: 'keys')] + #[ExpectedFor(dataSet: 'keys-16', instructions: '1.921M', cycles: '3.173M', memory: '157.0KiB')] + #[ExpectedFor(dataSet: 'keys-64', instructions: '3.080M', cycles: '4.728M', memory: '157.0KiB')] + #[ExpectedFor(dataSet: 'keys-256', instructions: '7.715M', cycles: '10.97M', memory: '160.8KiB')] public function benchAcceptsSameShape(int $keys): bool { if ($this->sameShape === null) { diff --git a/tests/bench/ConstantArrayTypeBuilderBench.php b/tests/bench/ConstantArrayTypeBuilderBench.php index b0afea85ae7..4fd07f9a566 100644 --- a/tests/bench/ConstantArrayTypeBuilderBench.php +++ b/tests/bench/ConstantArrayTypeBuilderBench.php @@ -11,6 +11,7 @@ use PHPStan\Type\Type; use Tally\Attribute\Before; use Tally\Attribute\DataProvider; +use Tally\Attribute\ExpectedFor; use Tally\Attribute\Quadratic; use Tally\ScratchDirectory; @@ -78,6 +79,8 @@ public function benchSetOffsetValueType(int $keys): Type * A list of closures that return 0, 1, 2, …, like an array of handlers, and the type of its values. */ #[DataProvider(method: 'closures')] + #[ExpectedFor(dataSet: 'closures-16', instructions: '13.03M', cycles: '17.98M', memory: '7.516KiB')] + #[ExpectedFor(dataSet: 'closures-64', instructions: '62.79M', cycles: '76.63M', memory: '1.507MiB')] public function benchListOfClosures(int $closures): Type { if ($this->closures === []) { diff --git a/tests/bench/ConstantArrayTypePhp74Bench.php b/tests/bench/ConstantArrayTypePhp74Bench.php index 700b9cb509b..9ae4fbabbaf 100644 --- a/tests/bench/ConstantArrayTypePhp74Bench.php +++ b/tests/bench/ConstantArrayTypePhp74Bench.php @@ -10,6 +10,7 @@ use PHPStan\Type\ObjectType; use ReflectionClass; use Tally\Attribute\Before; +use Tally\Attribute\Expected; use Tally\ScratchDirectory; use function count; use function dirname; @@ -49,6 +50,7 @@ public function boot(): void /** * [$dateTime, 'format'] and the other methods of DateTime, called on an object. */ + #[Expected(instructions: '169.9K', cycles: '324.2K', memory: '1.164KiB')] public function benchFindTypeAndMethodNames(): int { $found = 0; diff --git a/tests/bench/ConstantStringTypeBench.php b/tests/bench/ConstantStringTypeBench.php index b476eb71ab6..e0b3be39eaa 100644 --- a/tests/bench/ConstantStringTypeBench.php +++ b/tests/bench/ConstantStringTypeBench.php @@ -6,6 +6,7 @@ use PHPStan\Type\Constant\ConstantStringType; use PHPStan\Type\Type; use Tally\Attribute\Before; +use Tally\Attribute\Expected; use Tally\ScratchDirectory; final class ConstantStringTypeBench @@ -49,6 +50,7 @@ public function boot(): void * * @return list */ + #[Expected(instructions: '302.2K', cycles: '435.1K', memory: '8.422KiB')] public function benchToArrayKey(): array { $arrayKeys = []; @@ -63,6 +65,7 @@ public function benchToArrayKey(): array * 'DateTime::format' and other methods of DateTime, static or not. Since PHP 8 such a string * may name an instance method, so the method is not looked into. */ + #[Expected(instructions: '86.86K', cycles: '225.3K', memory: '952B')] public function benchIsCallable(): TrinaryLogic { $result = TrinaryLogic::createYes(); diff --git a/tests/bench/ConstantStringTypeDescribeBench.php b/tests/bench/ConstantStringTypeDescribeBench.php index 60dfe478910..e950d20203c 100644 --- a/tests/bench/ConstantStringTypeDescribeBench.php +++ b/tests/bench/ConstantStringTypeDescribeBench.php @@ -5,6 +5,7 @@ use PHPStan\Type\Constant\ConstantStringType; use PHPStan\Type\VerbosityLevel; use Tally\Attribute\Before; +use Tally\Attribute\Expected; use Tally\ScratchDirectory; final class ConstantStringTypeDescribeBench @@ -35,6 +36,7 @@ public function boot(): void /** * Strings already described, as the members of a union of an enum's values are again and again. */ + #[Expected(instructions: '513.6K', cycles: '726.7K', memory: '0B')] public function benchDescribeAgain(): string { $description = ''; diff --git a/tests/bench/DependencyResolverBench.php b/tests/bench/DependencyResolverBench.php index 25ac1154bee..270fb7a4e43 100644 --- a/tests/bench/DependencyResolverBench.php +++ b/tests/bench/DependencyResolverBench.php @@ -17,6 +17,7 @@ use SplFileInfo; use Tally\Attribute\Before; use Tally\Attribute\DataProvider; +use Tally\Attribute\ExpectedFor; use Tally\ScratchDirectory; use function dirname; @@ -79,6 +80,9 @@ public function boot(string $case): void * The files and packages a reference to a class from an analysed file depends on. */ #[DataProvider(method: 'references')] + #[ExpectedFor(dataSet: 'analysed', instructions: '75.18K', cycles: '221.1K', memory: '4.234KiB')] + #[ExpectedFor(dataSet: 'not-analysed', instructions: '84.77K', cycles: '226.1K', memory: '4.234KiB')] + #[ExpectedFor(dataSet: 'own-file', instructions: '73.58K', cycles: '217.5K', memory: '4.234KiB')] public function benchResolveClassReference(string $case): ResolvedDependencies { if ($this->resolver === null || $this->dependencies === null) { diff --git a/tests/bench/DynamicReturnTypeExtensionRegistryBench.php b/tests/bench/DynamicReturnTypeExtensionRegistryBench.php index fa75d4787d6..ac4fa10f760 100644 --- a/tests/bench/DynamicReturnTypeExtensionRegistryBench.php +++ b/tests/bench/DynamicReturnTypeExtensionRegistryBench.php @@ -6,6 +6,7 @@ use PHPStan\Type\DynamicReturnTypeExtensionRegistry; use RecursiveArrayIterator; use Tally\Attribute\Before; +use Tally\Attribute\Expected; use Tally\ScratchDirectory; use function count; @@ -32,6 +33,7 @@ public function boot(): void $this->registry->getDynamicStaticMethodReturnTypeExtensionsForClass(RecursiveArrayIterator::class); } + #[Expected(instructions: '845.6K', cycles: '896.5K', memory: '424B')] public function benchMethodExtensionsForClass(): int { $registry = $this->registry(); @@ -43,6 +45,7 @@ public function benchMethodExtensionsForClass(): int return $count; } + #[Expected(instructions: '845.6K', cycles: '891.2K', memory: '424B')] public function benchStaticMethodExtensionsForClass(): int { $registry = $this->registry(); diff --git a/tests/bench/EnumTypesBench.php b/tests/bench/EnumTypesBench.php index 244c3c5d0b0..f7348cc7033 100644 --- a/tests/bench/EnumTypesBench.php +++ b/tests/bench/EnumTypesBench.php @@ -13,6 +13,7 @@ use PHPStan\Type\TypeCombinator; use PHPStan\Type\UnionType; use Tally\Attribute\Before; +use Tally\Attribute\Expected; use Tally\ScratchDirectory; /** @@ -54,6 +55,7 @@ public function boot(): void * A member that is one enum case is set aside; whether the intersection with the enum is one * does not need all of its cases. */ + #[Expected(instructions: '156.3K', cycles: '516.7K', memory: '4.328KiB')] public function benchUnionWithIntersection(): Type { if ($this->unionMembers === []) { @@ -66,6 +68,7 @@ public function benchUnionWithIntersection(): Type /** * @return list */ + #[Expected(instructions: '804.4K', cycles: '1.086M', memory: '40.11KiB')] public function benchUnionTypeGetFiniteTypes(): array { if ($this->union === null) { @@ -80,6 +83,7 @@ public function benchUnionTypeGetFiniteTypes(): array * * @return list */ + #[Expected(instructions: '1.121M', cycles: '1.668M', memory: '72.42KiB')] public function benchIntersectionTypeGetFiniteTypes(): array { if ($this->intersection === null) { diff --git a/tests/bench/ExpressionTypeHolderBench.php b/tests/bench/ExpressionTypeHolderBench.php index 1e6bb765e8b..a15327f784c 100644 --- a/tests/bench/ExpressionTypeHolderBench.php +++ b/tests/bench/ExpressionTypeHolderBench.php @@ -8,6 +8,7 @@ use PHPStan\Type\Constant\ConstantStringType; use PHPStan\Type\UnionType; use Tally\Attribute\Before; +use Tally\Attribute\Expected; use Tally\ScratchDirectory; /** @@ -46,6 +47,7 @@ public function boot(): void /** * The same holder (#4777). */ + #[Expected(instructions: '1.101K', cycles: '14.52K', memory: '0B')] public function benchEqualsSameHolder(): bool { $holder = $this->holder(); @@ -56,6 +58,7 @@ public function benchEqualsSameHolder(): bool /** * Another holder of the same type object (#4800). */ + #[Expected(instructions: '2.659K', cycles: '28.84K', memory: '0B')] public function benchAndSameType(): ExpressionTypeHolder { if ($this->sameType === null) { diff --git a/tests/bench/FileCacheStorageBench.php b/tests/bench/FileCacheStorageBench.php index 805f4515929..55a093f41d0 100644 --- a/tests/bench/FileCacheStorageBench.php +++ b/tests/bench/FileCacheStorageBench.php @@ -7,6 +7,7 @@ use PHPStan\Internal\DirectoryCreatorException; use Tally\Attribute\Before; use Tally\Attribute\DataProvider; +use Tally\Attribute\ExpectedFor; use Tally\ScratchDirectory; final class FileCacheStorageBench @@ -52,6 +53,8 @@ public function boot(int $entries): void * @throws DirectoryCreatorException */ #[DataProvider(method: 'entries')] + #[ExpectedFor(dataSet: 'entries-100', instructions: '246.7K', cycles: '601.2K', memory: '17.29KiB')] + #[ExpectedFor(dataSet: 'entries-10000', instructions: '19.12M', cycles: '32.34M', memory: '1.345MiB')] public function benchSave(int $entries): void { if ($this->storage === null) { @@ -62,6 +65,8 @@ public function benchSave(int $entries): void } #[DataProvider(method: 'entries')] + #[ExpectedFor(dataSet: 'entries-100', instructions: '388.9K', cycles: '763.4K', memory: '130.1KiB')] + #[ExpectedFor(dataSet: 'entries-10000', instructions: '37.00M', cycles: '70.18M', memory: '11.67MiB')] public function benchLoad(int $entries): mixed { if ($this->storage === null) { diff --git a/tests/bench/FileCacheStorageMissBench.php b/tests/bench/FileCacheStorageMissBench.php index 60d5b27560d..a1c1fe74a0f 100644 --- a/tests/bench/FileCacheStorageMissBench.php +++ b/tests/bench/FileCacheStorageMissBench.php @@ -6,6 +6,7 @@ use PHPStan\Cache\FileCacheStorage; use PHPStan\Internal\DirectoryCreatorException; use Tally\Attribute\Before; +use Tally\Attribute\Expected; use Tally\ScratchDirectory; final class FileCacheStorageMissBench @@ -33,6 +34,7 @@ public function boot(): void * The reflection cache in front of the PHP-internal locator is asked for every class name the * locators before it do not find, and most were never written. */ + #[Expected(instructions: '7.924K', cycles: '57.79K', memory: '824B')] public function benchLoadMissing(): mixed { if ($this->storage === null) { diff --git a/tests/bench/FileFinderBench.php b/tests/bench/FileFinderBench.php index 3bace4cc118..d1bbe35c7ee 100644 --- a/tests/bench/FileFinderBench.php +++ b/tests/bench/FileFinderBench.php @@ -9,6 +9,7 @@ use PHPStan\File\FileFinderResult; use PHPStan\File\FileHelper; use Tally\Attribute\Before; +use Tally\Attribute\Expected; use Tally\ScratchDirectory; use function class_exists; use function is_dir; @@ -59,6 +60,7 @@ public function boot(): void $this->finder->findFiles([$this->paths[0], $this->paths[1]]); } + #[Expected(instructions: '3.204M', cycles: '3.939M', memory: '46.80KiB')] public function benchFindFiles(): FileFinderResult { if ($this->finder === null) { diff --git a/tests/bench/FiniteTypeSetBench.php b/tests/bench/FiniteTypeSetBench.php index 255db0036e9..d2dee8b159d 100644 --- a/tests/bench/FiniteTypeSetBench.php +++ b/tests/bench/FiniteTypeSetBench.php @@ -8,6 +8,7 @@ use PHPStan\Type\NullType; use PHPStan\Type\Type; use Tally\Attribute\Before; +use Tally\Attribute\Expected; use Tally\ScratchDirectory; final class FiniteTypeSetBench @@ -42,6 +43,7 @@ public function boot(): void /** * Every value a union is compared with is keyed. */ + #[Expected(instructions: '355.5K', cycles: '465.2K', memory: '336B')] public function benchKey(): ?string { $key = null; diff --git a/tests/bench/GenericObjectTypeCheckBench.php b/tests/bench/GenericObjectTypeCheckBench.php index a4a1e76c6f8..f7d53597b88 100644 --- a/tests/bench/GenericObjectTypeCheckBench.php +++ b/tests/bench/GenericObjectTypeCheckBench.php @@ -12,6 +12,7 @@ use PHPStan\Type\UnionType; use Tally\Attribute\Before; use Tally\Attribute\DataProvider; +use Tally\Attribute\ExpectedFor; use Tally\ScratchDirectory; final class GenericObjectTypeCheckBench @@ -49,6 +50,8 @@ public function boot(int $keys): void * @return list */ #[DataProvider(method: 'keys')] + #[ExpectedFor(dataSet: 'keys-16', instructions: '46.09K', cycles: '104.2K', memory: '0B')] + #[ExpectedFor(dataSet: 'keys-64', instructions: '174.5K', cycles: '291.4K', memory: '0B')] public function benchCheckTypeWithoutClasses(int $keys): array { if ($this->type === null) { diff --git a/tests/bench/GenericParametersAcceptorResolverBench.php b/tests/bench/GenericParametersAcceptorResolverBench.php index 3a19c399e92..d10bdbfa158 100644 --- a/tests/bench/GenericParametersAcceptorResolverBench.php +++ b/tests/bench/GenericParametersAcceptorResolverBench.php @@ -16,6 +16,7 @@ use PHPStan\Type\Type; use Tally\Attribute\Before; use Tally\Attribute\DataProvider; +use Tally\Attribute\ExpectedFor; use Tally\ScratchDirectory; final class GenericParametersAcceptorResolverBench @@ -55,6 +56,8 @@ public function boot(int $parameters): void * resolved into an extended one with all of its parameters copied. */ #[DataProvider(method: 'parameters')] + #[ExpectedFor(dataSet: 'parameters-2', instructions: '91.12K', cycles: '342.5K', memory: '4.328KiB')] + #[ExpectedFor(dataSet: 'parameters-16', instructions: '1.002M', cycles: '1.510M', memory: '10.73KiB')] public function benchResolve(int $parameters): ParametersAcceptor { if ($this->acceptor === null) { diff --git a/tests/bench/HashFunctionsReturnTypeExtensionBench.php b/tests/bench/HashFunctionsReturnTypeExtensionBench.php index 3df7e2f8cca..6aa5921d8f0 100644 --- a/tests/bench/HashFunctionsReturnTypeExtensionBench.php +++ b/tests/bench/HashFunctionsReturnTypeExtensionBench.php @@ -4,6 +4,7 @@ use PHPStan\Php\PhpVersion; use PHPStan\Type\Php\HashFunctionsReturnTypeExtension; +use Tally\Attribute\Expected; use Tally\Attribute\Warmup; #[Warmup(calls: 1)] @@ -17,6 +18,7 @@ final class HashFunctionsReturnTypeExtensionBench * The container creates the extension for every analysis, also of code that calls no hash function. * Since 2.3.0 its constructor takes no PHP version, PHP ignores the argument there. */ + #[Expected(instructions: '1.640K', cycles: '19.02K', memory: '136B')] public function benchCreate(): HashFunctionsReturnTypeExtension { // @phpstan-ignore new.noConstructor (2.2.13 to 2.2.17 take the PHP version) diff --git a/tests/bench/IgnoredErrorHelperBench.php b/tests/bench/IgnoredErrorHelperBench.php index 73d2823cdcf..52692e1e78a 100644 --- a/tests/bench/IgnoredErrorHelperBench.php +++ b/tests/bench/IgnoredErrorHelperBench.php @@ -10,6 +10,7 @@ use PHPStan\Analyser\Ignore\IgnoredErrorHelperResult; use Tally\Attribute\Before; use Tally\Attribute\DataProvider; +use Tally\Attribute\ExpectedFor; use Tally\ScratchDirectory; use function file_put_contents; @@ -85,6 +86,8 @@ public function boot(int $paths, bool $otherIdentifiers): void * Each error is matched against each ignoreErrors entry. */ #[DataProvider(method: 'ignoreErrors')] + #[ExpectedFor(dataSet: 'other-identifiers', instructions: '227.2K', cycles: '417.5K', memory: '3.438KiB')] + #[ExpectedFor(dataSet: 'paths-8', instructions: '21.83M', cycles: '29.11M', memory: '5.750KiB')] public function benchProcess(int $paths, bool $otherIdentifiers): IgnoredErrorHelperProcessedResult { if ($this->result === null) { diff --git a/tests/bench/InitializerExprTypeResolverBench.php b/tests/bench/InitializerExprTypeResolverBench.php index d4de75405f7..8ea39d438e5 100644 --- a/tests/bench/InitializerExprTypeResolverBench.php +++ b/tests/bench/InitializerExprTypeResolverBench.php @@ -12,6 +12,7 @@ use PHPStan\Type\Type; use Tally\Attribute\Before; use Tally\Attribute\DataProvider; +use Tally\Attribute\ExpectedFor; use Tally\ScratchDirectory; final class InitializerExprTypeResolverBench @@ -52,6 +53,8 @@ public function boot(int $items): void * A ternary with both branches, `[...] ? 1 : 2`, does not need the type of its condition. */ #[DataProvider(method: 'items')] + #[ExpectedFor(dataSet: 'items-8', instructions: '57.59K', cycles: '258.3K', memory: '2.516KiB')] + #[ExpectedFor(dataSet: 'items-128', instructions: '57.59K', cycles: '259.2K', memory: '2.516KiB')] public function benchTernary(int $items): Type { if ($this->ternary === null) { @@ -65,6 +68,8 @@ public function benchTernary(int $items): Type * `[...['k0' => 0, 'k1' => 1, ...]]`, whose string keys are kept since PHP 8.1 */ #[DataProvider(method: 'items')] + #[ExpectedFor(dataSet: 'items-8', instructions: '234.4K', cycles: '467.9K', memory: '10.89KiB')] + #[ExpectedFor(dataSet: 'items-128', instructions: '11.05M', cycles: '12.69M', memory: '143.9KiB')] public function benchUnpackStringKeys(int $items): Type { if ($this->unpack === null) { diff --git a/tests/bench/IntersectionTypeBench.php b/tests/bench/IntersectionTypeBench.php index 7838b40b79a..1cbafe9863e 100644 --- a/tests/bench/IntersectionTypeBench.php +++ b/tests/bench/IntersectionTypeBench.php @@ -13,6 +13,7 @@ use Tally\Attribute\Before; use Tally\Attribute\Constant; use Tally\Attribute\DataProvider; +use Tally\Attribute\ExpectedFor; use Tally\ScratchDirectory; /** @@ -74,12 +75,16 @@ public function benchAskAgainWhatItIs(int $members): TrinaryLogic * stdClass, the first member, is already a subtype of stdClass. */ #[DataProvider(method: 'members')] + #[ExpectedFor(dataSet: 'members-16', instructions: '10.59K', cycles: '94.13K', memory: '1.188KiB')] + #[ExpectedFor(dataSet: 'members-64', instructions: '10.59K', cycles: '94.13K', memory: '1.188KiB')] public function benchIsSubTypeOf(int $members): IsSuperTypeOfResult { return $this->subject()->isSubTypeOf(new ObjectType(stdClass::class)); } #[DataProvider(method: 'members')] + #[ExpectedFor(dataSet: 'members-16', instructions: '230.8K', cycles: '467.4K', memory: '5.898KiB')] + #[ExpectedFor(dataSet: 'members-64', instructions: '1.378M', cycles: '1.659M', memory: '11.98KiB')] public function benchIsAcceptedBy(int $members): AcceptsResult { return $this->subject()->isAcceptedBy(new ObjectType(stdClass::class), true); diff --git a/tests/bench/IntersectionTypeMethodReflectionBench.php b/tests/bench/IntersectionTypeMethodReflectionBench.php index 4fe7f1d264a..9e93539aed8 100644 --- a/tests/bench/IntersectionTypeMethodReflectionBench.php +++ b/tests/bench/IntersectionTypeMethodReflectionBench.php @@ -8,6 +8,7 @@ use PHPStan\Reflection\Type\IntersectionTypeMethodReflection; use Tally\Attribute\Before; use Tally\Attribute\DataProvider; +use Tally\Attribute\ExpectedFor; use Tally\ScratchDirectory; use function array_slice; use function sprintf; @@ -57,6 +58,8 @@ public function boot(int $methods): void * @return list */ #[DataProvider(method: 'methods')] + #[ExpectedFor(dataSet: 'methods-2', instructions: '186.4K', cycles: '493.4K', memory: '5.094KiB')] + #[ExpectedFor(dataSet: 'methods-8', instructions: '1.070M', cycles: '1.642M', memory: '7.250KiB')] public function benchGetVariants(int $methods): array { if ($this->method === null) { diff --git a/tests/bench/IntersectionTypeQueriesBench.php b/tests/bench/IntersectionTypeQueriesBench.php index 8ecdb9eb361..5889a798c66 100644 --- a/tests/bench/IntersectionTypeQueriesBench.php +++ b/tests/bench/IntersectionTypeQueriesBench.php @@ -18,6 +18,7 @@ use PHPStan\Type\StringType; use PHPStan\Type\VerbosityLevel; use Tally\Attribute\Before; +use Tally\Attribute\Expected; use Tally\ScratchDirectory; /** @@ -59,6 +60,7 @@ public function boot(): void $this->string->describe(VerbosityLevel::precise()); } + #[Expected(instructions: '349.9K', cycles: '365.6K', memory: '0B')] public function benchIsOffsetAccessible(): TrinaryLogic { if ($this->array === null) { @@ -73,6 +75,7 @@ public function benchIsOffsetAccessible(): TrinaryLogic return $result; } + #[Expected(instructions: '1.180M', cycles: '1.199M', memory: '0B')] public function benchDescribe(): string { if ($this->string === null) { diff --git a/tests/bench/IsSuperTypeOfResultBench.php b/tests/bench/IsSuperTypeOfResultBench.php index 0f7cd61a527..d799cc987be 100644 --- a/tests/bench/IsSuperTypeOfResultBench.php +++ b/tests/bench/IsSuperTypeOfResultBench.php @@ -16,6 +16,7 @@ use PHPStan\Type\UnionType; use Tally\Attribute\Before; use Tally\Attribute\DataProvider; +use Tally\Attribute\ExpectedFor; use Tally\ScratchDirectory; final class IsSuperTypeOfResultBench @@ -63,12 +64,16 @@ public function boot(int $results): void } #[DataProvider(method: 'results')] + #[ExpectedFor(dataSet: 'results-16', instructions: '17.77K', cycles: '64.88K', memory: '1.102KiB')] + #[ExpectedFor(dataSet: 'results-256', instructions: '234.9K', cycles: '392.2K', memory: '24.16KiB')] public function benchMaxMin(int $results): IsSuperTypeOfResult { return IsSuperTypeOfResult::maxMin(...$this->results); } #[DataProvider(method: 'results')] + #[ExpectedFor(dataSet: 'results-16', instructions: '19.58K', cycles: '75.07K', memory: '1.469KiB')] + #[ExpectedFor(dataSet: 'results-256', instructions: '249.9K', cycles: '422.3K', memory: '32.22KiB')] public function benchExtremeIdentity(int $results): IsSuperTypeOfResult { return IsSuperTypeOfResult::extremeIdentity(...$this->results); @@ -78,6 +83,8 @@ public function benchExtremeIdentity(int $results): IsSuperTypeOfResult * StringType::isSuperTypeOf() is asked about each constant string of the union. */ #[DataProvider(method: 'results')] + #[ExpectedFor(dataSet: 'results-16', instructions: '41.64K', cycles: '128.5K', memory: '1.469KiB')] + #[ExpectedFor(dataSet: 'results-256', instructions: '548.1K', cycles: '720.9K', memory: '32.22KiB')] public function benchStringIsSuperTypeOfUnion(int $results): IsSuperTypeOfResult { return $this->string->isSuperTypeOf($this->union); @@ -87,6 +94,8 @@ public function benchStringIsSuperTypeOfUnion(int $results): IsSuperTypeOfResult * Each accessory type is asked about each constant string. */ #[DataProvider(method: 'results')] + #[ExpectedFor(dataSet: 'results-16', instructions: '262.0K', cycles: '350.0K', memory: '496B')] + #[ExpectedFor(dataSet: 'results-256', instructions: '4.107M', cycles: '4.341M', memory: '496B')] public function benchAccessoriesIsSuperTypeOf(int $results): int { return self::accessoriesIsSuperTypeOf(self::accessories(), $this->strings); diff --git a/tests/bench/IsSuperTypeOfResultOperationsBench.php b/tests/bench/IsSuperTypeOfResultOperationsBench.php index 5fa874eb2f1..a9965c8da3e 100644 --- a/tests/bench/IsSuperTypeOfResultOperationsBench.php +++ b/tests/bench/IsSuperTypeOfResultOperationsBench.php @@ -5,6 +5,7 @@ use PHPStan\TrinaryLogic; use PHPStan\Type\IsSuperTypeOfResult; use Tally\Attribute\Before; +use Tally\Attribute\Expected; /** * Almost all results carry no reasons, so these combine results without them, a thousand times. @@ -28,6 +29,7 @@ public function boot(): void $this->no = new IsSuperTypeOfResult(TrinaryLogic::createNo(), []); } + #[Expected(instructions: '3.151M', cycles: '3.208M', memory: '576B')] public function benchAnd(): IsSuperTypeOfResult { $result = $this->yes; @@ -38,6 +40,7 @@ public function benchAnd(): IsSuperTypeOfResult return $result; } + #[Expected(instructions: '3.151M', cycles: '3.209M', memory: '576B')] public function benchOr(): IsSuperTypeOfResult { $result = $this->no; @@ -48,6 +51,7 @@ public function benchOr(): IsSuperTypeOfResult return $result; } + #[Expected(instructions: '5.538M', cycles: '5.600M', memory: '792B')] public function benchMaxMin(): IsSuperTypeOfResult { $result = $this->no; @@ -58,6 +62,7 @@ public function benchMaxMin(): IsSuperTypeOfResult return $result; } + #[Expected(instructions: '1.807M', cycles: '1.851M', memory: '144B')] public function benchNegate(): IsSuperTypeOfResult { $result = $this->no; diff --git a/tests/bench/LazyInternalScopeFactoryBench.php b/tests/bench/LazyInternalScopeFactoryBench.php index ba5bb1a2584..23096b79e0f 100644 --- a/tests/bench/LazyInternalScopeFactoryBench.php +++ b/tests/bench/LazyInternalScopeFactoryBench.php @@ -8,6 +8,7 @@ use PHPStan\Analyser\MutatingScope; use PHPStan\Analyser\ScopeContext; use Tally\Attribute\Before; +use Tally\Attribute\Expected; use Tally\ScratchDirectory; /** @@ -35,6 +36,7 @@ public function boot(): void $this->factory->create($this->context); } + #[Expected(instructions: '1.962M', cycles: '2.006M', memory: '1.000KiB')] public function benchCreate(): MutatingScope { if ($this->factory === null || $this->context === null) { diff --git a/tests/bench/LoaderFactoryBench.php b/tests/bench/LoaderFactoryBench.php index e14e30f825f..e8ca5ffb0da 100644 --- a/tests/bench/LoaderFactoryBench.php +++ b/tests/bench/LoaderFactoryBench.php @@ -8,6 +8,7 @@ use PHPStan\File\FileHelper; use ReflectionClass; use Tally\Attribute\Before; +use Tally\Attribute\Expected; use Tally\ScratchDirectory; use function dirname; @@ -37,6 +38,7 @@ public function boot(): void * * @return mixed[] */ + #[Expected(instructions: '549.6K', cycles: '1.044M', memory: '41.72KiB')] public function benchLoadConfigAgain(): array { if ($this->loaderFactory === null) { diff --git a/tests/bench/MemoizingReflectionProviderBench.php b/tests/bench/MemoizingReflectionProviderBench.php index 6eca10b00d3..5870272a3db 100644 --- a/tests/bench/MemoizingReflectionProviderBench.php +++ b/tests/bench/MemoizingReflectionProviderBench.php @@ -6,6 +6,7 @@ use PHPStan\Reflection\ClassReflection; use PHPStan\Reflection\ReflectionProvider; use Tally\Attribute\Before; +use Tally\Attribute\Expected; use Tally\ScratchDirectory; final class MemoizingReflectionProviderBench @@ -33,6 +34,7 @@ class Member {} $this->reflectionProvider->getClass('MemoizingReflectionProviderBench\Member'); } + #[Expected(instructions: '2.166K', cycles: '26.71K', memory: '64B')] public function benchHasClassAgain(): bool { return $this->reflectionProvider()->hasClass('MemoizingReflectionProviderBench\Member'); @@ -41,11 +43,13 @@ public function benchHasClassAgain(): bool /** * The memo is case-insensitive since 2.1.34, like class names. */ + #[Expected(instructions: '2.166K', cycles: '26.82K', memory: '64B')] public function benchHasClassInOtherCase(): bool { return $this->reflectionProvider()->hasClass('memoizingReflectionProviderBench\member'); } + #[Expected(instructions: '2.345K', cycles: '29.09K', memory: '64B')] public function benchGetClassAgain(): ClassReflection { return $this->reflectionProvider()->getClass('MemoizingReflectionProviderBench\Member'); diff --git a/tests/bench/MutatingScopeArrayDimFetchBench.php b/tests/bench/MutatingScopeArrayDimFetchBench.php index 3faeceb4173..41cd18a5890 100644 --- a/tests/bench/MutatingScopeArrayDimFetchBench.php +++ b/tests/bench/MutatingScopeArrayDimFetchBench.php @@ -19,6 +19,7 @@ use PHPStan\Type\Type; use Tally\Attribute\Before; use Tally\Attribute\DataProvider; +use Tally\Attribute\ExpectedFor; use Tally\ScratchDirectory; final class MutatingScopeArrayDimFetchBench @@ -57,6 +58,8 @@ public function boot(int $keys): void * Specifying an offset also specifies the array: an array is offset-accessible already. */ #[DataProvider(method: 'arrays')] + #[ExpectedFor(dataSet: 'general-array', instructions: '132.8K', cycles: '508.2K', memory: '4.516KiB')] + #[ExpectedFor(dataSet: 'shape-32', instructions: '196.2K', cycles: '683.9K', memory: '6.398KiB')] public function benchSpecifyArrayDimFetchType(int $keys): MutatingScope { if ($this->scope === null) { diff --git a/tests/bench/MutatingScopeBench.php b/tests/bench/MutatingScopeBench.php index c0b647c56d0..f775fabeaba 100644 --- a/tests/bench/MutatingScopeBench.php +++ b/tests/bench/MutatingScopeBench.php @@ -15,6 +15,8 @@ use PHPStan\Type\Type; use Tally\Attribute\Before; use Tally\Attribute\DataProvider; +use Tally\Attribute\Expected; +use Tally\Attribute\ExpectedFor; use Tally\ScratchDirectory; use function implode; use function sprintf; @@ -72,6 +74,7 @@ public function boot(): void /** * The certainty is looked up once (#4726). */ + #[Expected(instructions: '2.247K', cycles: '26.67K', memory: '32B')] public function benchGetVariableType(): Type { return $this->scope()->getVariableType('i'); @@ -80,6 +83,7 @@ public function benchGetVariableType(): Type /** * Only the cases with a left side that may be set narrow the scope by isset() (#4661). */ + #[Expected(instructions: '965.3K', cycles: '2.091M', memory: '93.89KiB')] public function benchGetTypeOfCoalesceWithNullLeft(): Type { return $this->scope()->getType(self::coalesce()); @@ -96,6 +100,8 @@ public static function keepVoidExprs(): iterable } #[DataProvider(method: 'keepVoidExprs')] + #[ExpectedFor(dataSet: 'func-call', instructions: '389.4K', cycles: '1.305M', memory: '15.49KiB')] + #[ExpectedFor(dataSet: 'variable', instructions: '7.615K', cycles: '68.25K', memory: '128B')] public function benchGetKeepVoidType(string $expr): Type { return $this->scope()->getKeepVoidType($expr === 'variable' ? new Expr\Variable('str') : self::funcCall('trim')); @@ -104,6 +110,7 @@ public function benchGetKeepVoidType(string $expr): Type /** * A function without a dynamic return type extension: the extensions for it are remembered (#4644). */ + #[Expected(instructions: '377.5K', cycles: '1.270M', memory: '15.49KiB')] public function benchGetTypeOfFuncCall(): Type { return $this->scope()->getType(self::funcCall('ucfirst')); @@ -112,6 +119,7 @@ public function benchGetTypeOfFuncCall(): Type /** * Creates a scope, without getting the parser from the container each time (#4672). */ + #[Expected(instructions: '11.82K', cycles: '76.11K', memory: '944B')] public function benchPushInFunctionCall(): MutatingScope { return $this->scope()->pushInFunctionCall(null, null, true); @@ -120,6 +128,7 @@ public function benchPushInFunctionCall(): MutatingScope /** * An argument's type resolved before is not resolved again (#4648). */ + #[Expected(instructions: '52.32K', cycles: '243.5K', memory: '3.086KiB')] public function benchPushInFunctionCallGetType(): Type { return $this->scope()->pushInFunctionCall(null, null, true)->getType(self::resolvedExpr()); @@ -128,6 +137,7 @@ public function benchPushInFunctionCallGetType(): Type /** * A scalar's type is not stored (#4663). */ + #[Expected(instructions: '3.965K', cycles: '38.75K', memory: '176B')] public function benchSpecifyScalarType(): MutatingScope { return $this->scope()->specifyExpressionType(new String_('x'), new StringType(), new StringType(), TrinaryLogic::createYes()); @@ -136,6 +146,7 @@ public function benchSpecifyScalarType(): MutatingScope /** * The keys of the narrowed expressions are not printed again (#4691). */ + #[Expected(instructions: '2.047M', cycles: '3.874M', memory: '109.7KiB')] public function benchFilterByTruthyValue(): MutatingScope { return $this->scope()->filterByTruthyValue(self::condition()); @@ -145,6 +156,7 @@ public function benchFilterByTruthyValue(): MutatingScope * The scope remembers what the properties assigned in `if ($s !== null)` are when it holds, * but not what they are otherwise, so they are not intersected with anything (#4690). */ + #[Expected(instructions: '611.7K', cycles: '1.427M', memory: '26.10KiB')] public function benchFilterByConditionOfConditionalExpressions(): MutatingScope { if ($this->conditionalScope === null) { @@ -157,6 +169,7 @@ public function benchFilterByConditionOfConditionalExpressions(): MutatingScope /** * Invalidates the method calls on $this without searching each tracked expression (#4692). */ + #[Expected(instructions: '324.9K', cycles: '640.5K', memory: '34.63KiB')] public function benchAssignPropertyFetch(): MutatingScope { return $this->scope()->assignExpression(self::propertyFetch(0), new IntegerType(), new IntegerType()); @@ -165,6 +178,7 @@ public function benchAssignPropertyFetch(): MutatingScope /** * Finds the expressions on $this by their keys, not by searching them (#4697). */ + #[Expected(instructions: '65.96K', cycles: '185.2K', memory: '16.61KiB')] public function benchRestoreThis(): MutatingScope { $scope = $this->scope(); diff --git a/tests/bench/MutatingScopeBranchMergeBench.php b/tests/bench/MutatingScopeBranchMergeBench.php index e27539278d8..be8eb44e396 100644 --- a/tests/bench/MutatingScopeBranchMergeBench.php +++ b/tests/bench/MutatingScopeBranchMergeBench.php @@ -15,6 +15,7 @@ use PHPStan\Type\TypeCombinator; use Tally\Attribute\Before; use Tally\Attribute\DataProvider; +use Tally\Attribute\ExpectedFor; use Tally\ScratchDirectory; /** @@ -67,6 +68,13 @@ public function boot(string $shape, int $variables): void } #[DataProvider(method: 'branches')] + #[ExpectedFor(dataSet: 'absorbed-targets-16', instructions: '3.036M', cycles: '4.672M', memory: '58.13KiB')] + #[ExpectedFor(dataSet: 'differing-unions-4', instructions: '3.576M', cycles: '4.634M', memory: '74.34KiB')] + #[ExpectedFor(dataSet: 'differing-unions-16', instructions: '16.78M', cycles: '20.46M', memory: '599.2KiB')] + #[ExpectedFor(dataSet: 'maybe-defined-64', instructions: '747.8K', cycles: '1.081M', memory: '18.58KiB')] + #[ExpectedFor(dataSet: 'one-sided-64', instructions: '1.405M', cycles: '2.004M', memory: '50.22KiB')] + #[ExpectedFor(dataSet: 'shared-holders-16', instructions: '153.0K', cycles: '426.3K', memory: '9.445KiB')] + #[ExpectedFor(dataSet: 'shared-holders-256', instructions: '1.372M', cycles: '2.123M', memory: '121.9KiB')] public function benchMergeWith(string $shape, int $variables): MutatingScope { if ($this->ours === null || $this->theirs === null) { diff --git a/tests/bench/MutatingScopeImpureCallsBench.php b/tests/bench/MutatingScopeImpureCallsBench.php index c535ab87e27..bba9670d348 100644 --- a/tests/bench/MutatingScopeImpureCallsBench.php +++ b/tests/bench/MutatingScopeImpureCallsBench.php @@ -6,6 +6,7 @@ use PhpParser\Node\Stmt\Return_; use PHPStan\Analyser\MutatingScope; use Tally\Attribute\Before; +use Tally\Attribute\Expected; use Tally\Attribute\Warmup; use Tally\ScratchDirectory; use function count; @@ -43,6 +44,7 @@ public function boot(): void /** * Merging two branches after calls to user functions and methods that are possibly impure. */ + #[Expected(instructions: '75.45K', cycles: '307.0K', memory: '3.523KiB')] public function benchMergeWithAfterPossiblyImpureCalls(): MutatingScope { if ($this->scope === null || $this->otherScope === null) { diff --git a/tests/bench/MutatingScopeMergeBench.php b/tests/bench/MutatingScopeMergeBench.php index 7380b6e1ea0..18ddc89ed8d 100644 --- a/tests/bench/MutatingScopeMergeBench.php +++ b/tests/bench/MutatingScopeMergeBench.php @@ -6,6 +6,7 @@ use PHPStan\Analyser\MutatingScope; use Tally\Attribute\Before; use Tally\Attribute\DataProvider; +use Tally\Attribute\ExpectedFor; use Tally\ScratchDirectory; use function implode; use function sprintf; @@ -61,6 +62,9 @@ public function boot(string $merge): void } #[DataProvider(method: 'merges')] + #[ExpectedFor(dataSet: 'property-fetches-on-one-side', instructions: '1.749M', cycles: '2.291M', memory: '82.25KiB')] + #[ExpectedFor(dataSet: 'same-scope', instructions: '1.083K', cycles: '11.64K', memory: '0B')] + #[ExpectedFor(dataSet: 'variables-on-one-side', instructions: '890.9K', cycles: '1.271M', memory: '53.26KiB')] public function benchMergeWith(string $merge): MutatingScope { if ($this->ours === null || $this->theirs === null) { diff --git a/tests/bench/NetteContainerBench.php b/tests/bench/NetteContainerBench.php index c48e4442530..1ef69ceba63 100644 --- a/tests/bench/NetteContainerBench.php +++ b/tests/bench/NetteContainerBench.php @@ -5,6 +5,7 @@ use LogicException; use PHPStan\DependencyInjection\Container; use Tally\Attribute\Before; +use Tally\Attribute\Expected; use Tally\ScratchDirectory; final class NetteContainerBench @@ -23,6 +24,7 @@ public function boot(): void $this->container->getParameter('tmpDir'); } + #[Expected(instructions: '1.051K', cycles: '12.71K', memory: '0B')] public function benchGetParameter(): mixed { if ($this->container === null) { @@ -32,6 +34,7 @@ public function benchGetParameter(): mixed return $this->container->getParameter('tmpDir'); } + #[Expected(instructions: '1.529K', cycles: '16.16K', memory: '0B')] public function benchHasParameter(): bool { if ($this->container === null) { diff --git a/tests/bench/NodeScopeResolverBench.php b/tests/bench/NodeScopeResolverBench.php index f81fca44511..52c4101711b 100644 --- a/tests/bench/NodeScopeResolverBench.php +++ b/tests/bench/NodeScopeResolverBench.php @@ -11,6 +11,7 @@ use PHPStan\Analyser\StatementResult; use Tally\Attribute\Before; use Tally\Attribute\DataProvider; +use Tally\Attribute\ExpectedFor; use Tally\ScratchDirectory; use function sprintf; @@ -110,6 +111,12 @@ public function boot(string $statements): void } #[DataProvider(method: 'statements')] + #[ExpectedFor(dataSet: 'assign-non-falsy', instructions: '9.434M', cycles: '13.90M', memory: '274.6KiB')] + #[ExpectedFor(dataSet: 'for-without-condition', instructions: '3.168M', cycles: '5.155M', memory: '64.02KiB')] + #[ExpectedFor(dataSet: 'func-call-args', instructions: '3.748M', cycles: '6.165M', memory: '146.4KiB')] + #[ExpectedFor(dataSet: 'match-with-default', instructions: '111.9M', cycles: '148.9M', memory: '425.7KiB')] + #[ExpectedFor(dataSet: 'nested-foreach', instructions: '16.72M', cycles: '24.15M', memory: '180.2KiB')] + #[ExpectedFor(dataSet: 'nested-while', instructions: '38.49M', cycles: '53.24M', memory: '382.7KiB')] public function benchProcessStmtNodes(string $statements): StatementResult { if ($this->resolver === null || $this->scope === null || $this->function === null) { diff --git a/tests/bench/NodeScopeResolverWalkBench.php b/tests/bench/NodeScopeResolverWalkBench.php index 1dd3450573a..cbf6ce1384c 100644 --- a/tests/bench/NodeScopeResolverWalkBench.php +++ b/tests/bench/NodeScopeResolverWalkBench.php @@ -20,6 +20,7 @@ use PHPStan\Type\Type; use Tally\Attribute\Before; use Tally\Attribute\DataProvider; +use Tally\Attribute\ExpectedFor; use Tally\Meter; use Tally\ScratchDirectory; use function implode; @@ -91,6 +92,11 @@ public function boot(string $code): void } #[DataProvider(method: 'statements')] + #[ExpectedFor(dataSet: 'foreach', instructions: '16.95M', cycles: '25.08M', memory: '315.2KiB')] + #[ExpectedFor(dataSet: 'nested-dim-assign', instructions: '277.4M', cycles: '330.1M', memory: '801.3KiB')] + #[ExpectedFor(dataSet: 'or-chain-16', instructions: '22.42M', cycles: '31.58M', memory: '614.1KiB')] + #[ExpectedFor(dataSet: 'or-chain-32', instructions: '50.17M', cycles: '67.50M', memory: '1.219MiB')] + #[ExpectedFor(dataSet: 'while', instructions: '10.43M', cycles: '16.14M', memory: '236.3KiB')] public function benchProcessStmtNodes(string $code): Type { $stmts = $this->meter->exclude(fn (): array => $this->parse($code)); diff --git a/tests/bench/NullsafeOperatorHelperBench.php b/tests/bench/NullsafeOperatorHelperBench.php index 552c254dd86..83d6395637e 100644 --- a/tests/bench/NullsafeOperatorHelperBench.php +++ b/tests/bench/NullsafeOperatorHelperBench.php @@ -15,6 +15,7 @@ use PHPStan\Type\MixedType; use Tally\Attribute\Before; use Tally\Attribute\DataProvider; +use Tally\Attribute\ExpectedFor; use Tally\ScratchDirectory; use function array_reverse; @@ -82,6 +83,8 @@ public function boot(int $depth, bool $nullsafe): void * The analyser asks about each level of a chain, innermost first. */ #[DataProvider(method: 'levelsWithoutNullsafe')] + #[ExpectedFor(dataSet: 'levels-16', instructions: '74.18K', cycles: '132.2K', memory: '6.242KiB')] + #[ExpectedFor(dataSet: 'levels-64', instructions: '283.4K', cycles: '379.1K', memory: '23.87KiB')] public function benchGetNullsafeShortcircuitedExprOfEveryLevel(int $depth, bool $nullsafe): int { $unchanged = 0; @@ -100,6 +103,8 @@ public function benchGetNullsafeShortcircuitedExprOfEveryLevel(int $depth, bool * Every link above a nullsafe one is rebuilt, a chain without one is returned as it is. */ #[DataProvider(method: 'chains')] + #[ExpectedFor(dataSet: 'innermost-nullsafe-64', instructions: '261.6K', cycles: '383.1K', memory: '9.609KiB')] + #[ExpectedFor(dataSet: 'without-nullsafe-64', instructions: '173.3K', cycles: '258.8K', memory: '23.87KiB')] public function benchGetNullsafeShortcircuitedExpr(int $depth, bool $nullsafe): Expr { if ($this->chain === null) { @@ -113,6 +118,7 @@ public function benchGetNullsafeShortcircuitedExpr(int $depth, bool $nullsafe): * A chain without a nullsafe link needs no type from the scope. */ #[DataProvider(method: 'chainsWithoutNullsafe')] + #[ExpectedFor(dataSet: 'without-nullsafe-8', instructions: '27.17K', cycles: '75.85K', memory: '3.305KiB')] public function benchGetNullsafeShortcircuitedExprRespectingScope(int $depth, bool $nullsafe): Expr { if ($this->scope === null || $this->chain === null) { diff --git a/tests/bench/ObjectTypeBench.php b/tests/bench/ObjectTypeBench.php index e027b9b059f..ba3891124be 100644 --- a/tests/bench/ObjectTypeBench.php +++ b/tests/bench/ObjectTypeBench.php @@ -9,6 +9,7 @@ use PHPStan\Type\ObjectType; use Tally\Attribute\Before; use Tally\Attribute\DataProvider; +use Tally\Attribute\ExpectedFor; use Tally\ScratchDirectory; final class ObjectTypeBench @@ -41,6 +42,8 @@ public function boot(string $className): void * The type was asked for its class before. */ #[DataProvider(method: 'classes')] + #[ExpectedFor(dataSet: 'class', instructions: '741', cycles: '9.321K', memory: '0B')] + #[ExpectedFor(dataSet: 'generic-class', instructions: '741', cycles: '9.321K', memory: '0B')] public function benchGetClassReflection(string $className): ?ClassReflection { if ($this->object === null) { diff --git a/tests/bench/ObjectTypeIsSuperTypeOfBench.php b/tests/bench/ObjectTypeIsSuperTypeOfBench.php index f37a82f1f9e..e081bed7410 100644 --- a/tests/bench/ObjectTypeIsSuperTypeOfBench.php +++ b/tests/bench/ObjectTypeIsSuperTypeOfBench.php @@ -10,6 +10,7 @@ use PHPStan\Type\UnionType; use stdClass; use Tally\Attribute\Before; +use Tally\Attribute\Expected; use Tally\ScratchDirectory; /** @@ -43,6 +44,7 @@ public function boot(): void $this->dateTime->isSuperTypeOf($this->union); } + #[Expected(instructions: '252.2K', cycles: '311.5K', memory: '368B')] public function benchIsSuperTypeOfUnrelatedClass(): IsSuperTypeOfResult { if ($this->dateTime === null || $this->exception === null) { @@ -57,6 +59,7 @@ public function benchIsSuperTypeOfUnrelatedClass(): IsSuperTypeOfResult return $result; } + #[Expected(instructions: '850.0K', cycles: '1.075M', memory: '1.578KiB')] public function benchIsSuperTypeOfUnionOfUnrelatedClasses(): IsSuperTypeOfResult { if ($this->dateTime === null || $this->union === null) { diff --git a/tests/bench/OperatorTypeSpecifyingExtensionBench.php b/tests/bench/OperatorTypeSpecifyingExtensionBench.php index f1a4eced234..26e8b6f1303 100644 --- a/tests/bench/OperatorTypeSpecifyingExtensionBench.php +++ b/tests/bench/OperatorTypeSpecifyingExtensionBench.php @@ -14,6 +14,7 @@ use PHPStan\Type\Type; use Tally\Attribute\Before; use Tally\Attribute\DataProvider; +use Tally\Attribute\ExpectedFor; use Tally\ScratchDirectory; /** @@ -72,6 +73,10 @@ public function boot(string $extension, string $operands): void * @param class-string $extension */ #[DataProvider(method: 'extensions')] + #[ExpectedFor(dataSet: 'bcmath-integers', instructions: '309.3K', cycles: '380.8K', memory: '0B')] + #[ExpectedFor(dataSet: 'bcmath-objects', instructions: '651.3K', cycles: '786.5K', memory: '216B')] + #[ExpectedFor(dataSet: 'gmp-integers', instructions: '294.4K', cycles: '365.1K', memory: '0B')] + #[ExpectedFor(dataSet: 'gmp-objects', instructions: '593.5K', cycles: '718.6K', memory: '216B')] public function benchSpecify(string $extension, string $operands): int { if ($this->extension === null) { diff --git a/tests/bench/PackageDependencyResolverBench.php b/tests/bench/PackageDependencyResolverBench.php index fe8c1da880a..bd0e8f4daca 100644 --- a/tests/bench/PackageDependencyResolverBench.php +++ b/tests/bench/PackageDependencyResolverBench.php @@ -9,6 +9,7 @@ use ReflectionClass; use Tally\Attribute\Before; use Tally\Attribute\DataProvider; +use Tally\Attribute\ExpectedFor; use Tally\ScratchDirectory; use function dirname; @@ -49,6 +50,8 @@ public function boot(string $file): void * Every analysed file that depends on a file resolves it again. */ #[DataProvider(method: 'files')] + #[ExpectedFor(dataSet: 'package-file', instructions: '1.082K', cycles: '11.64K', memory: '0B')] + #[ExpectedFor(dataSet: 'project-file', instructions: '1.082K', cycles: '11.64K', memory: '0B')] public function benchResolvePackageAgain(string $file): ?string { if ($this->resolver === null) { diff --git a/tests/bench/ParametersAcceptorSelectorBench.php b/tests/bench/ParametersAcceptorSelectorBench.php index 7cfa7dc6647..caee9ce134e 100644 --- a/tests/bench/ParametersAcceptorSelectorBench.php +++ b/tests/bench/ParametersAcceptorSelectorBench.php @@ -18,6 +18,7 @@ use ReflectionMethod; use Tally\Attribute\Before; use Tally\Attribute\DataProvider; +use Tally\Attribute\ExpectedFor; use Tally\ScratchDirectory; final class ParametersAcceptorSelectorBench @@ -59,6 +60,9 @@ public function boot(int $items, bool $templateFirst): void * of nested shapes. The search can stop at a template type in the first item. */ #[DataProvider(method: 'returnTypes')] + #[ExpectedFor(dataSet: 'no-template-256', instructions: '543.0K', cycles: '1.047M', memory: '0B')] + #[ExpectedFor(dataSet: 'template-first-16', instructions: '1.876K', cycles: '19.70K', memory: '0B')] + #[ExpectedFor(dataSet: 'template-first-256', instructions: '1.876K', cycles: '19.70K', memory: '0B')] public function benchHasAcceptorTemplateOrLateResolvableType(int $items, bool $templateFirst): bool { if ($this->hasTemplateOrLateResolvableType === null || $this->acceptor === null) { diff --git a/tests/bench/PathRoutingParserBench.php b/tests/bench/PathRoutingParserBench.php index 968db4f2f86..c90f8809b44 100644 --- a/tests/bench/PathRoutingParserBench.php +++ b/tests/bench/PathRoutingParserBench.php @@ -7,6 +7,7 @@ use PHPStan\Parser\Parser; use Tally\Attribute\Before; use Tally\Attribute\DataProvider; +use Tally\Attribute\ExpectedFor; use Tally\ScratchDirectory; use function dirname; @@ -49,6 +50,8 @@ public function boot(string $file): void * @return Node[] */ #[DataProvider(method: 'stubs')] + #[ExpectedFor(dataSet: 'class', instructions: '6.901K', cycles: '63.71K', memory: '0B')] + #[ExpectedFor(dataSet: 'function', instructions: '6.901K', cycles: '63.72K', memory: '0B')] public function benchParsePhp8StubAgain(string $file): array { if ($this->parser === null) { diff --git a/tests/bench/Php8SignatureMapProviderBench.php b/tests/bench/Php8SignatureMapProviderBench.php index 1455638ca6a..032f72b36f9 100644 --- a/tests/bench/Php8SignatureMapProviderBench.php +++ b/tests/bench/Php8SignatureMapProviderBench.php @@ -6,6 +6,7 @@ use PHPStan\Reflection\SignatureMap\Php8SignatureMapProvider; use Tally\Attribute\Before; use Tally\Attribute\DataProvider; +use Tally\Attribute\ExpectedFor; use Tally\ScratchDirectory; final class Php8SignatureMapProviderBench @@ -47,6 +48,9 @@ public function boot(): void * @param list $methods */ #[DataProvider(method: 'members')] + #[ExpectedFor(dataSet: 'constants-1', instructions: '4.015K', cycles: '36.03K', memory: '136B')] + #[ExpectedFor(dataSet: 'constants-8', instructions: '28.44K', cycles: '65.63K', memory: '144B')] + #[ExpectedFor(dataSet: 'constants-8-methods-8', instructions: '55.67K', cycles: '101.5K', memory: '144B')] public function benchFindMembers(array $constants, array $methods): int { if ($this->provider === null) { diff --git a/tests/bench/PhpDocInheritanceResolverBench.php b/tests/bench/PhpDocInheritanceResolverBench.php index c1f57faed91..43a097b30c7 100644 --- a/tests/bench/PhpDocInheritanceResolverBench.php +++ b/tests/bench/PhpDocInheritanceResolverBench.php @@ -8,6 +8,7 @@ use PHPStan\Type\Type; use Tally\Attribute\Before; use Tally\Attribute\DataProvider; +use Tally\Attribute\ExpectedFor; use Tally\ScratchDirectory; use function sprintf; @@ -52,6 +53,8 @@ public function boot(int $depth): void * the PHPDoc of the root. */ #[DataProvider(method: 'depths')] + #[ExpectedFor(dataSet: 'depth-4', instructions: '3.208M', cycles: '5.530M', memory: '54.20KiB')] + #[ExpectedFor(dataSet: 'depth-16', instructions: '9.423M', cycles: '13.84M', memory: '161.0KiB')] public function benchInheritedReturnType(int $depth): Type { if ($this->class === null) { diff --git a/tests/bench/PhpFileCleanerBench.php b/tests/bench/PhpFileCleanerBench.php index bfae413976e..9585cd965df 100644 --- a/tests/bench/PhpFileCleanerBench.php +++ b/tests/bench/PhpFileCleanerBench.php @@ -6,6 +6,7 @@ use PHPStan\Reflection\BetterReflection\SourceLocator\PhpFileCleaner; use Tally\Attribute\Before; use Tally\Attribute\DataProvider; +use Tally\Attribute\ExpectedFor; use function sprintf; use function str_repeat; @@ -37,6 +38,10 @@ public function boot(): void * matches as the file may declare symbols. */ #[DataProvider(method: 'files')] + #[ExpectedFor(dataSet: 'code', instructions: '3.054M', cycles: '3.365M', memory: '4.172KiB')] + #[ExpectedFor(dataSet: 'docblocks', instructions: '3.788M', cycles: '4.135M', memory: '4.172KiB')] + #[ExpectedFor(dataSet: 'line-comments', instructions: '3.947M', cycles: '4.288M', memory: '8.172KiB')] + #[ExpectedFor(dataSet: 'strings', instructions: '2.909M', cycles: '3.235M', memory: '4.164KiB')] public function benchClean(string $contents): string { if ($this->cleaner === null) { diff --git a/tests/bench/PhpFunctionFromParserNodeReflectionBench.php b/tests/bench/PhpFunctionFromParserNodeReflectionBench.php index 3265c3e017a..55f82238af6 100644 --- a/tests/bench/PhpFunctionFromParserNodeReflectionBench.php +++ b/tests/bench/PhpFunctionFromParserNodeReflectionBench.php @@ -10,6 +10,7 @@ use ReflectionProperty; use Tally\Attribute\Before; use Tally\Attribute\DataProvider; +use Tally\Attribute\ExpectedFor; use Tally\ScratchDirectory; use function sprintf; @@ -47,6 +48,8 @@ public function boot(int $statements): void * a yield unless the answer is memoized on its node. */ #[DataProvider(method: 'statements')] + #[ExpectedFor(dataSet: 'statements-16', instructions: '1.268K', cycles: '14.69K', memory: '0B')] + #[ExpectedFor(dataSet: 'statements-256', instructions: '1.268K', cycles: '14.69K', memory: '0B')] public function benchIsGeneratorAgain(int $statements): bool { if ($this->function === null) { diff --git a/tests/bench/PhpMethodReflectionBench.php b/tests/bench/PhpMethodReflectionBench.php index 85a72a9523f..49c3d5b2faa 100644 --- a/tests/bench/PhpMethodReflectionBench.php +++ b/tests/bench/PhpMethodReflectionBench.php @@ -6,6 +6,7 @@ use PHPStan\Reflection\ExtendedMethodReflection; use PHPStan\Reflection\ReflectionProvider; use Tally\Attribute\Before; +use Tally\Attribute\Expected; use Tally\ScratchDirectory; final class PhpMethodReflectionBench @@ -37,6 +38,7 @@ public function doubleValues(): void $this->method->getName(); } + #[Expected(instructions: '700', cycles: '8.840K', memory: '0B')] public function benchGetNameAgain(): string { if ($this->method === null) { diff --git a/tests/bench/RegexGroupParserBench.php b/tests/bench/RegexGroupParserBench.php index d9775e6d8ba..cc7285150af 100644 --- a/tests/bench/RegexGroupParserBench.php +++ b/tests/bench/RegexGroupParserBench.php @@ -6,6 +6,7 @@ use PHPStan\Type\Regex\RegexGroupParser; use Tally\Attribute\Before; use Tally\Attribute\DataProvider; +use Tally\Attribute\ExpectedFor; use Tally\ScratchDirectory; final class RegexGroupParserBench @@ -36,6 +37,8 @@ public function boot(string $pattern): void } #[DataProvider(method: 'patterns')] + #[ExpectedFor(dataSet: 'date', instructions: '1.426M', cycles: '2.041M', memory: '9.656KiB')] + #[ExpectedFor(dataSet: 'unsupported-modifier', instructions: '1.286K', cycles: '15.92K', memory: '0B')] public function benchParseGroupsAgain(string $pattern): bool { if ($this->parser === null) { diff --git a/tests/bench/ReturnTypeExtensionBench.php b/tests/bench/ReturnTypeExtensionBench.php index 68b0bfd9b82..6b7f9c6f4c3 100644 --- a/tests/bench/ReturnTypeExtensionBench.php +++ b/tests/bench/ReturnTypeExtensionBench.php @@ -17,6 +17,7 @@ use PHPStan\Type\Type; use Tally\Attribute\Before; use Tally\Attribute\DataProvider; +use Tally\Attribute\ExpectedFor; use Tally\Attribute\Warmup; use Tally\ScratchDirectory; use function sprintf; @@ -90,6 +91,12 @@ public function boot(string $extension, string $code): void * @return list */ #[DataProvider(method: 'extensions')] + #[ExpectedFor(dataSet: 'array-chunk', instructions: '622.4K', cycles: '1.076M', memory: '23.70KiB')] + #[ExpectedFor(dataSet: 'array-column', instructions: '2.033M', cycles: '3.088M', memory: '10.32KiB')] + #[ExpectedFor(dataSet: 'date-non-constant-format', instructions: '72.79K', cycles: '163.2K', memory: '1016B')] + #[ExpectedFor(dataSet: 'preg-replace-string-subject', instructions: '1.029M', cycles: '1.676M', memory: '10.78KiB')] + #[ExpectedFor(dataSet: 'range-100000', instructions: '526.1K', cycles: '878.5K', memory: '16.39KiB')] + #[ExpectedFor(dataSet: 'str-replace-string-subject', instructions: '530.5K', cycles: '874.6K', memory: '2.594KiB')] public function benchGetTypeFromFunctionCall(string $extension, string $code): array { if ($this->extension === null) { diff --git a/tests/bench/RuleBench.php b/tests/bench/RuleBench.php index 0d6fd08ce6e..763358a46d1 100644 --- a/tests/bench/RuleBench.php +++ b/tests/bench/RuleBench.php @@ -28,6 +28,7 @@ use PHPStan\Rules\Rule; use Tally\Attribute\Before; use Tally\Attribute\DataProvider; +use Tally\Attribute\ExpectedFor; use Tally\Attribute\Warmup; use Tally\ScratchDirectory; use function array_map; @@ -123,6 +124,25 @@ public function boot(string $rule, string $code, ?int $phpVersion): void * @param class-string $rule */ #[DataProvider(method: 'rules')] + #[ExpectedFor(dataSet: 'call-private-method-through-static-final-class', instructions: '59.05K', cycles: '111.0K', memory: '0B')] + #[ExpectedFor(dataSet: 'call-to-function-parameters-resolvable-types', instructions: '117.0M', cycles: '141.7M', memory: '13.45KiB')] + #[ExpectedFor(dataSet: 'call-to-method-statement-with-no-discard-dynamic-name', instructions: '20.65K', cycles: '50.36K', memory: '0B')] + #[ExpectedFor(dataSet: 'call-to-static-method-statement-with-no-discard-dynamic-name', instructions: '20.80K', cycles: '50.52K', memory: '0B')] + #[ExpectedFor(dataSet: 'dump-native-type-no-arguments', instructions: '21.29K', cycles: '48.81K', memory: '0B')] + #[ExpectedFor(dataSet: 'dump-phpdoc-type-no-arguments', instructions: '21.29K', cycles: '49.24K', memory: '0B')] + #[ExpectedFor(dataSet: 'dump-type-no-arguments', instructions: '21.29K', cycles: '48.80K', memory: '0B')] + #[ExpectedFor(dataSet: 'duplicate-class-declaration', instructions: '116.2K', cycles: '225.8K', memory: '0B')] + #[ExpectedFor(dataSet: 'duplicate-function-declaration', instructions: '163.0K', cycles: '296.3K', memory: '0B')] + #[ExpectedFor(dataSet: 'file-assert-one-argument', instructions: '21.25K', cycles: '49.98K', memory: '0B')] + #[ExpectedFor(dataSet: 'final-private-method-constructors', instructions: '138.0K', cycles: '236.5K', memory: '0B')] + #[ExpectedFor(dataSet: 'function-conditional-return-type-without-conditional-types', instructions: '143.7K', cycles: '258.3K', memory: '376B')] + #[ExpectedFor(dataSet: 'impossible-check-type-function-call', instructions: '6.246M', cycles: '8.939M', memory: '5.758KiB')] + #[ExpectedFor(dataSet: 'instantiation-restricted-constructor', instructions: '3.525M', cycles: '5.588M', memory: '6.531KiB')] + #[ExpectedFor(dataSet: 'node-connecting-visitor-attributes-no-arguments', instructions: '38.75K', cycles: '77.96K', memory: '40B')] + #[ExpectedFor(dataSet: 'old-php-parser4-class-outside-class', instructions: '20.21K', cycles: '44.86K', memory: '0B')] + #[ExpectedFor(dataSet: 'parameter-castable-to-string-diff-assoc', instructions: '6.028M', cycles: '7.718M', memory: '3.172KiB')] + #[ExpectedFor(dataSet: 'restricted-function-usage', instructions: '282.2K', cycles: '443.2K', memory: '848B')] + #[ExpectedFor(dataSet: 'restricted-method-usage', instructions: '376.2K', cycles: '607.7K', memory: '256B')] public function benchProcessNode(string $rule, string $code, ?int $phpVersion): int { if ($this->rule === null) { diff --git a/tests/bench/TemplateTypeBench.php b/tests/bench/TemplateTypeBench.php index 2fdc6caf106..76b8a4c241b 100644 --- a/tests/bench/TemplateTypeBench.php +++ b/tests/bench/TemplateTypeBench.php @@ -15,6 +15,7 @@ use PHPStan\Type\VerbosityLevel; use Tally\Attribute\Before; use Tally\Attribute\DataProvider; +use Tally\Attribute\ExpectedFor; use Tally\ScratchDirectory; /** @@ -65,6 +66,8 @@ public function boot(int $members): void * T0 of int = 0, T1 of int = 1, …: the description of a default is guarded against recursion. */ #[DataProvider(method: 'members')] + #[ExpectedFor(dataSet: 'members-16', instructions: '150.0K', cycles: '261.1K', memory: '2.820KiB')] + #[ExpectedFor(dataSet: 'members-256', instructions: '2.385M', cycles: '2.976M', memory: '10.51KiB')] public function benchDescribeWithDefault(int $members): string { return self::describeAll($this->withDefaults); @@ -106,6 +109,8 @@ private static function withDefaults(int $members): array * None of the strings is an int, so T is not inferred from any of them. */ #[DataProvider(method: 'members')] + #[ExpectedFor(dataSet: 'members-16', instructions: '72.31K', cycles: '193.8K', memory: '1.469KiB')] + #[ExpectedFor(dataSet: 'members-256', instructions: '930.9K', cycles: '1.141M', memory: '32.22KiB')] public function benchInferTemplateTypesFromUnion(int $members): TemplateTypeMap { if ($this->template === null || $this->strings === null) { diff --git a/tests/bench/TemplateTypeHelperBench.php b/tests/bench/TemplateTypeHelperBench.php index 847f0e0568a..8c2a0aa684b 100644 --- a/tests/bench/TemplateTypeHelperBench.php +++ b/tests/bench/TemplateTypeHelperBench.php @@ -13,6 +13,7 @@ use PHPStan\Type\Type; use Tally\Attribute\Before; use Tally\Attribute\DataProvider; +use Tally\Attribute\ExpectedFor; use Tally\ScratchDirectory; final class TemplateTypeHelperBench @@ -46,6 +47,8 @@ public function boot(int $keys): void * An array shape without template types, such as the return type of most functions. */ #[DataProvider(method: 'keys')] + #[ExpectedFor(dataSet: 'keys-16', instructions: '8.998K', cycles: '46.85K', memory: '0B')] + #[ExpectedFor(dataSet: 'keys-256', instructions: '95.16K', cycles: '205.0K', memory: '0B')] public function benchResolveTemplateTypes(int $keys): Type { if ($this->shape === null) { diff --git a/tests/bench/TrinaryLogicBench.php b/tests/bench/TrinaryLogicBench.php index de67f8826c7..72a45b6f6c4 100644 --- a/tests/bench/TrinaryLogicBench.php +++ b/tests/bench/TrinaryLogicBench.php @@ -5,6 +5,7 @@ use PHPStan\TrinaryLogic; use Tally\Attribute\Before; use Tally\Attribute\DataProvider; +use Tally\Attribute\ExpectedFor; /** * TrinaryLogic is combined everywhere types are compared, so each case combines it a thousand times. @@ -60,6 +61,8 @@ public function boot(int $count): void } #[DataProvider(method: 'operands')] + #[ExpectedFor(dataSet: 'operands-1', instructions: '1.111M', cycles: '1.135M', memory: '0B')] + #[ExpectedFor(dataSet: 'operands-4', instructions: '2.356M', cycles: '2.387M', memory: '216B')] public function benchAnd(int $operands): TrinaryLogic { $result = TrinaryLogic::createYes(); @@ -72,6 +75,8 @@ public function benchAnd(int $operands): TrinaryLogic } #[DataProvider(method: 'operands')] + #[ExpectedFor(dataSet: 'operands-1', instructions: '1.111M', cycles: '1.136M', memory: '0B')] + #[ExpectedFor(dataSet: 'operands-4', instructions: '2.356M', cycles: '2.388M', memory: '216B')] public function benchOr(int $operands): TrinaryLogic { $result = TrinaryLogic::createNo(); @@ -84,6 +89,7 @@ public function benchOr(int $operands): TrinaryLogic } #[DataProvider(method: 'fourOperands')] + #[ExpectedFor(dataSet: 'operands-4', instructions: '2.739M', cycles: '2.771M', memory: '216B')] public function benchMaxMin(int $operands): TrinaryLogic { $result = TrinaryLogic::createNo(); @@ -99,6 +105,7 @@ public function benchMaxMin(int $operands): TrinaryLogic * The lazy methods stop at the first no or yes, so every callback answers maybe. */ #[DataProvider(method: 'objects')] + #[ExpectedFor(dataSet: 'objects-8', instructions: '11.62M', cycles: '11.92M', memory: '832B')] public function benchLazyAnd(int $objects): TrinaryLogic { $result = TrinaryLogic::createYes(); @@ -113,6 +120,7 @@ public function benchLazyAnd(int $objects): TrinaryLogic * The lazy methods stop at the first no or yes, so every callback answers maybe. */ #[DataProvider(method: 'objects')] + #[ExpectedFor(dataSet: 'objects-8', instructions: '11.62M', cycles: '11.98M', memory: '832B')] public function benchLazyOr(int $objects): TrinaryLogic { $result = TrinaryLogic::createNo(); @@ -127,6 +135,7 @@ public function benchLazyOr(int $objects): TrinaryLogic * The lazy methods stop at the first no or yes, so every callback answers maybe. */ #[DataProvider(method: 'objects')] + #[ExpectedFor(dataSet: 'objects-8', instructions: '8.283M', cycles: '8.323M', memory: '400B')] public function benchLazyMaxMin(int $objects): TrinaryLogic { $result = TrinaryLogic::createNo(); diff --git a/tests/bench/TypeCombinatorBench.php b/tests/bench/TypeCombinatorBench.php index cb8e23f2be2..5af4f0d84f6 100644 --- a/tests/bench/TypeCombinatorBench.php +++ b/tests/bench/TypeCombinatorBench.php @@ -26,6 +26,7 @@ use stdClass; use Tally\Attribute\Before; use Tally\Attribute\DataProvider; +use Tally\Attribute\ExpectedFor; use Tally\Attribute\Linear; use Tally\Attribute\Polynomial; use Tally\ScratchDirectory; @@ -168,12 +169,18 @@ public function benchRemoveFromUnion(int $types): Type * Arrays of the same keys that differ in two constant values cannot be keys-supersets of each other. */ #[DataProvider(method: 'types')] + #[ExpectedFor(dataSet: 'types-16', instructions: '1.491M', cycles: '2.056M', memory: '36.67KiB')] + #[ExpectedFor(dataSet: 'types-64', instructions: '6.140M', cycles: '7.464M', memory: '135.3KiB')] + #[ExpectedFor(dataSet: 'types-256', instructions: '32.93M', cycles: '39.25M', memory: '566.8KiB')] public function benchUnionTaggedConstantArrays(int $types): Type { return TypeCombinator::union(...$this->taggedArrays); } #[DataProvider(method: 'types')] + #[ExpectedFor(dataSet: 'types-16', instructions: '168.4K', cycles: '382.9K', memory: '8.875KiB')] + #[ExpectedFor(dataSet: 'types-64', instructions: '601.1K', cycles: '919.0K', memory: '26.18KiB')] + #[ExpectedFor(dataSet: 'types-256', instructions: '2.307M', cycles: '3.020M', memory: '107.7KiB')] public function benchIntersectBenevolentUnion(int $types): Type { return TypeCombinator::intersect(...$this->benevolentUnions); @@ -183,6 +190,9 @@ public function benchIntersectBenevolentUnion(int $types): Type * Each member is a general bool, float, int or string. */ #[DataProvider(method: 'types')] + #[ExpectedFor(dataSet: 'types-16', instructions: '436.3K', cycles: '627.1K', memory: '3.430KiB')] + #[ExpectedFor(dataSet: 'types-64', instructions: '473.0K', cycles: '682.2K', memory: '8.453KiB')] + #[ExpectedFor(dataSet: 'types-256', instructions: '1.723M', cycles: '2.065M', memory: '38.72KiB')] public function benchUnionGeneralScalars(int $types): Type { return TypeCombinator::union(...$this->generalScalars); @@ -192,6 +202,9 @@ public function benchUnionGeneralScalars(int $types): Type * numeric-string is compared with each constant string. */ #[DataProvider(method: 'types')] + #[ExpectedFor(dataSet: 'types-16', instructions: '706.8K', cycles: '1.176M', memory: '9.969KiB')] + #[ExpectedFor(dataSet: 'types-64', instructions: '2.643M', cycles: '3.650M', memory: '37.66KiB')] + #[ExpectedFor(dataSet: 'types-256', instructions: '10.38M', cycles: '13.58M', memory: '146.4KiB')] public function benchUnionNumericStringAndConstantStrings(int $types): Type { return TypeCombinator::union(...$this->numericStringAndStrings); @@ -201,6 +214,9 @@ public function benchUnionNumericStringAndConstantStrings(int $types): Type * The constant arrays have the same eight keys, which the general array gets once. */ #[DataProvider(method: 'types')] + #[ExpectedFor(dataSet: 'types-16', instructions: '2.856M', cycles: '3.610M', memory: '64.25KiB')] + #[ExpectedFor(dataSet: 'types-64', instructions: '10.91M', cycles: '12.94M', memory: '211.5KiB')] + #[ExpectedFor(dataSet: 'types-256', instructions: '43.08M', cycles: '50.19M', memory: '813.5KiB')] public function benchUnionGeneralArrayAndConstantArrays(int $types): Type { return TypeCombinator::union(...$this->arraysWithSameKeys); @@ -211,6 +227,9 @@ public function benchUnionGeneralArrayAndConstantArrays(int $types): Type * to an oversized array. */ #[DataProvider(method: 'types')] + #[ExpectedFor(dataSet: 'types-16', instructions: '3.245M', cycles: '4.253M', memory: '214.9KiB')] + #[ExpectedFor(dataSet: 'types-64', instructions: '100.9M', cycles: '127.5M', memory: '902.8KiB')] + #[ExpectedFor(dataSet: 'types-256', instructions: '315.7M', cycles: '400.5M', memory: '3.128MiB')] public function benchUnionConstantArraysWithOwnKeys(int $types): Type { return TypeCombinator::union(...$this->arraysWithOwnKeys); @@ -220,6 +239,9 @@ public function benchUnionConstantArraysWithOwnKeys(int $types): Type * Intersections of stdClass with one method each are flattened into one. */ #[DataProvider(method: 'types')] + #[ExpectedFor(dataSet: 'types-16', instructions: '1.992M', cycles: '2.574M', memory: '6.297KiB')] + #[ExpectedFor(dataSet: 'types-64', instructions: '22.29M', cycles: '23.87M', memory: '35.36KiB')] + #[ExpectedFor(dataSet: 'types-256', instructions: '315.1M', cycles: '322.1M', memory: '115.6KiB')] public function benchIntersectIntersections(int $types): Type { return TypeCombinator::intersect(...$this->intersections); @@ -239,6 +261,7 @@ public function benchUnionWithNevers(int $types): Type * Accessory types only, which need no sorting, a hundred times. */ #[DataProvider(method: 'smallest')] + #[ExpectedFor(dataSet: 'types-16', instructions: '5.855M', cycles: '6.808M', memory: '2.297KiB')] public function benchIntersectAccessories(int $types): Type { $type = new StringType(); diff --git a/tests/bench/TypeNodeResolverBench.php b/tests/bench/TypeNodeResolverBench.php index 8aa8ea36370..78c6ce49584 100644 --- a/tests/bench/TypeNodeResolverBench.php +++ b/tests/bench/TypeNodeResolverBench.php @@ -12,6 +12,7 @@ use PHPStan\Type\Type; use Tally\Attribute\Before; use Tally\Attribute\DataProvider; +use Tally\Attribute\ExpectedFor; use Tally\ScratchDirectory; use function implode; @@ -54,6 +55,8 @@ public function boot(int $items): void * A non-empty-list shape gets both the list and the non-empty accessory. */ #[DataProvider(method: 'items')] + #[ExpectedFor(dataSet: 'items-4', instructions: '80.52K', cycles: '323.3K', memory: '5.430KiB')] + #[ExpectedFor(dataSet: 'items-32', instructions: '545.4K', cycles: '854.6K', memory: '15.37KiB')] public function benchResolveNonEmptyListShape(int $items): Type { if ($this->resolver === null || $this->typeNode === null) { diff --git a/tests/bench/TypeSpecifierBench.php b/tests/bench/TypeSpecifierBench.php index 05e927b93d7..0ecfa6cd8dd 100644 --- a/tests/bench/TypeSpecifierBench.php +++ b/tests/bench/TypeSpecifierBench.php @@ -29,6 +29,8 @@ use SplObjectStorage; use Tally\Attribute\Before; use Tally\Attribute\DataProvider; +use Tally\Attribute\Expected; +use Tally\Attribute\ExpectedFor; use Tally\Meter; use Tally\ScratchDirectory; use function count; @@ -87,6 +89,10 @@ public function boot(): void * 'a' === $t || 'b' === $t || ..., which PHP parses to a chain as deep as it is long. */ #[DataProvider(method: 'chains')] + #[ExpectedFor(dataSet: 'falsey-16', instructions: '11.38M', cycles: '16.93M', memory: '476.1KiB')] + #[ExpectedFor(dataSet: 'falsey-64', instructions: '50.77M', cycles: '71.38M', memory: '1.886MiB')] + #[ExpectedFor(dataSet: 'truthy-16', instructions: '16.18M', cycles: '23.04M', memory: '497.0KiB')] + #[ExpectedFor(dataSet: 'truthy-64', instructions: '94.60M', cycles: '122.5M', memory: '2.028MiB')] public function benchSpecifyBooleanOrChain(string $context, int $arms): SpecifiedTypes { $chain = $this->meter->exclude(static fn (): Expr => self::orChain('arm', $arms)); @@ -97,6 +103,7 @@ public function benchSpecifyBooleanOrChain(string $context, int $arms): Specifie /** * $i ?: $t */ + #[Expected(instructions: '186.7M', cycles: '239.2M', memory: '2.017MiB')] public function benchSpecifyShortTernary(): int { $exprs = $this->meter->exclude(static function (): array { @@ -114,6 +121,7 @@ public function benchSpecifyShortTernary(): int /** * An int remembered by match() compared with a string is always false. */ + #[Expected(instructions: '38.39M', cycles: '52.40M', memory: '284.4KiB')] public function benchSpecifyAlwaysFalseIdentical(): int { $exprs = $this->meter->exclude(static function (): array { @@ -132,6 +140,7 @@ public function benchSpecifyAlwaysFalseIdentical(): int /** * abs($i) > 0 is not one of the calls a comparison narrows, like count() or strlen(). */ + #[Expected(instructions: '132.1M', cycles: '172.5M', memory: '140.2KiB')] public function benchSpecifyComparisonWithCall(): int { $exprs = $this->meter->exclude(function (): array { @@ -152,6 +161,8 @@ public function benchSpecifyComparisonWithCall(): int * Creating the narrowing of an expression: only the true and false contexts need its key. */ #[DataProvider(method: 'contexts')] + #[ExpectedFor(dataSet: 'true', instructions: '4.120M', cycles: '5.675M', memory: '79.96KiB')] + #[ExpectedFor(dataSet: 'truthy', instructions: '4.120M', cycles: '5.681M', memory: '79.96KiB')] public function benchCreate(string $context): int { [$exprs, $type] = $this->meter->exclude(function (): array { diff --git a/tests/bench/TypeUtilsBench.php b/tests/bench/TypeUtilsBench.php index 73edd04f798..214ca83a03f 100644 --- a/tests/bench/TypeUtilsBench.php +++ b/tests/bench/TypeUtilsBench.php @@ -12,6 +12,7 @@ use PHPStan\Type\UnionType; use Tally\Attribute\Before; use Tally\Attribute\DataProvider; +use Tally\Attribute\ExpectedFor; use Tally\ScratchDirectory; final class TypeUtilsBench @@ -46,6 +47,8 @@ public function boot(int $members): void * The traverser visits every member of a union that holds a conditional type to resolve it. */ #[DataProvider(method: 'members')] + #[ExpectedFor(dataSet: 'members-16', instructions: '320.6K', cycles: '570.7K', memory: '9.563KiB')] + #[ExpectedFor(dataSet: 'members-128', instructions: '2.804M', cycles: '3.271M', memory: '66.31KiB')] public function benchResolveLateResolvableTypes(int $members): Type { if ($this->type === null) { diff --git a/tests/bench/UnionTypeBench.php b/tests/bench/UnionTypeBench.php index 214d4ab2f09..4eda0acad8d 100644 --- a/tests/bench/UnionTypeBench.php +++ b/tests/bench/UnionTypeBench.php @@ -18,6 +18,7 @@ use Tally\Attribute\Before; use Tally\Attribute\Constant; use Tally\Attribute\DataProvider; +use Tally\Attribute\ExpectedFor; use Tally\Attribute\Linear; use Tally\Attribute\Linearithmic; use Tally\ScratchDirectory; @@ -75,6 +76,9 @@ public function boot(int $members): void * Each member is an intersection, described in parentheses. */ #[DataProvider(method: 'members')] + #[ExpectedFor(dataSet: 'members-16', instructions: '1.728M', cycles: '2.738M', memory: '16.65KiB')] + #[ExpectedFor(dataSet: 'members-64', instructions: '7.620M', cycles: '10.73M', memory: '44.40KiB')] + #[ExpectedFor(dataSet: 'members-256', instructions: '39.92M', cycles: '63.68M', memory: '198.8KiB')] public function benchDescribeIntersections(int $members): string { if ($this->intersections === null) { @@ -88,6 +92,9 @@ public function benchDescribeIntersections(int $members): string * The value types of the member arrays are unioned. */ #[DataProvider(method: 'members')] + #[ExpectedFor(dataSet: 'members-16', instructions: '151.6K', cycles: '310.4K', memory: '4.680KiB')] + #[ExpectedFor(dataSet: 'members-64', instructions: '573.3K', cycles: '796.1K', memory: '12.18KiB')] + #[ExpectedFor(dataSet: 'members-256', instructions: '2.259M', cycles: '2.771M', memory: '62.18KiB')] public function benchGetIterableValueType(int $members): Type { if ($this->arrays === null) { diff --git a/tests/bench/UnresolvableTypeHelperBench.php b/tests/bench/UnresolvableTypeHelperBench.php index 97f44da7f2d..c9ef98f6352 100644 --- a/tests/bench/UnresolvableTypeHelperBench.php +++ b/tests/bench/UnresolvableTypeHelperBench.php @@ -15,6 +15,7 @@ use ReflectionClass; use Tally\Attribute\Before; use Tally\Attribute\DataProvider; +use Tally\Attribute\ExpectedFor; use Tally\ScratchDirectory; final class UnresolvableTypeHelperBench @@ -55,6 +56,8 @@ public function boot(int $keys): void * once the first value is found, the rest of the type is not traversed. */ #[DataProvider(method: 'keys')] + #[ExpectedFor(dataSet: 'keys-16', instructions: '74.44K', cycles: '182.6K', memory: '6.461KiB')] + #[ExpectedFor(dataSet: 'keys-64', instructions: '260.1K', cycles: '413.9K', memory: '25.21KiB')] public function benchUnresolvableFirst(int $keys): bool { if ($this->type === null) { diff --git a/tests/bench/VarTagTypeRuleHelperBench.php b/tests/bench/VarTagTypeRuleHelperBench.php index a4ccd635cdd..99a4965b45b 100644 --- a/tests/bench/VarTagTypeRuleHelperBench.php +++ b/tests/bench/VarTagTypeRuleHelperBench.php @@ -12,6 +12,7 @@ use PHPStan\Type\Type; use PHPStan\Type\UnionType; use Tally\Attribute\Before; +use Tally\Attribute\Expected; use Tally\Attribute\Warmup; use Tally\ScratchDirectory; use function count; @@ -50,6 +51,7 @@ public function boot(): void * Without reportWrongPhpDocTypeInVarTag, only the native type of the expression is checked * against a @var tag that is not a PHPStan type. */ + #[Expected(instructions: '353.4K', cycles: '603.9K', memory: '984B')] public function benchCheckExprTypeWithoutPhpDocCheck(): int { if ($this->helper === null || $this->varTagType === null) { diff --git a/tests/bench/VerbosityLevelBench.php b/tests/bench/VerbosityLevelBench.php index 003e6129db3..9a53801225e 100644 --- a/tests/bench/VerbosityLevelBench.php +++ b/tests/bench/VerbosityLevelBench.php @@ -12,6 +12,7 @@ use PHPStan\Type\VerbosityLevel; use Tally\Attribute\Before; use Tally\Attribute\DataProvider; +use Tally\Attribute\ExpectedFor; use Tally\ScratchDirectory; final class VerbosityLevelBench @@ -46,6 +47,8 @@ public function boot(int $values): void * so the arrays in the other values are not looked into. */ #[DataProvider(method: 'values')] + #[ExpectedFor(dataSet: 'values-16', instructions: '89.62K', cycles: '224.3K', memory: '2.508KiB')] + #[ExpectedFor(dataSet: 'values-256', instructions: '749.4K', cycles: '937.2K', memory: '9.930KiB')] public function benchGetRecommendedLevelByType(int $values): VerbosityLevel { if ($this->shape === null) { diff --git a/tests/bench/VerbosityLevelFactoriesBench.php b/tests/bench/VerbosityLevelFactoriesBench.php index 7435bcd8e27..b6a86c6d4f3 100644 --- a/tests/bench/VerbosityLevelFactoriesBench.php +++ b/tests/bench/VerbosityLevelFactoriesBench.php @@ -4,6 +4,7 @@ use PHPStan\Type\VerbosityLevel; use Tally\Attribute\Before; +use Tally\Attribute\Expected; final class VerbosityLevelFactoriesBench { @@ -22,6 +23,7 @@ public function boot(): void /** * Every describe() call gets its level from one of these. */ + #[Expected(instructions: '1.101M', cycles: '1.115M', memory: '0B')] public function benchFactories(): VerbosityLevel { $level = VerbosityLevel::typeOnly(); diff --git a/tests/bench/data/and-chain-resolve-type-blowup.php b/tests/bench/data/and-chain-resolve-type-blowup.php index 83ee80d234a..b30165b72dc 100644 --- a/tests/bench/data/and-chain-resolve-type-blowup.php +++ b/tests/bench/data/and-chain-resolve-type-blowup.php @@ -1,9 +1,9 @@ = 8.1 /* - * instructions: 4.344B - * cycles: 5.682B - * memory: 94.20MiB + * instructions: 4.318B + * cycles: 5.652B + * memory: 58.76MiB */ namespace Bug10772; diff --git a/tests/bench/data/bug-10979.php b/tests/bench/data/bug-10979.php index 4d3383b2d03..b5dcbd6a75f 100644 --- a/tests/bench/data/bug-10979.php +++ b/tests/bench/data/bug-10979.php @@ -1,9 +1,9 @@ = 8.1 /* - * instructions: 4.081B - * cycles: 5.558B - * memory: 42.37MiB + * instructions: 3.967B + * cycles: 5.391B + * memory: 6.272MiB */ declare(strict_types=1); diff --git a/tests/bench/data/bug-11263.php b/tests/bench/data/bug-11263.php index bb35537ddb5..1c75f64b4f2 100644 --- a/tests/bench/data/bug-11263.php +++ b/tests/bench/data/bug-11263.php @@ -1,9 +1,9 @@ = 8.1 /* - * instructions: 1.927B - * cycles: 2.275B - * memory: 42.12MiB + * instructions: 1.916B + * cycles: 2.259B + * memory: 6.367MiB */ declare(strict_types = 1); diff --git a/tests/bench/data/bug-11283.php b/tests/bench/data/bug-11283.php index 8756488c8a1..76b2e1b3995 100644 --- a/tests/bench/data/bug-11283.php +++ b/tests/bench/data/bug-11283.php @@ -1,9 +1,9 @@ = 8.0 /* - * instructions: 1.208B - * cycles: 1.591B - * memory: 40.16MiB + * instructions: 1.166B + * cycles: 1.541B + * memory: 4.506MiB */ declare(strict_types=1); diff --git a/tests/bench/data/bug-11297.php b/tests/bench/data/bug-11297.php index af51ef84c3c..e8c74d84309 100644 --- a/tests/bench/data/bug-11297.php +++ b/tests/bench/data/bug-11297.php @@ -1,9 +1,9 @@ = 8.1 /* - * instructions: 9.284B - * cycles: 10.27B - * memory: 65.86MiB + * instructions: 9.100B + * cycles: 10.00B + * memory: 30.08MiB */ namespace Bug11297; diff --git a/tests/bench/data/bug-11913.php b/tests/bench/data/bug-11913.php index 1fc3dc7190a..2be2df56771 100644 --- a/tests/bench/data/bug-11913.php +++ b/tests/bench/data/bug-11913.php @@ -1,9 +1,9 @@ = 8.3 /* - * instructions: 1.811B - * cycles: 2.245B - * memory: 58.08MiB + * instructions: 1.790B + * cycles: 2.223B + * memory: 22.20MiB */ namespace Bug12159; diff --git a/tests/bench/data/bug-12671.php b/tests/bench/data/bug-12671.php index 4139e86d1bc..432f10dbe56 100644 --- a/tests/bench/data/bug-12671.php +++ b/tests/bench/data/bug-12671.php @@ -1,9 +1,9 @@ = 8.0 /* - * instructions: 350.9M - * cycles: 436.8M - * memory: 39.21MiB + * instructions: 114.0M + * cycles: 157.4M + * memory: 1.387MiB */ declare(strict_types = 1); diff --git a/tests/bench/data/bug-13310.php b/tests/bench/data/bug-13310.php index 5a498f9d605..6d3447dc17c 100644 --- a/tests/bench/data/bug-13310.php +++ b/tests/bench/data/bug-13310.php @@ -1,9 +1,9 @@ = 8.0 /* - * instructions: 12.87B - * cycles: 17.65B - * memory: 175.9MiB + * instructions: 12.62B + * cycles: 17.45B + * memory: 143.8MiB */ namespace Bug13352; diff --git a/tests/bench/data/bug-13685.php b/tests/bench/data/bug-13685.php index fc34cacab0b..96541e9f685 100644 --- a/tests/bench/data/bug-13685.php +++ b/tests/bench/data/bug-13685.php @@ -1,9 +1,9 @@ = 8.0 /* - * instructions: 338.9M - * cycles: 432.9M - * memory: 37.44MiB + * instructions: 290.6M + * cycles: 369.4M + * memory: 1.648MiB */ namespace BenchBug14319; diff --git a/tests/bench/data/bug-14452.php b/tests/bench/data/bug-14452.php index 023b21535a2..ea88e21b7dd 100644 --- a/tests/bench/data/bug-14452.php +++ b/tests/bench/data/bug-14452.php @@ -1,9 +1,9 @@ = 8.1 /* - * instructions: 1.622B - * cycles: 1.942B - * memory: 52.35MiB + * instructions: 1.590B + * cycles: 1.905B + * memory: 17.01MiB */ namespace Bug14869Enum; diff --git a/tests/bench/data/bug-14869.php b/tests/bench/data/bug-14869.php index a4038ce02f7..cf862094d1d 100644 --- a/tests/bench/data/bug-14869.php +++ b/tests/bench/data/bug-14869.php @@ -1,9 +1,9 @@ = 8.0 /* - * instructions: 27.17M - * cycles: 39.59M - * memory: 35.87MiB + * instructions: 27.14M + * cycles: 39.31M + * memory: 90.86KiB */ namespace Bug4308; diff --git a/tests/bench/data/bug-5081.php b/tests/bench/data/bug-5081.php index 14030da9602..a03fd9871fa 100644 --- a/tests/bench/data/bug-5081.php +++ b/tests/bench/data/bug-5081.php @@ -1,9 +1,9 @@ = 8.0 /* - * instructions: 53.47M - * cycles: 75.86M - * memory: 36.05MiB + * instructions: 51.80M + * cycles: 73.74M + * memory: 310.2KiB */ declare(strict_types=1); diff --git a/tests/bench/data/enum-case-union-removal.php b/tests/bench/data/enum-case-union-removal.php index 2aabd21928f..0c5a048e5b4 100644 --- a/tests/bench/data/enum-case-union-removal.php +++ b/tests/bench/data/enum-case-union-removal.php @@ -1,9 +1,9 @@ = 8.1 /* - * instructions: 1.923B - * cycles: 2.330B - * memory: 42.18MiB + * instructions: 1.884B + * cycles: 2.287B + * memory: 6.379MiB */ declare(strict_types = 1); diff --git a/tests/bench/data/enum-value-keyed-map.php b/tests/bench/data/enum-value-keyed-map.php index 49319efc071..e552ec2f876 100644 --- a/tests/bench/data/enum-value-keyed-map.php +++ b/tests/bench/data/enum-value-keyed-map.php @@ -1,9 +1,9 @@ = 8.1 /* - * instructions: 697.9M - * cycles: 877.6M - * memory: 53.48MiB + * instructions: 668.5M + * cycles: 834.2M + * memory: 17.69MiB */ namespace BenchEnumValueKeyedMap; diff --git a/tests/bench/data/finite-types-optional-keys-blowup.php b/tests/bench/data/finite-types-optional-keys-blowup.php index 5c672402da7..114af40fd98 100644 --- a/tests/bench/data/finite-types-optional-keys-blowup.php +++ b/tests/bench/data/finite-types-optional-keys-blowup.php @@ -1,9 +1,9 @@ = 8.0 /* - * instructions: 3.496B - * cycles: 4.608B - * memory: 66.92MiB + * instructions: 3.487B + * cycles: 4.594B + * memory: 31.14MiB */ declare(strict_types = 1); diff --git a/tests/bench/data/implode-optional-keys-blowup.php b/tests/bench/data/implode-optional-keys-blowup.php index 8d95ae08d10..2c14f5d62f1 100644 --- a/tests/bench/data/implode-optional-keys-blowup.php +++ b/tests/bench/data/implode-optional-keys-blowup.php @@ -1,9 +1,9 @@ = 8.0 /* - * instructions: 3.264B - * cycles: 4.504B - * memory: 104.9MiB + * instructions: 3.192B + * cycles: 4.415B + * memory: 69.14MiB */ namespace ImpureCallColumnsBench; diff --git a/tests/bench/data/in-array-intersect-blowup.php b/tests/bench/data/in-array-intersect-blowup.php index ed69d503454..d0ee8765012 100644 --- a/tests/bench/data/in-array-intersect-blowup.php +++ b/tests/bench/data/in-array-intersect-blowup.php @@ -1,9 +1,9 @@ = 8.0 /* - * instructions: 149.4M - * cycles: 182.0M - * memory: 36.69MiB + * instructions: 148.1M + * cycles: 180.5M + * memory: 966.1KiB */ declare(strict_types = 1); diff --git a/tests/bench/data/literal-key-union-offset-write.php b/tests/bench/data/literal-key-union-offset-write.php index bc233e4b9da..771d9e37ac9 100644 --- a/tests/bench/data/literal-key-union-offset-write.php +++ b/tests/bench/data/literal-key-union-offset-write.php @@ -1,9 +1,9 @@ = 8.0 /* - * instructions: 819.9M - * cycles: 1.051B - * memory: 39.43MiB + * instructions: 807.8M + * cycles: 1.041B + * memory: 3.733MiB */ namespace UnionFastPath; diff --git a/tests/bench/data/unrolled-foreach-list-guards.php b/tests/bench/data/unrolled-foreach-list-guards.php index 91f637da575..04171f35e36 100644 --- a/tests/bench/data/unrolled-foreach-list-guards.php +++ b/tests/bench/data/unrolled-foreach-list-guards.php @@ -1,9 +1,9 @@ = 8.0 /* - * instructions: 2.781B - * cycles: 3.536B - * memory: 45.12MiB + * instructions: 2.076B + * cycles: 2.668B + * memory: 8.117MiB */ namespace BenchComplexUnion;