Skip to content

WIP: DO NOT REVIEW Vectorize eligible leading/fixed-distance set scans in the regex interpreter#130602

Closed
artl93 wants to merge 2 commits into
dotnet:mainfrom
artl93:artl93-regex-interpreter-vectorization
Closed

WIP: DO NOT REVIEW Vectorize eligible leading/fixed-distance set scans in the regex interpreter#130602
artl93 wants to merge 2 commits into
dotnet:mainfrom
artl93:artl93-regex-interpreter-vectorization

Conversation

@artl93

@artl93 artl93 commented Jul 13, 2026

Copy link
Copy Markdown
Member

I am running an experiment - full analysis to follow. Contact @artl93


WIP — experimental. Do not review.

This PR brings the vectorized SearchValues<char> / IndexOfAny-style set scanning already used by the compiled and source-generated regex engines to selected hot paths in the default (interpreted) engine, with a guard that preserves the interpreter's scalar advantage on common-character sets.

What changes

In RegexFindOptimizations, when the interpreter scans for a leading or fixed-distance character set (RegexNodeKind.Set), it previously always walked the input character-by-character calling CharInClass. Compiled/source-gen already lower eligible sets to a vectorized IndexOfAny(SearchValues) scan. This PR lets the interpreter take the same vectorized path — but only for eligible sets:

  • Set has > 5 characters (below that, scalar is already competitive).
  • Set is not high-frequency (!HasHighFrequencyChars(set) — the same pre-existing heuristic). Negated sets and sets dominated by common ASCII letters stay scalar.
  • Not gated behind a BOL anchor; starting position, RTL, captures, timeout cadence, culture/IgnoreCase, ECMAScript, and Unicode categories are all unchanged — the vectorized scan only replaces the search-for-candidate-start loop, then normal matching proceeds.

The guard exists because the interpreter, unlike compiled/source-gen, has a cheap scalar fallback. On dense common-character text (e.g. [a-zA-Z]{3}), the vectorized scan produces tiny per-call advances and SIMD setup overhead loses. The guard makes the interpreter decline vectorization exactly where it would be a net loss.

Scope of impact — broad Regex vs. eligible interpreter patterns (important)

This is an honest characterization, because it matters for reviewing the value:

  • Broad Regex reach: the interpreter is the default engine (RegexOptions.None), so the code path is on the most common Regex usage.
  • Narrow eligible surface (under the guard): in practice only non-ASCII sets and rare (>5 low-frequency ASCII char) sets are vectorized. Common realistic ASCII sets — e.g. [a-zA-Z]{3} (common) and the negated [^A-Za-z0-9_] sanitizer — are guard-excluded → scalar parity. So the dramatic wins apply to a specific slice, not to every interpreted set.

Canonical A/B (drift-corrected, interleaved)

Measurement method (see caveat below): two SHA-bound corerun processes (exact parent 7aa830a0359 scalar vs final 369e6e94e30 guard), BenchmarkDotNet InProcess, 4 interleaved rounds, medians; identical-code control benchmarks averaged 1.016× (ideal 1.0), so ratios below are drift-corrected. Apple M-series, .NET 11 preview.

Scenario Set parent → guard Ratio Note
UnicodeNoMatch (long) Greek (non-ASCII) 50296 → 1104 ns ~40–45× eligible
RareAscii NoMatch Long/Huge [BFGHJKQVWXZ] 1971 → ~138 ns ~14–19× eligible
RareAscii NoMatch Medium [BFGHJKQVWXZ] ~12× eligible
RareAscii Short (16) [BFGHJKQVWXZ] ~2.1× eligible
Letters3 ([a-zA-Z]{3}) common ASCII 5312 → 5025 ns ~1.05× guard-excluded (parity)
IdSanitize ([^A-Za-z0-9_]) negated 1982 → 2025 ns ~1.06× guard-excluded (parity)
Controls (≤5-char, categories, ranges, RTL, NonBacktracking, Compiled, source-gen) ~1.0× unchanged

Steady-state allocation on the scan path is 0 B; an eligible set pays a one-time SearchValues construction cost only.

3-way regression justification (why the guard is there)

Rebuilt a third pre-guard binary (3491e696608, vectorize-all, md5 e2e80327…) and ran parent (scalar) / pre-guard (vectorize-all) / guard side-by-side (ns, medians):

Scenario parent (scalar) pre-guard (vec-all) guard Reading
[a-zA-Z]{3} (common) 5312 13907 (2.6× SLOWER) 5025 guard prevents a real regression ✅
[BFGHJKQVWXZ] (rare) 1971 143 138 eligible win kept ✅
Greek (non-ASCII) 50296 1141 1104 eligible win kept ✅
[^A-Za-z0-9_] (negated) 1982 160 (~12× faster) 2025 guard is conservative — see below ⚠️

Honest trade-off: the guard's blanket "negated → scalar" rule cleanly kills the [a-zA-Z]{3} regression, but on this input it also forgoes a measured ~12× win on the negated identifier-sanitizer [^A-Za-z0-9_] (input is all word-chars, so IndexOfAnyExcept skips the whole span in one call). Negated-set behavior is input-dependent (dense-match inputs would not win), so the current conservative exclusion is safe but leaves value on the table. Refining the guard to admit sparse negated sets — without reintroducing the common-set regression — is a reviewable follow-up, deliberately not done in this WIP to keep the change safe.

Tests (this build, guard DLL md5 92014a34…)

  • Unit (System.Text.RegularExpressions.Unit.Tests, incl. find-mode guard assertions): 1071 passed / 0 failed.
  • Functional (System.Text.RegularExpressions.Tests, all engines/cultures/ECMAScript/RTL/theories): 32,151 passed / 0 failed.
  • Timeout stress (100 MB input, 50 ms / 1 ms timeouts): vectorized skip does not delay timeout observation (validated locally).

Semantics preserved exactly

Culture and IgnoreCase (set contents already reflect case folding at build time), ECMAScript, RTL, capture semantics, Unicode category membership, negation, starting position, and timeout-check cadence. The vectorized scan only chooses where matching starts; subsequent match logic is untouched.

Provenance & measurement caveats

  • SHA→binary is byte-reproducible: rebuilding the two changed files from each pinned SHA reproduces exact md5s — scalar 7aa830a03595bfe8d53…, guard 369e6e94e3092014a34…, pre-guard 3491e696608e2e80327….
  • BDN net11 out-of-process blocker: BenchmarkDotNet's out-of-process/CoreRun toolchain throws NotImplementedException: GetRuntimeVersion not implemented for NotRecognized on the net11 moniker, so a standard out-of-process job is currently impossible here. The A/B therefore uses two independent SHA-bound corerun processes each running BDN InProcess (OS-level isolation), with ratios computed from the CSVs and thermal drift cancelled by interleaving. The initial PR numbers (5.2×–46.3×) came from a pre-guard/rare-set matrix; the drift-corrected eligible range above (~2.1× short → ~40× long) supersedes them.
  • Raw Markdown/CSV/JSON, harness, runners, and SHA-bound DLLs are preserved as local session evidence (the artifacts/ harness is gitignored; it rebuilds deterministically from the pinned SHAs).
  • Durable benchmarks belong in dotnet/performance; a self-contained proposal (Perf.Regex.SearchValues.cs, guarded/eligible + control scenarios) is prepared for a companion PR at src/benchmarks/micro/libraries/System.Text.RegularExpressions/.

Full analysis to follow. This remains work in progress — please do not review.

Note

This PR description was drafted with the assistance of GitHub Copilot (AI-generated). Contact @artl93.

Art Leonard and others added 2 commits July 12, 2026 05:29
Use lazily initialized SearchValues for enumerable leading and fixed-distance sets in the default interpreter, while retaining existing fallbacks and engine semantics.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>

Copilot-Session: 36566486-d9fc-49ff-98ad-d415c1d60183
The prior commit vectorized leading/fixed-distance set scans in the regex
interpreter using IndexOfAny(SearchValues). Isolated-process A/B benchmarking
revealed that on inputs where the leading set is composed of common characters
(e.g. [a-zA-Z] against ordinary text), the vectorized scan advances only a
couple of characters per call, so its fixed per-call overhead loses to the
scalar CharInClass loop -- a ~2.7x regression on dense [a-zA-Z]{3} scans.

Calibration against the compiled engine (which always vectorizes and has no
scalar fallback) showed the interpreter's vectorized cost is identical to
compiled and inherent to vectorization, not an implementation defect. Unlike
the compiled/source-generated engines, the interpreter retains a cheap scalar
fallback, so it can decline to vectorize when doing so would be a net loss.

Gate the interpreter's useSearchValues selection on the same
HasHighFrequencyChars heuristic the compiled engine already uses to judge
IndexOfAny a poor filter. The interpreter now vectorizes a >5-char leading set
only when its characters are rare enough in typical text for the scan to
reliably skip large regions. This keeps the large sparse/no-match wins on rare
and non-ASCII sets (e.g. ~42x on a non-ASCII no-match scan) while guaranteeing
the default engine never regresses relative to its existing scalar path on
common-character sets.

Tests updated to reflect the gating: rare ([BFGHJK]) and non-ASCII sets assert
the SearchValues find mode; common ([acegik]) and negated sets assert the
scalar find mode; added a fixed-distance high-frequency-stays-scalar case and
[BFGHJK] match-correctness cases across all engines.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 36566486-d9fc-49ff-98ad-d415c1d60183
@dotnet-policy-service

Copy link
Copy Markdown
Contributor

Tagging subscribers to this area: @dotnet/area-system-text-regularexpressions
See info in area-owners.md if you want to be subscribed.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Pull request overview

This PR updates the interpreted System.Text.RegularExpressions engine’s “find next starting position” logic to optionally use a cached SearchValues<char>-based IndexOfAny/IndexOfAnyExcept scan for certain eligible leading/fixed-distance character sets, and adds tests that validate mode selection and behavioral parity.

