Search by

phpstan / turbo

ondrejmirtes

Native acceleration extension for PHPStan

Package info

github.com/phpstan/turbo-ext

Homepage

Issues

Language:C++

Type:php-ext

Ext name:ext-phpstan_turbo

pkg:composer/phpstan/turbo

Statistics

Installs: 1 197

Dependents: 0

Suggesters: 0

Stars: 32

2.2.15 2026-09-21 15:54 UTC

This package is auto-updated.

Last update: 2026-09-23 18:55:46 UTC


README

Native PHP extension that reimplements PHPStan's hottest code paths in C++. It is entirely optional: PHPStan behaves identically without it, just slower. With the extension loaded, analysis output is bit-for-bit identical — only faster (~25% on PHPStan's own single-threaded self-analysis).

PHPStan Turbo

Installation

Most users do not need to install anything. The phpstan/phpstan Composer package ships prebuilt binaries for the most common platforms — Linux (glibc and musl, x86_64 and arm64), macOS (arm64), and Windows (x86_64), for PHP 8.3 and newer — and PHPStan automatically loads the one matching your runtime into its worker processes.

Installing the extension with PIE is only needed when you download and run phpstan.phar manually, outside of Composer (the prebuilt binaries ship next to the phar in the Composer package, not inside it):

pie install phpstan/turbo

Useful to know:

  • vendor/bin/phpstan diagnose reports the extension's status.
  • There is no switch to turn it off — an installed, matching extension is always used.
  • The extension only activates when its version matches the one your PHPStan release expects — on a mismatch PHPStan prints a note and runs without it, so an outdated extension can never affect results, only speed.

Developer notes

A plain Zend C++ extension, no framework dependencies.

How it works — shadowing under the real names

Every shadowed piece of PHP code follows the same three steps:

  1. The code is extracted into a dedicated PHP class (plain PHP, this is what runs when the extension is absent) — e.g. PHPStan\Analyser\ScopeOps, PHPStan\Node\NodeScanner, or an existing value class like PHPStan\TrinaryLogic.
  2. The extension implements the same class natively (one class per file in src/), registered through the reg::Class builder with the twin's real name, final flag, parent and interfaces: reg::Class cls("PHPStan\\TrinaryLogic"); cls.final(); … cls.shadow(&pt_ce_trinary);
  3. The PHP class is marked with the #[ShadowedByTurboExtension] attribute. On every composer dump-autoload, build/generate-turbo-manifest.php collects the attributes with runtime reflection into vendor/turbo-shadowed-classes.json — the manifest of shadowed pairs (shadowed classes living in vendor/ cannot carry the attribute and are hardcoded in build/TurboAttributeCollector.php; currently PhpParser\NodeTraverser).

Nothing is registered under PHPStan's names at module startup: reg::Class::shadow() only records a plan. Activation happens in PHP — TurboExtensionEnabler::activateIfCompatible(), called right after the Composer autoloader registers — which checks the extension's version and calls Runtime::activateShadowing() (Shadow.cpp). That declares each plan as a linked user class carrying the twin's real name: built the way the compiler builds one (zend_initialize_class_data, internal method entries, zend_do_link_class()), so the parent and the interfaces are resolved through the autoloader and the class passes the same inheritance and signature checks a PHP declaration gets. PHPStan\TrinaryLogic is the native class; the PHP twin is never loaded, and every reference, instanceof, type hint and DI lookup resolves to it. PHP classes may extend a native class and a native class may extend a PHP one or another native one. The twin's source file is recorded as the class's file, so reflection — and PHPStan's own AutoloadSourceLocator — keeps reading the PHP declaration with its PHPDocs and attributes.

Internal classes could not do this: they are registered at module startup, before any userland parent or interface exists, and PHPStan's classes implement userland interfaces (PHPStan\Type\Type) and extend each other.

The consequences for a port:

  • A native method calling another non-final method of its own class must dispatch through the object's class entry (fast path when the object is of the native class itself), because a PHP subclass may override it.
  • Methods implementing an interface declare their return types (reg.h's returns argument) — the engine checks covariance at link time; parameter types may still be erased (contravariance allows it).
  • The differential tests run the extension the other way round: tests/activate-prefixed.php declares the native classes as PHPStanTurbo\<ShortName> next to the PHP twins in one process, so both sides can be compared. The manifest derives that name the way the extension derives it, and rejects two shadowed classes sharing a short name — they would arrive at one native name. The Type ports are the exception: a Type's results flow into the PHP compound types and back through self-typed statics (IsSuperTypeOfResult::extremeIdentity()), so a native result object meeting the PHP result class is a TypeError. tests/type-family.php therefore observes the whole Type family under the real names — once as the PHP twins, once with the natives activated in their place, exactly as production runs — and smoke.php runs both and requires the two observation sets to be identical.

Class names the native code references at run time come through PHPStanTurbo\Runtime::configure(): TurboExtensionEnabler feeds it the generated vendor/turbo-class-map.php, derived from the #[ReferencedByTurboExtension] attributes (vendored PhpParser classes are hardcoded in the collector), so a renamed class updates the map on the next autoloader dump. Shadowed classes are never referenced that way — the native code holds their class entries itself and instantiates them directly.

Two more Runtime entry points serve fork mode (see ForkParallelChecker): enablePharForkGuard() gives each pcntl_fork()ed worker a private cursor on the running phar's fd (PharForkGuard.cpp), and exitImmediately()_exit() with the engine's exit status — is how ForkedChildTerminator ends a forked worker. A forked child inherits every loaded extension but none of their threads, so PHP's full teardown can wedge in a fork-unsafe extension's module shutdown (ext-grpc without grpc.enable_fork_support); a forked child that does not exec() must _exit() instead.

Runtime::trustTypesUnder() is the one entry point that changes how PHPStan's own code runs rather than replacing it: it arms an opcache optimizer pass (TrustedTypes.cpp) that drops the engine's argument and return type checks from scripts compiled under the given prefix — the running phar, passed by TurboExtensionEnabler::trustOwnTypesIfSuitable() — the running phar, or the source checkout bin/phpstan runs from. PHPStan's code is verified by PHPStan, so those checks re-check what analysis proved, at about 8% of the analysis CPU. Nothing outside the prefix is touched, and a check sits in the callee, so extensions and bootstrapped code keep checking what PHPStan hands them; what goes is the TypeError at the boundary when they pass PHPStan a wrong value — a --debug run keeps the checks for exactly that. Float-coercing signatures, typed variadics and typed property writes stay checked (TrustedTypes.cpp explains each); tests/trusted-types.php pins the behaviour.

The extension is version-pinned (TurboExtensionEnabler::EXPECTED_EXTENSION_VERSION); a mismatched extension never gets activateShadowing() called and the PHP implementations load as usual (Runtime's fork guard and trusted-types pass do not depend on it). There is no runtime kill switch — the only way to run without it is not to load it.

The version is the short SHA of the last commit that touched turbo-ext/src/. The binary's (actual) version is baked in at build time — the Makefile computes it from git over the same path — so only the expected side, TurboExtensionEnabler::EXPECTED_EXTENSION_VERSION, is maintained (by make bump-turbo, which edits and commits it). After changing the native side, verify the implementations still match and run make bump-turbo (recomputed after the change lands on the target branch — a pull request commit gets a new SHA when rebased, and rerunning refreshes an unpushed bump commit in place); the phar.yml turbo-version job enforces the SHA and the compile job verifies the built binary reports what the enabler expects. A PHP-twin-only edit does not move the version — keeping the pair in sync there is on the review and the differential tests, not the version gate. Builds outside the monorepo — the phpstan/turbo-ext subsplit and PIE source builds from it — cannot ask git (the subsplit's replayed commits have different SHAs, tarballs have no checkout at all), so the subsplit workflow generates and commits a VERSION.txt there and the builds fall back to it; the file must never exist in the monorepo. With neither git nor VERSION.txt the version bakes as "dev", which the enabler rejects — the extension then simply stays inactive.

Keeping the two implementations in sync

The manifest of shadowed pairs — each PHP class and the C++ file implementing it natively — is derived from the #[ShadowedByTurboExtension] attributes: the attributed file is the PHP side, the attribute names the native class and the implementing .cpp. Nothing is maintained by hand; build/generate-turbo-manifest.php derives the same map into vendor/turbo-shadowed-classes.json on every composer dump-autoload (for the runtime consumers: the enabler's activation, the phar's preload builder, tests/signature-parity.php), and the vendored PhpParser\NodeTraverser pair, which cannot carry the attribute, is hardcoded in build/TurboAttributeCollector.php — the collection and rendering shared by that script and bin/side-by-side.php. The manifest drives three things:

  • CI method parityphp bin/side-by-side.php (part of the version job, needs vendor/) verifies every public method of each PHP class has a PHP_METHOD counterpart in the C++ file and every PHP_METHOD corresponds to a method of the PHP class. Non-public PHP methods may stay PHP-only (native code inlines them or uses C helpers). It also verifies every class-defining .cpp corresponds 1:1 to the attributes and declares the twin's exact name, and re-derives the two generated vendor/turbo-* files from the attributes and byte-compares them, so a stale autoloader dump (or a hand edit of a generated file) fails.
  • CI signature parityphp tests/signature-parity.php (compile job, needs the built extension and vendor/) reflects each native class against its PHP twin: final flag, parent, interfaces, and per method visibility, staticness, parameter names/optionality/by-ref/variadic, and types. It also verifies each manifest entry points at the file the class actually lives in (and that the vendored flag matches), so a stale autoloader dump fails instead of silently comparing against the wrong source. Native arginfo may erase types to none/object (baking class names into the binary would couple it to userland names, and engine-level type checks cost per call), but what it does declare must match, and parameter names must match exactly — a renamed parameter would break named arguments only in turbo mode.
  • CI version coupling — the version job enforces the expected-version constant against turbo-ext/src/ history (see above), so a native-side edit cannot ship without the explicit bump attesting the pair still matches.

Semantic equivalence is still proven by the differential smoke test and by running the full test suite with the extension loaded — the manifest checks guard structure and force the version bump ritual, not behavior. The smoke test also guards two structural invariants at the real runtime: the generated class map must cover the native class-reference table exactly (Runtime::classRefs()), and every shadowed class must have registered differential coverage (in $covered there, or via its dedicated script).

The native parser engine

src/parser/ reimplements php-parser 5.9.0's LALR engine and node building (PhpParser\ParserAbstract + the generated Parser\Php8), shadowed through the PHPStan\Parser\ParserRunner seam. The parsing tables are read at run time from the first Php8 parser object seen — they are generated data, so nothing is duplicated — and node classes resolve relative to the parser's namespace, so no node class name is baked into the binary. Tokenization stays in PHP's C tokenizer (one Lexer::tokenize() crossing per file); everything after — the shift/reduce loop, all 482 semantic actions, attribute arrays, node construction (direct property-slot writes derived from constructor parameter names; classes with non-trivial constructors call the real PHP constructor), error recovery, and comment annotation — is native. Non-Php8 parsers and non-string inputs fall back to $parser->parse().

Because the input domain is "all PHP source code", method-level parity is not enough here: tests/parser-corpus.php parses thousands of files with both implementations and requires byte-identical serialized ASTs, identical collected errors, and identical token streams. tests/parser-upstream-corpus.php runs the same comparison over every input of php-parser's own test suite at the installed commit (fetched from GitHub, or pass a checkout) — upstream's cases cover every grammar rule, error recovery and version-gated behavior, including syntax PHPStan's own code does not use yet. Both run in CI on every build.

Updating php-parser

The CI version job pins the php-parser version the engine was ported against (SUPPORTED_PHP_PARSER_VERSION in .github/workflows/phar.yml), so a composer.lock bump fails CI until the engine is consciously re-verified:

  1. Diff what is actually ported. Only two vendored files matter: lib/PhpParser/ParserAbstract.php (engine loop + semantic helpers → src/parser/ParserRunner.cpp + ParserRunnerHelpers.cpp) and the reduce closures in lib/PhpParser/Parser/Php8.php (→ ParserRunnerActions{1,2,3}.cpp, generated). The parsing tables need nothing — they are generated data read at run time from the parser object. New node classes also need nothing: classes resolve by name and property plans derive from constructor parameters at run time (only a new constructor with real logic needs the PN_NEW_CTOR treatment — a table in the generator).
  2. Regenerate the reduce actions. ParserRunnerActions{1,2,3}.cpp and ParserRunnerActionsSplit.h (the ParserEngine::reduce dispatch boundaries) are generated by turbo-ext/bin/generate-parser-actions.php from the closures in the vendored Php8.php, so rule renumbering costs nothing. Run it; it fails loudly listing any closure whose body changed upstream (or is new) and has no handling: the transpiler covers the formulaic majority, and hand-ported special cases live in src/parser/action-overrides/<sha1-of-normalized-body>.inc — keyed by content, so unchanged bodies keep matching regardless of their rule number. Port the flagged bodies (usually by updating the corresponding override; the generated cases are the cookbook), re-run until clean. Orphaned override files (their body no longer exists upstream) are reported as warnings — delete them once their replacement is handled. Never hand-edit the generated files: CI regenerates and diffs them.
  3. Verify: strict build, then php turbo-ext/tests/parser-corpus.php and php turbo-ext/tests/parser-upstream-corpus.php until byte-identical over both corpora. The upstream corpus covers the new version's syntax the moment the lock file points at it; the repo corpus only covers it once fixtures using it exist — PHPStan's own test data for the new syntax, or tests/parser-fixtures/; make sure they land before or with the bump. Then the full test suite and make phpstan with the extension loaded, and tests/parser-bench.php to confirm the speedup held.
  4. Bump both pins: SUPPORTED_PHP_PARSER_VERSION in the workflow, and — since src/parser/ changed — the extension version (TurboExtensionEnabler::EXPECTED_EXTENSION_VERSION) per the usual ritual. The version gate is also what protects users: a phar ships a consistent extension/sources/php-parser triple, and a stale extension build simply deactivates instead of parsing with drifted semantics.

The generator itself (bin/generate-parser-actions.php) resolves php-parser constants (Modifiers::*, Stmt\Use_::TYPE_*, ...) under the Composer autoloader at generation time, decides per node class between property-slot writes (PN_NEW) and calling the real PHP constructor (PN_NEW_CTOR) — verifying at generation time that slot-write classes have trivial assignment-only constructors — and fails the build on anything it cannot prove it handles. A brand-new node class with constructor logic shows up as such a failure and needs an entry in the generator's class-policy tables.

The shared-memory arena

src/ArenaCache.cpp (shadowing PHPStan\Cache\ArenaCache, whose PHP twin is a cache that never hits) shares lazily-computed read-mostly data across the parallel worker processes of a single run. The master creates a named shared-memory object (POSIX shm_open / Windows pagefile-backed section) before spawning workers and passes the name via the worker command's --arena option; whichever process first computes a record publishes it, and the arena's physical pages are shared, so N workers stop paying N copies. Lifetime is exactly one run — no persistence, no invalidation: the master unlinks the name once every worker's TCP hello arrived (the mapping stays valid; the kernel reclaims the memory with the last process, even after SIGKILL) and destroys the mapping when the analysis ends. PHPSTAN_ARENA=0 disables just the arena.

Records are flat, offset-based, position-independent blobs of data-only PHP values — nothing in the mapping is ever seen by the GC, so shared pages are never dirtied by refcounting. Publication is lock-free (bump-allocate, write, CAS an index slot from 0 with release ordering); racing publishers of the same key converge on the first writer, wasteful-not-unsafe. Corruption degrades to a miss via bounds checks — the caller recomputes locally, like a worker that never attached.

Consumers: the function signature map (FunctionSignatureMapProvider), published once as a hash record and read per-row so workers stop materializing the multi-megabyte merged map; the per-directory symbol indexes (OptimizedDirectorySourceLocatorFactory), fingerprint-bound and read lazily per name; and — generically — every data-only entry of PHPStan\Cache\Cache, so each var_export'd cache blob is include()d by one process per run instead of every worker (object-carrying payloads stay per-worker: encoding them double-buffers exactly when worker memory peaks). tests/arena-smoke.php is the cross-process differential test.

Building

cd turbo-ext
make          # builds phpstan_turbo.so

or, from the repository root, make build-turbo — the same build, run in parallel over the machine's cores and followed by a check that the binary's baked version is the one TurboExtensionEnabler activates (a build made between a turbo-ext/src commit and its make bump-turbo loads but stays inactive).

The only requirements are a C++17 compiler and php-config on PATH (or passed as make PHP_CONFIG=..., make build-turbo PHP_CONFIG=... from the root). Both NTS and ZTS interpreters are supported — the build inherits thread-safety from the php-config it is pointed at (ZTS hosts like PMMP's bundled PHP get a matching build; PHPStan itself only ever runs the native code single-threaded).

The standard phpize && ./configure && make pipeline works too (config.m4) — it is what PIE drives when it builds the phpstan/turbo package from source, e.g. for combinations without a prebuilt binary. That path bakes the version from VERSION.txt (present only in the phpstan/turbo-ext subsplit, where its workflow commits it — in the monorepo build with make instead), and its ./configure overwrites this directory's Makefile with the generated one (git restore Makefile brings it back; PIE builds in its own extracted copy). The hand-written Makefile stays the primary build: it statically links libstdc++/libgcc on Linux — the distributed binaries must not depend on the build host's GLIBCXX symbol versions — and carries the strict warning setup, neither of which survives the libtool link.

On Windows the extension builds through the standard PHP extension pipeline (config.w32): with a PHP devel pack, php-sdk-binary-tools and a VS2022 x64 developer prompt, run phpize && configure --enable-phpstan-turbo && nmake inside turbo-ext/. The toolset generation matters for distribution: PHP's module loader rejects DLLs linked with a newer MSVC generation than the PHP core, and the official php.net binaries are built with VS2022 (toolset 14.4x) — so CI builds on windows-2022, not windows-latest (whose VS2026 image links with 14.5x). Set the PHPSTANTURBO_VERSION environment variable before configure to bake the version (the Makefile computes it from git automatically; config.w32 reads it from the environment, falling back to VERSION.txt).

Build flags: hardening and size

make applies protections by default, each probed against the compiler in use rather than assumed, because the targets disagree: GCC 11.4 (the CI floor) rejects -ftrivial-auto-var-init, -fcf-protection is x86-only, and -fstack-clash-protection is accepted but silently unused by Apple clang on arm64. What survives the probe on Linux is stack canaries, clash protection, _FORTIFY_SOURCE=3, register clearing on return, array-bounds checks that trap without linking a sanitizer runtime, and a GOT made read-only before control is handed over. make HARDENING_FLAGS= builds without them.

Every flag was measured before adoption, on interleaved A/B pairs judged on user CPU: the protections together came to −0.16% (t=−0.70), and the size flags -fvisibility=hidden and -fno-exceptions -fno-rtti to +0.20% and +0.05% — all inside the noise floor, while the shipped .so shrank ~26%.

Those timings are from macOS, where -fvisibility=hidden changes no machine code at all: Mach-O's two-level namespace already prevents interposition, so only the symbol table shrinks. On ELF it is far from cosmetic — measured in the CI image, it removes 8,866 of 9,907 dynamic symbols and 18.9% of __TEXT, because a default-visibility symbol can be interposed at load time and therefore neither inlined nor garbage-collected. The Linux binaries are where that matters, and where it has not been timed.

-fno-exceptions -fno-rtti binds new code: a throw or a dynamic_cast becomes a compile error. That matches the style rules above — the engine unwinds with longjmp, and the ports neither throw nor use RTTI — but it is a constraint, not merely an optimisation.

Measured and not adopted, so they are not re-tried: thin LTO (+1.76% slower, t=+3.44 — it inlines across translation units and loses more in locality than it gains), -O3 (a wash at +4.63% code), PGO (no longer shows the benefit it was originally landed on, even measured on its own training corpus), and -fstrict-flex-arrays=3, which traps on ordinary string and property access because the engine's public structures use the C struct-hack (zend_string.val[1], zend_object.properties_table[1] behind OBJ_PROP_NUM) — all 29 violation sites in this codebase were that macro expanding, none of them fixable here.

Enabling

Add to php.ini (recommended — parallel worker processes inherit it):

extension=/absolute/path/to/phpstan-src/turbo-ext/phpstan_turbo.so

Code style

The native sources are C++ that mirrors the PHP implementations they replace: each shadowed class is a handle class in namespace phpstanturbo with the twin's methods (see src/TrinaryLogic.cpp for the reference shape), built on the zero-cost wrappers in src/zv.h — borrowed zv::Ref views, owned move-only zv::Val RAII values, range-for HashTable iteration, zv::Args argument packs for engine calls — and the shared bodies in src/TypeTraits.h (a member the ports would otherwise repeat verbatim forwards there). The wrappers compile to the same instructions as the raw zend macros (verified by interleaved A/B benchmark), so readability costs nothing. Classes register through the fluent builder in src/reg.h, which emits the raw zend structures with raw handler pointers — no per-call trampoline or argument boxing; each method's name, flags, signature and parameter-parsing glue live together in one declaration. A method that only parses its parameters and delegates them is declared by its handle member and parameter kinds (cls.method<&UnionType::accepts, zp::Obj, zp::Bool>(...)) and gets a generated handler; other glue parses with zp::parse<...>(). Both expand to the engine's own ZEND_PARSE_PARAMETERS macros, so the handlers compile to what the hand-written glue did. Raw zend form remains where an abstraction would not be provably free — always with a comment saying so.

Linting and sanitizers

make lint-turbo      # clang-tidy over the hand-written sources
make sanitize-turbo  # the differential tests under UndefinedBehaviorSanitizer

lint-turbo runs clang-tidy with the curated check list in .clang-tidy; a finding fails the target, and there is no baseline. The list is curated because this is a Zend extension, and whole check families object to exactly that: the path-sensitive analyzer reads every zval access as an uninitialized union member (the type tag decides which member is live and it does not model that), others object to the engine's do {} while (0) macros, to php.h being an umbrella header, to the handler signatures' unused parameters, or propose linkage changes that measurably change inlining here. Every exclusion in .clang-tidy carries the number of findings it produced when the list was calibrated, so a future reader can re-judge it. Generated sources are not linted at all — a finding in src/generated/*.h or in the parser's action tables could only be fixed in their generator.

sanitize-turbo rebuilds the extension with UndefinedBehaviorSanitizer and runs the differential tests under it (smoke, arena-smoke, signature-parity, parser-corpus). The sanitizer runtime is linked into the .so, so an ordinary interpreter loads it — no debug or instrumented PHP build is needed. It builds from clean and cleans up after itself, because objects must never mix flags. This is the half that covers memory safety: the static checks cannot see zval lifetimes, and the differential tests already exercise the code paths that matter.

Both run in CI on every pull request (.github/workflows/lint.yml). The clang-tidy version is pinned in one place, CLANG_TIDY_VERSION in the Makefile: CI reads that number and installs exactly it (Ubuntu ships an older one, so it comes from apt.llvm.org), and make lint-turbo refuses to run with a different major. Without that, a green CI run and a green local run would be two different check lists rather than the same evidence, and the finding counts recorded in .clang-tidy would hold for neither. Install the pinned version with brew install llvm@<version> or from apt.llvm.org. Moving the pin is deliberate: raise the number, re-run, and fix or exclude what the new checks report — with its count and reason, the way the existing entries carry theirs.

Design rules for new ports

Measured in the July 2026 benchmarks (callback-free absorptions gained 5–8.5% each, callback-dense ones ~1% or nothing):

  1. Cross the PHP/C++ boundary per operation, never per element. Absorb a whole loop into one native call; a native loop invoking a PHP callback per element performs like the PHP loop it replaced.
  2. Fast paths natively, callbacks only on slow paths (pointer-compare before Type::equals(), etc.).
  3. Resolve callables once per site (zend_function pointers cached in plans/caches).
  4. Third-party userland objects degrade per-operation, never per-element.
  5. No materialization at the boundary — operate on the engine's own zvals/hashtables in place. This is also why every class is registered with raw handler pointers: a framework trampoline that boxes each argument per call is exactly the per-element boundary cost these rules forbid. (The extension originally hosted its lifecycle in PHP-CPP; it is a plain Zend module since the Windows port.)
  6. A shadowed DI-service class has to stay autowirable. Nette reflects __construct while it compiles the container, so the native arginfo must declare the real parameter class names — erasing them fails container compilation for every shadowed service at once. (Aliasing a service class is what genuinely cannot work: getByType() normalizes the requested type through reflection to the real class name. A shadowing class carries the original name itself, so that does not apply to it.)
  7. Every port must prove itself: interleaved A/B benchmark on a long run (user CPU, result cache cleared) plus a byte-identical output diff. Ports measuring ≤0.5% get reverted — the failure mode is silent no-gain, and unproven native code is pure maintenance debt.

Performance frontiers (July 2026)

Status quo, measured on PHPStan's own single-threaded self-analysis of src/ (interleaved A/B, user CPU): 59.6s with the extension vs 77.3s without — a 23% gain. The remaining cost is structural, not hotspot-shaped: SPX counts ~535M userland calls spread over 20K functions, the top 120 functions by exclusive time explain only ~15% of the run, and an on-CPU sample attributes 42.7% to VM call mechanics (frame setup, argument passing, return-type checks), 23.3% to other VM opcodes, ~8% each to memory/GC and syscalls — and only 2.3% to this extension's own code. Every further tier therefore means absorbing whole call subtrees, not porting leaf bodies.

What each gain level over the no-extension baseline requires:

  • 30% (−5.5s) — reachable with targeted ports and known PHP-side fixes: a native ExpressionResultStorage (the per-expression result table; its SplObjectStorage copies and inserts allocate ~3GB per run), the CachedParser content-key re-read fix (72K full-file reads per run just to compute LRU keys), getName()/return-type memos in better-reflection (5.3M calls survive), member-lookup pricing (ObjectType::getMethod + getMethodReflection + dynamic-extension registry sweeps, ~1.9M calls), and the FileTypeMapper cache hydration format.
  • 50% (−20.9s) — requires the native expression engine: the NodeScopeResolver expression walk, ExprHandler dispatch loop, ExpressionResult/holder plumbing and scope-table mutation move into C++, crossing back to PHP only for Type-level operations and third-party extensions. MutatingScope::getType alone is ~22% of the run inclusive. Estimated 150–250M absorbed frames ≈ 10–15s; a quarter-rewrite, to be approached one handler chain at a time.
  • 70% (−36.4s) — "everything PHPStan-owned is native": on top of the expression engine, a native Type kernel (isSuperTypeOf/accepts/ union/intersection graphs operating natively, PHP Type objects as views), the statement-level walk, and native reflection-data storage (extending the arena). The floor left in PHP — rule bodies, vendor better-reflection, phpdoc-parser, third-party plugins — is an estimated 15–22s, so this target sits at the boundary of what a hybrid can do.
  • 90% (−51.9s) — below any architecture that keeps PHP rules, dynamic extensions and vendor parsers. This is not a port but a ground-up native analyzer; extension-ecosystem compatibility is the casualty. The realistic ceiling for the hybrid approach is ~60–75%.

(Benchmarks include the ShipMonk dead-code plugin, ~8% of the self-analysis run — third-party PHP that no port removes.)

Testing

# differential test of the native classes vs the PHP implementations (they
# are declared as PHPStanTurbo\* next to the twins, see tests/activate-prefixed.php)
php -d extension=$(pwd)/phpstan_turbo.so tests/smoke.php

# PHPStan's own test suite with the extension loaded
php vendor/bin/phpunit ...

# output identity (clear the result cache between runs!)
# nothing disables a loaded extension, so keep it out of php.ini and load it
# per run instead — through PHP_INI_SCAN_DIR, not -d extension=: PHPStan's
# OPcache restart re-executes the process and drops command-line -d flags
mkdir -p /tmp/turbo-ini && echo "extension=$(pwd)/phpstan_turbo.so" > /tmp/turbo-ini/turbo.ini
PHP_INI_SCAN_DIR=":/tmp/turbo-ini" php ../bin/phpstan analyse ... --error-format=raw   # with
php ../bin/phpstan analyse ... --error-format=raw                                     # without

History

  • The original proof of concept used Zephir (removed).
  • The first full implementation was hand-written C (phpize); it is preserved on the turbo-c-extension branch together with the matching PHPStan sources, and this C++ version is its port.