Skip to content

Cut the cost of compiling ECMAScript patterns - #4

Merged
sebastienros merged 2 commits into
mainfrom
ecma-compile-cost
Oct 18, 2026
Merged

sebastienros merged 2 commits into
mainfrom
ecma-compile-cost

Conversation

@sebastienros

@sebastienros sebastienros commented Oct 18, 2026 •

Copy link
Copy Markdown
Owner

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.GetIntervals returns a span over the generated table. The cost was ResolvedSet.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 under iu ([^x]) from 1.7 ms to about 60 µs. With this fixed, a per-property cache wasn't needed.
  • Legacy i fast sets (the folded bitmap from ECMAScript: cut per-search and case-insensitive costs found under Jint #3):
    • the SearchValues are built by walking the bitmap's set bits instead of testing all 65,536 code units;
    • the folded bitmap is corrected only at mappings whose source or target is inside the class. Before, it binary-searched the class for each of the ~1,400 legacy mappings. A by-target copy of the legacy table is cached per EcmaCaseTables;
    • the ASCII masks are read from the bitmap's first two words instead of 128 table lookups.
  • WithoutCaselessIgnoreCase rebuilt a set of ~2,500 case-mapped points on every compile. It is now cached per EcmaCaseTables (one slot per mode), the same way as InverseFolds.
  • Identical classes share one EcmaFastSet. Set nodes with the same members and case mode now share one instance. Strings and the (?i:) modifier keep sets apart.
  • FirstChars stops early on a class with more BMP members than its 2,048 cap. Before, it hashed 2,049 characters of \p{L} and then gave up.
  • I did not drop the 8 KiB bitmap for ASCII-only sets. About ten plan and executor call sites read BmpBits is null as "no exact bitmap", and the zeroed allocation costs about 0.5 µs.

The source generator compiles EcmaAccelerator.cs for netstandard2.0, where BitOperations is internal. A stand-in is added to Netstandard2Polyfills.cs.

Per-compile timings

The table measures the two stages Jint runs on a cache miss: EcmaCompiler.TryCompile and EcmaExecutorFactory.TryCreate (which builds the EcmaAccelerator). 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):

Pattern Flags Compiler µs Executor factory µs Total µs
[a-z]+@[a-z0-9.]+\.[a-z]{2,} IgnoreCase 2.0 98.3 100.3
[À-ɏ][a-zͰ-Ͽ]+\w* IgnoreCase 33.4 92.8 126.2
\p{L}+\p{Nd}* Unicode 281.2 25.3 306.5
[a-z]+@[a-z0-9.]+\.[a-z]{2,} None 1.0 2.6 3.6
\w+\s[^x]+ IgnoreCase, Unicode 1663.2 9.1 1672.3
[\p{L}--[a-z]]+ UnicodeSets 532.1 4.1 536.2
"(?:[^"\\]|\\.)*" None 1.2 2.5 3.6

After:

Pattern Flags Compiler µs Executor factory µs Total µs
[a-z]+@[a-z0-9.]+\.[a-z]{2,} IgnoreCase 2.9 11.4 14.3
[À-ɏ][a-zͰ-Ͽ]+\w* IgnoreCase 8.7 16.3 25.0
\p{L}+\p{Nd}* Unicode 12.8 3.4 16.2
[a-z]+@[a-z0-9.]+\.[a-z]{2,} None 1.1 1.8 2.9
\w+\s[^x]+ IgnoreCase, Unicode 61.1 6.1 67.2
[\p{L}--[a-z]]+ UnicodeSets 18.7 2.6 21.4
"(?:[^"\\]|\\.)*" None 1.2 2.3 3.5

Tests

New tests in JintRegressionTests:

  • FoldedClassMembershipFollowsCanonicalizeOnEveryCodeUnit: 14 classes under i, no flags and u, 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 Release on 185b1f5: 24,543 passed,## Variable-length backtracking frames (d576d26)

A choice frame in EcmaExecutor 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:

  • Tasks: copy-on-pop. The first time a slot under the top choice's height is popped, its value goes to a task trail. The stack moves one slot at a time, so a frame's segment is contiguous and needs no slot numbers. The task an alternative, lazy body or set string resumes with is stored in the same segment.
  • Registers: (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.
  • Positive lookahead success: the dropped choices' task trail is truncated (the body cannot pop below its 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 still WorkspaceCapacityExceeded, never growth. GetRequiredCapacity/ComputeAdmittedFrames publish 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.

Pattern, input ints/frame before → after Longest match before Longest match after
"(?:[^"\\]|\\.)*", "aaa…" 102 → 18 82,239 466,030 (5.7×)
"(?:[^"\\]|\\.)*", literal with escapes 102 → 18 106,912 605,840
(a|b)*c, abab…c 92 → 30 121,572 372,823 (3.1×)
(?:a|b)*c, abab…c 78 → 18 143,392 621,374 (4.3×)

A 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)

Case ints/char before → after time before → after
quoted string, 1 MiB 156.9 → 27.7 48.9 → 27.0 ms
quoted string, 64 Ki 156.9 → 27.7 1.92 → 1.69 ms
((a)|b)+c, 256 a + c 212 → 60 13.4 → 15.0 µs
((a)|b)+c, bbaaac 176 → 63 388 → 413 ns
(a|b|c)*d, 64 Ki 208 → 60 5.61 → 4.34 ms
(?:(?=(\w))\w)+!, 4 Ki 104 → 30 244 → 227 µs
(\w+)\s(\w+) 25 → 14 237 → 147 ns

ReadmeBenchmarks (BDN, README recipe: --iterationTime 200 --warmupCount 3 --iterationCount 3), ECMAScript interpreted, 0 B allocated before and after:

Method before after
ShortIsMatch 276.5 ns 279.8 ns
LongIsMatch 6,382 ns 6,495 ns
ShortCaptures 399.2 ns 418.9 ns

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

  • AQuotedStringLiteralReservesItsTrailNotACopyOfTheStackPerFrame pins 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 Release on d576d26: 24,554 passed, 0 failed, in two consecutive runs. In two other full runs, CompiledContractTests.MatchingAllocatesNothing reported 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

sebastienros and others added 2 commits October 17, 2026 23:22
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
sebastienros merged commit c48524b into main Oct 18, 2026
6 checks passed
@sebastienros
sebastienros deleted the ecma-compile-cost branch October 18, 2026 17:50
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>
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.

1 participant