Changes:

  • Add new interpreter-only find modes that use SearchValues<char> for large, non–high-frequency sets and cache the constructed SearchValues<char> on RegexFindOptimizations.
  • Route the interpreter’s left-to-right scan loop through a new interpreter-specific entrypoint that handles the SearchValues-based modes.
  • Add unit/functional tests covering mode selection and interpreter vs compiled behavioral parity for representative set patterns.
Show a summary per file
File Description
src/libraries/System.Text.RegularExpressions/tests/UnitTests/RegexFindOptimizationsTests.cs Adds assertions for the new SearchValues-based find modes (leading set + fixed-distance set) and verifies the high-frequency heuristic keeps scalar mode.
src/libraries/System.Text.RegularExpressions/tests/FunctionalTests/Regex.Match.Tests.cs Adds functional match cases targeting leading/fixed-distance set scenarios and a randomized interpreter-vs-compiled equivalence check.
src/libraries/System.Text.RegularExpressions/src/System/Text/RegularExpressions/RegexInterpreter.cs Uses the new interpreter-specific LTR “find next start” entrypoint during scanning.
src/libraries/System.Text.RegularExpressions/src/System/Text/RegularExpressions/RegexFindOptimizations.cs Introduces SearchValues<char> caching and IndexOfAny(SearchValues)-based scans for eligible interpreter set searches plus new FindNextStartingPositionMode values.

Copilot's findings

  • Files reviewed: 4/4 changed files
  • Comments generated: 0
@artl93

artl93 commented Jul 13, 2026

Copy link
Copy Markdown
Member Author

Status: work in progress — please do not review yet.

Update posted to the description: canonical drift-corrected A/B (interleaved, SHA-bound corerun isolation), a 3-way parent/pre-guard/guard regression table justifying the guard, and full test results (unit 1071/0; functional 32,151/0 across all engines). The eligible-set wins (~2.1× short → ~40× long, 0 B steady-state) are confirmed, and the guard is shown to eliminate the [a-zA-Z]{3} pre-guard regression (2.6× slower → parity).

Two honest caveats are called out in the body and are the reason this stays WIP:

  1. Narrow eligible surface. Under the guard only non-ASCII and rare (>5 low-frequency ASCII char) sets vectorize; common ASCII sets like [a-zA-Z]{3} and the negated [^A-Za-z0-9_] sanitizer are guard-excluded → scalar parity. Broad Regex reach ≠ broad interpreter-pattern reach.
  2. Conservative negated handling. The 3-way shows the guard forgoes a measured ~12× win on [^A-Za-z0-9_] (input-dependent). Admitting sparse negated sets without reintroducing the common-set regression is a possible follow-up, intentionally not attempted here.

Still outstanding before this could be considered for review: a standard out-of-process BDN job (currently blocked by the net11 GetRuntimeVersion moniker bug) and landing the durable benchmark in dotnet/performance.

Note

This comment was drafted with the assistance of GitHub Copilot (AI-generated). Contact @artl93.

@stephentoub

Copy link
Copy Markdown
Member

We've also been careful to avoid adding startup costs to the interpreter. SearchValues has similar tradeoffs to RegexOptions.Compiled: they both spend more time preparing state so that later executions become faster. The interpreter is the "let's get going as quickly as possible" option that's usable for throwaway one-off regexes... adding more first-use cost in exchange for faster throughput after that isn't necessarily the right tradeoff.

@artl93

artl93 commented Jul 13, 2026

Copy link
Copy Markdown
Member Author

@stephentoub - what does the tipping point look like? Is the expectation that the interpreter is not used for high-throuput scenarios and that folks are expected to use compiled regex for those scenarios?

@stephentoub

Copy link
Copy Markdown
Member

Is the expectation that the interpreter is not used for high-throuput scenarios and that folks are expected to use compiled regex for those scenarios?

compiled or source generated, yes

@stephentoub

Copy link
Copy Markdown
Member

what does the tipping point look like?

Subjective.

@MichalPetryka

Copy link
Copy Markdown
Contributor

We've also been careful to avoid adding startup costs to the interpreter. SearchValues has similar tradeoffs to RegexOptions.Compiled: they both spend more time preparing state so that later executions become faster. The interpreter is the "let's get going as quickly as possible" option that's usable for throwaway one-off regexes... adding more first-use cost in exchange for faster throughput after that isn't necessarily the right tradeoff.

Another usecase for interpreter is AOT compilation where the pattern isn't a const so the sourcegen can't be used.

I very often have such variable regexes in my AOT projects so interpreter perf matters too here. However such "more expensive creation" logic for interpreter could be guarded under Compiled && AOT and only enabled there.

@artl93

artl93 commented Jul 14, 2026

Copy link
Copy Markdown
Member Author

While the gains are real, on the basis of complexity + the applicability of the fix, it strikes me that the risk for regression would be too high for .NET 11. We could revisit for 12 - but we'd want to start over.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment