From c1a6f51f53b54ae8a5f5e3a2b74fea4de23c7150 Mon Sep 17 00:00:00 2001 From: Tomas Votruba Date: Mon, 14 Sep 2026 18:27:37 +0200 Subject: [PATCH 1/2] add jobsize benhcmark sciprt --- scripts/rector-jobsize-benchmark.php | 343 +++++++++++++++++++++++++++ 1 file changed, 343 insertions(+) create mode 100644 scripts/rector-jobsize-benchmark.php diff --git a/scripts/rector-jobsize-benchmark.php b/scripts/rector-jobsize-benchmark.php new file mode 100644 index 00000000000..2fb61a7edb3 --- /dev/null +++ b/scripts/rector-jobsize-benchmark.php @@ -0,0 +1,343 @@ +projectDirectory, + $this->configPath, + $this->detectCoreCount(), + PHP_VERSION + ); + + $rows = []; + + foreach ($this->jobSizes as $jobSize) { + $rows[] = $this->measure($jobSize, $workingDirectory); + } + + $this->removeDirectory($workingDirectory); + $this->printMarkdownTable($rows); + + return 0; + } + + /** + * @return array{jobSize: int, seconds: float, exitCode: int, cached: int, changed: int, workers: int} + */ + private function measure(int $jobSize, string $workingDirectory): array + { + $runDirectory = $workingDirectory . '/job-' . $jobSize; + $cacheDirectory = $runDirectory . '/cache'; + $containerCacheDirectory = $runDirectory . '/container'; + + $this->removeDirectory($runDirectory); + + // Rector creates the result cache on demand but REQUIRES the container cache + // directory to exist, so make both here. + if (! mkdir($cacheDirectory, 0o777, true) || ! mkdir($containerCacheDirectory, 0o777, true)) { + throw new RuntimeException('Could not create cache directories under ' . $runDirectory); + } + + $wrapperConfigPath = $runDirectory . '/rector-wrapper.php'; + file_put_contents($wrapperConfigPath, $this->buildWrapperConfig( + $jobSize, + $cacheDirectory, + $containerCacheDirectory + )); + + $spawnLogPath = $runDirectory . '/spawns.log'; + + $command = sprintf( + '%s%s %s process --dry-run --no-progress-bar --output-format=json --config=%s 2>&1', + $this->buildSpawnLoggingEnvironment($spawnLogPath), + escapeshellarg(PHP_BINARY), + escapeshellarg($this->rectorBinary), + escapeshellarg($wrapperConfigPath) + ); + + printf("jobSize %-6d running ... ", $jobSize); + + $startedAt = hrtime(true); + exec($command, $outputLines, $exitCode); + $seconds = (hrtime(true) - $startedAt) / 1_000_000_000; + + $changed = $this->parseChangedFiles(implode("\n", $outputLines)); + $cached = $this->countCacheEntries($cacheDirectory); + $workers = $this->countWorkerProcesses($spawnLogPath); + + printf("%6.1f s exit %d analysed %d\n", $seconds, $exitCode, $cached + $changed); + + return [ + 'jobSize' => $jobSize, + 'seconds' => $seconds, + 'exitCode' => $exitCode, + 'cached' => $cached, + 'changed' => $changed, + 'workers' => $workers, + ]; + } + + private function buildWrapperConfig( + int $jobSize, + string $cacheDirectory, + string $containerCacheDirectory + ): string { + // The project config is imported, never modified. Calls made after the import win, + // because each of them writes straight into the parameter bag. + return sprintf( + <<<'PHP' + import(%s); + $rectorConfig->parallel(%d, %d, %d); + $rectorConfig->cacheDirectory(%s); + $rectorConfig->containerCacheDirectory(%s); + }; + PHP, + var_export($this->configPath, true), + $this->timeoutSeconds, + $this->detectCoreCount(), + $jobSize, + var_export($cacheDirectory, true), + var_export($containerCacheDirectory, true) + ); + } + + /** + * Counts how many PHP processes Rector starts, by prepending a one-line logger to every + * one of them. Rector spawns its workers as plain `php` processes, so the only way to + * reach them is through the environment they inherit - hence PHP_INI_SCAN_DIR rather + * than a `-d` flag, which would apply to the parent only. + * + * The logger is a single `file_put_contents` per process, which is noise next to a + * process start, but it is opt-in so that the default timings carry nothing extra. + */ + private function buildSpawnLoggingEnvironment(string $spawnLogPath): string + { + if (! $this->countProcesses) { + return ''; + } + + if ($this->spawnIniDirectory === null) { + $this->spawnIniDirectory = dirname($spawnLogPath, 2) . '/spawn-ini'; + mkdir($this->spawnIniDirectory, 0o777, true); + + $loggerPath = $this->spawnIniDirectory . '/spawn-logger.php'; + file_put_contents($loggerPath, <<<'PHP' + spawnIniDirectory . '/zz-spawn.ini', + 'auto_prepend_file=' . $loggerPath . "\n" + ); + } + + return sprintf( + 'PHP_INI_SCAN_DIR=%s RECTOR_BENCH_SPAWN_LOG=%s ', + escapeshellarg(':' . $this->spawnIniDirectory), + escapeshellarg($spawnLogPath) + ); + } + + private function countWorkerProcesses(string $spawnLogPath): int + { + if (! is_file($spawnLogPath)) { + return 0; + } + + $contents = file_get_contents($spawnLogPath); + if (! is_string($contents)) { + return 0; + } + + return substr_count($contents, "worker\n"); + } + + private function parseChangedFiles(string $output): int + { + if (preg_match('/"changed_files"\s*:\s*(\d+)/', $output, $matches) === 1) { + return (int) $matches[1]; + } + + return 0; + } + + private function countCacheEntries(string $cacheDirectory): int + { + if (! is_dir($cacheDirectory)) { + return 0; + } + + $count = 0; + $iterator = new RecursiveIteratorIterator( + new RecursiveDirectoryIterator($cacheDirectory, FilesystemIterator::SKIP_DOTS) + ); + + foreach ($iterator as $fileInfo) { + if (! $fileInfo instanceof SplFileInfo || $fileInfo->getExtension() !== 'php') { + continue; + } + + // the cache also holds one configuration-hash entry, which is not a file + $contents = file_get_contents($fileInfo->getPathname()); + if (is_string($contents) && str_contains($contents, 'file_hash')) { + ++$count; + } + } + + return $count; + } + + /** + * @param array $rows + */ + private function printMarkdownTable(array $rows): void + { + $fastest = min(array_column($rows, 'seconds')); + $workerColumn = $this->countProcesses ? ' worker processes |' : ''; + $workerDivider = $this->countProcesses ? ' --: |' : ''; + + echo "\n| `jobSize` | wall | vs fastest |" . $workerColumn . " exit | analysed | of which changed |\n"; + echo "| --: | --: | --: |" . $workerDivider . " --: | --: | --: |\n"; + + foreach ($rows as $row) { + printf( + "| %d | %.1f s | %.2fx |%s %d | %d | %d |\n", + $row['jobSize'], + $row['seconds'], + $row['seconds'] / $fastest, + $this->countProcesses ? ' ' . $row['workers'] . ' |' : '', + $row['exitCode'], + $row['cached'] + $row['changed'], + $row['changed'] + ); + } + + echo "\nEvery row is a cold run. `analysed` should be identical across rows - if a row is\n"; + echo "lower, that run silently dropped work rather than doing it faster.\n"; + } + + private function detectCoreCount(): int + { + $shellCoreCount = @shell_exec('getconf _NPROCESSORS_ONLN 2>/dev/null || sysctl -n hw.ncpu 2>/dev/null'); + $coreCount = is_string($shellCoreCount) ? (int) trim($shellCoreCount) : 0; + + return $coreCount > 0 ? $coreCount : 16; + } + + private function removeDirectory(string $directory): void + { + if (! is_dir($directory)) { + return; + } + + $iterator = new RecursiveIteratorIterator( + new RecursiveDirectoryIterator($directory, FilesystemIterator::SKIP_DOTS), + RecursiveIteratorIterator::CHILD_FIRST + ); + + foreach ($iterator as $fileInfo) { + if (! $fileInfo instanceof SplFileInfo) { + continue; + } + + $fileInfo->isDir() ? rmdir($fileInfo->getPathname()) : unlink($fileInfo->getPathname()); + } + + rmdir($directory); + } +} + +// --------------------------------------------------------------------------------------- + +$options = getopt('', ['config::', 'sizes::', 'bin::', 'timeout::', 'processes']); + +$projectDirectory = getcwd(); +if ($projectDirectory === false) { + fwrite(STDERR, "Cannot resolve the working directory.\n"); + exit(1); +} + +$configPath = realpath((string) ($options['config'] ?? 'rector.php')); +if ($configPath === false) { + fwrite(STDERR, "Config not found. Pass --config=path/to/rector.php\n"); + exit(1); +} + +$rectorBinary = realpath((string) ($options['bin'] ?? 'vendor/bin/rector')); +if ($rectorBinary === false) { + fwrite(STDERR, "Rector binary not found. Pass --bin=path/to/rector\n"); + exit(1); +} + +$jobSizes = array_map( + static fn (string $size): int => (int) trim($size), + explode(',', (string) ($options['sizes'] ?? '16,50,100,150,300')) +); + +$jobSizes = array_values(array_filter($jobSizes, static fn (int $size): bool => $size > 0)); +if ($jobSizes === []) { + fwrite(STDERR, "No valid job sizes given.\n"); + exit(1); +} + +$benchmark = new JobSizeBenchmark( + $projectDirectory, + $configPath, + $rectorBinary, + $jobSizes, + (int) ($options['timeout'] ?? 3600), + array_key_exists('processes', $options) +); + +exit($benchmark->run()); From 5b58bf89514a754fdc2d497479b7ce01b1d4757a Mon Sep 17 00:00:00 2001 From: Tomas Votruba Date: Mon, 14 Sep 2026 19:53:47 +0200 Subject: [PATCH 2/2] [CI] Add on-demand job size benchmark workflow --- .github/workflows/jobsize_benchmark.yaml | 41 ++++++++++++++++++++++++ 1 file changed, 41 insertions(+) create mode 100644 .github/workflows/jobsize_benchmark.yaml diff --git a/.github/workflows/jobsize_benchmark.yaml b/.github/workflows/jobsize_benchmark.yaml new file mode 100644 index 00000000000..04b790a563d --- /dev/null +++ b/.github/workflows/jobsize_benchmark.yaml @@ -0,0 +1,41 @@ +name: Job Size Benchmark + +on: + workflow_dispatch: + inputs: + sizes: + description: "Comma-separated parallel job sizes to measure" + required: false + default: "50,65,80,95,110,125,140,155,170,185,200,215,230,245,260,275,290,300" + +env: + COMPOSER_ROOT_VERSION: "dev-main" + +jobs: + jobsize_benchmark: + runs-on: ubuntu-latest + timeout-minutes: 90 + + steps: + - uses: actions/checkout@v5 + + - + uses: shivammathur/setup-php@v2 + with: + php-version: 8.4 + coverage: none + + - run: composer install --no-progress --ansi + + # measures Rector wall time across parallel job sizes, results land in the job summary + - run: php scripts/rector-jobsize-benchmark.php --bin=bin/rector --sizes=${{ inputs.sizes }} | tee results.txt + + - run: cat results.txt >> "$GITHUB_STEP_SUMMARY" + if: always() + + - + uses: actions/upload-artifact@v4 + if: always() + with: + name: jobsize-benchmark-results + path: results.txt