Skip to content

Commit 70e25dc

Browse files
authored
[perf] Run the workers with PHPStan Turbo (#8812)
1 parent 2c5dcce commit 70e25dc

7 files changed

Lines changed: 168 additions & 18 deletions

File tree

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
# Runs scripts/rector-ab-benchmark.php on Rector's own rules/ with the base branch and with the pull request,
2+
# and puts the A/B table in the job summary
3+
name: Self Benchmark
4+
5+
on:
6+
pull_request:
7+
branches:
8+
- main
9+
10+
env:
11+
COMPOSER_ROOT_VERSION: "dev-main"
12+
13+
jobs:
14+
self_benchmark:
15+
runs-on: ubuntu-latest
16+
timeout-minutes: 40
17+
18+
steps:
19+
-
20+
uses: actions/checkout@v5
21+
with:
22+
ref: ${{ github.event.pull_request.base.sha }}
23+
path: base
24+
25+
-
26+
uses: actions/checkout@v5
27+
with:
28+
path: head
29+
30+
-
31+
uses: shivammathur/setup-php@v2
32+
with:
33+
php-version: 8.4
34+
coverage: none
35+
36+
- run: composer install --no-progress --ansi --working-dir=base
37+
- run: composer install --no-progress --ansi --working-dir=head
38+
39+
# GNU time gives the CPU time and peak memory
40+
- run: sudo apt-get install -y time
41+
42+
# both sides process the same code: the rules/ directory of the pull request
43+
- run: php head/scripts/rector-ab-benchmark.php --bin=base/bin/rector --project=head/rules --runs=5 --save=base.json
44+
- run: php head/scripts/rector-ab-benchmark.php --bin=head/bin/rector --project=head/rules --runs=5 --baseline=base.json | tee results.txt
45+
46+
- run: cat results.txt >> "$GITHUB_STEP_SUMMARY"
47+
if: always()

‎bin/rector.php‎

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,8 @@
44

55
use Composer\XdebugHandler\XdebugHandler;
66
use Nette\Utils\Json;
7+
use PHPStan\PharAutoloader;
8+
use PHPStan\Turbo\TurboExtensionEnabler;
79
use Rector\Bootstrap\AutoloadFileParameterResolver;
810
use Rector\Bootstrap\RectorConfigsResolver;
911
use Rector\ChangesReporting\Output\JsonOutputFormatter;
@@ -30,6 +32,25 @@
3032
$autoloadIncluder = new AutoloadIncluder();
3133
$autoloadIncluder->includeDependencyOrRepositoryVendorAutoloadIfExists();
3234

35+
// PHPStan Turbo replaces php-parser classes, so it is activated before preload.php declares them.
36+
// The main process only gets Turbo when php.ini loads it; the workers get it from WorkerCommandLineFactory.
37+
$phpstanBootstrapFile = __DIR__ . '/../../../phpstan/phpstan/bootstrap.php';
38+
if (! class_exists(PharAutoloader::class) && is_file($phpstanBootstrapFile)) {
39+
// Rector installed as a dependency: the project autoloader that loads PHPStan comes after preload.php,
40+
// so load PHPStan's bootstrap now, and mark it as loaded with the identifier Composer gives the file
41+
$GLOBALS['__composer_autoload_files'][md5('phpstan/phpstan:bootstrap.php')] = true;
42+
require_once $phpstanBootstrapFile;
43+
}
44+
45+
if (class_exists(PharAutoloader::class)) {
46+
$phpstanPharPath = dirname((string) new ReflectionClass(PharAutoloader::class)->getFileName()) . '/phpstan.phar';
47+
TurboExtensionEnabler::trustOwnTypesIfSuitable($_SERVER['argv'], $phpstanPharPath);
48+
TurboExtensionEnabler::activateIfCompatible($phpstanPharPath);
49+
unset($phpstanPharPath);
50+
}
51+
52+
unset($phpstanBootstrapFile);
53+
3354
final class AutoloadIncluder
3455
{
3556
/**

‎build/target-repository/composer.json‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@
99
],
1010
"require": {
1111
"php": "^7.4|^8.0",
12-
"phpstan/phpstan": "^2.3.0"
12+
"phpstan/phpstan": "^2.3.2"
1313
},
1414
"autoload": {
1515
"files": [

‎composer.json‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@
2323
"nikic/php-parser": "^5.9",
2424
"ondram/ci-detector": "^4.2",
2525
"phpstan/phpdoc-parser": "^2.3.3",
26-
"phpstan/phpstan": "^2.3.0",
26+
"phpstan/phpstan": "^2.3.2",
2727
"react/child-process": "^0.6.5",
2828
"react/event-loop": "^1.6",
2929
"react/socket": "^1.17",

‎scripts/rector-ab-benchmark.php‎

Lines changed: 44 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@
2626
* (cache primed once, then reused):
2727
*
2828
* - median wall time
29+
* - median CPU time, user + system with the workers included, when /usr/bin/time is available (Linux)
2930
* - peak resident memory, when /usr/bin/time is available (Linux)
3031
* - changed-file count AND a fingerprint of the sorted changed-file list. The fingerprint
3132
* must stay identical across refs - a perf change that alters it changed behaviour, not
@@ -95,7 +96,7 @@ public function report(array $result, ?array $baseline): void
9596
}
9697

9798
/**
98-
* @return array{seconds: float, peakKb: int, changed: int, fingerprint: string}
99+
* @return array{seconds: float, cpuSeconds: float, peakKb: int, changed: int, fingerprint: string}
99100
*/
100101
private function measurePhase(string $phase, string $configPath, string $cacheDirectory, bool $cold): array
101102
{
@@ -105,6 +106,7 @@ private function measurePhase(string $phase, string $configPath, string $cacheDi
105106
}
106107

107108
$durations = [];
109+
$cpuDurations = [];
108110
$peakKb = 0;
109111
$changed = 0;
110112
$fingerprint = '';
@@ -115,6 +117,7 @@ private function measurePhase(string $phase, string $configPath, string $cacheDi
115117
$measurement = $this->runRector($configPath, $cacheDirectory, $cold);
116118

117119
$durations[] = $measurement['seconds'];
120+
$cpuDurations[] = $measurement['cpuSeconds'];
118121
$peakKb = max($peakKb, $measurement['peakKb']);
119122
$changed = $measurement['changed'];
120123
$fingerprint = $measurement['fingerprint'];
@@ -124,14 +127,15 @@ private function measurePhase(string $phase, string $configPath, string $cacheDi
124127

125128
return [
126129
'seconds' => $this->median($durations),
130+
'cpuSeconds' => $this->median($cpuDurations),
127131
'peakKb' => $peakKb,
128132
'changed' => $changed,
129133
'fingerprint' => $fingerprint,
130134
];
131135
}
132136

133137
/**
134-
* @return array{seconds: float, peakKb: int, changed: int, fingerprint: string}
138+
* @return array{seconds: float, cpuSeconds: float, peakKb: int, changed: int, fingerprint: string}
135139
*/
136140
private function runRector(string $configPath, string $cacheDirectory, bool $cold): array
137141
{
@@ -159,12 +163,14 @@ private function runRector(string $configPath, string $cacheDirectory, bool $col
159163
$seconds = (hrtime(true) - $startedAt) / 1_000_000_000;
160164

161165
$peakKb = $usesGnuTime ? $this->parsePeakKb($timeOutputPath) : 0;
166+
$cpuSeconds = $usesGnuTime ? $this->parseCpuSeconds($timeOutputPath) : 0.0;
162167
@unlink($timeOutputPath);
163168

164169
$changedFiles = $this->parseChangedFiles(is_string($jsonOutput) ? $jsonOutput : '');
165170

166171
return [
167172
'seconds' => $seconds,
173+
'cpuSeconds' => $cpuSeconds,
168174
'peakKb' => $peakKb,
169175
'changed' => count($changedFiles),
170176
'fingerprint' => substr(hash('xxh128', implode("\n", $changedFiles)), 0, 12),
@@ -235,6 +241,27 @@ private function parsePeakKb(string $timeErrorPath): int
235241
return 0;
236242
}
237243

244+
private function parseCpuSeconds(string $timeErrorPath): float
245+
{
246+
if (! is_file($timeErrorPath)) {
247+
return 0.0;
248+
}
249+
250+
$contents = file_get_contents($timeErrorPath);
251+
if (! is_string($contents)) {
252+
return 0.0;
253+
}
254+
255+
$cpuSeconds = 0.0;
256+
foreach (['User', 'System'] as $kind) {
257+
if (preg_match('/' . $kind . ' time \(seconds\):\s*([\d.]+)/', $contents, $matches) === 1) {
258+
$cpuSeconds += (float) $matches[1];
259+
}
260+
}
261+
262+
return $cpuSeconds;
263+
}
264+
238265
private function hasGnuTime(): bool
239266
{
240267
if (! is_file('/usr/bin/time')) {
@@ -266,17 +293,18 @@ private function median(array $values): float
266293
}
267294

268295
/**
269-
* @param array{seconds: float, peakKb: int, changed: int, fingerprint: string} $current
270-
* @param array{seconds: float, peakKb: int, changed: int, fingerprint: string}|null $baseline
296+
* @param array{seconds: float, cpuSeconds: float, peakKb: int, changed: int, fingerprint: string} $current
297+
* @param array{seconds: float, cpuSeconds: float, peakKb: int, changed: int, fingerprint: string}|null $baseline
271298
*/
272299
private function printPhase(string $phase, array $current, ?array $baseline): void
273300
{
274301
printf("## %s phase\n\n", $phase);
275302

276303
if ($baseline === null) {
277304
printf(
278-
"wall: %.2f s\npeak: %s\nchanged: %d (fingerprint %s)\n\n",
305+
"wall: %.2f s\ncpu: %s\npeak: %s\nchanged: %d (fingerprint %s)\n\n",
279306
$current['seconds'],
307+
$this->formatCpu($current['cpuSeconds']),
280308
$this->formatMemory($current['peakKb']),
281309
$current['changed'],
282310
$current['fingerprint']
@@ -286,11 +314,13 @@ private function printPhase(string $phase, array $current, ?array $baseline): vo
286314
}
287315

288316
$wallDelta = $this->percentDelta($baseline['seconds'], $current['seconds']);
317+
$cpuDelta = $this->percentDelta($baseline['cpuSeconds'], $current['cpuSeconds']);
289318
$memoryDelta = $this->percentDelta((float) $baseline['peakKb'], (float) $current['peakKb']);
290319

291320
printf("| metric | baseline | this run | delta |\n");
292321
printf("| --- | --: | --: | --: |\n");
293322
printf("| wall | %.2f s | %.2f s | %s |\n", $baseline['seconds'], $current['seconds'], $wallDelta);
323+
printf("| cpu | %s | %s | %s |\n", $this->formatCpu($baseline['cpuSeconds']), $this->formatCpu($current['cpuSeconds']), $cpuDelta);
294324
printf("| peak | %s | %s | %s |\n", $this->formatMemory($baseline['peakKb']), $this->formatMemory($current['peakKb']), $memoryDelta);
295325
printf("| changed | %d | %d | %s |\n", $baseline['changed'], $current['changed'], $baseline['changed'] === $current['changed'] ? 'same' : 'DIFFERENT');
296326

@@ -312,6 +342,15 @@ private function percentDelta(float $baseline, float $current): string
312342
return sprintf('%+.1f%% (%s)', $delta, $delta < 0 ? 'faster/less' : 'slower/more');
313343
}
314344

345+
private function formatCpu(float $seconds): string
346+
{
347+
if ($seconds <= 0.0) {
348+
return 'n/a';
349+
}
350+
351+
return sprintf('%.2f s', $seconds);
352+
}
353+
315354
private function formatMemory(int $kb): string
316355
{
317356
if ($kb === 0) {

‎src/Parallel/Command/WorkerCommandLineFactory.php‎

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44

55
namespace Rector\Parallel\Command;
66

7+
use PHPStan\Turbo\TurboExtensionSelector;
78
use Rector\ChangesReporting\Output\JsonOutputFormatter;
89
use Rector\Configuration\Option;
910
use Rector\FileSystem\FilePathHelper;
@@ -46,7 +47,7 @@ public function create(
4647
$commandArguments = array_merge(['process'], $commandArguments);
4748
}
4849

49-
$args = [PHP_BINARY, $mainScript, ...$commandArguments];
50+
$args = [PHP_BINARY, ...$this->createTurboExtensionArguments(), $mainScript, ...$commandArguments];
5051
$workerCommandArray = [];
5152

5253
$mainCommand = $this->commandFromReflectionFactory->create($mainCommandClass);
@@ -140,6 +141,27 @@ private function shouldSkipOption(InputInterface $input, string $optionName): bo
140141
return $optionName === Option::OUTPUT_FORMAT || $optionName === Option::CONFIG;
141142
}
142143

144+
/**
145+
* The workers get PHPStan Turbo on their command line, so the main process needs no restart to enable it.
146+
*
147+
* @return string[]
148+
*/
149+
private function createTurboExtensionArguments(): array
150+
{
151+
$extensionPath = TurboExtensionSelector::findExtensionForWorkers();
152+
if ($extensionPath === null) {
153+
return [];
154+
}
155+
156+
// quote the value, so PHP parses it as a string even when the path contains an operator like ~
157+
return [
158+
'-d',
159+
"extension='" . $extensionPath . "'",
160+
'-d',
161+
TurboExtensionSelector::EXTENSION_PATH_INI . "='" . $extensionPath . "'",
162+
];
163+
}
164+
143165
/**
144166
* @return string[]
145167
*/

0 commit comments

Comments
 (0)