diff --git a/Jint.Benchmark/RegExpCustomEngineBenchmark.cs b/Jint.Benchmark/RegExpCustomEngineBenchmark.cs new file mode 100644 index 0000000000..cd52559b82 --- /dev/null +++ b/Jint.Benchmark/RegExpCustomEngineBenchmark.cs @@ -0,0 +1,125 @@ +using BenchmarkDotNet.Attributes; +using Jint.Runtime.RegExp; + +namespace Jint.Benchmark; + +/// +/// Benchmarks targeting the custom regex engine (QuickJS libregexp port). +/// All patterns use the /u flag to force routing through the custom engine +/// instead of .NET Regex. +/// +[MemoryDiagnoser] +public class RegExpCustomEngineBenchmark +{ + private string _input = ""; + + private JintRegExpEngine _literalEngine = null!; + private JintRegExpEngine _variableLengthEngine = null!; + private JintRegExpEngine _namedCaptureEngine = null!; + private JintRegExpEngine _noMatchEngine = null!; + private JintRegExpEngine _caseInsensitiveEngine = null!; + private JintRegExpEngine _globalLiteralEngine = null!; + private JintRegExpEngine _anchoredEngine = null!; + private JintRegExpEngine _digitScanEngine = null!; + private JintRegExpEngine _charClassScanEngine = null!; + + [GlobalSetup] + public void Setup() + { + // Generate ~65K random lowercase string matching dromaeo's approach + var random = new Random(42); // fixed seed for reproducibility + var chars = new char[16384]; + for (int i = 0; i < chars.Length; i++) + { + chars[i] = (char) ('a' + random.Next(26)); + } + + var str = new string(chars); + _input = str + str + str + str; // ~65K chars + + // Pre-compile engines (isolate execution cost from compilation) + _literalEngine = JintRegExpEngine.Compile("aaaaaaaaaa", RegExpFlags.Unicode); + _variableLengthEngine = JintRegExpEngine.Compile("a.*a", RegExpFlags.Unicode); + _namedCaptureEngine = JintRegExpEngine.Compile("aa(?b)aa", RegExpFlags.Unicode | RegExpFlags.Global); + _noMatchEngine = JintRegExpEngine.Compile("zzzzz", RegExpFlags.Unicode); + _caseInsensitiveEngine = JintRegExpEngine.Compile("aaaaaaaaaa", RegExpFlags.Unicode | RegExpFlags.IgnoreCase); + _globalLiteralEngine = JintRegExpEngine.Compile("aaaaaaaaaa", RegExpFlags.Unicode | RegExpFlags.Global); + _anchoredEngine = JintRegExpEngine.Compile("^aaaaaaaaaa", RegExpFlags.Unicode); + _digitScanEngine = JintRegExpEngine.Compile("[0-9]+", RegExpFlags.Unicode); + _charClassScanEngine = JintRegExpEngine.Compile("[a-z]+", RegExpFlags.Unicode); + } + + [Benchmark] + public bool LiteralScan() + { + return _literalEngine.Execute(_input, 0).Success; + } + + [Benchmark] + public bool VariableLength() + { + return _variableLengthEngine.Execute(_input, 0).Success; + } + + [Benchmark] + public bool NamedCapture() + { + return _namedCaptureEngine.Execute(_input, 0).Success; + } + + [Benchmark] + public bool NoMatchScan() + { + return _noMatchEngine.Execute(_input, 0).Success; + } + + [Benchmark] + public bool IsMatchOnly() + { + return _literalEngine.IsMatch(_input, 0); + } + + [Benchmark] + public bool CaseInsensitiveScan() + { + return _caseInsensitiveEngine.Execute(_input, 0).Success; + } + + [Benchmark] + public int GlobalMatchAll() + { + int count = 0; + int lastIndex = 0; + while (lastIndex <= _input.Length) + { + var result = _globalLiteralEngine.Execute(_input, lastIndex); + if (!result.Success) + { + break; + } + + count++; + lastIndex = result.Index + Math.Max(result.Length, 1); + } + + return count; + } + + [Benchmark] + public bool AnchoredLiteral() + { + return _anchoredEngine.Execute(_input, 0).Success; + } + + [Benchmark] + public bool DigitScan() + { + return _digitScanEngine.Execute(_input, 0).Success; + } + + [Benchmark] + public bool CharClassScan() + { + return _charClassScanEngine.Execute(_input, 0).Success; + } +} diff --git a/Jint/Native/RegExp/RegExpConstructor.cs b/Jint/Native/RegExp/RegExpConstructor.cs index 30d70d0661..7e73dfa8f5 100644 --- a/Jint/Native/RegExp/RegExpConstructor.cs +++ b/Jint/Native/RegExp/RegExpConstructor.cs @@ -20,9 +20,20 @@ public sealed class RegExpConstructor : Constructor internal string _legacyInput = ""; internal string _legacyLastMatch = ""; internal string _legacyLastParen = ""; - internal string _legacyLeftContext = ""; - internal string _legacyRightContext = ""; internal readonly string[] _legacyParens = new string[9]; + // Left/right context computed lazily to avoid Substring allocations per exec() + private string _legacyContextInput = ""; + private int _legacyMatchIndex; + private int _legacyMatchEnd; + internal string _legacyLeftContext => _legacyContextInput.Length > 0 ? _legacyContextInput.Substring(0, _legacyMatchIndex) : ""; + internal string _legacyRightContext => _legacyContextInput.Length > 0 ? _legacyContextInput.Substring(_legacyMatchEnd) : ""; + + internal void SetLegacyContext(string input, int matchIndex, int matchLength) + { + _legacyContextInput = input; + _legacyMatchIndex = matchIndex; + _legacyMatchEnd = matchIndex + matchLength; + } internal RegExpConstructor( Engine engine, diff --git a/Jint/Native/RegExp/RegExpPrototype.cs b/Jint/Native/RegExp/RegExpPrototype.cs index e55ecc29f7..9a34cabc2d 100644 --- a/Jint/Native/RegExp/RegExpPrototype.cs +++ b/Jint/Native/RegExp/RegExpPrototype.cs @@ -163,13 +163,49 @@ private JsValue Replace(JsValue thisObject, JsCallArguments arguments) rx.Set(JsRegExp.PropertyLastIndex, 0, true); } + // Custom engine fast path for simple string replacement (no $-substitutions, no function) + if (!functionalReplace + && !mayHaveNamedCaptures + && rx is JsRegExp { HasDefaultRegExpExec: true, UsesDotNetEngine: false } customRei) + { + var customEngine = customRei.CustomEngine!; + var replStr = TypeConverter.ToString(replaceValue); + var sb = new ValueStringBuilder(stackalloc char[256]); + + int lastPos = 0; + int searchStart = 0; + int maxCount = global ? int.MaxValue : 1; + int count = 0; + + while (count < maxCount && searchStart <= s.Length) + { + var result = customEngine.Execute(s, searchStart); + if (!result.Success) + { + break; + } + + sb.Append(s.AsSpan(lastPos, result.Index - lastPos)); + sb.Append(replStr); + + lastPos = result.Index + result.Length; + searchStart = result.Length == 0 + ? (int) AdvanceStringIndex(s, (ulong) result.Index, fullUnicode) + : lastPos; + count++; + } + + sb.Append(s.AsSpan(lastPos)); + rx.Set(JsRegExp.PropertyLastIndex, JsNumber.PositiveZero); + return sb.ToString(); + } + // check if we can access fast path (only for .NET Regex engine) // Derive sticky from already-read flags string to avoid extra observable property access if (!fullUnicode && !mayHaveNamedCaptures && !flags.Contains('y') - && rx is JsRegExp rei && rei.HasDefaultRegExpExec - && rei.UsesDotNetEngine) + && rx is JsRegExp { HasDefaultRegExpExec: true, UsesDotNetEngine: true } rei) { var count = global ? int.MaxValue : 1; @@ -638,29 +674,59 @@ private JsValue Test(JsValue thisObject, JsCallArguments arguments) var r = AssertThisIsObjectInstance(thisObject, "RegExp.prototype.test"); var s = TypeConverter.ToString(arguments.At(0)); - // check couple fast paths (only for .NET Regex engine) - if (r is JsRegExp R && !R.FullUnicode && R.UsesDotNetEngine) + if (r is JsRegExp R && R.HasDefaultRegExpExec) { - if (!R.Sticky && !R.Global) + // Fast path for custom engine (allocation-free IsMatch) + if (!R.UsesDotNetEngine) { - R.Set(JsRegExp.PropertyLastIndex, 0, throwOnError: true); - return R.Value.IsMatch(s); - } + var customEngine = R.CustomEngine!; + if (!R.Sticky && !R.Global) + { + return customEngine.IsMatch(s, 0); + } - var lastIndex = (int) TypeConverter.ToLength(R.Get(JsRegExp.PropertyLastIndex)); - if (lastIndex >= s.Length && s.Length > 0) - { - return JsBoolean.False; + var lastIndex = (int) TypeConverter.ToLength(R.Get(JsRegExp.PropertyLastIndex)); + if (lastIndex >= s.Length && s.Length > 0) + { + R.Set(JsRegExp.PropertyLastIndex, 0, throwOnError: true); + return JsBoolean.False; + } + + // For global/sticky, we need the match position to update lastIndex + var result = customEngine.Execute(s, lastIndex); + if (!result.Success || (R.Sticky && result.Index != lastIndex)) + { + R.Set(JsRegExp.PropertyLastIndex, 0, throwOnError: true); + return JsBoolean.False; + } + R.Set(JsRegExp.PropertyLastIndex, result.Index + result.Length, throwOnError: true); + return JsBoolean.True; } - var m = R.Value.Match(s, lastIndex); - if (!m.Success || (R.Sticky && m.Index != lastIndex)) + // Fast path for .NET Regex engine + if (!R.FullUnicode) { - R.Set(JsRegExp.PropertyLastIndex, 0, throwOnError: true); - return JsBoolean.False; + if (!R.Sticky && !R.Global) + { + R.Set(JsRegExp.PropertyLastIndex, 0, throwOnError: true); + return R.Value.IsMatch(s); + } + + var lastIndex = (int) TypeConverter.ToLength(R.Get(JsRegExp.PropertyLastIndex)); + if (lastIndex >= s.Length && s.Length > 0) + { + return JsBoolean.False; + } + + var m = R.Value.Match(s, lastIndex); + if (!m.Success || (R.Sticky && m.Index != lastIndex)) + { + R.Set(JsRegExp.PropertyLastIndex, 0, throwOnError: true); + return JsBoolean.False; + } + R.Set(JsRegExp.PropertyLastIndex, m.Index + m.Length, throwOnError: true); + return JsBoolean.True; } - R.Set(JsRegExp.PropertyLastIndex, m.Index + m.Length, throwOnError: true); - return JsBoolean.True; } var match = RegExpExec(r, s); @@ -681,6 +747,19 @@ private JsValue Search(JsValue thisObject, JsCallArguments arguments) rx.Set(JsRegExp.PropertyLastIndex, 0, true); } + // Fast path for custom engine: only need the index, skip full result array + if (rx is JsRegExp { HasDefaultRegExpExec: true, UsesDotNetEngine: false } customR) + { + var searchResult = customR.CustomEngine!.Execute(s, 0); + var currentLastIndex2 = rx.Get(JsRegExp.PropertyLastIndex); + if (!SameValue(currentLastIndex2, previousLastIndex)) + { + rx.Set(JsRegExp.PropertyLastIndex, previousLastIndex, true); + } + + return searchResult.Success ? searchResult.Index : -1; + } + var result = RegExpExec(rx, s); var currentLastIndex = rx.Get(JsRegExp.PropertyLastIndex); if (!SameValue(currentLastIndex, previousLastIndex)) @@ -714,17 +793,49 @@ private JsValue Match(JsValue thisObject, JsCallArguments arguments) var fullUnicode = flags.Contains('u') || flags.Contains('v'); rx.Set(JsRegExp.PropertyLastIndex, JsNumber.PositiveZero, true); + if (rx is JsRegExp rei && rei.HasDefaultRegExpExec && !rei.UsesDotNetEngine) + { + // fast path for custom engine: call Execute directly, skip building + // full JS result arrays per match (saves 15-20 allocations per match) + var customEngine = rei.CustomEngine!; + var a = _realm.Intrinsics.Array.ArrayCreate(0); + uint n = 0; + int lastIndex = 0; + while (lastIndex <= s.Length) + { + var result = customEngine.Execute(s, lastIndex); + if (!result.Success) + { + break; + } + + a.SetIndexValue(n, result.Value, updateLength: false); + + if (result.Length == 0) + { + lastIndex = (int) AdvanceStringIndex(s, (ulong) result.Index, fullUnicode); + } + else + { + lastIndex = result.Index + result.Length; + } + + n++; + } + + a.SetLength(n); + return n == 0 ? Null : a; + } + if (!fullUnicode - && rx is JsRegExp rei - && rei.HasDefaultRegExpExec - && rei.UsesDotNetEngine) + && rx is JsRegExp { HasDefaultRegExpExec: true, UsesDotNetEngine: true } dotnetRei) { // fast path (only for .NET Regex engine) var a = _realm.Intrinsics.Array.ArrayCreate(0); - if (rei.Sticky) + if (dotnetRei.Sticky) { - var match = rei.Value.Match(s); + var match = dotnetRei.Value.Match(s); if (!match.Success || match.Index != 0) { return Null; @@ -744,7 +855,7 @@ private JsValue Match(JsValue thisObject, JsCallArguments arguments) } else { - var matches = rei.Value.Matches(s); + var matches = dotnetRei.Value.Matches(s); if (matches.Count == 0) { return Null; @@ -1120,8 +1231,7 @@ private static void UpdateLegacyStaticPropertiesFromCustom(Engine engine, in Reg var constructor = engine.Realm.Intrinsics.RegExp; constructor._legacyInput = s; constructor._legacyLastMatch = result.Value; - constructor._legacyLeftContext = s.Substring(0, result.Index); - constructor._legacyRightContext = s.Substring(result.Index + result.Length); + constructor.SetLegacyContext(s, result.Index, result.Length); var groups = result.Groups; var lastParen = ""; @@ -1237,8 +1347,7 @@ private static void UpdateLegacyStaticProperties(Engine engine, Match match, str var constructor = engine.Realm.Intrinsics.RegExp; constructor._legacyInput = s; constructor._legacyLastMatch = match.Value; - constructor._legacyLeftContext = s.Substring(0, match.Index); - constructor._legacyRightContext = s.Substring(match.Index + match.Length); + constructor.SetLegacyContext(s, match.Index, match.Length); // Update $1-$9 var lastParen = ""; diff --git a/Jint/Runtime/RegExp/JintRegExpEngine.cs b/Jint/Runtime/RegExp/JintRegExpEngine.cs index f8dd104156..36c59b820d 100644 --- a/Jint/Runtime/RegExp/JintRegExpEngine.cs +++ b/Jint/Runtime/RegExp/JintRegExpEngine.cs @@ -1,4 +1,5 @@ using System.Threading; +using static Jint.Runtime.RegExp.RegExpInterpreter; namespace Jint.Runtime.RegExp; @@ -13,6 +14,7 @@ internal sealed class JintRegExpEngine private readonly int _captureCount; private readonly string?[] _groupNames = []; private readonly RegExpFlags _flags; + private readonly ScanLoopInfo _scanInfo; private JintRegExpEngine(byte[] bytecode) { @@ -20,6 +22,7 @@ private JintRegExpEngine(byte[] bytecode) _captureCount = RegExpInterpreter.GetCaptureCount(_bytecode); _flags = RegExpInterpreter.GetFlags(_bytecode); _groupNames = RegExpInterpreter.GetGroupNames(_bytecode) ?? []; + _scanInfo = RegExpInterpreter.DetectScanLoop(_bytecode); } /// Compile a JavaScript regex pattern into bytecode. @@ -49,7 +52,7 @@ public static JintRegExpEngine Compile(string pattern, RegExpFlags flags, Cancel /// Execute the regex against the input string starting at the given index. public RegExpMatchResult Execute(string input, int startIndex, CancellationToken cancellationToken = default) { - var captures = RegExpInterpreter.Execute(_bytecode, input, startIndex, cancellationToken); + var captures = RegExpInterpreter.Execute(_bytecode, input, startIndex, _scanInfo, cancellationToken); if (captures is null) { return RegExpMatchResult.NoMatch; @@ -61,7 +64,7 @@ public RegExpMatchResult Execute(string input, int startIndex, CancellationToken /// Test if the regex matches the input string. public bool IsMatch(string input, int startIndex = 0, CancellationToken cancellationToken = default) { - return RegExpInterpreter.Execute(_bytecode, input, startIndex, cancellationToken) is not null; + return RegExpInterpreter.ExecuteIsMatch(_bytecode, input, startIndex, _scanInfo, cancellationToken); } private RegExpMatchResult BuildResult(string input, int[] captures) diff --git a/Jint/Runtime/RegExp/RegExpInterpreter.cs b/Jint/Runtime/RegExp/RegExpInterpreter.cs index 3244a80b33..f78960b304 100644 --- a/Jint/Runtime/RegExp/RegExpInterpreter.cs +++ b/Jint/Runtime/RegExp/RegExpInterpreter.cs @@ -26,6 +26,7 @@ using System.Buffers; using System.Buffers.Binary; using System.Runtime.CompilerServices; +using System.Runtime.InteropServices; using System.Text; using System.Text.RegularExpressions; using System.Threading; @@ -39,6 +40,14 @@ namespace Jint.Runtime.RegExp; /// internal static class RegExpInterpreter { + /// Pre-computed scan loop info, extracted once at compile time. + internal readonly record struct ScanLoopInfo( + bool HasFastScan, int PatternStartPc, + char ScanChar, char ScanCharAlt, + bool IsAnchored, + bool IsRange, char RangeLow, char RangeHigh, + string? ScanLiteral); + /// Unset capture position sentinel (mirrors NULL in the C code). private const int Unset = -1; @@ -61,6 +70,14 @@ internal static class RegExpInterpreter // Public API // ----------------------------------------------------------------------- + /// Analyze bytecode header and detect scan loop for caching. + public static ScanLoopInfo DetectScanLoop(ReadOnlySpan bytecode) + { + int bytecodeLen = BinaryPrimitives.ReadInt32LittleEndian(bytecode.Slice(RegExpHeader.OffsetBytecodeLen)); + var bc = bytecode.Slice(RegExpHeader.Length, bytecodeLen); + return TryDetectScanLoop(bc); + } + /// /// Execute regex bytecode against input string. /// Returns capture positions array on match, null on no match. @@ -71,6 +88,7 @@ internal static class RegExpInterpreter ReadOnlySpan bytecode, string input, int startIndex, + in ScanLoopInfo scanInfo = default, CancellationToken cancellationToken = default) { var flags = GetFlags(bytecode); @@ -105,7 +123,7 @@ internal static class RegExpInterpreter try { - int ret = ExecBacktrack(bc, input, capture, cindex, captureCount, isUnicode, cancellationToken); + int ret = ExecBacktrack(bc, input, capture, cindex, captureCount, isUnicode, scanInfo, cancellationToken); int[]? result = null; if (ret == 1) @@ -125,6 +143,55 @@ internal static class RegExpInterpreter } } + /// + /// Test if regex bytecode matches the input string, without allocating a result array. + /// + public static bool ExecuteIsMatch( + ReadOnlySpan bytecode, + string input, + int startIndex, + in ScanLoopInfo scanInfo = default, + CancellationToken cancellationToken = default) + { + var flags = GetFlags(bytecode); + int captureCount = bytecode[RegExpHeader.OffsetCaptureCount]; + int registerCount = bytecode[RegExpHeader.OffsetRegisterCount]; + bool isUnicode = (flags & (RegExpFlags.Unicode | RegExpFlags.UnicodeSets)) != RegExpFlags.None; + + int allocCount = captureCount * 2 + registerCount; + + int[]? capturePooled = null; + Span capture = allocCount <= 64 + ? stackalloc int[allocCount] + : (capturePooled = ArrayPool.Shared.Rent(allocCount)).AsSpan(0, allocCount); + + capture.Fill(Unset); + + int bytecodeLen = BinaryPrimitives.ReadInt32LittleEndian(bytecode.Slice(RegExpHeader.OffsetBytecodeLen)); + var bc = bytecode.Slice(RegExpHeader.Length, bytecodeLen); + + int cindex = startIndex; + if (cindex > 0 && cindex < input.Length && isUnicode) + { + if (char.IsLowSurrogate(input[cindex]) && char.IsHighSurrogate(input[cindex - 1])) + { + cindex--; + } + } + + try + { + return ExecBacktrack(bc, input, capture, cindex, captureCount, isUnicode, scanInfo, cancellationToken) == 1; + } + finally + { + if (capturePooled is not null) + { + ArrayPool.Shared.Return(capturePooled); + } + } + } + /// Get capture count from bytecode header. public static int GetCaptureCount(ReadOnlySpan bytecode) { @@ -415,19 +482,19 @@ private static int CanonicalizeSlow(int c, bool isUnicode) [MethodImpl(MethodImplOptions.AggressiveInlining)] private static ushort ReadU16(ReadOnlySpan bc, int offset) { - return BinaryPrimitives.ReadUInt16LittleEndian(bc.Slice(offset)); + return Unsafe.ReadUnaligned(ref Unsafe.Add(ref MemoryMarshal.GetReference(bc), offset)); } [MethodImpl(MethodImplOptions.AggressiveInlining)] private static int ReadI32(ReadOnlySpan bc, int offset) { - return BinaryPrimitives.ReadInt32LittleEndian(bc.Slice(offset)); + return Unsafe.ReadUnaligned(ref Unsafe.Add(ref MemoryMarshal.GetReference(bc), offset)); } [MethodImpl(MethodImplOptions.AggressiveInlining)] private static uint ReadU32(ReadOnlySpan bc, int offset) { - return BinaryPrimitives.ReadUInt32LittleEndian(bc.Slice(offset)); + return Unsafe.ReadUnaligned(ref Unsafe.Add(ref MemoryMarshal.GetReference(bc), offset)); } // ----------------------------------------------------------------------- @@ -588,11 +655,8 @@ private static void PushFrame( stackBuf[sp] = savedPc; stackBuf[sp + 1] = savedCindex; stackBuf[sp + 2] = PackBpType(bp, stateType); - // Full snapshot of captures + registers - for (int i = 0; i < allocCount; i++) - { - stackBuf[sp + 3 + i] = capture[i]; - } + // Full snapshot of captures + registers (SIMD-optimized bulk copy) + capture.CopyTo(stackBuf.AsSpan(sp + 3, allocCount)); sp += frameSize; bp = sp; } @@ -618,11 +682,8 @@ private static ExecStateType PopFrame( int packed = stackBuf[sp + 2]; var stateType = UnpackType(packed); bp = UnpackBp(packed); - // Restore full snapshot - for (int i = 0; i < allocCount; i++) - { - capture[i] = stackBuf[sp + 3 + i]; - } + // Restore full snapshot (SIMD-optimized bulk copy) + stackBuf.AsSpan(sp + 3, allocCount).CopyTo(capture); return stateType; } @@ -635,6 +696,200 @@ private static void ReturnStack(int[]? stackPooled) } } + // ----------------------------------------------------------------------- + // First-character scan loop detection + // ----------------------------------------------------------------------- + + /// + /// Detect the compiler-emitted scan loop at the start of bytecode for non-sticky patterns. + /// The scan loop is: SplitGotoFirst(5) + Any(1) + Goto(5) = 11 bytes, followed by SaveStart 0. + /// Returns scan info for Char, CharI, Range, or LineStart (anchored) first opcodes. + /// + private static ScanLoopInfo TryDetectScanLoop(ReadOnlySpan bc) + { + // Need at least: 11 (scan loop) + 2 (SaveStart 0) + 1 (opcode) = 14 bytes + if (bc.Length < 14) + { + return default; + } + + // Verify the scan loop byte pattern + if (bc[0] != (byte) RegExpOpcode.SplitGotoFirst + || bc[5] != (byte) RegExpOpcode.Any + || bc[6] != (byte) RegExpOpcode.Goto + || bc[11] != (byte) RegExpOpcode.SaveStart + || bc[12] != 0) + { + return default; + } + + byte firstOp = bc[13]; + + // Anchored pattern: ^ (non-multiline) can only match at position 0. + // Skip the scan loop entirely. + if (firstOp == (byte) RegExpOpcode.LineStart) + { + return new ScanLoopInfo(HasFastScan: true, PatternStartPc: 11, + ScanChar: '\0', ScanCharAlt: '\0', IsAnchored: true, + IsRange: false, RangeLow: '\0', RangeHigh: '\0', + ScanLiteral: null); + } + + // Exact character match — also look ahead for consecutive Char opcodes + // to extract a multi-char literal for SIMD substring search. + if (firstOp == (byte) RegExpOpcode.Char && bc.Length >= 16) + { + char scanChar = (char) ReadU16(bc, 14); + if (char.IsSurrogate(scanChar)) + { + return default; + } + + // Look ahead for consecutive Char opcodes to build a literal prefix. + // Each Char opcode is 3 bytes: opcode(1) + u16(2). + string? literal = null; + int nextOp = 16; // position after first Char's u16 + if (bc.Length > nextOp && bc[nextOp] == (byte) RegExpOpcode.Char) + { + // At least 2 consecutive Chars — extract the literal + Span litBuf = stackalloc char[32]; // up to 32 chars + litBuf[0] = scanChar; + int litLen = 1; + while (litLen < litBuf.Length + && nextOp < bc.Length + && bc[nextOp] == (byte) RegExpOpcode.Char) + { + char c = (char) ReadU16(bc, nextOp + 1); + if (char.IsSurrogate(c)) + { + break; + } + + litBuf[litLen++] = c; + nextOp += 3; + } + + if (litLen > 1) + { +#if NETSTANDARD2_0 || NET462 + literal = litBuf.Slice(0, litLen).ToString(); +#else + literal = new string(litBuf.Slice(0, litLen)); +#endif + } + } + + return new ScanLoopInfo(HasFastScan: true, PatternStartPc: 11, + ScanChar: scanChar, ScanCharAlt: '\0', IsAnchored: false, + IsRange: false, RangeLow: '\0', RangeHigh: '\0', + ScanLiteral: literal); + } + + // Case-insensitive: CharI stores the canonicalized value. + // Also extract multi-char literal from consecutive CharI for OrdinalIgnoreCase search. + if (firstOp == (byte) RegExpOpcode.CharI && bc.Length >= 16) + { + char val = (char) ReadU16(bc, 14); + if (val < 128) + { + char alt = char.IsLower(val) ? char.ToUpperInvariant(val) : char.ToLowerInvariant(val); + + // Look ahead for consecutive CharI opcodes (same as Char literal extraction) + string? literal = null; + int nextOp = 16; + if (bc.Length > nextOp && bc[nextOp] == (byte) RegExpOpcode.CharI) + { + Span litBuf = stackalloc char[32]; + litBuf[0] = val; + int litLen = 1; + while (litLen < litBuf.Length + && nextOp < bc.Length + && bc[nextOp] == (byte) RegExpOpcode.CharI) + { + char c = (char) ReadU16(bc, nextOp + 1); + if (c >= 128) + { + break; + } + + litBuf[litLen++] = c; + nextOp += 3; + } + + if (litLen > 1) + { +#if NETSTANDARD2_0 || NET462 + literal = litBuf.Slice(0, litLen).ToString(); +#else + literal = new string(litBuf.Slice(0, litLen)); +#endif + } + } + + return new ScanLoopInfo(HasFastScan: true, PatternStartPc: 11, + ScanChar: val, ScanCharAlt: alt, IsAnchored: false, + IsRange: false, RangeLow: '\0', RangeHigh: '\0', + ScanLiteral: literal); + } + } + +#if NET8_0_OR_GREATER + // Single-pair character range: [a-z], \d, etc. + // Use IndexOfAnyInRange for SIMD-accelerated scanning. + if (firstOp == (byte) RegExpOpcode.Range && bc.Length >= 20) + { + int n = ReadU16(bc, 14); + if (n == 1) + { + char low = (char) ReadU16(bc, 16); + char high = (char) ReadU16(bc, 18); + if (!char.IsSurrogate(low) && !char.IsSurrogate(high)) + { + return new ScanLoopInfo(HasFastScan: true, PatternStartPc: 11, + ScanChar: '\0', ScanCharAlt: '\0', IsAnchored: false, + IsRange: true, RangeLow: low, RangeHigh: high, + ScanLiteral: null); + } + } + } +#endif + + return default; + } + + /// + /// Find the next position of the scan character(s) in the input string. + /// Handles exact char, case-insensitive pair, and character range scanning. + /// + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private static int FindScanChar(string input, int startIndex, in ScanLoopInfo info) + { + // Multi-char literal: SIMD substring search (much faster when first char is common) + if (info.ScanLiteral is { Length: > 1 }) + { + var comparison = info.ScanCharAlt != '\0' + ? StringComparison.OrdinalIgnoreCase + : StringComparison.Ordinal; + return input.IndexOf(info.ScanLiteral, startIndex, comparison); + } + +#if NET8_0_OR_GREATER + if (info.IsRange) + { + var idx = input.AsSpan(startIndex).IndexOfAnyInRange(info.RangeLow, info.RangeHigh); + return idx < 0 ? -1 : idx + startIndex; + } +#endif + + if (info.ScanCharAlt == '\0' || info.ScanCharAlt == info.ScanChar) + { + return input.IndexOf(info.ScanChar, startIndex); + } + + var result = input.AsSpan(startIndex).IndexOfAny(info.ScanChar, info.ScanCharAlt); + return result < 0 ? -1 : result + startIndex; + } + // ----------------------------------------------------------------------- // Main backtracking interpreter (lre_exec_backtrack equivalent) // ----------------------------------------------------------------------- @@ -651,6 +906,7 @@ private static int ExecBacktrack( int cindex, int captureCount, bool isUnicode, + in ScanLoopInfo scanInfo, CancellationToken cancellationToken) { int inputEnd = input.Length; @@ -668,6 +924,35 @@ private static int ExecBacktrack( int pc = 0; // program counter (index into bc) int interruptCounter = InterruptCounterInit; + // First-character scan optimization: replace the bytecode scan loop + // (SplitGotoFirst/Any/Goto) with SIMD-accelerated string.IndexOf/IndexOfAny. + // Use pre-computed scan info when available, fall back to runtime detection. + var effectiveScanInfo = scanInfo.HasFastScan ? scanInfo : TryDetectScanLoop(bc); + bool hasFastScan = effectiveScanInfo.HasFastScan; + int lastScanStart = cindex; + + if (hasFastScan) + { + if (effectiveScanInfo.IsAnchored) + { + // Anchored (^): only try from startIndex, no scanning needed + pc = effectiveScanInfo.PatternStartPc; + } + else + { + int pos = FindScanChar(input, cindex, effectiveScanInfo); + if (pos < 0) + { + ReturnStack(stackPooled); + return 0; + } + + cindex = pos; + lastScanStart = pos; + pc = effectiveScanInfo.PatternStartPc; + } + } + try { while (true) { @@ -842,20 +1127,197 @@ private static int ExecBacktrack( } case RegExpOpcode.SplitGotoFirst: + { + int val = ReadI32(bc, pc); + pc += 4; + + int pc1 = pc; // continuation + int gotoTarget = pc + val; + + // Detect greedy Char+/Range+ loop: backward SplitGotoFirst to Char or Range. + // The + quantifier compiles as: atom + SplitGotoFirst(backward to atom). + // Bulk-advance cindex past all matching chars instead of looping per-char. + // Only safe when continuation is SaveEnd(0) — meaning the + is the + // outermost quantifier and no further pattern needs to backtrack into it. + if (val < 0 + && bc[pc1] == (byte) RegExpOpcode.SaveEnd && bc[pc1 + 1] == 0 + && cindex < inputEnd) + { + // Char+ bulk advance: skip all identical chars + if (bc[gotoTarget] == (byte) RegExpOpcode.Char) + { + char targetChar = (char) ReadU16(bc, gotoTarget + 1); + int scanEnd = cindex; + while (scanEnd < inputEnd && input[scanEnd] == targetChar) + { + scanEnd++; + } + + PushFrame(ref stackBuf, ref stackPooled, ref sp, ref bp, + capture, allocCount, pc1, scanEnd, ExecStateType.Split); + cindex = scanEnd; + pc = gotoTarget; + break; + } + + // Range+ bulk advance: skip all chars in a single-pair range + if (bc[gotoTarget] == (byte) RegExpOpcode.Range) + { + int n = ReadU16(bc, gotoTarget + 1); + if (n == 1) + { + char low = (char) ReadU16(bc, gotoTarget + 3); + char high = (char) ReadU16(bc, gotoTarget + 5); + + int scanEnd; +#if NET8_0_OR_GREATER + int skipLen = input.AsSpan(cindex).IndexOfAnyExceptInRange(low, high); + scanEnd = skipLen < 0 ? inputEnd : cindex + skipLen; +#else + scanEnd = cindex; + while (scanEnd < inputEnd) + { + char ch = input[scanEnd]; + if (ch < low || ch > high) + { + break; + } + scanEnd++; + } +#endif + PushFrame(ref stackBuf, ref stackPooled, ref sp, ref bp, + capture, allocCount, pc1, scanEnd, ExecStateType.Split); + cindex = scanEnd; + pc = gotoTarget; + break; + } + } + } + + pc = gotoTarget; + + // Frame pruning for SplitGotoFirst: if continuation can't match + // at current position, skip the frame push. + if (cindex < inputEnd) + { + if (bc[pc1] == (byte) RegExpOpcode.Char + && input[cindex] != (char) ReadU16(bc, pc1 + 1)) + { + break; + } + + if (bc[pc1] == (byte) RegExpOpcode.Range) + { + int rn = ReadU16(bc, pc1 + 1); + if (!MatchRange16(bc, pc1 + 3, rn, input[cindex])) + { + break; + } + } + } + + PushFrame(ref stackBuf, ref stackPooled, ref sp, ref bp, + capture, allocCount, pc1, cindex, ExecStateType.Split); + break; + } + case RegExpOpcode.SplitNextFirst: { int val = ReadI32(bc, pc); pc += 4; - int pc1; - if (opcode == RegExpOpcode.SplitNextFirst) + int pc1 = pc + val; // continuation (backtrack target) + + // Greedy dot-star bulk scan: detect SplitNextFirst → Dot/Any → Goto(back) + // and replace the per-character opcode loop with a direct scan. + // Verify the Goto jumps backward (loop) and the atom is exactly 1 opcode. + if (bc[pc] is (byte) RegExpOpcode.Dot or (byte) RegExpOpcode.Any + && bc[pc + 1] == (byte) RegExpOpcode.Goto + && ReadI32(bc, pc + 2) < 0 + && bc[pc1] is (byte) RegExpOpcode.Char or (byte) RegExpOpcode.Range + && cindex < inputEnd) { - pc1 = pc + val; + bool isDot = bc[pc] == (byte) RegExpOpcode.Dot; + bool continuationIsChar = bc[pc1] == (byte) RegExpOpcode.Char; + + // Determine scan limit: Dot stops at line terminators, Any goes to end. + int scanLimit = inputEnd; + if (isDot) + { + // Find the nearest line terminator to cap the scan. + // \n and \r cover >99.9% of cases; 0x2028/0x2029 are checked below. + var remaining = input.AsSpan(cindex, inputEnd - cindex); + int ltPos = remaining.IndexOfAny('\n', '\r'); + if (ltPos >= 0) + { + scanLimit = cindex + ltPos; + } + + // Also check for rare Unicode line terminators (LS/PS) + int uPos = remaining.IndexOfAny('\u2028', '\u2029'); + if (uPos >= 0 && cindex + uPos < scanLimit) + { + scanLimit = cindex + uPos; + } + } + + // Use LastIndexOf (for Char) or backward scan (for Range) to find + // the RIGHTMOST position matching the continuation. Greedy quantifiers + // try the longest match first, so only this single frame is needed. + if (scanLimit > cindex) + { + int lastHit; + if (continuationIsChar) + { + char targetChar = (char) ReadU16(bc, pc1 + 1); + lastHit = input.LastIndexOf(targetChar, scanLimit - 1, scanLimit - cindex); + } + else + { + // Range continuation: scan backward for a char in the range + int rn = ReadU16(bc, pc1 + 1); + lastHit = -1; + for (int ri = scanLimit - 1; ri >= cindex; ri--) + { + if (MatchRange16(bc, pc1 + 3, rn, input[ri])) + { + lastHit = ri; + break; + } + } + } + + if (lastHit >= 0) + { + PushFrame(ref stackBuf, ref stackPooled, ref sp, ref bp, + capture, allocCount, pc1, lastHit, ExecStateType.Split); + } + } + + cindex = scanLimit; + pc = pc1; // Jump to continuation + break; } - else + + // Greedy quantifier frame pruning: if the continuation starts + // with a Char or Range opcode and the current input position + // can't match it, skip pushing the frame. + if (cindex < inputEnd) { - pc1 = pc; - pc = pc + val; + if (bc[pc1] == (byte) RegExpOpcode.Char + && input[cindex] != (char) ReadU16(bc, pc1 + 1)) + { + break; + } + + if (bc[pc1] == (byte) RegExpOpcode.Range) + { + int n = ReadU16(bc, pc1 + 1); + if (!MatchRange16(bc, pc1 + 3, n, input[cindex])) + { + break; + } + } } PushFrame(ref stackBuf, ref stackPooled, ref sp, ref bp, @@ -1272,6 +1734,29 @@ private static int ExecBacktrack( { if (sp == 0) { + if (hasFastScan && !effectiveScanInfo.IsAnchored) + { + // All inner backtracking exhausted at this position. + // Use IndexOf to jump to the next candidate. + int nextStart = lastScanStart + 1; + if (nextStart >= inputEnd) + { + return 0; + } + + int nextPos = FindScanChar(input, nextStart, effectiveScanInfo); + if (nextPos < 0) + { + return 0; + } + + lastScanStart = nextPos; + cindex = nextPos; + capture.Fill(Unset); + pc = effectiveScanInfo.PatternStartPc; + break; // Resume main interpreter loop + } + // No more backtracking frames - overall failure. return 0; }