From 3555951be8113fc2812832ab700ab5ad8d2bf95f Mon Sep 17 00:00:00 2001 From: Tomas Votruba Date: Tue, 20 Oct 2026 18:54:25 +0200 Subject: [PATCH 1/2] [perf] Bucket method renames by old name in RenameMethodRector Per call/method node the rule scanned the whole configured rename list, running isStringName() on each before the type/reflection checks. With large sets (hundreds of renames) this is a per-node linear scan of pure string compares. Bucket the renames by lowercased old-method name at configure() time, so a node only visits the renames sharing its name - one hash lookup instead of N comparisons. Behaviour is unchanged; isStringName() still runs on the matched candidates. Also two small cleanups on the class-rename docblock path: reuse a single PhpDocNodeTraverser in DocBlockClassRenamer instead of allocating one per node, and hoist a loop-invariant getValue('type') lookup out of the per-class loop in PhpDocClassRenamer. bench/rename_method_bucket_bench.php (400 renames, 200 non-matching calls): 159.9 -> 2.0 us/call. --- bench/rename_method_bucket_bench.php | 102 ++++++++++++++++++ .../Rector/MethodCall/RenameMethodRector.php | 15 ++- .../PhpDocManipulator/PhpDocClassRenamer.php | 18 ++-- .../NodeAnalyzer/DocBlockClassRenamer.php | 10 +- 4 files changed, 125 insertions(+), 20 deletions(-) create mode 100644 bench/rename_method_bucket_bench.php diff --git a/bench/rename_method_bucket_bench.php b/bench/rename_method_bucket_bench.php new file mode 100644 index 00000000000..f84927ee9dc --- /dev/null +++ b/bench/rename_method_bucket_bench.php @@ -0,0 +1,102 @@ +create(); +$rectorConfig->boot(); + +/** @var TestingParser $testingParser */ +$testingParser = $rectorConfig->make(TestingParser::class); + +/** @var BetterNodeFinder $betterNodeFinder */ +$betterNodeFinder = $rectorConfig->make(BetterNodeFinder::class); + +$rector = $rectorConfig->make(RenameMethodRector::class); + +$methodCallRenames = []; +for ($i = 0; $i < RENAMES; ++$i) { + $methodCallRenames[] = new MethodCallRename('Bench\\SomeClass' . $i, 'oldMethod' . $i, 'newMethod' . $i); +} + +$rector->configure($methodCallRenames); + +$source = "freshCall{$i}();\n"; +} + +$sourceFilePath = tempnam(sys_get_temp_dir(), 'bench_') . '.php'; +file_put_contents($sourceFilePath, $source); + +$stmts = $testingParser->parseFileToDecoratedNodes($sourceFilePath); + +/** @var MethodCall[] $methodCalls */ +$methodCalls = $betterNodeFinder->findInstanceOf($stmts, MethodCall::class); + +$cpuSeconds = static function (): float { + $usage = getrusage(); + return $usage['ru_utime.tv_sec'] + $usage['ru_utime.tv_usec'] / 1_000_000 + + $usage['ru_stime.tv_sec'] + $usage['ru_stime.tv_usec'] / 1_000_000; +}; + +$run = static function () use ($rector, $methodCalls): void { + foreach ($methodCalls as $methodCall) { + $rector->refactor($methodCall); + } +}; + +// warm up autoloading/reflection caches once, excluded from timing +$run(); + +$wallStart = microtime(true); +$cpuStart = $cpuSeconds(); + +for ($repeat = 0; $repeat < REPEAT; ++$repeat) { + $run(); +} + +$cpu = $cpuSeconds() - $cpuStart; +$wall = microtime(true) - $wallStart; + +@unlink($sourceFilePath); + +$totalCalls = REPEAT * count($methodCalls); + +printf('renames: %d, calls: %d, repeats: %d, refactor() calls: %d%s', RENAMES, count($methodCalls), REPEAT, $totalCalls, PHP_EOL); +printf('CPU: %.4f s (%.3f us/call)%s', $cpu, $cpu / $totalCalls * 1_000_000, PHP_EOL); +printf('wall: %.4f s%s', $wall, PHP_EOL); diff --git a/rules/Renaming/Rector/MethodCall/RenameMethodRector.php b/rules/Renaming/Rector/MethodCall/RenameMethodRector.php index c19990bc79b..9d77fc9f192 100644 --- a/rules/Renaming/Rector/MethodCall/RenameMethodRector.php +++ b/rules/Renaming/Rector/MethodCall/RenameMethodRector.php @@ -36,9 +36,9 @@ final class RenameMethodRector extends AbstractRector implements ConfigurableRectorInterface { /** - * @var MethodCallRenameInterface[] + * @var array */ - private array $methodCallRenames = []; + private array $methodCallRenamesByOldMethod = []; public function __construct( private readonly ClassManipulator $classManipulator, @@ -101,7 +101,12 @@ public function configure(array $configuration): void { Assert::allIsAOf($configuration, MethodCallRenameInterface::class); - $this->methodCallRenames = $configuration; + // bucket by old method name, so a node matches only the few renames sharing its name, + // instead of scanning the whole configured list per node + $this->methodCallRenamesByOldMethod = []; + foreach ($configuration as $methodCallRename) { + $this->methodCallRenamesByOldMethod[strtolower($methodCallRename->getOldMethod())][] = $methodCallRename; + } } private function shouldSkipClassMethod( @@ -176,7 +181,7 @@ private function refactorClass( continue; } - foreach ($this->methodCallRenames as $methodCallRename) { + foreach ($this->methodCallRenamesByOldMethod[strtolower($methodName)] ?? [] as $methodCallRename) { if ($this->shouldSkipRename( $methodName, $classMethod, @@ -238,7 +243,7 @@ private function refactorMethodCallAndStaticCall( return null; } - foreach ($this->methodCallRenames as $methodCallRename) { + foreach ($this->methodCallRenamesByOldMethod[strtolower($callName)] ?? [] as $methodCallRename) { if (! $this->nodeNameResolver->isStringName($callName, $methodCallRename->getOldMethod())) { continue; } diff --git a/src/BetterPhpDocParser/PhpDocManipulator/PhpDocClassRenamer.php b/src/BetterPhpDocParser/PhpDocManipulator/PhpDocClassRenamer.php index 88fc559a1aa..8bb85a70fa8 100644 --- a/src/BetterPhpDocParser/PhpDocManipulator/PhpDocClassRenamer.php +++ b/src/BetterPhpDocParser/PhpDocManipulator/PhpDocClassRenamer.php @@ -130,6 +130,12 @@ private function processSerializerTypeTagValueNode( $classNameArrayItemNode = $doctrineAnnotationTagValueNode->getSilentValue(); + // loop-invariant, resolve once instead of per configured class + $currentTypeArrayItemNode = $doctrineAnnotationTagValueNode->getValue('type'); + $currentTypeStringNode = $currentTypeArrayItemNode instanceof ArrayItemNode + ? $currentTypeArrayItemNode->value + : null; + foreach ($oldToNewClasses as $oldClass => $newClass) { if ($classNameArrayItemNode instanceof ArrayItemNode && $classNameArrayItemNode->value instanceof StringNode) { $classNameStringNode = $classNameArrayItemNode->value; @@ -151,17 +157,7 @@ private function processSerializerTypeTagValueNode( $hasChanged = true; } - $currentTypeArrayItemNode = $doctrineAnnotationTagValueNode->getValue('type'); - if (! $currentTypeArrayItemNode instanceof ArrayItemNode) { - continue; - } - - $currentTypeStringNode = $currentTypeArrayItemNode->value; - if (! $currentTypeStringNode instanceof StringNode) { - continue; - } - - if ($currentTypeStringNode->value === $oldClass) { + if ($currentTypeStringNode instanceof StringNode && $currentTypeStringNode->value === $oldClass) { $currentTypeStringNode->value = $newClass; $hasChanged = true; diff --git a/src/NodeTypeResolver/PhpDoc/NodeAnalyzer/DocBlockClassRenamer.php b/src/NodeTypeResolver/PhpDoc/NodeAnalyzer/DocBlockClassRenamer.php index 3f1432aa119..9a03e1141f3 100644 --- a/src/NodeTypeResolver/PhpDoc/NodeAnalyzer/DocBlockClassRenamer.php +++ b/src/NodeTypeResolver/PhpDoc/NodeAnalyzer/DocBlockClassRenamer.php @@ -12,9 +12,14 @@ final readonly class DocBlockClassRenamer { + private PhpDocNodeTraverser $phpDocNodeTraverser; + public function __construct( private ClassRenamePhpDocNodeVisitor $classRenamePhpDocNodeVisitor, ) { + // the traverser + visitor are reusable across calls, so build them once + $this->phpDocNodeTraverser = new PhpDocNodeTraverser(); + $this->phpDocNodeTraverser->addPhpDocNodeVisitor($this->classRenamePhpDocNodeVisitor); } /** @@ -26,14 +31,11 @@ public function renamePhpDocType(PhpDocInfo $phpDocInfo, array $oldToNewTypes, N return false; } - $phpDocNodeTraverser = new PhpDocNodeTraverser(); - $phpDocNodeTraverser->addPhpDocNodeVisitor($this->classRenamePhpDocNodeVisitor); - $this->classRenamePhpDocNodeVisitor->setCurrentPhpNode($currentPhpNode); $this->classRenamePhpDocNodeVisitor->setOldToNewTypes($oldToNewTypes); - $phpDocNodeTraverser->traverse($phpDocInfo->getPhpDocNode()); + $this->phpDocNodeTraverser->traverse($phpDocInfo->getPhpDocNode()); return $this->classRenamePhpDocNodeVisitor->hasChanged(); } From d21057379aeba6c4f13cd591a0295940889a058a Mon Sep 17 00:00:00 2001 From: Tomas Votruba Date: Tue, 20 Oct 2026 18:55:55 +0200 Subject: [PATCH 2/2] [perf] Memoize serializer word-boundary regex per old class in PhpDocClassRenamer The JMS @Type rename path rebuilt the preg_quote word-boundary pattern per configured class per node; cache it by old class name. --- .../PhpDocManipulator/PhpDocClassRenamer.php | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/src/BetterPhpDocParser/PhpDocManipulator/PhpDocClassRenamer.php b/src/BetterPhpDocParser/PhpDocManipulator/PhpDocClassRenamer.php index 8bb85a70fa8..0ebccb4244d 100644 --- a/src/BetterPhpDocParser/PhpDocManipulator/PhpDocClassRenamer.php +++ b/src/BetterPhpDocParser/PhpDocManipulator/PhpDocClassRenamer.php @@ -16,11 +16,16 @@ use Rector\Enum\ClassName; use Rector\Renaming\Collector\RenamedNameCollector; -final readonly class PhpDocClassRenamer +final class PhpDocClassRenamer { + /** + * @var array + */ + private array $wordBoundaryPatternByOldClass = []; + public function __construct( - private ClassAnnotationMatcher $classAnnotationMatcher, - private RenamedNameCollector $renamedNameCollector + private readonly ClassAnnotationMatcher $classAnnotationMatcher, + private readonly RenamedNameCollector $renamedNameCollector ) { } @@ -148,7 +153,7 @@ private function processSerializerTypeTagValueNode( $this->renamedNameCollector->add($oldClass); $classNameStringNode->value = Strings::replace( $classNameStringNode->value, - '#\b' . preg_quote($oldClass, '#') . '\b#', + $this->wordBoundaryPatternByOldClass[$oldClass] ??= '#\b' . preg_quote($oldClass, '#') . '\b#', $newClass );