Skip to content

Optimize custom regex engine interpreter performance#2388

Merged
lahma merged 10 commits into
sebastienros:mainfrom
lahma:faster-regex
Apr 11, 2026
Merged

Optimize custom regex engine interpreter performance#2388
lahma merged 10 commits into
sebastienros:mainfrom
lahma:faster-regex

Conversation

@lahma

@lahma lahma commented Apr 11, 2026

Copy link
Copy Markdown
Collaborator

Summary

Optimize the custom JavaScript regex engine (QuickJS libregexp port) with interpreter-level and integration-layer improvements, delivering 20-22,000x speedup across common pattern shapes.

Interpreter optimizations

  • Multi-char literal substring search: Extract consecutive Char/CharI opcodes into a literal string and use SIMD-accelerated string.IndexOf(literal) / OrdinalIgnoreCase instead of single-char scanning with per-char bytecode retries
  • First-character IndexOf scan: Replace the bytecode scan loop (SplitGotoFirst/Any/Goto) with string.IndexOf/IndexOfAny for Char, CharI, and single-pair Range opcodes
  • Anchored pattern detection: Detect LineStart (^) and skip the scan loop entirely — only try position 0
  • Greedy dot-star bulk scan with LastIndexOf: Detect SplitNextFirst + Dot/Any + Goto loop and find the rightmost continuation match via LastIndexOf, pushing a single frame instead of ~65K
  • Range+ / Char+ quantifier bulk advance: Detect backward SplitGotoFirst to Range or Char (the + quantifier) and use IndexOfAnyExceptInRange (.NET 8+) to skip all matching characters at once
  • Greedy quantifier frame pruning: Skip pushing backtrack frames in both SplitNextFirst and SplitGotoFirst when the continuation's first Char or Range opcode can't match the current input position
  • Dot-star with Range continuation: Extend the greedy dot-star optimization to also handle Range opcodes at the continuation, enabling patterns like /.*\d/u
  • Span.CopyTo in PushFrame/PopFrame: Replace element-by-element copy loops with SIMD-optimized bulk copy
  • Unsafe.ReadUnaligned bytecode reads: Eliminate Span.Slice overhead in hot-path ReadU16/ReadI32/ReadU32
  • Allocation-free ExecuteIsMatch: New ExecuteIsMatch method returns bool directly without allocating result arrays
  • Cached ScanLoopInfo: Detect scan loop characteristics once at compile time instead of on every Execute call

Integration fast paths

  • test(): Custom engine fast path using allocation-free IsMatch for non-global/non-sticky patterns
  • match() with /g: Bypass RegExpExecCreateReturnValueArrayFromCustom chain, calling Execute directly (saves 15-20 allocations per match)
  • replace(): Custom engine fast path for simple string replacement using ValueStringBuilder
  • search(): Custom engine fast path returning match index directly without building result arrays
  • Lazy legacy static properties: Defer RegExp.$\`` and $'` substring allocations until accessed (affects both .NET Regex and custom engine paths)

Benchmark results

AMD Ryzen 9 5950X, .NET 10.0.5, BenchmarkDotNet v0.15.8 (default settings).

RegExpCustomEngineBenchmark (65K random lowercase string, custom engine via /u flag)

Method Pattern main PR Speedup
LiteralScan /aaaaaaaaaa/u 701.7 us 2.94 us 239x
VariableLength /a.*a/u 657.8 us 31.5 us 20.9x
NamedCapture /aa(?<cap>b)aa/gu 854.1 us 5.55 us 154x
NoMatchScan /zzzzz/u 796.8 us 2.89 us 276x
IsMatchOnly /aaaaaaaaaa/u 709.0 us 2.94 us 241x
CaseInsensitiveScan /aaaaaaaaaa/iu 729.0 us 4.50 us 162x
GlobalMatchAll /aaaaaaaaaa/gu 715.4 us 2.91 us 246x
AnchoredLiteral /^aaaaaaaaaa/u 690.3 us 0.030 us 22,716x
DigitScan /[0-9]+/u 857.6 us 1.16 us 739x
CharClassScan /[a-z]+/u 754.9 us 1.38 us 547x

DromaeoBenchmark.ObjectRegExp (no regression — these patterns use .NET Regex)

Params main PR Change
Modern=False, Prepared=False 133.1 ms 132.4 ms ~same
Modern=False, Prepared=True 102.1 ms 109.3 ms ~same
Modern=True, Prepared=False 138.9 ms 138.5 ms ~same
Modern=True, Prepared=True 112.1 ms 113.9 ms ~same

No regressions in the Dromaeo regexp benchmark.

Test plan

  • All Jint.Tests pass (2,818 tests, 0 failures)
  • test262 conformance: 97,702 passed, 0 failures, 0 regressions vs main
  • Surrogate-value patterns correctly excluded from IndexOf scan
  • Greedy dot-star correctly guarded against forward-Goto false matches
  • Range+ bulk advance guarded to terminal-only patterns (SaveEnd 0)
  • Dromaeo regexp benchmark shows no regressions

🤖 Generated with Claude Code

lahma and others added 6 commits April 11, 2026 20:22
Replace the bytecode scan loop (SplitGotoFirst/Any/Goto) with
SIMD-accelerated string.IndexOf for patterns starting with a literal
character, delivering 35x speedup on scan-dominated workloads.

Additional optimizations:
- Use Span.CopyTo for bulk capture array snapshots in PushFrame/PopFrame
- Add allocation-free ExecuteIsMatch path for test()/IsMatch() calls
- Replace BinaryPrimitives.ReadXXX(bc.Slice()) with Unsafe.ReadUnaligned
  to eliminate Span construction overhead in the hot interpreter loop

Benchmark results (65K random string, /u flag patterns):
| Method       | Before (us) | After (us) | Speedup |
|--------------|-------------|------------|---------|
| LiteralScan  | 692.6       | 19.4       | 35.7x   |
| NamedCapture | 781.2       | 20.0       | 39.0x   |
| NoMatchScan  | 745.0       | 19.8       | 37.6x   |
| IsMatchOnly  | 662.9       | 19.4       | 34.1x   |

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
…() fast path

Greedy quantifier frame pruning: skip pushing backtrack frames in
SplitNextFirst when the continuation's first Char opcode can't match
the current input position. For /a.*a/u on random text, reduces frame
count from ~65K to ~2500 (only positions with matching char get frames).

Additional optimizations:
- Extend TryDetectScanLoop to handle CharI (case-insensitive) using
  IndexOfAny for both case variants
- Cache scan loop detection info in JintRegExpEngine at compile time
  instead of re-detecting on every Execute call
- Add custom engine fast path in RegExpPrototype.Test() using
  allocation-free IsMatch for non-global/non-sticky patterns

Benchmark results (65K random string, custom engine):
| Method              | Phase 1 (us) | Phase 2 (us) | vs Original |
|---------------------|--------------|--------------|-------------|
| LiteralScan         | 19.4         | 22.0         | 31.5x       |
| VariableLength      | 643.6        | 413.9        | 1.4x        |
| NamedCapture        | 20.0         | 22.1         | 35.3x       |
| NoMatchScan         | 19.8         | 20.2         | 36.9x       |
| CaseInsensitiveScan | (no scan)    | 25.4         | new         |

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Detect the SplitNextFirst → Dot/Any → Goto(back) loop pattern
emitted by greedy .* quantifiers. When the continuation starts with
a Char opcode, replace the per-character opcode interpretation loop
with a direct scan that pushes frames only at positions matching the
continuation character.

This eliminates ~65K bytecode iterations (3 opcodes each) for /a.*a/u
on a 65K string, replacing them with a single scan loop.

VariableLength benchmark: 414us → 211us (2x improvement, 2.7x total).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
…mark

Bypass RegExpExec → CustomEngineBuiltinExec → CreateReturnValueArrayFromCustom
chain for global match with custom engine. Call Execute directly and collect
match strings without building full JS arrays per match (saves 15-20
allocations per match iteration).

Add GlobalMatchAll benchmark scenario exercising repeated Execute calls.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Replace char-by-char iteration in the greedy dot-star bulk scan with
IndexOf-based jumps between target positions. Instead of iterating 65K
chars, performs ~2500 SIMD-accelerated IndexOf calls to locate frame
push positions directly.

Also add custom engine fast path for String.prototype.replace with
simple string replacement (no $-substitutions, no function), using
ValueStringBuilder to avoid intermediate allocations.

VariableLength benchmark: 199us → 48us (4.1x improvement, 11.9x total
from original 573us).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Replace forward IndexOf bulk scan (pushing ~2500 frames) with a single
LastIndexOf call pushing ONE frame at the rightmost target position.
Greedy quantifiers try the longest match first, so only the last
position is needed in the common case. This eliminates thousands of
unnecessary PushFrame calls and stack growth allocations.

Also add custom engine fast path for RegExp.prototype.search() using
Execute directly instead of building a full JS result array.

VariableLength benchmark: 48us → 30us (1.6x, 19.3x total from 573us).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
…properties

Anchored pattern detection: detect LineStart (^) as first opcode and
skip the scan loop entirely — only try position 0. Patterns like
/^prefix/u go from ~700us to ~32ns (O(n) → O(1)).

Range first-char scan: detect single-pair Range opcodes (\d, [a-z],
etc.) and use IndexOfAnyInRange for SIMD-accelerated scanning on
.NET 8+. Patterns like /\d+/u go from ~700us to ~1.4us.

Range+ quantifier loop bulk advance: detect backward SplitGotoFirst
to single-pair Range (the + quantifier pattern) and use
IndexOfAnyExceptInRange to bulk-skip all matching characters instead
of looping per-char through the bytecode interpreter. Patterns like
/[a-z]+/u on 65K all-lowercase go from 672us to 1.3us (516x).

SplitGotoFirst frame pruning: extend frame pruning to SplitGotoFirst
handler for both Char and Range continuations.

Range continuation in SplitNextFirst frame pruning: extend greedy
quantifier frame pruning to check Range opcodes, not just Char.

Lazy legacy static properties: replace eager Substring allocations
for RegExp.$` and $' with lazy computation — only allocate when
actually accessed. Eliminates 2 string allocations per exec()/match()
on both .NET Regex and custom engine paths.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
lahma and others added 3 commits April 11, 2026 21:36
Char+ bulk advance: detect backward SplitGotoFirst to Char opcode
(the + quantifier on a literal character) and skip all identical
chars at once instead of per-character bytecode interpretation.

Dot-star with Range continuation: extend the greedy dot-star bulk
scan (SplitNextFirst) to also handle Range opcodes at the continuation
position, not just Char. Patterns like /.*\d/u now use backward
scanning to find the rightmost digit.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
When the pattern starts with consecutive Char opcodes (e.g.,
/hello world/u), extract the full literal string and use
string.IndexOf(literal, StringComparison.Ordinal) instead of
single-char IndexOf. This eliminates false-positive retries when
the first character is common but the full literal is rare.

For /aaaaaaaaaa/u on a 65K random string, this eliminates ~2500
false single-char matches, each requiring a full pattern retry.

LiteralScan: 24us → 3.6us (6.7x). NoMatchScan: 23us → 2.8us (8.2x).
IsMatchOnly: 23.5us → 2.8us (8.4x). GlobalMatchAll: 24.7us → 3.6us (6.9x).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Extend the literal prefix extraction to also handle consecutive CharI
opcodes (case-insensitive patterns). Use StringComparison.OrdinalIgnoreCase
for the substring search, eliminating false single-char retries.

CaseInsensitiveScan /aaaaaaaaaa/iu: 29.5us → 4.5us (6.5x).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
@lahma
lahma merged commit 07860da into sebastienros:main Apr 11, 2026
4 checks passed
@lahma
lahma deleted the faster-regex branch April 11, 2026 20:14
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