From c1e6d951c2011458c0feeb184e160ce24567b5e5 Mon Sep 17 00:00:00 2001 From: Anders Jenbo Date: Mon, 24 Aug 2026 17:30:19 +0200 Subject: [PATCH] Implement worker auto-scaler --- conf/config.neon | 2 +- conf/parametersSchema.neon | 2 +- .../SystemResourcesDiagnoseExtension.php | 47 +++ src/Parallel/Scheduler.php | 67 +++- src/Parallel/WorkerMemoryBudget.php | 58 +++ src/Process/CpuCoreCounter.php | 34 +- src/Process/SystemResources.php | 379 ++++++++++++++++++ tests/PHPStan/Parallel/SchedulerTest.php | 92 ++++- .../Parallel/WorkerMemoryBudgetTest.php | 34 ++ tests/PHPStan/Process/SystemResourcesTest.php | 273 +++++++++++++ 10 files changed, 973 insertions(+), 15 deletions(-) create mode 100644 src/Diagnose/SystemResourcesDiagnoseExtension.php create mode 100644 src/Parallel/WorkerMemoryBudget.php create mode 100644 src/Process/SystemResources.php create mode 100644 tests/PHPStan/Parallel/WorkerMemoryBudgetTest.php create mode 100644 tests/PHPStan/Process/SystemResourcesTest.php 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); + } + +}