Optimize custom regex engine interpreter performance#2388
Merged
Conversation
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>
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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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
Char/CharIopcodes into a literal string and use SIMD-acceleratedstring.IndexOf(literal)/OrdinalIgnoreCaseinstead of single-char scanning with per-char bytecode retriesSplitGotoFirst/Any/Goto) withstring.IndexOf/IndexOfAnyforChar,CharI, and single-pairRangeopcodesLineStart(^) and skip the scan loop entirely — only try position 0SplitNextFirst+Dot/Any+Gotoloop and find the rightmost continuation match viaLastIndexOf, pushing a single frame instead of ~65KSplitGotoFirsttoRangeorChar(the+quantifier) and useIndexOfAnyExceptInRange(.NET 8+) to skip all matching characters at onceSplitNextFirstandSplitGotoFirstwhen the continuation's firstCharorRangeopcode can't match the current input positionRangeopcodes at the continuation, enabling patterns like/.*\d/uSpan.Sliceoverhead in hot-pathReadU16/ReadI32/ReadU32ExecuteIsMatchmethod returnsbooldirectly without allocating result arraysExecutecallIntegration fast paths
test(): Custom engine fast path using allocation-freeIsMatchfor non-global/non-sticky patternsmatch()with/g: BypassRegExpExec→CreateReturnValueArrayFromCustomchain, callingExecutedirectly (saves 15-20 allocations per match)replace(): Custom engine fast path for simple string replacement usingValueStringBuildersearch(): Custom engine fast path returning match index directly without building result arraysRegExp.$\`` 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
/uflag)/aaaaaaaaaa/u/a.*a/u/aa(?<cap>b)aa/gu/zzzzz/u/aaaaaaaaaa/u/aaaaaaaaaa/iu/aaaaaaaaaa/gu/^aaaaaaaaaa/u/[0-9]+/u/[a-z]+/uDromaeoBenchmark.ObjectRegExp (no regression — these patterns use .NET Regex)
No regressions in the Dromaeo regexp benchmark.
Test plan
🤖 Generated with Claude Code