Skip to content

Commit 80324fe

Browse files
ondrejmirtesclaude
andcommitted
Keep the restarted process's OPcache private and adapt it to the php.ini
A php.ini tuned for the web server can turn the OPcache activation into something worse than a slowdown: - opcache.file_cache would persist this run's opcodes and, being validated by build id and mtime only (and the mtime checks are off here), serve the previous PHPStan's opcodes after an update at the same phar path — it is blanked. - opcache.save_comments=0 strips the doc comments annotation readers in the bootstrapped project code depend on — pinned on. - opcache.max_file_size also triggers the mtime read that refuses zero-mtime phar members — pinned to 0. - opcache.file_cache_only makes blanking the file cache a fatal startup error and usually means shared memory is unavailable on purpose; opcache.preload would run the application's preload script inside PHPStan (there is no CLI exemption) and rejects an empty -d value. Both leave the configuration untouched, as before this feature. - Buffer sizes the php.ini already grants are never lowered, and the interned strings buffer stays below the memory it is carved out of (a fatal startup error otherwise). The raised buffers are not tuning: with the stock 128M/8M the interned strings run out during PHPStan's own boot, the lazily loaded classes end up uncached and uninterned, and the whole gain is gone (slevomat app/model: 24.8s user against 23.7s with OPcache off and 23.5s with the buffers raised). Full slevomat with 256M/64M: 613s user, 678 MB largest forked worker, against 684s / 833 MB with OPcache off. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Ek2A83bWb3Yf4CRmtvBfi4
1 parent 4206933 commit 80324fe

2 files changed

Lines changed: 224 additions & 30 deletions

File tree

‎src/Turbo/TurboProcessRestarter.php‎

Lines changed: 113 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
use function get_cfg_var;
88
use function ini_get;
99
use function is_string;
10+
use function max;
1011
use function pcntl_exec;
1112
use function php_ini_loaded_file;
1213
use const PHP_BINARY;
@@ -28,7 +29,7 @@
2829
*
2930
* The restarted process also gets OPcache activated — with JIT pinned off,
3031
* whatever the user's ini says — so forked workers share the parent's warm
31-
* opcode cache (see getOpcacheArgs()).
32+
* opcode cache (see resolveOpcacheArgs()).
3233
*
3334
* The restart carries a marker ini entry with the extension path. It doubles
3435
* as the retry stop (a binary that failed to load leaves the extension
@@ -41,14 +42,23 @@ final class TurboProcessRestarter
4142

4243
public const EXTENSION_PATH_INI = 'phpstan.turboExtensionPath';
4344

44-
/** Shared memory reserved (not touched) for the opcode cache, in MB — see getOpcacheArgs() for the sizing */
45+
/** Shared memory reserved (not touched) for the opcode cache, in MB — see resolveOpcacheArgs() for the sizing */
4546
private const OPCACHE_MEMORY_CONSUMPTION_MB_LIMIT = 256;
4647

4748
/** Carved out of the memory above for interned strings, in MB */
4849
private const OPCACHE_INTERNED_STRINGS_BUFFER_MB_LIMIT = 64;
4950

5051
private const OPCACHE_MAX_ACCELERATED_FILES_LIMIT = 20000;
5152

53+
/** The php.ini directives resolveOpcacheArgs() reacts to */
54+
private const OPCACHE_INI_INPUTS = [
55+
'opcache.file_cache_only',
56+
'opcache.preload',
57+
'opcache.memory_consumption',
58+
'opcache.interned_strings_buffer',
59+
'opcache.max_accelerated_files',
60+
];
61+
5262
/**
5363
* The extension path this process was restarted with, null when the
5464
* process was not restarted.
@@ -120,7 +130,35 @@ public static function restartIfSuitable(array $argv): void
120130
}
121131

122132
/**
123-
* OPcache directives for the restarted process, as `name=value` ini entries.
133+
* `-d` entries activating OPcache for the restarted process — see
134+
* resolveOpcacheArgs() for the reasoning behind each of them.
135+
*
136+
* Nothing is added when OPcache is not loaded at all — real on PHP <= 8.4,
137+
* gone on 8.5+ (always built in and loaded). Loading it from here is not
138+
* worth it: -d zend_extension=opcache emits a startup warning on builds
139+
* without the shared object, and on 8.5+ always.
140+
*
141+
* @return list<string>
142+
*/
143+
private static function getOpcacheArgs(): array
144+
{
145+
if (!extension_loaded('Zend OPcache')) {
146+
return [];
147+
}
148+
149+
$ini = [];
150+
foreach (self::OPCACHE_INI_INPUTS as $name) {
151+
$ini[$name] = ini_get($name);
152+
}
153+
154+
return self::resolveOpcacheArgs($ini);
155+
}
156+
157+
/**
158+
* OPcache directives for the restarted process, as `name=value` ini
159+
* entries, given the current values of the OPCACHE_INI_INPUTS directives
160+
* (the restarted process loads the same php.ini, so they are what it
161+
* would otherwise run with).
124162
*
125163
* The exec is paid for anyway, so it doubles as the chance to activate
126164
* OPcache, usually dormant on CLI (opcache.enable_cli defaults to off):
@@ -144,40 +182,72 @@ public static function restartIfSuitable(array $argv): void
144182
*
145183
* Timestamp checks are switched off, or nothing of PHPStan itself would be
146184
* cached: opcache_compile_file() refuses any file whose mtime it reads as
147-
* 0, and the members of the distributed phar carry exactly that (the
148-
* build normalizes them for reproducibility). It reads the mtime whenever
149-
* opcache.validate_timestamps *or* opcache.file_update_protection is on,
150-
* so both go — for a private cache that dies with the process they
151-
* revalidate nothing anyway, and skipping them also drops a stat() per
152-
* include. The uncached state is what made OPcache *slower* than no
153-
* OPcache for phar runs: code compiled under an active OPcache but not
154-
* persisted never gets its strings interned into SHM, so its type names
155-
* have no class-entry cache slot and every class-typed parameter, return
156-
* and property check falls back to a lowercased-copy class-table lookup
157-
* (measured at +27% CPU on slevomat).
185+
* 0, which is what every member of the distributed phar carried until the
186+
* build started stamping them (phar.yml). It reads the mtime whenever
187+
* opcache.validate_timestamps, opcache.file_update_protection or
188+
* opcache.max_file_size is on, so all three go — for a private cache that
189+
* dies with the process they revalidate nothing anyway, and skipping them
190+
* also drops a stat() per include. The uncached state is what made
191+
* OPcache *slower* than no OPcache for phar runs: code compiled under an
192+
* active OPcache but not persisted never gets its strings interned into
193+
* SHM, so its type names have no class-entry cache slot and every
194+
* class-typed parameter, return and property check falls back to a
195+
* lowercased-copy class-table lookup (measured at +27% CPU on slevomat).
158196
*
159197
* The buffers are raised above the stock 128M/8M/10000 for the same
160198
* reason: once any of them is exhausted, everything compiled afterwards
161-
* lands in that same uncached, uninterned state. PHPStan's own phar needs
162-
* about 35M of code; project bootstraps loaded by extensions (a Doctrine
163-
* objectManagerLoader booting the whole app, say) add hundreds of MB, and
164-
* the shared memory is only reserved, not touched, until used. It is not
165-
* sized for the largest possible project, though — an SHM reservation that
166-
* cannot be satisfied is fatal (exit code 254) in the restarted process,
167-
* with no parent left to fall back to.
199+
* lands in that same uncached, uninterned state — the stock 8M of interned
200+
* strings run out during PHPStan's own boot already, which alone costs the
201+
* whole gain. PHPStan's phar needs about 35M of code; project bootstraps
202+
* loaded by extensions (a Doctrine objectManagerLoader booting the whole
203+
* app, say) add hundreds of MB, and the shared memory is only reserved,
204+
* not touched, until used. It is not sized for the largest possible
205+
* project, though — an SHM reservation that cannot be satisfied is fatal
206+
* (exit code 254) in the restarted process, with no parent left to fall
207+
* back to. Sizes the php.ini already grants are never lowered, and the
208+
* interned strings buffer is kept below the memory it is carved out of
209+
* (another fatal startup error otherwise).
210+
*
211+
* That private cache must neither outlive the process nor reach outside
212+
* it, which is what the remaining entries guard against in a php.ini tuned
213+
* for the web server rather than for us:
214+
* - opcache.file_cache is blanked. A file cache is validated by the PHP
215+
* build id and (only with opcache.validate_timestamps) the mtime — so
216+
* with the checks off it would keep serving the previous PHPStan's
217+
* opcodes after an update, the phar path being the same, and it would
218+
* fill the web server's cache directory with this run's scripts.
219+
* - opcache.save_comments is pinned on: stripping doc comments (a common
220+
* web tuning) breaks annotation readers in the project code the
221+
* extensions bootstrap, which worked with OPcache dormant.
168222
*
169-
* The only case adding nothing is OPcache not being loaded at all — real
170-
* on PHP <= 8.4, gone on 8.5+ (always built in and loaded). Loading it
171-
* from here is not worth it: -d zend_extension=opcache emits a startup
172-
* warning on builds without the shared object, and on 8.5+ always.
223+
* Two configurations are left alone entirely — no OPcache entries at all,
224+
* so the restarted process runs with what the php.ini says, as before:
225+
* - opcache.file_cache_only: blanking the file cache would be a fatal
226+
* startup error, and it usually means shared memory is unavailable on
227+
* that host on purpose.
228+
* - opcache.preload: the application's preload script would run inside
229+
* PHPStan (there is no CLI exemption), and it cannot be blanked with -d
230+
* (the directive rejects an empty value).
173231
*
232+
* @param array<string, string|false> $ini
174233
* @return list<string>
175234
*/
176-
private static function getOpcacheArgs(): array
235+
public static function resolveOpcacheArgs(array $ini): array
177236
{
178-
if (!extension_loaded('Zend OPcache')) {
237+
if (self::isIniOn($ini['opcache.file_cache_only'] ?? false)) {
179238
return [];
180239
}
240+
$preload = $ini['opcache.preload'] ?? false;
241+
if ($preload !== false && $preload !== '') {
242+
return [];
243+
}
244+
245+
$memory = max(self::OPCACHE_MEMORY_CONSUMPTION_MB_LIMIT, self::iniInt($ini['opcache.memory_consumption'] ?? false));
246+
$internedStrings = max(self::OPCACHE_INTERNED_STRINGS_BUFFER_MB_LIMIT, self::iniInt($ini['opcache.interned_strings_buffer'] ?? false));
247+
if ($internedStrings >= $memory) {
248+
$memory = $internedStrings + self::OPCACHE_MEMORY_CONSUMPTION_MB_LIMIT - self::OPCACHE_INTERNED_STRINGS_BUFFER_MB_LIMIT;
249+
}
250+
$files = max(self::OPCACHE_MAX_ACCELERATED_FILES_LIMIT, self::iniInt($ini['opcache.max_accelerated_files'] ?? false));
181251

182252
return [
183253
'opcache.enable=1',
@@ -186,10 +256,23 @@ private static function getOpcacheArgs(): array
186256
'opcache.jit_buffer_size=0',
187257
'opcache.validate_timestamps=0',
188258
'opcache.file_update_protection=0',
189-
'opcache.memory_consumption=' . self::OPCACHE_MEMORY_CONSUMPTION_MB_LIMIT,
190-
'opcache.interned_strings_buffer=' . self::OPCACHE_INTERNED_STRINGS_BUFFER_MB_LIMIT,
191-
'opcache.max_accelerated_files=' . self::OPCACHE_MAX_ACCELERATED_FILES_LIMIT,
259+
'opcache.max_file_size=0',
260+
'opcache.file_cache=',
261+
'opcache.save_comments=1',
262+
'opcache.memory_consumption=' . $memory,
263+
'opcache.interned_strings_buffer=' . $internedStrings,
264+
'opcache.max_accelerated_files=' . $files,
192265
];
193266
}
194267

268+
private static function isIniOn(string|false $value): bool
269+
{
270+
return $value !== false && $value !== '' && $value !== '0';
271+
}
272+
273+
private static function iniInt(string|false $value): int
274+
{
275+
return $value === false ? 0 : (int) $value;
276+
}
277+
195278
}
Lines changed: 111 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,111 @@
1+
<?php declare(strict_types = 1);
2+
3+
namespace PHPStan\Turbo;
4+
5+
use PHPStan\Testing\PHPStanTestCase;
6+
use PHPUnit\Framework\Attributes\DataProvider;
7+
use function array_fill_keys;
8+
use function array_keys;
9+
10+
final class TurboProcessRestarterTest extends PHPStanTestCase
11+
{
12+
13+
private const STOCK_ARGS = [
14+
'opcache.enable=1',
15+
'opcache.enable_cli=1',
16+
'opcache.jit=disable',
17+
'opcache.jit_buffer_size=0',
18+
'opcache.validate_timestamps=0',
19+
'opcache.file_update_protection=0',
20+
'opcache.max_file_size=0',
21+
'opcache.file_cache=',
22+
'opcache.save_comments=1',
23+
'opcache.memory_consumption=256',
24+
'opcache.interned_strings_buffer=64',
25+
'opcache.max_accelerated_files=20000',
26+
];
27+
28+
/**
29+
* @return iterable<string, array{array<string, string|false>, list<string>}>
30+
*/
31+
public static function dataResolveOpcacheArgs(): iterable
32+
{
33+
$stock = [
34+
'opcache.file_cache_only' => '0',
35+
'opcache.preload' => '',
36+
'opcache.memory_consumption' => '128',
37+
'opcache.interned_strings_buffer' => '8',
38+
'opcache.max_accelerated_files' => '10000',
39+
];
40+
41+
yield 'stock php.ini' => [$stock, self::STOCK_ARGS];
42+
43+
yield 'directives unknown (ini_get false)' => [
44+
array_fill_keys(array_keys($stock), false),
45+
self::STOCK_ARGS,
46+
];
47+
48+
// a web-tuned file cache must not persist this run's opcodes: after a
49+
// PHPStan update it would serve the old ones (nothing but the mtime is
50+
// validated, and validation is off here) — the same arguments blank it
51+
yield 'file cache configured' => [
52+
['opcache.file_cache' => '/tmp/opcache'] + $stock,
53+
self::STOCK_ARGS,
54+
];
55+
56+
// blanking the file cache would be a fatal startup error here, and SHM
57+
// may be unavailable on purpose — leave the configuration alone
58+
yield 'file_cache_only' => [
59+
['opcache.file_cache_only' => '1'] + $stock,
60+
[],
61+
];
62+
63+
// would execute the application's preload script inside PHPStan, and
64+
// cannot be blanked with -d (OnUpdateStringUnempty rejects '')
65+
yield 'preload configured' => [
66+
['opcache.preload' => '/var/www/config/preload.php'] + $stock,
67+
[],
68+
];
69+
70+
yield 'larger user sizes are kept' => [
71+
['opcache.memory_consumption' => '512', 'opcache.interned_strings_buffer' => '96', 'opcache.max_accelerated_files' => '100000'] + $stock,
72+
self::withSizes(512, 96, 100000),
73+
];
74+
75+
yield 'smaller user sizes are raised' => [
76+
['opcache.memory_consumption' => '64', 'opcache.interned_strings_buffer' => '4', 'opcache.max_accelerated_files' => '2000'] + $stock,
77+
self::STOCK_ARGS,
78+
];
79+
80+
// the interned strings buffer is carved out of memory_consumption; an
81+
// interned buffer at or above it is a fatal startup error
82+
yield 'user interned buffer larger than the memory' => [
83+
['opcache.memory_consumption' => '128', 'opcache.interned_strings_buffer' => '300'] + $stock,
84+
self::withSizes(492, 300, 20000),
85+
];
86+
}
87+
88+
/**
89+
* @param array<string, string|false> $ini
90+
* @param list<string> $expected
91+
*/
92+
#[DataProvider('dataResolveOpcacheArgs')]
93+
public function testResolveOpcacheArgs(array $ini, array $expected): void
94+
{
95+
$this->assertSame($expected, TurboProcessRestarter::resolveOpcacheArgs($ini));
96+
}
97+
98+
/**
99+
* @return list<string>
100+
*/
101+
private static function withSizes(int $memory, int $interned, int $files): array
102+
{
103+
$args = self::STOCK_ARGS;
104+
$args[9] = 'opcache.memory_consumption=' . $memory;
105+
$args[10] = 'opcache.interned_strings_buffer=' . $interned;
106+
$args[11] = 'opcache.max_accelerated_files=' . $files;
107+
108+
return $args;
109+
}
110+
111+
}

0 commit comments

Comments
 (0)