Skip to content

perf: regex hot-path optimizations — ~2x throughput improvement - #1905

Merged
fglock merged 4 commits into
masterfrom
feature/regex-perf-hot-path
Oct 27, 2026
Merged

fglock merged 4 commits into
masterfrom
feature/regex-perf-hot-path

Conversation

@fglock

@fglock fglock commented Oct 27, 2026 •

Copy link
Copy Markdown
Owner

Summary

Reduces per-match regex overhead in the hot path ($i =~ /42/ benchmark).
Async-profiler showed the Joni regex engine was only ~2% of CPU — the gap
was in runtime plumbing: ThreadLocal operations, object allocations, redundant
lookups.

Round 1 — +67% throughput

  • Short-circuit emitWarningsOnUse when warning list is empty (saves 4 ThreadLocal reads/match)
  • Guard ACTIVE_DEBUG_MODE save/restore behind lexicalDebugMode != 0 (saves 2 ThreadLocal ops/match)
  • Defer RuntimeList allocation to LIST context only (eliminates allocation in SCALAR path)
  • Eliminate double resolveRegex call in matchRegex → matchRegexDirect
  • Use matcherNoRegion and pre-allocate captures Region in JoniRegexPattern
  • Skip package/source-lifecycle save/restore when values unchanged

Round 2 — +101% total throughput

  • Eliminate Integer boxing in callsite warning ThreadLocals (int[]/String[] holders)
  • Use callsiteId directly as regex cache key, removing string-concatenation and extra HashMap lookup
  • Pass regexState to capture-group update methods to avoid redundant state() ThreadLocal calls
  • Cache regexState in updateReplacementMatchState (was calling state() 8+ times)

Round 3 — +111% total throughput

  • Skip expensive buildCharToByte/buildByteToChar for ASCII-only strings (identity mapping)
  • Inline warningsOnUse.isEmpty() check at call sites to avoid method call overhead
  • Check regex callsite cache before reading package/lifecycle ThreadLocals, saving 2 TL reads per match

Benchmark results

dev/bench/benchmark_regex.pl — $i =~ /42/ in 400-iteration loop, 100k calls:

Rate (iterations/s) vs native Perl
Baseline ~10,453/s 3.8x slower
Round 1 ~17,430/s 2.3x slower
Round 2 ~21,000/s 1.9x slower
Round 3 ~22,100/s 1.8x slower
Native Perl ~39,683/s —

Test plan

  • make — all unit tests pass (all 3 rounds)
  • Benchmark shows consistent improvement across multiple runs
  • Async-profiler confirms overhead reduction in targeted methods

Closes #1904

🤖 Generated with Claude Code

@fglock fglock changed the title perf: reduce per-match regex overhead by ~67% perf: regex hot-path optimizations — ~2x throughput improvement Oct 27, 2026
fglock and others added 4 commits October 27, 2026 17:51
Eliminate unnecessary ThreadLocal operations, object allocations and
redundant method calls on the regex match hot path:

- Short-circuit emitWarningsOnUse() when warningsOnUse list is empty
- Guard ACTIVE_DEBUG_MODE save/restore with lexicalDebugMode != 0 check
- Defer RuntimeList allocation to LIST context only (SCALAR skips it)
- Eliminate double resolveRegex call between matchRegex and matchRegexDirect
- Pre-allocate and reuse Joni captures Region instead of allocating per match
- Use matcherNoRegion to skip unnecessary Joni-level Region allocation
- Skip getQuotedRegexInPackage ThreadLocal save/restore when values unchanged

Benchmark ($i =~ /42/ in 400-iteration loop, 100k times):
  Before: ~10,453/s
  After:  ~17,430/s  (+67%)

Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Additional hot-path optimizations on top of the first round:

- Eliminate Integer boxing in callsite warning state ThreadLocals by
  using int[]/String[] holder arrays (one allocation per thread)
- Use callsiteId directly as cache key in getQuotedRegex, removing
  the string-concatenation indirection and extra HashMap lookup
- Pass regexState to updateLastNamedCaptureGroups and
  updateNumberedCaptureGroups to avoid redundant state() ThreadLocal lookups
- Cache regexState in updateReplacementMatchState to avoid 8 redundant
  state() calls

Benchmark ($i =~ /42/ in 400-iteration loop, 100k times):
  Baseline:  ~10,453/s
  Round 1:   ~17,430/s  (+67%)
  Round 2:   ~21,000/s  (+101% total, gap vs Perl 5 narrowed from 3.8x to 1.9x)

Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
- Skip expensive buildCharToByte/buildByteToChar for ASCII-only strings
  (bytes.length == input.length implies identity mapping)
- Inline warningsOnUse.isEmpty() check at call sites to avoid method
  call overhead when warning list is empty (common case)
- Check regex callsite cache before reading package/lifecycle ThreadLocals
  in getQuotedRegexInPackage, saving 2 TL reads per match on cache hit

Benchmark ($i =~ /42/ in 400-iteration loop, 100k times):
  Baseline:  ~10,453/s
  Round 1:   ~17,430/s  (+67%)
  Round 2:   ~21,000/s  (+101%)
  Round 3:   ~22,100/s  (+111% total, gap vs Perl 5: 1.8x)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
matcherNoRegion passes null for the Region, so
Matcher.captureBegin(n) for n > 0 calls getEagerRegion() which
creates a minimal Region with only group 0 — $1 and higher are
silently lost.

Use regex.matcher(bytes) (with Region) when captureCount > 0;
keep the faster matcherNoRegion path for patterns with no captures.

Adds regression test regex_define_capture.t covering recursive
DEFINE patterns and simple multi-group captures.

Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
@fglock
fglock force-pushed the feature/regex-perf-hot-path branch from 6190a82 to ad62884 Compare October 27, 2026 16:51
@fglock
fglock merged commit 16b5a7d into master Oct 27, 2026
2 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.

Regex match performance: ~4x slower than native Perl

1 participant