Skip to content

Cheaper file cache, type memo bookkeeping and expression-key lookups - #6568

Merged
ondrejmirtes merged 5 commits into
2.3.xfrom
native-representation
Sep 23, 2026
Merged

ondrejmirtes merged 5 commits into
2.3.xfrom
native-representation

Conversation

@ondrejmirtes

Copy link
Copy Markdown
Member

Three performance changes and one bug fix the second of them depends on.

Full self-analysis (build/phpstan.neon, parallel, result cache cleared, disk caches warm), 6 interleaved pairs against 2.3.x: user CPU −3.4 % (6/6, t = −5.8), wall −3.2 %, output identical.

Store file cache entries serialized instead of as var_export()ed PHP

FileCacheStorage wrote every entry as a PHP file returning a var_export()ed CacheItem and loaded it with include. Compiling such a file costs more than unserializing the same data, every object in it is hydrated through a userland __set_state() call, and the compiled code stays in memory for the rest of the process. Entries are now serialize() payloads in *.dat files; the separate extension keeps an older PHPStan sharing the directory from including a payload that is not PHP. clearUnusedFiles() (cache version v3-serialized) deletes the *.php entries of the former formats once.

Single process, src/Analyser src/Rules src/Type, 8 pairs: −2.86 % user CPU, peak RSS 589 MB → 526 MB (−11 %). PHP only - applies without the extension as well.

Keep a type's structural hash and memo keys in a header in front of the object (turbo)

TypeCombinatorCache kept the structural hash of every hashed type and the memo keys each result answers in weak maps - an EG(weakrefs) registration per object and a weak-reference notification when it dies. The shadowing classes it hashes structurally now allocate a 32-byte header in front of the zend_object holding both; PHP subclasses inherit the allocator, free_obj tombstones the object's memo slots and clone_obj gives the clone a fresh header.

Single process, 8 pairs: −1.82 % user CPU.

Do not let a node built from another node's attributes inherit its printed key (bug fix)

ExprPrinter caches an expression's printed key in the node's phpstan_cache_printer attribute, and every node constructed from another node's attributes copied it along - the BooleanAnd/BooleanOr and callable virtual nodes, the single-subject Isset_ nodes of a multi-variable isset(), the plain MethodCall read for an argument-less nullsafe call, the __invoke() call of an invokable callee, the clone-with property fetches, the foreach value assignment and the $this property fetches ClassStatementsGatherer records. Such a node then printed as the node it was made from whenever that one had been printed first, and whatever was tracked for it answered for the original expression: the keep-void type of $foo && $i++; in a loop read mixed instead of bool. The cached key is now dropped from the copied attributes, in PHP and in the native mirrors. InheritedExpressionKeyRuleTest fails without the fix (with and without the extension).

Answer hasExpressionType() without printing a key no tracked expression can match (turbo)

ScopeOps::hasExpressionType() printed the expression's key before looking it up - for a node never printed before that is a trip through the PHP pretty printer, most of the printer misses of an analysis, and most such lookups answer No. A printed key determines the kind of node it was printed from (PHP syntax its node class, every virtual node its own __phpstan marker; a short list shares the array's kind), so when none of the tracked expressions shares the node's kind, the native implementation answers No without printing. Exact since the previous commit.

Single process, 8 pairs: −1.08 % user CPU, walk-trace identical.

Verification

Before the rebase onto the current 2.3.x: make tests with and without the extension (22,237 tests), make phpstan, side-by-side, signature parity, walk-trace identical, make lint-turbo.

🤖 Generated with Claude Code

https://claude.ai/code/session_01QSGnoYacTfJv3taAhZkj5o

ondrejmirtes and others added 5 commits September 23, 2026 20:12
…he object

TypeCombinatorCache kept both per-object facts it needs in weak maps: the
128-bit structural hash of every hashed type (pt_type_hashes) and the list
of memo keys a result object answers (pt_memo_results). Every entry is an
EG(weakrefs) registration on the object, and every such object pays the
engine's weak-reference notification when it dies.

The shadowing classes the memo hashes structurally (PHPStan\Type\*,
PHPStan\Php\*, TrinaryLogic) now get a create_object at activation that
allocates a 32-byte header in front of the zend_object: the hash once
computed, the memo key list and the memo generation the list belongs to.
PHP subclasses inherit the allocator. The header's free_obj tombstones the
object's memo slots, its clone_obj gives the clone a fresh header, and
clear() bumps the generation so that lists from before a clear are ignored.
Objects without the header keep using the weak maps.

src/Analyser src/Rules src/Type single process, 8 interleaved pairs:
20.78 s -> 20.40 s user CPU (-1.82 %, 8/8, t = -14.7), peak RSS unchanged,
output identical.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QSGnoYacTfJv3taAhZkj5o
…inted key

ExprPrinter caches an expression's printed key in the node's
phpstan_cache_printer attribute. Every node constructed from another
node's attributes - the BooleanAnd/BooleanOr and callable virtual nodes,
the single-subject Isset_ nodes of a multi-variable isset(), the plain
MethodCall read for an argument-less nullsafe call, the __invoke() call of
an invokable callee, the clone-with property fetches, the foreach value
assignment and the $this property fetches ClassStatementsGatherer records -
copied that attribute along, and so printed as the node it was made from
whenever that node had been printed first. A BooleanAndNode then was the
original `$foo && $i++`, and what was tracked for it answered for the
original expression: its keep-void type read mixed instead of bool.

Each of these now drops the cached key from the copied attributes, the way
ArgumentsNormalizer and the nullsafe and pipe handlers already did, in the
PHP classes and in the native mirrors.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QSGnoYacTfJv3taAhZkj5o
…on can match

ScopeOps::hasExpressionType() printed the expression's key before looking
it up, and for a node that had never been printed that means a trip
through the PHP pretty printer - ~60 % of the printer misses of an
analysis, and most of those lookups answer No.

A printed key determines the kind of node it was printed from: PHP syntax
its node class, and every virtual node its own __phpstan marker (a short
list prints like an array, so the two share a kind). The native
hasExpressionType() now looks at the kinds of the tracked expressions
first, and when none shares the node's, it answers No without printing.
The PHP twin keeps printing; the answers are the same, only the printer's
cache attribute of such a node is left unset. Exact since the previous
commit stopped virtual nodes from inheriting another node's key.

src/Analyser src/Rules src/Type single process, 8 interleaved pairs:
20.43 s -> 20.21 s user CPU (-1.08 %, 7/8), output identical, walk-trace
identical.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QSGnoYacTfJv3taAhZkj5o
FileCacheStorage wrote every entry as a PHP file returning a
var_export()ed CacheItem and loaded it with include. Compiling such a file
costs more than unserializing the same data, every object in it is
hydrated through a userland __set_state() call, and the compiled code stays
in memory for the rest of the process.

Entries are now serialize() payloads in *.dat files - the separate
extension keeps an older PHPStan sharing the directory from including a
payload that is not PHP. clearUnusedFiles() (cache version v3-serialized)
deletes the *.php entries of the former formats once.

src/Analyser src/Rules src/Type single process, 8 interleaved pairs, warm
cache: 20.27 s -> 19.69 s user CPU (-2.86 %, 8/8, t = -53), peak RSS
589 MB -> 526 MB (-11 %), output identical. IntermediaryNameScope::intern()
stays: serialize() keeps objects shared within an entry but stores arrays
by value, and skipping the interning measured +38 MB for no CPU gain.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QSGnoYacTfJv3taAhZkj5o
@ondrejmirtes
ondrejmirtes merged commit 8e7f80c into 2.3.x Sep 23, 2026
523 of 526 checks passed
@ondrejmirtes
ondrejmirtes deleted the native-representation branch September 23, 2026 18:21
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant