Parser: Add track_locations option to skip source-location objects - #2199
Conversation
3db3581 to
b70351c
Compare
b70351c to
eaf5dfb
Compare
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.
eaf5dfb to
9e7f518
Compare
|
@marcoroth updated and marked ready for review! I'm purposefully going to do the perf PRs one at a time ❤️ |
track_locations option to skip source-location objects
…d, update tree inspect functions
marcoroth
left a comment
There was a problem hiding this comment.
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.
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! 🙏🏼
Re-ran the benchmark on the current PR head (
|
| 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.
|
Sweet, thanks for the confirmation, @joelhawksley, merging! 🙏🏼 |
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…`).
Summary
Splits the
track_locationsparse option out of #1872 into its own PR, as requested.Herb.parsebuilds aLocation(twoPositions) — and aRange— 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/Positionaccount for roughly half of the parse's Ruby allocations.This adds an additive
track_locations:option toHerb.parse(defaulttrue, fully backward compatible). Whenfalse, the node/token builders leavelocationandrangeasnil. 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# commentby comparingnode.location.start.linetonode.location.end.line, which raises oncetrack_locations: falseleaves the locationnil. 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::Precompileragainst GitHub.com's views directory (Ruby 4.0.5 + PRISM), comparingtrack_locationson (current default) vs. off:End-to-end
ActionView::Precompiler(6,302 templates):track_locations: falseThe 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::Enginecompile of the same corpus (8,578.html.erbfiles, validation disabled):track_locations: falseCompiled
Herb::Engine#srcoutput was verified byte-for-byte identical across all corpus templates with locations on vs. off (0 mismatches, 0 skips).Notes
track_locations:option onHerb.parse.ext/herb/nodes.c,ext/herb/error_helpers.c) are not touched by this PR; the location builders they call already honor the flag.track_locationsas its own PR.