Intern token/node type strings and short token values - #2234
Open
joelhawksley wants to merge 3 commits into
Open
Intern token/node type strings and short token values#2234joelhawksley wants to merge 3 commits into
joelhawksley wants to merge 3 commits into
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.
joelhawksley
marked this pull request as ready for review
August 14, 2026 20:17
joelhawksley
marked this pull request as draft
August 14, 2026 20:22
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
joelhawksley
marked this pull request as ready for review
August 17, 2026 19:20
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.
What
Intern the strings that the C extension attaches to every
Herb::Tokenand ASTnode, so identical bytes are shared instead of re-allocated on every parse/lex.
Two categories are interned into Ruby's global fstring table via
rb_enc_interned_str:"TOKEN_NEWLINE","AST_HTML_TEXT_NODE", …).These come from fixed enums, so previously every token/node re-allocated an
identical
String."\n","<",">","%>","=", quotes, tag andattribute names). The overwhelming majority of token values are short,
structural literals drawn from a tiny vocabulary that repeats across most
tokens.
Long token values (ERB code, prose text runs) are effectively unique, so they
keep allocating a fresh, mutable
Stringas before — a length threshold(
HERB_MAX_INTERNED_TOKEN_VALUE_LENGTH = 16) draws the line and keeps thefstring table bounded.
Why
Building the Ruby token/AST objects allocated one fresh
Stringper node type,one per token type, and one per token value — even though ~96% of those bytes
are duplicates from tiny vocabularies. Interning collapses them onto shared
frozen instances.
Frozen-value safety
Interned strings are frozen, so the two in-repo consumers that mutated a token
value in place are made copy-on-write:
Engine::Compilerwhitespace-trim helpers take a mutable copy before trimming(
text = +@tokens.last[1]/+token[1]— a no-op unless the value is frozen,so it only allocates on the rare trim path when the value is shared).
Token#tree_inspectcoerces a copy for display instead of callingforce_encodingon the value in place.The full test suite passes (
2386 runs, 5462 assertions, 0 failures, 0 errors).Benchmarks
Compiling a large real-world
.erbview corpus (~6,000 templates, ~10 MB, fromthe github.com monolith) with
Herb::Engine, on Ruby 4.0.5, measuringGC.stat(:total_allocated_objects):T_STRINGallocationsT_STRINGallocationsHerb::Enginecompile(
T_STRINGfigures measured over a 600-template subset with GC disabled;totals over the full corpus.)
Measured end-to-end through the actual Rails view precompiler (github.com's
ViewPrecompiler.precompile, which compiles.html.erbviaHerb::Enginebehind
reactionview), compiling 4,056 templates on Ruby 4.0.5:match?change too)The allocation reduction is deterministic. Because the type names are cached
by enum value (an O(1) array for tokens, a pinned per-builder static for nodes),
the hot path is both allocation-free and fstring-probe-free, so the win comes at
roughly neutral wall time — an isolated
Herb::Engine-over-the-corpus loopactually times a hair faster than stock (~6.24s vs ~6.28s). Only the variable
token values still hit the fstring table, and only when short.
Notes for reviewers
templates/ext/herb/nodes.c.erb; the generatedext/herb/nodes.cis a build artifact.rb_enc_interned_stris used directly only for the variable, short token values.