Repository navigation
Run every script RegExp on GregsEx's ECMAScript engine - #4300
sebastienros wants to merge 25 commits into
Conversation
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
left a comment
There was a problem hiding this comment.
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) andSave(:860-894). - Per frame that is
6 × (2·depth + 4) + 2·groups + 4ints (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).
- Reservation:
- 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
FramePlanis published onto theJintRegExpEngineitself (Volatile.Write(ref _plan, …)).- That instance is cached process-wide (
RegExpParseCache.cs:28) and memoised on AST nodes (JintLiteralExpression.cs:80-86). Searchrequires 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.
- That instance is cached process-wide (
- Never released.
t_scratchonly grows (:355-370), so the memory stays for the life of the thread. - Constraints can't prevent it.
LimitMemorycan only object after the native call has allocated. The regex timeout doesn't prevent the allocation either. - Overflow. Past
taskSlots ≥ 512, orgroups ≥ 256at 4M frames, thecheckedarithmetic inGetRequiredCapacitythrows a rawOverflowException. #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
usedints rather than reservingtaskSlotsper 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
SearchSlowthrowsRegExpResourceException(JintRegExpEngine.cs:318, class at:411).- It's internal and derives from
Exception. - Nothing catches it, and it isn't in
Throw.MustPropagateHostException.
- It's internal and derives from
- The QuickJS port threw
RegexMatchTimeoutException("Regular expression backtracking stack overflow"). V8 throws aRangeError. - So an embedder now receives a type they can't name, with no migration entry.
- Fix: map exhaustion, and the
OverflowExceptionpath from finding 1, to a JSRangeError, or keepRegexMatchTimeoutException. 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
RegExpno longer binds to aRegex-typed method parameter, property or delegate.- Binding goes through
argument.ToObject()(MethodInfoFunction.cs:459), which now yields theJsRegExp. DefaultTypeConverterhas noRegexcase.- So
host.Use(/a/)withstring Use(Regex r)stops resolving.
- Binding goes through
- Engine-bound objects in host graphs.
ToObject()on{ re: /x/ }now puts an engine-boundJsRegExpinto the host'sExpandoObjector dictionary.- It exposes
ObjectInstance.Engineto any serializer that walks public properties. - A host that hands results from one engine to another now moves a
JsValueacross engines, which is unsupported.
- It exposes
- No way to match from C#. The guide says "read
Source/Flags" but doesn't say how a host can still match. Eitherengine.Invokeonexec, or a best-effortnew Regex(source, …), would do. - The public
JsRegExp(Engine)constructor can no longer be made usable, sinceValueandParseResultare gone. The guide could point toIntrinsics.RegExp.Construct.
4. search stopped updating RegExp.$1
- On
main, a non-upattern inRegExp.prototype[@@search]went throughRegExpExec, and so throughUpdateLegacyStaticProperties. - 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.$1gives"a"onmainand"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 thanmain.
5. Cold compile cost and memory per compiled pattern
- Bitmap per node. Every set node gets its own
EcmaFastSetwith an 8 KiBulong[1024]bitmap (EcmaAccelerator.cs:66,:671). That covers.,[…],\d,\wand\s, and it is per node, not per distinct set. - Legacy
iloop. Under legacyi, each set also loops over all 65,536 BMP code units (:158-173). - Case-mapping set. An
ipattern that contains a cased literal builds aHashSetof 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-100uses only 10 distinct sources. A cold-compile row with a few hundred distinct patterns,iclasses among them, would show whether it matters. - Fix:
- Share
EcmaFastSetinstances 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
InverseFoldsis cached.
- Share
6. gregsex-sync.yml runs fetched upstream code with a write token
- The workflow grants
contents: writeandpull-requests: writeworkflow-wide. actions/checkoutpersistsGITHUB_TOKENin.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 --forceongregsex-syncdiscards any commits someone pushed there, which the file's own header invites.- The test filters miss
HostRegexTimeoutTests, the URLPattern hostile-pattern test and test262'sString.prototyperegex tests.
7. Smaller items
- Stale agent docs.
Jint/Constraints/AGENTS.md:67andJint/Runtime/Interpreter/AGENTS.md:22still describeJintLiteralExpression.AdaptedRegExp, the compilation mode and the custom-engine split.RegexConversionOptions.ResolveTimeoutis now dead code, and the comment atRegExpConstructor.cs:476names aGetMatchTimeoutthat doesn't exist. - Compiling a run-time pattern is no longer bounded.
maincompiled for the custom engine underCancellationTokenSource(regexTimeout).JintRegExpEngine.Compileis now called without a token, andCompileLimitssets every budget toMaxValue.- 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'sMatchTimeout. - §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.
- It rejects
- 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_GREATERblocks insideEcmaAccelerator/EcmaExecutor. A Jint-owned wrapper, like the existingStringSearchValues, would keep the patch structural and less fragile on every sync. - The
net8.0asset runs those fallback lanes, andJint.Benchmarkisnet10.0-only, so they have never been measured.
- The patch puts about ten
- Attribution.
CREDITS.txtdoesn't mention GregsEx (MIT) or the UCD 17 data. - AOT.
Jint.AotExamplehas 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,.CharClassScanRegExpShapeBenchmark.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
- the slower rows:
RegExpSplitBenchmark.Split,.SplitUnicode;RegExpMatchBenchmark.MatchGlobal;RegExpExecLoopBenchmark.ExecWhileLoop,.StickyExecRegExpFreshEngineBenchmark.FreshEngineSource,.FreshEnginePreparedSunSpiderBenchmark.Run:regexp-dna,string-tagcloud,string-unpack-code,string-validate-input,date-format-xparbDromaeoBenchmark.ObjectRegExp, all fourModern×Preparedrows- Handlebars (
PreparedScriptBenchmark):ExecuteStringOnMultipleEngines, and this PR'sExecutePreparedOnMultipleEnginesagainstmain'sExecutePreparedWithRegexCompilationOnMultipleEngines, 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>
|
Thanks, Marko, this was a very useful read. Everything that lives in this PR is fixed in 1. Backtracking scratch
2. Exhaustion exception. Exhaustion now throws a JS 3. Interop. §4.161 now covers:
4. 5. Cold compile. You were right, and it's worse than the arithmetic suggested. I added
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.
7. Smaller items
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
|
Thanks, Sebastien. I read
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.
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.
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>
|
Thanks, Marko. Both points are addressed, and the GregsEx sync has landed, so 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:
A literal containing escapes gets further (about 930K characters). 2. The initial plan. Fixed in
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. Also in this sync:
All suites, test262 (102,653 / 0 failed), the AOT example and the docs build pass on |
|
x64 gate on Setup
Slower in all 3 rounds
Faster in all 3 rounds
Reading it
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.
…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>
|
Thanks for the gate, Marko. The rows it flagged are addressed in GregsEx sebastienros/gregsex#5, now merged and vendored here at
The branch also merges Re-measured on x64 The setup is different from yours, so this is not a substitute for your gate:
Other rows in the same run: Still slower
Backtracking ceiling (your earlier point 1)
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. |
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.0only, and its public surface stops above what a JavaScript engine needs (group names before a match, and an executor below thelastIndexsession). The ECMAScript closure is vendored instead:Jint/Runtime/RegExp/GregsEx/. All of it is internal.tools/sync_gregsex.pyregenerates the folder from GregsExmain. It applies mechanical transforms (namespace,internal,#nullable enable), thentools/gregsex-sync/jint.patch.LaneContracts, only the Unicode 17.0 tables, and the .NET 9SearchValues<string>lanes with their downlevel fallbacks. Every behavioural fix went upstream..github/workflows/gregsex-sync.ymlregenerates 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:
int.MaxValue;It also made GregsEx
#nullable enablethroughout.sebastienros/gregsex#3 — the second pass, after the first paired run:
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:JsRegExp.Value,JsRegExp.ParseResult, andCompileRegexon the parsing options. A scriptRegExpnow converts to the CLR as theJsRegExpitself.Regexobjects keep the semantics they had. ARegexthe host hands to script keeps .NET semantics, as before. The new escape hatchOptions.Interop.RegexConversion = RegexConversionMode.EcmaScriptconverts it to an ECMAScript pattern instead.Compliance
main7ab7fafa, all five sub-directories, both modes)Jint.Tests,Jint.Tests.PublicInterface,Jint.Tests.CommonScriptsRegExpEngineRegressionTestsNone 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:
/./uywithlastIndexinside a surrogate pair, which GregsEx's corpus marks as disputed;String(-0)).Performance
Paired BenchmarkDotNet run (
Jint.Benchmark/measure-paired.ps1, 3 rounds in alternating order) of every regex benchmark the suite has, plus SunSpiderregexp-dnaand DromaeoObjectRegExp:mainat82b0efbaaagainst this branch with GregsExdc455e97vendored.control-indexOfnever 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.
maintemplate-replace-g(str.replace(/(a)(b)?/g, '$2$1'))SplitUnicodeuprop-match-g(/\p{L}+/gu)VariableLengthregexp-dnaFreshEngineSourceSplitWithCapturescapture-match-gNamedCaptureclass-match-gGlobalMatchAll/IsMatchOnly/LiteralScanMatchGlobalUnicode/MatchGlobalnullable-split(csv.split(/\s*,\s*/))NoMatchScangreedy-dotstar-exec/-test(/a.*a/over 64 KiB)construct-100/function-replace-g/CaseInsensitiveScanchar-split(str.split(/a/))NamedGroupsAccess,MatchAllIterate,ExecWhileLoop,exec-loop,StickyExec)RegExpSplitBenchmark.SplitObjectRegExp(4 rows)FreshEnginePreparedCharClassScan/DigitScanAnchoredLiteralliteral-test/literal-replace-gicase-literal-testAllocation fell on the capture-heavy rows:
template-replace-gSplitUnicodecapture-match-gMatchGlobalThe 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 forIndexOf(string)). .NET Regex chooses its anchor characters from a frequency table, which is what the remaining 0.5 µs would take.The baseline is
mainfrom before the latest merge. The only regex change onmainsince then (#4267) touched the removed QuickJS interpreter. Its new surrogate-pair tests inRegExpTestsrun against GregsEx here.Follow-ups
These are listed in the design doc's §6:
matchand plain-string replace the way split now is;String.prototype.toUpperCase/toLowerCaseonto the GregsEx Unicode tables;RegExp.$1statics in the fused lanes.🤖 Generated with Claude Code