Skip to content

Remember an expression's printed form as the printer produces it - #6118

Merged
ondrejmirtes merged 1 commit into
2.2.xfrom
printer-expr-cache
Jul 28, 2026
Merged

Remember an expression's printed form as the printer produces it#6118
ondrejmirtes merged 1 commit into
2.2.xfrom
printer-expr-cache

Conversation

@ondrejmirtes

Copy link
Copy Markdown
Member

Every level of a chain is printed for its own expression key, and printing a level descends through everything below it — so the levels were already being printed as part of the level above, just never remembered. A chain of depth N was printed O(N²) times over.

Found by profiling tests/bench/data/nullsafe-chain-walk.php (the stock SPX build dies on these chains — STACK_CAPACITY exceeded — so this needed a build with the frame limit raised). The printer was ~40s of a 50.7s profile:

exclusive calls function
10.30s 2.5M PrettyPrinterAbstract::p
9.49s 2.4M Standard::pDereferenceLhs
9.40s 2.3M Standard::pExpr_PropertyFetch
4.99s 2.4M dereferenceLhsRequiresParens
4.86s 2.3M Printer::pObjectProperty

4 chains × 800 levels ⇒ Σk ≈ 2.5M, matching the call counts.

The change

p() is where the printer descends into every construct, so remembering each expression's printed form there makes the levels below it O(1) — and it fills the same cache ExprPrinter reads, so a key printed once is never rebuilt no matter which expression first reached it. Only the standalone form is remembered: at a lower precedence the printer may parenthesise, and a form containing a newline was indented for the level it was printed at, while expression keys are printed at the top level.

ArgumentsNormalizer copied the whole attribute set onto the calls it rebuilds, handing a reordered call the printed form of the original — foo(need: true) as the key for foo(true). That was already so before this change (28 occurrences in the Analyser test suite), harmless because nothing recomputed the key; remembering more of them would have made it reachable, so the derived form is now left behind.

Effect on the bench

before after
userland calls 27.0M 9.8M
PrettyPrinterAbstract::p 10.30s / 2.5M 0.37s / 79K
pDereferenceLhs 9.49s / 2.4M 0.22s / 65K
pExpr_PropertyFetch 9.40s / 2.3M 0.17s / 46K

Benchmark

Interleaved A/B, pair order alternated (ABBA), user CPU, result cache cleared before every run, turbo extension enabled, machine otherwise idle:

slice base this branch delta pairs won paired t
nullsafe-chain-walk.php 2.27s 1.53s −32.6% 8/8 12.4
src (ordinary code) 59.23s 58.81s −0.72% 6/8 1.05
src peak RSS 702.6MB 701.3MB −1.38MB 5/8 0.48

Ordinary code is neutral — the direction is favourable but not significant, so it should be read as "no regression" rather than a win. Profiling src/Type before and after confirms why: printer work drops there too (PrettyPrinterAbstract::p 837K→515K calls, pExpr_Variable 180K→36K, pDereferenceLhs 130K→45K), against exactly three added costs — the new Printer::p frame (628K calls, +348ms), getAttribute (+400K, +51ms) and setAttribute (+267K, +21ms). Peak memory was measured on every A/B, not just CPU.

Correctness

A differential comparing each cached hit against a fresh re-print found zero mismatches over 6.5M comparisons on the bench plus the Analyser, Properties, Methods and Comparison suites. It caught two real bugs while developing this: a closure inside a chain prints multi-line and its form depends on the indentation level it was printed at (hence the newline guard), and the ArgumentsNormalizer attribute copying above.

--error-format=raw output over src is byte-identical, and 4,287 tests pass.

Closes phpstan/phpstan#14991

🤖 Generated with Claude Code

https://claude.ai/code/session_016XhKccs7xeB1wRTAQEemc2

Every level of a chain is printed for its own expression key, and printing a
level descends through everything below it, so a chain of depth N was
printed O(N^2) times over — the levels were already printed as part of the
level above, just not remembered.

p() is where the printer descends into every construct, so remembering each
expression's printed form there makes the levels below it O(1), and it fills
the same cache ExprPrinter reads: a key printed once is never rebuilt, no
matter which expression it was first reached from. Only the standalone form
is remembered — at a lower precedence the printer may parenthesise, and a
form containing a newline was indented for the level it was printed at,
while expression keys are printed at the top level.

ArgumentsNormalizer copied the whole attribute set onto the calls it
rebuilds, which handed a reordered call the printed form of the original,
named arguments and all. That was already so before this change (28
occurrences in the Analyser test suite), harmless because nothing recomputed
the key; remembering more of them would have made it reachable, so the
derived form is now left behind.

Interleaved A/B on a quiet machine, user CPU, result cache cleared, turbo
extension enabled: -32.6% on tests/bench/data/nullsafe-chain-walk.php
(2.27s -> 1.53s, 8/8 pairs, paired t=12.4). Ordinary code and peak memory
are unaffected (src/Type -0.09%, t=0.2; peak RSS identical). Analysis output
is unchanged.

See phpstan/phpstan#14991

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016XhKccs7xeB1wRTAQEemc2
@ondrejmirtes
ondrejmirtes merged commit 1501355 into 2.2.x Jul 28, 2026
738 of 742 checks passed
@ondrejmirtes
ondrejmirtes deleted the printer-expr-cache branch July 28, 2026 08:07
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.

expr printing bottleneck in nullsafe-chain-walk.php

1 participant