Memoise Decompile(0) on AST nodes - #77
Merged
Merged
Conversation
Decompile(0) is the form consumers ask for, and they ask repeatedly: a CFLint scan runs every rule over every node and several rules decompile the same node. Instrumented over a 3,002-file scan, 1.25M calls of which 74% of expression calls and 76% of statement calls were repeats. Each one rebuilds the whole subtree's text by concatenation, so the repeats are pure waste. Both abstract CFParsedStatement bases now implement Decompile(int) as a wrapper caching the indent == 0 result, delegating to a new abstract decompileImpl(int) that the 59 concrete nodes override. The public CFStatement / CFScriptStatement interfaces are untouched, so callers see no change. A non-zero indent is only ever reached from a parent already rendering itself, so it is not worth keying on. CFCase and CFCatchStatement implement the interface directly rather than extending a base, so they keep overriding Decompile and get no caching. The cached string is valid only while the node is unchanged. Nothing mutates a node after the visitor finishes building it -- which is before any consumer can hold a reference -- but a setter added later that changes rendering must call invalidateDecompiled(). That contract is recorded at the cache and in CLAUDE.md, because getting it wrong returns stale text rather than failing. Worth ~6% on a large scan and nothing on a small one, where cold DFA construction dominates everything. Reported for what it is: the 74% hit rate is not a 74% saving. Verified: 326 cfparser tests, 675 CFLint tests, and a 3,002-file CFLint scan whose JSON output is byte-identical to baseline (33,468,306 bytes, 48,690 issues both ways). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GzpZFd4rnE1Yi2sVHAji35
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.
Decompile(0)is the form consumers ask for, and they ask repeatedly — a CFLint scan runs every rule over every node and several rules decompile the same node. Each call rebuilds the whole subtree's text by concatenation, so the repeats are pure waste.Instrumented over a 3,002-file CFLint scan:
How
Both abstract
CFParsedStatementbases now implementDecompile(int)as a wrapper that caches theindent == 0result and delegates to a new abstractdecompileImpl(int), which the 59 concrete nodes override.The public
CFStatement/CFScriptStatementinterfaces are unchanged, so callers see no difference —((CFScriptStatement) expression).Decompile(0)still works exactly as before. A non-zero indent is only ever reached from a parent already rendering itself, so it is not worth keying on.CFCaseandCFCatchStatementimplement the interface directly rather than extending a base, so they keep overridingDecompileand get no caching.The contract this introduces
The cached string is valid only while the node is unchanged. Nothing mutates a node after the visitor finishes building it — which is before any consumer can hold a reference — but a setter added later that changes rendering must call
invalidateDecompiled().setIsShortHand,setStatic,setModifierandsetMemberOperatorare the kind of setter this applies to.This is the real cost of the change, and it fails quietly: getting it wrong returns stale text rather than throwing. The contract is recorded both at the cache and in CLAUDE.md's Parser API section.
What it is actually worth
~6% on a large scan, and nothing on a small one. Stating that plainly because a 74% hit rate is not a 74% saving:
~5% on best, ~8% on median. The memoised runs are also markedly more consistent (182 ms spread against 793 ms), which is what less allocation looks like.
On a 158-file scan there is no measurable difference (5254 / 5445 / 5268 against a 5133 / 5213 / 5436 baseline) — cold ANTLR DFA construction is ~4.7 s of fixed cost there and swamps everything else.
That ~6% is consistent with the profile:
Decompilewas 9.2% of samples, and removing ~74% of it predicts ~6.8%.One measurement note, since it nearly misled me: an early 20409 ms "baseline" was taken with JFR profiling enabled. The true baseline is ~8.9 s. Every number above is without JFR.
Verification
Worth a look during review
This renames 59 method overrides.
Decompileremains the public entry point on both interfaces and both bases, so external callers are unaffected — but an external subclass overridingDecompilewould no longer be called through the cache. Nothing in this repo or CFLint does that.🤖 Generated with Claude Code
https://claude.ai/code/session_01GzpZFd4rnE1Yi2sVHAji35
Generated by Claude Code