Skip to content

Cache ANTLR parse trees between parses - #2566

Merged
shangyian merged 4 commits into
mainfrom
perf/antlr-parse-tree-cache
Sep 24, 2026
Merged

shangyian merged 4 commits into
mainfrom
perf/antlr-parse-tree-cache

Conversation

@shangyian

@shangyian shangyian commented Sep 23, 2026

Copy link
Copy Markdown
Collaborator

Summary

Profiling /sql/measures/v3/ and /sql/metrics/v3/ showed that sql text parsing dominated the build time with the parse() call accounting for roughly 55% of a request. Many calls to parse() would reparse strings that had already been parsed by the same request.

Memoizing parse() itself is not safe because the builder mutates the DJ AST, but caching below that at the ANTLR tree level is fine (the mutable DJ AST is rebuilt from the ANTLR tree).

Measured on the BUILD_V3 examples:

  /sql/measures/v3/   7-31% faster
  /sql/metrics/v3/    5-29% faster

Test Plan

  • PR has an associated issue: #
  • make check passes
  • make test shows 100% unit test coverage

Deployment Plan

@netlify

netlify Bot commented Sep 23, 2026

Copy link
Copy Markdown

Deploy Preview for thriving-cassata-78ae72 canceled.

Name Link
🔨 Latest commit 6c4b3bb
🔍 Latest deploy log https://app.netlify.com/projects/thriving-cassata-78ae72/deploys/6ab41fd5d8c99a00097c9847

@shangyian
shangyian requested a review from robinld September 23, 2026 01:29
@shangyian
shangyian marked this pull request as ready for review September 23, 2026 01:29
Profiling /sql/measures/v3/ and /sql/metrics/v3/ showed SQL text parsing
dominating build time: `parse()` accounted for roughly 55% of a request,
and up to two thirds of the calls re-parsed a string the same request had
already parsed. The worst offenders were filter predicates (42 calls over
2 distinct strings on a 12-metric query), dimension link join SQL, and
node query text.

Memoizing `parse()` itself is not safe — the builder mutates the DJ AST it
gets back, and `Function.__deepcopy__` returns `self`, so copies still
alias. Caching one step lower is: `parse_rule` splits into an ANTLR parse
and a visitor pass, the ANTLR tree is only ever read, and the mutable DJ
AST is rebuilt on every call.

Measured on the BUILD_V3 example model, with byte-identical responses
across twelve metric/dimension/filter shapes:

  /sql/measures/v3/   7-31% faster
  /sql/metrics/v3/    5-29% faster

The widest queries gain the most, since they re-parse the most.
@shangyian
shangyian force-pushed the perf/antlr-parse-tree-cache branch from baed376 to 6fdcec3 Compare September 23, 2026 01:29

#: How many ANTLR parse trees to keep. Each entry retains its parser and token
#: stream, so this trades memory for parse time.
ANTLR_TREE_CACHE_SIZE = 512

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

feels small? I don't know how big an antlr tree is though.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah, I ended up splitting this into two configurable caches, one for the node definitions and one for the filters (we parse a lot of filters it appears).

Filters and orderby clauses are parsed from request query params, so their
variety is unbounded — every distinct literal a caller sends claimed a slot
in the same cache holding node definitions. Under real traffic that churn
would evict the entries worth keeping, and the hit rate would decay with
nothing visibly breaking.

Request SQL now uses a separate small cache. Callers opt in with
`from_request=True`, which keeps the routing at the two sites that read
request input rather than guessing from the SQL itself.

Both caches report hits, misses, size and max size once per SQL build.
Misses climbing while size sits at max size is the signal that a cache is
undersized; today the example model fills 46 of 512 slots at a 98% hit
rate, which says nothing about a production graph.
Replaying a day of prod v3 traffic (1,212 requests) against a prod-sized
graph showed the previous split was backwards. Caching node definitions is
worth nothing on this workload — it measured 4% *slower* than no cache at
all, on a 52% win rate. Every bit of the gain comes from caching the SQL
that arrives on the request: filters and orderby clauses.

The reason is that `BuildContext.get_parsed_query` already caches parsed
node ASTs within a request, so a definitions cache only helps across
requests — and real traffic repeats node definitions far less than
expected (755 distinct request shapes over 963 distinct metrics). Filters
are the opposite: a single build parses the same predicate dozens of times.

Measured against the same corpus, caches off vs on:

  p50    238ms -> 170ms
  p90    914ms -> 757ms
  p99   3944ms -> 3377ms
  mean   507ms -> 408ms   (18.7%, faster on 1083/1175 requests)

Dropping the definitions cache also drops the memory question it carried:
each entry retained a parser and token stream for a multi-KB node query,
per worker process. What remains caches `SELECT 1 WHERE <predicate>`.

The size moves to `Settings.request_parse_cache_size` so it can be retuned
from the counters without shipping code. The cache is built on first use
because `lru_cache` binds `maxsize` at decoration time while settings
resolve lazily.
The previous commit removed the node definition cache on the strength of
two benchmark runs that turned out to be confounded. Those arms ran fourth
and fifth in a sequence whose wall time climbed steadily run over run
(468s, 597s, 615s, 672s) regardless of configuration, so the slowdown they
showed was the environment, not the design.

Re-measured with the three configurations interleaved, three repetitions
each, so any drift spreads across all arms instead of landing on whichever
ran last. Baseline reps came in at 515/503/507ms, so the environment was
stable this time and the gaps below are real.

  config          r1     r2     r3    mean   vs off
  no caching     515    503    507    508ms       -
  request only   482    496    501    493ms    -3.0%
  both caches    459    457    466    460ms    -9.4%

Paired per request, pooling all three repetitions: both caches beat no
caching on 947/1173 requests, and beat request-only caching on 855/1173.

So both caches earn their place, the node definition cache more than the
request one, and the change is worth about 9% on this traffic — not the
19% claimed two commits ago, which came from unreplicated runs.

Both sizes now live in Settings so they can be retuned from the counters
without shipping code.

Measured on 400 prod v3 requests replayed against a prod-sized graph, with
both caches cold at the start of every run. A long-lived worker should do
better. The legacy /sql path, which carries far more traffic, is untested.
@shangyian
shangyian merged commit b3338fb into main Sep 24, 2026
48 of 49 checks passed
@shangyian
shangyian deleted the perf/antlr-parse-tree-cache branch September 24, 2026 00:41
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants