Repository navigation
Cut the cost of compiling ECMAScript patterns - #4
Merged
Merged
Conversation
Under Jint every unique RegExp compiles on a cache miss, and an i-flag or
\p{...} pattern cost 0.1-1.7 ms to compile:
- ResolvedSet.AddRange placed each range by scanning from the first one, so
loading a property's sorted ranges was quadratic (\p{L}: ~240k compile
work units). Append past the last range directly, otherwise binary search.
- A legacy-i EcmaFastSet built its SearchValues by testing all 65,536 units;
walk the bitmap's set bits instead. Correct the folded bitmap only at the
mappings whose source or target lies in the class (a by-target copy of the
legacy table is cached per EcmaCaseTables), and take the ASCII masks from
the bitmap's first two words.
- Cache the cased-point set WithoutCaselessIgnoreCase rebuilt per compile,
per EcmaCaseTables like InverseFolds.
- Share one EcmaFastSet between Set nodes with the same members and case mode.
- FirstChars gives up on a class with more BMP members than its cap before
hashing them.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…k and registers A choice frame copied the whole task stack (TaskWidth * taskCapacity ints) and every register, so /"(?:[^"\\]|\\.)*"/ needed about 157 workspace ints per character of a string literal. A frame now keeps only what it must undo: - the tasks popped from under its height, copied on first pop (the stack moves one slot at a time, so the segment is contiguous and needs no slot numbers); - the registers first written after it, as (register, value, stamp) entries, with a per-register depth stamp so each is trailed once per frame; - a resumed task (alternative, lazy body, set string) stored in its trail segment. A successful positive assertion drops its choices and compacts the register trail to one oldest entry per register. Each admitted frame adds a fixed allowance to a task trail and a register trail shared by all frames, so the partitions stay affine in the frame capacity and running out of either is WorkspaceCapacityExceeded. Quoted string: 157 -> 27.7 ints per character, 48.9 -> 27.0 ms over 1 MiB. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
sebastienros
added a commit
to sebastienros/jint
that referenced
this pull request
Oct 18, 2026
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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Jint now runs every script RegExp on GregsEx's ECMAScript flavor, so every unique pattern compiles on a cache miss. Reviewing sebastienros/jint#4300 (item 5) found i-flag and
\p{...}patterns compiling 10-16× slower than Jint's previous engine. This PR fixes the causes.Causes and fixes
\p{L}was ~300 µs to compile, and the tables were not the cause.EcmaUnicodeData.GetIntervalsreturns a span over the generated table. The cost wasResolvedSet.AddRange, which found each new range's position by scanning from the first range, so loading a property's ~700 sorted ranges was quadratic: about 240,000 compile-work units for\p{L}. It now appends directly past the last range and otherwise uses a binary search. The same fix takes the fold of a complement underiu([^x]) from 1.7 ms to about 60 µs. With this fixed, a per-property cache wasn't needed.ifast sets (the folded bitmap from ECMAScript: cut per-search and case-insensitive costs found under Jint #3):EcmaCaseTables;WithoutCaselessIgnoreCaserebuilt a set of ~2,500 case-mapped points on every compile. It is now cached perEcmaCaseTables(one slot per mode), the same way asInverseFolds.EcmaFastSet. Set nodes with the same members and case mode now share one instance. Strings and the(?i:)modifier keep sets apart.\p{L}and then gave up.BmpBits is nullas "no exact bitmap", and the zeroed allocation costs about 0.5 µs.The source generator compiles
EcmaAccelerator.csfor netstandard2.0, whereBitOperationsis internal. A stand-in is added toNetstandard2Polyfills.cs.Per-compile timings
The table measures the two stages Jint runs on a cache miss:
EcmaCompiler.TryCompileandEcmaExecutorFactory.TryCreate(which builds theEcmaAccelerator). Unicode 17.0 tables, Release, best of 7 rounds of 2,000 compiles. Host: Apple Silicon (arm64) macOS 15.8.1, .NET 10, run in a quiet session after the test suite had finished.Before (origin/main dc455e9):
[a-z]+@[a-z0-9.]+\.[a-z]{2,}[À-ɏ][a-zͰ-Ͽ]+\w*\p{L}+\p{Nd}*[a-z]+@[a-z0-9.]+\.[a-z]{2,}\w+\s[^x]+[\p{L}--[a-z]]+"(?:[^"\\]|\\.)*"After:
[a-z]+@[a-z0-9.]+\.[a-z]{2,}[À-ɏ][a-zͰ-Ͽ]+\w*\p{L}+\p{Nd}*[a-z]+@[a-z0-9.]+\.[a-z]{2,}\w+\s[^x]+[\p{L}--[a-z]]+"(?:[^"\\]|\\.)*"Tests
New tests in
JintRegressionTests:FoldedClassMembershipFollowsCanonicalizeOnEveryCodeUnit: 14 classes underi, no flags andu, for Unicode 16 and 17. For every code unit it checks that the bitmap, the ASCII masks and the SearchValues agree with Canonicalize.ClassesWithTheSameMembersAndCaseModeShareOneFastSet, including a(?i:)modifier that must stay separate.CasedCodePointsAreBuiltOncePerCaseTables: the cache is per Unicode version (U+A7CE/U+A7CF are new in 17.0).PropertyClassesLoadInLinearWork: the loaded ranges equal the table's, and the compile work stays linear. On main,\p{L}charges 240,451 work units and fails this test.OutOfOrderClassRangesMerge.dotnet test GregsEx.slnx -c Releaseon 185b1f5: 24,543 passed,## Variable-length backtracking frames (d576d26)A choice frame in
EcmaExecutorcopied the whole task stack (TaskWidth * taskCapacityints) and every register, so/"(?:[^"\\]|\\.)*"/needed about 157 workspace ints per character of a string literal. A frame now keeps only what it must undo:(register, value, stamp)entries. A per-register stamp holds the depth it was last trailed at, so each register is trailed once per frame. An undo puts the stamp back.AssertionEnd). The register trail is compacted to one oldest entry per register, because captures set inside the assertion survive it.Sizing stays affine in
frameCapacity, which Jint's growth plan relies on. Per frame: 6 ints of choice, 12 ints of task-trail allowance, and 3 × min(2·groups, 4) ints of register-trail allowance (none without groups). The allowances go into pools that every frame draws from. Running out of choice frames or of either pool is stillWorkspaceCapacityExceeded, never growth.GetRequiredCapacity/ComputeAdmittedFramespublish the new equations, and docs/specs/ecmascript.md states the rule.Longest input on Jint's 64 MiB ceiling
These use
JintRegExpEngine's own sizing (16 Mi ints, frames grown to the most that fit,MaxFrames = 1 << 22). The baseline reproduces the review's figures."(?:[^"\\]|\\.)*","aaa…""(?:[^"\\]|\\.)*", literal with escapes(a|b)*c,abab…c(?:a|b)*c,abab…cA 1 MB plain literal still exceeds 64 MiB: it needs about 28 ints per character, against 157 before.
Workspace and time (Stopwatch harness, best of 5, Apple M-series arm64, .NET 10, minimal admitted frames)
((a)|b)+c, 256a+c((a)|b)+c,bbaaac(a|b|c)*d, 64 Ki(?:(?=(\w))\w)+!, 4 Ki(\w+)\s(\w+)ReadmeBenchmarks(BDN, README recipe:--iterationTime 200 --warmupCount 3 --iterationCount 3), ECMAScript interpreted, 0 B allocated before and after:Capture-heavy short searches cost up to about 5% more: they trail registers that used to be copied in one block. These are noisy ShortRun-style numbers.
Tests
AQuotedStringLiteralReservesItsTrailNotACopyOfTheStackPerFramepins the reservation below 32 ints per character for a 64 Ki literal. It was about 163 before.TrailedCapturesRollBack: 10 capture-rollback witnesses through positive and negative lookaheads, lazy and greedy repeats, and quantified assertions. They run at a 64-frame reservation, and the expected results were checked against Node 24.4 (V8 13.6).dotnet test GregsEx.slnx -c Releaseon d576d26: 24,554 passed, 0 failed, in two consecutive runs. In two other full runs,CompiledContractTests.MatchingAllocatesNothingreported 8,136 bytes. That test drives the RegularCore compiled (FEC) lane, which this change does not touch, and it passes in isolation, so it looks like an allocation flake under parallel load.te change.
🤖 Generated with Claude Code