Skip to content

perf(strscan): make the compile cache thread-safe (sync.Map) - #1

Merged
tannevaled merged 1 commit into
mainfrom
compile-cache
Jun 30, 2026
Merged

perf(strscan): make the compile cache thread-safe (sync.Map)#1
tannevaled merged 1 commit into
mainfrom
compile-cache

Conversation

@tannevaled

Copy link
Copy Markdown
Contributor

What

The compiled-regexp cache that lets a repeated pattern source compile only once — instead of recompiling via onigmo.Compile on every Scan/Match/Skip/Check (and *Until) — was backed by a plain, unsynchronized map[string]*onigmo.Regexp. Caching by the source string is correctness-safe (the flags a caller wants are embedded inline in the source, e.g. (?imx)..., so the compiled regexp for a given source is deterministic), but the bare map raced under concurrent Scanners: go test -race reliably reports a data race when many goroutines scan at once, and a concurrent map write can crash the process.

The fix

Replace the map with a process-wide sync.Map, consulted at the single onigmo.Compile site:

  • Load on the read-mostly hot path is lock-free.
  • LoadOrStore collapses concurrent compile-misses of the same source to one shared *Regexp (losers drop their duplicate), so every Scanner reuses the same compiled value.
  • Compile errors are not cached and are still surfaced as "no match", keeping error behavior byte-for-byte identical to compiling every call.

Results are unchanged — caching is purely internal. The shared *Regexp's Match is itself concurrency-safe, so Scanners can share one compiled pattern.

Tests added

  • strscan_bench_test.go — a lexer-shaped scan loop, cached vs recompile-every-call, to quantify the win.
  • TestCompileCacheConcurrent — many Scanners, many goroutines: the permanent -race regression guard (fails on the old map, passes on sync.Map).
  • TestCompileCacheMalformedNotCached — a malformed pattern keeps missing identically, and a later valid pattern still compiles.

Benchmark (Apple M4 Max, go1.26.4, CGO_ENABLED=0)

Full tokenizing pass over a representative input with a small fixed pattern set:

benchmark ns/op B/op allocs/op
ScanLoopCached (this PR) ~611,771 1,095,379 28,120
ScanLoopNoCache (recompile every Scan) ~27,846,799 46,283,964 669,327

The compile cache is ~45x faster and uses ~24x fewer allocations than recompiling on every Scan, with zero behavior change.

Conformance

  • 100% coverage maintained (compile 100%, total 100%).
  • gofmt / go vet / go test -race clean; CGO_ENABLED=0.
  • Builds on all 6 arches (amd64, arm64, riscv64, loong64, ppc64le, s390x).
  • The MRI-4.0.5 differential oracle still passes against real ruby 4.0.5.

🤖 Generated with Claude Code

The compiled-regexp cache that lets a repeated pattern source compile only
once (instead of recompiling on every Scan/Match/Skip/Check and *Until) was
backed by a plain, unsynchronized map. Caching by source string is
correctness-safe — flags are embedded inline in the source the caller passes
(e.g. "(?imx)..."), so the compiled regexp for a given source is
deterministic — but the bare map raced under concurrent Scanners: `go test
-race` reliably reports a data race when many goroutines scan at once, and a
concurrent map write can crash the process.

Replace the map with a process-wide sync.Map, consulted at the single
onigmo.Compile site:

- Load on the read-mostly hot path is lock-free.
- LoadOrStore collapses concurrent compile-misses of the same source to one
  shared *Regexp (losers drop their duplicate), so every Scanner reuses the
  same compiled value.
- Compile errors are still surfaced as "no match" and are NOT cached, keeping
  error behavior byte-for-byte identical to compiling every call.

Results are unchanged (caching is purely internal). The shared *Regexp's
Match is itself concurrency-safe, so Scanners can share one compiled pattern.

Add strscan_bench_test.go (a lexer-shaped scan loop, cached vs recompile-every-
call) and tests for concurrent scanning (the -race regression guard) and the
malformed-not-cached path.

Benchmark (Apple M4 Max, go1.26.4, CGO_ENABLED=0), full tokenizing pass:

  ScanLoopCached     ~0.61 ms/op    1,095,383 B/op     28,120 allocs/op
  ScanLoopNoCache   ~28.0  ms/op   46,284,000 B/op    669,328 allocs/op

i.e. the compile cache is ~45x faster and ~24x fewer allocations than
recompiling every Scan, with zero behavior change.

100% coverage, gofmt/go vet/-race clean, CGO=0, builds on all 6 arches; the
MRI-4.0.5 differential oracle still passes.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@tannevaled
tannevaled merged commit 10bd95a into main Jun 30, 2026
9 checks passed
@tannevaled
tannevaled deleted the compile-cache branch June 30, 2026 06:02
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