Skip to content

Reduce template compilation overhead#1872

Draft
joelhawksley wants to merge 6 commits into
marcoroth:mainfrom
joelhawksley:perf/reduce-template-compilation-overhead
Draft

Reduce template compilation overhead#1872
joelhawksley wants to merge 6 commits into
marcoroth:mainfrom
joelhawksley:perf/reduce-template-compilation-overhead

Conversation

@joelhawksley

Copy link
Copy Markdown
Contributor

Summary

A set of allocation- and CPU-focused optimizations for the parse + compile
path, found by profiling Herb::Engine compilation of a large real-world
template corpus (~700 .html.erb files). Each optimization is in its own commit.

Measured on that corpus (Ruby 3.4.9 +PRISM, compiling with validation
disabled), versus v0.10.2:

  • Compile time: −36% (≈281 µs → ≈181 µs per template)
  • Parse allocations: −72%; compile-path allocations −67%
  • Compiled output is byte-identical across the whole corpus and a set of
    adversarial whitespace/trim cases
  • Hot-path (post-compile) rendering behavior is unchanged

Optimizations (one per commit)

  1. track_locations parse optionHerb.parse builds a Location (two
    Positions) for every node and token. Callers that never read source
    locations (e.g. rendering with validation off) can pass
    track_locations: false to leave them nil, skipping roughly half of the
    parse's Ruby allocations. Defaults to true, so existing behavior is
    unchanged.

  2. Intern node/token type strings and short token values — type strings and
    short token values are drawn from tiny fixed vocabularies (~96% of token
    values are duplicates like "\n", "%>", ">", tag names). Interning
    shares one frozen String per distinct value instead of allocating a fresh
    copy each time. Longer token values (arbitrary text) are left as-is.

  3. Skip the recursive error walk for cleanly-parsed templates
    ParseResult#errors walked the entire AST (value.recursive_errors) on
    every call. Count errors as they are materialized and record the total on the
    result; when it is zero (the common case), return the top-level errors and
    skip the walk entirely.

  4. Accumulator-based Node#recursive_errors — replace
    errors + compact_child_nodes.flat_map(&:recursive_errors) (which allocates
    an intermediate array at every node) with a single shared accumulator walked
    iteratively.

  5. Lazy Engine#relative_file_path — was computed eagerly with
    Pathname#relative_path_from + #to_s on every compile, but is only used
    when emitting errors, overlays, or debug output. Derive it on demand instead.

  6. Fuse whitespace compaction into optimize_tokens; use String#match?
    fold the separate compact_whitespace_tokens pass (which built an
    intermediate array via map.with_index + compact) into optimize_tokens'
    single pass, and switch the boolean-context regexp guards in the whitespace
    helpers from =~ to String#match? (no MatchData allocation, no $~).

Notes

  • The only public API change is the additive track_locations: option on
    Herb.parse.
  • Generated files (ext/herb/nodes.c, ext/herb/error_helpers.c) are not
    committed; the changes live in their .erb templates.
  • Verification: compiled Herb::Engine#src output is byte-for-byte identical to
    v0.10.2 across all ~700 corpus templates and a set of hand-crafted
    whitespace/trim edge cases; the downstream ReActionView test suite passes
    against this build.

This PR was written with Claude Opus 4.8.

Herb builds a Location (two Positions) for every AST node and token during
parsing. Callers that never read source locations — e.g. rendering a template
with validation disabled — pay for materializing objects they immediately
discard. Location/Range/Position account for roughly half of the parse's Ruby
allocations and a meaningful share of its time.

Add a `track_locations:` option to `Herb.parse` (default true, fully backward
compatible). When false, the node/token builders leave location and range as
nil. The AST value objects are also built via direct allocation + ivar set,
which is what lets the location/range builders bail out before allocating.

The flag is applied under the GVL immediately before Ruby AST materialization,
so it cannot race with the native parse.
Every node materialized a fresh String for its type (e.g.
"AST_HTML_ELEMENT_NODE"), and every token a fresh String for its type and
value — even though these are drawn from tiny fixed vocabularies. In a typical
template ~96% of token values are duplicates of a handful of structural
strings ("\n", "%>", "<%", ">", " ", tag names, ...).

Intern node and token type strings, and token values up to 16 bytes, so the
whole AST shares one frozen String per distinct value. Longer token values
(arbitrary text content) are left as ordinary strings since they rarely repeat.
This roughly halves the number of strings allocated during a parse.
ParseResult#errors collects errors by walking the entire AST
(value.recursive_errors) on every call, allocating along the way — even when
the template parsed with no errors, which is the overwhelmingly common case.

Count errors as they are materialized onto nodes (rb_errors_array_from_c_array)
and record the total on the ParseResult as @total_error_count. When it is zero,
ParseResult#errors returns the top-level errors directly and skips the full
recursive walk entirely.
@joelhawksley

joelhawksley commented Jul 24, 2026

Copy link
Copy Markdown
Contributor Author

@marcoroth I just took a first pass at trying to improve compilation performance with a lot of help from Claude. What do you think of these changes? I'm also happy to pursue any other approaches you have in mind.

Node#recursive_errors was `errors + compact_child_nodes.flat_map(&:recursive_errors)`,
which allocated an intermediate array (and a compacted child array) at every
node. Rewrite it to walk children iteratively into a single shared accumulator,
avoiding the per-node throwaway allocations on large trees.
Engine#initialize eagerly built two Pathnames and ran
Pathname#relative_path_from + #to_s on every compile, but relative_file_path is
only consulted when emitting errors, overlays, or debug output. Derive it on
demand in a reader instead, keeping it off the common, error-free render path.
optimize_tokens ran compact_whitespace_tokens first, which built a whole
intermediate array (map.with_index + compact), then re-scanned it to merge
adjacent text. Fold whitespace resolution into optimize_tokens' single pass:
whitespace is resolved against its neighbours in the original stream and text
is merged inline, removing an array allocation and a full pass per template.

Also switch the boolean-context regexp guards in the whitespace helpers from
=~ to String#match?, which is faster and does not allocate MatchData or set $~.
@joelhawksley
joelhawksley force-pushed the perf/reduce-template-compilation-overhead branch from 71b114c to 04afe67 Compare July 24, 2026 21:08
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.

1 participant