Skip to content

Commit 3788169

Browse files
ondrejmirtesclaude
andcommitted
Strip run-time type checks from PHPStan's own code with a turbo optimizer pass
PHPStan's code is verified by PHPStan at its strictest level, so the engine's argument and return type checks on it re-check what analysis already proved — at a measurable price: a class-typed parameter costs a class-entry lookup plus an instanceof on every call (ZEND_RECV), a typed return the same on the way out (ZEND_VERIFY_RETURN_TYPE). A php-src build that skipped both for PHPStan's files put the ceiling at -8.3% user CPU on the self-analysis. The extension now registers an OPcache optimizer pass (zend_optimizer_register_pass, resolved by name so the extension keeps loading without OPcache). Registered passes run at the end of zend_optimize_script(), before the script is persisted, so what shared memory holds is the stripped code. For every function of a script compiled from the running phar (the prefix TurboExtensionEnabler hands over): ZEND_ACC_HAS_TYPE_HINTS is cleared — the engine then skips the RECV opcodes of the passed arguments altogether and RECV_INIT stops verifying, while reflection and inheritance checks keep reading arg_info — and VERIFY_RETURN_TYPE on a variable or temporary becomes a NOP. Signatures that can coerce (float without int), typed variadics (RECV_VARIADIC reads arg_info directly), by-reference returns, the implicit missing-return check and typed property writes are left as they are. Nothing outside the phar is touched, and since a check sits in the callee, extensions, bootstrap files and the analysed project keep checking what PHPStan hands them. What goes is the TypeError at the boundary when such code passes PHPStan a wrong value; it surfaces later instead. A --debug run therefore keeps the checks — the "run with --debug" advice on internal errors then yields the original error — and PHPUnit never reaches the switch, so test suites always run fully checked. The restarted process pins opcache.optimization_level to PHP's default because the pass lives inside the optimizer. Slevomat, full project, distributed phar (restart -> OPcache -> turbo -> forked workers), pass on vs off, interleaved: 612.8/634.7s vs 645.4/670.3s user CPU (-5.2%), identical output. The phar run gains less than the ceiling because turbo already runs the hottest classes natively and the variadic checks stay. Co-Authored-By: Claude Fable 5 <[email protected]> Claude-Session: https://claude.ai/code/session_01MJ5dqQJ8BgjZjK28Zj9WBL
1 parent b293f3c commit 3788169

15 files changed

Lines changed: 628 additions & 3 deletions

File tree

.github/workflows/phar.yml

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -303,6 +303,9 @@ jobs:
303303
- name: "Forked-worker exit (exitImmediately() ends a child whose teardown would wedge)"
304304
run: php -d extension="$RUNNER_TEMP/turbo-ext/modules/phpstan_turbo.so" turbo-ext/tests/exit-immediately.php
305305

306+
- name: "Trusted types (the optimizer pass drops type checks under the prefix only)"
307+
run: php -d extension="$RUNNER_TEMP/turbo-ext/modules/phpstan_turbo.so" -d opcache.enable_cli=1 turbo-ext/tests/trusted-types.php
308+
306309
- name: "Signature parity (reflect native classes against the PHP twins)"
307310
run: php -d extension="$RUNNER_TEMP/turbo-ext/modules/phpstan_turbo.so" turbo-ext/tests/signature-parity.php
308311

@@ -494,6 +497,9 @@ jobs:
494497
- name: "Forked-worker exit (exitImmediately() ends a child whose teardown would wedge)"
495498
run: php -d extension="$PWD/turbo-ext/phpstan_turbo.so" turbo-ext/tests/exit-immediately.php
496499

500+
- name: "Trusted types (the optimizer pass drops type checks under the prefix only)"
501+
run: php -d extension="$PWD/turbo-ext/phpstan_turbo.so" -d opcache.enable_cli=1 turbo-ext/tests/trusted-types.php
502+
497503
- name: "Signature parity (reflect native classes against the PHP twins)"
498504
run: php -d extension="$PWD/turbo-ext/phpstan_turbo.so" turbo-ext/tests/signature-parity.php
499505

@@ -607,6 +613,9 @@ jobs:
607613
- name: "Forked-worker exit (exitImmediately() ends a child whose teardown would wedge)"
608614
run: docker exec alpine-build php -d extension=/work/turbo-ext/phpstan_turbo.so turbo-ext/tests/exit-immediately.php
609615

616+
- name: "Trusted types (the optimizer pass drops type checks under the prefix only)"
617+
run: docker exec alpine-build php -d extension=/work/turbo-ext/phpstan_turbo.so -d opcache.enable_cli=1 turbo-ext/tests/trusted-types.php
618+
610619
- name: "Signature parity (reflect native classes against the PHP twins)"
611620
run: docker exec alpine-build php -d extension=/work/turbo-ext/phpstan_turbo.so turbo-ext/tests/signature-parity.php
612621

@@ -948,6 +957,10 @@ jobs:
948957
shell: bash
949958
run: php -d extension="$TURBO_DLL" turbo-ext/tests/exit-immediately.php
950959

960+
- name: "Trusted types (the optimizer pass drops type checks under the prefix only)"
961+
shell: bash
962+
run: php -d extension="$TURBO_DLL" -d opcache.enable_cli=1 turbo-ext/tests/trusted-types.php
963+
951964
- name: "Signature parity (reflect native classes against the PHP twins)"
952965
shell: bash
953966
run: php -d extension="$TURBO_DLL" turbo-ext/tests/signature-parity.php

bin/phpstan

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ use Symfony\Component\Console\Helper\ProgressBar;
3030
require_once __DIR__ . '/../src/Turbo/TurboProcessRestarter.php';
3131
TurboProcessRestarter::restartIfSuitable($_SERVER['argv']);
3232
TurboExtensionEnabler::enableIfLoaded();
33+
TurboExtensionEnabler::trustOwnTypesIfSuitable($_SERVER['argv']);
3334

3435
$analysisStartTime = microtime(true);
3536

build/phpstan.neon

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -154,6 +154,11 @@ parameters:
154154
identifier: shipmonk.deadMethod
155155
message: '#^Unused PHPStan\\Turbo\\TurboProcessRestarter::restartIfSuitable#'
156156
reportUnmatched: false
157+
-
158+
# called from bin/phpstan before the autoloader, outside the analysed paths
159+
identifier: shipmonk.deadMethod
160+
message: '#^Unused PHPStan\\Turbo\\TurboExtensionEnabler::trustOwnTypesIfSuitable#'
161+
reportUnmatched: false
157162
-
158163
# the turbo extension shadows this seam class; the nullable returns
159164
# are the contract of the pair, the PHP twin honestly never hits

src/Turbo/TurboDiagnoseExtension.php

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
namespace PHPStan\Turbo;
44

5+
use Phar;
56
use PHPStan\Command\Output;
67
use PHPStan\DependencyInjection\AutowiredService;
78
use PHPStan\Diagnose\DiagnoseExtension;
@@ -64,9 +65,28 @@ public function print(Output $output): void
6465
'<info>Turbo worker binary:</info> %s',
6566
$workerBinaryLine,
6667
));
68+
$output->writeLineFormatted(sprintf(
69+
'<info>Turbo trusted types:</info> %s',
70+
$this->describeTrustedTypes(),
71+
));
6772
$output->writeLineFormatted('');
6873
}
6974

75+
private function describeTrustedTypes(): string
76+
{
77+
if (TurboExtensionEnabler::isTrustingOwnTypes()) {
78+
return 'on (PHPStan\'s own argument and return type checks are dropped; --debug keeps them)';
79+
}
80+
if (!TurboExtensionEnabler::isActive()) {
81+
return 'off (extension inactive)';
82+
}
83+
if (Phar::running(false) === '') {
84+
return 'off (not running from a phar)';
85+
}
86+
87+
return 'off (--debug, or OPcache is not active)';
88+
}
89+
7090
private function describeStatus(?string $workerBinary): string
7191
{
7292
if (!TurboExtensionEnabler::isLoaded()) {

src/Turbo/TurboExtensionEnabler.php

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
use function dirname;
99
use function extension_loaded;
1010
use function file_get_contents;
11+
use function in_array;
1112
use function is_file;
1213
use function json_decode;
1314
use function phpversion;
@@ -27,6 +28,8 @@ final class TurboExtensionEnabler
2728

2829
private static bool $enabled = false;
2930

31+
private static bool $trustingOwnTypes = false;
32+
3033
public static function isLoaded(): bool
3134
{
3235
return extension_loaded('phpstan_turbo');
@@ -159,4 +162,57 @@ public static function enableIfLoaded(): void
159162
self::$enabled = true;
160163
}
161164

165+
/**
166+
* Whether the extension drops the engine's argument and return type
167+
* checks from PHPStan's own code in this process, see
168+
* trustOwnTypesIfSuitable().
169+
*/
170+
public static function isTrustingOwnTypes(): bool
171+
{
172+
return self::$trustingOwnTypes;
173+
}
174+
175+
/**
176+
* PHPStan's code is verified by PHPStan itself at the strictest level, so
177+
* the engine's run-time checks of its parameter and return types re-check
178+
* what analysis already proved — at about 8% of the analysis CPU: a
179+
* class-typed parameter costs a class lookup and an instanceof on every
180+
* call, a typed return the same on the way out. With the extension active
181+
* and PHPStan running from a phar, its optimizer pass (TrustedTypes.cpp)
182+
* drops those checks from the code compiled out of the phar. Nothing else
183+
* is touched: extensions, bootstrap files and the analysed project keep
184+
* their checks, including on what they receive from PHPStan and return to
185+
* it — a check sits in the callee.
186+
*
187+
* What is lost is the TypeError at the boundary when such code passes a
188+
* wrong value into PHPStan: it surfaces later, deeper. That is why --debug
189+
* keeps the checks — the "run with --debug" advice on internal errors then
190+
* yields the original error. PHPUnit never gets here, so the test suites
191+
* of PHPStan and of extensions always run fully checked.
192+
*
193+
* Must run right after enableIfLoaded(), before the Composer autoloader
194+
* and preload.php are compiled: the pass rewrites scripts as they are
195+
* compiled, so whatever was compiled earlier keeps its checks.
196+
*
197+
* @param list<string> $argv
198+
*/
199+
public static function trustOwnTypesIfSuitable(array $argv): void
200+
{
201+
if (!self::$enabled) {
202+
return;
203+
}
204+
if (in_array('--debug', $argv, true)) {
205+
return;
206+
}
207+
if (!class_exists('Phar', false)) {
208+
return;
209+
}
210+
$pharPath = Phar::running(false);
211+
if ($pharPath === '') {
212+
return;
213+
}
214+
215+
self::$trustingOwnTypes = Runtime::trustTypesUnder('phar://' . $pharPath . '/');
216+
}
217+
162218
}

src/Turbo/TurboProcessRestarter.php

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,9 @@ final class TurboProcessRestarter
6262

6363
private const OPCACHE_MAX_ACCELERATED_FILES_LIMIT = 20000;
6464

65+
/** PHP's default opcache.optimization_level, pinned so the optimizer (and the extension's pass in it) always runs */
66+
private const OPCACHE_OPTIMIZATION_LEVEL = '0x7FFEBFFF';
67+
6568
/** The php.ini directives resolveOpcacheArgs() reacts to */
6669
private const OPCACHE_INI_INPUTS = [
6770
'opcache.file_cache_only',
@@ -296,6 +299,10 @@ private static function getOpcacheArgs(): array
296299
* - opcache.save_comments is pinned on: stripping doc comments (a common
297300
* web tuning) breaks annotation readers in the project code the
298301
* extensions bootstrap, which worked with OPcache dormant.
302+
* - opcache.optimization_level is pinned to PHP's default: the
303+
* extension's pass dropping PHPStan's own type checks
304+
* (TurboExtensionEnabler::trustOwnTypesIfSuitable()) runs inside the
305+
* optimizer, which a php.ini can switch off entirely.
299306
*
300307
* Two configurations are left alone entirely — no OPcache entries at all,
301308
* so the restarted process runs with what the php.ini says, as before:
@@ -336,6 +343,7 @@ public static function resolveOpcacheArgs(array $ini): array
336343
'opcache.max_file_size=0',
337344
'opcache.file_cache=',
338345
'opcache.save_comments=1',
346+
'opcache.optimization_level=' . self::OPCACHE_OPTIMIZATION_LEVEL,
339347
'opcache.memory_consumption=' . $memory,
340348
'opcache.interned_strings_buffer=' . $internedStrings,
341349
'opcache.max_accelerated_files=' . $files,

tests/PHPStan/Turbo/TurboProcessRestarterTest.php

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ final class TurboProcessRestarterTest extends PHPStanTestCase
2020
'opcache.max_file_size=0',
2121
'opcache.file_cache=',
2222
'opcache.save_comments=1',
23+
'opcache.optimization_level=0x7FFEBFFF',
2324
'opcache.memory_consumption=256',
2425
'opcache.interned_strings_buffer=64',
2526
'opcache.max_accelerated_files=20000',
@@ -101,9 +102,9 @@ public function testResolveOpcacheArgs(array $ini, array $expected): void
101102
private static function withSizes(int $memory, int $interned, int $files): array
102103
{
103104
$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;
105+
$args[10] = 'opcache.memory_consumption=' . $memory;
106+
$args[11] = 'opcache.interned_strings_buffer=' . $interned;
107+
$args[12] = 'opcache.max_accelerated_files=' . $files;
107108

108109
return $args;
109110
}

turbo-ext/README.md

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -88,6 +88,20 @@ their threads, so PHP's full teardown can wedge in a fork-unsafe extension's
8888
module shutdown (ext-grpc without `grpc.enable_fork_support`); a forked child
8989
that does not exec() must `_exit()` instead.
9090

91+
`Runtime::trustTypesUnder()` is the one entry point that changes how PHPStan's
92+
own code runs rather than replacing it: it arms an opcache optimizer pass
93+
(`TrustedTypes.cpp`) that drops the engine's argument and return type checks
94+
from scripts compiled under the given prefix — the running phar, passed by
95+
`TurboExtensionEnabler::trustOwnTypesIfSuitable()`. PHPStan's code is verified
96+
by PHPStan, so those checks re-check what analysis proved, at about 8% of the
97+
analysis CPU. Nothing outside the prefix is touched, and a check sits in the
98+
callee, so extensions and bootstrapped code keep checking what PHPStan hands
99+
them; what goes is the TypeError at the boundary when *they* pass PHPStan a
100+
wrong value — a `--debug` run keeps the checks for exactly that. Float-coercing
101+
signatures, typed variadics and typed property writes stay checked
102+
(`TrustedTypes.cpp` explains each); `tests/trusted-types.php` pins the
103+
behaviour.
104+
91105
The extension is version-pinned (`TurboExtensionEnabler::EXPECTED_EXTENSION_VERSION`);
92106
a mismatched extension is ignored. There is no runtime kill switch — the only
93107
way to run without it is not to load it.

turbo-ext/analysis-stub.php

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,10 @@ public static function enablePharForkGuard(string $pharPath): void
2323
{
2424
}
2525

26+
public static function trustTypesUnder(string $prefix): bool
27+
{
28+
}
29+
2630
public static function exitImmediately(): never
2731
{
2832
}

0 commit comments

Comments
 (0)