Skip to content

Engine: Skip location tracking when nothing asks for it - #2317

Merged
marcoroth merged 6 commits into
mainfrom
engine/compile-allocations
Aug 20, 2026
Merged

marcoroth merged 6 commits into
mainfrom
engine/compile-allocations

Conversation

@marcoroth

@marcoroth marcoroth commented Aug 20, 2026 •

Copy link
Copy Markdown
Owner

This pull request stops the engine from building a Location, two Positions and a Range for every node and token when nothing is going to read them.

Herb.parse attaches source locations to the whole tree. Profiling Herb::Engine.new with ObjectSpace allocation tracing showed those objects are the single largest allocation source in a compile, well ahead of anything in the compiler itself:

class objects share of a compile
Herb::Position 27,124 23.2%
Herb::Location 13,562 11.6%
Herb::Range 7,308 6.3%
total 47,994 41.1%

Locations are now off unless something asks for them. Compiler never reads node.location, so a template compiled without a visitor never needs one.

Letting a visitor say what it needs

Every built-in visitor that reads node.location declares required_parser_option track_locations: true, and Herb::Visitor.parser_options_for already folds those declarations into the options the engine parses with, so the engine needs no list of its own.

Herb::Engine::Diagnostics declares it on include. Any visitor that reports a diagnostic keeps its locations without knowing this option exists, including visitors from outside this repository, since reporting is the usual reason to want a location at all. The engine's own DiagnosticsTest caught this before the declaration was added.

A visitor that reads locations, reports nothing, and declares nothing gets nil. That is the one behaviour this trades away.

Building on #2199

#2199 added track_locations for callers that never read source locations, naming "rendering a template with validation disabled" as the case it had in mind. This turns that case on automatically, since the engine can tell when it applies without the caller having to know.

That pull request also did the work that makes this safe. Herb::AST::Helpers#inline_ruby_comment? used to compare node.location.start.line against node.location.end.line, and it runs on the compiler's happy path through Compiler#visit_erb_content_node, so it raised as soon as a location was nil. #2199 replaced that with an equivalent newline check on the node content. Without it, every template containing an inline <% # comment %> would fail to compile here.

Errors keep their locations

A parse error carries its own location and track_locations does not touch it, so the error path needs no second parse. Error messages are byte-identical with tracking off, carets and all.

Results

Measured over marcoroth/herb-corpus at 12cced87, the 35,881 templates of 36,989 that compile cleanly, Ruby 4.0.2. Samples interleaved with main:

metric main this branch delta
allocated objects 77,459,989 45,354,443 -32,105,546 (-41.4%)
objects per template 2,158 1,264 -41.4%
wall time 9.75s 8.10s -16.9%

Compiled output is byte-identical across the corpus: one SHA256 over all 35,881 compiled sources matches main exactly (3b3f9279…).

This pull request stops the engine from building a `Location`, two
`Position`s and a `Range` for every node and token when nothing is going to
read them.

`Herb.parse` attaches source locations to the whole tree. Profiling
`Herb::Engine.new` over the `examples/` corpus showed that those objects are
the single largest allocation source in a compile, well ahead of anything in
the compiler itself:

| class | objects | share of a compile |
| --- | ---: | ---: |
| `Herb::Position` | 27,124 | 23.2% |
| `Herb::Location` | 13,562 | 11.6% |
| `Herb::Range` | 7,308 | 6.3% |
| **total** | **47,994** | **41.1%** |

Nothing in the default compile path reads any of them. `Compiler` never
touches `node.location`, and `report` returns early when no visitor reports
diagnostics. The consumers are all visitors, which the caller supplies.

So when the visitor stack is empty, the engine now parses with
`track_locations: false`, the option #2199 added for exactly this. A caller
that sets `track_locations` itself still gets what it asked for, in either
direction.

#### Errors keep their locations

A parse error carries its own location, and `track_locations` does not touch
it. Error messages are byte-identical with tracking off, carets and all, so
the error path needs no second parse.

#### Results

Measured A/B interleaved against `main` over the `examples/` corpus:

| metric | `main` | this branch | delta |
| --- | ---: | ---: | ---: |
| allocated objects | 116,723 | 68,795 | **-47,928 (-41.1%)** |
| wall time | 11.43 ms | 9.00 ms | **-21%** |

Compiled output is byte-identical across all 128 `.erb` templates in the
repository, each compiled in three configurations.
`with_element_context` downcases the tag name it is given, and
`visit_html_element_node` then downcased the same tag name again inside the
block that context yields to. Every element paid for two `String`s where one
would do, since `downcase` allocates even when the name is already lowercase.

The context now yields the name it already has.

Exiting an element also built a fresh `["script", "style"]` on every call to
decide whether to pop a raw-text context. That array is now a frozen
constant.

Together this is 1,910 fewer objects over the `examples/` corpus, about 2.8%
of what a compile allocates after #2199's location option is applied.
Compiled output is byte-identical across all 128 `.erb` templates in the
repository.
@github-actions github-actions Bot added ruby Ruby source for the gem and its libraries rbs RBS type signatures in sig/ engine Herb engine and Rails template compilation rubygem The herb RubyGem and its packaging labels Aug 20, 2026
@marcoroth marcoroth added optimization Compile-time and generated-output optimizations performance Speed and memory usage improvements labels Aug 20, 2026
@nx-cloud

nx-cloud Bot commented Aug 20, 2026 •

Copy link
Copy Markdown

View your CI Pipeline Execution ↗ for commit d6aaa74

Command Status Duration Result
nx run-many -t test --all --parallel --exclude=... ✅ Succeeded 3m 47s View ↗
nx run-many -t typecheck --all --parallel --exc... ✅ Succeeded 13s View ↗
nx run-many -t build --all --exclude=herb-langu... ✅ Succeeded 42s View ↗
nx build @herb-tools/tailwind-class-sorter ✅ Succeeded <1s View ↗

💡 Verify your cache is correct by running tasks in a sandbox. Read docs ↗


☁️ Nx Cloud last updated this comment at 2026-08-20 06:37:03 UTC

The rendered error message draws its gutter with a box character or an
ASCII pipe depending on the environment, so matching the message text
failed outside the environment it was written in. Assert on the
diagnostic's location instead.
`OptimizeVisitor` collects a location per element so the guard it compiles
in can say where an overwritten helper was used, but it only compiles that
guard when `verify` is on, and `verify` defaults to off. With the class
declaring the option, every Action View compile paid for a full set of
location objects to fill a hash nothing read.

The declaration mechanism resolves options from the visitor instance, so
the requirement can depend on how the visitor was built. `OptimizeVisitor`
now asks for `track_locations` only when it is going to verify.

Over the 35,875 corpus templates that compile with the visitor attached,
with `verify` left at its default:

| metric | before | after | delta |
| --- | ---: | ---: | ---: |
| allocated objects | 91,861,776 | 57,585,374 | -34,276,402 (-37.3%) |
| wall time | 13.56s | 11.44s | -15.6% |

Compiled output is byte-identical across those templates.
@marcoroth
marcoroth enabled auto-merge (squash) August 20, 2026 06:34
@marcoroth marcoroth changed the title Engine: Skip location tracking when no visitor can observe it Engine: Skip location tracking when nothing asks for it Aug 20, 2026
@marcoroth
marcoroth disabled auto-merge August 20, 2026 06:34
@marcoroth
marcoroth enabled auto-merge (squash) August 20, 2026 06:35
@marcoroth
marcoroth merged commit 7dd41b5 into main Aug 20, 2026
34 of 35 checks passed
@marcoroth
marcoroth deleted the engine/compile-allocations branch August 20, 2026 06:44
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

engine Herb engine and Rails template compilation optimization Compile-time and generated-output optimizations performance Speed and memory usage improvements 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.

1 participant