WIP: DO NOT REVIEW Vectorize eligible leading/fixed-distance set scans in the regex interpreter#130602
WIP: DO NOT REVIEW Vectorize eligible leading/fixed-distance set scans in the regex interpreter#130602artl93 wants to merge 2 commits into
Conversation
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
|
Tagging subscribers to this area: @dotnet/area-system-text-regularexpressions |
There was a problem hiding this comment.
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 constructedSearchValues<char>onRegexFindOptimizations. - 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
|
Status: work in progress — please do not review yet. Update posted to the description: canonical drift-corrected A/B (interleaved, SHA-bound Two honest caveats are called out in the body and are the reason this stays WIP:
Still outstanding before this could be considered for review: a standard out-of-process BDN job (currently blocked by the Note This comment was drafted with the assistance of GitHub Copilot (AI-generated). Contact @artl93. |
|
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. |
|
@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? |
compiled or source generated, yes |
Subjective. |
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 |
|
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. |
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 callingCharInClass. Compiled/source-gen already lower eligible sets to a vectorizedIndexOfAny(SearchValues)scan. This PR lets the interpreter take the same vectorized path — but only for eligible sets:!HasHighFrequencyChars(set)— the same pre-existing heuristic). Negated sets and sets dominated by common ASCII letters stay scalar.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:
RegexOptions.None), so the code path is on the most common Regex usage.[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
corerunprocesses (exact parent7aa830a0359scalar vs final369e6e94e30guard), 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.[BFGHJKQVWXZ][BFGHJKQVWXZ][BFGHJKQVWXZ][a-zA-Z]{3})[^A-Za-z0-9_])Steady-state allocation on the scan path is 0 B; an eligible set pays a one-time
SearchValuesconstruction cost only.3-way regression justification (why the guard is there)
Rebuilt a third pre-guard binary (
3491e696608, vectorize-all, md5e2e80327…) and ran parent (scalar) / pre-guard (vectorize-all) / guard side-by-side (ns, medians):[a-zA-Z]{3}(common)[BFGHJKQVWXZ](rare)[^A-Za-z0-9_](negated)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, soIndexOfAnyExceptskips 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…)System.Text.RegularExpressions.Unit.Tests, incl. find-mode guard assertions): 1071 passed / 0 failed.System.Text.RegularExpressions.Tests, all engines/cultures/ECMAScript/RTL/theories): 32,151 passed / 0 failed.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
7aa830a0359→5bfe8d53…, guard369e6e94e30→92014a34…, pre-guard3491e696608→e2e80327….NotImplementedException: GetRuntimeVersion not implemented for NotRecognizedon thenet11moniker, so a standard out-of-process job is currently impossible here. The A/B therefore uses two independent SHA-boundcorerunprocesses 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.artifacts/harness is gitignored; it rebuilds deterministically from the pinned SHAs).dotnet/performance; a self-contained proposal (Perf.Regex.SearchValues.cs, guarded/eligible + control scenarios) is prepared for a companion PR atsrc/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.