Remember an expression's printed form as the printer produces it - #6118
Merged
Conversation
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
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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:PrettyPrinterAbstract::pStandard::pDereferenceLhsStandard::pExpr_PropertyFetchdereferenceLhsRequiresParensPrinter::pObjectProperty4 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 cacheExprPrinterreads, 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.ArgumentsNormalizercopied 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 forfoo(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
PrettyPrinterAbstract::ppDereferenceLhspExpr_PropertyFetchBenchmark
Interleaved A/B, pair order alternated (ABBA), user CPU, result cache cleared before every run, turbo extension enabled, machine otherwise idle:
nullsafe-chain-walk.phpsrc(ordinary code)srcpeak RSSOrdinary code is neutral — the direction is favourable but not significant, so it should be read as "no regression" rather than a win. Profiling
src/Typebefore and after confirms why: printer work drops there too (PrettyPrinterAbstract::p837K→515K calls,pExpr_Variable180K→36K,pDereferenceLhs130K→45K), against exactly three added costs — the newPrinter::pframe (628K calls, +348ms),getAttribute(+400K, +51ms) andsetAttribute(+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
ArgumentsNormalizerattribute copying above.--error-format=rawoutput oversrcis byte-identical, and 4,287 tests pass.Closes phpstan/phpstan#14991
🤖 Generated with Claude Code
https://claude.ai/code/session_016XhKccs7xeB1wRTAQEemc2