Defer Range/Location/Position allocation - #2346
Open
joelhawksley wants to merge 5 commits into
Open
Conversation
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
force-pushed
the
joelhawksley-lazy-herb-token
branch
from
August 21, 2026 01:36
697d122 to
843a1eb
Compare
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
marked this pull request as draft
August 21, 2026 03:31
joelhawksley
marked this pull request as ready for review
August 21, 2026 15:30
This was referenced Aug 25, 2026
Closed
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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Follow-on to #2328.
Herb::Tokenwas 27% of live parse objects, with its backingRange/Location/Positionobjects accounting for another 33%.Herb.lexalone allocates ~86M objects across the herb-corpus benchmark.This defers construction of the
Range/Location/PositionRuby objects for each token until they're actually accessed viaHerb::Token#range/#location, instead of eagerly building them for every token during lex/parse.Update: rebased onto
mainafter #2234 merged, which independently added a very similar ivar-based (rb_obj_alloc+rb_ivar_set) direct construction path forPosition/Location/Range/Token, plus atrack_locationsparser option (skip range/location entirely) and interned/cached token type strings. This branch now builds on top of that: it keeps #2234'strack_locationsskip-path and type interning as-is, and adds the lazy deferral on top -- whentrack_locationsis enabled, the raw range/location components are still copied out cheaply, but theRange/Location/Positionwrapper objects are only materialized on first access to#range/#location, rather than unconditionally.Approach
rb_token_from_c_struct(inext/herb/extension_helpers.c) allocates theHerb::Tokeninstance directly (rb_obj_alloc) and, whenoptions->track_locationsis 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 constructingRange/Location/Positionobjects. Whentrack_locationsis false, no range/location ivars are set at all (matching Ruby: Intern token and AST node type strings聽#2234's skip behavior).Herb::Token#rangeand#location(inlib/herb/token.rb) lazily build and memoize theRange/Locationobjects from the raw ivars on first access, and returnnilwhen the raw components were never set (i.e.track_locations: false), rather than constructing a bogusRange.new(nil, nil).@typeis set viarb_token_type_value(interned/cached, from Ruby: Intern token and AST node type strings聽#2234) rather than re-allocating a string per token.token_T/ the C-side token struct, arena lifetime, orvaluehandling -- those remain eagerly copied out as before. This intentionally does not attempt lazyvaluematerialization, 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_rawclass method call from C (rb_funcallv). Benchmarking showed the per-token method dispatch overhead outweighed the allocation savings forHerb.parse, which touches#range/#locationon nearly every token (AST node locations, error reporting, etc). Setting ivars directly from C keeps token construction as cheap as the previous eagerToken.newcall while still avoiding theRange/Location/Positionallocations until they're needed.Benchmarks
Measured against marcoroth/herb-corpus (36,989
.erbfiles),GC.stat(:total_allocated_objects)deltas andBenchmark.realtimewall clock, after a warmup pass andGC.start(median of 3 runs). Baseline is currentupstream/main(f5c0347e, includes #2234's own ivar-based construction,track_locations, and type interning):main(post-#2234)Herb.lexallocationsHerb.lexwall timeHerb.parseallocationsHerb.parsewall time(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.