Skip to content

Performance

Performance

go-ruby-strscan/strscan is the pure-Go, CGO-free library that rbgo binds for Ruby’s strscan. This page records a real, library-level benchmark of that module’s Go API against every reference runtime’s own C-extension StringScanner, one row per StringScanner operation. It is part of the ecosystem-wide per-module parity suite, and the bar is beating MRI + YJIT, not just plain MRI.

What is measured

Six representative StringScanner operations run over one fixed, deterministic corpus (a 2 752-byte lexer-shaped input, "foo123 + bar456 - baz789 * qux000 / quux ; " × 64):

OpWhat it exercises
scan-tokenizethe classic lexer loop: anchored scan(/…/) per token until eos? (4 reused patterns)
skipskip(/…/) alternating whitespace / non-whitespace runs
match?anchored, non-advancing match?(/…/) at every character position
scan_untilforward scan_until(/…/) hopping to and past each operator
getchconsume the corpus one character at a time
peekpeek(4) window at each position

The go-ruby column drives this pure-Go library through its Go API; every other column is that interpreter’s own stdlib strscan C extension. The Go and Ruby drivers build the identical corpus and, before any timing, each op’s integer checksum is verified byte-identical to MRI — all four runtimes and the Go driver agree on every op (e.g. scan-tokenize=1280 tokens, match?=6016, peek=11002). So the comparison is the same observable operation, apples-to-apples.

  • Host: Apple M4 Max, macOS (arm64-darwin). Date: 2026-07-03.
  • Runtimes: Go 1.26.4; ruby 4.0.5 +PRISM (MRI, the oracle) and ruby --yjit; jruby 10.1.0.0 (OpenJDK 25); truffleruby 34.0.1 (GraalVM CE Native).
  • Method: each process runs 3 untimed warm-up passes then 25 timed passes of a fixed inner loop, timed with a monotonic clock; the best pass is reported as ns/op. Interpreter start-up is outside the timed region, so the number is the operation’s own cost, not ruby file.rb process cost. Numbers were stable to within ~5 % across repeated runs.
  • Harness and drivers live in this repo under benchmarks/ (go/, ruby/strscan.rb, run.sh). Reproduce: bash benchmarks/run.sh.

Results (ns/op, best of 25)

These are the numbers after go-ruby-regexp landed its anchored class-run consumer — a fast path in the regexp VM that consumes a run of a single character class (\s+, \S+, [0-9]+, [A-Za-z0-9_]+, …) directly at the anchor with no per-byte VM dispatch. The scanner reaches it for free through the allocation-free bounds-only match API (MatchBoundsAt / MatchBounds) it already calls: no strscan code changed, only the go-ruby-regexp pin was bumped. The regexp-driven ops whose pattern is a single anchored class-repeat — skip, match?, and the class tokens inside scan-tokenize — take the win directly; capture-bearing MatchData is still rebuilt lazily only if the caller reads a group (StringScanner#[]), which a tokenizing lexer never does. Output stays byte-identical to MRI on every op, captures included.

Opgo-ruby (pure Go)MRIMRI + YJITJRubyTruffleRubygo vs YJIT
peek6 666245 935187 19560 65456 23128.1× faster ✅
getch5 516137 45099 58539 62768 88818.1× faster ✅
match?50 972330 070259 040166 611106 8115.08× faster ✅
skip34 785132 42096 91084 37029 7682.79× faster ✅
scan-tokenize167 708464 985327 880383 66957 1401.96× faster ✅
scan_until23 88427 09021 14016 5479 1231.13× slower

Before → after (this optimization)

Same host, same session shape, only the go-ruby-regexp pin changed — from the bounds-only match API (previous page revision) to the anchored class-run consumer. The single-class-repeat ops moved the most; skip and match? crossed from behind YJIT to well ahead of it:

Opgo vs YJIT beforego vs YJIT after
skip1.64× slower2.79× faster ✅
match?1.15× slower5.08× faster ✅
scan-tokenize1.35× faster1.96× faster ✅
scan_until1.08× slower1.13× slower (no class-run fast path)
getch19.0× faster18.1× faster (unchanged, non-regexp)
peek28.3× faster28.1× faster (unchanged, non-regexp)

skip was the whole point: it is the most match-setup-bound op (many short alternating \s+ / \S+ runs), so it gained the most once the VM stopped dispatching per byte — from 1.64× behind YJIT to 2.79× ahead. match? (a single anchored [A-Za-z0-9_]+ at every position) is the same shape and jumped from 1.15× behind to 5.08× ahead.

The go-vs-YJIT verdict, per op

Beats YJIT (5 of 6):

  • peek — 28.1× faster than YJIT (6 666 ns vs 187 195 ns).
  • getch — 18.1× faster than YJIT (5 516 ns vs 99 585 ns).
  • match? — 5.08× faster than YJIT (50 972 ns vs 259 040 ns).
  • skip — 2.79× faster than YJIT (34 785 ns vs 96 910 ns).
  • scan-tokenize — 1.96× faster than YJIT (167 708 ns vs 327 880 ns).

peek and getch are pure byte/rune cursor moves — a slice reslice plus a UTF-8 decode — so they leave every interpreter far behind. The three regexp-driven wins (match?, skip, scan-tokenize) all now beat YJIT because their hot pattern is a single anchored character-class repeat, exactly what the new class-run consumer collapses into one tight loop over the input bytes with no MatchData allocation and no per-byte VM step.

Still behind YJIT (1 of 6, but much closer):

  • scan_until — 1.13× slower than YJIT (beats plain MRI at 0.88×).

scan_until is the one op the class-run consumer does not accelerate: it is a forward search for the next operator, not an anchored class-repeat consume, so it still walks the regexp engine’s general search path. MRI’s strscan calls C Onigmo directly and YJIT additionally removes interpreter dispatch, leaving a residual ~13 % gap; it still beats plain MRI. Closing the last search-path gap is tracked in go-ruby-regexp. Output is byte-identical to MRI on every op; only scan_until throughput lags.

Net: 5 of the 6 operations now beat MRI + YJIT (peek, getch, match?, skip, scan-tokenize) — up from 2 — and the last one (scan_until) beats plain MRI and sits within ~13 % of YJIT.

Cold-JIT caveat

JRuby and TruffleRuby are measured in-process after 3 warm-up passes, but 3 passes is not full JIT steady state for the JVM / GraalVM — their columns still carry cold-to-warming-JIT cost and should be read as order-of-magnitude, not as fully-warmed peak throughput. TruffleRuby’s very low scan-tokenize in particular reflects aggressive Graal specialization of that tight loop. The go-vs-MRI and go-vs-YJIT comparisons are the load- bearing ones — MRI and YJIT reach steady state within the warm-up, and both they and the Go driver are timed identically. All numbers are real, single-host measurements from the 2026-07-03 run; nothing is cherry-picked.