Ruby: Intern token and AST node type strings - #2234
Conversation
Every AST node and Token was materialized in the C extension with freshly
allocated type/value strings (rb_utf8_str_new, no dedup), even though those
bytes are drawn from tiny, highly repetitive vocabularies:
- token & node *type* names ("TOKEN_NEWLINE", "AST_HTML_TEXT_NODE", ...) come
from fixed enums, so every token/node reallocated an identical string.
- the overwhelming majority of token *values* are short, structural literals
("\n", "<", ">", "%>", "=", quotes, tag/attribute names) that repeat across
the vast majority of tokens.
Type names are cached by enum value -- an O(1) array indexed by token type, and
a pinned per-builder static for each node type -- and interned once via Ruby's
fstring table. Short token values are interned via rb_enc_interned_str. Long
token values (ERB code, prose text runs) remain effectively unique, so those
keep allocating a fresh, mutable String as before.
Caching the type names by enum keeps the hot path both allocation-free and
probe-free: after the first token/node of a given type, its type String is
returned by a direct lookup with no fstring hashing, so the allocation win comes
at roughly neutral wall time.
Because interned values are frozen, the two in-repo consumers that mutated a
token value in place are made copy-on-write:
- Engine::Compiler trim helpers take a mutable copy before trimming (unary +@,
a no-op unless the value is frozen).
- Token#tree_inspect coerces a copy for display instead of force_encoding-ing
the value in place.
Measured over a large real-world .erb view corpus (~6k templates, Ruby 4.0.5):
lex string allocations -93.6%, parse string allocations -77.8%, and full
Herb::Engine compilation -10.5% total objects allocated at roughly neutral wall
time. Full test suite green.
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
There was a problem hiding this comment.
Thanks @joelhawksley!
I was curious where the win actually comes from, so I built three variants off this branch and ran parse + lex over all 37,433 files in the corpus:
| variant | allocations | vs baseline | median |
|---|---|---|---|
| no interning | 147,906,914 | - | 11.266s |
| types only | 126,795,736 | -14.3% | 10.729s |
| types + short values | 110,954,929 | -25.0% | 10.523s |
Interning only the type names accounts for 21.1M of the 37.0M saved allocations, so 57% of the win. The short values add the remaining 15.8M.
That makes me lean towards keeping just the type interning. All four frozen-value breakages were on token.value, never on token.type. Type names come from a closed enum and nothing outside the extension writes to them, so it needs no attr_accessor :value and no frozen-string contract to document. It also avoids token.value.frozen? depending on how long the expression is.
Would you be up for splitting it, landing the type interning and then let's see what we can do about the short values as their own PR?
Thank you! 🙏🏼
Restore independently allocated mutable token values and remove the copy-on-write compatibility changes that short-value interning required. Add focused coverage for shared frozen type strings and unchanged token value semantics. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
|
@marcoroth done: #2328 |
Co-authored-by: Marco Roth <marco.roth@intergga.ch> Signed-off-by: Marco Roth <marco.roth@intergga.ch>
What
Intern the type strings that the Ruby C extension attaches to every
Herb::Tokenand AST node. Token and node type names come from closed enums, so identical names are now shared frozen strings instead of being allocated for every object.Token types are cached by enum value, while each generated AST node builder caches its constant type. Both paths avoid repeated fstring-table probes after their first use.
Token values are unchanged: each value remains independently allocated and mutable, with the existing read-only
Token#valueAPI and RBS contract.Why
Type interning captures most of the allocation reduction without introducing frozen-value compatibility concerns or changing token value semantics.
Benchmark
Measured over the herb-corpus (36,989
.erbfiles), building both sides from source and taking the median of 5 runs. Allocations areGC.stat(:total_allocated_objects)deltas.Herb.parse, mainHerb.parse, this branchHerb.lex, mainHerb.lex, this branch21,032,485 fewer allocations (−14.3%) across the corpus.
Lexing gains more wall clock because a token type string is a larger share of the work per token than a node type string is per node.
Testing