Skip to content

Ruby: Intern token and AST node type strings - #2234

Merged
marcoroth merged 6 commits into
marcoroth:mainfrom
joelhawksley:perf/intern-token-node-strings
Aug 21, 2026
Merged

Ruby: Intern token and AST node type strings#2234
marcoroth merged 6 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 type strings that the Ruby C extension attaches to every Herb::Token and 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#value API 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 .erb files), building both sides from source and taking the median of 5 runs. Allocations are GC.stat(:total_allocated_objects) deltas.

allocations delta wall clock (median) delta
Herb.parse, main 61,372,804 6.226s
Herb.parse, this branch 52,596,949 −14.3% 6.024s −3.2%
Herb.lex, main 85,981,356 4.516s
Herb.lex, this branch 73,724,726 −14.3% 4.034s −10.7%

21,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

  • Built the Ruby native extension after regenerating templates and vendoring Prism.
  • Added focused coverage that token and AST node type strings are frozen and shared, while token values remain mutable and independently allocated.
  • Ran the interning, diagnostics, HTML-safe assertions visitor, and engine test files.

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
@marcoroth marcoroth added the performance Speed and memory usage improvements label Aug 19, 2026
@marcoroth marcoroth changed the title Intern token/node type strings and short token values Ruby: Intern token/node type strings and short token values Aug 20, 2026

@marcoroth marcoroth left a comment

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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>
@joelhawksley joelhawksley changed the title Ruby: Intern token/node type strings and short token values Ruby: Intern token and AST node type strings Aug 20, 2026
@joelhawksley

Copy link
Copy Markdown
Contributor Author

@marcoroth done: #2328

Comment thread templates/ext/herb/nodes.c.erb Outdated
Comment thread ext/herb/extension_helpers.c Outdated
Comment thread ext/herb/extension_helpers.c Outdated
Co-authored-by: Marco Roth <marco.roth@intergga.ch>
Signed-off-by: Marco Roth <marco.roth@intergga.ch>

@marcoroth marcoroth left a comment

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thank you @joelhawksley! 🙏🏼

@marcoroth
marcoroth merged commit f5c0347 into marcoroth:main Aug 21, 2026
28 checks passed
@joelhawksley
joelhawksley deleted the perf/intern-token-node-strings branch August 21, 2026 01:22
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 performance Speed and memory usage improvements 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