Skip to content

Engine: Reduce template compilation overhead - #1872

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

Engine: Reduce template compilation overhead#1872
joelhawksley wants to merge 5 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.

@github-actions github-actions Bot added ruby Ruby source for the gem and its libraries c C source for the core parser, lexer, and AST c-extension Ruby C extension in ext/ engine Herb engine and Rails template compilation rubygem The herb RubyGem and its packaging labels Jul 24, 2026
@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.

@joelhawksley
joelhawksley force-pushed the perf/reduce-template-compilation-overhead branch from 71b114c to 04afe67 Compare July 24, 2026 21:08
@marcoroth marcoroth added this to the v0.11.0 milestone Jul 30, 2026
@marcoroth marcoroth changed the title Reduce template compilation overhead Engine: Reduce template compilation overhead Aug 11, 2026

@marcoroth marcoroth left a comment

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

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

Hey @joelhawksley, thanks for these ideas!

I think all 5 ideas are all valuable and definitely worth a look. Though, I think I would prefer these as individual pull requests so they are more easily identifiable.

Or, at least, the track_locations , ParseResult#errors and Node#recursive_errors ideas feel like easier and simple to land as separate PRs.

Would you mind splitting them up? Thank you!

marcoroth added a commit that referenced this pull request Aug 13, 2026
…2199)

Splits the `track_locations` parse option out of #1872 into its own PR,
as requested.

`Herb.parse` builds a `Location` (two `Position`s) — and a `Range` — for
every AST node and token. Callers that never read source locations (e.g.
rendering a template with validation disabled) pay to materialize
objects they immediately discard; `Location`/`Range`/`Position` account
for roughly half of the parse's Ruby allocations.

This adds an additive `track_locations:` option to `Herb.parse` (default
`true`, fully backward compatible). When `false`, the node/token
builders leave `location` and `range` as `nil`. The flag is applied
under the GVL immediately before Ruby AST materialization, so it cannot
race with the native parse.

It also makes `Herb::AST::Helpers#inline_ruby_comment?` safe when
locations are not tracked. That helper detected a single-line inline `#
comment` by comparing `node.location.start.line` to
`node.location.end.line`, which raises once `track_locations: false`
leaves the location `nil`. It runs on the compiler's happy path
(`Compiler#visit_erb_content_node`), so without this fix any template
containing an inline Ruby comment fails to compile when locations are
off. It now falls back to an equivalent newline check on the node
content, keeping compiled output byte-identical whether or not locations
are tracked.

## Metrics

Measured by running `ActionView::Precompiler` against GitHub.com's views
directory (Ruby 4.0.5 + PRISM), comparing `track_locations` on (current
default) vs. off:

**End-to-end `ActionView::Precompiler` (6,302 templates):**

| | compile time (median) | allocations |
| --- | --- | --- |
| locations tracked (default) | 21.53s | 39.3M |
| `track_locations: false` | 20.91s | 32.5M |
| **delta** | **~3% faster** | **~17% fewer** |

The end-to-end wall-clock delta is modest because Herb parsing is only a
fraction of total precompile work (render-call scanning, file I/O, and
ActionView's `compile!` machinery dominate); the allocation reduction is
the headline.

**Isolated `Herb::Engine` compile of the same corpus (8,578 `.html.erb`
files, validation disabled):**

| | compile time (median) | allocations |
| --- | --- | --- |
| locations tracked (default) | 11.43s | 38.3M |
| `track_locations: false` | 10.40s | 29.7M |
| **delta** | **9.0% faster** | **22.5% fewer** |

Compiled `Herb::Engine#src` output was verified byte-for-byte identical
across all corpus templates with locations on vs. off (0 mismatches, 0
skips).

## 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 touched by this PR; the location builders they call already honor
the flag.
- Split out of #1872 per the request to land `track_locations` as its
own PR.

---------

Co-authored-by: Marco Roth <marco.roth@intergga.ch>
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.
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 04afe67 to ea56d7c Compare August 13, 2026 16:09
@github-actions github-actions Bot added the rbs RBS type signatures in sig/ label Aug 13, 2026
marcoroth added a commit that referenced this pull request Aug 13, 2026
…alk (#2217)

## Summary

`ParseResult#errors` collects errors by walking the **entire AST** on
every call, allocating an intermediate array at every node, even when
the template parsed with **no errors**, which is the overwhelmingly
common case. Every `Herb::Engine` compile calls `parse_result.errors`,
so this walk runs once per template.

This PR makes the clean-template path free, and does it once for every
binding:

1. **Count the errors in `libherb`, not in a binding.**
`parser_options_T` already carried an `error_count` field for the
`max_errors` cap. `herb_parse` now fills it with the exact total and
hands it back to the caller, so Ruby, JavaScript, WASM, Java and Rust
all get the count instead of Ruby alone.
2. **Skip the recursive walk when the count is zero.** Each binding
exposes the count on its parse result and returns the top-level errors
directly when it is zero.
3. **Collect recursive errors into a shared accumulator.**
`recursive_errors` was `errors +
compact_child_nodes.flat_map(&:recursive_errors)`, allocating throwaway
arrays at every node. It now walks children iteratively into a single
accumulator, in all four language bindings.

Split out of #1872 so it can be reviewed and benchmarked on its own.

## Implementation notes

The total comes from one allocation-free visitor walk at the end of
`herb_parse` instead of counting as errors are constructed. Six sites
attach errors directly instead of going through the generated `append_*`
helper, including the Ruby parse errors in `analyze/parse_errors.c`, so
counting at construction time reported zero errors for a template like
`<% if condition without end %>` while the tree held one. The walk is
exact regardless of how an error was attached.

`herb_parse` only installs its own counter when the caller leaves
`error_count` NULL. A binding that supplies one reads the total back,
and a binding that does not gets `nil` and simply walks, so the fallback
is safe by construction.

`max_errors` now caps every error type instead of only the two
tag-matching sites it reached before, which is what the option has
always been documented to do. This is a behavior change. The corpus run
reports 129 fewer diagnostics across 36,976 files with no file changing
verdict.

## Benchmark

Measured end-to-end through the `github/github` monolith's
`ViewPrecompiler.precompile`, which compiles the full template set
through the Herb engine (so `ParseResult#errors` is exercised once per
template). Numbers are the full-precompile pass (`bin/rails runner`),
comparing the vendored gem built from `main` vs. built with this change:

| variant | wall clock | allocations |
| --- | --- | --- |
| baseline (`main`) | 34.254s | 63,842,961 |
| this PR | 32.264s | 54,098,228 |

**≈9.74M fewer allocations (−15.3%)** and **≈2.0s faster (−5.8%)**
across the whole precompile.

## Correctness

- Clean template → `error_count == 0`, the fast path returns the
top-level errors and skips the walk.
- Template with errors → the count matches the tree exactly, including
Ruby parse errors and errors suppressed by `max_errors`.
- New tests assert `error_count == recursive_errors.size` across every
error path in Ruby, both JavaScript bindings, Java and Rust.
- Two pre-existing bugs fixed along the way. The Node binding never read
`max_errors`, so the cap silently did nothing there while WASM honoured
it. Java seeded the parse result's error list from the document node,
which the recursive walk then visited again, double counting every
document-level error.
- Ruby, C, Java, Rust and every JavaScript package suite passes. RuboCop
and `cargo +nightly fmt --check` clean.

---

*This change was produced by Claude Opus 4.8 (GitHub Copilot), acting on
behalf of @joelhawksley.*

---------

Co-authored-by: Marco Roth <marco.roth@intergga.ch>
marcoroth pushed a commit that referenced this pull request Aug 15, 2026
Collapses the two-pass whitespace/text optimization in
`Herb::Engine::Compiler#optimize_tokens` into a single pass over the raw
token stream.

Previously `optimize_tokens` did two passes:

1. `compact_whitespace_tokens` built a whole intermediate array
(`tokens.map.with_index { ... }.compact`), dropping/relabeling
whitespace tokens.
2. `optimize_tokens` re-scanned that array to merge consecutive text
tokens, accumulating with `current_text += value`.

This PR folds both into one pass: whitespace tokens are resolved against
their neighbours in the *original* stream (dropped, or turned into text)
and consecutive text is merged inline. `compact_whitespace_tokens` is
removed.

`optimize_tokens` runs once per template compile, so this is on the hot
path for any large `ActionView`/ReActionView precompile.

### Implementation notes

- No intermediate array: the `map.with_index + compact` allocation is
gone; the surviving whitespace-vs-neighbour checks
(`adjacent_whitespace?`, `whitespace_before_code_sequence?`) run against
the original `tokens`/`index`, exactly as before.
- Text is accumulated into a single buffer that is mutated with
`current_text << value` instead of `current_text += value`, which
reallocated and copied the whole accumulated string on every text token.
- The buffer is seeded with `value.dup` so a (possibly frozen) token
value is never mutated in place.

### Benchmark

Measured end-to-end through `ViewPrecompiler.precompile` over the
github/github monolith template corpus (**6,302 templates**),
ReActionView ON (Herb path), Ruby 4.0.5.

Both sides were run against the **same Herb `main` native extension** —
only `lib/herb/engine/compiler.rb` differs between them — so the delta
isolates this change. 2 boots × 5 timed iterations each.

| Config | Time (median) | Time (min) | Allocated objects (median) |
|---|---|---|---|
| `main` baseline | 19.618s / 19.396s | 19.050s / 19.107s | ~32,044,000
|
| this PR | 18.967s / 19.175s | 18.838s / 18.823s | ~30,988,000 |
| **delta** | ~−1 to −3% | ~−1.2% | **−1,056,000 (−3.3%)** |

The stable, reproducible win is **~1.06M fewer allocated objects per
full precompile (−3.3%)**. Wall-clock is consistently favorable but
smaller and noisier (~1–3%).

Split out of #1872.
@joelhawksley
joelhawksley deleted the perf/reduce-template-compilation-overhead branch August 17, 2026 19:32
@marcoroth

marcoroth commented Aug 17, 2026

Copy link
Copy Markdown
Owner

Just for context, the work from this pull request landed in #2199, #2217, #2232, #2235, #2234, and #2262.

marcoroth pushed a commit that referenced this pull request Aug 17, 2026
Compute `VisitorContext#relative_file_path` on demand instead of eagerly
during context construction.

`Herb::Engine` creates a context for every template, but the relative
path is primarily consumed by diagnostics, overlays, debug output, and visitors
that explicitly request it. Valid templates compiled without those features
previously still paid for `Pathname#absolute?`, path joining,
`Pathname#relative_path_from`, and `#to_s`.

The public behavior is unchanged: `relative_file_path`, context hash
access, merging, inspection, and serialization still return the same value. A
regression test verifies that derivation does not happen during initialization.

Split out of #1872.

## Benchmark

Measured `Herb::Engine` compilation over
[`marcoroth/herb-corpus`](https://github.com/marcoroth/herb-corpus) at
`5560d823`, using Ruby 4.0.2.

The benchmark compiled the 36,046 corpus templates accepted by both
variants with a filename and project path, `escape: true`, no visitors, non-strict
parsing, and Ruby validation disabled. Results are medians of three full-corpus
runs:

| metric            | `main` (`cc3eb8bc`) | this branch | delta                   |
|-------------------|:--------------------|:------------|:------------------------|
| allocated objects | 88,907,732          | 79,115,256  | **-9,792,476 (-11.0%)** |
| wall time         | 9.21s               | 7.94s       | **-13.8%**              |
@marcoroth marcoroth added the optimization Compile-time and generated-output optimizations label Aug 17, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

c C source for the core parser, lexer, and AST c-extension Ruby C extension in ext/ engine Herb engine and Rails template compilation optimization Compile-time and generated-output optimizations rbs RBS type signatures in sig/ ruby Ruby source for the gem and its libraries rubygem The herb RubyGem and its packaging

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants