Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
125 changes: 125 additions & 0 deletions Jint.Benchmark/RegExpCustomEngineBenchmark.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,125 @@
using BenchmarkDotNet.Attributes;
using Jint.Runtime.RegExp;

namespace Jint.Benchmark;

/// <summary>
/// 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.
/// </summary>
[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(?<cap>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;
}
}
15 changes: 13 additions & 2 deletions Jint/Native/RegExp/RegExpConstructor.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
165 changes: 137 additions & 28 deletions Jint/Native/RegExp/RegExpPrototype.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -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);
Expand All @@ -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))
Expand Down Expand Up @@ -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;
Expand All @@ -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;
Expand Down Expand Up @@ -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 = "";
Expand Down Expand Up @@ -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 = "";
Expand Down
Loading