Skip to content

Prefer .NET Regex for quantified groups without capture or lookaround hazards#2682

Merged
lahma merged 1 commit into
sebastienros:mainfrom
lahma:perf-regexp-routing
Jul 14, 2026
Merged

Prefer .NET Regex for quantified groups without capture or lookaround hazards#2682
lahma merged 1 commit into
sebastienros:mainfrom
lahma:perf-regexp-routing

Conversation

@lahma

@lahma lahma commented Jul 14, 2026

Copy link
Copy Markdown
Collaborator

Summary

ETW profiling showed Jint's custom backtracking RegExp interpreter (RegExpInterpreter.ExecBacktrack) at 9.1% self time on string-tagcloud — in a script whose patterns are all .NET-translatable. Root cause: the quantified-group hazard scan in NeedCustomEngine treated the ? of every (?: group opener as a quantifier inside the group body, so every quantified non-capturing group ((?:x)+, (?:\s*\[)+, …) — one of the most common pattern shapes — routed to the interpreter. Meanwhile the check missed the hazard it was written for: ((a)|b)+ routed to .NET Regex and returned non-spec capture results on main.

The scan is rewritten with per-group state (capture/lookaround body flags, per-alternation-branch nullability): a quantified group now bails to the interpreter only when its body contains a capture or lookaround at any depth, or the group itself captures and can match empty (.NET keeps prior-iteration/empty-iteration captures where ECMAScript clears/rejects them — divergences verified against raw .NET behavior). Escape atoms (\uXXXX, \xHH, \cX, octal) are consumed whole so quantifiers attach to the right atom, and m/s inline modifier groups no longer bail (Acornima's adaptation verified compliant including \r LineTerminator handling).

Correctness fixes riding along (all were wrong on main via .NET routing, now spec-correct and tested): ((a)|b)+, (a|)*, (\b)*.

Verified with a 49-case routing matrix, a dual-engine parity script (identical match arrays, indices and lastIndex behavior including sticky/global), and 10 new tests.

Benchmarks (default jobs, adjacent A/B on latest main)

Benchmark main PR Δ
SunSpider string-tagcloud 45.39 ms / 18.58 MB 40.36 ms / 15.60 MB −11.1% time, −16.0% alloc
SunSpider string-unpack-code 48.36 ms 43.94 ms −9.1%
SunSpider regexp-dna 97.10 ms 96.81 ms neutral
SunSpider crypto-aes (guard) 55.62 ms 53.52 ms neutral/better

Broad effect: any user pattern with a quantified non-capturing group that previously fell to the interpreter via the (?: mis-scan now runs on .NET Regex when otherwise safe.

Gates

  • Jint.Tests: 3,596 (net10.0) + 3,533 (net472) passed, 0 failed (10 new RegExp routing/semantics tests)
  • Jint.Tests.PublicInterface / CommonScripts: green both TFMs (tagcloud/unpack-code output-asserted)
  • Test262: 99,429 passed, 0 failed

🤖 Generated with Claude Code

@lahma
lahma enabled auto-merge (squash) July 14, 2026 07:08
… hazards

RegExpConstructor.NeedCustomEngine routed every quantified group whose body
contained any quantifier to the custom backtracking interpreter, and the '?'
of a '(?:' group opener was itself scanned as a quantifier, so every
quantified non-capturing group fell back too. ETW profiling showed the custom
interpreter at 9.1% self time on string-tagcloud, whose two hot json2.js
patterns (the token alternation with '(?:\.\d*)?' and '(?:\s*\[)+') are both
.NET-translatable with identical semantics.

Replace the quantifier-poisoning heuristic with an analysis of the actual
.NET vs ECMAScript divergences (RepeatMatcher semantics):

- a quantified group needs the custom engine only when its body contains a
  capturing group or lookaround assertion (at any depth) - .NET retains
  captures from earlier iterations while ECMAScript clears them per
  iteration; this also closes a hole where '((a)|b)+'-style patterns
  routed to .NET and returned non-spec captures,
- a quantified capturing group additionally needs the custom engine when its
  body can match the empty string - .NET records an empty capture for an
  empty iteration while ECMAScript rejects empty iterations; nullability is
  tracked per alternation branch, which also fixes '(a|)*' and '(\b)*'
  previously routing to .NET with non-spec group results,
- quantified lookarounds keep falling back, backreference, named-group,
  unicode-mode and IgnoreCase rules are unchanged, inline 'i' modifiers now
  fall back even when preceded by other modifiers, and 'm'/'s' modifier
  groups no longer fall back since Acornima adapts them compliantly.

string-tagcloud's hot patterns now route to .NET Regex with verified
identical match results and lastIndex behavior on both engines;
string-unpack-code already routed all of its patterns to .NET.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013cnj7xfz8iZ5uaeU82keKa
@lahma
lahma force-pushed the perf-regexp-routing branch from 4b78139 to 81a8605 Compare July 14, 2026 07:19
@lahma
lahma merged commit 239edf8 into sebastienros:main Jul 14, 2026
4 checks passed
@lahma
lahma deleted the perf-regexp-routing branch July 14, 2026 07:33
lahma added a commit that referenced this pull request Jul 15, 2026
… Regex (#2694)

RegExpConstructor.NeedCustomEngine (#2682) diverted a quantified group to the
custom engine only when its body captured/looked-around or was a nullable
*capturing* group. A non-capturing quantified group whose body can reach the
empty string before a consuming alternative was sent to .NET Regex, whose
empty-subexpression loop protection stops the repetition at the first empty or
zero-width alternative. ECMAScript's RepeatMatcher instead prunes the empty
iteration and backtracks into a later consuming alternative, so the match
itself diverged (silently truncated):

  /(?:a*|b)*/.exec("aaabbb")      spec "aaabbb", was "aaa"
  /(?:|a)*/.exec("aaa")           spec "aaa",    was ""
  "aaa".replace(/(?:|a)*/g, "X")  spec "XX",     was "XaXaXaX"

This regressed v4.12.0: its (?:-header-skip bug happened to route every
(?:...)quantifier group to the custom engine, and #2682's correct header skip
exposed the gap. Fix: route any nullable-bodied quantified group (capturing or
not) to the custom engine. Conservative - some single greedy-nullable bodies
like (?:a*)+ would in fact agree with .NET - but correctness first; the hot
benchmark patterns (non-nullable bodies) are unaffected.


Claude-Session: https://claude.ai/code/session_01XM8rSnn8j66kDCTaP2exNK

Co-authored-by: Claude Opus 4.8 (1M context) <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