Skip to content

perf(strscan): re-pin @main for O(n) anchored scans (fix O(n²) tokenize) - #89

Merged
tannevaled merged 1 commit into
mainfrom
perf/strscan-anchored
Jul 1, 2026
Merged

tannevaled merged 1 commit into
mainfrom
perf/strscan-anchored

Conversation

@tannevaled

Copy link
Copy Markdown
Contributor

Summary

Closes the largest perf gap in the go-ruby-* ecosystem: StringScanner tokenize was O(n²) (~80× slower than MRI, super-linear). This PR re-pins the fixed go-ruby-strscan and proves the O(n²)→O(n) linearization end-to-end in rbgo.

Root cause (in the library, now fixed)

The anchored scan primitives (scan/skip/match?/check) matched by calling re.Match(str[pos:]) and then rejecting a result that didn't start at the cursor. re.Match scans forward to the first match, so every anchored scan was O(remaining length). A lexer that tries several alternative patterns per position pays that tail scan on each miss, making a full tokenize O(n²).

The fix

go-ruby-strscan now uses go-ruby-regexp's Regexp.MatchAt(s, pos) — a cursor-anchored match (\G bound to pos, whole string visible so ^/\A/lookbehind see the real prefix) that tries a single match at the cursor: O(match length), not O(tail). Semantics stay byte-identical to MRI StringScanner (the differential-vs-MRI oracle still passes).

  • lib: go-ruby-strscan/strscan d0cc926 (split matchAt into matchAnchored via MatchAt and matchForward for the inherently-forward *_until search); 100% coverage under -race, 6-arch, gofmt/vet clean, MRI oracle byte-identical.
  • rbgo binding: no code change needed — it delegates directly to sc.Scan/sc.Skip/sc.Match/sc.Check, which now take the fast path. Only the dependency pin changes (go-ruby-regexp bumps to fa3d6604 transitively for MatchAt).

Measured (rbgo vs MRI 4.0.5, identifier-lexer tokenize; alternatives miss at cursor and scan the tail)

input MRI (µs) before (µs) before ÷ MRI after (µs) after ÷ MRI
×1 (10.7 KB) 1661 12359 7.4× 7237 4.4×
×2 (21.5 KB) 2895 35870 12.4× 14004 4.8×
×4 (43.0 KB) 5745 114870 20.0× 27861 4.9×
×8 (86.0 KB) 11689 410307 35.1× 55545 4.8×

Before: the ratio grows with input size (7.4→35.1×), per-doubling ~2.9–3.6× — quadratic. After: the ratio is flat (~4.8×), per-doubling ~1.99× — linear. At ×8 the fix is already 7.4× faster than before and the gap widens with every doubling. The residual ~4.8× is flat interpreter overhead (dispatch/boxing), not the algorithmic bug.

Notes

🤖 Generated with Claude Code

go-ruby-strscan's anchored scan primitives (scan/skip/match?/check) were
O(remaining length) per call: they matched by re.Match(str[pos..]) and then
rejected a result that did not start at the cursor, so re.Match first scanned
forward through the tail. A lexer that tries several alternative patterns per
position paid that tail scan on every miss, making a full tokenize O(n^2) —
the largest perf gap in the ecosystem (~80x slower than MRI, super-linear).

The library now uses go-ruby-regexp's Regexp.MatchAt(s, pos), a cursor-anchored
match (\G bound to pos, whole string visible for ^/\A/lookbehind) that tries a
single match at the cursor: O(match length), not O(tail). Semantics stay
byte-identical to MRI StringScanner (the differential-vs-MRI oracle passes).

This binding routes through the fast path automatically: it delegates directly
to sc.Scan/sc.Skip/sc.Match/sc.Check, so only the dependency pin changes.
Re-pinned github.com/go-ruby-strscan/strscan @main (d0cc926); go-ruby-regexp
bumps to fa3d6604 transitively for MatchAt. Full internal suite green, all 6
arches build.

Measured rbgo-vs-MRI 4.0.5 tokenize (identifier lexer, alternatives-miss-scan
tail), input x1/x2/x4/x8:
  before: 7.4x / 12.4x / 20.0x / 35.1x  (ratio grows -> O(n^2))
  after:  4.4x /  4.8x /  4.9x /  4.8x  (ratio flat  -> O(n))
At x8 the fix is 7.4x faster than before and the gap widens with input size;
the residual ~4.8x is flat interpreter overhead, not the algorithmic bug.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@tannevaled
tannevaled merged commit 116773e into main Jul 1, 2026
9 checks passed
@tannevaled
tannevaled deleted the perf/strscan-anchored branch July 1, 2026 05:16
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant