Skip to content

Memoise Decompile(0) on AST nodes - #77

Merged
ghedwards merged 1 commit into
masterfrom
claude/caching-pr-review-80vzln
Sep 23, 2026
Merged

ghedwards merged 1 commit into
masterfrom
claude/caching-pr-review-80vzln

Conversation

@ghedwards

Copy link
Copy Markdown

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:

calls repeats
expressions 1,245,607 74%
statements 32,899 76%

How

Both abstract CFParsedStatement bases now implement Decompile(int) as a wrapper that caches the indent == 0 result and delegates to a new abstract decompileImpl(int), which the 59 concrete nodes override.

The public CFStatement / CFScriptStatement interfaces 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.

CFCase and CFCatchStatement implement the interface directly rather than extending a base, so they keep overriding Decompile and 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, setModifier and setMemberOperator are 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:

3,002-file CFLint scan best runs
baseline 8415 ms 9208 / 9141 / 8572 / 8415
memoised 7964 ms 8094 / 7964 / 8134 / 8146

~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: Decompile was 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

  • 326 cfparser tests, 0 failures
  • 675 CFLint tests against this parser, 0 failures
  • 3,002-file CFLint scan, JSON output byte-identical to baseline — 33,468,306 bytes and 48,690 issues both ways
  • 158-file scan output likewise identical

Worth a look during review

This renames 59 method overrides. Decompile remains the public entry point on both interfaces and both bases, so external callers are unaffected — but an external subclass overriding Decompile would 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

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
@ghedwards
ghedwards merged commit 07ed715 into master Sep 23, 2026
6 checks passed
@ghedwards
ghedwards deleted the claude/caching-pr-review-80vzln branch September 23, 2026 14:00
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.

2 participants