diff --git a/conf/config.neon b/conf/config.neon
index 8fc949c9361..e930c44e42a 100644
--- a/conf/config.neon
+++ b/conf/config.neon
@@ -106,7 +106,7 @@ parameters:
parallel:
jobSize: 20
processTimeout: 600.0
- maximumNumberOfProcesses: 8
+ maximumNumberOfProcesses: auto
minimumNumberOfJobsPerProcess: 2
buffer: 134217728 # 128 MB
loadLimit: 1.0
diff --git a/conf/parametersSchema.neon b/conf/parametersSchema.neon
index 953bab24371..36097f7ba76 100644
--- a/conf/parametersSchema.neon
+++ b/conf/parametersSchema.neon
@@ -108,7 +108,7 @@ parametersSchema:
parallel: structure([
jobSize: int(),
processTimeout: float(),
- maximumNumberOfProcesses: int(),
+ maximumNumberOfProcesses: anyOf(int(), 'auto'),
minimumNumberOfJobsPerProcess: int(),
buffer: int(),
loadLimit: schema(float(), nullable())
diff --git a/src/Diagnose/SystemResourcesDiagnoseExtension.php b/src/Diagnose/SystemResourcesDiagnoseExtension.php
new file mode 100644
index 00000000000..c996d1de430
--- /dev/null
+++ b/src/Diagnose/SystemResourcesDiagnoseExtension.php
@@ -0,0 +1,47 @@
+writeLineFormatted('System resources:');
+ $output->writeLineFormatted(sprintf('Detected CPU cores: %d', $this->cpuCoreCounter->getDetectedNumberOfCpuCores()));
+
+ $quota = $this->systemResources->getCpuQuota();
+ $output->writeLineFormatted(sprintf(
+ 'cgroup CPU quota: %s',
+ $quota === null ? 'none' : sprintf('%d cores', $quota),
+ ));
+
+ $output->writeLineFormatted(sprintf('Usable CPU cores: %d', $this->cpuCoreCounter->getNumberOfCpuCores()));
+
+ $memory = $this->systemResources->getAvailableMemoryBytes();
+ $output->writeLineFormatted(sprintf(
+ 'Available memory: %s',
+ $memory === null ? 'unknown' : sprintf('%.1f GB', $memory / 1024 / 1024 / 1024),
+ ));
+ $output->writeLineFormatted('');
+ }
+
+}
diff --git a/src/Parallel/Scheduler.php b/src/Parallel/Scheduler.php
index 50760e65b92..f396931a167 100644
--- a/src/Parallel/Scheduler.php
+++ b/src/Parallel/Scheduler.php
@@ -6,6 +6,7 @@
use PHPStan\DependencyInjection\AutowiredParameter;
use PHPStan\DependencyInjection\AutowiredService;
use PHPStan\Diagnose\DiagnoseExtension;
+use PHPStan\Process\SystemResources;
use function array_values;
use function ceil;
use function count;
@@ -19,21 +20,32 @@
final class Scheduler implements DiagnoseExtension
{
- /** @var array{int, int, int, int}|null */
+ public const AUTO = 'auto';
+
+ /**
+ * Used when the platform cannot say how much memory is available. This is the
+ * historical fixed default: conservative on a big machine, but the only safe
+ * answer when flying blind.
+ */
+ private const UNKNOWN_MEMORY_PROCESSES_LIMIT = 8;
+
+ /** @var array{int, int, int, int, string}|null */
private ?array $storedData = null;
/**
* @param positive-int $jobSize
- * @param positive-int $maximumNumberOfProcesses
+ * @param positive-int|self::AUTO $maximumNumberOfProcesses
* @param positive-int $minimumNumberOfJobsPerProcess
*/
public function __construct(
#[AutowiredParameter(ref: '%parallel.jobSize%')]
private int $jobSize,
#[AutowiredParameter(ref: '%parallel.maximumNumberOfProcesses%')]
- private int $maximumNumberOfProcesses,
+ private int|string $maximumNumberOfProcesses,
#[AutowiredParameter(ref: '%parallel.minimumNumberOfJobsPerProcess%')]
private int $minimumNumberOfJobsPerProcess,
+ private SystemResources $systemResources,
+ private WorkerMemoryBudget $workerMemoryBudget,
)
{
}
@@ -78,25 +90,68 @@ public function scheduleWork(
$cpuCores,
);
- $usedNumberOfProcesses = min($numberOfProcesses, $this->maximumNumberOfProcesses);
- $this->storedData = [$cpuCores, count($files), count($jobs), $usedNumberOfProcesses];
+ [$maximumNumberOfProcesses, $decision] = $this->resolveMaximumNumberOfProcesses($cpuCores);
+ $usedNumberOfProcesses = min($numberOfProcesses, $maximumNumberOfProcesses);
+ $this->storedData = [$cpuCores, count($files), count($jobs), $usedNumberOfProcesses, $decision];
return new Schedule($usedNumberOfProcesses, $jobs);
}
+ /**
+ * How many workers may run at once, and a human-readable account of why - which
+ * `diagnose` prints, because a user who thinks the number is wrong needs to see
+ * which input produced it.
+ *
+ * @return array{positive-int, string}
+ */
+ private function resolveMaximumNumberOfProcesses(int $cpuCores): array
+ {
+ if ($this->maximumNumberOfProcesses !== self::AUTO) {
+ return [$this->maximumNumberOfProcesses, 'configured'];
+ }
+
+ $availableMemory = $this->systemResources->getAvailableMemoryBytes();
+ if ($availableMemory === null) {
+ return [
+ self::UNKNOWN_MEMORY_PROCESSES_LIMIT,
+ sprintf('auto, available memory unknown so capped at %d', self::UNKNOWN_MEMORY_PROCESSES_LIMIT),
+ ];
+ }
+
+ $affordableProcesses = $this->workerMemoryBudget->getAffordableWorkerCount($availableMemory);
+
+ if ($affordableProcesses < $cpuCores) {
+ return [
+ $affordableProcesses,
+ sprintf(
+ 'auto, %d MB available memory fits %d workers of %d MB',
+ (int) ($availableMemory / 1024 / 1024),
+ $affordableProcesses,
+ WorkerMemoryBudget::EXPECTED_WORKER_MEMORY_LIMIT / 1024 / 1024,
+ ),
+ ];
+ }
+
+ return [
+ max(1, $cpuCores),
+ sprintf('auto, limited by %d usable CPU cores', $cpuCores),
+ ];
+ }
+
public function print(Output $output): void
{
if ($this->storedData === null) {
return;
}
- [$cpuCores, $filesCount, $jobsCount, $usedNumberOfProcesses] = $this->storedData;
+ [$cpuCores, $filesCount, $jobsCount, $usedNumberOfProcesses, $decision] = $this->storedData;
$output->writeLineFormatted('Parallel processing scheduler:');
$output->writeLineFormatted(sprintf('# of detected CPU cores: %d', $cpuCores));
$output->writeLineFormatted(sprintf('# of analysed files: %d', $filesCount));
$output->writeLineFormatted(sprintf('# of jobs: %d', $jobsCount));
$output->writeLineFormatted(sprintf('# of spawned processes: %d', $usedNumberOfProcesses));
+ $output->writeLineFormatted(sprintf('Process limit: %s', $decision));
$output->writeLineFormatted('');
}
diff --git a/src/Parallel/WorkerMemoryBudget.php b/src/Parallel/WorkerMemoryBudget.php
new file mode 100644
index 00000000000..96a768449ef
--- /dev/null
+++ b/src/Parallel/WorkerMemoryBudget.php
@@ -0,0 +1,58 @@
+count !== null) {
return $this->count;
}
+ $count = $this->getDetectedNumberOfCpuCores();
+
+ // fidry/cpu-core-counter has no cgroup finder, and its nproc-based default
+ // honours a cpuset affinity mask but not a CFS bandwidth quota, so inside a
+ // `docker run --cpus=2` container it reports the host's core count
+ $quota = $this->systemResources->getCpuQuota();
+ if ($quota !== null) {
+ $count = min($count, $quota);
+ }
+
+ return $this->count = $count;
+ }
+
+ /** What the machine reports before any cgroup quota is applied. */
+ public function getDetectedNumberOfCpuCores(): int
+ {
+ if ($this->detectedCount !== null) {
+ return $this->detectedCount;
+ }
+
try {
- $this->count = (new FidryCpuCoreCounter())->getAvailableForParallelisation(0, null, $this->loadLimit)->availableCpus;
+ $this->detectedCount = (new FidryCpuCoreCounter())->getAvailableForParallelisation(0, null, $this->loadLimit)->availableCpus;
} catch (NumberOfCpuCoreNotFound) {
- $this->count = 1;
+ $this->detectedCount = 1;
}
- return $this->count;
+ return $this->detectedCount;
}
}
diff --git a/src/Process/SystemResources.php b/src/Process/SystemResources.php
new file mode 100644
index 00000000000..9f7d4534686
--- /dev/null
+++ b/src/Process/SystemResources.php
@@ -0,0 +1,379 @@
+
+ */
+ private array $cgroupPaths = [];
+
+ /**
+ * @param string $filesystemRoot Prefix for every /proc and /sys path read, so tests
+ * can run against a fixture tree. Empty means the real
+ * filesystem.
+ */
+ public function __construct(private string $filesystemRoot = '')
+ {
+ }
+
+ /**
+ * Number of CPU cores the current cgroup's CFS quota allows, or null when no
+ * cgroup limits CPU bandwidth.
+ *
+ * @return positive-int|null
+ */
+ public function getCpuQuota(): ?int
+ {
+ $quotas = [];
+ foreach ([$this->getCgroupV2CpuQuota(), $this->getCgroupV1CpuQuota()] as $quota) {
+ if ($quota === null) {
+ continue;
+ }
+
+ $quotas[] = $quota;
+ }
+
+ if (count($quotas) === 0) {
+ return null;
+ }
+
+ // a sub-core quota still lets a single worker run, just throttled
+ return max(1, min($quotas));
+ }
+
+ /**
+ * Memory that can be handed to worker processes right now, or null when the
+ * platform cannot say. The smallest of the cgroup's remaining allowance and the
+ * host's own available memory: a container can be under its own limit while the
+ * host it shares is not.
+ */
+ public function getAvailableMemoryBytes(): ?int
+ {
+ $candidates = [];
+ foreach ([$this->getCgroupMemoryHeadroom(), $this->getHostAvailableMemory()] as $candidate) {
+ if ($candidate === null) {
+ continue;
+ }
+
+ $candidates[] = $candidate;
+ }
+
+ if (count($candidates) === 0) {
+ return null;
+ }
+
+ return min($candidates);
+ }
+
+ /** @return positive-int|null */
+ private function getCgroupV2CpuQuota(): ?int
+ {
+ $cgroupPath = $this->getCgroupPath('');
+ if ($cgroupPath === null) {
+ return null;
+ }
+
+ $quotas = [];
+ foreach ($this->getAncestorPaths($cgroupPath) as $path) {
+ $cpuMax = $this->readFile('/sys/fs/cgroup' . $path . '/cpu.max');
+ if ($cpuMax === null) {
+ // the cpu controller is not enabled at this depth - the root cgroup
+ // never has the file and a leaf often does not either - which says
+ // nothing about the ancestors that may still carry a quota
+ continue;
+ }
+
+ $parts = explode(' ', trim($cpuMax));
+ if (count($parts) !== 2 || !ctype_digit($parts[0]) || !ctype_digit($parts[1])) {
+ // "max " is how an unlimited cgroup states it
+ continue;
+ }
+
+ $period = (int) $parts[1];
+ if ($period <= 0) {
+ continue;
+ }
+
+ $quotas[] = (int) ceil((int) $parts[0] / $period);
+ }
+
+ return count($quotas) === 0 ? null : max(1, min($quotas));
+ }
+
+ /** @return positive-int|null */
+ private function getCgroupV1CpuQuota(): ?int
+ {
+ $cgroupPath = $this->getCgroupPath('cpu');
+ if ($cgroupPath === null) {
+ return null;
+ }
+
+ $quotas = [];
+ foreach ($this->getAncestorPaths($cgroupPath) as $path) {
+ foreach (['cpu', 'cpu,cpuacct'] as $controllerDir) {
+ $base = '/sys/fs/cgroup/' . $controllerDir . $path;
+ $quota = $this->readIntFile($base . '/cpu.cfs_quota_us');
+ $period = $this->readIntFile($base . '/cpu.cfs_period_us');
+ if ($quota === null || $period === null || $quota <= 0 || $period <= 0) {
+ // -1 means unlimited
+ continue;
+ }
+
+ $quotas[] = (int) ceil($quota / $period);
+ }
+ }
+
+ return count($quotas) === 0 ? null : max(1, min($quotas));
+ }
+
+ private function getCgroupMemoryHeadroom(): ?int
+ {
+ $headrooms = [];
+
+ $v2Path = $this->getCgroupPath('');
+ if ($v2Path !== null) {
+ foreach ($this->getAncestorPaths($v2Path) as $path) {
+ $base = '/sys/fs/cgroup' . $path;
+ $limit = $this->readIntFile($base . '/memory.max');
+ if ($limit === null || $limit >= self::UNLIMITED_MEMORY_LIMIT) {
+ // the file says "max" when unlimited, which readIntFile rejects
+ continue;
+ }
+
+ $headrooms[] = max(0, $limit - ($this->readIntFile($base . '/memory.current') ?? 0));
+ }
+ }
+
+ $v1Path = $this->getCgroupPath('memory');
+ if ($v1Path !== null) {
+ foreach ($this->getAncestorPaths($v1Path) as $path) {
+ $base = '/sys/fs/cgroup/memory' . $path;
+ $limit = $this->readIntFile($base . '/memory.limit_in_bytes');
+ if ($limit === null || $limit >= self::UNLIMITED_MEMORY_LIMIT) {
+ continue;
+ }
+
+ $headrooms[] = max(0, $limit - ($this->readIntFile($base . '/memory.usage_in_bytes') ?? 0));
+ }
+ }
+
+ return count($headrooms) === 0 ? null : min($headrooms);
+ }
+
+ private function getHostAvailableMemory(): ?int
+ {
+ $memInfo = $this->readFile('/proc/meminfo');
+ if ($memInfo !== null) {
+ // MemAvailable is the kernel's own estimate of what a new workload can
+ // get without swapping, which is what a worker is
+ if (preg_match('/^MemAvailable:\s+(\d+) kB$/m', $memInfo, $matches) === 1) {
+ return (int) $matches[1] * 1024;
+ }
+
+ return null;
+ }
+
+ if (PHP_OS_FAMILY !== 'Darwin' || $this->filesystemRoot !== '') {
+ return null;
+ }
+
+ return $this->getDarwinAvailableMemory();
+ }
+
+ private function getDarwinAvailableMemory(): ?int
+ {
+ $vmStat = $this->exec('vm_stat 2>/dev/null');
+ if ($vmStat === null) {
+ return null;
+ }
+
+ if (preg_match('/page size of (\d+) bytes/', $vmStat, $matches) !== 1) {
+ return null;
+ }
+
+ $pageSize = (int) $matches[1];
+
+ // Free pages plus the ones the kernel will hand over on demand; speculative
+ // pages are read-ahead nobody has asked for yet, so they count as available.
+ // Purgeable pages would too, but vm_stat also counts them under active and
+ // inactive, and over-reporting available memory.
+ $available = 0;
+ foreach (['Pages free', 'Pages inactive', 'Pages speculative'] as $key) {
+ if (preg_match('/^' . $key . ':\s+(\d+)\./m', $vmStat, $matches) !== 1) {
+ continue;
+ }
+
+ $available += (int) $matches[1] * $pageSize;
+ }
+
+ return $available > 0 ? $available : null;
+ }
+
+ /**
+ * The current process' path within a cgroup hierarchy, or null when it is not in
+ * one. An empty controller asks for the v2 unified hierarchy.
+ */
+ private function getCgroupPath(string $controller): ?string
+ {
+ if (array_key_exists($controller, $this->cgroupPaths)) {
+ return $this->cgroupPaths[$controller];
+ }
+
+ return $this->cgroupPaths[$controller] = $this->findCgroupPath($controller);
+ }
+
+ private function findCgroupPath(string $controller): ?string
+ {
+ $contents = $this->readFile('/proc/self/cgroup');
+ if ($contents === null) {
+ return null;
+ }
+
+ foreach (explode("\n", $contents) as $line) {
+ // hierarchy-ID:controller-list:path
+ $parts = explode(':', trim($line), 3);
+ if (count($parts) !== 3) {
+ continue;
+ }
+
+ [, $controllers, $path] = $parts;
+ if ($controller === '') {
+ if ($controllers !== '') {
+ continue;
+ }
+ } elseif (
+ $controllers !== $controller
+ && !str_starts_with($controllers, $controller . ',')
+ && !str_contains($controllers, ',' . $controller)
+ ) {
+ continue;
+ }
+
+ return $path === '/' ? '' : $path;
+ }
+
+ return null;
+ }
+
+ /**
+ * The cgroup's own path and every ancestor up to the root, because a limit set on
+ * an ancestor binds the leaf just as tightly - Kubernetes puts the pod's quota on
+ * the pod slice, not on the container's own cgroup.
+ *
+ * @return list
+ */
+ private function getAncestorPaths(string $path): array
+ {
+ $segments = [];
+ foreach (explode('/', $path) as $segment) {
+ if ($segment === '') {
+ continue;
+ }
+
+ $segments[] = $segment;
+ }
+
+ $paths = [''];
+ for ($i = 1; $i <= count($segments); $i++) {
+ $paths[] = '/' . implode('/', array_slice($segments, 0, $i));
+ }
+
+ return $paths;
+ }
+
+ private function readIntFile(string $path): ?int
+ {
+ $contents = $this->readFile($path);
+ if ($contents === null) {
+ return null;
+ }
+
+ $contents = trim($contents);
+ $negative = str_starts_with($contents, '-');
+ $digits = $negative ? substr($contents, 1) : $contents;
+ if ($digits === '' || !ctype_digit($digits)) {
+ return null;
+ }
+
+ return $negative ? -(int) $digits : (int) $digits;
+ }
+
+ private function readFile(string $path): ?string
+ {
+ $path = $this->filesystemRoot . $path;
+
+ // container filesystems routinely have these present but unreadable, and a
+ // warning from a probe would be worse than not knowing
+ if (!@is_file($path)) {
+ return null;
+ }
+
+ $contents = @file_get_contents($path);
+
+ return $contents === false ? null : $contents;
+ }
+
+ private function exec(string $command): ?string
+ {
+ if (!function_exists('shell_exec') || in_array('shell_exec', explode(',', (string) ini_get('disable_functions')), true)) {
+ return null;
+ }
+
+ $output = @shell_exec($command);
+
+ return $output === null || $output === false || trim($output) === '' ? null : $output;
+ }
+
+}
diff --git a/tests/PHPStan/Parallel/SchedulerTest.php b/tests/PHPStan/Parallel/SchedulerTest.php
index a5fa5b829d6..e861020329a 100644
--- a/tests/PHPStan/Parallel/SchedulerTest.php
+++ b/tests/PHPStan/Parallel/SchedulerTest.php
@@ -2,6 +2,8 @@
namespace PHPStan\Parallel;
+use PHPStan\File\FileWriter;
+use PHPStan\Process\SystemResources;
use PHPUnit\Framework\Attributes\DataProvider;
use PHPUnit\Framework\TestCase;
use function array_fill;
@@ -9,8 +11,11 @@
use function array_map;
use function array_merge;
use function count;
+use function mkdir;
use function sort;
use function sprintf;
+use function sys_get_temp_dir;
+use function uniqid;
class SchedulerTest extends TestCase
{
@@ -94,7 +99,7 @@ public function testSchedule(
): void
{
$files = array_fill(0, $numberOfFiles, 'file.php');
- $scheduler = new Scheduler($jobSize, $maximumNumberOfProcesses, $minimumNumberOfJobsPerProcess);
+ $scheduler = self::createScheduler($jobSize, $maximumNumberOfProcesses, $minimumNumberOfJobsPerProcess);
$schedule = $scheduler->scheduleWork($cpuCores, $files, static fn (string $file): int => 0);
$this->assertSame($expectedNumberOfProcesses, $schedule->getNumberOfProcesses());
@@ -113,7 +118,7 @@ public function testHeaviestFilesAreSpreadAcrossJobs(): void
'f.php' => 600,
];
- $scheduler = new Scheduler(2, 16, 1);
+ $scheduler = self::createScheduler(2, 16, 1);
$schedule = $scheduler->scheduleWork(16, array_keys($fileSizes), static fn (string $file): int => $fileSizes[$file] ?? 0);
// six files, job size 2 -> three jobs; the three heaviest files must not
@@ -136,7 +141,7 @@ public function testFilesWithinAJobKeepTheirInputOrder(): void
'src/Middleware.php' => 560,
];
- $scheduler = new Scheduler(20, 16, 1);
+ $scheduler = self::createScheduler(20, 16, 1);
$schedule = $scheduler->scheduleWork(16, array_keys($fileSizes), static fn (string $file): int => $fileSizes[$file] ?? 0);
$this->assertSame([['bootstrap.php', 'src/Middleware.php']], $schedule->getJobs());
@@ -152,7 +157,7 @@ public function testEveryFileIsScheduledExactlyOnce(): void
$sizes[$file] = ($i * 37) % 1000;
}
- $scheduler = new Scheduler(10, 16, 1);
+ $scheduler = self::createScheduler(10, 16, 1);
$schedule = $scheduler->scheduleWork(16, $files, static fn (string $file): int => $sizes[$file]);
$scheduled = array_merge(...$schedule->getJobs());
@@ -165,4 +170,83 @@ public function testEveryFileIsScheduledExactlyOnce(): void
}
}
+ public function testAutoIsLimitedByUsableCoresWhenMemoryIsPlentiful(): void
+ {
+ $scheduler = self::createScheduler(1, Scheduler::AUTO, 1, self::memoryOf(64 * 1024));
+ $schedule = $scheduler->scheduleWork(12, array_fill(0, 200, 'file.php'), static fn (string $file): int => 0);
+
+ $this->assertSame(12, $schedule->getNumberOfProcesses());
+ }
+
+ public function testAutoIsLimitedByAvailableMemory(): void
+ {
+ // 4 GB available, 75% of it planned for, 768 MB assumed per worker -> 4
+ $scheduler = self::createScheduler(1, Scheduler::AUTO, 1, self::memoryOf(4 * 1024));
+ $schedule = $scheduler->scheduleWork(32, array_fill(0, 200, 'file.php'), static fn (string $file): int => 0);
+
+ $this->assertSame(4, $schedule->getNumberOfProcesses());
+ }
+
+ public function testAutoAlwaysAllowsOneProcessOnATinyMachine(): void
+ {
+ $scheduler = self::createScheduler(1, Scheduler::AUTO, 1, self::memoryOf(128));
+ $schedule = $scheduler->scheduleWork(8, array_fill(0, 200, 'file.php'), static fn (string $file): int => 0);
+
+ $this->assertSame(1, $schedule->getNumberOfProcesses());
+ }
+
+ public function testAutoFallsBackToTheFixedLimitWhenMemoryIsUnknown(): void
+ {
+ // an empty fixture tree has no /proc/meminfo and no cgroup files
+ $scheduler = self::createScheduler(1, Scheduler::AUTO, 1, new SystemResources(sys_get_temp_dir() . '/phpstan-scheduler-nonexistent'));
+ $schedule = $scheduler->scheduleWork(32, array_fill(0, 200, 'file.php'), static fn (string $file): int => 0);
+
+ $this->assertSame(8, $schedule->getNumberOfProcesses());
+ }
+
+ public function testAnExplicitLimitIgnoresAvailableMemory(): void
+ {
+ $scheduler = self::createScheduler(1, 20, 1, self::memoryOf(1024));
+ $schedule = $scheduler->scheduleWork(32, array_fill(0, 200, 'file.php'), static fn (string $file): int => 0);
+
+ $this->assertSame(20, $schedule->getNumberOfProcesses());
+ }
+
+ /**
+ * A SystemResources that reports the given number of megabytes as available, via
+ * a cgroup v2 fixture tree.
+ */
+ private static function memoryOf(int $megabytes): SystemResources
+ {
+ $root = sys_get_temp_dir() . '/phpstan-scheduler-' . uniqid();
+ mkdir($root . '/proc/self', 0777, true);
+ mkdir($root . '/sys/fs/cgroup/limited', 0777, true);
+ FileWriter::write($root . '/proc/self/cgroup', "0::/limited\n");
+ FileWriter::write($root . '/sys/fs/cgroup/limited/memory.max', ($megabytes * 1024 * 1024) . "\n");
+ FileWriter::write($root . '/sys/fs/cgroup/limited/memory.current', "0\n");
+
+ return new SystemResources($root);
+ }
+
+ /**
+ * @param positive-int $jobSize
+ * @param positive-int|Scheduler::AUTO $maximumNumberOfProcesses
+ * @param positive-int $minimumNumberOfJobsPerProcess
+ */
+ private static function createScheduler(
+ int $jobSize,
+ int|string $maximumNumberOfProcesses,
+ int $minimumNumberOfJobsPerProcess,
+ ?SystemResources $systemResources = null,
+ ): Scheduler
+ {
+ return new Scheduler(
+ $jobSize,
+ $maximumNumberOfProcesses,
+ $minimumNumberOfJobsPerProcess,
+ $systemResources ?? new SystemResources(),
+ new WorkerMemoryBudget(),
+ );
+ }
+
}
diff --git a/tests/PHPStan/Parallel/WorkerMemoryBudgetTest.php b/tests/PHPStan/Parallel/WorkerMemoryBudgetTest.php
new file mode 100644
index 00000000000..b868af2f856
--- /dev/null
+++ b/tests/PHPStan/Parallel/WorkerMemoryBudgetTest.php
@@ -0,0 +1,34 @@
+assertSame(8, $budget->getAffordableWorkerCount(8192 * self::MB));
+ $this->assertSame(4, $budget->getAffordableWorkerCount(4096 * self::MB));
+ $this->assertSame(20, $budget->getAffordableWorkerCount(20480 * self::MB));
+ }
+
+ public function testIsNeverZero(): void
+ {
+ // A machine too small for even one worker of the assumed size still gets one:
+ // refusing to analyse is not an option, and a single worker is what a serial run
+ // would have used anyway.
+ $budget = new WorkerMemoryBudget();
+
+ $this->assertSame(1, $budget->getAffordableWorkerCount(512 * self::MB));
+ $this->assertSame(1, $budget->getAffordableWorkerCount(0));
+ $this->assertSame(1, $budget->getAffordableWorkerCount(-1));
+ }
+
+}
diff --git a/tests/PHPStan/Process/SystemResourcesTest.php b/tests/PHPStan/Process/SystemResourcesTest.php
new file mode 100644
index 00000000000..9b3dcffb037
--- /dev/null
+++ b/tests/PHPStan/Process/SystemResourcesTest.php
@@ -0,0 +1,273 @@
+ */
+ private array $roots = [];
+
+ #[Override]
+ protected function tearDown(): void
+ {
+ foreach ($this->roots as $root) {
+ self::removeDirectory($root);
+ }
+
+ $this->roots = [];
+ }
+
+ /**
+ * @return iterable, int|null}>
+ */
+ public static function dataCpuQuota(): iterable
+ {
+ yield 'v2, quota on the leaf' => [
+ [
+ '/proc/self/cgroup' => "0::/foo\n",
+ '/sys/fs/cgroup/foo/cpu.max' => "200000 100000\n",
+ ],
+ 2,
+ ];
+
+ yield 'v2, quota only on an ancestor' => [
+ [
+ '/proc/self/cgroup' => "0::/foo/bar\n",
+ '/sys/fs/cgroup/foo/cpu.max' => "200000 100000\n",
+ ],
+ 2,
+ ];
+
+ yield 'v2, nested with a tighter ancestor' => [
+ [
+ '/proc/self/cgroup' => "0::/foo/bar\n",
+ '/sys/fs/cgroup/foo/cpu.max' => "100000 100000\n",
+ '/sys/fs/cgroup/foo/bar/cpu.max' => "400000 100000\n",
+ ],
+ 1,
+ ];
+
+ yield 'v2, unlimited' => [
+ [
+ '/proc/self/cgroup' => "0::/foo\n",
+ '/sys/fs/cgroup/foo/cpu.max' => "max 100000\n",
+ ],
+ null,
+ ];
+
+ yield 'v2, cpu controller not enabled at any level' => [
+ [
+ '/proc/self/cgroup' => "0::/foo\n",
+ '/sys/fs/cgroup/foo/memory.max' => "4294967296\n",
+ ],
+ null,
+ ];
+
+ yield 'v2, quota is not a whole number of cores' => [
+ [
+ '/proc/self/cgroup' => "0::/foo\n",
+ '/sys/fs/cgroup/foo/cpu.max' => "250000 100000\n",
+ ],
+ 3,
+ ];
+
+ yield 'v2, quota below a single core' => [
+ [
+ '/proc/self/cgroup' => "0::/foo\n",
+ '/sys/fs/cgroup/foo/cpu.max' => "50000 100000\n",
+ ],
+ 1,
+ ];
+
+ yield 'v2, malformed' => [
+ [
+ '/proc/self/cgroup' => "0::/foo\n",
+ '/sys/fs/cgroup/foo/cpu.max' => "garbage\n",
+ ],
+ null,
+ ];
+
+ yield 'v1, quota' => [
+ [
+ '/proc/self/cgroup' => "4:cpu,cpuacct:/foo\n",
+ '/sys/fs/cgroup/cpu/foo/cpu.cfs_quota_us' => "200000\n",
+ '/sys/fs/cgroup/cpu/foo/cpu.cfs_period_us' => "100000\n",
+ ],
+ 2,
+ ];
+
+ yield 'v1, unlimited' => [
+ [
+ '/proc/self/cgroup' => "4:cpu,cpuacct:/foo\n",
+ '/sys/fs/cgroup/cpu/foo/cpu.cfs_quota_us' => "-1\n",
+ '/sys/fs/cgroup/cpu/foo/cpu.cfs_period_us' => "100000\n",
+ ],
+ null,
+ ];
+
+ yield 'v1, controller mounted as cpu,cpuacct' => [
+ [
+ '/proc/self/cgroup' => "4:cpu,cpuacct:/foo\n",
+ '/sys/fs/cgroup/cpu,cpuacct/foo/cpu.cfs_quota_us' => "400000\n",
+ '/sys/fs/cgroup/cpu,cpuacct/foo/cpu.cfs_period_us' => "100000\n",
+ ],
+ 4,
+ ];
+
+ yield 'no cgroup filesystem at all' => [[], null];
+
+ yield 'in the root cgroup, which has no cpu.max' => [
+ ['/proc/self/cgroup' => "0::/\n"],
+ null,
+ ];
+ }
+
+ /**
+ * @param array $files
+ */
+ #[DataProvider('dataCpuQuota')]
+ public function testGetCpuQuota(array $files, ?int $expectedQuota): void
+ {
+ $resources = new SystemResources($this->createFixtureRoot($files));
+
+ $this->assertSame($expectedQuota, $resources->getCpuQuota());
+ }
+
+ /**
+ * @return iterable, int|null}>
+ */
+ public static function dataAvailableMemory(): iterable
+ {
+ yield 'v2 limit minus current usage' => [
+ [
+ '/proc/self/cgroup' => "0::/foo\n",
+ '/sys/fs/cgroup/foo/memory.max' => "4294967296\n",
+ '/sys/fs/cgroup/foo/memory.current' => "1073741824\n",
+ ],
+ 3 * 1024 * 1024 * 1024,
+ ];
+
+ yield 'v2 unlimited falls through to the host' => [
+ [
+ '/proc/self/cgroup' => "0::/foo\n",
+ '/sys/fs/cgroup/foo/memory.max' => "max\n",
+ '/proc/meminfo' => "MemTotal: 63379648 kB\nMemAvailable: 1048576 kB\n",
+ ],
+ 1024 * 1024 * 1024,
+ ];
+
+ yield 'the tighter of cgroup and host wins' => [
+ [
+ '/proc/self/cgroup' => "0::/foo\n",
+ '/sys/fs/cgroup/foo/memory.max' => "4294967296\n",
+ '/sys/fs/cgroup/foo/memory.current' => "0\n",
+ '/proc/meminfo' => "MemAvailable: 1048576 kB\n",
+ ],
+ 1024 * 1024 * 1024,
+ ];
+
+ yield 'v1 sentinel limit is not a limit' => [
+ [
+ '/proc/self/cgroup' => "9:memory:/foo\n",
+ '/sys/fs/cgroup/memory/foo/memory.limit_in_bytes' => "9223372036854771712\n",
+ '/proc/meminfo' => "MemAvailable: 2097152 kB\n",
+ ],
+ 2 * 1024 * 1024 * 1024,
+ ];
+
+ yield 'v1 real limit' => [
+ [
+ '/proc/self/cgroup' => "9:memory:/foo\n",
+ '/sys/fs/cgroup/memory/foo/memory.limit_in_bytes' => "2147483648\n",
+ '/sys/fs/cgroup/memory/foo/memory.usage_in_bytes' => "1073741824\n",
+ ],
+ 1024 * 1024 * 1024,
+ ];
+
+ yield 'nothing knowable' => [[], null];
+ }
+
+ /**
+ * @param array $files
+ */
+ #[DataProvider('dataAvailableMemory')]
+ public function testGetAvailableMemoryBytes(array $files, ?int $expectedBytes): void
+ {
+ $resources = new SystemResources($this->createFixtureRoot($files));
+
+ $this->assertSame($expectedBytes, $resources->getAvailableMemoryBytes());
+ }
+
+ public function testUsableCoresNeverExceedDetectedCoresOnThisMachine(): void
+ {
+ // the important regression: whatever this machine turns out to be, applying
+ // its quota must narrow the core count rather than invent capacity or
+ // collapse to zero - a probe that guesses low would throttle every run
+ $counter = new CpuCoreCounter(null, new SystemResources());
+
+ $this->assertGreaterThanOrEqual(1, $counter->getNumberOfCpuCores());
+ $this->assertLessThanOrEqual($counter->getDetectedNumberOfCpuCores(), $counter->getNumberOfCpuCores());
+ }
+
+ /**
+ * @param array $files
+ */
+ private function createFixtureRoot(array $files): string
+ {
+ $root = sys_get_temp_dir() . '/phpstan-system-resources-' . uniqid();
+ $this->roots[] = $root;
+
+ foreach ($files as $path => $contents) {
+ $fullPath = $root . $path;
+ $directory = dirname($fullPath);
+ if (!is_dir($directory)) {
+ mkdir($directory, 0777, true);
+ }
+
+ FileWriter::write($fullPath, $contents);
+ }
+
+ if (!is_dir($root)) {
+ mkdir($root, 0777, true);
+ }
+
+ return $root;
+ }
+
+ private static function removeDirectory(string $directory): void
+ {
+ if (!is_dir($directory)) {
+ return;
+ }
+
+ foreach (scandir($directory) ?: [] as $entry) {
+ if ($entry === '.' || $entry === '..') {
+ continue;
+ }
+
+ $path = $directory . '/' . $entry;
+ if (is_dir($path)) {
+ self::removeDirectory($path);
+ } else {
+ unlink($path);
+ }
+ }
+
+ rmdir($directory);
+ }
+
+}