Reduce template compilation overhead#1872
Draft
joelhawksley wants to merge 6 commits into
Draft
Conversation
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.
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
force-pushed
the
perf/reduce-template-compilation-overhead
branch
from
July 24, 2026 21:08
71b114c to
04afe67
Compare
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.
Summary
A set of allocation- and CPU-focused optimizations for the parse + compile
path, found by profiling
Herb::Enginecompilation of a large real-worldtemplate corpus (~700
.html.erbfiles). Each optimization is in its own commit.Measured on that corpus (Ruby 3.4.9 +PRISM, compiling with validation
disabled), versus v0.10.2:
adversarial whitespace/trim cases
Optimizations (one per commit)
track_locationsparse option —Herb.parsebuilds aLocation(twoPositions) for every node and token. Callers that never read sourcelocations (e.g. rendering with validation off) can pass
track_locations: falseto leave them nil, skipping roughly half of theparse's Ruby allocations. Defaults to
true, so existing behavior isunchanged.
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). Interningshares one frozen
Stringper distinct value instead of allocating a freshcopy each time. Longer token values (arbitrary text) are left as-is.
Skip the recursive error walk for cleanly-parsed templates —
ParseResult#errorswalked the entire AST (value.recursive_errors) onevery 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.
Accumulator-based
Node#recursive_errors— replaceerrors + compact_child_nodes.flat_map(&:recursive_errors)(which allocatesan intermediate array at every node) with a single shared accumulator walked
iteratively.
Lazy
Engine#relative_file_path— was computed eagerly withPathname#relative_path_from+#to_son every compile, but is only usedwhen emitting errors, overlays, or debug output. Derive it on demand instead.
Fuse whitespace compaction into
optimize_tokens; useString#match?—fold the separate
compact_whitespace_tokenspass (which built anintermediate array via
map.with_index+compact) intooptimize_tokens'single pass, and switch the boolean-context regexp guards in the whitespace
helpers from
=~toString#match?(noMatchDataallocation, no$~).Notes
track_locations:option onHerb.parse.ext/herb/nodes.c,ext/herb/error_helpers.c) are notcommitted; the changes live in their
.erbtemplates.Herb::Engine#srcoutput is byte-for-byte identical tov0.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.