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;
}