Skip to content

Intern token/node type strings and short token values - #2234

Open
joelhawksley wants to merge 3 commits into
marcoroth:mainfrom
joelhawksley:perf/intern-token-node-strings
Open

Intern token/node type strings and short token values#2234
joelhawksley wants to merge 3 commits into
marcoroth:mainfrom
joelhawksley:perf/intern-token-node-strings

Conversation

@joelhawksley

@joelhawksley joelhawksley commented Aug 14, 2026

Copy link
Copy Markdown
Contributor

What

Intern the strings that the C extension attaches to every Herb::Token and AST
node, 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 & node type names ("TOKEN_NEWLINE", "AST_HTML_TEXT_NODE", …).
    These come from fixed enums, so previously every token/node re-allocated an
    identical String.
  • Short token values ("\n", "<", ">", "%>", "=", quotes, tag and
    attribute 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 String as before — a length threshold
(HERB_MAX_INTERNED_TOKEN_VALUE_LENGTH = 16) draws the line and keeps the
fstring table bounded.

Why

Building the Ruby token/AST objects allocated one fresh String per 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::Compiler whitespace-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_inspect coerces a copy for display instead of calling
    force_encoding on the value in place.

The full test suite passes (2386 runs, 5462 assertions, 0 failures, 0 errors).

Benchmarks

Compiling a large real-world .erb view corpus (~6,000 templates, ~10 MB, from
the github.com monolith) with Herb::Engine, on Ruby 4.0.5, measuring
GC.stat(:total_allocated_objects):

path metric before after Δ
lex T_STRING allocations 103,332 6,645 −93.6%
parse T_STRING allocations 98,097 21,756 −77.8%
parse total objects 9.84M 7.75M −21.2%
lex total objects 11.35M 8.24M −27.4%
Herb::Engine compile total objects 20.99M 18.78M −10.5%

(T_STRING figures 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.erb via Herb::Engine
behind reactionview), compiling 4,056 templates on Ruby 4.0.5:

metric before after (with the small match? change too) Δ
objects allocated 21,154,648 19,201,428 −9.2%
wall time ~13.3s ~13.8s roughly neutral

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 loop
actually 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

  • The node-type change is in templates/ext/herb/nodes.c.erb; the generated
    ext/herb/nodes.c is a build artifact.
  • Type names are cached by enum (probe-free after first use); rb_enc_interned_str
    is used directly only for the variable, short token values.

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.
@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/ engine Herb engine and Rails template compilation rubygem The herb RubyGem and its packaging labels Aug 14, 2026
@joelhawksley
joelhawksley marked this pull request as ready for review August 14, 2026 20:17
@joelhawksley
joelhawksley marked this pull request as draft August 14, 2026 20:22
@marcoroth marcoroth added the optimization Compile-time and generated-output optimizations label Aug 15, 2026
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
@github-actions github-actions Bot added the rbs RBS type signatures in sig/ label Aug 17, 2026
@joelhawksley
joelhawksley marked this pull request as ready for review August 17, 2026 19:20
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/ engine Herb engine and Rails template compilation optimization Compile-time and generated-output optimizations rbs RBS type signatures in sig/ ruby Ruby source for the gem and its libraries rubygem The herb RubyGem and its packaging

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants