Add LeadingStrings benchmarks for binary and non-ASCII regex patterns - #5126

Merged
danmoseley merged 3 commits into
dotnet:mainfrom
danmoseley:regex-redux/benchmarks
Feb 24, 2026
Merged

Add LeadingStrings benchmarks for binary and non-ASCII regex patterns#5126
danmoseley merged 3 commits into
dotnet:mainfrom
danmoseley:regex-redux/benchmarks

Conversation

@danmoseley

@danmoseleydanmoseley commented Feb 23, 2026

Copy link
Copy Markdown
Contributor

Adds two new benchmark classes to exercise the LeadingStrings vs FixedDistanceSets heuristic in the regex engine:

  • Perf_Regex_LeadingStrings_BinaryData: 1MB binary corpus (PE-header-like seed duplicated), alternation of binary patterns. Validates no regression on non-text input. (Lots of ASCII here, but obviously not English frequencies.)
  • Perf_Regex_LeadingStrings_NonAscii: ~100KB Russian text (Anna Karenina opening), alternation of Russian words. Validates no regression on non-ASCII text where the frequency heuristic bails out.

Companion to dotnet/runtime change: dotnet/runtime#124736

CopilotAI 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 adds two new benchmark classes to test regex alternation pattern performance with different input types. The benchmarks are designed to validate that the LeadingStrings vs FixedDistanceSets heuristic in the regex engine doesn't regress on binary and non-ASCII data.

Changes:

  • Added Perf_Regex_LeadingStrings_BinaryData class with benchmarks for binary data patterns (1MB PE-header-like corpus)
  • Added Perf_Regex_LeadingStrings_NonAscii class with benchmarks for Russian text patterns (~100KB from Anna Karenina)

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Dan Moseleyand others added 3 commits February 23, 2026 20:26
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Remove Perf_Regex_LeadingStrings_BinaryData, keeping only the non-ASCII benchmark.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Port curated/01-literal and curated/02-literal-alternate benchmarks from
https://github.com/BurntSushi/rebar for Russian and Chinese text.
Haystacks are OpenSubtitles data (https://opus.nlpl.eu/OpenSubtitles-v2018.php).
This replaces the ad-hoc NonAscii benchmark with well-established cross-engine
regex benchmarks that test literal and alternation search on non-ASCII text.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
@danmoseley
danmoseleyforce-pushed the regex-redux/benchmarks branch from 3a03edd to a3f6116CompareFebruary 24, 2026 05:06
@danmoseley

Copy link
Copy Markdown
ContributorAuthor

Benchmark results comparing main (base) vs 21a68d75 (test: "Skip frequency heuristic for single-char FixedDistanceSets")

Built from dotnet/runtime repo. Mann-Whitney U test, two-sided.

LangPatternOptionsBase(us)Test(us)Ratiop-valueSig
ChineseliteralCompiled25.4525.801.010.490
ChineseliteralNonBacktracking24.2714.940.62<0.001***
ChineseliteralNone21.9223.071.050.001**
ChinesealternationCompiled25.1327.311.090.044*
ChinesealternationNonBacktracking31.0328.870.930.378
ChinesealternationNone45.9344.640.970.023*
Russian(?i) literalCompiled110.39110.951.000.322
Russian(?i) literalNonBacktracking161.59160.010.990.106
Russian(?i) literalNone190.67191.381.000.120
Russian(?i) alternationCompiled1,126.601,152.901.020.044*
Russian(?i) alternationNonBacktracking7,629.457,767.191.02<0.001***
Russian(?i) alternationNone11,849.6411,796.661.000.071
RussianliteralCompiled48.7949.881.020.543
RussianliteralNonBacktracking81.0088.571.090.102
RussianliteralNone58.2757.690.990.867
RussianalternationCompiled1,214.601,228.941.010.086
RussianalternationNonBacktracking2,331.083,208.261.38<0.001***
RussianalternationNone3,074.013,164.351.030.017*

* p<0.05, ** p<0.01, *** p<0.001

@danmoseley

Copy link
Copy Markdown
ContributorAuthor

Hmm, speedups are nice except I wouldn't expect any as this is non ASCII

@danmoseley

Copy link
Copy Markdown
ContributorAuthor

Reran with more iterations and the tests are stable and at parity. The issue is some of them are super short (a few MB all in CPU cache doing IndexOf with SIMD) so I needed to do more iterations, which I guess the perf lab would. Merging.

@danmoseley
danmoseley merged commit 24575dc into dotnet:mainFeb 24, 2026
74 checks passed
@danmoseley
danmoseley deleted the regex-redux/benchmarks branch February 24, 2026 05:58
danmoseley pushed a commit to dotnet/runtime that referenced this pull request Feb 25, 2026
…124736)
I thought it would be interesting to see whether AI could take another
look at the [commented out search
strategy](https://github.com/dotnet/runtime/blob/99b76018b6e4/src/libraries/System.Text.RegularExpressions/src/System/Text/RegularExpressions/RegexFindOptimizations.cs#L152-L161)
originally introduced by @stephentoub in #98791 to see whether we can
enable it and keep the wins without the regressions that caused it to be
commented out.
AI tried various experiments, and got to a dead end. I recalled the
frequency table approach that Ripgrep uses (credit to @BurntSushi).
Turns out that fixes the regressions entirely. This means our engine now
has assumptions built in about char frequencies in ASCII (only) text.
That's an approach that's been proven in ripgrep, one of the fastest
engines, for 10 years, and it turns out to work OK for regex-redux as
well because a, c, g, t are relatively high frequency in English anyway.
Code unchanged if pattern has anything other than ASCII (see benchmark
results below).
This gives us a nice win on regex-redux, a few other wins in existing
tests, and no regressions.
Note: a char frequency table already existed in `RegexPrefixAnalyzer.cs`
for ranking which fixed-distance character sets are most selective. Our
new table serves a different purpose: deciding whether to use
`LeadingStrings` vs `FixedDistanceSets` at all. The two are
complementary.
====
When a regex has multiple alternation prefixes (e.g. `a|b|c|...`), this
change decides whether to use `SearchValues<string>`
(Teddy/Aho-Corasick) or fall through to `FixedDistanceSets`
(`IndexOfAny`) based on the frequency of the starting characters.
High-frequency starters (common letters like lowercase vowels) benefit
from multi-string search; low-frequency starters (uppercase, digits,
rare consonants) are already excellent `IndexOfAny` filters. Non-ASCII
starters bail out (no frequency data), preserving baseline behavior.
## Benchmark results (444 benchmarks, BDN A/B with --statisticalTest
3ms)
| Benchmark | Baseline | PR | Ratio | Verdict |
|-----------|----------|-----|-------|---------|
| RegexRedux_1 (Compiled) | 25.77ms | 14.27ms | **1.81x faster** |
Faster |
| Leipzig Tom.*river (Compiled) | 6.13ms | 1.87ms | **3.28x faster** |
Faster |
| RegexRedux_5 (Compiled) | 2.83ms | 2.35ms | **1.20x faster** | Faster
|
| Sherlock, BinaryData, BoostDocs, Mariomkas, SliceSlice | -- | -- | --
| Same |
| LeadingStrings_NonAscii (all variants) | -- | -- | -- | Same |
| LeadingStrings_BinaryData (all variants) | -- | -- | -- | Same |
Leipzig win is because the pattern is
`Tom.{10,25}river|river.{10,25}Tom` so there is a short prefix that is
common in the text; with this change it notices `r` is common and `T`
fairly common in English text, so it switches to `SearchValues` which
looks for `Tom` and `river` simultaneously, causing far fewer false
starts.
regex-redux win is because it was previously looking for short, very
common prefixes naively, and now (specifically because the pattern chars
are common) it changed to use `SearchValues` (ie Aho-Corasick/Teddy) to
search for the longer strings simultaneously.
No regressions detected. All MannWhitney tests report Same for
non-improved benchmarks.
## Key design decisions
- **Frequency table**: First 128 entries of Rust's `BYTE_FREQUENCIES`
from @BurntSushi's aho-corasick crate
- **Threshold**: Average rank >= 200 triggers `LeadingStrings`; below
200 falls through to `FixedDistanceSets`
- **Non-ASCII**: Returns false (no frequency data), so the heuristic
does not engage and behavior is unchanged
Companion benchmarks: dotnet/performance#5126
## New benchmark results (not yet in dotnet/performance, won't be picked
up by PR bot)
These benchmarks are from the companion PR
dotnet/performance#5126.
```
BenchmarkDotNet v0.16.0-custom.20260127.101, Windows 11 (10.0.26100.7840/24H2/2024Update/HudsonValley)
Intel Core i9-14900K 3.20GHz, 1 CPU, 32 logical and 24 physical cores
```
| Benchmark | Options | Baseline | PR | Ratio | MannWhitney(3ms) |
|-----------|---------|----------|-----|-------|------------------|
| LeadingStrings_BinaryData | None | 4,483 us | 4,365 us | 0.97 | Same |
| LeadingStrings_BinaryData | Compiled | 2,188 us | 2,184 us | 1.00 |
Same |
| LeadingStrings_BinaryData | NonBacktracking | 3,734 us | 3,725 us |
1.00 | Same |
| LeadingStrings_NonAscii Count | None | 913 us | 956 us | 1.05 | Same |
| LeadingStrings_NonAscii Count | Compiled | 244 us | 243 us | 1.00 |
Same |
| LeadingStrings_NonAscii CountIgnoreCase | None | 1,758 us | 1,714 us |
0.98 | Same |
| LeadingStrings_NonAscii CountIgnoreCase | Compiled | 258 us | 250 us |
0.97 | Same |
| LeadingStrings_NonAscii Count | NonBacktracking | 392 us | 398 us |
1.02 | Same |
| LeadingStrings_NonAscii CountIgnoreCase | NonBacktracking | 409 us |
431 us | 1.05 | Same |
Binary didn't regress even though it's ASCII with non English
frequencies because the pattern has chars that are not particularly
common in English, so it uses the old codepath. It's hard to hypothesize
about what one might search for in a binary file; searching for a bunch
of leading lower case ASCII chars might regress somewhat. We've never
particularly tested on binary before, and I don't recall seeing any bugs
mentioning binary, so I don't think this is particularly interesting.
NonASCII didn't regress since as previously mentioned, the non ASCII
leading chars in the pattern (presumably likely for any searching of non
ASCII text) causes it to choose the existing codepath.
All MannWhitney tests report Same -- no regressions on binary or
non-ASCII input.
---------
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
iremyux pushed a commit to iremyux/dotnet-runtime that referenced this pull request Mar 2, 2026
…otnet#124736)
I thought it would be interesting to see whether AI could take another
look at the [commented out search
strategy](https://github.com/dotnet/runtime/blob/99b76018b6e4/src/libraries/System.Text.RegularExpressions/src/System/Text/RegularExpressions/RegexFindOptimizations.cs#L152-L161)
originally introduced by @stephentoub in dotnet#98791 to see whether we can
enable it and keep the wins without the regressions that caused it to be
commented out.
AI tried various experiments, and got to a dead end. I recalled the
frequency table approach that Ripgrep uses (credit to @BurntSushi).
Turns out that fixes the regressions entirely. This means our engine now
has assumptions built in about char frequencies in ASCII (only) text.
That's an approach that's been proven in ripgrep, one of the fastest
engines, for 10 years, and it turns out to work OK for regex-redux as
well because a, c, g, t are relatively high frequency in English anyway.
Code unchanged if pattern has anything other than ASCII (see benchmark
results below).
This gives us a nice win on regex-redux, a few other wins in existing
tests, and no regressions.
Note: a char frequency table already existed in `RegexPrefixAnalyzer.cs`
for ranking which fixed-distance character sets are most selective. Our
new table serves a different purpose: deciding whether to use
`LeadingStrings` vs `FixedDistanceSets` at all. The two are
complementary.
====
When a regex has multiple alternation prefixes (e.g. `a|b|c|...`), this
change decides whether to use `SearchValues<string>`
(Teddy/Aho-Corasick) or fall through to `FixedDistanceSets`
(`IndexOfAny`) based on the frequency of the starting characters.
High-frequency starters (common letters like lowercase vowels) benefit
from multi-string search; low-frequency starters (uppercase, digits,
rare consonants) are already excellent `IndexOfAny` filters. Non-ASCII
starters bail out (no frequency data), preserving baseline behavior.
## Benchmark results (444 benchmarks, BDN A/B with --statisticalTest
3ms)
| Benchmark | Baseline | PR | Ratio | Verdict |
|-----------|----------|-----|-------|---------|
| RegexRedux_1 (Compiled) | 25.77ms | 14.27ms | **1.81x faster** |
Faster |
| Leipzig Tom.*river (Compiled) | 6.13ms | 1.87ms | **3.28x faster** |
Faster |
| RegexRedux_5 (Compiled) | 2.83ms | 2.35ms | **1.20x faster** | Faster
|
| Sherlock, BinaryData, BoostDocs, Mariomkas, SliceSlice | -- | -- | --
| Same |
| LeadingStrings_NonAscii (all variants) | -- | -- | -- | Same |
| LeadingStrings_BinaryData (all variants) | -- | -- | -- | Same |
Leipzig win is because the pattern is
`Tom.{10,25}river|river.{10,25}Tom` so there is a short prefix that is
common in the text; with this change it notices `r` is common and `T`
fairly common in English text, so it switches to `SearchValues` which
looks for `Tom` and `river` simultaneously, causing far fewer false
starts.
regex-redux win is because it was previously looking for short, very
common prefixes naively, and now (specifically because the pattern chars
are common) it changed to use `SearchValues` (ie Aho-Corasick/Teddy) to
search for the longer strings simultaneously.
No regressions detected. All MannWhitney tests report Same for
non-improved benchmarks.
## Key design decisions
- **Frequency table**: First 128 entries of Rust's `BYTE_FREQUENCIES`
from @BurntSushi's aho-corasick crate
- **Threshold**: Average rank >= 200 triggers `LeadingStrings`; below
200 falls through to `FixedDistanceSets`
- **Non-ASCII**: Returns false (no frequency data), so the heuristic
does not engage and behavior is unchanged
Companion benchmarks: dotnet/performance#5126
## New benchmark results (not yet in dotnet/performance, won't be picked
up by PR bot)
These benchmarks are from the companion PR
dotnet/performance#5126.
```
BenchmarkDotNet v0.16.0-custom.20260127.101, Windows 11 (10.0.26100.7840/24H2/2024Update/HudsonValley)
Intel Core i9-14900K 3.20GHz, 1 CPU, 32 logical and 24 physical cores
```
| Benchmark | Options | Baseline | PR | Ratio | MannWhitney(3ms) |
|-----------|---------|----------|-----|-------|------------------|
| LeadingStrings_BinaryData | None | 4,483 us | 4,365 us | 0.97 | Same |
| LeadingStrings_BinaryData | Compiled | 2,188 us | 2,184 us | 1.00 |
Same |
| LeadingStrings_BinaryData | NonBacktracking | 3,734 us | 3,725 us |
1.00 | Same |
| LeadingStrings_NonAscii Count | None | 913 us | 956 us | 1.05 | Same |
| LeadingStrings_NonAscii Count | Compiled | 244 us | 243 us | 1.00 |
Same |
| LeadingStrings_NonAscii CountIgnoreCase | None | 1,758 us | 1,714 us |
0.98 | Same |
| LeadingStrings_NonAscii CountIgnoreCase | Compiled | 258 us | 250 us |
0.97 | Same |
| LeadingStrings_NonAscii Count | NonBacktracking | 392 us | 398 us |
1.02 | Same |
| LeadingStrings_NonAscii CountIgnoreCase | NonBacktracking | 409 us |
431 us | 1.05 | Same |
Binary didn't regress even though it's ASCII with non English
frequencies because the pattern has chars that are not particularly
common in English, so it uses the old codepath. It's hard to hypothesize
about what one might search for in a binary file; searching for a bunch
of leading lower case ASCII chars might regress somewhat. We've never
particularly tested on binary before, and I don't recall seeing any bugs
mentioning binary, so I don't think this is particularly interesting.
NonASCII didn't regress since as previously mentioned, the non ASCII
leading chars in the pattern (presumably likely for any searching of non
ASCII text) causes it to choose the existing codepath.
All MannWhitney tests report Same -- no regressions on binary or
non-ASCII input.
---------
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Sign up for freeto 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.

3 participants

@danmoseley@stephentoub
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Add copy buttons to all
 blocks\n(function() {\n function addCopyButtons() {\n document.querySelectorAll('pre code').forEach(function(codeBlock) {\n if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;\n codeBlock.parentElement.setAttribute('data-copy-added', 'true');\n \n var btn = document.createElement('button');\n btn.textContent = 'Copy';\n btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';\n btn.onmouseover = function() { this.style.opacity = '1'; };\n btn.onmouseout = function() { this.style.opacity = '0.7'; };\n btn.onclick = function() {\n navigator.clipboard.writeText(codeBlock.textContent).then(function() {\n btn.textContent = 'Copied!';\n setTimeout(function() { btn.textContent = 'Copy'; }, 1500);\n });\n };\n codeBlock.parentElement.style.position = 'relative';\n codeBlock.parentElement.appendChild(btn);\n });\n }\n \n addCopyButtons();\n \n // Re-run on dynamic content\n var observer = new MutationObserver(addCopyButtons);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Add Copy Buttons to Code Blocks");
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
Skip to content

Add LeadingStrings benchmarks for binary and non-ASCII regex patterns - #5126

Merged
danmoseley merged 3 commits into
dotnet:mainfrom
danmoseley:regex-redux/benchmarks
Feb 24, 2026
Merged

Add LeadingStrings benchmarks for binary and non-ASCII regex patterns#5126
danmoseley merged 3 commits into
dotnet:mainfrom
danmoseley:regex-redux/benchmarks

Conversation

@danmoseley

@danmoseleydanmoseley commented Feb 23, 2026

Copy link
Copy Markdown
Contributor

Adds two new benchmark classes to exercise the LeadingStrings vs FixedDistanceSets heuristic in the regex engine:

  • Perf_Regex_LeadingStrings_BinaryData: 1MB binary corpus (PE-header-like seed duplicated), alternation of binary patterns. Validates no regression on non-text input. (Lots of ASCII here, but obviously not English frequencies.)
  • Perf_Regex_LeadingStrings_NonAscii: ~100KB Russian text (Anna Karenina opening), alternation of Russian words. Validates no regression on non-ASCII text where the frequency heuristic bails out.

Companion to dotnet/runtime change: dotnet/runtime#124736

CopilotAI 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 adds two new benchmark classes to test regex alternation pattern performance with different input types. The benchmarks are designed to validate that the LeadingStrings vs FixedDistanceSets heuristic in the regex engine doesn't regress on binary and non-ASCII data.

Changes:

  • Added Perf_Regex_LeadingStrings_BinaryData class with benchmarks for binary data patterns (1MB PE-header-like corpus)
  • Added Perf_Regex_LeadingStrings_NonAscii class with benchmarks for Russian text patterns (~100KB from Anna Karenina)

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Dan Moseleyand others added 3 commits February 23, 2026 20:26
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Remove Perf_Regex_LeadingStrings_BinaryData, keeping only the non-ASCII benchmark.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Port curated/01-literal and curated/02-literal-alternate benchmarks from
https://github.com/BurntSushi/rebar for Russian and Chinese text.
Haystacks are OpenSubtitles data (https://opus.nlpl.eu/OpenSubtitles-v2018.php).
This replaces the ad-hoc NonAscii benchmark with well-established cross-engine
regex benchmarks that test literal and alternation search on non-ASCII text.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
@danmoseley
danmoseleyforce-pushed the regex-redux/benchmarks branch from 3a03edd to a3f6116CompareFebruary 24, 2026 05:06
@danmoseley

Copy link
Copy Markdown
ContributorAuthor

Benchmark results comparing main (base) vs 21a68d75 (test: "Skip frequency heuristic for single-char FixedDistanceSets")

Built from dotnet/runtime repo. Mann-Whitney U test, two-sided.

LangPatternOptionsBase(us)Test(us)Ratiop-valueSig
ChineseliteralCompiled25.4525.801.010.490
ChineseliteralNonBacktracking24.2714.940.62<0.001***
ChineseliteralNone21.9223.071.050.001**
ChinesealternationCompiled25.1327.311.090.044*
ChinesealternationNonBacktracking31.0328.870.930.378
ChinesealternationNone45.9344.640.970.023*
Russian(?i) literalCompiled110.39110.951.000.322
Russian(?i) literalNonBacktracking161.59160.010.990.106
Russian(?i) literalNone190.67191.381.000.120
Russian(?i) alternationCompiled1,126.601,152.901.020.044*
Russian(?i) alternationNonBacktracking7,629.457,767.191.02<0.001***
Russian(?i) alternationNone11,849.6411,796.661.000.071
RussianliteralCompiled48.7949.881.020.543
RussianliteralNonBacktracking81.0088.571.090.102
RussianliteralNone58.2757.690.990.867
RussianalternationCompiled1,214.601,228.941.010.086
RussianalternationNonBacktracking2,331.083,208.261.38<0.001***
RussianalternationNone3,074.013,164.351.030.017*

* p<0.05, ** p<0.01, *** p<0.001

@danmoseley

Copy link
Copy Markdown
ContributorAuthor

Hmm, speedups are nice except I wouldn't expect any as this is non ASCII

@danmoseley

Copy link
Copy Markdown
ContributorAuthor

Reran with more iterations and the tests are stable and at parity. The issue is some of them are super short (a few MB all in CPU cache doing IndexOf with SIMD) so I needed to do more iterations, which I guess the perf lab would. Merging.

@danmoseley
danmoseley merged commit 24575dc into dotnet:mainFeb 24, 2026
74 checks passed
@danmoseley
danmoseley deleted the regex-redux/benchmarks branch February 24, 2026 05:58
danmoseley pushed a commit to dotnet/runtime that referenced this pull request Feb 25, 2026
…124736)
I thought it would be interesting to see whether AI could take another
look at the [commented out search
strategy](https://github.com/dotnet/runtime/blob/99b76018b6e4/src/libraries/System.Text.RegularExpressions/src/System/Text/RegularExpressions/RegexFindOptimizations.cs#L152-L161)
originally introduced by @stephentoub in #98791 to see whether we can
enable it and keep the wins without the regressions that caused it to be
commented out.
AI tried various experiments, and got to a dead end. I recalled the
frequency table approach that Ripgrep uses (credit to @BurntSushi).
Turns out that fixes the regressions entirely. This means our engine now
has assumptions built in about char frequencies in ASCII (only) text.
That's an approach that's been proven in ripgrep, one of the fastest
engines, for 10 years, and it turns out to work OK for regex-redux as
well because a, c, g, t are relatively high frequency in English anyway.
Code unchanged if pattern has anything other than ASCII (see benchmark
results below).
This gives us a nice win on regex-redux, a few other wins in existing
tests, and no regressions.
Note: a char frequency table already existed in `RegexPrefixAnalyzer.cs`
for ranking which fixed-distance character sets are most selective. Our
new table serves a different purpose: deciding whether to use
`LeadingStrings` vs `FixedDistanceSets` at all. The two are
complementary.
====
When a regex has multiple alternation prefixes (e.g. `a|b|c|...`), this
change decides whether to use `SearchValues<string>`
(Teddy/Aho-Corasick) or fall through to `FixedDistanceSets`
(`IndexOfAny`) based on the frequency of the starting characters.
High-frequency starters (common letters like lowercase vowels) benefit
from multi-string search; low-frequency starters (uppercase, digits,
rare consonants) are already excellent `IndexOfAny` filters. Non-ASCII
starters bail out (no frequency data), preserving baseline behavior.
## Benchmark results (444 benchmarks, BDN A/B with --statisticalTest
3ms)
| Benchmark | Baseline | PR | Ratio | Verdict |
|-----------|----------|-----|-------|---------|
| RegexRedux_1 (Compiled) | 25.77ms | 14.27ms | **1.81x faster** |
Faster |
| Leipzig Tom.*river (Compiled) | 6.13ms | 1.87ms | **3.28x faster** |
Faster |
| RegexRedux_5 (Compiled) | 2.83ms | 2.35ms | **1.20x faster** | Faster
|
| Sherlock, BinaryData, BoostDocs, Mariomkas, SliceSlice | -- | -- | --
| Same |
| LeadingStrings_NonAscii (all variants) | -- | -- | -- | Same |
| LeadingStrings_BinaryData (all variants) | -- | -- | -- | Same |
Leipzig win is because the pattern is
`Tom.{10,25}river|river.{10,25}Tom` so there is a short prefix that is
common in the text; with this change it notices `r` is common and `T`
fairly common in English text, so it switches to `SearchValues` which
looks for `Tom` and `river` simultaneously, causing far fewer false
starts.
regex-redux win is because it was previously looking for short, very
common prefixes naively, and now (specifically because the pattern chars
are common) it changed to use `SearchValues` (ie Aho-Corasick/Teddy) to
search for the longer strings simultaneously.
No regressions detected. All MannWhitney tests report Same for
non-improved benchmarks.
## Key design decisions
- **Frequency table**: First 128 entries of Rust's `BYTE_FREQUENCIES`
from @BurntSushi's aho-corasick crate
- **Threshold**: Average rank >= 200 triggers `LeadingStrings`; below
200 falls through to `FixedDistanceSets`
- **Non-ASCII**: Returns false (no frequency data), so the heuristic
does not engage and behavior is unchanged
Companion benchmarks: dotnet/performance#5126
## New benchmark results (not yet in dotnet/performance, won't be picked
up by PR bot)
These benchmarks are from the companion PR
dotnet/performance#5126.
```
BenchmarkDotNet v0.16.0-custom.20260127.101, Windows 11 (10.0.26100.7840/24H2/2024Update/HudsonValley)
Intel Core i9-14900K 3.20GHz, 1 CPU, 32 logical and 24 physical cores
```
| Benchmark | Options | Baseline | PR | Ratio | MannWhitney(3ms) |
|-----------|---------|----------|-----|-------|------------------|
| LeadingStrings_BinaryData | None | 4,483 us | 4,365 us | 0.97 | Same |
| LeadingStrings_BinaryData | Compiled | 2,188 us | 2,184 us | 1.00 |
Same |
| LeadingStrings_BinaryData | NonBacktracking | 3,734 us | 3,725 us |
1.00 | Same |
| LeadingStrings_NonAscii Count | None | 913 us | 956 us | 1.05 | Same |
| LeadingStrings_NonAscii Count | Compiled | 244 us | 243 us | 1.00 |
Same |
| LeadingStrings_NonAscii CountIgnoreCase | None | 1,758 us | 1,714 us |
0.98 | Same |
| LeadingStrings_NonAscii CountIgnoreCase | Compiled | 258 us | 250 us |
0.97 | Same |
| LeadingStrings_NonAscii Count | NonBacktracking | 392 us | 398 us |
1.02 | Same |
| LeadingStrings_NonAscii CountIgnoreCase | NonBacktracking | 409 us |
431 us | 1.05 | Same |
Binary didn't regress even though it's ASCII with non English
frequencies because the pattern has chars that are not particularly
common in English, so it uses the old codepath. It's hard to hypothesize
about what one might search for in a binary file; searching for a bunch
of leading lower case ASCII chars might regress somewhat. We've never
particularly tested on binary before, and I don't recall seeing any bugs
mentioning binary, so I don't think this is particularly interesting.
NonASCII didn't regress since as previously mentioned, the non ASCII
leading chars in the pattern (presumably likely for any searching of non
ASCII text) causes it to choose the existing codepath.
All MannWhitney tests report Same -- no regressions on binary or
non-ASCII input.
---------
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
iremyux pushed a commit to iremyux/dotnet-runtime that referenced this pull request Mar 2, 2026
…otnet#124736)
I thought it would be interesting to see whether AI could take another
look at the [commented out search
strategy](https://github.com/dotnet/runtime/blob/99b76018b6e4/src/libraries/System.Text.RegularExpressions/src/System/Text/RegularExpressions/RegexFindOptimizations.cs#L152-L161)
originally introduced by @stephentoub in dotnet#98791 to see whether we can
enable it and keep the wins without the regressions that caused it to be
commented out.
AI tried various experiments, and got to a dead end. I recalled the
frequency table approach that Ripgrep uses (credit to @BurntSushi).
Turns out that fixes the regressions entirely. This means our engine now
has assumptions built in about char frequencies in ASCII (only) text.
That's an approach that's been proven in ripgrep, one of the fastest
engines, for 10 years, and it turns out to work OK for regex-redux as
well because a, c, g, t are relatively high frequency in English anyway.
Code unchanged if pattern has anything other than ASCII (see benchmark
results below).
This gives us a nice win on regex-redux, a few other wins in existing
tests, and no regressions.
Note: a char frequency table already existed in `RegexPrefixAnalyzer.cs`
for ranking which fixed-distance character sets are most selective. Our
new table serves a different purpose: deciding whether to use
`LeadingStrings` vs `FixedDistanceSets` at all. The two are
complementary.
====
When a regex has multiple alternation prefixes (e.g. `a|b|c|...`), this
change decides whether to use `SearchValues<string>`
(Teddy/Aho-Corasick) or fall through to `FixedDistanceSets`
(`IndexOfAny`) based on the frequency of the starting characters.
High-frequency starters (common letters like lowercase vowels) benefit
from multi-string search; low-frequency starters (uppercase, digits,
rare consonants) are already excellent `IndexOfAny` filters. Non-ASCII
starters bail out (no frequency data), preserving baseline behavior.
## Benchmark results (444 benchmarks, BDN A/B with --statisticalTest
3ms)
| Benchmark | Baseline | PR | Ratio | Verdict |
|-----------|----------|-----|-------|---------|
| RegexRedux_1 (Compiled) | 25.77ms | 14.27ms | **1.81x faster** |
Faster |
| Leipzig Tom.*river (Compiled) | 6.13ms | 1.87ms | **3.28x faster** |
Faster |
| RegexRedux_5 (Compiled) | 2.83ms | 2.35ms | **1.20x faster** | Faster
|
| Sherlock, BinaryData, BoostDocs, Mariomkas, SliceSlice | -- | -- | --
| Same |
| LeadingStrings_NonAscii (all variants) | -- | -- | -- | Same |
| LeadingStrings_BinaryData (all variants) | -- | -- | -- | Same |
Leipzig win is because the pattern is
`Tom.{10,25}river|river.{10,25}Tom` so there is a short prefix that is
common in the text; with this change it notices `r` is common and `T`
fairly common in English text, so it switches to `SearchValues` which
looks for `Tom` and `river` simultaneously, causing far fewer false
starts.
regex-redux win is because it was previously looking for short, very
common prefixes naively, and now (specifically because the pattern chars
are common) it changed to use `SearchValues` (ie Aho-Corasick/Teddy) to
search for the longer strings simultaneously.
No regressions detected. All MannWhitney tests report Same for
non-improved benchmarks.
## Key design decisions
- **Frequency table**: First 128 entries of Rust's `BYTE_FREQUENCIES`
from @BurntSushi's aho-corasick crate
- **Threshold**: Average rank >= 200 triggers `LeadingStrings`; below
200 falls through to `FixedDistanceSets`
- **Non-ASCII**: Returns false (no frequency data), so the heuristic
does not engage and behavior is unchanged
Companion benchmarks: dotnet/performance#5126
## New benchmark results (not yet in dotnet/performance, won't be picked
up by PR bot)
These benchmarks are from the companion PR
dotnet/performance#5126.
```
BenchmarkDotNet v0.16.0-custom.20260127.101, Windows 11 (10.0.26100.7840/24H2/2024Update/HudsonValley)
Intel Core i9-14900K 3.20GHz, 1 CPU, 32 logical and 24 physical cores
```
| Benchmark | Options | Baseline | PR | Ratio | MannWhitney(3ms) |
|-----------|---------|----------|-----|-------|------------------|
| LeadingStrings_BinaryData | None | 4,483 us | 4,365 us | 0.97 | Same |
| LeadingStrings_BinaryData | Compiled | 2,188 us | 2,184 us | 1.00 |
Same |
| LeadingStrings_BinaryData | NonBacktracking | 3,734 us | 3,725 us |
1.00 | Same |
| LeadingStrings_NonAscii Count | None | 913 us | 956 us | 1.05 | Same |
| LeadingStrings_NonAscii Count | Compiled | 244 us | 243 us | 1.00 |
Same |
| LeadingStrings_NonAscii CountIgnoreCase | None | 1,758 us | 1,714 us |
0.98 | Same |
| LeadingStrings_NonAscii CountIgnoreCase | Compiled | 258 us | 250 us |
0.97 | Same |
| LeadingStrings_NonAscii Count | NonBacktracking | 392 us | 398 us |
1.02 | Same |
| LeadingStrings_NonAscii CountIgnoreCase | NonBacktracking | 409 us |
431 us | 1.05 | Same |
Binary didn't regress even though it's ASCII with non English
frequencies because the pattern has chars that are not particularly
common in English, so it uses the old codepath. It's hard to hypothesize
about what one might search for in a binary file; searching for a bunch
of leading lower case ASCII chars might regress somewhat. We've never
particularly tested on binary before, and I don't recall seeing any bugs
mentioning binary, so I don't think this is particularly interesting.
NonASCII didn't regress since as previously mentioned, the non ASCII
leading chars in the pattern (presumably likely for any searching of non
ASCII text) causes it to choose the existing codepath.
All MannWhitney tests report Same -- no regressions on binary or
non-ASCII input.
---------
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Sign up for freeto 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.

3 participants

@danmoseley@stephentoub
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Force GitHub README to respect dark mode\n(function() {\n var style = document.createElement('style');\n style.textContent = '\n .markdown-body {\n color-scheme: dark light;\n }\n .markdown-body pre { background: #161b22 !important; }\n .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; }\n .markdown-body table th, .markdown-body table td { border-color: #30363d !important; }\n .markdown-body img { background: #0d1117; }\n .markdown-body blockquote { border-left-color: #8b949e; }\n .markdown-body hr { border-color: #30363d; }\n ';\n document.head.appendChild(style);\n})();", "GitHub Dark Mode README Fix"); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

Add LeadingStrings benchmarks for binary and non-ASCII regex patterns - #5126

Merged
danmoseley merged 3 commits into
dotnet:mainfrom
danmoseley:regex-redux/benchmarks
Feb 24, 2026
Merged

Add LeadingStrings benchmarks for binary and non-ASCII regex patterns#5126
danmoseley merged 3 commits into
dotnet:mainfrom
danmoseley:regex-redux/benchmarks

Conversation

@danmoseley

@danmoseleydanmoseley commented Feb 23, 2026

Copy link
Copy Markdown
Contributor

Adds two new benchmark classes to exercise the LeadingStrings vs FixedDistanceSets heuristic in the regex engine:

  • Perf_Regex_LeadingStrings_BinaryData: 1MB binary corpus (PE-header-like seed duplicated), alternation of binary patterns. Validates no regression on non-text input. (Lots of ASCII here, but obviously not English frequencies.)
  • Perf_Regex_LeadingStrings_NonAscii: ~100KB Russian text (Anna Karenina opening), alternation of Russian words. Validates no regression on non-ASCII text where the frequency heuristic bails out.

Companion to dotnet/runtime change: dotnet/runtime#124736

CopilotAI 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 adds two new benchmark classes to test regex alternation pattern performance with different input types. The benchmarks are designed to validate that the LeadingStrings vs FixedDistanceSets heuristic in the regex engine doesn't regress on binary and non-ASCII data.

Changes:

  • Added Perf_Regex_LeadingStrings_BinaryData class with benchmarks for binary data patterns (1MB PE-header-like corpus)
  • Added Perf_Regex_LeadingStrings_NonAscii class with benchmarks for Russian text patterns (~100KB from Anna Karenina)

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Dan Moseleyand others added 3 commits February 23, 2026 20:26
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Remove Perf_Regex_LeadingStrings_BinaryData, keeping only the non-ASCII benchmark.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Port curated/01-literal and curated/02-literal-alternate benchmarks from
https://github.com/BurntSushi/rebar for Russian and Chinese text.
Haystacks are OpenSubtitles data (https://opus.nlpl.eu/OpenSubtitles-v2018.php).
This replaces the ad-hoc NonAscii benchmark with well-established cross-engine
regex benchmarks that test literal and alternation search on non-ASCII text.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
@danmoseley
danmoseleyforce-pushed the regex-redux/benchmarks branch from 3a03edd to a3f6116CompareFebruary 24, 2026 05:06
@danmoseley

Copy link
Copy Markdown
ContributorAuthor

Benchmark results comparing main (base) vs 21a68d75 (test: "Skip frequency heuristic for single-char FixedDistanceSets")

Built from dotnet/runtime repo. Mann-Whitney U test, two-sided.

LangPatternOptionsBase(us)Test(us)Ratiop-valueSig
ChineseliteralCompiled25.4525.801.010.490
ChineseliteralNonBacktracking24.2714.940.62<0.001***
ChineseliteralNone21.9223.071.050.001**
ChinesealternationCompiled25.1327.311.090.044*
ChinesealternationNonBacktracking31.0328.870.930.378
ChinesealternationNone45.9344.640.970.023*
Russian(?i) literalCompiled110.39110.951.000.322
Russian(?i) literalNonBacktracking161.59160.010.990.106
Russian(?i) literalNone190.67191.381.000.120
Russian(?i) alternationCompiled1,126.601,152.901.020.044*
Russian(?i) alternationNonBacktracking7,629.457,767.191.02<0.001***
Russian(?i) alternationNone11,849.6411,796.661.000.071
RussianliteralCompiled48.7949.881.020.543
RussianliteralNonBacktracking81.0088.571.090.102
RussianliteralNone58.2757.690.990.867
RussianalternationCompiled1,214.601,228.941.010.086
RussianalternationNonBacktracking2,331.083,208.261.38<0.001***
RussianalternationNone3,074.013,164.351.030.017*

* p<0.05, ** p<0.01, *** p<0.001

@danmoseley

Copy link
Copy Markdown
ContributorAuthor

Hmm, speedups are nice except I wouldn't expect any as this is non ASCII

@danmoseley

Copy link
Copy Markdown
ContributorAuthor

Reran with more iterations and the tests are stable and at parity. The issue is some of them are super short (a few MB all in CPU cache doing IndexOf with SIMD) so I needed to do more iterations, which I guess the perf lab would. Merging.

@danmoseley
danmoseley merged commit 24575dc into dotnet:mainFeb 24, 2026
74 checks passed
@danmoseley
danmoseley deleted the regex-redux/benchmarks branch February 24, 2026 05:58
danmoseley pushed a commit to dotnet/runtime that referenced this pull request Feb 25, 2026
…124736)
I thought it would be interesting to see whether AI could take another
look at the [commented out search
strategy](https://github.com/dotnet/runtime/blob/99b76018b6e4/src/libraries/System.Text.RegularExpressions/src/System/Text/RegularExpressions/RegexFindOptimizations.cs#L152-L161)
originally introduced by @stephentoub in #98791 to see whether we can
enable it and keep the wins without the regressions that caused it to be
commented out.
AI tried various experiments, and got to a dead end. I recalled the
frequency table approach that Ripgrep uses (credit to @BurntSushi).
Turns out that fixes the regressions entirely. This means our engine now
has assumptions built in about char frequencies in ASCII (only) text.
That's an approach that's been proven in ripgrep, one of the fastest
engines, for 10 years, and it turns out to work OK for regex-redux as
well because a, c, g, t are relatively high frequency in English anyway.
Code unchanged if pattern has anything other than ASCII (see benchmark
results below).
This gives us a nice win on regex-redux, a few other wins in existing
tests, and no regressions.
Note: a char frequency table already existed in `RegexPrefixAnalyzer.cs`
for ranking which fixed-distance character sets are most selective. Our
new table serves a different purpose: deciding whether to use
`LeadingStrings` vs `FixedDistanceSets` at all. The two are
complementary.
====
When a regex has multiple alternation prefixes (e.g. `a|b|c|...`), this
change decides whether to use `SearchValues<string>`
(Teddy/Aho-Corasick) or fall through to `FixedDistanceSets`
(`IndexOfAny`) based on the frequency of the starting characters.
High-frequency starters (common letters like lowercase vowels) benefit
from multi-string search; low-frequency starters (uppercase, digits,
rare consonants) are already excellent `IndexOfAny` filters. Non-ASCII
starters bail out (no frequency data), preserving baseline behavior.
## Benchmark results (444 benchmarks, BDN A/B with --statisticalTest
3ms)
| Benchmark | Baseline | PR | Ratio | Verdict |
|-----------|----------|-----|-------|---------|
| RegexRedux_1 (Compiled) | 25.77ms | 14.27ms | **1.81x faster** |
Faster |
| Leipzig Tom.*river (Compiled) | 6.13ms | 1.87ms | **3.28x faster** |
Faster |
| RegexRedux_5 (Compiled) | 2.83ms | 2.35ms | **1.20x faster** | Faster
|
| Sherlock, BinaryData, BoostDocs, Mariomkas, SliceSlice | -- | -- | --
| Same |
| LeadingStrings_NonAscii (all variants) | -- | -- | -- | Same |
| LeadingStrings_BinaryData (all variants) | -- | -- | -- | Same |
Leipzig win is because the pattern is
`Tom.{10,25}river|river.{10,25}Tom` so there is a short prefix that is
common in the text; with this change it notices `r` is common and `T`
fairly common in English text, so it switches to `SearchValues` which
looks for `Tom` and `river` simultaneously, causing far fewer false
starts.
regex-redux win is because it was previously looking for short, very
common prefixes naively, and now (specifically because the pattern chars
are common) it changed to use `SearchValues` (ie Aho-Corasick/Teddy) to
search for the longer strings simultaneously.
No regressions detected. All MannWhitney tests report Same for
non-improved benchmarks.
## Key design decisions
- **Frequency table**: First 128 entries of Rust's `BYTE_FREQUENCIES`
from @BurntSushi's aho-corasick crate
- **Threshold**: Average rank >= 200 triggers `LeadingStrings`; below
200 falls through to `FixedDistanceSets`
- **Non-ASCII**: Returns false (no frequency data), so the heuristic
does not engage and behavior is unchanged
Companion benchmarks: dotnet/performance#5126
## New benchmark results (not yet in dotnet/performance, won't be picked
up by PR bot)
These benchmarks are from the companion PR
dotnet/performance#5126.
```
BenchmarkDotNet v0.16.0-custom.20260127.101, Windows 11 (10.0.26100.7840/24H2/2024Update/HudsonValley)
Intel Core i9-14900K 3.20GHz, 1 CPU, 32 logical and 24 physical cores
```
| Benchmark | Options | Baseline | PR | Ratio | MannWhitney(3ms) |
|-----------|---------|----------|-----|-------|------------------|
| LeadingStrings_BinaryData | None | 4,483 us | 4,365 us | 0.97 | Same |
| LeadingStrings_BinaryData | Compiled | 2,188 us | 2,184 us | 1.00 |
Same |
| LeadingStrings_BinaryData | NonBacktracking | 3,734 us | 3,725 us |
1.00 | Same |
| LeadingStrings_NonAscii Count | None | 913 us | 956 us | 1.05 | Same |
| LeadingStrings_NonAscii Count | Compiled | 244 us | 243 us | 1.00 |
Same |
| LeadingStrings_NonAscii CountIgnoreCase | None | 1,758 us | 1,714 us |
0.98 | Same |
| LeadingStrings_NonAscii CountIgnoreCase | Compiled | 258 us | 250 us |
0.97 | Same |
| LeadingStrings_NonAscii Count | NonBacktracking | 392 us | 398 us |
1.02 | Same |
| LeadingStrings_NonAscii CountIgnoreCase | NonBacktracking | 409 us |
431 us | 1.05 | Same |
Binary didn't regress even though it's ASCII with non English
frequencies because the pattern has chars that are not particularly
common in English, so it uses the old codepath. It's hard to hypothesize
about what one might search for in a binary file; searching for a bunch
of leading lower case ASCII chars might regress somewhat. We've never
particularly tested on binary before, and I don't recall seeing any bugs
mentioning binary, so I don't think this is particularly interesting.
NonASCII didn't regress since as previously mentioned, the non ASCII
leading chars in the pattern (presumably likely for any searching of non
ASCII text) causes it to choose the existing codepath.
All MannWhitney tests report Same -- no regressions on binary or
non-ASCII input.
---------
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
iremyux pushed a commit to iremyux/dotnet-runtime that referenced this pull request Mar 2, 2026
…otnet#124736)
I thought it would be interesting to see whether AI could take another
look at the [commented out search
strategy](https://github.com/dotnet/runtime/blob/99b76018b6e4/src/libraries/System.Text.RegularExpressions/src/System/Text/RegularExpressions/RegexFindOptimizations.cs#L152-L161)
originally introduced by @stephentoub in dotnet#98791 to see whether we can
enable it and keep the wins without the regressions that caused it to be
commented out.
AI tried various experiments, and got to a dead end. I recalled the
frequency table approach that Ripgrep uses (credit to @BurntSushi).
Turns out that fixes the regressions entirely. This means our engine now
has assumptions built in about char frequencies in ASCII (only) text.
That's an approach that's been proven in ripgrep, one of the fastest
engines, for 10 years, and it turns out to work OK for regex-redux as
well because a, c, g, t are relatively high frequency in English anyway.
Code unchanged if pattern has anything other than ASCII (see benchmark
results below).
This gives us a nice win on regex-redux, a few other wins in existing
tests, and no regressions.
Note: a char frequency table already existed in `RegexPrefixAnalyzer.cs`
for ranking which fixed-distance character sets are most selective. Our
new table serves a different purpose: deciding whether to use
`LeadingStrings` vs `FixedDistanceSets` at all. The two are
complementary.
====
When a regex has multiple alternation prefixes (e.g. `a|b|c|...`), this
change decides whether to use `SearchValues<string>`
(Teddy/Aho-Corasick) or fall through to `FixedDistanceSets`
(`IndexOfAny`) based on the frequency of the starting characters.
High-frequency starters (common letters like lowercase vowels) benefit
from multi-string search; low-frequency starters (uppercase, digits,
rare consonants) are already excellent `IndexOfAny` filters. Non-ASCII
starters bail out (no frequency data), preserving baseline behavior.
## Benchmark results (444 benchmarks, BDN A/B with --statisticalTest
3ms)
| Benchmark | Baseline | PR | Ratio | Verdict |
|-----------|----------|-----|-------|---------|
| RegexRedux_1 (Compiled) | 25.77ms | 14.27ms | **1.81x faster** |
Faster |
| Leipzig Tom.*river (Compiled) | 6.13ms | 1.87ms | **3.28x faster** |
Faster |
| RegexRedux_5 (Compiled) | 2.83ms | 2.35ms | **1.20x faster** | Faster
|
| Sherlock, BinaryData, BoostDocs, Mariomkas, SliceSlice | -- | -- | --
| Same |
| LeadingStrings_NonAscii (all variants) | -- | -- | -- | Same |
| LeadingStrings_BinaryData (all variants) | -- | -- | -- | Same |
Leipzig win is because the pattern is
`Tom.{10,25}river|river.{10,25}Tom` so there is a short prefix that is
common in the text; with this change it notices `r` is common and `T`
fairly common in English text, so it switches to `SearchValues` which
looks for `Tom` and `river` simultaneously, causing far fewer false
starts.
regex-redux win is because it was previously looking for short, very
common prefixes naively, and now (specifically because the pattern chars
are common) it changed to use `SearchValues` (ie Aho-Corasick/Teddy) to
search for the longer strings simultaneously.
No regressions detected. All MannWhitney tests report Same for
non-improved benchmarks.
## Key design decisions
- **Frequency table**: First 128 entries of Rust's `BYTE_FREQUENCIES`
from @BurntSushi's aho-corasick crate
- **Threshold**: Average rank >= 200 triggers `LeadingStrings`; below
200 falls through to `FixedDistanceSets`
- **Non-ASCII**: Returns false (no frequency data), so the heuristic
does not engage and behavior is unchanged
Companion benchmarks: dotnet/performance#5126
## New benchmark results (not yet in dotnet/performance, won't be picked
up by PR bot)
These benchmarks are from the companion PR
dotnet/performance#5126.
```
BenchmarkDotNet v0.16.0-custom.20260127.101, Windows 11 (10.0.26100.7840/24H2/2024Update/HudsonValley)
Intel Core i9-14900K 3.20GHz, 1 CPU, 32 logical and 24 physical cores
```
| Benchmark | Options | Baseline | PR | Ratio | MannWhitney(3ms) |
|-----------|---------|----------|-----|-------|------------------|
| LeadingStrings_BinaryData | None | 4,483 us | 4,365 us | 0.97 | Same |
| LeadingStrings_BinaryData | Compiled | 2,188 us | 2,184 us | 1.00 |
Same |
| LeadingStrings_BinaryData | NonBacktracking | 3,734 us | 3,725 us |
1.00 | Same |
| LeadingStrings_NonAscii Count | None | 913 us | 956 us | 1.05 | Same |
| LeadingStrings_NonAscii Count | Compiled | 244 us | 243 us | 1.00 |
Same |
| LeadingStrings_NonAscii CountIgnoreCase | None | 1,758 us | 1,714 us |
0.98 | Same |
| LeadingStrings_NonAscii CountIgnoreCase | Compiled | 258 us | 250 us |
0.97 | Same |
| LeadingStrings_NonAscii Count | NonBacktracking | 392 us | 398 us |
1.02 | Same |
| LeadingStrings_NonAscii CountIgnoreCase | NonBacktracking | 409 us |
431 us | 1.05 | Same |
Binary didn't regress even though it's ASCII with non English
frequencies because the pattern has chars that are not particularly
common in English, so it uses the old codepath. It's hard to hypothesize
about what one might search for in a binary file; searching for a bunch
of leading lower case ASCII chars might regress somewhat. We've never
particularly tested on binary before, and I don't recall seeing any bugs
mentioning binary, so I don't think this is particularly interesting.
NonASCII didn't regress since as previously mentioned, the non ASCII
leading chars in the pattern (presumably likely for any searching of non
ASCII text) causes it to choose the existing codepath.
All MannWhitney tests report Same -- no regressions on binary or
non-ASCII input.
---------
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Sign up for freeto 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.

3 participants

@danmoseley@stephentoub
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Highlight search terms from Google/DuckDuckGo/Bing referrer\n(function() {\n var ref = document.referrer;\n var terms = [];\n \n if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) {\n var url = new URL(ref);\n var q = url.searchParams.get('q') || url.searchParams.get('p');\n if (q) {\n terms = q.split(/\\s+/).filter(function(t) { return t.length > 2; });\n }\n }\n \n if (terms.length === 0) return;\n \n var style = document.createElement('style');\n style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }';\n document.head.appendChild(style);\n \n function highlight(node) {\n if (node.nodeType === 3) { // text node\n var text = node.textContent;\n var found = false;\n terms.forEach(function(term) {\n var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\') + ')', 'gi');\n if (regex.test(text)) {\n found = true;\n var frag = document.createDocumentFragment();\n var parts = text.split(regex);\n parts.forEach(function(part, i) {\n if (i % 2 === 0) {\n frag.appendChild(document.createTextNode(part));\n } else {\n var span = document.createElement('span');\n span.className = 'userscript-highlight';\n span.textContent = part;\n frag.appendChild(span);\n }\n });\n node.parentNode.replaceChild(frag, node);\n }\n });\n } else if (node.nodeType === 1 && node.childNodes) { // element\n var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT'];\n if (!skipTags.includes(node.tagName)) {\n Array.from(node.childNodes).forEach(highlight);\n }\n }\n }\n \n highlight(document.body);\n \n // Re-highlight on dynamic content\n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1 || node.nodeType === 3) highlight(node);\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Highlight Search Terms"); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

Add LeadingStrings benchmarks for binary and non-ASCII regex patterns - #5126

Merged
danmoseley merged 3 commits into
dotnet:mainfrom
danmoseley:regex-redux/benchmarks
Feb 24, 2026
Merged

Add LeadingStrings benchmarks for binary and non-ASCII regex patterns#5126
danmoseley merged 3 commits into
dotnet:mainfrom
danmoseley:regex-redux/benchmarks

Conversation

@danmoseley

@danmoseleydanmoseley commented Feb 23, 2026

Copy link
Copy Markdown
Contributor

Adds two new benchmark classes to exercise the LeadingStrings vs FixedDistanceSets heuristic in the regex engine:

  • Perf_Regex_LeadingStrings_BinaryData: 1MB binary corpus (PE-header-like seed duplicated), alternation of binary patterns. Validates no regression on non-text input. (Lots of ASCII here, but obviously not English frequencies.)
  • Perf_Regex_LeadingStrings_NonAscii: ~100KB Russian text (Anna Karenina opening), alternation of Russian words. Validates no regression on non-ASCII text where the frequency heuristic bails out.

Companion to dotnet/runtime change: dotnet/runtime#124736

CopilotAI 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 adds two new benchmark classes to test regex alternation pattern performance with different input types. The benchmarks are designed to validate that the LeadingStrings vs FixedDistanceSets heuristic in the regex engine doesn't regress on binary and non-ASCII data.

Changes:

  • Added Perf_Regex_LeadingStrings_BinaryData class with benchmarks for binary data patterns (1MB PE-header-like corpus)
  • Added Perf_Regex_LeadingStrings_NonAscii class with benchmarks for Russian text patterns (~100KB from Anna Karenina)

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Dan Moseleyand others added 3 commits February 23, 2026 20:26
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Remove Perf_Regex_LeadingStrings_BinaryData, keeping only the non-ASCII benchmark.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Port curated/01-literal and curated/02-literal-alternate benchmarks from
https://github.com/BurntSushi/rebar for Russian and Chinese text.
Haystacks are OpenSubtitles data (https://opus.nlpl.eu/OpenSubtitles-v2018.php).
This replaces the ad-hoc NonAscii benchmark with well-established cross-engine
regex benchmarks that test literal and alternation search on non-ASCII text.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
@danmoseley
danmoseleyforce-pushed the regex-redux/benchmarks branch from 3a03edd to a3f6116CompareFebruary 24, 2026 05:06
@danmoseley

Copy link
Copy Markdown
ContributorAuthor

Benchmark results comparing main (base) vs 21a68d75 (test: "Skip frequency heuristic for single-char FixedDistanceSets")

Built from dotnet/runtime repo. Mann-Whitney U test, two-sided.

LangPatternOptionsBase(us)Test(us)Ratiop-valueSig
ChineseliteralCompiled25.4525.801.010.490
ChineseliteralNonBacktracking24.2714.940.62<0.001***
ChineseliteralNone21.9223.071.050.001**
ChinesealternationCompiled25.1327.311.090.044*
ChinesealternationNonBacktracking31.0328.870.930.378
ChinesealternationNone45.9344.640.970.023*
Russian(?i) literalCompiled110.39110.951.000.322
Russian(?i) literalNonBacktracking161.59160.010.990.106
Russian(?i) literalNone190.67191.381.000.120
Russian(?i) alternationCompiled1,126.601,152.901.020.044*
Russian(?i) alternationNonBacktracking7,629.457,767.191.02<0.001***
Russian(?i) alternationNone11,849.6411,796.661.000.071
RussianliteralCompiled48.7949.881.020.543
RussianliteralNonBacktracking81.0088.571.090.102
RussianliteralNone58.2757.690.990.867
RussianalternationCompiled1,214.601,228.941.010.086
RussianalternationNonBacktracking2,331.083,208.261.38<0.001***
RussianalternationNone3,074.013,164.351.030.017*

* p<0.05, ** p<0.01, *** p<0.001

@danmoseley

Copy link
Copy Markdown
ContributorAuthor

Hmm, speedups are nice except I wouldn't expect any as this is non ASCII

@danmoseley

Copy link
Copy Markdown
ContributorAuthor

Reran with more iterations and the tests are stable and at parity. The issue is some of them are super short (a few MB all in CPU cache doing IndexOf with SIMD) so I needed to do more iterations, which I guess the perf lab would. Merging.

@danmoseley
danmoseley merged commit 24575dc into dotnet:mainFeb 24, 2026
74 checks passed
@danmoseley
danmoseley deleted the regex-redux/benchmarks branch February 24, 2026 05:58
danmoseley pushed a commit to dotnet/runtime that referenced this pull request Feb 25, 2026
…124736)
I thought it would be interesting to see whether AI could take another
look at the [commented out search
strategy](https://github.com/dotnet/runtime/blob/99b76018b6e4/src/libraries/System.Text.RegularExpressions/src/System/Text/RegularExpressions/RegexFindOptimizations.cs#L152-L161)
originally introduced by @stephentoub in #98791 to see whether we can
enable it and keep the wins without the regressions that caused it to be
commented out.
AI tried various experiments, and got to a dead end. I recalled the
frequency table approach that Ripgrep uses (credit to @BurntSushi).
Turns out that fixes the regressions entirely. This means our engine now
has assumptions built in about char frequencies in ASCII (only) text.
That's an approach that's been proven in ripgrep, one of the fastest
engines, for 10 years, and it turns out to work OK for regex-redux as
well because a, c, g, t are relatively high frequency in English anyway.
Code unchanged if pattern has anything other than ASCII (see benchmark
results below).
This gives us a nice win on regex-redux, a few other wins in existing
tests, and no regressions.
Note: a char frequency table already existed in `RegexPrefixAnalyzer.cs`
for ranking which fixed-distance character sets are most selective. Our
new table serves a different purpose: deciding whether to use
`LeadingStrings` vs `FixedDistanceSets` at all. The two are
complementary.
====
When a regex has multiple alternation prefixes (e.g. `a|b|c|...`), this
change decides whether to use `SearchValues<string>`
(Teddy/Aho-Corasick) or fall through to `FixedDistanceSets`
(`IndexOfAny`) based on the frequency of the starting characters.
High-frequency starters (common letters like lowercase vowels) benefit
from multi-string search; low-frequency starters (uppercase, digits,
rare consonants) are already excellent `IndexOfAny` filters. Non-ASCII
starters bail out (no frequency data), preserving baseline behavior.
## Benchmark results (444 benchmarks, BDN A/B with --statisticalTest
3ms)
| Benchmark | Baseline | PR | Ratio | Verdict |
|-----------|----------|-----|-------|---------|
| RegexRedux_1 (Compiled) | 25.77ms | 14.27ms | **1.81x faster** |
Faster |
| Leipzig Tom.*river (Compiled) | 6.13ms | 1.87ms | **3.28x faster** |
Faster |
| RegexRedux_5 (Compiled) | 2.83ms | 2.35ms | **1.20x faster** | Faster
|
| Sherlock, BinaryData, BoostDocs, Mariomkas, SliceSlice | -- | -- | --
| Same |
| LeadingStrings_NonAscii (all variants) | -- | -- | -- | Same |
| LeadingStrings_BinaryData (all variants) | -- | -- | -- | Same |
Leipzig win is because the pattern is
`Tom.{10,25}river|river.{10,25}Tom` so there is a short prefix that is
common in the text; with this change it notices `r` is common and `T`
fairly common in English text, so it switches to `SearchValues` which
looks for `Tom` and `river` simultaneously, causing far fewer false
starts.
regex-redux win is because it was previously looking for short, very
common prefixes naively, and now (specifically because the pattern chars
are common) it changed to use `SearchValues` (ie Aho-Corasick/Teddy) to
search for the longer strings simultaneously.
No regressions detected. All MannWhitney tests report Same for
non-improved benchmarks.
## Key design decisions
- **Frequency table**: First 128 entries of Rust's `BYTE_FREQUENCIES`
from @BurntSushi's aho-corasick crate
- **Threshold**: Average rank >= 200 triggers `LeadingStrings`; below
200 falls through to `FixedDistanceSets`
- **Non-ASCII**: Returns false (no frequency data), so the heuristic
does not engage and behavior is unchanged
Companion benchmarks: dotnet/performance#5126
## New benchmark results (not yet in dotnet/performance, won't be picked
up by PR bot)
These benchmarks are from the companion PR
dotnet/performance#5126.
```
BenchmarkDotNet v0.16.0-custom.20260127.101, Windows 11 (10.0.26100.7840/24H2/2024Update/HudsonValley)
Intel Core i9-14900K 3.20GHz, 1 CPU, 32 logical and 24 physical cores
```
| Benchmark | Options | Baseline | PR | Ratio | MannWhitney(3ms) |
|-----------|---------|----------|-----|-------|------------------|
| LeadingStrings_BinaryData | None | 4,483 us | 4,365 us | 0.97 | Same |
| LeadingStrings_BinaryData | Compiled | 2,188 us | 2,184 us | 1.00 |
Same |
| LeadingStrings_BinaryData | NonBacktracking | 3,734 us | 3,725 us |
1.00 | Same |
| LeadingStrings_NonAscii Count | None | 913 us | 956 us | 1.05 | Same |
| LeadingStrings_NonAscii Count | Compiled | 244 us | 243 us | 1.00 |
Same |
| LeadingStrings_NonAscii CountIgnoreCase | None | 1,758 us | 1,714 us |
0.98 | Same |
| LeadingStrings_NonAscii CountIgnoreCase | Compiled | 258 us | 250 us |
0.97 | Same |
| LeadingStrings_NonAscii Count | NonBacktracking | 392 us | 398 us |
1.02 | Same |
| LeadingStrings_NonAscii CountIgnoreCase | NonBacktracking | 409 us |
431 us | 1.05 | Same |
Binary didn't regress even though it's ASCII with non English
frequencies because the pattern has chars that are not particularly
common in English, so it uses the old codepath. It's hard to hypothesize
about what one might search for in a binary file; searching for a bunch
of leading lower case ASCII chars might regress somewhat. We've never
particularly tested on binary before, and I don't recall seeing any bugs
mentioning binary, so I don't think this is particularly interesting.
NonASCII didn't regress since as previously mentioned, the non ASCII
leading chars in the pattern (presumably likely for any searching of non
ASCII text) causes it to choose the existing codepath.
All MannWhitney tests report Same -- no regressions on binary or
non-ASCII input.
---------
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
iremyux pushed a commit to iremyux/dotnet-runtime that referenced this pull request Mar 2, 2026
…otnet#124736)
I thought it would be interesting to see whether AI could take another
look at the [commented out search
strategy](https://github.com/dotnet/runtime/blob/99b76018b6e4/src/libraries/System.Text.RegularExpressions/src/System/Text/RegularExpressions/RegexFindOptimizations.cs#L152-L161)
originally introduced by @stephentoub in dotnet#98791 to see whether we can
enable it and keep the wins without the regressions that caused it to be
commented out.
AI tried various experiments, and got to a dead end. I recalled the
frequency table approach that Ripgrep uses (credit to @BurntSushi).
Turns out that fixes the regressions entirely. This means our engine now
has assumptions built in about char frequencies in ASCII (only) text.
That's an approach that's been proven in ripgrep, one of the fastest
engines, for 10 years, and it turns out to work OK for regex-redux as
well because a, c, g, t are relatively high frequency in English anyway.
Code unchanged if pattern has anything other than ASCII (see benchmark
results below).
This gives us a nice win on regex-redux, a few other wins in existing
tests, and no regressions.
Note: a char frequency table already existed in `RegexPrefixAnalyzer.cs`
for ranking which fixed-distance character sets are most selective. Our
new table serves a different purpose: deciding whether to use
`LeadingStrings` vs `FixedDistanceSets` at all. The two are
complementary.
====
When a regex has multiple alternation prefixes (e.g. `a|b|c|...`), this
change decides whether to use `SearchValues<string>`
(Teddy/Aho-Corasick) or fall through to `FixedDistanceSets`
(`IndexOfAny`) based on the frequency of the starting characters.
High-frequency starters (common letters like lowercase vowels) benefit
from multi-string search; low-frequency starters (uppercase, digits,
rare consonants) are already excellent `IndexOfAny` filters. Non-ASCII
starters bail out (no frequency data), preserving baseline behavior.
## Benchmark results (444 benchmarks, BDN A/B with --statisticalTest
3ms)
| Benchmark | Baseline | PR | Ratio | Verdict |
|-----------|----------|-----|-------|---------|
| RegexRedux_1 (Compiled) | 25.77ms | 14.27ms | **1.81x faster** |
Faster |
| Leipzig Tom.*river (Compiled) | 6.13ms | 1.87ms | **3.28x faster** |
Faster |
| RegexRedux_5 (Compiled) | 2.83ms | 2.35ms | **1.20x faster** | Faster
|
| Sherlock, BinaryData, BoostDocs, Mariomkas, SliceSlice | -- | -- | --
| Same |
| LeadingStrings_NonAscii (all variants) | -- | -- | -- | Same |
| LeadingStrings_BinaryData (all variants) | -- | -- | -- | Same |
Leipzig win is because the pattern is
`Tom.{10,25}river|river.{10,25}Tom` so there is a short prefix that is
common in the text; with this change it notices `r` is common and `T`
fairly common in English text, so it switches to `SearchValues` which
looks for `Tom` and `river` simultaneously, causing far fewer false
starts.
regex-redux win is because it was previously looking for short, very
common prefixes naively, and now (specifically because the pattern chars
are common) it changed to use `SearchValues` (ie Aho-Corasick/Teddy) to
search for the longer strings simultaneously.
No regressions detected. All MannWhitney tests report Same for
non-improved benchmarks.
## Key design decisions
- **Frequency table**: First 128 entries of Rust's `BYTE_FREQUENCIES`
from @BurntSushi's aho-corasick crate
- **Threshold**: Average rank >= 200 triggers `LeadingStrings`; below
200 falls through to `FixedDistanceSets`
- **Non-ASCII**: Returns false (no frequency data), so the heuristic
does not engage and behavior is unchanged
Companion benchmarks: dotnet/performance#5126
## New benchmark results (not yet in dotnet/performance, won't be picked
up by PR bot)
These benchmarks are from the companion PR
dotnet/performance#5126.
```
BenchmarkDotNet v0.16.0-custom.20260127.101, Windows 11 (10.0.26100.7840/24H2/2024Update/HudsonValley)
Intel Core i9-14900K 3.20GHz, 1 CPU, 32 logical and 24 physical cores
```
| Benchmark | Options | Baseline | PR | Ratio | MannWhitney(3ms) |
|-----------|---------|----------|-----|-------|------------------|
| LeadingStrings_BinaryData | None | 4,483 us | 4,365 us | 0.97 | Same |
| LeadingStrings_BinaryData | Compiled | 2,188 us | 2,184 us | 1.00 |
Same |
| LeadingStrings_BinaryData | NonBacktracking | 3,734 us | 3,725 us |
1.00 | Same |
| LeadingStrings_NonAscii Count | None | 913 us | 956 us | 1.05 | Same |
| LeadingStrings_NonAscii Count | Compiled | 244 us | 243 us | 1.00 |
Same |
| LeadingStrings_NonAscii CountIgnoreCase | None | 1,758 us | 1,714 us |
0.98 | Same |
| LeadingStrings_NonAscii CountIgnoreCase | Compiled | 258 us | 250 us |
0.97 | Same |
| LeadingStrings_NonAscii Count | NonBacktracking | 392 us | 398 us |
1.02 | Same |
| LeadingStrings_NonAscii CountIgnoreCase | NonBacktracking | 409 us |
431 us | 1.05 | Same |
Binary didn't regress even though it's ASCII with non English
frequencies because the pattern has chars that are not particularly
common in English, so it uses the old codepath. It's hard to hypothesize
about what one might search for in a binary file; searching for a bunch
of leading lower case ASCII chars might regress somewhat. We've never
particularly tested on binary before, and I don't recall seeing any bugs
mentioning binary, so I don't think this is particularly interesting.
NonASCII didn't regress since as previously mentioned, the non ASCII
leading chars in the pattern (presumably likely for any searching of non
ASCII text) causes it to choose the existing codepath.
All MannWhitney tests report Same -- no regressions on binary or
non-ASCII input.
---------
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Sign up for freeto 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.

3 participants

@danmoseley@stephentoub
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Strip utm_, fbclid, gclid, etc. from all links on page\n(function() {\n var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content',\n 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid',\n 'ref', 'ref_src', 'source', 'medium', 'campaign'];\n \n function cleanUrl(url) {\n try {\n var u = new URL(url, window.location.origin);\n var changed = false;\n trackingParams.forEach(function(p) {\n if (u.searchParams.has(p)) {\n u.searchParams.delete(p);\n changed = true;\n }\n });\n return changed ? u.toString() : url;\n } catch (e) {\n return url;\n }\n }\n \n function cleanLinks() {\n document.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n \n cleanLinks();\n \n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1) {\n if (node.tagName === 'A') cleanLinks();\n node.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Remove Tracking Parameters from Links"); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + '
Skip to content

Add LeadingStrings benchmarks for binary and non-ASCII regex patterns - #5126

Merged
danmoseley merged 3 commits into
dotnet:mainfrom
danmoseley:regex-redux/benchmarks
Feb 24, 2026
Merged

Add LeadingStrings benchmarks for binary and non-ASCII regex patterns#5126
danmoseley merged 3 commits into
dotnet:mainfrom
danmoseley:regex-redux/benchmarks

Conversation

@danmoseley

@danmoseleydanmoseley commented Feb 23, 2026

Copy link
Copy Markdown
Contributor

Adds two new benchmark classes to exercise the LeadingStrings vs FixedDistanceSets heuristic in the regex engine:

  • Perf_Regex_LeadingStrings_BinaryData: 1MB binary corpus (PE-header-like seed duplicated), alternation of binary patterns. Validates no regression on non-text input. (Lots of ASCII here, but obviously not English frequencies.)
  • Perf_Regex_LeadingStrings_NonAscii: ~100KB Russian text (Anna Karenina opening), alternation of Russian words. Validates no regression on non-ASCII text where the frequency heuristic bails out.

Companion to dotnet/runtime change: dotnet/runtime#124736

CopilotAI 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 adds two new benchmark classes to test regex alternation pattern performance with different input types. The benchmarks are designed to validate that the LeadingStrings vs FixedDistanceSets heuristic in the regex engine doesn't regress on binary and non-ASCII data.

Changes:

  • Added Perf_Regex_LeadingStrings_BinaryData class with benchmarks for binary data patterns (1MB PE-header-like corpus)
  • Added Perf_Regex_LeadingStrings_NonAscii class with benchmarks for Russian text patterns (~100KB from Anna Karenina)

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Dan Moseleyand others added 3 commits February 23, 2026 20:26
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Remove Perf_Regex_LeadingStrings_BinaryData, keeping only the non-ASCII benchmark.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Port curated/01-literal and curated/02-literal-alternate benchmarks from
https://github.com/BurntSushi/rebar for Russian and Chinese text.
Haystacks are OpenSubtitles data (https://opus.nlpl.eu/OpenSubtitles-v2018.php).
This replaces the ad-hoc NonAscii benchmark with well-established cross-engine
regex benchmarks that test literal and alternation search on non-ASCII text.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
@danmoseley
danmoseleyforce-pushed the regex-redux/benchmarks branch from 3a03edd to a3f6116CompareFebruary 24, 2026 05:06
@danmoseley

Copy link
Copy Markdown
ContributorAuthor

Benchmark results comparing main (base) vs 21a68d75 (test: "Skip frequency heuristic for single-char FixedDistanceSets")

Built from dotnet/runtime repo. Mann-Whitney U test, two-sided.

LangPatternOptionsBase(us)Test(us)Ratiop-valueSig
ChineseliteralCompiled25.4525.801.010.490
ChineseliteralNonBacktracking24.2714.940.62<0.001***
ChineseliteralNone21.9223.071.050.001**
ChinesealternationCompiled25.1327.311.090.044*
ChinesealternationNonBacktracking31.0328.870.930.378
ChinesealternationNone45.9344.640.970.023*
Russian(?i) literalCompiled110.39110.951.000.322
Russian(?i) literalNonBacktracking161.59160.010.990.106
Russian(?i) literalNone190.67191.381.000.120
Russian(?i) alternationCompiled1,126.601,152.901.020.044*
Russian(?i) alternationNonBacktracking7,629.457,767.191.02<0.001***
Russian(?i) alternationNone11,849.6411,796.661.000.071
RussianliteralCompiled48.7949.881.020.543
RussianliteralNonBacktracking81.0088.571.090.102
RussianliteralNone58.2757.690.990.867
RussianalternationCompiled1,214.601,228.941.010.086
RussianalternationNonBacktracking2,331.083,208.261.38<0.001***
RussianalternationNone3,074.013,164.351.030.017*

* p<0.05, ** p<0.01, *** p<0.001

@danmoseley

Copy link
Copy Markdown
ContributorAuthor

Hmm, speedups are nice except I wouldn't expect any as this is non ASCII

@danmoseley

Copy link
Copy Markdown
ContributorAuthor

Reran with more iterations and the tests are stable and at parity. The issue is some of them are super short (a few MB all in CPU cache doing IndexOf with SIMD) so I needed to do more iterations, which I guess the perf lab would. Merging.

@danmoseley
danmoseley merged commit 24575dc into dotnet:mainFeb 24, 2026
74 checks passed
@danmoseley
danmoseley deleted the regex-redux/benchmarks branch February 24, 2026 05:58
danmoseley pushed a commit to dotnet/runtime that referenced this pull request Feb 25, 2026
…124736)
I thought it would be interesting to see whether AI could take another
look at the [commented out search
strategy](https://github.com/dotnet/runtime/blob/99b76018b6e4/src/libraries/System.Text.RegularExpressions/src/System/Text/RegularExpressions/RegexFindOptimizations.cs#L152-L161)
originally introduced by @stephentoub in #98791 to see whether we can
enable it and keep the wins without the regressions that caused it to be
commented out.
AI tried various experiments, and got to a dead end. I recalled the
frequency table approach that Ripgrep uses (credit to @BurntSushi).
Turns out that fixes the regressions entirely. This means our engine now
has assumptions built in about char frequencies in ASCII (only) text.
That's an approach that's been proven in ripgrep, one of the fastest
engines, for 10 years, and it turns out to work OK for regex-redux as
well because a, c, g, t are relatively high frequency in English anyway.
Code unchanged if pattern has anything other than ASCII (see benchmark
results below).
This gives us a nice win on regex-redux, a few other wins in existing
tests, and no regressions.
Note: a char frequency table already existed in `RegexPrefixAnalyzer.cs`
for ranking which fixed-distance character sets are most selective. Our
new table serves a different purpose: deciding whether to use
`LeadingStrings` vs `FixedDistanceSets` at all. The two are
complementary.
====
When a regex has multiple alternation prefixes (e.g. `a|b|c|...`), this
change decides whether to use `SearchValues<string>`
(Teddy/Aho-Corasick) or fall through to `FixedDistanceSets`
(`IndexOfAny`) based on the frequency of the starting characters.
High-frequency starters (common letters like lowercase vowels) benefit
from multi-string search; low-frequency starters (uppercase, digits,
rare consonants) are already excellent `IndexOfAny` filters. Non-ASCII
starters bail out (no frequency data), preserving baseline behavior.
## Benchmark results (444 benchmarks, BDN A/B with --statisticalTest
3ms)
| Benchmark | Baseline | PR | Ratio | Verdict |
|-----------|----------|-----|-------|---------|
| RegexRedux_1 (Compiled) | 25.77ms | 14.27ms | **1.81x faster** |
Faster |
| Leipzig Tom.*river (Compiled) | 6.13ms | 1.87ms | **3.28x faster** |
Faster |
| RegexRedux_5 (Compiled) | 2.83ms | 2.35ms | **1.20x faster** | Faster
|
| Sherlock, BinaryData, BoostDocs, Mariomkas, SliceSlice | -- | -- | --
| Same |
| LeadingStrings_NonAscii (all variants) | -- | -- | -- | Same |
| LeadingStrings_BinaryData (all variants) | -- | -- | -- | Same |
Leipzig win is because the pattern is
`Tom.{10,25}river|river.{10,25}Tom` so there is a short prefix that is
common in the text; with this change it notices `r` is common and `T`
fairly common in English text, so it switches to `SearchValues` which
looks for `Tom` and `river` simultaneously, causing far fewer false
starts.
regex-redux win is because it was previously looking for short, very
common prefixes naively, and now (specifically because the pattern chars
are common) it changed to use `SearchValues` (ie Aho-Corasick/Teddy) to
search for the longer strings simultaneously.
No regressions detected. All MannWhitney tests report Same for
non-improved benchmarks.
## Key design decisions
- **Frequency table**: First 128 entries of Rust's `BYTE_FREQUENCIES`
from @BurntSushi's aho-corasick crate
- **Threshold**: Average rank >= 200 triggers `LeadingStrings`; below
200 falls through to `FixedDistanceSets`
- **Non-ASCII**: Returns false (no frequency data), so the heuristic
does not engage and behavior is unchanged
Companion benchmarks: dotnet/performance#5126
## New benchmark results (not yet in dotnet/performance, won't be picked
up by PR bot)
These benchmarks are from the companion PR
dotnet/performance#5126.
```
BenchmarkDotNet v0.16.0-custom.20260127.101, Windows 11 (10.0.26100.7840/24H2/2024Update/HudsonValley)
Intel Core i9-14900K 3.20GHz, 1 CPU, 32 logical and 24 physical cores
```
| Benchmark | Options | Baseline | PR | Ratio | MannWhitney(3ms) |
|-----------|---------|----------|-----|-------|------------------|
| LeadingStrings_BinaryData | None | 4,483 us | 4,365 us | 0.97 | Same |
| LeadingStrings_BinaryData | Compiled | 2,188 us | 2,184 us | 1.00 |
Same |
| LeadingStrings_BinaryData | NonBacktracking | 3,734 us | 3,725 us |
1.00 | Same |
| LeadingStrings_NonAscii Count | None | 913 us | 956 us | 1.05 | Same |
| LeadingStrings_NonAscii Count | Compiled | 244 us | 243 us | 1.00 |
Same |
| LeadingStrings_NonAscii CountIgnoreCase | None | 1,758 us | 1,714 us |
0.98 | Same |
| LeadingStrings_NonAscii CountIgnoreCase | Compiled | 258 us | 250 us |
0.97 | Same |
| LeadingStrings_NonAscii Count | NonBacktracking | 392 us | 398 us |
1.02 | Same |
| LeadingStrings_NonAscii CountIgnoreCase | NonBacktracking | 409 us |
431 us | 1.05 | Same |
Binary didn't regress even though it's ASCII with non English
frequencies because the pattern has chars that are not particularly
common in English, so it uses the old codepath. It's hard to hypothesize
about what one might search for in a binary file; searching for a bunch
of leading lower case ASCII chars might regress somewhat. We've never
particularly tested on binary before, and I don't recall seeing any bugs
mentioning binary, so I don't think this is particularly interesting.
NonASCII didn't regress since as previously mentioned, the non ASCII
leading chars in the pattern (presumably likely for any searching of non
ASCII text) causes it to choose the existing codepath.
All MannWhitney tests report Same -- no regressions on binary or
non-ASCII input.
---------
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
iremyux pushed a commit to iremyux/dotnet-runtime that referenced this pull request Mar 2, 2026
…otnet#124736)
I thought it would be interesting to see whether AI could take another
look at the [commented out search
strategy](https://github.com/dotnet/runtime/blob/99b76018b6e4/src/libraries/System.Text.RegularExpressions/src/System/Text/RegularExpressions/RegexFindOptimizations.cs#L152-L161)
originally introduced by @stephentoub in dotnet#98791 to see whether we can
enable it and keep the wins without the regressions that caused it to be
commented out.
AI tried various experiments, and got to a dead end. I recalled the
frequency table approach that Ripgrep uses (credit to @BurntSushi).
Turns out that fixes the regressions entirely. This means our engine now
has assumptions built in about char frequencies in ASCII (only) text.
That's an approach that's been proven in ripgrep, one of the fastest
engines, for 10 years, and it turns out to work OK for regex-redux as
well because a, c, g, t are relatively high frequency in English anyway.
Code unchanged if pattern has anything other than ASCII (see benchmark
results below).
This gives us a nice win on regex-redux, a few other wins in existing
tests, and no regressions.
Note: a char frequency table already existed in `RegexPrefixAnalyzer.cs`
for ranking which fixed-distance character sets are most selective. Our
new table serves a different purpose: deciding whether to use
`LeadingStrings` vs `FixedDistanceSets` at all. The two are
complementary.
====
When a regex has multiple alternation prefixes (e.g. `a|b|c|...`), this
change decides whether to use `SearchValues<string>`
(Teddy/Aho-Corasick) or fall through to `FixedDistanceSets`
(`IndexOfAny`) based on the frequency of the starting characters.
High-frequency starters (common letters like lowercase vowels) benefit
from multi-string search; low-frequency starters (uppercase, digits,
rare consonants) are already excellent `IndexOfAny` filters. Non-ASCII
starters bail out (no frequency data), preserving baseline behavior.
## Benchmark results (444 benchmarks, BDN A/B with --statisticalTest
3ms)
| Benchmark | Baseline | PR | Ratio | Verdict |
|-----------|----------|-----|-------|---------|
| RegexRedux_1 (Compiled) | 25.77ms | 14.27ms | **1.81x faster** |
Faster |
| Leipzig Tom.*river (Compiled) | 6.13ms | 1.87ms | **3.28x faster** |
Faster |
| RegexRedux_5 (Compiled) | 2.83ms | 2.35ms | **1.20x faster** | Faster
|
| Sherlock, BinaryData, BoostDocs, Mariomkas, SliceSlice | -- | -- | --
| Same |
| LeadingStrings_NonAscii (all variants) | -- | -- | -- | Same |
| LeadingStrings_BinaryData (all variants) | -- | -- | -- | Same |
Leipzig win is because the pattern is
`Tom.{10,25}river|river.{10,25}Tom` so there is a short prefix that is
common in the text; with this change it notices `r` is common and `T`
fairly common in English text, so it switches to `SearchValues` which
looks for `Tom` and `river` simultaneously, causing far fewer false
starts.
regex-redux win is because it was previously looking for short, very
common prefixes naively, and now (specifically because the pattern chars
are common) it changed to use `SearchValues` (ie Aho-Corasick/Teddy) to
search for the longer strings simultaneously.
No regressions detected. All MannWhitney tests report Same for
non-improved benchmarks.
## Key design decisions
- **Frequency table**: First 128 entries of Rust's `BYTE_FREQUENCIES`
from @BurntSushi's aho-corasick crate
- **Threshold**: Average rank >= 200 triggers `LeadingStrings`; below
200 falls through to `FixedDistanceSets`
- **Non-ASCII**: Returns false (no frequency data), so the heuristic
does not engage and behavior is unchanged
Companion benchmarks: dotnet/performance#5126
## New benchmark results (not yet in dotnet/performance, won't be picked
up by PR bot)
These benchmarks are from the companion PR
dotnet/performance#5126.
```
BenchmarkDotNet v0.16.0-custom.20260127.101, Windows 11 (10.0.26100.7840/24H2/2024Update/HudsonValley)
Intel Core i9-14900K 3.20GHz, 1 CPU, 32 logical and 24 physical cores
```
| Benchmark | Options | Baseline | PR | Ratio | MannWhitney(3ms) |
|-----------|---------|----------|-----|-------|------------------|
| LeadingStrings_BinaryData | None | 4,483 us | 4,365 us | 0.97 | Same |
| LeadingStrings_BinaryData | Compiled | 2,188 us | 2,184 us | 1.00 |
Same |
| LeadingStrings_BinaryData | NonBacktracking | 3,734 us | 3,725 us |
1.00 | Same |
| LeadingStrings_NonAscii Count | None | 913 us | 956 us | 1.05 | Same |
| LeadingStrings_NonAscii Count | Compiled | 244 us | 243 us | 1.00 |
Same |
| LeadingStrings_NonAscii CountIgnoreCase | None | 1,758 us | 1,714 us |
0.98 | Same |
| LeadingStrings_NonAscii CountIgnoreCase | Compiled | 258 us | 250 us |
0.97 | Same |
| LeadingStrings_NonAscii Count | NonBacktracking | 392 us | 398 us |
1.02 | Same |
| LeadingStrings_NonAscii CountIgnoreCase | NonBacktracking | 409 us |
431 us | 1.05 | Same |
Binary didn't regress even though it's ASCII with non English
frequencies because the pattern has chars that are not particularly
common in English, so it uses the old codepath. It's hard to hypothesize
about what one might search for in a binary file; searching for a bunch
of leading lower case ASCII chars might regress somewhat. We've never
particularly tested on binary before, and I don't recall seeing any bugs
mentioning binary, so I don't think this is particularly interesting.
NonASCII didn't regress since as previously mentioned, the non ASCII
leading chars in the pattern (presumably likely for any searching of non
ASCII text) causes it to choose the existing codepath.
All MannWhitney tests report Same -- no regressions on binary or
non-ASCII input.
---------
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Sign up for freeto 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.

3 participants

@danmoseley@stephentoub
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Auto-enable theater mode on YouTube\n(function() {\n function tryTheater() {\n var btn = document.querySelector('button[aria-label=\"Theater mode\"], ytd-player #player button[title=\"Theater mode\"]');\n if (btn && !btn.classList.contains('activated')) {\n btn.click();\n }\n }\n \n // Try immediately\n tryTheater();\n \n // Try after navigation (SPA)\n var lastUrl = location.href;\n setInterval(function() {\n if (location.href !== lastUrl) {\n lastUrl = location.href;\n setTimeout(tryTheater, 500);\n }\n }, 1000);\n \n // Also try on player load\n var observer = new MutationObserver(tryTheater);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "YouTube Theater Mode Default"); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

Add LeadingStrings benchmarks for binary and non-ASCII regex patterns - #5126

Merged
danmoseley merged 3 commits into
dotnet:mainfrom
danmoseley:regex-redux/benchmarks
Feb 24, 2026
Merged

Add LeadingStrings benchmarks for binary and non-ASCII regex patterns#5126
danmoseley merged 3 commits into
dotnet:mainfrom
danmoseley:regex-redux/benchmarks

Conversation

@danmoseley

@danmoseleydanmoseley commented Feb 23, 2026

Copy link
Copy Markdown
Contributor

Adds two new benchmark classes to exercise the LeadingStrings vs FixedDistanceSets heuristic in the regex engine:

  • Perf_Regex_LeadingStrings_BinaryData: 1MB binary corpus (PE-header-like seed duplicated), alternation of binary patterns. Validates no regression on non-text input. (Lots of ASCII here, but obviously not English frequencies.)
  • Perf_Regex_LeadingStrings_NonAscii: ~100KB Russian text (Anna Karenina opening), alternation of Russian words. Validates no regression on non-ASCII text where the frequency heuristic bails out.

Companion to dotnet/runtime change: dotnet/runtime#124736

CopilotAI 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 adds two new benchmark classes to test regex alternation pattern performance with different input types. The benchmarks are designed to validate that the LeadingStrings vs FixedDistanceSets heuristic in the regex engine doesn't regress on binary and non-ASCII data.

Changes:

  • Added Perf_Regex_LeadingStrings_BinaryData class with benchmarks for binary data patterns (1MB PE-header-like corpus)
  • Added Perf_Regex_LeadingStrings_NonAscii class with benchmarks for Russian text patterns (~100KB from Anna Karenina)

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Dan Moseleyand others added 3 commits February 23, 2026 20:26
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Remove Perf_Regex_LeadingStrings_BinaryData, keeping only the non-ASCII benchmark.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Port curated/01-literal and curated/02-literal-alternate benchmarks from
https://github.com/BurntSushi/rebar for Russian and Chinese text.
Haystacks are OpenSubtitles data (https://opus.nlpl.eu/OpenSubtitles-v2018.php).
This replaces the ad-hoc NonAscii benchmark with well-established cross-engine
regex benchmarks that test literal and alternation search on non-ASCII text.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
@danmoseley
danmoseleyforce-pushed the regex-redux/benchmarks branch from 3a03edd to a3f6116CompareFebruary 24, 2026 05:06
@danmoseley

Copy link
Copy Markdown
ContributorAuthor

Benchmark results comparing main (base) vs 21a68d75 (test: "Skip frequency heuristic for single-char FixedDistanceSets")

Built from dotnet/runtime repo. Mann-Whitney U test, two-sided.

LangPatternOptionsBase(us)Test(us)Ratiop-valueSig
ChineseliteralCompiled25.4525.801.010.490
ChineseliteralNonBacktracking24.2714.940.62<0.001***
ChineseliteralNone21.9223.071.050.001**
ChinesealternationCompiled25.1327.311.090.044*
ChinesealternationNonBacktracking31.0328.870.930.378
ChinesealternationNone45.9344.640.970.023*
Russian(?i) literalCompiled110.39110.951.000.322
Russian(?i) literalNonBacktracking161.59160.010.990.106
Russian(?i) literalNone190.67191.381.000.120
Russian(?i) alternationCompiled1,126.601,152.901.020.044*
Russian(?i) alternationNonBacktracking7,629.457,767.191.02<0.001***
Russian(?i) alternationNone11,849.6411,796.661.000.071
RussianliteralCompiled48.7949.881.020.543
RussianliteralNonBacktracking81.0088.571.090.102
RussianliteralNone58.2757.690.990.867
RussianalternationCompiled1,214.601,228.941.010.086
RussianalternationNonBacktracking2,331.083,208.261.38<0.001***
RussianalternationNone3,074.013,164.351.030.017*

* p<0.05, ** p<0.01, *** p<0.001

@danmoseley

Copy link
Copy Markdown
ContributorAuthor

Hmm, speedups are nice except I wouldn't expect any as this is non ASCII

@danmoseley

Copy link
Copy Markdown
ContributorAuthor

Reran with more iterations and the tests are stable and at parity. The issue is some of them are super short (a few MB all in CPU cache doing IndexOf with SIMD) so I needed to do more iterations, which I guess the perf lab would. Merging.

@danmoseley
danmoseley merged commit 24575dc into dotnet:mainFeb 24, 2026
74 checks passed
@danmoseley
danmoseley deleted the regex-redux/benchmarks branch February 24, 2026 05:58
danmoseley pushed a commit to dotnet/runtime that referenced this pull request Feb 25, 2026
…124736)
I thought it would be interesting to see whether AI could take another
look at the [commented out search
strategy](https://github.com/dotnet/runtime/blob/99b76018b6e4/src/libraries/System.Text.RegularExpressions/src/System/Text/RegularExpressions/RegexFindOptimizations.cs#L152-L161)
originally introduced by @stephentoub in #98791 to see whether we can
enable it and keep the wins without the regressions that caused it to be
commented out.
AI tried various experiments, and got to a dead end. I recalled the
frequency table approach that Ripgrep uses (credit to @BurntSushi).
Turns out that fixes the regressions entirely. This means our engine now
has assumptions built in about char frequencies in ASCII (only) text.
That's an approach that's been proven in ripgrep, one of the fastest
engines, for 10 years, and it turns out to work OK for regex-redux as
well because a, c, g, t are relatively high frequency in English anyway.
Code unchanged if pattern has anything other than ASCII (see benchmark
results below).
This gives us a nice win on regex-redux, a few other wins in existing
tests, and no regressions.
Note: a char frequency table already existed in `RegexPrefixAnalyzer.cs`
for ranking which fixed-distance character sets are most selective. Our
new table serves a different purpose: deciding whether to use
`LeadingStrings` vs `FixedDistanceSets` at all. The two are
complementary.
====
When a regex has multiple alternation prefixes (e.g. `a|b|c|...`), this
change decides whether to use `SearchValues<string>`
(Teddy/Aho-Corasick) or fall through to `FixedDistanceSets`
(`IndexOfAny`) based on the frequency of the starting characters.
High-frequency starters (common letters like lowercase vowels) benefit
from multi-string search; low-frequency starters (uppercase, digits,
rare consonants) are already excellent `IndexOfAny` filters. Non-ASCII
starters bail out (no frequency data), preserving baseline behavior.
## Benchmark results (444 benchmarks, BDN A/B with --statisticalTest
3ms)
| Benchmark | Baseline | PR | Ratio | Verdict |
|-----------|----------|-----|-------|---------|
| RegexRedux_1 (Compiled) | 25.77ms | 14.27ms | **1.81x faster** |
Faster |
| Leipzig Tom.*river (Compiled) | 6.13ms | 1.87ms | **3.28x faster** |
Faster |
| RegexRedux_5 (Compiled) | 2.83ms | 2.35ms | **1.20x faster** | Faster
|
| Sherlock, BinaryData, BoostDocs, Mariomkas, SliceSlice | -- | -- | --
| Same |
| LeadingStrings_NonAscii (all variants) | -- | -- | -- | Same |
| LeadingStrings_BinaryData (all variants) | -- | -- | -- | Same |
Leipzig win is because the pattern is
`Tom.{10,25}river|river.{10,25}Tom` so there is a short prefix that is
common in the text; with this change it notices `r` is common and `T`
fairly common in English text, so it switches to `SearchValues` which
looks for `Tom` and `river` simultaneously, causing far fewer false
starts.
regex-redux win is because it was previously looking for short, very
common prefixes naively, and now (specifically because the pattern chars
are common) it changed to use `SearchValues` (ie Aho-Corasick/Teddy) to
search for the longer strings simultaneously.
No regressions detected. All MannWhitney tests report Same for
non-improved benchmarks.
## Key design decisions
- **Frequency table**: First 128 entries of Rust's `BYTE_FREQUENCIES`
from @BurntSushi's aho-corasick crate
- **Threshold**: Average rank >= 200 triggers `LeadingStrings`; below
200 falls through to `FixedDistanceSets`
- **Non-ASCII**: Returns false (no frequency data), so the heuristic
does not engage and behavior is unchanged
Companion benchmarks: dotnet/performance#5126
## New benchmark results (not yet in dotnet/performance, won't be picked
up by PR bot)
These benchmarks are from the companion PR
dotnet/performance#5126.
```
BenchmarkDotNet v0.16.0-custom.20260127.101, Windows 11 (10.0.26100.7840/24H2/2024Update/HudsonValley)
Intel Core i9-14900K 3.20GHz, 1 CPU, 32 logical and 24 physical cores
```
| Benchmark | Options | Baseline | PR | Ratio | MannWhitney(3ms) |
|-----------|---------|----------|-----|-------|------------------|
| LeadingStrings_BinaryData | None | 4,483 us | 4,365 us | 0.97 | Same |
| LeadingStrings_BinaryData | Compiled | 2,188 us | 2,184 us | 1.00 |
Same |
| LeadingStrings_BinaryData | NonBacktracking | 3,734 us | 3,725 us |
1.00 | Same |
| LeadingStrings_NonAscii Count | None | 913 us | 956 us | 1.05 | Same |
| LeadingStrings_NonAscii Count | Compiled | 244 us | 243 us | 1.00 |
Same |
| LeadingStrings_NonAscii CountIgnoreCase | None | 1,758 us | 1,714 us |
0.98 | Same |
| LeadingStrings_NonAscii CountIgnoreCase | Compiled | 258 us | 250 us |
0.97 | Same |
| LeadingStrings_NonAscii Count | NonBacktracking | 392 us | 398 us |
1.02 | Same |
| LeadingStrings_NonAscii CountIgnoreCase | NonBacktracking | 409 us |
431 us | 1.05 | Same |
Binary didn't regress even though it's ASCII with non English
frequencies because the pattern has chars that are not particularly
common in English, so it uses the old codepath. It's hard to hypothesize
about what one might search for in a binary file; searching for a bunch
of leading lower case ASCII chars might regress somewhat. We've never
particularly tested on binary before, and I don't recall seeing any bugs
mentioning binary, so I don't think this is particularly interesting.
NonASCII didn't regress since as previously mentioned, the non ASCII
leading chars in the pattern (presumably likely for any searching of non
ASCII text) causes it to choose the existing codepath.
All MannWhitney tests report Same -- no regressions on binary or
non-ASCII input.
---------
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
iremyux pushed a commit to iremyux/dotnet-runtime that referenced this pull request Mar 2, 2026
…otnet#124736)
I thought it would be interesting to see whether AI could take another
look at the [commented out search
strategy](https://github.com/dotnet/runtime/blob/99b76018b6e4/src/libraries/System.Text.RegularExpressions/src/System/Text/RegularExpressions/RegexFindOptimizations.cs#L152-L161)
originally introduced by @stephentoub in dotnet#98791 to see whether we can
enable it and keep the wins without the regressions that caused it to be
commented out.
AI tried various experiments, and got to a dead end. I recalled the
frequency table approach that Ripgrep uses (credit to @BurntSushi).
Turns out that fixes the regressions entirely. This means our engine now
has assumptions built in about char frequencies in ASCII (only) text.
That's an approach that's been proven in ripgrep, one of the fastest
engines, for 10 years, and it turns out to work OK for regex-redux as
well because a, c, g, t are relatively high frequency in English anyway.
Code unchanged if pattern has anything other than ASCII (see benchmark
results below).
This gives us a nice win on regex-redux, a few other wins in existing
tests, and no regressions.
Note: a char frequency table already existed in `RegexPrefixAnalyzer.cs`
for ranking which fixed-distance character sets are most selective. Our
new table serves a different purpose: deciding whether to use
`LeadingStrings` vs `FixedDistanceSets` at all. The two are
complementary.
====
When a regex has multiple alternation prefixes (e.g. `a|b|c|...`), this
change decides whether to use `SearchValues<string>`
(Teddy/Aho-Corasick) or fall through to `FixedDistanceSets`
(`IndexOfAny`) based on the frequency of the starting characters.
High-frequency starters (common letters like lowercase vowels) benefit
from multi-string search; low-frequency starters (uppercase, digits,
rare consonants) are already excellent `IndexOfAny` filters. Non-ASCII
starters bail out (no frequency data), preserving baseline behavior.
## Benchmark results (444 benchmarks, BDN A/B with --statisticalTest
3ms)
| Benchmark | Baseline | PR | Ratio | Verdict |
|-----------|----------|-----|-------|---------|
| RegexRedux_1 (Compiled) | 25.77ms | 14.27ms | **1.81x faster** |
Faster |
| Leipzig Tom.*river (Compiled) | 6.13ms | 1.87ms | **3.28x faster** |
Faster |
| RegexRedux_5 (Compiled) | 2.83ms | 2.35ms | **1.20x faster** | Faster
|
| Sherlock, BinaryData, BoostDocs, Mariomkas, SliceSlice | -- | -- | --
| Same |
| LeadingStrings_NonAscii (all variants) | -- | -- | -- | Same |
| LeadingStrings_BinaryData (all variants) | -- | -- | -- | Same |
Leipzig win is because the pattern is
`Tom.{10,25}river|river.{10,25}Tom` so there is a short prefix that is
common in the text; with this change it notices `r` is common and `T`
fairly common in English text, so it switches to `SearchValues` which
looks for `Tom` and `river` simultaneously, causing far fewer false
starts.
regex-redux win is because it was previously looking for short, very
common prefixes naively, and now (specifically because the pattern chars
are common) it changed to use `SearchValues` (ie Aho-Corasick/Teddy) to
search for the longer strings simultaneously.
No regressions detected. All MannWhitney tests report Same for
non-improved benchmarks.
## Key design decisions
- **Frequency table**: First 128 entries of Rust's `BYTE_FREQUENCIES`
from @BurntSushi's aho-corasick crate
- **Threshold**: Average rank >= 200 triggers `LeadingStrings`; below
200 falls through to `FixedDistanceSets`
- **Non-ASCII**: Returns false (no frequency data), so the heuristic
does not engage and behavior is unchanged
Companion benchmarks: dotnet/performance#5126
## New benchmark results (not yet in dotnet/performance, won't be picked
up by PR bot)
These benchmarks are from the companion PR
dotnet/performance#5126.
```
BenchmarkDotNet v0.16.0-custom.20260127.101, Windows 11 (10.0.26100.7840/24H2/2024Update/HudsonValley)
Intel Core i9-14900K 3.20GHz, 1 CPU, 32 logical and 24 physical cores
```
| Benchmark | Options | Baseline | PR | Ratio | MannWhitney(3ms) |
|-----------|---------|----------|-----|-------|------------------|
| LeadingStrings_BinaryData | None | 4,483 us | 4,365 us | 0.97 | Same |
| LeadingStrings_BinaryData | Compiled | 2,188 us | 2,184 us | 1.00 |
Same |
| LeadingStrings_BinaryData | NonBacktracking | 3,734 us | 3,725 us |
1.00 | Same |
| LeadingStrings_NonAscii Count | None | 913 us | 956 us | 1.05 | Same |
| LeadingStrings_NonAscii Count | Compiled | 244 us | 243 us | 1.00 |
Same |
| LeadingStrings_NonAscii CountIgnoreCase | None | 1,758 us | 1,714 us |
0.98 | Same |
| LeadingStrings_NonAscii CountIgnoreCase | Compiled | 258 us | 250 us |
0.97 | Same |
| LeadingStrings_NonAscii Count | NonBacktracking | 392 us | 398 us |
1.02 | Same |
| LeadingStrings_NonAscii CountIgnoreCase | NonBacktracking | 409 us |
431 us | 1.05 | Same |
Binary didn't regress even though it's ASCII with non English
frequencies because the pattern has chars that are not particularly
common in English, so it uses the old codepath. It's hard to hypothesize
about what one might search for in a binary file; searching for a bunch
of leading lower case ASCII chars might regress somewhat. We've never
particularly tested on binary before, and I don't recall seeing any bugs
mentioning binary, so I don't think this is particularly interesting.
NonASCII didn't regress since as previously mentioned, the non ASCII
leading chars in the pattern (presumably likely for any searching of non
ASCII text) causes it to choose the existing codepath.
All MannWhitney tests report Same -- no regressions on binary or
non-ASCII input.
---------
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Sign up for freeto 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.

3 participants

@danmoseley@stephentoub
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Remove or un-stick sticky/fixed headers that block content\n(function() {\n function unstick() {\n document.querySelectorAll('header, nav, [role=\"banner\"], .header, .navbar, .sticky, .fixed-top, [style*=\"position: fixed\"], [style*=\"position:sticky\"]').forEach(function(el) {\n if (el.style.position === 'fixed' || el.style.position === 'sticky' || \n getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') {\n el.style.position = 'static';\n el.style.top = 'auto';\n el.style.zIndex = 'auto';\n }\n });\n }\n \n unstick();\n \n var observer = new MutationObserver(unstick);\n observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] });\n})();", "Kill Sticky Headers"); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

Add LeadingStrings benchmarks for binary and non-ASCII regex patterns - #5126

Merged
danmoseley merged 3 commits into
dotnet:mainfrom
danmoseley:regex-redux/benchmarks
Feb 24, 2026
Merged

Add LeadingStrings benchmarks for binary and non-ASCII regex patterns#5126
danmoseley merged 3 commits into
dotnet:mainfrom
danmoseley:regex-redux/benchmarks

Conversation

@danmoseley

@danmoseleydanmoseley commented Feb 23, 2026

Copy link
Copy Markdown
Contributor

Adds two new benchmark classes to exercise the LeadingStrings vs FixedDistanceSets heuristic in the regex engine:

  • Perf_Regex_LeadingStrings_BinaryData: 1MB binary corpus (PE-header-like seed duplicated), alternation of binary patterns. Validates no regression on non-text input. (Lots of ASCII here, but obviously not English frequencies.)
  • Perf_Regex_LeadingStrings_NonAscii: ~100KB Russian text (Anna Karenina opening), alternation of Russian words. Validates no regression on non-ASCII text where the frequency heuristic bails out.

Companion to dotnet/runtime change: dotnet/runtime#124736

CopilotAI 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 adds two new benchmark classes to test regex alternation pattern performance with different input types. The benchmarks are designed to validate that the LeadingStrings vs FixedDistanceSets heuristic in the regex engine doesn't regress on binary and non-ASCII data.

Changes:

  • Added Perf_Regex_LeadingStrings_BinaryData class with benchmarks for binary data patterns (1MB PE-header-like corpus)
  • Added Perf_Regex_LeadingStrings_NonAscii class with benchmarks for Russian text patterns (~100KB from Anna Karenina)

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Dan Moseleyand others added 3 commits February 23, 2026 20:26
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Remove Perf_Regex_LeadingStrings_BinaryData, keeping only the non-ASCII benchmark.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Port curated/01-literal and curated/02-literal-alternate benchmarks from
https://github.com/BurntSushi/rebar for Russian and Chinese text.
Haystacks are OpenSubtitles data (https://opus.nlpl.eu/OpenSubtitles-v2018.php).
This replaces the ad-hoc NonAscii benchmark with well-established cross-engine
regex benchmarks that test literal and alternation search on non-ASCII text.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
@danmoseley
danmoseleyforce-pushed the regex-redux/benchmarks branch from 3a03edd to a3f6116CompareFebruary 24, 2026 05:06
@danmoseley

Copy link
Copy Markdown
ContributorAuthor

Benchmark results comparing main (base) vs 21a68d75 (test: "Skip frequency heuristic for single-char FixedDistanceSets")

Built from dotnet/runtime repo. Mann-Whitney U test, two-sided.

LangPatternOptionsBase(us)Test(us)Ratiop-valueSig
ChineseliteralCompiled25.4525.801.010.490
ChineseliteralNonBacktracking24.2714.940.62<0.001***
ChineseliteralNone21.9223.071.050.001**
ChinesealternationCompiled25.1327.311.090.044*
ChinesealternationNonBacktracking31.0328.870.930.378
ChinesealternationNone45.9344.640.970.023*
Russian(?i) literalCompiled110.39110.951.000.322
Russian(?i) literalNonBacktracking161.59160.010.990.106
Russian(?i) literalNone190.67191.381.000.120
Russian(?i) alternationCompiled1,126.601,152.901.020.044*
Russian(?i) alternationNonBacktracking7,629.457,767.191.02<0.001***
Russian(?i) alternationNone11,849.6411,796.661.000.071
RussianliteralCompiled48.7949.881.020.543
RussianliteralNonBacktracking81.0088.571.090.102
RussianliteralNone58.2757.690.990.867
RussianalternationCompiled1,214.601,228.941.010.086
RussianalternationNonBacktracking2,331.083,208.261.38<0.001***
RussianalternationNone3,074.013,164.351.030.017*

* p<0.05, ** p<0.01, *** p<0.001

@danmoseley

Copy link
Copy Markdown
ContributorAuthor

Hmm, speedups are nice except I wouldn't expect any as this is non ASCII

@danmoseley

Copy link
Copy Markdown
ContributorAuthor

Reran with more iterations and the tests are stable and at parity. The issue is some of them are super short (a few MB all in CPU cache doing IndexOf with SIMD) so I needed to do more iterations, which I guess the perf lab would. Merging.

@danmoseley
danmoseley merged commit 24575dc into dotnet:mainFeb 24, 2026
74 checks passed
@danmoseley
danmoseley deleted the regex-redux/benchmarks branch February 24, 2026 05:58
danmoseley pushed a commit to dotnet/runtime that referenced this pull request Feb 25, 2026
…124736)
I thought it would be interesting to see whether AI could take another
look at the [commented out search
strategy](https://github.com/dotnet/runtime/blob/99b76018b6e4/src/libraries/System.Text.RegularExpressions/src/System/Text/RegularExpressions/RegexFindOptimizations.cs#L152-L161)
originally introduced by @stephentoub in #98791 to see whether we can
enable it and keep the wins without the regressions that caused it to be
commented out.
AI tried various experiments, and got to a dead end. I recalled the
frequency table approach that Ripgrep uses (credit to @BurntSushi).
Turns out that fixes the regressions entirely. This means our engine now
has assumptions built in about char frequencies in ASCII (only) text.
That's an approach that's been proven in ripgrep, one of the fastest
engines, for 10 years, and it turns out to work OK for regex-redux as
well because a, c, g, t are relatively high frequency in English anyway.
Code unchanged if pattern has anything other than ASCII (see benchmark
results below).
This gives us a nice win on regex-redux, a few other wins in existing
tests, and no regressions.
Note: a char frequency table already existed in `RegexPrefixAnalyzer.cs`
for ranking which fixed-distance character sets are most selective. Our
new table serves a different purpose: deciding whether to use
`LeadingStrings` vs `FixedDistanceSets` at all. The two are
complementary.
====
When a regex has multiple alternation prefixes (e.g. `a|b|c|...`), this
change decides whether to use `SearchValues<string>`
(Teddy/Aho-Corasick) or fall through to `FixedDistanceSets`
(`IndexOfAny`) based on the frequency of the starting characters.
High-frequency starters (common letters like lowercase vowels) benefit
from multi-string search; low-frequency starters (uppercase, digits,
rare consonants) are already excellent `IndexOfAny` filters. Non-ASCII
starters bail out (no frequency data), preserving baseline behavior.
## Benchmark results (444 benchmarks, BDN A/B with --statisticalTest
3ms)
| Benchmark | Baseline | PR | Ratio | Verdict |
|-----------|----------|-----|-------|---------|
| RegexRedux_1 (Compiled) | 25.77ms | 14.27ms | **1.81x faster** |
Faster |
| Leipzig Tom.*river (Compiled) | 6.13ms | 1.87ms | **3.28x faster** |
Faster |
| RegexRedux_5 (Compiled) | 2.83ms | 2.35ms | **1.20x faster** | Faster
|
| Sherlock, BinaryData, BoostDocs, Mariomkas, SliceSlice | -- | -- | --
| Same |
| LeadingStrings_NonAscii (all variants) | -- | -- | -- | Same |
| LeadingStrings_BinaryData (all variants) | -- | -- | -- | Same |
Leipzig win is because the pattern is
`Tom.{10,25}river|river.{10,25}Tom` so there is a short prefix that is
common in the text; with this change it notices `r` is common and `T`
fairly common in English text, so it switches to `SearchValues` which
looks for `Tom` and `river` simultaneously, causing far fewer false
starts.
regex-redux win is because it was previously looking for short, very
common prefixes naively, and now (specifically because the pattern chars
are common) it changed to use `SearchValues` (ie Aho-Corasick/Teddy) to
search for the longer strings simultaneously.
No regressions detected. All MannWhitney tests report Same for
non-improved benchmarks.
## Key design decisions
- **Frequency table**: First 128 entries of Rust's `BYTE_FREQUENCIES`
from @BurntSushi's aho-corasick crate
- **Threshold**: Average rank >= 200 triggers `LeadingStrings`; below
200 falls through to `FixedDistanceSets`
- **Non-ASCII**: Returns false (no frequency data), so the heuristic
does not engage and behavior is unchanged
Companion benchmarks: dotnet/performance#5126
## New benchmark results (not yet in dotnet/performance, won't be picked
up by PR bot)
These benchmarks are from the companion PR
dotnet/performance#5126.
```
BenchmarkDotNet v0.16.0-custom.20260127.101, Windows 11 (10.0.26100.7840/24H2/2024Update/HudsonValley)
Intel Core i9-14900K 3.20GHz, 1 CPU, 32 logical and 24 physical cores
```
| Benchmark | Options | Baseline | PR | Ratio | MannWhitney(3ms) |
|-----------|---------|----------|-----|-------|------------------|
| LeadingStrings_BinaryData | None | 4,483 us | 4,365 us | 0.97 | Same |
| LeadingStrings_BinaryData | Compiled | 2,188 us | 2,184 us | 1.00 |
Same |
| LeadingStrings_BinaryData | NonBacktracking | 3,734 us | 3,725 us |
1.00 | Same |
| LeadingStrings_NonAscii Count | None | 913 us | 956 us | 1.05 | Same |
| LeadingStrings_NonAscii Count | Compiled | 244 us | 243 us | 1.00 |
Same |
| LeadingStrings_NonAscii CountIgnoreCase | None | 1,758 us | 1,714 us |
0.98 | Same |
| LeadingStrings_NonAscii CountIgnoreCase | Compiled | 258 us | 250 us |
0.97 | Same |
| LeadingStrings_NonAscii Count | NonBacktracking | 392 us | 398 us |
1.02 | Same |
| LeadingStrings_NonAscii CountIgnoreCase | NonBacktracking | 409 us |
431 us | 1.05 | Same |
Binary didn't regress even though it's ASCII with non English
frequencies because the pattern has chars that are not particularly
common in English, so it uses the old codepath. It's hard to hypothesize
about what one might search for in a binary file; searching for a bunch
of leading lower case ASCII chars might regress somewhat. We've never
particularly tested on binary before, and I don't recall seeing any bugs
mentioning binary, so I don't think this is particularly interesting.
NonASCII didn't regress since as previously mentioned, the non ASCII
leading chars in the pattern (presumably likely for any searching of non
ASCII text) causes it to choose the existing codepath.
All MannWhitney tests report Same -- no regressions on binary or
non-ASCII input.
---------
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
iremyux pushed a commit to iremyux/dotnet-runtime that referenced this pull request Mar 2, 2026
…otnet#124736)
I thought it would be interesting to see whether AI could take another
look at the [commented out search
strategy](https://github.com/dotnet/runtime/blob/99b76018b6e4/src/libraries/System.Text.RegularExpressions/src/System/Text/RegularExpressions/RegexFindOptimizations.cs#L152-L161)
originally introduced by @stephentoub in dotnet#98791 to see whether we can
enable it and keep the wins without the regressions that caused it to be
commented out.
AI tried various experiments, and got to a dead end. I recalled the
frequency table approach that Ripgrep uses (credit to @BurntSushi).
Turns out that fixes the regressions entirely. This means our engine now
has assumptions built in about char frequencies in ASCII (only) text.
That's an approach that's been proven in ripgrep, one of the fastest
engines, for 10 years, and it turns out to work OK for regex-redux as
well because a, c, g, t are relatively high frequency in English anyway.
Code unchanged if pattern has anything other than ASCII (see benchmark
results below).
This gives us a nice win on regex-redux, a few other wins in existing
tests, and no regressions.
Note: a char frequency table already existed in `RegexPrefixAnalyzer.cs`
for ranking which fixed-distance character sets are most selective. Our
new table serves a different purpose: deciding whether to use
`LeadingStrings` vs `FixedDistanceSets` at all. The two are
complementary.
====
When a regex has multiple alternation prefixes (e.g. `a|b|c|...`), this
change decides whether to use `SearchValues<string>`
(Teddy/Aho-Corasick) or fall through to `FixedDistanceSets`
(`IndexOfAny`) based on the frequency of the starting characters.
High-frequency starters (common letters like lowercase vowels) benefit
from multi-string search; low-frequency starters (uppercase, digits,
rare consonants) are already excellent `IndexOfAny` filters. Non-ASCII
starters bail out (no frequency data), preserving baseline behavior.
## Benchmark results (444 benchmarks, BDN A/B with --statisticalTest
3ms)
| Benchmark | Baseline | PR | Ratio | Verdict |
|-----------|----------|-----|-------|---------|
| RegexRedux_1 (Compiled) | 25.77ms | 14.27ms | **1.81x faster** |
Faster |
| Leipzig Tom.*river (Compiled) | 6.13ms | 1.87ms | **3.28x faster** |
Faster |
| RegexRedux_5 (Compiled) | 2.83ms | 2.35ms | **1.20x faster** | Faster
|
| Sherlock, BinaryData, BoostDocs, Mariomkas, SliceSlice | -- | -- | --
| Same |
| LeadingStrings_NonAscii (all variants) | -- | -- | -- | Same |
| LeadingStrings_BinaryData (all variants) | -- | -- | -- | Same |
Leipzig win is because the pattern is
`Tom.{10,25}river|river.{10,25}Tom` so there is a short prefix that is
common in the text; with this change it notices `r` is common and `T`
fairly common in English text, so it switches to `SearchValues` which
looks for `Tom` and `river` simultaneously, causing far fewer false
starts.
regex-redux win is because it was previously looking for short, very
common prefixes naively, and now (specifically because the pattern chars
are common) it changed to use `SearchValues` (ie Aho-Corasick/Teddy) to
search for the longer strings simultaneously.
No regressions detected. All MannWhitney tests report Same for
non-improved benchmarks.
## Key design decisions
- **Frequency table**: First 128 entries of Rust's `BYTE_FREQUENCIES`
from @BurntSushi's aho-corasick crate
- **Threshold**: Average rank >= 200 triggers `LeadingStrings`; below
200 falls through to `FixedDistanceSets`
- **Non-ASCII**: Returns false (no frequency data), so the heuristic
does not engage and behavior is unchanged
Companion benchmarks: dotnet/performance#5126
## New benchmark results (not yet in dotnet/performance, won't be picked
up by PR bot)
These benchmarks are from the companion PR
dotnet/performance#5126.
```
BenchmarkDotNet v0.16.0-custom.20260127.101, Windows 11 (10.0.26100.7840/24H2/2024Update/HudsonValley)
Intel Core i9-14900K 3.20GHz, 1 CPU, 32 logical and 24 physical cores
```
| Benchmark | Options | Baseline | PR | Ratio | MannWhitney(3ms) |
|-----------|---------|----------|-----|-------|------------------|
| LeadingStrings_BinaryData | None | 4,483 us | 4,365 us | 0.97 | Same |
| LeadingStrings_BinaryData | Compiled | 2,188 us | 2,184 us | 1.00 |
Same |
| LeadingStrings_BinaryData | NonBacktracking | 3,734 us | 3,725 us |
1.00 | Same |
| LeadingStrings_NonAscii Count | None | 913 us | 956 us | 1.05 | Same |
| LeadingStrings_NonAscii Count | Compiled | 244 us | 243 us | 1.00 |
Same |
| LeadingStrings_NonAscii CountIgnoreCase | None | 1,758 us | 1,714 us |
0.98 | Same |
| LeadingStrings_NonAscii CountIgnoreCase | Compiled | 258 us | 250 us |
0.97 | Same |
| LeadingStrings_NonAscii Count | NonBacktracking | 392 us | 398 us |
1.02 | Same |
| LeadingStrings_NonAscii CountIgnoreCase | NonBacktracking | 409 us |
431 us | 1.05 | Same |
Binary didn't regress even though it's ASCII with non English
frequencies because the pattern has chars that are not particularly
common in English, so it uses the old codepath. It's hard to hypothesize
about what one might search for in a binary file; searching for a bunch
of leading lower case ASCII chars might regress somewhat. We've never
particularly tested on binary before, and I don't recall seeing any bugs
mentioning binary, so I don't think this is particularly interesting.
NonASCII didn't regress since as previously mentioned, the non ASCII
leading chars in the pattern (presumably likely for any searching of non
ASCII text) causes it to choose the existing codepath.
All MannWhitney tests report Same -- no regressions on binary or
non-ASCII input.
---------
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Sign up for freeto 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.

3 participants

@danmoseley@stephentoub
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Universal Dark Mode - works on any site\n(function() {\n var enabled = true;\n \n function applyDarkMode() {\n if (!enabled) return;\n \n // Create style element if it doesn't exist\n var style = document.getElementById('universal-dark-mode-style');\n if (!style) {\n style = document.createElement('style');\n style.id = 'universal-dark-mode-style';\n document.head.appendChild(style);\n }\n \n // Dark mode CSS - inverts colors but preserves images/video\n style.textContent = '\n /* Invert everything except media */\n html {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #1a1a2e !important;\n }\n \n /* Restore images, videos, iframes, canvas */\n img, video, iframe, canvas, svg, picture, [style*=\"background-image\"] {\n filter: invert(1) hue-rotate(180deg) !important;\n }\n \n /* Preserve specific elements that should not be inverted */\n .no-dark-mode, .no-dark-mode *,\n [data-theme=\"light\"], [data-theme=\"light\"],\n .ace_editor, .ace_editor *,\n .CodeMirror, .CodeMirror *,\n .monaco-editor, .monaco-editor *,\n .markdown-body pre, .markdown-body pre *,\n .highlight, .highlight *,\n pre code, pre code * {\n filter: none !important;\n }\n \n /* Fix common UI elements */\n .modal, .popup, .dropdown-menu, .tooltip, .popover {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #2d2d44 !important;\n border-color: #444 !important;\n }\n \n /* Scrollbars */\n ::-webkit-scrollbar { background: #1a1a2e !important; }\n ::-webkit-scrollbar-thumb { background: #444 !important; }\n ::-webkit-scrollbar-thumb:hover { background: #555 !important; }\n \n /* Selection */\n ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ';\n }\n \n function removeDarkMode() {\n var style = document.getElementById('universal-dark-mode-style');\n if (style) style.remove();\n }\n \n // Toggle with Alt+Shift+D\n document.addEventListener('keydown', function(e) {\n if (e.altKey && e.shiftKey && e.key === 'D') {\n e.preventDefault();\n enabled = !enabled;\n if (enabled) {\n applyDarkMode();\n console.log('[Universal Dark Mode] Enabled');\n } else {\n removeDarkMode();\n console.log('[Universal Dark Mode] Disabled');\n }\n }\n });\n \n // Apply on load\n applyDarkMode();\n \n // Re-apply on dynamic content\n var observer = new MutationObserver(function(mutations) {\n if (enabled && !document.getElementById('universal-dark-mode-style')) {\n applyDarkMode();\n }\n });\n observer.observe(document.head, { childList: true });\n \n console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle');\n})();", "Universal Dark Mode"); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })();
Skip to content

Add LeadingStrings benchmarks for binary and non-ASCII regex patterns - #5126

Merged
danmoseley merged 3 commits into
dotnet:mainfrom
danmoseley:regex-redux/benchmarks
Feb 24, 2026
Merged

Add LeadingStrings benchmarks for binary and non-ASCII regex patterns#5126
danmoseley merged 3 commits into
dotnet:mainfrom
danmoseley:regex-redux/benchmarks

Conversation

@danmoseley

@danmoseleydanmoseley commented Feb 23, 2026

Copy link
Copy Markdown
Contributor

Adds two new benchmark classes to exercise the LeadingStrings vs FixedDistanceSets heuristic in the regex engine:

  • Perf_Regex_LeadingStrings_BinaryData: 1MB binary corpus (PE-header-like seed duplicated), alternation of binary patterns. Validates no regression on non-text input. (Lots of ASCII here, but obviously not English frequencies.)
  • Perf_Regex_LeadingStrings_NonAscii: ~100KB Russian text (Anna Karenina opening), alternation of Russian words. Validates no regression on non-ASCII text where the frequency heuristic bails out.

Companion to dotnet/runtime change: dotnet/runtime#124736

CopilotAI 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 adds two new benchmark classes to test regex alternation pattern performance with different input types. The benchmarks are designed to validate that the LeadingStrings vs FixedDistanceSets heuristic in the regex engine doesn't regress on binary and non-ASCII data.

Changes:

  • Added Perf_Regex_LeadingStrings_BinaryData class with benchmarks for binary data patterns (1MB PE-header-like corpus)
  • Added Perf_Regex_LeadingStrings_NonAscii class with benchmarks for Russian text patterns (~100KB from Anna Karenina)

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Dan Moseleyand others added 3 commits February 23, 2026 20:26
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Remove Perf_Regex_LeadingStrings_BinaryData, keeping only the non-ASCII benchmark.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Port curated/01-literal and curated/02-literal-alternate benchmarks from
https://github.com/BurntSushi/rebar for Russian and Chinese text.
Haystacks are OpenSubtitles data (https://opus.nlpl.eu/OpenSubtitles-v2018.php).
This replaces the ad-hoc NonAscii benchmark with well-established cross-engine
regex benchmarks that test literal and alternation search on non-ASCII text.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
@danmoseley
danmoseleyforce-pushed the regex-redux/benchmarks branch from 3a03edd to a3f6116CompareFebruary 24, 2026 05:06
@danmoseley

Copy link
Copy Markdown
ContributorAuthor

Benchmark results comparing main (base) vs 21a68d75 (test: "Skip frequency heuristic for single-char FixedDistanceSets")

Built from dotnet/runtime repo. Mann-Whitney U test, two-sided.

LangPatternOptionsBase(us)Test(us)Ratiop-valueSig
ChineseliteralCompiled25.4525.801.010.490
ChineseliteralNonBacktracking24.2714.940.62<0.001***
ChineseliteralNone21.9223.071.050.001**
ChinesealternationCompiled25.1327.311.090.044*
ChinesealternationNonBacktracking31.0328.870.930.378
ChinesealternationNone45.9344.640.970.023*
Russian(?i) literalCompiled110.39110.951.000.322
Russian(?i) literalNonBacktracking161.59160.010.990.106
Russian(?i) literalNone190.67191.381.000.120
Russian(?i) alternationCompiled1,126.601,152.901.020.044*
Russian(?i) alternationNonBacktracking7,629.457,767.191.02<0.001***
Russian(?i) alternationNone11,849.6411,796.661.000.071
RussianliteralCompiled48.7949.881.020.543
RussianliteralNonBacktracking81.0088.571.090.102
RussianliteralNone58.2757.690.990.867
RussianalternationCompiled1,214.601,228.941.010.086
RussianalternationNonBacktracking2,331.083,208.261.38<0.001***
RussianalternationNone3,074.013,164.351.030.017*

* p<0.05, ** p<0.01, *** p<0.001

@danmoseley

Copy link
Copy Markdown
ContributorAuthor

Hmm, speedups are nice except I wouldn't expect any as this is non ASCII

@danmoseley

Copy link
Copy Markdown
ContributorAuthor

Reran with more iterations and the tests are stable and at parity. The issue is some of them are super short (a few MB all in CPU cache doing IndexOf with SIMD) so I needed to do more iterations, which I guess the perf lab would. Merging.

@danmoseley
danmoseley merged commit 24575dc into dotnet:mainFeb 24, 2026
74 checks passed
@danmoseley
danmoseley deleted the regex-redux/benchmarks branch February 24, 2026 05:58
danmoseley pushed a commit to dotnet/runtime that referenced this pull request Feb 25, 2026
…124736)
I thought it would be interesting to see whether AI could take another
look at the [commented out search
strategy](https://github.com/dotnet/runtime/blob/99b76018b6e4/src/libraries/System.Text.RegularExpressions/src/System/Text/RegularExpressions/RegexFindOptimizations.cs#L152-L161)
originally introduced by @stephentoub in #98791 to see whether we can
enable it and keep the wins without the regressions that caused it to be
commented out.
AI tried various experiments, and got to a dead end. I recalled the
frequency table approach that Ripgrep uses (credit to @BurntSushi).
Turns out that fixes the regressions entirely. This means our engine now
has assumptions built in about char frequencies in ASCII (only) text.
That's an approach that's been proven in ripgrep, one of the fastest
engines, for 10 years, and it turns out to work OK for regex-redux as
well because a, c, g, t are relatively high frequency in English anyway.
Code unchanged if pattern has anything other than ASCII (see benchmark
results below).
This gives us a nice win on regex-redux, a few other wins in existing
tests, and no regressions.
Note: a char frequency table already existed in `RegexPrefixAnalyzer.cs`
for ranking which fixed-distance character sets are most selective. Our
new table serves a different purpose: deciding whether to use
`LeadingStrings` vs `FixedDistanceSets` at all. The two are
complementary.
====
When a regex has multiple alternation prefixes (e.g. `a|b|c|...`), this
change decides whether to use `SearchValues<string>`
(Teddy/Aho-Corasick) or fall through to `FixedDistanceSets`
(`IndexOfAny`) based on the frequency of the starting characters.
High-frequency starters (common letters like lowercase vowels) benefit
from multi-string search; low-frequency starters (uppercase, digits,
rare consonants) are already excellent `IndexOfAny` filters. Non-ASCII
starters bail out (no frequency data), preserving baseline behavior.
## Benchmark results (444 benchmarks, BDN A/B with --statisticalTest
3ms)
| Benchmark | Baseline | PR | Ratio | Verdict |
|-----------|----------|-----|-------|---------|
| RegexRedux_1 (Compiled) | 25.77ms | 14.27ms | **1.81x faster** |
Faster |
| Leipzig Tom.*river (Compiled) | 6.13ms | 1.87ms | **3.28x faster** |
Faster |
| RegexRedux_5 (Compiled) | 2.83ms | 2.35ms | **1.20x faster** | Faster
|
| Sherlock, BinaryData, BoostDocs, Mariomkas, SliceSlice | -- | -- | --
| Same |
| LeadingStrings_NonAscii (all variants) | -- | -- | -- | Same |
| LeadingStrings_BinaryData (all variants) | -- | -- | -- | Same |
Leipzig win is because the pattern is
`Tom.{10,25}river|river.{10,25}Tom` so there is a short prefix that is
common in the text; with this change it notices `r` is common and `T`
fairly common in English text, so it switches to `SearchValues` which
looks for `Tom` and `river` simultaneously, causing far fewer false
starts.
regex-redux win is because it was previously looking for short, very
common prefixes naively, and now (specifically because the pattern chars
are common) it changed to use `SearchValues` (ie Aho-Corasick/Teddy) to
search for the longer strings simultaneously.
No regressions detected. All MannWhitney tests report Same for
non-improved benchmarks.
## Key design decisions
- **Frequency table**: First 128 entries of Rust's `BYTE_FREQUENCIES`
from @BurntSushi's aho-corasick crate
- **Threshold**: Average rank >= 200 triggers `LeadingStrings`; below
200 falls through to `FixedDistanceSets`
- **Non-ASCII**: Returns false (no frequency data), so the heuristic
does not engage and behavior is unchanged
Companion benchmarks: dotnet/performance#5126
## New benchmark results (not yet in dotnet/performance, won't be picked
up by PR bot)
These benchmarks are from the companion PR
dotnet/performance#5126.
```
BenchmarkDotNet v0.16.0-custom.20260127.101, Windows 11 (10.0.26100.7840/24H2/2024Update/HudsonValley)
Intel Core i9-14900K 3.20GHz, 1 CPU, 32 logical and 24 physical cores
```
| Benchmark | Options | Baseline | PR | Ratio | MannWhitney(3ms) |
|-----------|---------|----------|-----|-------|------------------|
| LeadingStrings_BinaryData | None | 4,483 us | 4,365 us | 0.97 | Same |
| LeadingStrings_BinaryData | Compiled | 2,188 us | 2,184 us | 1.00 |
Same |
| LeadingStrings_BinaryData | NonBacktracking | 3,734 us | 3,725 us |
1.00 | Same |
| LeadingStrings_NonAscii Count | None | 913 us | 956 us | 1.05 | Same |
| LeadingStrings_NonAscii Count | Compiled | 244 us | 243 us | 1.00 |
Same |
| LeadingStrings_NonAscii CountIgnoreCase | None | 1,758 us | 1,714 us |
0.98 | Same |
| LeadingStrings_NonAscii CountIgnoreCase | Compiled | 258 us | 250 us |
0.97 | Same |
| LeadingStrings_NonAscii Count | NonBacktracking | 392 us | 398 us |
1.02 | Same |
| LeadingStrings_NonAscii CountIgnoreCase | NonBacktracking | 409 us |
431 us | 1.05 | Same |
Binary didn't regress even though it's ASCII with non English
frequencies because the pattern has chars that are not particularly
common in English, so it uses the old codepath. It's hard to hypothesize
about what one might search for in a binary file; searching for a bunch
of leading lower case ASCII chars might regress somewhat. We've never
particularly tested on binary before, and I don't recall seeing any bugs
mentioning binary, so I don't think this is particularly interesting.
NonASCII didn't regress since as previously mentioned, the non ASCII
leading chars in the pattern (presumably likely for any searching of non
ASCII text) causes it to choose the existing codepath.
All MannWhitney tests report Same -- no regressions on binary or
non-ASCII input.
---------
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
iremyux pushed a commit to iremyux/dotnet-runtime that referenced this pull request Mar 2, 2026
…otnet#124736)
I thought it would be interesting to see whether AI could take another
look at the [commented out search
strategy](https://github.com/dotnet/runtime/blob/99b76018b6e4/src/libraries/System.Text.RegularExpressions/src/System/Text/RegularExpressions/RegexFindOptimizations.cs#L152-L161)
originally introduced by @stephentoub in dotnet#98791 to see whether we can
enable it and keep the wins without the regressions that caused it to be
commented out.
AI tried various experiments, and got to a dead end. I recalled the
frequency table approach that Ripgrep uses (credit to @BurntSushi).
Turns out that fixes the regressions entirely. This means our engine now
has assumptions built in about char frequencies in ASCII (only) text.
That's an approach that's been proven in ripgrep, one of the fastest
engines, for 10 years, and it turns out to work OK for regex-redux as
well because a, c, g, t are relatively high frequency in English anyway.
Code unchanged if pattern has anything other than ASCII (see benchmark
results below).
This gives us a nice win on regex-redux, a few other wins in existing
tests, and no regressions.
Note: a char frequency table already existed in `RegexPrefixAnalyzer.cs`
for ranking which fixed-distance character sets are most selective. Our
new table serves a different purpose: deciding whether to use
`LeadingStrings` vs `FixedDistanceSets` at all. The two are
complementary.
====
When a regex has multiple alternation prefixes (e.g. `a|b|c|...`), this
change decides whether to use `SearchValues<string>`
(Teddy/Aho-Corasick) or fall through to `FixedDistanceSets`
(`IndexOfAny`) based on the frequency of the starting characters.
High-frequency starters (common letters like lowercase vowels) benefit
from multi-string search; low-frequency starters (uppercase, digits,
rare consonants) are already excellent `IndexOfAny` filters. Non-ASCII
starters bail out (no frequency data), preserving baseline behavior.
## Benchmark results (444 benchmarks, BDN A/B with --statisticalTest
3ms)
| Benchmark | Baseline | PR | Ratio | Verdict |
|-----------|----------|-----|-------|---------|
| RegexRedux_1 (Compiled) | 25.77ms | 14.27ms | **1.81x faster** |
Faster |
| Leipzig Tom.*river (Compiled) | 6.13ms | 1.87ms | **3.28x faster** |
Faster |
| RegexRedux_5 (Compiled) | 2.83ms | 2.35ms | **1.20x faster** | Faster
|
| Sherlock, BinaryData, BoostDocs, Mariomkas, SliceSlice | -- | -- | --
| Same |
| LeadingStrings_NonAscii (all variants) | -- | -- | -- | Same |
| LeadingStrings_BinaryData (all variants) | -- | -- | -- | Same |
Leipzig win is because the pattern is
`Tom.{10,25}river|river.{10,25}Tom` so there is a short prefix that is
common in the text; with this change it notices `r` is common and `T`
fairly common in English text, so it switches to `SearchValues` which
looks for `Tom` and `river` simultaneously, causing far fewer false
starts.
regex-redux win is because it was previously looking for short, very
common prefixes naively, and now (specifically because the pattern chars
are common) it changed to use `SearchValues` (ie Aho-Corasick/Teddy) to
search for the longer strings simultaneously.
No regressions detected. All MannWhitney tests report Same for
non-improved benchmarks.
## Key design decisions
- **Frequency table**: First 128 entries of Rust's `BYTE_FREQUENCIES`
from @BurntSushi's aho-corasick crate
- **Threshold**: Average rank >= 200 triggers `LeadingStrings`; below
200 falls through to `FixedDistanceSets`
- **Non-ASCII**: Returns false (no frequency data), so the heuristic
does not engage and behavior is unchanged
Companion benchmarks: dotnet/performance#5126
## New benchmark results (not yet in dotnet/performance, won't be picked
up by PR bot)
These benchmarks are from the companion PR
dotnet/performance#5126.
```
BenchmarkDotNet v0.16.0-custom.20260127.101, Windows 11 (10.0.26100.7840/24H2/2024Update/HudsonValley)
Intel Core i9-14900K 3.20GHz, 1 CPU, 32 logical and 24 physical cores
```
| Benchmark | Options | Baseline | PR | Ratio | MannWhitney(3ms) |
|-----------|---------|----------|-----|-------|------------------|
| LeadingStrings_BinaryData | None | 4,483 us | 4,365 us | 0.97 | Same |
| LeadingStrings_BinaryData | Compiled | 2,188 us | 2,184 us | 1.00 |
Same |
| LeadingStrings_BinaryData | NonBacktracking | 3,734 us | 3,725 us |
1.00 | Same |
| LeadingStrings_NonAscii Count | None | 913 us | 956 us | 1.05 | Same |
| LeadingStrings_NonAscii Count | Compiled | 244 us | 243 us | 1.00 |
Same |
| LeadingStrings_NonAscii CountIgnoreCase | None | 1,758 us | 1,714 us |
0.98 | Same |
| LeadingStrings_NonAscii CountIgnoreCase | Compiled | 258 us | 250 us |
0.97 | Same |
| LeadingStrings_NonAscii Count | NonBacktracking | 392 us | 398 us |
1.02 | Same |
| LeadingStrings_NonAscii CountIgnoreCase | NonBacktracking | 409 us |
431 us | 1.05 | Same |
Binary didn't regress even though it's ASCII with non English
frequencies because the pattern has chars that are not particularly
common in English, so it uses the old codepath. It's hard to hypothesize
about what one might search for in a binary file; searching for a bunch
of leading lower case ASCII chars might regress somewhat. We've never
particularly tested on binary before, and I don't recall seeing any bugs
mentioning binary, so I don't think this is particularly interesting.
NonASCII didn't regress since as previously mentioned, the non ASCII
leading chars in the pattern (presumably likely for any searching of non
ASCII text) causes it to choose the existing codepath.
All MannWhitney tests report Same -- no regressions on binary or
non-ASCII input.
---------
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Sign up for freeto 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.

3 participants

@danmoseley@stephentoub