Skip to content

Parser: Add track_locations option to skip source-location objects - #2199

Merged
marcoroth merged 6 commits into
marcoroth:mainfrom
joelhawksley:perf/track-locations-parse-option
Aug 13, 2026
Merged

Parser: Add track_locations option to skip source-location objects#2199
marcoroth merged 6 commits into
marcoroth:mainfrom
joelhawksley:perf/track-locations-parse-option

Conversation

@joelhawksley

Copy link
Copy Markdown
Contributor

Summary

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

Herb.parse builds a Location (two Positions) — 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 Engine: Reduce template compilation overhead #1872 per the request to land track_locations as its own PR.

@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/ rubygem The herb RubyGem and its packaging labels Aug 12, 2026
@joelhawksley
joelhawksley force-pushed the perf/track-locations-parse-option branch 2 times, most recently from 3db3581 to b70351c Compare August 12, 2026 19:19
Comment thread ext/herb/extension.c Outdated
@joelhawksley
joelhawksley force-pushed the perf/track-locations-parse-option branch from b70351c to eaf5dfb Compare August 12, 2026 19:58
@github-actions github-actions Bot added parser HTML+ERB parser rbs RBS type signatures in sig/ labels Aug 12, 2026
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 lives in `parser_options_T` (default true via
HERB_DEFAULT_PARSER_OPTIONS) and is threaded through the Ruby AST builders —
`rb_node_from_c_struct`, the generated `rb_*_from_c_struct` node builders,
`rb_nodes_array_from_c_array`, and `rb_token_from_c_struct` — via an explicit
`bool track_locations` parameter rather than a file-scope global. The native
parse still computes locations (cheap value types the parser and analyzer rely
on internally); the option only gates the Ruby object materialization. The
resolved value is echoed on `Herb::ParserOptions#track_locations` and therefore
on `ParseResult#options`.

Also make `Herb::AST::Helpers#inline_ruby_comment?` independent of source
locations. It detected a single-line inline `# comment` by comparing
`node.location.start.line` and `node.location.end.line`, which raises once
`track_locations: false` leaves the location nil. This runs on the compiler's
happy path (`Compiler#visit_erb_content_node`), so without it templates with an
inline Ruby comment fail to compile when locations are off. Replace the line
comparison with an equivalent check on the node content (an inline comment spans
one source line iff its Ruby content has no newline), keeping compiled output
byte-identical whether or not locations are tracked.
@joelhawksley
joelhawksley force-pushed the perf/track-locations-parse-option branch from eaf5dfb to 9e7f518 Compare August 12, 2026 20:10
@github-actions github-actions Bot added the rust Rust bindings and the Herb Rust crate label Aug 12, 2026
@joelhawksley
joelhawksley marked this pull request as ready for review August 12, 2026 20:20
@joelhawksley

Copy link
Copy Markdown
Contributor Author

@marcoroth updated and marked ready for review! I'm purposefully going to do the perf PRs one at a time ❤️

@marcoroth marcoroth changed the title Engine: add track_locations parse option to skip source-location objects Parser: Add track_locations option to skip source-location objects Aug 13, 2026
@github-actions github-actions Bot added documentation Improvements or additions to documentation linter @herb-tools/linter for HTML+ERB templates wasm WebAssembly build and bindings typescript TypeScript source across the javascript/ packages printer @herb-tools/printer AST printing and lossless reconstruction playground The Herb playground web app node @herb-tools/node native Node.js addon node-wasm @herb-tools/node-wasm WebAssembly parser for Node.js java Java bindings and the org.herb package core @herb-tools/core shared AST nodes, interfaces, and utilities cpp C++ source, primarily the WebAssembly bindings labels Aug 13, 2026
@github-actions github-actions Bot added the rewriter @herb-tools/rewriter AST and string transformation system label Aug 13, 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.

Thanks for splitting this out @joelhawksley!

I pushed a few commits to your branch to make sure track_locations is treated as a parser option across all language bindings.

I changed the node and token builders take a parser_options_T instead of a track_locations. This should make it easier for future options without having to touch the signatures again.

The make this more visible, I updated the pretty print functions to render the locations as (location: ∅).

Also, added the option to the playground and the CLI via the --no-track-locations flag.

Image

And finally, in TypeScript, parse(source, { track_locations: false }) now returns a branded result that the Printer and Rewriter reject at compile time.

Please let me know if this still yields the results you were seeing in your benchmarks!

Thank you! 🙏🏼

@joelhawksley

Copy link
Copy Markdown
Contributor Author

Re-ran the benchmark on the current PR head (3c3b6b57)

I rebuilt the extension from the latest state of this branch (now including the parser_options_T threading and the Locationless brand) and re-ran a parse benchmark against a real corpus of github/github ERB templates.

Setup

  • Corpus: 7,169 .erb files from github/github (app/views/** + packages/**), 15.1 MB; 7,165 parse with zero errors and are used for the comparison (so both modes do identical work).
  • Call mirrors the ReActionView engine path: Herb.parse(src, track_whitespace: true), toggling only track_locations.
  • Ruby 4.0.5, herb 0.10.3 (this branch). Median of 7 rounds for wall-clock; allocations measured via GC.stat(:total_allocated_objects) (deterministic).

Results

Config Wall-clock (median) Allocations
track_locations: true (default) 3.94 s 15,816,110
track_locations: false 3.34 s 7,826,904
Delta ~14% faster (13.8–15.2% across runs) 50.5% fewer (−7,989,206 objects)

The allocation reduction is exactly reproducible run-to-run (50.5% fewer Ruby objects across the 7,165 templates); wall-clock lands at ~14% faster. This lines up with the original hypothesis that Location/Range/Position materialization is roughly half of the parse's Ruby-object allocations — skipping it when the caller never reads source locations is a clean, backward-compatible win.


Disclosure: this comment and the benchmark run were produced by Claude Opus 4.8 (GitHub Copilot), acting on behalf of @joelhawksley.

@marcoroth

Copy link
Copy Markdown
Owner

Sweet, thanks for the confirmation, @joelhawksley, merging! 🙏🏼

@marcoroth
marcoroth merged commit e14fe9a into marcoroth:main Aug 13, 2026
27 checks passed
@joelhawksley
joelhawksley deleted the perf/track-locations-parse-option branch August 13, 2026 15:58
@marcoroth marcoroth added the optimization Compile-time and generated-output optimizations label Aug 13, 2026
@marcoroth marcoroth added this to the v0.11.0 milestone Aug 13, 2026
marcoroth added a commit that referenced this pull request Aug 20, 2026
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` 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`](https://github.com/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…`).
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/ core @herb-tools/core shared AST nodes, interfaces, and utilities cpp C++ source, primarily the WebAssembly bindings documentation Improvements or additions to documentation java Java bindings and the org.herb package linter @herb-tools/linter for HTML+ERB templates node @herb-tools/node native Node.js addon node-wasm @herb-tools/node-wasm WebAssembly parser for Node.js optimization Compile-time and generated-output optimizations parser HTML+ERB parser playground The Herb playground web app printer @herb-tools/printer AST printing and lossless reconstruction rbs RBS type signatures in sig/ rewriter @herb-tools/rewriter AST and string transformation system ruby Ruby source for the gem and its libraries rubygem The herb RubyGem and its packaging rust Rust bindings and the Herb Rust crate typescript TypeScript source across the javascript/ packages wasm WebAssembly build and bindings

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants