Skip to content

Take the allocation-free bounds-only regexp path, rebuild captures lazily - #2

Merged
tannevaled merged 1 commit into
mainfrom
bounds-only-fast-path
Jul 3, 2026
Merged

Take the allocation-free bounds-only regexp path, rebuild captures lazily#2
tannevaled merged 1 commit into
mainfrom
bounds-only-fast-path

Conversation

@tannevaled

Copy link
Copy Markdown
Contributor

Summary

go-ruby-regexp gained a bounds-only match API (MatchBoundsAt / MatchBounds returning (begin, end, ok) with no MatchData) whose lazy-NFA subset runs on pooled, per-call-allocation-free state — built precisely for a StringScanner-style cursor. This wires strscan onto it.

  • Every regexp-driven op (Scan/ScanUntil/Skip/SkipUntil/Match/Check/CheckUntil) now takes the bounds-only primitive: the whole-match [beg,end) span with no MatchData allocation on the tokenizing hot path.
  • The capture-bearing MatchData is rebuilt lazily only when a group is actually read (Group/GroupName, i.e. Ruby's StringScanner#[]), reproducing the identical match: anchored ops via MatchAt(str, matchBeg) (byte-for-byte the old eager call), forward ops via Match(rest) over the same rest slice.
  • Byte-identical to MRI preserved: #[] still works after scan / scan_until / skip / match? / check_until (MRI exposes captures after all of them) — a new differential-oracle scenario asserts it against real MRI and covers the lazy forward-rebuild path.
  • Bumps the go-ruby-regexp pin to the match-optimized pseudo-version.

Impact (library-level benchmark, Apple M4 Max, same-session before→after)

go-vs-YJIT on the regexp-driven ops:

Op before after
scan-tokenize 1.64× slower 1.35× faster
scan_until 2.24× slower 1.07× slower (≈parity)
match? 2.44× slower 1.17× slower (beats plain MRI)
skip 4.15× slower 1.68× slower

Tests

  • go build / go vet / go test -race green; differential oracle vs real MRI 4.0.5 passes.
  • 100.0% statement coverage.
  • Cross-compiles CGO=0 on all 6 arches (amd64/arm64/riscv64/loong64/ppc64le/s390x).

🤖 Generated with Claude Code

…zily

go-ruby-regexp gained a bounds-only match API (MatchBoundsAt / MatchBounds
returning (begin, end, ok) with no MatchData) whose lazy-NFA subset runs on
pooled, per-call-allocation-free state. It exists precisely for a
StringScanner-style cursor: scan / skip / match? / scan_until re-match an
advancing cursor over capture-free class/quantifier patterns and almost never
read a capture group.

The scanner previously built a full capture-bearing MatchData on every
recording op (MatchAt / Match), paying a fresh capture array per token in the
tokenizing hot loop even though a lexer never inspects the groups. Switch every
regexp-driven op (Scan/ScanUntil/Skip/SkipUntil/Match/Check/CheckUntil) to the
bounds-only primitive, which yields the whole-match [beg,end) span with no
allocation, and defer building the MatchData until a group is actually read
(Group / GroupName, i.e. Ruby's StringScanner#[]).

The lazy rebuild reproduces the identical match the bounds-only path found:
anchored ops via MatchAt(str, matchBeg) — byte-for-byte the call the old eager
path made — and forward ops via Match(rest) over the same rest slice. So
captures stay byte-identical to MRI after every op, including scan_until / skip
/ match? / check_until (MRI's #[] works after all of them), while a scan/skip
loop that never touches a group now allocates nothing per token.

Bump the go-ruby-regexp pin to the match-optimized pseudo-version. Add a
differential-oracle scenario reading capture groups after scan_until / skip /
match? / check_until so the lazy forward-rebuild path is covered and its MRI
parity is asserted. Coverage stays at 100%.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@tannevaled
tannevaled merged commit 92eab52 into main Jul 3, 2026
9 checks passed
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