Skip to content

Defer Range/Location/Position allocation - #2346

Open
joelhawksley wants to merge 5 commits into
marcoroth:mainfrom
joelhawksley:joelhawksley-lazy-herb-token
Open

Defer Range/Location/Position allocation#2346
joelhawksley wants to merge 5 commits into
marcoroth:mainfrom
joelhawksley:joelhawksley-lazy-herb-token

Conversation

@joelhawksley

@joelhawksley joelhawksley commented Aug 21, 2026

Copy link
Copy Markdown
Contributor

Summary

Follow-on to #2328. Herb::Token was 27% of live parse objects, with its backing Range/Location/Position objects accounting for another 33%. Herb.lex alone allocates ~86M objects across the herb-corpus benchmark.

This defers construction of the Range/Location/Position Ruby objects for each token until they're actually accessed via Herb::Token#range / #location, instead of eagerly building them for every token during lex/parse.

Update: rebased onto main after #2234 merged, which independently added a very similar ivar-based (rb_obj_alloc + rb_ivar_set) direct construction path for Position/Location/Range/Token, plus a track_locations parser option (skip range/location entirely) and interned/cached token type strings. This branch now builds on top of that: it keeps #2234's track_locations skip-path and type interning as-is, and adds the lazy deferral on top -- when track_locations is enabled, the raw range/location components are still copied out cheaply, but the Range/Location/Position wrapper objects are only materialized on first access to #range/#location, rather than unconditionally.

Approach

  • rb_token_from_c_struct (in ext/herb/extension_helpers.c) allocates the Herb::Token instance directly (rb_obj_alloc) and, when options->track_locations is true, sets ivars from the raw numeric range/location components (token->range.from/to, token->location.start/end.line/column) using cached ivar IDs, instead of eagerly constructing Range/Location/Position objects. When track_locations is false, no range/location ivars are set at all (matching Ruby: Intern token and AST node type strings聽#2234's skip behavior).
  • Herb::Token#range and #location (in lib/herb/token.rb) lazily build and memoize the Range/Location objects from the raw ivars on first access, and return nil when the raw components were never set (i.e. track_locations: false), rather than constructing a bogus Range.new(nil, nil).
  • @type is set via rb_token_type_value (interned/cached, from Ruby: Intern token and AST node type strings聽#2234) rather than re-allocating a string per token.
  • No changes to token_T / the C-side token struct, arena lifetime, or value handling -- those remain eagerly copied out as before. This intentionally does not attempt lazy value materialization, which would require keeping the arena alive per-token and was judged too risky for the win.

An earlier iteration routed token construction through a Ruby-level Herb::Token.from_raw class method call from C (rb_funcallv). Benchmarking showed the per-token method dispatch overhead outweighed the allocation savings for Herb.parse, which touches #range/#location on nearly every token (AST node locations, error reporting, etc). Setting ivars directly from C keeps token construction as cheap as the previous eager Token.new call while still avoiding the Range/Location/Position allocations until they're needed.

Benchmarks

Measured against marcoroth/herb-corpus (36,989 .erb files), GC.stat(:total_allocated_objects) deltas and Benchmark.realtime wall clock, after a warmup pass and GC.start (median of 3 runs). Baseline is current upstream/main (f5c0347e, includes #2234's own ivar-based construction, track_locations, and type interning):

main (post-#2234) this branch delta
Herb.lex allocations 73.72M 24.70M -66%
Herb.lex wall time ~3.05s ~1.87s -39%
Herb.parse allocations 52.60M 33.71M -36%
Herb.parse wall time ~4.67s ~4.21s -10%

(Note: main's own numbers already reflect #2234's independent allocation/interning improvements over the pre-#2234 baseline used in the original benchmark table below -- this branch's deltas are measured on top of that, not in addition to it.)

Testing

  • bundle exec rake test -- 2435 runs, 5602 assertions, 0 failures, 0 errors.
  • bundle exec rubocop lib/herb/token.rb -- no offenses.

Opening as a draft since this touches a hot path in the C extension and I'd like feedback on the ivar-based construction approach before merging.

@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/ rbs RBS type signatures in sig/ rubygem The herb RubyGem and its packaging labels Aug 21, 2026
Follow-on to marcoroth#2328. Herb::Token was 27% of live parse objects, with its
backing Range/Location/Position objects accounting for another 33%.
This changes rb_token_from_c_struct to allocate the Herb::Token
instance directly and set its ivars from the raw numeric range/location
components (via cached ivar IDs) instead of eagerly constructing
Range/Location/Position Ruby objects for every token. Those objects are
now materialized lazily, memoized on first access to #range/#location.

Benchmarked against marcoroth/herb-corpus (36,989 .erb files):

  Herb.lex:
    allocations: 85.98M -> 36.95M (-57%)
    wall time:    3.17s ->  2.13s (-33%)

  Herb.parse:
    allocations: 62.22M -> 42.48M (-32%)
    wall time:    4.93s ->  4.95s (~flat)

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
@joelhawksley
joelhawksley force-pushed the joelhawksley-lazy-herb-token branch from 697d122 to 843a1eb Compare August 21, 2026 01:36
@joelhawksley
joelhawksley marked this pull request as ready for review August 21, 2026 01:37
- sig/herb/token.rbs: mark #range/#location return types as nilable
  (Range?/Location?) to match track_locations: false behavior.
- lib/herb/token.rb: assign range/location to a local before calling
  methods on them in #tree_inspect and #colorize_range, since Steep
  can't narrow the type across repeated method calls on the same
  nilable method.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot-Session: a082a628-d609-4e4e-8315-6f71d1f0fe59
@marcoroth
marcoroth marked this pull request as draft August 21, 2026 03:31
@joelhawksley
joelhawksley marked this pull request as ready for review August 21, 2026 15:30
This was referenced Aug 25, 2026
@joelhawksley joelhawksley changed the title Lazy/flyweight Herb::Token: defer Range/Location/Position allocation Defer Range/Location/Position allocation Aug 25, 2026
@joelhawksley

Copy link
Copy Markdown
Contributor Author

@marcoroth thoughts on getting this landed? I'm seeing rough a 2x speedup in per-thread performance with this change.

The '--all-rules > applies when the run is split across workers' test
runs two full parallel linter invocations (~7.6s in CI) and was hitting
the default 5000ms Vitest timeout. The sibling test in the '`all`
pseudo rule in .herb.yml' block already uses a 10_000ms timeout for the
same pattern; apply the same here.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
@github-actions github-actions Bot added linter @herb-tools/linter for HTML+ERB templates typescript TypeScript source across the javascript/ packages labels Aug 25, 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/ linter @herb-tools/linter for HTML+ERB templates rbs RBS type signatures in sig/ ruby Ruby source for the gem and its libraries rubygem The herb RubyGem and its packaging typescript TypeScript source across the javascript/ packages

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant