Skip to content

Run every script RegExp on GregsEx's ECMAScript engine - #4300

Open
sebastienros wants to merge 25 commits into
mainfrom
sebros/jint-gregsex-regex-engine-a5ccbe
Open

sebastienros wants to merge 25 commits into
mainfrom
sebros/jint-gregsex-regex-engine-a5ccbe

Conversation

@sebastienros

Copy link
Copy Markdown
Owner

Every regular expression written in script now runs on the ECMAScript flavor of GregsEx. Jint previously ran most patterns by converting them to .NET Regex, and the rest on a QuickJS libregexp port. Both are removed.

The full record — the decision, what changed, what the work found in either project, and the benchmarks — is in docs/design/regexp-gregsex-migration.md.

How GregsEx is consumed

Referencing the package is not an option: it ships net10.0 only, and its public surface stops above what a JavaScript engine needs (group names before a match, and an executor below the lastIndex session). The ECMAScript closure is vendored instead:

  • Where it lives: about 9.6K lines plus the Unicode 17.0 tables, under Jint/Runtime/RegExp/GregsEx/. All of it is internal.
  • How it is produced: tools/sync_gregsex.py regenerates the folder from GregsEx main. It applies mechanical transforms (namespace, internal, #nullable enable), then tools/gregsex-sync/jint.patch.
  • What the patch holds: structural edits only — the trimmed LaneContracts, only the Unicode 17.0 tables, and the .NET 9 SearchValues<string> lanes with their downlevel fallbacks. Every behavioural fix went upstream.
  • Keeping it current: .github/workflows/gregsex-sync.yml regenerates the folder every Monday. If the diff is empty it stops; otherwise it builds every target framework, runs the regex tests and opens a pull request.

Upstream fixes

The defects this work found in GregsEx were fixed there first, each pinned by a test on both sides:

  • sebastienros/gregsex#2 — correctness and the first speedups:

    • lone surrogates in the literal plans;
    • quantifier bounds above int.MaxValue;
    • an overflow in the atomic-sequence plan;
    • a greedy single-character run kept in one counted choice;
    • the case-insensitive and nullable-prefix prefilters;
    • timed budgets.

    It also made GregsEx #nullable enable throughout.

  • sebastienros/gregsex#3 — the second pass, after the first paired run:

    • per-search VM frame setup;
    • a clock read per search;
    • nullable repeats on the atomic-sequence plan;
    • case-insensitive classes and literals;
    • FIRST-character prefilters through groups;
    • line-terminator scans.

    It also fixes an inverse-fold cache that was shared across Unicode versions.

Breaking changes

Documented in docs/guide/migrating-to-v5.md §2, §4.161 and §5.42:

  • Removed: JsRegExp.Value, JsRegExp.ParseResult, and CompileRegex on the parsing options. A script RegExp now converts to the CLR as the JsRegExp itself.
  • Host Regex objects keep the semantics they had. A Regex the host hands to script keeps .NET semantics, as before. The new escape hatch Options.Interop.RegexConversion = RegexConversionMode.EcmaScript converts it to an ECMAScript pattern instead.

Compliance

Suite main this branch
test262 (7ab7fafa, all five sub-directories, both modes) 102,653 passed, 0 failed, 87 skipped 102,653 passed, 0 failed, 87 skipped
GregsEx ECMAScript corpora against their Node 24.4.1 / V8 13.6 oracle 2,342 of 2,346 2,342 of 2,346 (the same four failures)
Jint.Tests, Jint.Tests.PublicInterface, Jint.Tests.CommonScripts all pass all pass, with 71 new cases in RegExpEngineRegressionTests

None of the 87 skipped test262 cases is regex-related, and the harness settings exclude nothing regex-related, so there was no skipped regex corpus to re-enable. The four corpus failures are the same on both builds:

  • three cases of /./uy with lastIndex inside a surrogate pair, which GregsEx's corpus marks as disputed;
  • one harness artefact (String(-0)).

Performance

Paired BenchmarkDotNet run (Jint.Benchmark/measure-paired.ps1, 3 rounds in alternating order) of every regex benchmark the suite has, plus SunSpider regexp-dna and Dromaeo ObjectRegExp:

  • Builds compared: main at 82b0efbaa against this branch with GregsEx dc455e97 vendored.
  • Machine: Apple M4 Pro, .NET 10.0.11, macOS 15.
  • Reading it: a row counts as a change only when its 95% CI excludes zero. control-indexOf never reaches regex code; it reads +0.9% [+0.1, +3.0], the run's noise floor. Three rounds settles the large effects; a ±5% row needs six.

36 of the 41 rows are faster; 4 are slower.

Row Δ vs main 95% CI
template-replace-g (str.replace(/(a)(b)?/g, '$2$1')) −97.9% [−98.0, −97.8]
SplitUnicode −92.1% [−92.2, −92.1]
uprop-match-g (/\p{L}+/gu) −92.0% [−92.2, −92.0]
VariableLength −83.8% [−83.9, −82.8]
SunSpider regexp-dna −73.5% [−74.4, −72.8]
FreshEngineSource −73.0% [−73.5, −71.2]
SplitWithCaptures −59.9% [−60.0, −59.0]
capture-match-g −50.9% [−51.9, −48.3]
NamedCapture −46.3% [−46.3, −45.7]
class-match-g −45.4% [−45.5, −44.7]
GlobalMatchAll / IsMatchOnly / LiteralScan −40.5% / −40.2% / −38.2% all exclude zero
MatchGlobalUnicode / MatchGlobal −39.2% / −31.9% all exclude zero
nullable-split (csv.split(/\s*,\s*/)) −35.6% [−38.5, −34.4]
NoMatchScan −34.9% [−35.9, −34.3]
greedy-dotstar-exec / -test (/a.*a/ over 64 KiB) −30.3% / −27.1% all exclude zero
construct-100 / function-replace-g / CaseInsensitiveScan −26.9% / −26.1% / −25.7% all exclude zero
char-split (str.split(/a/)) −21.9% [−24.1, −20.9]
exec loops (NamedGroupsAccess, MatchAllIterate, ExecWhileLoop, exec-loop, StickyExec) −21.2% … −16.4% all exclude zero
RegExpSplitBenchmark.Split −10.2% [−10.6, −9.1]
Dromaeo ObjectRegExp (4 rows) −10.0% … −7.9% all exclude zero
FreshEnginePrepared −6.5% [−15.0, −6.4]
CharClassScan / DigitScan −2.9% / −2.0% all exclude zero
Slower: AnchoredLiteral +50.6% [+49.1, +61.8] — 13 ns → 19 ns
Slower: literal-test / literal-replace-g +17.6% / +15.6% 3.2 µs → 3.7 µs / 3.3 µs → 3.8 µs
Slower: icase-literal-test +4.7% [+4.5, +5.7], below the three-round bar

Allocation fell on the capture-heavy rows:

Row Before After (per operation)
template-replace-g 177 MB 0.6 MB
SplitUnicode 25 MB 1.6 MB
capture-match-g 10.5 MB 2.3 MB
MatchGlobal 5.8 MB 1.4 MB

The literal rows scan 64 KiB for a 10-character literal that isn't there. GregsEx already uses the fastest search I measured for that (SearchValues<string>: 3.1 µs, against 4.9 µs for IndexOf(string)). .NET Regex chooses its anchor characters from a frequency table, which is what the remaining 0.5 µs would take.

The baseline is main from before the latest merge. The only regex change on main since then (#4267) touched the removed QuickJS interpreter. Its new surrogate-pair tests in RegExpTests run against GregsEx here.

Follow-ups

These are listed in the design doc's §6:

  • the literal-scan anchor choice;
  • batching global match and plain-string replace the way split now is;
  • a parse-time validator on the GregsEx parser, so a literal is no longer parsed twice;
  • moving String.prototype.toUpperCase/toLowerCase onto the GregsEx Unicode tables;
  • the legacy RegExp.$1 statics in the fused lanes.

🤖 Generated with Claude Code

sebastienros and others added 17 commits October 17, 2026 10:49
Sources from sebastienros/gregsex@d989dd84 (MIT), ECMAScript flavor only:
parser, program contracts, backtracking executor and accelerator, budgets,
and the Unicode 17.0 tables the pinned test262 asserts. Types are internal,
namespaces moved under Jint.Runtime.RegExp.GregsEx, RegularCore/PCRE2 lane
contracts trimmed, and downlevel gaps closed with Polyfills.cs entries.
Not wired in yet.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
JintRegExpEngine now wraps the GregsEx ECMAScript executor in place of the
QuickJS bytecode port, with per-thread scratch buffers and frames that grow
on demand. Every pattern is routed to it (temporary switch, .NET adaptation
still present). Three defects surfaced and are fixed: two in the GregsEx
copy (lone surrogate literals in u-mode plans, quantifier bounds above
Int32, atomic-plan length overflow) and one in RegExp.prototype[@@replace]
that turned an undefined capture into an empty string for a replacer.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
RegExpParseCache now caches compiled matchers; literals memoize the matcher
on the AST; the NeedCustomEngine scan, the case-folding fallback and the
CompileRegex options are gone. A host-supplied Regex keeps .NET semantics by
default, with Options.Interop.RegexConversion = EcmaScript as the opt-in to
translate it. JsRegExp.Value and ParseResult are removed; a script RegExp
converts to the JsRegExp itself.

Fused replace (function and $-template) and split lanes run on the matcher's
capture spans, exec builds its result straight from them, and range-only
lanes do no capture work. In the GregsEx copy: greedy single-character
repeats save one counted choice instead of one per character, accelerated
plans are no longer refused by the VM's frame reservation, and a timed
search takes the fast charge path and starts without reading the clock.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…rch costs

- FIRST-set prefilter for a pattern whose first atom is case-insensitive or
  is preceded by nullable atoms (/ZZQ/i was 1150x slower than .NET);
  never starts a u/v match inside a surrogate pair.
- Literal plan searches with SearchValues<string> on .NET 9+.
- exec captures are slices of the subject; a replace with no match returns
  the subject.
- Searches in one native loop share a clock reading for their deadlines.
- Regression tests for every defect the migration surfaced.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
RegExpShapeBenchmark: one isolated engine per shape over a 64 KiB subject, with
an indexOf control row. RegExpCustomEngineBenchmark calls the GregsEx-backed
engine through its span API.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- FirstStrings: each branch's leading fixed strings (literals and small
  classes expanded) searched with SearchValues<string>, OrdinalIgnoreCase where
  a case-insensitive atom allows it. SunSpider regexp-dna was 28x slower than
  main (case-insensitive DNA alternations); it is now faster than main.
- UnicodeRunStops: a u/v class holding every surrogate and astral code point
  ends its run only at a few BMP units, found vectorized (/q[^z]*q/u 557us ->
  3us).
- The matcher plans its frame count and partition sizes once per growth and
  searches through the executor's prevalidated entry.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The behavioural fixes the migration found are upstream since sebastienros/gregsex#2, so the vendored copy is now
GregsEx a12c3ce8 plus the mechanical transforms and tools/gregsex-sync/jint.patch, which holds only what stays
Jint's own: the trimmed LaneContracts, the Unicode 17.0 tables alone and the .NET 9 guards.

tools/sync_gregsex.py writes gregsex.lock.json with every file's digest; CI verifies it, so a hand edit fails the
build. gregsex-sync.yml re-vendors every Monday, does nothing when upstream has not moved, and otherwise builds,
runs the regex tests and opens a pull request.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The sync tool now keeps no state: it regenerates every vendored file from GregsEx main, and the workflow opens a
pull request only when that leaves a non-empty diff. A lock recording the synced revision changed on every upstream
commit, including the many that touch nothing Jint vendors, so it would have opened a pull request for each of them.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The vendored GregsEx edits here are to be upstreamed and then synced back:
- SearchCore no longer holds the VM, whose large frame was zeroed on every search, plan or not;
- a budget built from an absolute deadline amortizes its zero-unit checkpoint instead of reading the clock per search;
- the atomic-sequence plan admits nullable repeats (split(/\s*,\s*/) left the VM);
- a legacy-i class gets an exact folded BMP bitmap, ASCII canonicalization is a table, and an i-flag literal with no
  case variants is planned as a plain literal (/[a-z]+@/i: 2 us to 37 ns);
- the FIRST-character prefilter looks through groups and alternations;
- a /./ run is measured with one range search for its line terminators.

Jint side: a leaner adapter search, and split on a pattern without groups takes match ranges a batch at a time.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The vendored files are unchanged: GregsEx 008b4aa8 (sebastienros/gregsex#3) carries the matcher edits this branch
made locally, so the patch is back to Jint's structural edits only. The migration record gains G11-G17 and J4, and
section 5 is the paired run after them: 36 of the 41 regex rows faster than main, the literal scans +16-18%.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…egex-engine-a5ccbe

# Conflicts:
#	Jint.Tests/SpecAnchors.txt
#	Jint/Runtime/RegExp/RegExpInterpreter.cs
#	docs/guide/migrating-to-v5.md
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ark page

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

@lahma lahma left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sebastien, this is a careful migration: test262 holds at zero failures, the fused lanes keep the spec's order, and the timeout still surfaces as RegexMatchTimeoutException. I'd hold the merge for the first finding below. The others are smaller and mostly a question of documenting what changed for embedders.

Everything here comes from reading the PR head (ebe97e074) against main; I haven't built or run it, so the sizes below are arithmetic from the code, not measurements.

1. Backtracking scratch grows to gigabytes, is shared across engines, and is never released

  • Per-frame cost. Every choice point reserves a full copy of the task stack plus the registers.
    • Reservation: EcmaExecutor.GetRequiredCapacity (EcmaExecutor.cs:55-65) and Save (:860-894).
    • Per frame that is 6 × (2·depth + 4) + 2·groups + 4 ints (EcmaExecutorFactory.cs:56-57).
    • A greedy repeat over a multi-character body, and every alternative, saves one frame per iteration (EcmaExecutor.cs:467-473, :532-539).
  • Ceiling. Plans grow ×4 up to MaxFrames = 1 << 22 (JintRegExpEngine.cs:43, :316-323).
  • Example. /"(?:[^"\\]|\\.)*"/, an ordinary string-literal tokenizer, over a ~1 MB literal needs about 100 ints per frame at two frames per character. That is ~0.4 GB at the 1M-frame plan and ~1.7 GB at 4M. The QuickJS port capped its stack at 16M ints (64 MB).
  • Shared across engines. The grown FramePlan is published onto the JintRegExpEngine itself (Volatile.Write(ref _plan, …)).
    • That instance is cached process-wide (RegExpParseCache.cs:28) and memoised on AST nodes (JintLiteralExpression.cs:80-86).
    • Search requires every thread's scratch to fit the grown plan (:269-272).
    • So after one large input, every thread and engine that later runs the same pattern, even on a three-character subject, allocates the full buffer.
  • Never released. t_scratch only grows (:355-370), so the memory stays for the life of the thread.
  • Constraints can't prevent it. LimitMemory can only object after the native call has allocated. The regex timeout doesn't prevent the allocation either.
  • Overflow. Past taskSlots ≥ 512, or groups ≥ 256 at 4M frames, the checked arithmetic in GetRequiredCapacity throws a raw OverflowException. #4240 just lifted the 255-group limit, so that second case is now reachable.
  • Fix:
    • Bound the scratch by bytes, not frames; the old 64 MB is a reasonable figure.
    • Above a small threshold, allocate per search and drop the buffer afterwards, rather than keeping it in t_scratch.
    • Don't publish growth onto the shared matcher.
    • Upstream, variable-length frames (store used ints rather than reserving taskSlots per frame) would cut this by an order of magnitude.

The design doc and the comment in JintLiteralExpression describe the matcher as immutable; _plan makes that untrue.

2. Exhausting the frame ceiling throws an internal exception

  • SearchSlow throws RegExpResourceException (JintRegExpEngine.cs:318, class at :411).
    • It's internal and derives from Exception.
    • Nothing catches it, and it isn't in Throw.MustPropagateHostException.
  • The QuickJS port threw RegexMatchTimeoutException("Regular expression backtracking stack overflow"). V8 throws a RangeError.
  • So an embedder now receives a type they can't name, with no migration entry.
  • Fix: map exhaustion, and the OverflowException path from finding 1, to a JS RangeError, or keep RegexMatchTimeoutException. Add a row to the guide.

3. Interop changes the migration guide doesn't spell out

§4.161 says ToObject() and "every conversion built on it" now return the JsRegExp. In practice that means:

  • CLR binding. A script RegExp no longer binds to a Regex-typed method parameter, property or delegate.
    • Binding goes through argument.ToObject() (MethodInfoFunction.cs:459), which now yields the JsRegExp.
    • DefaultTypeConverter has no Regex case.
    • So host.Use(/a/) with string Use(Regex r) stops resolving.
  • Engine-bound objects in host graphs. ToObject() on { re: /x/ } now puts an engine-bound JsRegExp into the host's ExpandoObject or dictionary.
    • It exposes ObjectInstance.Engine to any serializer that walks public properties.
    • A host that hands results from one engine to another now moves a JsValue across engines, which is unsupported.
  • No way to match from C#. The guide says "read Source/Flags" but doesn't say how a host can still match. Either engine.Invoke on exec, or a best-effort new Regex(source, …), would do.
  • The public JsRegExp(Engine) constructor can no longer be made usable, since Value and ParseResult are gone. The guide could point to Intrinsics.RegExp.Construct.

4. search stopped updating RegExp.$1

  • On main, a non-u pattern in RegExp.prototype[@@search] went through RegExpExec, and so through UpdateLegacyStaticProperties.
  • Now every script pattern takes the fused lane (RegExpPrototype.cs:1478-1488), which doesn't update the statics.
  • So 'zzq'.match(/(z)/); 'xaby'.search(/(a)(b)/); RegExp.$1 gives "a" on main and "z" here.
  • J3 in the design doc calls the fused-lane gap pre-existing and unchanged; that's true for the other lanes but not for search.
  • FusedReplace, by contrast, now sets the statics from the last match before any replacer runs, which is better than main.

5. Cold compile cost and memory per compiled pattern

  • Bitmap per node. Every set node gets its own EcmaFastSet with an 8 KiB ulong[1024] bitmap (EcmaAccelerator.cs:66, :671). That covers ., […], \d, \w and \s, and it is per node, not per distinct set.
  • Legacy i loop. Under legacy i, each set also loops over all 65,536 BMP code units (:158-173).
  • Case-mapping set. An i pattern that contains a cased literal builds a HashSet of roughly 2,500 case mappings on every compile (:607-639).
  • Not measured. None of this shows in the suite, because every compile row hits the process cache after warm-up. construct-100 uses only 10 distinct sources. A cold-compile row with a few hundred distinct patterns, i classes among them, would show whether it matters.
  • Fix:
    • Share EcmaFastSet instances for identical sets, at least within a program.
    • Build a bitmap only for sets with non-ASCII members.
    • Walk the bitmap's words instead of all 65,536 code units.
    • Cache the cased-character set the way InverseFolds is cached.

6. gregsex-sync.yml runs fetched upstream code with a write token

  • The workflow grants contents: write and pull-requests: write workflow-wide.
  • actions/checkout persists GITHUB_TOKEN in .git/config, and the same job then builds and runs tests over freshly downloaded upstream files and restored packages.
  • Fix: a read-only build-and-test job with persist-credentials: false, then a second job, holding the write permissions, that applies the diff, pushes and opens the PR without running repo code.

Two smaller points in the same file:

  • git push --force on gregsex-sync discards any commits someone pushed there, which the file's own header invites.
  • The test filters miss HostRegexTimeoutTests, the URLPattern hostile-pattern test and test262's String.prototype regex tests.

7. Smaller items

  • Stale agent docs. Jint/Constraints/AGENTS.md:67 and Jint/Runtime/Interpreter/AGENTS.md:22 still describe JintLiteralExpression.AdaptedRegExp, the compilation mode and the custom-engine split. RegexConversionOptions.ResolveTimeout is now dead code, and the comment at RegExpConstructor.cs:476 names a GetMatchTimeout that doesn't exist.
  • Compiling a run-time pattern is no longer bounded.
    • main compiled for the custom engine under CancellationTokenSource(regexTimeout).
    • JintRegExpEngine.Compile is now called without a token, and CompileLimits sets every budget to MaxValue.
    • If GregsEx's compile is linear in the pattern this doesn't matter; if any stage isn't, a script-supplied new RegExp(src) is now unbounded.
  • RegexConversionMode.EcmaScript.
    • It rejects RegexOptions.ECMAScript, the one .NET option that aligns the syntax.
    • It silently drops the host Regex's MatchTimeout.
    • §5.42 and the enum doc say an incompatible pattern throws "rather than matching differently". That isn't so: \d, \w, \b, $ and . are valid in both and match differently.
  • Split timeout. A split on a pattern without groups shares one deadline across up to 32 searches (JintRegExpEngine.cs:168), so §4.161's "the timeout applies to every match" doesn't quite hold there.
  • Downlevel.
    • The patch puts about ten #if NET9_0_OR_GREATER blocks inside EcmaAccelerator/EcmaExecutor. A Jint-owned wrapper, like the existing StringSearchValues, would keep the patch structural and less fragile on every sync.
    • The net8.0 asset runs those fallback lanes, and Jint.Benchmark is net10.0-only, so they have never been measured.
  • Attribution. CREDITS.txt doesn't mention GregsEx (MIT) or the UCD 17 data.
  • AOT. Jint.AotExample has no regex probe, so the native leg never exercises the new engine.

Gate rows (x64)

I'll run these in a quiet window once finding 1 is addressed.

  • RegExpCustomEngineBenchmark.AnchoredLiteral, .CaseInsensitiveScan, .DigitScan, .CharClassScan
  • RegExpShapeBenchmark.Run:
    • the slower rows: literal-test, literal-replace-g, icase-literal-test
    • the floor: control-indexOf
    • representative faster rows: template-replace-g, nullable-split, greedy-dotstar-test, char-split, function-replace-g, construct-100
  • RegExpSplitBenchmark.Split, .SplitUnicode; RegExpMatchBenchmark.MatchGlobal; RegExpExecLoopBenchmark.ExecWhileLoop, .StickyExec
  • RegExpFreshEngineBenchmark.FreshEngineSource, .FreshEnginePrepared
  • SunSpiderBenchmark.Run: regexp-dna, string-tagcloud, string-unpack-code, string-validate-input, date-format-xparb
  • DromaeoBenchmark.ObjectRegExp, all four Modern × Prepared rows
  • Handlebars (PreparedScriptBenchmark): ExecuteStringOnMultipleEngines, and this PR's ExecutePreparedOnMultipleEngines against main's ExecutePreparedWithRegexCompilationOnMultipleEngines, which was renamed
  • Non-regex controls: StringSplitBenchmark.SplitOnChar, StringReplaceBenchmark.ReplaceAllPattern, DromaeoBenchmark.ObjectString

… compile budget

- A search's backtracking storage is bounded by size, not frame count: a thread keeps at most 1 MiB, growth beyond
  it belongs to the search and is dropped, and only growth within it is published to the shared matcher. At 64 MiB
  the match throws a RangeError, as V8 does, instead of an internal exception type; the frame ceiling is derived
  from the per-frame size, so GetRequiredCapacity can no longer overflow.
- RegExp.prototype[@@search] updates the legacy RegExp.$1 statics again.
- Compiling a pattern runs under the regex timeout, as a GregsEx compile-work budget, and throws
  RegexMatchTimeoutException when it runs out.
- RegexConversionMode.EcmaScript accepts RegexOptions.ECMAScript and keeps the host Regex's MatchTimeout; the docs no
  longer claim a pattern valid in both syntaxes matches the same way.
- new JsRegExp(engine) gets the empty pattern's matcher.
- gregsex-sync.yml: a read-only build-and-test job hands its diff to a write job that runs no repository code; one
  branch per upstream revision, never force-pushed; the test filters cover the timeout, URLPattern and
  String.prototype regex tests.
- The migration guide spells out the interop consequences and the new limits; the agent docs, the design record,
  CREDITS.txt (GregsEx, UCD 17), the AOT example (a regex probe) and a cold-compile benchmark follow.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@sebastienros

sebastienros commented Oct 18, 2026 •

Copy link
Copy Markdown
Owner Author

Thanks, Marko, this was a very useful read. Everything that lives in this PR is fixed in 122bc7520. Two items need GregsEx changes; for one of them that work has started.

1. Backtracking scratch

  • Bounded by size, not by frame count. The ceiling is computed from the pattern's own per-frame size, so GetRequiredCapacity can no longer overflow (including at high group counts).
  • Retention: a thread keeps at most 1 MiB between searches. Growth beyond that belongs to the one search and is dropped when it returns.
  • Sharing: only growth within that 1 MiB is published to the shared matcher, as a sizing hint. The docs now call it that, instead of claiming the matcher is immutable.
  • Ceiling: a search may use at most 64 MiB, the QuickJS figure.
  • Your example now throws. Your string-literal tokenizer over 1 MB hits that ceiling. main matched it, because QuickJS frames are smaller. The real fix is your variable-length-frames suggestion, which is GregsEx work I'm proposing upstream. Until then it's recorded in the migration guide and the design doc.

2. Exhaustion exception. Exhaustion now throws a JS RangeError ("Maximum regular expression backtracking depth exceeded"), as V8 does. The guide has a row for it.

3. Interop. §4.161 now covers:

  • Regex-typed parameters, properties and delegates no longer binding;
  • ToObject() graphs holding the engine-bound JsRegExp;
  • matching from C# via exec, and the caveats of new Regex(source);
  • Intrinsics.RegExp.Construct.

new JsRegExp(engine) now gets the empty pattern's matcher instead of none.

4. search statics. Fixed: search updates RegExp.$1… again. Your example now gives "a", the same as main, and is pinned. J3 in the design doc is corrected.

5. Cold compile. You were right, and it's worse than the arithmetic suggested. I added RegExpColdCompileBenchmark (50 unique patterns per operation, cache misses):

Row vs main
email (no flags) +55%
email-i +1,225%
classes-i +461%
\p{L} under u +1,596%

Part of this is my own regression from sebastienros/gregsex#3: the folded-bitmap loop and the per-compile cased-character set. The rest predates it: property resolution in the compiler, and one 8 KiB bitmap per set node. All of it is GregsEx code. A GregsEx PR is in progress, and its numbers will come back here through the sync.

6. gregsex-sync.yml

  • Jobs split: a read-only build-and-test job with persist-credentials: false hands its diff to a write job that runs no repository code.
  • Branches: one branch per upstream revision, never force-pushed.
  • Test filters: they now cover HostRegexTimeoutTests, URLPattern, and test262's String.prototype regex methods.

7. Smaller items

  • Stale agent docs: both rewritten for the new design. ResolveTimeout is used again rather than deleted, and the stale GetMatchTimeout comment is fixed.
  • Compile bound: compiling now runs under the regex timeout, converted to a GregsEx compile-work budget (GregsEx's compiler reads no clock). Running out throws RegexMatchTimeoutException.
  • RegexConversionMode.EcmaScript: it accepts RegexOptions.ECMAScript, keeps the host Regex's MatchTimeout, and the docs now list \d, \w, \b, $ and . as matching differently.
  • Split timeout: the guide now says a split takes its matches 32 at a time, with one deadline per batch.
  • Attribution and AOT: CREDITS.txt lists GregsEx and UCD 17, and Jint.AotExample has a regex probe, its RangeError case included.
  • Not done:
    • Moving the #if NET9_0_OR_GREATER blocks behind a Jint-owned wrapper. I'd rather fold that into the next sync than grow this PR.
    • Downlevel measurement, still unmeasured.
    • Legacy statics for the other fused lanes, still a follow-up.

All suites, test262 (102,653 / 0 failed), the docs build and its link check pass. Your gate list looks right; run it whenever you have a quiet window. I'd hold the merge for the GregsEx cold-compile PR so its fix lands with this one.

…egex-engine-a5ccbe

# Conflicts:
#	docs/guide/migrating-to-v5.md
@lahma

lahma commented Oct 18, 2026

Copy link
Copy Markdown
Collaborator

Thanks, Sebastien. I read 122bc7520 against the points I raised, and the fixes hold:

  • Scratch growth. _maxFrames comes from the affine per-frame size, so GetRequiredCapacity stays far below int.MaxValue.
  • Retention. Growth past RetainedScratchInts uses per-search arrays and is never published.
  • Exhaustion error. Every FindRanges/exec catch site maps RegExpResourceException to RangeError. That includes URLPattern, which goes through RegExpBuiltinExec.
  • Compile timeouts. A compile that runs out of budget is not cached (RegExpParseCache), so a short-timeout engine can't poison a pattern for a long-timeout one.

Two things remain, both from reading the code. I haven't run either.

1. The ceiling is reached at roughly 80K characters, not 1 MB.

  • The tokenizer frame is about 100 ints, and the pattern needs two frames per character.
  • So MaxScratchInts (16M ints) runs out at about 16M / 200 ≈ 84K characters.
  • That means /"(?:[^"\\]|\\.)*"/ throws RangeError on any string literal longer than that. main matches it, and so does V8, whose backtrack stack holds millions of entries.
  • The same applies to any (?:a|b)* over a long input, which is a common shape in markdown and template tokenizers.

Since the merge is already waiting for the GregsEx cold-compile PR, I'd like variable-length frames to land with it. If not, could the migration guide state the actual size limit (about 80K characters for an alternation inside a star), not just that "large inputs" can throw?

2. The initial plan isn't capped by the retention limit.

  • The constructor starts at min(64, _maxFrames) frames (JintRegExpEngine.cs:98), and the fast paths at :205 and :311 keep that plan in t_scratch.
  • Once a frame passes 4,096 ints (about 2,000 groups, or a deep task stack), that first plan is already over 1 MiB. Each thread then keeps it, up to the full 64 MiB at around 128K groups.
  • The comment at :81 ("never above RetainedScratchInts") overstates it.
  • A fix would be to start at the number of frames that fits RetainedScratchInts (at least one), or to send an oversized initial plan down the per-search path.

Gate. Since the head will change with the GregsEx sync, I'll run the x64 gate on that head, not this one. Today's quiet window is already booked for #4277 and #4265.

…al backtracking limit

The initial plan was 64 frames whatever a frame cost, so a pattern with thousands of groups kept more than
RetainedScratchInts in every thread's scratch before it searched at all. It now starts at what fits the cap, and a
pattern whose single frame is over the cap brings storage of its own to every search. The thread's arrays no longer
double past the cap either.

The migration guide said the backtracking ceiling arrives at "a megabyte or more"; measured, an alternation inside a
repeat reaches it at about 82,000 characters (/"(?:[^"\\]|\\.)*"/), 120,000 (/(a|b)*c/) and 140,000 (/(?:a|b)*c/).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
sebastienros/gregsex#4 makes backtracking frames variable-length and cuts per-pattern compile setup. The longest run
the 64 MiB ceiling admits goes from about 82,000 to about 466,000 characters for /"(?:[^"\\]|\\.)*"/ (121,573 to
372,823 for /(a|b)*c/, 143,393 to 621,375 for /(?:a|b)*c/). Against main, compiling 50 unique patterns goes from
+1,596% to +1% for \p{L} under u, +1,225% to +131% for an i-flag email pattern and +461% to +101% for i classes.

- System.Numerics.BitOperations, which the new code uses, is polyfilled for net472 and netstandard2.0/2.1.
- A frame is now a small fixed width and the registers are the fixed part of the storage, so the retained-storage
  test pins the group count at which a pattern starts at one frame.
- The compile-timeout test uses a pattern that still charges more than the budget's floor.
- The guide's limits and the design record's follow-ups are re-measured.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@sebastienros

sebastienros commented Oct 18, 2026 •

Copy link
Copy Markdown
Owner Author

Thanks, Marko. Both points are addressed, and the GregsEx sync has landed, so 26f093590 is the head for your gate.

1. The ceiling. You were right about the size: I measured 82,239 characters for the tokenizer. sebastienros/gregsex#4 makes frames variable-length: a choice saves a fixed-width frame and trails only the task and register slots it changes. Measured on Jint's 64 MiB ceiling:

Pattern Before After
/"(?:[^"\\]|\\.)*"/ 82,239 466,030
/(a|b)*c/ 121,573 372,823
/(?:a|b)*c/ 143,393 621,375

A literal containing escapes gets further (about 930K characters). main and V8 still match longer runs, so §4.161 now states these measured limits, notes that a single-character repeat ([ab]*, .*, [^"]*) has none, and suggests rewriting (?:a|b)* as [ab]*. GregsEx's own numbers for the change are in sebastienros/gregsex#4: a 1 MiB quoted string goes from 157 to 28 workspace ints per character, and from 48.9 to 27.0 ms. One shape got slower: groups inside an alternation inside a repeat, such as ((a)|b)+c, is 7–12% slower, so it may be worth a row in the gate.

2. The initial plan. Fixed in bf66ac477:

  • A pattern starts at the frame count whose storage fits RetainedScratchInts. A pattern whose fixed part alone is over the cap starts at one frame and brings storage of its own to every search.
  • The thread's arrays no longer double past the cap.
  • The comment no longer overstates it.

After sebastienros/gregsex#4 a frame is a small fixed width and the registers are the fixed part, so the boundary is now a group count: about 65K groups, where the registers and their stamps pass 1 MiB. APatternWhoseStorageExceedsTheRetainedCapStartsAtOneFrame pins both sides: 3,000 groups start at 64 frames, 140,000 at one.

Also in this sync:

  • Cold compile, against main (50 unique patterns per operation; alternating runs on a loaded machine, so treat these as approximate):

    Pattern Before Cut the cost of compiling ECMAScript patterns gregsex#4 After
    \p{L} under u +1,596% +1%
    email pattern under i +1,225% +131%
    i classes +461% +101%
    plain email +55% +57%
    alternation +48% +59%
    literal −10% −26%

    What's left is per-pattern setup; it's listed in the design doc's follow-ups. RegExpColdCompileBenchmark has these shapes if you want them in the gate.

  • Downlevel: System.Numerics.BitOperations, which the new GregsEx code uses, is polyfilled for net472 and netstandard2.0/2.1.

All suites, test262 (102,653 / 0 failed), the AOT example and the docs build pass on 26f093590.

@lahma

lahma commented Oct 19, 2026

Copy link
Copy Markdown
Collaborator

x64 gate on 26f093590, stage 1 only: 3 rounds over 29 rows. The wins are large, but three SunSpider string rows and cold compile are slower in every round, so as measured I'd hold the merge. Under our rule these are stage-1 flags, not formal blocks: the stage-2 rounds didn't fit in today's window. At these sizes, though, I don't expect more rounds to change the reading.

Setup

  • Code: base is main at 84fcbdc32. The candidate is that plus a --no-ff merge of 26f093590 (fb2ccb40e, clean).
  • Local-only scaffolding on base, never committed, so both sides run the same harness:
    • RegExpColdCompileBenchmark.cs and RegExpShapeBenchmark.cs are copied from the PR, since both are new in it;
    • an ExecutePreparedOnMultipleEngines alias of main's ExecutePreparedWithRegexCompilationOnMultipleEngines.
  • RegExpCustomEngineBenchmark: each side calls its own engine's direct API.
  • Harness: measure-paired.ps1 stable mode, the default BDN job, affinity on CPUs 2–15 and blocking GC.
  • Rounds: 3, alternating the order (b,c / c,b / b,c), each starting on a clean live CPU sample. Memory load was 43–46%.
  • Reruns: desktop activity forced one whole-round rerun and ten row-level re-pairs; no flag below rests on a re-paired cell alone.
  • Controls: StringSplit.SplitOnChar, StringReplace.ReplaceAllPattern, Dromaeo ObjectString[F,F] and ColdCompile control-concat. No control moved the same way in both orders, so the adjusted series stands, and every call below reads the same on raw.

Slower in all 3 rounds

row base candidate raw Δ (95% CI) adjusted Δ allocation, cand/base
SunSpider string-tagcloud 30.37 ms 37.29 ms +23.4% [+12.8, +24.7] +23.7% 1.00
SunSpider string-unpack-code 35.93 ms 41.12 ms +14.4% [+3.8, +21.6] +14.4% 0.61×
SunSpider string-validate-input 19.52 ms 20.99 ms +7.3% [+7.3, +7.7] +8.0% 0.99
ColdCompile email-i 711.6 µs 980.0 µs +37.7% [+29.5, +45.9] +37.7% 5.28× (299 KB → 1.54 MB)
ColdCompile email 601.5 µs 821.8 µs +35.6% [+33.3, +37.4] +37.7% 8.58× (172 KB → 1.44 MB)
ColdCompile uprop-u 1.155 ms 1.519 ms +30.1% [+28.6, +32.2] +30.4% 1.64×
ColdCompile classes-i 1.572 ms 1.770 ms +12.6% [+12.5, +13.7] +12.9% 0.47×
Shape literal-test 2.367 µs 2.652 µs +12.1% [+10.5, +12.4] +12.1% 0.97
Shape icase-literal-test 2.458 µs 2.696 µs +7.7% [+5.4, +10.5] +8.0% 0.97
Shape literal-replace-g 2.596 µs 2.787 µs +7.2% [+5.6, +9.5] +7.5% 0.98
Dromaeo ObjectRegExp[F,T] 40.61 ms 41.95 ms +1.5% [+1.3, +3.8] +3.9% 1.00
  • Flagged only on the adjusted series: SunSpider date-format-xparb (+1.2% raw, 2/3; adjusted +1.1%, 3/3) and Dromaeo ObjectRegExp[T,T] (+0.7% raw; adjusted +1.0%, 3/3).
  • Leaning slower, 2/3: Handlebars ExecutePreparedOnMultipleEngines +6.1% [−1.8, +7.2] and Dromaeo ObjectRegExp[F,F] +3.9%.
  • No change: ExecuteStringOnMultipleEngines +0.2% and ObjectRegExp[T,F] −1.4%.

Faster in all 3 rounds

row base candidate raw Δ allocation, cand/base
SunSpider regexp-dna 12.04 ms 5.43 ms −54.9% 1.00
FreshEngine FreshEngineSource 61.47 µs 26.60 µs −57.1% 0.24×
CustomEngine CaseInsensitiveScan 4.154 µs 2.503 µs −39.8% 0 B
Match MatchGlobal 1.411 ms 933.1 µs −33.9% 0.24×
Split Split 966.8 µs 784.7 µs −19.0% 0.99
ExecLoop ExecWhileLoop 247.2 µs 218.9 µs −10.2% 0.73×
CustomEngine AnchoredLiteral 30.0 ns 28.1 ns −6.1% 0 B

Reading it

  • The SunSpider rows are whole-script workloads, not micro rows, and each is slower in both orders.
    • string-tagcloud is mostly its parseJSON validation: three chained global replaces over a large JSON string, then a ^…$ test. The middle one is /"[^"\\\n\r]*"|true|false|null|-?\d+(?:\.\d*)?(:?[eE][+\-]?\d+)?/g: an alternation with optional groups, replaced globally.
    • string-unpack-code is the packer's while(c--) p = p.replace(new RegExp('\\b' + e(c) + '\\b', 'g'), k[c]): one new RegExp per keyword, then a global replace. That makes it a run-time compile workload as much as a matching one.
    • string-validate-input evaluates a regex literal (/^[a-zA-Z0-9\-\._]+@…\.[a-zA-Z]{2,3}$/) inside a 4,000-iteration loop and calls .test once per iteration. So it pays for literal evaluation (JintLiteralExpression changed in this PR) as well as a short anchored match.
    • I haven't profiled any of them. If you want an A/B ETW profile of string-tagcloud from this machine, I can capture one.
  • Cold compile: much better than your pre-sync numbers (email-i +38% against +1,225%; uprop-u +30% against +1,596%). But email and email-i still allocate 5–9× more per compile.
  • The three Shape literal rows may not be real. They spend most of their time in vectorised SpanHelpers searches, where Support regular expressions with more than 255 captures #4240 showed a 64-byte loop-placement lottery: each process lands at one of two levels, and an unrelated diff shifts which. control-indexOf (no regex at all) showed exactly that: both sides were bimodal at about 1.95–2.01 or 2.74–2.75 µs per process, so it was dropped as a control. These three rows were uniform across 3 processes per side, which is too few to rule the lottery out, so treat them as suspects rather than findings. The SunSpider and cold-compile flags stand without them.
  • ((a)|b)+c: no RegExpShapeBenchmark case has groups inside an alternation inside a repeat. The closest are template-replace-g (/(a)(b)?/g) and capture-match-g. A case for it would be worth adding.

The full table, the per-process levels and the re-pair log are kept locally. I'll re-run the gate, with stage 2, on whatever head you want measured next.

…egex-engine-a5ccbe

# Conflicts:
#	Jint.Tests/SpecAnchors.txt
…os/jint-gregsex-regex-engine-a5ccbe

# Conflicts:
#	Jint.Tests/SpecAnchors.txt
…h, boundary plans, a position matcher

Answers the win-x64 gate on #4300. GregsEx gains a FIRST-set start filter under a non-multiline ^, per-alternative
ASCII start masks, a bounded-literal plan and \b checks in the single-set repeat plan, shared ASCII sets and pooled
compile buffers, and a Glushkov position matcher that runs in place of the VM where the next unit decides (or
backtracks over the automaton where moves overlap, counting greedy runs). Jint opts into the matcher through
SearchPrevalidated(hostSized: true), so GregsEx's own frame-capacity contract is unchanged.

A pattern on the position matcher needs no backtracking storage, so the RangeError tests and the AOT probe now use a
lookahead to stay on the VM, and a new test pins long runs that main matched and the VM did not. The sync tool sends a
GitHub token when one is set (the API rate limit emptied a sync), and the workflow passes one.

Measured on the Apple M4 Pro only, against main c553a78: regexp-dna -78%, unpack-code -13%, tagcloud -4%,
validate-input +2%, greedy-dotstar -39%, grouped-alternation-repeat -93%, Dromaeo ObjectRegExp -3.5 to -4.5%, cold
compile +17 to +33%. Windows x64 is not re-measured with the final position matcher.

Also adds RegExpShapeBenchmark's grouped-alternation-repeat row.
sebastienros added a commit to sebastienros/gregsex that referenced this pull request Oct 20, 2026
…a position matcher for host-sized searches (#5)

* ECMAScript: start filters, alternation dispatch, boundary plans and shared ASCII sets

Found by the x64 gate on sebastienros/jint#4300 (SunSpider string-tagcloud, string-unpack-code, cold compile):
- A non-multiline ^ inside an alternation no longer widens the FIRST-character set: it can only match at the
  input's start, which is always tried (FirstCharsTryStart). /(?:^|:|,)(?:\s*\[)+/ over a JSON skeleton started the
  VM at every space.
- An alternative skips a branch whose possible first characters (per-node ASCII masks) exclude the current one,
  without saving a choice for it, and saves none for a second branch that would fail the same way. This also halves
  the frames an alternation inside a repeat needs: /"(?:[^"\\]|\\.)*"/ now reaches 932,061 characters on a 64 MiB
  workspace, up from 466,030.
- The literal plan takes \b on either side of the literal (/\bkeyword\b/), and the set-repeat plan takes \b around an
  unbounded run of exactly the ASCII word class (/\b\w+\b/, the packer's replace), outside u+i.
- A class whose members are all ASCII shares its bitmap and SearchValues across compiles instead of allocating
  8 KiB and a SearchValues per Set node.

MostCorpusPatternsAreLowered leaves out patterns an accelerated direct plan serves, which are declined by design.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* ECMAScript: a position matcher for host-sized searches, and cheaper compiles

Measured under Jint, whose x64 gate on sebastienros/jint#4300 flagged SunSpider string-tagcloud and
string-validate-input and slower cold compiles:
- EcmaPositionMatcher runs a backtracking search over the pattern's position (Glushkov) automaton in place of the VM,
  inside the VM's own candidate loop so the prefilters still apply. Where the next unit decides every choice (the
  parseJSON token alternation) it never backtracks; elsewhere it keeps the VM's choice order, so the match and the
  captures are the VM's. It is built on the first search, so compiling does not pay for it, and hands a candidate
  back to the VM when its choices outgrow the stack it keeps.
- That stack is the matcher's own, outside the caller's partitions, and the matcher needs no VM frames, so under a
  fixed reservation it could succeed where the VM reports WorkspaceCapacityExceeded. It therefore runs only for a
  host-sized search: SearchPrevalidated with hostSized: true, which a host passes when it sizes the workspace itself
  and grows it on that failure (Jint). Search, EcmaScriptMatcher and every other caller behave exactly as before.
- Compiles allocate less: pooled parser buffers, the executor no longer copies the node array, and class bitmaps and
  the case-fold table are shared.
- The clock is read every 16,384 work units instead of every 256 (about 16 us of overshoot at current speeds).

PositionMatcherDifferentialTests searches 15 patterns both ways, from every start, sticky and not, for captures and
for the range, and requires the VM's answer; it also checks the matcher actually runs for most of them.

* ECMAScript position matcher: count greedy runs that overlap what follows

A state whose first item is the move back to itself, while a later item can take
the same units (the . of /a.*a/), was stepped a unit at a time with a choice per
unit; the deterministic self-run lane did not apply. Measure the run with CountRun
and keep one counted choice that gives it back a unit at a time and tries the
state's other items there, as the VM's GreedyRun does. The self-move writes
nothing, so one register snapshot serves every position, and the VM's priority
order is unchanged.

Under Jint on a 64 KiB subject, /a.*a/.test went from 211 us to 2.7 us (the VM
takes 3.7 us), and Dromaeo ObjectRegExp from +245% to within noise of the VM.
PositionMatcherDifferentialTests gains seven overlapping-run patterns and a long
subject compared with the VM.

---------

Co-authored-by: Sébastien Ros <1165805+sebastienros@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
@sebastienros

Copy link
Copy Markdown
Owner Author

Thanks for the gate, Marko. The rows it flagged are addressed in GregsEx sebastienros/gregsex#5, now merged and vendored here at d949514fa. That PR adds:

  • a FIRST-set start filter under a non-multiline ^;
  • per-alternative ASCII start masks for alternations;
  • \b plans for literals and for \b\w+\b;
  • shared ASCII sets and pooled compile buffers;
  • a position (Glushkov) automaton that runs in place of the VM where the next character decides.

The branch also merges main up to 4fd6bb98f; the head after d949514fa, 0c453aac4, only adds these numbers to the design doc.

Re-measured on x64

The setup is different from yours, so this is not a substitute for your gate:

  • devbox1 (AMD EPYC 7763), measure-paired.ps1 with its defaults, 3 alternating rounds;
  • base is main at c553a7870 plus the same local-only scaffolding you used (the two new benchmark files and the ExecutePreparedOnMultipleEngines alias);
  • the candidate is d949514fa;
  • the 47 rows are a superset of your 29.
Row Your gate Now 95% CI
SunSpider string-tagcloud +23.4% +0.8% [−0.5, +2.2], no change
SunSpider string-unpack-code +14.4% −9.4% [−13.3, −7.4]
SunSpider string-validate-input +7.3% −1.5% [−5.8, −1.1]
SunSpider date-format-xparb +1.2% +0.3% [−0.6, +1.2], no change
Dromaeo ObjectRegExp (four rows) +0.7 … +1.5% −1.0 … +1.6% each includes zero
ColdCompile classes-i +12.6% −25.1% [−26.3, −23.4]
ColdCompile uprop-u +30.1% +15.5% [+14.2, +18.5]
ColdCompile email +35.6% +20.9% [+18.0, +21.9]
ColdCompile email-i +37.7% +29.4% [+29.0, +30.2]
Shape literal-test +12.1% +16.5% [+14.0, +21.5]
Shape literal-replace-g +7.2% +14.0% [+13.3, +14.5]
Shape icase-literal-test +7.7% +3.0% [+0.8, +3.4]

Other rows in the same run: regexp-dna −55%, SplitUnicode −93%, MatchGlobal −32%, FreshEngineSource −57%, CaseInsensitiveScan −39%.

Still slower

  • Cold compile of most shapes is +15 to +39%. That includes literal, alternation and tokenizer, which your run didn't cover. On the Mac these compiles still allocate 2 to 2.5× main; I didn't measure allocation on x64. What's left is per-pattern setup in the GregsEx compiler and accelerator. It's listed as a follow-up in docs/design/regexp-gregsex-migration.md §6.
  • The literal scans are +14 to +17%: a 10-character literal that isn't in a 64 KiB subject. GregsEx already uses the fastest of IndexOf and SearchValues<string> for that subject. .NET Regex picks its anchor characters from a frequency table, which is the remaining gap; that's also in §6.
  • greedy-dotstar-test is +7.9% and char-split +4.5% on x64, although the Mac reads both faster than main.
  • The non-regex control. control-indexOf read +42.6% in this run, and +43.5% and −0.1% in two earlier runs of the same harness on this machine. I read that as a bimodal floor, not a regression, but it's one more reason to prefer your quiet-window numbers.

Backtracking ceiling (your earlier point 1)

/"(?:[^"\\]|\\.)*"/ and /(a|b)*c/ now run on the position matcher and need no frames: a 2,000,000-character string literal matches. The RangeError ceiling remains for what the matcher declines (lookarounds, backreferences, u/v, more than 256 positions). The migration guide now says so, and the tests and the AOT probe use a lookahead to keep exercising it.

When the head is stable for you, an x64 gate on it in your quiet window would settle whether the remaining rows block the merge.

This branch has not been deployed

No deployments
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.

2 participants