Skip to content

Parse each template once - #54

Merged
johanrd merged 1 commit into
mainfrom
perf-parse-memo
Aug 29, 2026
Merged

Parse each template once#54
johanrd merged 1 commit into
mainfrom
perf-parse-memo

Conversation

@johanrd

@johanrd johanrd commented Aug 29, 2026

Copy link
Copy Markdown
Owner

A template was parsed by the transformer (transform.ts), again by blankTemplateContentMultipass for the branch tree, and once more by blankTemplateContent per pass; the resolver (walk.ts) parsed the same component templates for every consumer that reached them. lib/parse-template.ts memoises preprocess by content string (64 entries, oldest evicted; parse errors are rethrown, not cached) and all four call sites use it.

Safe because nothing writes to AST nodes: the walkers read, build-maps uses @glimmer/syntax's traverse, and both test lanes pass (291 + 1 expected fail).

Measured:

  • Example app (838 files), warm run: 31.1 s → 5.8 s with the published @glimmer/syntax 0.95.0, whose per-node position lookup is O(lines) and therefore scales with the number of parses. With the charPosFor fix (Perf: cache newline offsets in Source for O(log n) loc conversion emberjs/ember.js#21314) applied the gain is smaller in absolute terms; the parse count drops the same way.
  • Bench (pnpm bench:compare): the whole-process cases drop 57–60 % once the set contains a template with conditionals, and cross-file resolution −30…−38 %.

Also adds test/bench/large.gts — a generated 12-conditional template — to the bench fixtures: the examples/ files are ≤2 KB and parse in microseconds, so the bench could not see parse cost before. While sizing it I measured that run time is superlinear in the number of conditionals (12 → ~0.5 s of plugin work, 30 → 4 s, 120 → 25 s): the multipass enumeration blanks the whole template once per branch combination, up to 2^cap passes. That's a separate optimisation.

Cowritten by Claude

A template was parsed by the transformer, again for the multipass branch
tree, and once more per pass; the resolver parsed the same component
templates for every consumer. `parseTemplate` memoises `preprocess` by
content (64 entries, oldest evicted; parse errors are not cached).

Console app, warm run: 31.1 s -> 5.8 s with the published @glimmer/syntax
(whose per-node position lookup scales with the number of parses), and
the bench's whole-process cases drop 57-60 % with a 12-conditional
template in the set — added as test/bench/large.gts, since the examples
were too small to show parse cost.

Cowritten by Claude
@johanrd johanrd added enhancement New feature or request run-bench Run the benchmark comparison on this pull request labels Aug 29, 2026
@github-actions

Copy link
Copy Markdown
Contributor

🏎️ Benchmark Comparison

Benchmark Control (p50) Experiment (p50) Δ
extract small template 854.33 µs 825.57 µs -3.4%
🟢 extract medium template 4.20 ms 3.39 ms -19.3%
🟢 extract cross-file resolution 4.43 ms 3.10 ms -30.0%
extract large template 716.71 µs 719.47 µs +0.4%
🟢 cold run (cache off) 14922.69 ms 6543.43 ms -56.2%
🟢 warm run (all cached) 14206.76 ms 5500.50 ms -61.3%
one cached file 525.10 ms 532.01 ms +1.3%
🟢 no glint 13526.95 ms 5107.34 ms -62.2%

🟢 faster · 🔴 slower · 🟡 5–10 %, possibly noise · ⚪ within 5 %

Full output
clk: ~3.08 GHz
cpu: AMD EPYC 7763 64-Core Processor
runtime: node 24.19.0 (x64-linux)

benchmark                    avg (min … max) p75 / p99    (min … top 1%)
-------------------------------------------- -------------------------------
extract small template          1.24 ms/iter   1.17 ms  █                   
                       (686.43 µs … 5.32 ms)   4.10 ms ▂█                   
                     ( 23.22 kb …   3.38 mb) 309.40 kb ██▇▅▃▃▂▂▂▂▁▁▁▁▂▂▁▂▂▂▁

extract medium template         4.93 ms/iter   5.29 ms  █ ▃                 
                        (3.30 ms … 12.19 ms)   9.81 ms ▇███                 
                     ( 48.09 kb …   4.79 mb)   1.01 mb ████▇▆█▁▅█▂▅▃▁▁▂▂▃▄▂▄

extract cross-file resolution   5.07 ms/iter   5.50 ms  █                   
                        (3.93 ms … 11.64 ms)  10.00 ms ▂█▄                  
                     (429.53 kb …   2.24 mb)   1.30 mb ███▅▆▃▄▄▃▃▂▂▂▁▂▁▂▂▂▁▂

extract large template        762.03 µs/iter 771.79 µs  █▅                  
                       (615.63 µs … 3.67 ms)   2.16 ms ▂██                  
                     (  6.88 kb …   1.47 mb)  50.65 kb ███▅▂▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁

whole process (min / p50 of 3 runs, 21 files)
  cold run (cache off)          14872 ms /  14923 ms
  warm run (all cached)         13928 ms /  14207 ms
  one cached file                 523 ms /    525 ms
  no glint                      13279 ms /  13527 ms
clk: ~3.10 GHz
cpu: AMD EPYC 7763 64-Core Processor
runtime: node 24.19.0 (x64-linux)

benchmark                    avg (min … max) p75 / p99    (min … top 1%)
-------------------------------------------- -------------------------------
extract small template          1.25 ms/iter   1.23 ms  █                   
                       (646.09 µs … 4.99 ms)   4.37 ms ██▂                  
                     (  3.75 kb …   3.28 mb) 316.61 kb ███▇▃▃▂▂▂▂▂▂▁▂▂▂▁▂▁▂▁

extract medium template         4.13 ms/iter   4.42 ms  █                   
                        (2.81 ms … 11.24 ms)   9.81 ms  █                   
                     (  6.66 kb …   4.10 mb) 784.86 kb ▅█▇▆▅▅▂▂▂▃▂▂▁▂▁▁▁▂▁▁▂

extract cross-file resolution   3.49 ms/iter   3.70 ms  █▅                  
                        (2.58 ms … 10.12 ms)   8.11 ms ▆██▂                 
                     ( 17.91 kb …   1.46 mb) 431.05 kb ████▃▇▅▄▁▂▃▁▂▂▁▁▁▁▁▁▁

extract large template        755.41 µs/iter 771.03 µs  █                   
                       (615.52 µs … 3.36 ms)   1.75 ms  █▇█                 
                     (  4.21 kb …   1.32 mb)  49.54 kb ▇███▄▁▂▁▁▁▁▁▁▁▁▁▁▁▁▁▁

whole process (min / p50 of 3 runs, 21 files)
  cold run (cache off)           6497 ms /   6543 ms
  warm run (all cached)          5458 ms /   5501 ms
  one cached file                 531 ms /    532 ms
  no glint                       5093 ms /   5107 ms

Copilot AI left a comment

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.

🔵 Needs a closer look

The change introduces a process-global shared mutable AST cache across the transformer, multipass, and resolver whose correctness hinges on an unenforced read-only invariant, warranting final human verification.

Pull request overview

This PR eliminates redundant Glimmer template parsing. Previously the same template content was passed to @glimmer/syntax's preprocess multiple times: once by the transformer (transform.ts), again by blankTemplateContentMultipass to build the branch tree, once more per multipass pass in blankTemplateContent, and repeatedly by the resolver (walk.ts) for every consumer that reaches a given component template. A new lib/parse-template.ts module memoizes preprocess keyed by the content string (bounded to 64 entries with FIFO eviction; parse errors are rethrown and not cached), and all production call sites now route through it. This is a hot-path performance optimization that relies on the fact that all consumers only read the AST.

I verified the safety invariant the change depends on: the traverse visitors in blank.ts (line 1563) and build-maps.ts (lines 33/53/103), and the resolver walkers in walk.ts, all read AST nodes and accumulate offsets/ranges in external structures — none mutate the shared AST. The multipass code re-invokes blankTemplateContent with the same content but different branchSelections, which correctly reuses the cached parse since the AST is independent of branch selection.

Changes:

  • Add lib/parse-template.ts, a bounded content-keyed memoization wrapper around preprocess.
  • Replace the eight direct preprocess(...) call sites in transform.ts, blank.ts, and walk.ts (removing the local parseTemplate in walk.ts) with the shared helper.
  • Add a generated 12-conditional bench fixture (test/bench/large.gts) and wire it into test/validate.bench.mjs, adjusting SUBSET/ONE so the single-file case still measures a small file.
File summaries
File Description
lib/parse-template.ts New content-keyed FIFO cache (64 entries) around preprocess; errors not cached.
transform.ts Swaps two preprocess calls for parseTemplate.
blank.ts Routes the two preprocess calls (single-pass + multipass) through parseTemplate; comment updated.
lib/resolver/walk.ts Removes local parseTemplate/preprocess import in favor of the shared helper.
test/validate.bench.mjs Adds the large fixture to FIXTURES/SUBSET; ONE now indexes a small example file.
test/bench/large.gts New generated large template exercising multipass enumeration cost.
Review details
  • Files reviewed: 6/6 changed files
  • Comments generated: 0
  • Review effort level: Balanced

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

@johanrd
johanrd merged commit c7aa72a into main Aug 29, 2026
8 of 11 checks passed
@github-actions github-actions Bot mentioned this pull request Aug 29, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

enhancement New feature or request run-bench Run the benchmark comparison on this pull request

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants