Skip to content

Commit c1e6d95

Browse files
committed
Implement worker auto-scaler
1 parent 65fd609 commit c1e6d95

10 files changed

Lines changed: 973 additions & 15 deletions

File tree

conf/config.neon

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -106,7 +106,7 @@ parameters:
106106
parallel:
107107
jobSize: 20
108108
processTimeout: 600.0
109-
maximumNumberOfProcesses: 8
109+
maximumNumberOfProcesses: auto
110110
minimumNumberOfJobsPerProcess: 2
111111
buffer: 134217728 # 128 MB
112112
loadLimit: 1.0

conf/parametersSchema.neon

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -108,7 +108,7 @@ parametersSchema:
108108
parallel: structure([
109109
jobSize: int(),
110110
processTimeout: float(),
111-
maximumNumberOfProcesses: int(),
111+
maximumNumberOfProcesses: anyOf(int(), 'auto'),
112112
minimumNumberOfJobsPerProcess: int(),
113113
buffer: int(),
114114
loadLimit: schema(float(), nullable())
Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
<?php declare(strict_types = 1);
2+
3+
namespace PHPStan\Diagnose;
4+
5+
use PHPStan\Command\Output;
6+
use PHPStan\DependencyInjection\AutowiredService;
7+
use PHPStan\Process\CpuCoreCounter;
8+
use PHPStan\Process\SystemResources;
9+
use function sprintf;
10+
11+
/**
12+
* Reports what PHPStan believes about the machine, so a user who disagrees with the
13+
* number of workers it chose can see which input was wrong.
14+
*/
15+
#[AutowiredService]
16+
final class SystemResourcesDiagnoseExtension implements DiagnoseExtension
17+
{
18+
19+
public function __construct(
20+
private CpuCoreCounter $cpuCoreCounter,
21+
private SystemResources $systemResources,
22+
)
23+
{
24+
}
25+
26+
public function print(Output $output): void
27+
{
28+
$output->writeLineFormatted('<info>System resources:</info>');
29+
$output->writeLineFormatted(sprintf('Detected CPU cores: %d', $this->cpuCoreCounter->getDetectedNumberOfCpuCores()));
30+
31+
$quota = $this->systemResources->getCpuQuota();
32+
$output->writeLineFormatted(sprintf(
33+
'cgroup CPU quota: %s',
34+
$quota === null ? 'none' : sprintf('%d cores', $quota),
35+
));
36+
37+
$output->writeLineFormatted(sprintf('Usable CPU cores: %d', $this->cpuCoreCounter->getNumberOfCpuCores()));
38+
39+
$memory = $this->systemResources->getAvailableMemoryBytes();
40+
$output->writeLineFormatted(sprintf(
41+
'Available memory: %s',
42+
$memory === null ? 'unknown' : sprintf('%.1f GB', $memory / 1024 / 1024 / 1024),
43+
));
44+
$output->writeLineFormatted('');
45+
}
46+
47+
}

src/Parallel/Scheduler.php

Lines changed: 61 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
use PHPStan\DependencyInjection\AutowiredParameter;
77
use PHPStan\DependencyInjection\AutowiredService;
88
use PHPStan\Diagnose\DiagnoseExtension;
9+
use PHPStan\Process\SystemResources;
910
use function array_values;
1011
use function ceil;
1112
use function count;
@@ -19,21 +20,32 @@
1920
final class Scheduler implements DiagnoseExtension
2021
{
2122

22-
/** @var array{int, int, int, int}|null */
23+
public const AUTO = 'auto';
24+
25+
/**
26+
* Used when the platform cannot say how much memory is available. This is the
27+
* historical fixed default: conservative on a big machine, but the only safe
28+
* answer when flying blind.
29+
*/
30+
private const UNKNOWN_MEMORY_PROCESSES_LIMIT = 8;
31+
32+
/** @var array{int, int, int, int, string}|null */
2333
private ?array $storedData = null;
2434

2535
/**
2636
* @param positive-int $jobSize
27-
* @param positive-int $maximumNumberOfProcesses
37+
* @param positive-int|self::AUTO $maximumNumberOfProcesses
2838
* @param positive-int $minimumNumberOfJobsPerProcess
2939
*/
3040
public function __construct(
3141
#[AutowiredParameter(ref: '%parallel.jobSize%')]
3242
private int $jobSize,
3343
#[AutowiredParameter(ref: '%parallel.maximumNumberOfProcesses%')]
34-
private int $maximumNumberOfProcesses,
44+
private int|string $maximumNumberOfProcesses,
3545
#[AutowiredParameter(ref: '%parallel.minimumNumberOfJobsPerProcess%')]
3646
private int $minimumNumberOfJobsPerProcess,
47+
private SystemResources $systemResources,
48+
private WorkerMemoryBudget $workerMemoryBudget,
3749
)
3850
{
3951
}
@@ -78,25 +90,68 @@ public function scheduleWork(
7890
$cpuCores,
7991
);
8092

81-
$usedNumberOfProcesses = min($numberOfProcesses, $this->maximumNumberOfProcesses);
82-
$this->storedData = [$cpuCores, count($files), count($jobs), $usedNumberOfProcesses];
93+
[$maximumNumberOfProcesses, $decision] = $this->resolveMaximumNumberOfProcesses($cpuCores);
94+
$usedNumberOfProcesses = min($numberOfProcesses, $maximumNumberOfProcesses);
95+
$this->storedData = [$cpuCores, count($files), count($jobs), $usedNumberOfProcesses, $decision];
8396

8497
return new Schedule($usedNumberOfProcesses, $jobs);
8598
}
8699

100+
/**
101+
* How many workers may run at once, and a human-readable account of why - which
102+
* `diagnose` prints, because a user who thinks the number is wrong needs to see
103+
* which input produced it.
104+
*
105+
* @return array{positive-int, string}
106+
*/
107+
private function resolveMaximumNumberOfProcesses(int $cpuCores): array
108+
{
109+
if ($this->maximumNumberOfProcesses !== self::AUTO) {
110+
return [$this->maximumNumberOfProcesses, 'configured'];
111+
}
112+
113+
$availableMemory = $this->systemResources->getAvailableMemoryBytes();
114+
if ($availableMemory === null) {
115+
return [
116+
self::UNKNOWN_MEMORY_PROCESSES_LIMIT,
117+
sprintf('auto, available memory unknown so capped at %d', self::UNKNOWN_MEMORY_PROCESSES_LIMIT),
118+
];
119+
}
120+
121+
$affordableProcesses = $this->workerMemoryBudget->getAffordableWorkerCount($availableMemory);
122+
123+
if ($affordableProcesses < $cpuCores) {
124+
return [
125+
$affordableProcesses,
126+
sprintf(
127+
'auto, %d MB available memory fits %d workers of %d MB',
128+
(int) ($availableMemory / 1024 / 1024),
129+
$affordableProcesses,
130+
WorkerMemoryBudget::EXPECTED_WORKER_MEMORY_LIMIT / 1024 / 1024,
131+
),
132+
];
133+
}
134+
135+
return [
136+
max(1, $cpuCores),
137+
sprintf('auto, limited by %d usable CPU cores', $cpuCores),
138+
];
139+
}
140+
87141
public function print(Output $output): void
88142
{
89143
if ($this->storedData === null) {
90144
return;
91145
}
92146

93-
[$cpuCores, $filesCount, $jobsCount, $usedNumberOfProcesses] = $this->storedData;
147+
[$cpuCores, $filesCount, $jobsCount, $usedNumberOfProcesses, $decision] = $this->storedData;
94148

95149
$output->writeLineFormatted('<info>Parallel processing scheduler:</info>');
96150
$output->writeLineFormatted(sprintf('# of detected CPU cores: %d', $cpuCores));
97151
$output->writeLineFormatted(sprintf('# of analysed files: %d', $filesCount));
98152
$output->writeLineFormatted(sprintf('# of jobs: %d', $jobsCount));
99153
$output->writeLineFormatted(sprintf('# of spawned processes: %d', $usedNumberOfProcesses));
154+
$output->writeLineFormatted(sprintf('Process limit: %s', $decision));
100155
$output->writeLineFormatted('');
101156
}
102157

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
<?php declare(strict_types = 1);
2+
3+
namespace PHPStan\Parallel;
4+
5+
use PHPStan\DependencyInjection\AutowiredService;
6+
use function floor;
7+
use function max;
8+
9+
/**
10+
* How many workers the available memory can pay for.
11+
*
12+
* Decided once, before any worker starts, and not revisited: the pool cannot shrink, so
13+
* a width that turns out to be too wide is unrecoverable, and the readings that would
14+
* justify widening it later cannot be trusted (see the note on
15+
* EXPECTED_WORKER_MEMORY_LIMIT).
16+
*/
17+
#[AutowiredService]
18+
final class WorkerMemoryBudget
19+
{
20+
21+
/**
22+
* What one worker is assumed to cost.
23+
*
24+
* Sampled peak PSS per worker across 25 real projects (26 to 5.3k files, four
25+
* workers each, cold): most sit between 100 MB and 300 MB, but analysis-hostile
26+
* codebases go much further - phpBB 487 MB, WordPress 635 MB, and large Laravel
27+
* applications 750-785 MB. File count predicts this badly; what a project does to
28+
* the type system matters more than how big it is, which is why this is a flat
29+
* figure rather than a per-file one.
30+
*
31+
* It is a divisor rather than a ceiling, so a project costing more than this is not
32+
* automatically a problem: at MEMORY_USAGE_FRACTION_LIMIT the pool still fits inside
33+
* available memory for real costs up to this figure divided by that fraction, i.e.
34+
* about 1 GB per worker. Past that, set the count explicitly.
35+
*
36+
* A flat estimate is used rather than the workers' own memory because no reliable
37+
* reading of a *running* worker exists. A worker's footprint climbs in steps -
38+
* level for seconds while it analyses ordinary files, then jumping - so neither a
39+
* plateau nor a completed-job count distinguishes "finished growing" from "between
40+
* growth spurts".
41+
*/
42+
public const EXPECTED_WORKER_MEMORY_LIMIT = 768 * 1024 * 1024;
43+
44+
/**
45+
* Share of the memory PHPStan is willing to plan for, leaving the rest to the main
46+
* process, the OS page cache, and whatever else the machine is doing.
47+
*/
48+
private const MEMORY_USAGE_FRACTION_LIMIT = 0.75;
49+
50+
/** @return positive-int */
51+
public function getAffordableWorkerCount(int $availableBytes): int
52+
{
53+
$plannable = max(0, $availableBytes) * self::MEMORY_USAGE_FRACTION_LIMIT;
54+
55+
return max(1, (int) floor($plannable / self::EXPECTED_WORKER_MEMORY_LIMIT));
56+
}
57+
58+
}

src/Process/CpuCoreCounter.php

Lines changed: 31 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,33 +6,61 @@
66
use Fidry\CpuCoreCounter\NumberOfCpuCoreNotFound;
77
use PHPStan\DependencyInjection\AutowiredParameter;
88
use PHPStan\DependencyInjection\AutowiredService;
9+
use function min;
910

1011
#[AutowiredService]
1112
final class CpuCoreCounter
1213
{
1314

1415
private ?int $count = null;
1516

17+
private ?int $detectedCount = null;
18+
1619
public function __construct(
1720
#[AutowiredParameter(ref: '%parallel.loadLimit%')]
1821
private ?float $loadLimit,
22+
private SystemResources $systemResources,
1923
)
2024
{
2125
}
2226

27+
/**
28+
* Cores PHPStan may actually use: what the machine reports, capped by the CPU
29+
* quota of the cgroup it runs in.
30+
*/
2331
public function getNumberOfCpuCores(): int
2432
{
2533
if ($this->count !== null) {
2634
return $this->count;
2735
}
2836

37+
$count = $this->getDetectedNumberOfCpuCores();
38+
39+
// fidry/cpu-core-counter has no cgroup finder, and its nproc-based default
40+
// honours a cpuset affinity mask but not a CFS bandwidth quota, so inside a
41+
// `docker run --cpus=2` container it reports the host's core count
42+
$quota = $this->systemResources->getCpuQuota();
43+
if ($quota !== null) {
44+
$count = min($count, $quota);
45+
}
46+
47+
return $this->count = $count;
48+
}
49+
50+
/** What the machine reports before any cgroup quota is applied. */
51+
public function getDetectedNumberOfCpuCores(): int
52+
{
53+
if ($this->detectedCount !== null) {
54+
return $this->detectedCount;
55+
}
56+
2957
try {
30-
$this->count = (new FidryCpuCoreCounter())->getAvailableForParallelisation(0, null, $this->loadLimit)->availableCpus;
58+
$this->detectedCount = (new FidryCpuCoreCounter())->getAvailableForParallelisation(0, null, $this->loadLimit)->availableCpus;
3159
} catch (NumberOfCpuCoreNotFound) {
32-
$this->count = 1;
60+
$this->detectedCount = 1;
3361
}
3462

35-
return $this->count;
63+
return $this->detectedCount;
3664
}
3765

3866
}

0 commit comments

Comments
 (0)