Skip to content

Fix BOL anchor not writing back updated position in TryFindNextPossibleStartingPosition - #125280

Merged
stephentoub merged 2 commits into
mainfrom
copilot/fix-regex-pattern-performance
Mar 7, 2026
Merged

Fix BOL anchor not writing back updated position in TryFindNextPossibleStartingPosition#125280
stephentoub merged 2 commits into
mainfrom
copilot/fix-regex-pattern-performance

Conversation

CopilotAI commented Mar 6, 2026

Copy link
Copy Markdown
Contributor

Description

The BOL (^ with Multiline) handler in TryFindNextPossibleStartingPosition uses vectorized IndexOf('\n') to advance pos to the next line start, but never writes the result back to base.runtextpos. For patterns where BOL is the only optimization (e.g., bare ^), the method returns true with NoSearch mode and the caller retries from the original position — negating the skip entirely.

The generated code was:

intpos=base.runtextpos;if(pos>0&&inputSpan[pos-1]!='\n'){intnewlinePos=inputSpan.Slice(pos).IndexOf('\n');if((uint)newlinePos>inputSpan.Length-pos-1)gotoNoMatchFound;pos+=newlinePos+1;// ← base.runtextpos never set}returntrue;
  • Source generator (RegexGenerator.Emitter.cs): Emit base.runtextpos = pos; after the BOL position advance and length check
  • IL compiler (RegexCompiler.cs): Emit equivalent Ldthis(); Ldloc(pos); Stfld(RuntextposField);

All 30,817 functional and 1,034 unit regex tests pass.

Benchmark Results

EgorBot benchmarks confirm the fix on both x64 and ARM64 (Regex.Count() on 1000 matches of ^ with Multiline):

Linux AMD (EPYC 9V45):

Gap (chars)PRmainSpeedup
513.97 μs32.51 μs2.3x
8014.22 μs447.66 μs31x
32016.40 μs1,763.62 μs108x
128033.08 μs11,002.82 μs333x

macOS ARM64 (Apple M4):

Gap (chars)PRmainSpeedup
59.73 μs25.84 μs2.7x
8011.87 μs422.31 μs36x
32019.20 μs3,005.44 μs157x
128061.44 μs36,555.42 μs595x

Speedup scales linearly with inter-match gap size as expected — without the fix, the vectorized IndexOf('\n') skip was computed but never written back, causing character-by-character scanning.


💬 We'd love your input! Share your thoughts on Copilot coding agent in our 2 minute survey.

@danmoseley

Copy link
Copy Markdown
Contributor

I'll let this run since it's going, but I assume it will produce the exact same as this
de0549f

with that change, perf:

1000 matches, Regex.Count(), varying inter-match gap (ns/match):

Gap (chars)Engine^ Before^ After(?=\n) (control)Improvement
5Compiled304442(noise)
5SourceGenerated--5353(noise)
80Compiled34144458x
80SourceGenerated--5655~6x
320Compiled2,008----~87x
1,280Compiled16,8057473227x
1,280SourceGenerated--8785~193x

After the fix, ^ matches (?=\n) performance for both Compiled and SourceGenerated engines across all gap sizes. It is also now faster than (?<=\n|\A) (which has no FindFirstChar skip and must check every position).

I guess (?<=\n|\A) could be lowered to ^ but that's not very intersting.

@dotnet-policy-service

Copy link
Copy Markdown
Contributor

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

When the beginning-of-line (BOL) anchor in TryFindNextPossibleStartingPosition
finds a newline via IndexOf and advances pos, the updated position was never
written back to base.runtextpos. This caused the match engine to retry from
the original position, negating the vectorized IndexOf optimization for
patterns like bare '^' with Multiline.
Fix both the source generator (RegexGenerator.Emitter.cs) and the IL compiler
(RegexCompiler.cs) to set base.runtextpos = pos after the BOL position update.
Co-authored-by: stephentoub <2642209+stephentoub@users.noreply.github.com>
CopilotAI changed the title [WIP] Fix regex pattern performance issue with '^' constructFix BOL anchor not writing back updated position in TryFindNextPossibleStartingPositionMar 6, 2026
@stephentoub

Copy link
Copy Markdown
Member

@copilot use the performance benchmark skill to validate the improvement from this change

@stephentoub
stephentoub marked this pull request as ready for review March 6, 2026 22:17
CopilotAI review requested due to automatic review settings March 6, 2026 22:17
@stephentoub
stephentoub enabled auto-merge (squash) March 6, 2026 22:17

CopilotAI commented Mar 6, 2026

Copy link
Copy Markdown
ContributorAuthor

@copilot use the performance benchmark skill to validate the improvement from this change

@EgorBot -linux_amd -osx_arm64

usingSystem.Text;usingSystem.Text.RegularExpressions;usingBenchmarkDotNet.Attributes;usingBenchmarkDotNet.Running;BenchmarkSwitcher.FromAssembly(typeof(Bench).Assembly).Run(args);publicclassBench{privatestring_input=default!;privateRegex_bolCompiled=default!;[Params(5,80,320,1280)]publicintGap{get;set;}[GlobalSetup]publicvoidSetup(){varsb=newStringBuilder();for(inti=0;i<1000;i++){sb.Append('a',Gap);sb.Append('\n');}_input=sb.ToString();_bolCompiled=newRegex("^",RegexOptions.Multiline|RegexOptions.Compiled);}[Benchmark]publicintBolCount()=>_bolCompiled.Count(_input);}

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

Fixes an issue in the regex “find next starting position” anchor handling where the computed position bump for ^ (BOL with Multiline) wasn’t persisted back to base.runtextpos, causing the caller to retry from the original position and lose the intended skip.

Changes:

  • Update the IL-emitting compiler (RegexCompiler.cs) to store the advanced pos back into base.runtextpos for the BOL optimization path.
  • Update the source generator emitter (RegexGenerator.Emitter.cs) to emit base.runtextpos = pos; after advancing past the next newline for BOL.

Reviewed changes

Copilot reviewed 2 out of 2 changed files in this pull request and generated 2 comments.

FileDescription
src/libraries/System.Text.RegularExpressions/src/System/Text/RegularExpressions/RegexCompiler.csPersists the BOL-advanced pos into RuntextposField so NoSearch mode uses the updated starting position.
src/libraries/System.Text.RegularExpressions/gen/RegexGenerator.Emitter.csEmits base.runtextpos = pos; after BOL-based pos advancement so generated code doesn’t lose the skip.

@danmoseley

Copy link
Copy Markdown
Contributor

@MihuBot regexdiff

@danmoseley

Copy link
Copy Markdown
Contributor

Maybe we should have a skill in this repo that explains @MihuBot commands like "benchmark regex" and "regexdiff" and such. I don't see anything for that. @MihaZupan ?

@danmoseley

Copy link
Copy Markdown
Contributor

Oh whoops, seeems like it started a jitdiff because of me mentioning it..

danmoseley
danmoseley approved these changes Mar 6, 2026
@MihuBot

Copy link
Copy Markdown

172 out of 18857 patterns have generated source code changes.

Examples of GeneratedRegex source diffs
"^" (5871 uses)
[GeneratedRegex("^",RegexOptions.Multiline)]
 {
goto NoMatchFound;
}
+ base.runtextpos = pos;
}
return true;
"^#include <([^>]+)>\\s*$" (2606 uses)
[GeneratedRegex("^#include <([^>]+)>\\s*$",RegexOptions.IgnoreCase|RegexOptions.Multiline)]
 {
goto NoMatchFound;
}
+ base.runtextpos = pos;
}
// The pattern has the literal "#include" ordinal case-insensitive at the beginning of the pattern. Find the next occurrence.
"^-+ *BEGIN (?<keyName>\\w+( \\w+)*) PRIVATE ..." (1964 uses)
[GeneratedRegex("^-+ *BEGIN (?<keyName>\\w+( \\w+)*) PRIVATE KEY *-+\\r?\\n(Proc-Type: 4,ENCRYPTED\\r?\\nDEK-Info: (?<cipherName>[A-Z0-9-]+),(?<salt>[A-F0-9]+)\\r?\\n\\r?\\n)?(?<data>([a-zA-Z0-9/+=]{1,80}\\r?\\n)+)-+ *END \\k<keyName> PRIVATE KEY *-+",RegexOptions.Multiline)]
 {
goto NoMatchFound;
}
+ base.runtextpos = pos;
}
// The pattern begins with a character in the set \-.
"^ *> ?" (823 uses)
[GeneratedRegex("^ *> ?",RegexOptions.Multiline)]
 {
goto NoMatchFound;
}
+ base.runtextpos = pos;
}
// The pattern begins with a character in the set [ >].
"^ +$" (823 uses)
[GeneratedRegex("^ +$",RegexOptions.Multiline)]
 {
goto NoMatchFound;
}
+ base.runtextpos = pos;
}
// The pattern begins with a character in the set .
"^ {4}" (823 uses)
[GeneratedRegex("^ {4}",RegexOptions.Multiline)]
 {
goto NoMatchFound;
}
+ base.runtextpos = pos;
}
// The pattern has the literal " " at the beginning of the pattern. Find the next occurrence.
"^( *)((?:[*+-]|\\d+\\.)) [^\\n]*(?:\\n(?!\\1 ..." (823 uses)
[GeneratedRegex("^( *)((?:[*+-]|\\d+\\.)) [^\\n]*(?:\\n(?!\\1(?:[*+-]|\\d+\\.) )[^\\n]*)*",RegexOptions.Multiline)]
 {
goto NoMatchFound;
}
+ base.runtextpos = pos;
}
// The pattern begins with a character in the set [ *+\-\d].
"^(\\d+)\\.(\\d+)\\.(\\d+)" (599 uses)
[GeneratedRegex("^(\\d+)\\.(\\d+)\\.(\\d+)",RegexOptions.Multiline)]
 {
goto NoMatchFound;
}
+ base.runtextpos = pos;
}
// The pattern begins with a Unicode digit.
"\r\n ^\r\n [\\x20\\t]* ..." (569 uses)
[GeneratedRegex("\r\n ^\r\n [\\x20\\t]*\r\n\\w+ [\\x20\\t]+\r\n (?<frame>\r\n (?<type> [^\\x20\\t]+ ) \\.\r\n (?<method> [^\\x20\\t]+? ) [\\x20\\t]*\r\n (?<params> \\( ( [\\x20\\t]* \\)\r\n | (?<pt> .+?) [\\x20\\t]+ (?<pn> .+?)\r\n (, [\\x20\\t]* (?<pt> .+?) [\\x20\\t]+ (?<pn> .+?) )* \\) ) )\r\n ( [\\x20\\t]+\r\n ( # Microsoft .NET stack traces\r\n\\w+ [\\x20\\t]+\r\n (?<file> [a-z] \\: .+? )\r\n\\: \\w+ [\\x20\\t]+\r\n (?<line> [0-9]+ ) \\p{P}?\r\n | # Mono stack traces\r\n\\[0x[0-9a-f]+\\] [\\x20\\t]+ \\w+ [\\x20\\t]+\r\n <(?<file> [^>]+ )>\r\n :(?<line> [0-9]+ )\r\n )\r\n )?\r\n )\r\n\\s*\r\n $",RegexOptions.IgnoreCase|RegexOptions.Multiline|RegexOptions.ExplicitCapture|RegexOptions.IgnorePatternWhitespace|RegexOptions.CultureInvariant)]
 {
goto NoMatchFound;
}
+ base.runtextpos = pos;
}
// The pattern begins with a character in the set [\t \w].
"^LD_LIBRARY_PATH=(.*)$" (526 uses)
[GeneratedRegex("^LD_LIBRARY_PATH=(.*)$",RegexOptions.Multiline)]
 {
goto NoMatchFound;
}
+ base.runtextpos = pos;
}
// The pattern has the literal "LD_LIBRARY_PATH=" at the beginning of the pattern. Find the next occurrence.

For more diff examples, see https://gist.github.com/MihuBot/d7021d5c0016c70a0e532293f19acdab

JIT assembly changes
Total bytes of base: 55609071
Total bytes of diff: 55610219
Total bytes of delta: 1148 (0.00 % of base)
Total relative delta: 4.41
diff is a regression.
relative diff is a regression.

For a list of JIT diff regressions, see Regressions.md

Sample source code for further analysis
conststringJsonPath="RegexResults-1804.json";if(!File.Exists(JsonPath)){awaitusingvararchiveStream=awaitnewHttpClient().GetStreamAsync("https://mihubot.xyz/r/FIih1ySA");usingvararchive=newZipArchive(archiveStream,ZipArchiveMode.Read);archive.Entries.First(e =>e.Name=="Results.json").ExtractToFile(JsonPath);}usingFileStreamjsonFileStream=File.OpenRead(JsonPath);RegexEntry[]entries=JsonSerializer.Deserialize<RegexEntry[]>(jsonFileStream,newJsonSerializerOptions{IncludeFields=true})!;Console.WriteLine($"Working with {entries.Length} patterns");recordKnownPattern(stringPattern,RegexOptionsOptions,intCount);sealedclassRegexEntry{publicrequiredKnownPatternRegex{get;set;}publicrequiredstringMainSource{get;set;}publicrequiredstringPrSource{get;set;}publicstring?FullDiff{get;set;}publicstring?ShortDiff{get;set;}public(stringName,stringValues)[]?SearchValuesOfChar{get;set;}public(string[]Values,StringComparisonComparisonType)[]?SearchValuesOfString{get;set;}}

@EgorBo

Copy link
Copy Markdown
Member

@copilot, benchmark results are ready:

Please analyze the results and act accordingly.
NOTE: some benchmarks may be flaky or bi-modal, so use your judgment when interpreting small differences.

CopilotAI commented Mar 6, 2026

Copy link
Copy Markdown
ContributorAuthor

@copilot, benchmark results are ready:

Benchmark results confirm the fix provides significant improvements that scale with inter-match gap size:

Linux AMD (EPYC 9V45):

GapPRmainSpeedup
513.97 μs32.51 μs2.3x
8014.22 μs447.66 μs31x
32016.40 μs1,763.62 μs108x
128033.08 μs11,002.82 μs333x

macOS ARM64 (Apple M4):

GapPRmainSpeedup
59.73 μs25.84 μs2.7x
8011.87 μs422.31 μs36x
32019.20 μs3,005.44 μs157x
128061.44 μs36,555.42 μs595x

The speedup scales linearly with gap size as expected — without the fix, the vectorized IndexOf('\n') skip was computed but never written back, causing character-by-character scanning. With the fix, the skip is properly applied. Results are consistent with @danmoseley's earlier measurements and confirm the fix on both x64 and ARM64.

Warning

Firewall rules blocked me from connecting to one or more addresses (expand for details)

I tried to connect to the following addresses, but was blocked by firewall rules:

  • bot.egorbo.com
    • Triggering command: /home/REDACTED/work/_temp/ghcca-node/node/bin/node /home/REDACTED/work/_temp/ghcca-node/node/bin/node --enable-source-maps /home/REDACTED/work/_temp/copilot-developer-action-main/dist/index.js (dns block)

If you need me to access, download, or install something from one of these locations, you can either:

@danmoseley

Copy link
Copy Markdown
Contributor

As above, this fixes quite everyday patterns like ^LD_LIBRARY_PATH=(.*)$ where there IS a nice string to search fast for. But if I understand right, until this fix, that could be arbitrarily impacted by wasting the find next position advance. So I guess depending on the text, this might significantly improve such patterns too. Just maybe not as much as ^

@MihaZupan

MihaZupan commented Mar 7, 2026

Copy link
Copy Markdown
Member

Maybe we should have a skill in this repo that explains @MihuBot commands like "benchmark regex" and "regexdiff" and such

We do have a skill for Egor's bot for benchmarks. MihuBot's arguments are simpler (no benchmark source), documented here. I don't think teaching copilot how to trigger it via comments would be too useful, but giving the agent the ability to invoke it and wait for results as it's working (i.e. via REST API) could be interesting.

Sign up for freeto subscribe to this conversation on GitHub. Already have an account? Sign in.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

7 participants

@danmoseley@stephentoub@MihuBot@EgorBo@MihaZupan
, 'i'); if (__m === '*' || __re.test(location.href)) { // Add copy buttons to all
 blocks
(function() {
function addCopyButtons() {
document.querySelectorAll('pre code').forEach(function(codeBlock) {
if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;
codeBlock.parentElement.setAttribute('data-copy-added', 'true');
var btn = document.createElement('button');
btn.textContent = 'Copy';
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;';
btn.onmouseover = function() { this.style.opacity = '1'; };
btn.onmouseout = function() { this.style.opacity = '0.7'; };
btn.onclick = function() {
navigator.clipboard.writeText(codeBlock.textContent).then(function() {
btn.textContent = 'Copied!';
setTimeout(function() { btn.textContent = 'Copy'; }, 1500);
});
};
codeBlock.parentElement.style.position = 'relative';
codeBlock.parentElement.appendChild(btn);
});
}
addCopyButtons();
// Re-run on dynamic content
var observer = new MutationObserver(addCopyButtons);
observer.observe(document.body, { childList: true, subtree: true });
})();
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
Fix BOL anchor not writing back updated position in TryFindNextPossibleStartingPosition by Copilot · Pull Request #125280 · dotnet/runtime · GitHub
Skip to content

Fix BOL anchor not writing back updated position in TryFindNextPossibleStartingPosition - #125280

Merged
stephentoub merged 2 commits into
mainfrom
copilot/fix-regex-pattern-performance
Mar 7, 2026
Merged

Fix BOL anchor not writing back updated position in TryFindNextPossibleStartingPosition#125280
stephentoub merged 2 commits into
mainfrom
copilot/fix-regex-pattern-performance

Conversation

CopilotAI commented Mar 6, 2026

Copy link
Copy Markdown
Contributor

Description

The BOL (^ with Multiline) handler in TryFindNextPossibleStartingPosition uses vectorized IndexOf('\n') to advance pos to the next line start, but never writes the result back to base.runtextpos. For patterns where BOL is the only optimization (e.g., bare ^), the method returns true with NoSearch mode and the caller retries from the original position — negating the skip entirely.

The generated code was:

intpos=base.runtextpos;if(pos>0&&inputSpan[pos-1]!='\n'){intnewlinePos=inputSpan.Slice(pos).IndexOf('\n');if((uint)newlinePos>inputSpan.Length-pos-1)gotoNoMatchFound;pos+=newlinePos+1;// ← base.runtextpos never set}returntrue;
  • Source generator (RegexGenerator.Emitter.cs): Emit base.runtextpos = pos; after the BOL position advance and length check
  • IL compiler (RegexCompiler.cs): Emit equivalent Ldthis(); Ldloc(pos); Stfld(RuntextposField);

All 30,817 functional and 1,034 unit regex tests pass.

Benchmark Results

EgorBot benchmarks confirm the fix on both x64 and ARM64 (Regex.Count() on 1000 matches of ^ with Multiline):

Linux AMD (EPYC 9V45):

Gap (chars)PRmainSpeedup
513.97 μs32.51 μs2.3x
8014.22 μs447.66 μs31x
32016.40 μs1,763.62 μs108x
128033.08 μs11,002.82 μs333x

macOS ARM64 (Apple M4):

Gap (chars)PRmainSpeedup
59.73 μs25.84 μs2.7x
8011.87 μs422.31 μs36x
32019.20 μs3,005.44 μs157x
128061.44 μs36,555.42 μs595x

Speedup scales linearly with inter-match gap size as expected — without the fix, the vectorized IndexOf('\n') skip was computed but never written back, causing character-by-character scanning.


💬 We'd love your input! Share your thoughts on Copilot coding agent in our 2 minute survey.

@danmoseley

Copy link
Copy Markdown
Contributor

I'll let this run since it's going, but I assume it will produce the exact same as this
de0549f

with that change, perf:

1000 matches, Regex.Count(), varying inter-match gap (ns/match):

Gap (chars)Engine^ Before^ After(?=\n) (control)Improvement
5Compiled304442(noise)
5SourceGenerated--5353(noise)
80Compiled34144458x
80SourceGenerated--5655~6x
320Compiled2,008----~87x
1,280Compiled16,8057473227x
1,280SourceGenerated--8785~193x

After the fix, ^ matches (?=\n) performance for both Compiled and SourceGenerated engines across all gap sizes. It is also now faster than (?<=\n|\A) (which has no FindFirstChar skip and must check every position).

I guess (?<=\n|\A) could be lowered to ^ but that's not very intersting.

@dotnet-policy-service

Copy link
Copy Markdown
Contributor

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

When the beginning-of-line (BOL) anchor in TryFindNextPossibleStartingPosition
finds a newline via IndexOf and advances pos, the updated position was never
written back to base.runtextpos. This caused the match engine to retry from
the original position, negating the vectorized IndexOf optimization for
patterns like bare '^' with Multiline.
Fix both the source generator (RegexGenerator.Emitter.cs) and the IL compiler
(RegexCompiler.cs) to set base.runtextpos = pos after the BOL position update.
Co-authored-by: stephentoub <2642209+stephentoub@users.noreply.github.com>
CopilotAI changed the title [WIP] Fix regex pattern performance issue with '^' constructFix BOL anchor not writing back updated position in TryFindNextPossibleStartingPositionMar 6, 2026
@stephentoub

Copy link
Copy Markdown
Member

@copilot use the performance benchmark skill to validate the improvement from this change

@stephentoub
stephentoub marked this pull request as ready for review March 6, 2026 22:17
CopilotAI review requested due to automatic review settings March 6, 2026 22:17
@stephentoub
stephentoub enabled auto-merge (squash) March 6, 2026 22:17

CopilotAI commented Mar 6, 2026

Copy link
Copy Markdown
ContributorAuthor

@copilot use the performance benchmark skill to validate the improvement from this change

@EgorBot -linux_amd -osx_arm64

usingSystem.Text;usingSystem.Text.RegularExpressions;usingBenchmarkDotNet.Attributes;usingBenchmarkDotNet.Running;BenchmarkSwitcher.FromAssembly(typeof(Bench).Assembly).Run(args);publicclassBench{privatestring_input=default!;privateRegex_bolCompiled=default!;[Params(5,80,320,1280)]publicintGap{get;set;}[GlobalSetup]publicvoidSetup(){varsb=newStringBuilder();for(inti=0;i<1000;i++){sb.Append('a',Gap);sb.Append('\n');}_input=sb.ToString();_bolCompiled=newRegex("^",RegexOptions.Multiline|RegexOptions.Compiled);}[Benchmark]publicintBolCount()=>_bolCompiled.Count(_input);}

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

Fixes an issue in the regex “find next starting position” anchor handling where the computed position bump for ^ (BOL with Multiline) wasn’t persisted back to base.runtextpos, causing the caller to retry from the original position and lose the intended skip.

Changes:

  • Update the IL-emitting compiler (RegexCompiler.cs) to store the advanced pos back into base.runtextpos for the BOL optimization path.
  • Update the source generator emitter (RegexGenerator.Emitter.cs) to emit base.runtextpos = pos; after advancing past the next newline for BOL.

Reviewed changes

Copilot reviewed 2 out of 2 changed files in this pull request and generated 2 comments.

FileDescription
src/libraries/System.Text.RegularExpressions/src/System/Text/RegularExpressions/RegexCompiler.csPersists the BOL-advanced pos into RuntextposField so NoSearch mode uses the updated starting position.
src/libraries/System.Text.RegularExpressions/gen/RegexGenerator.Emitter.csEmits base.runtextpos = pos; after BOL-based pos advancement so generated code doesn’t lose the skip.

@danmoseley

Copy link
Copy Markdown
Contributor

@MihuBot regexdiff

@danmoseley

Copy link
Copy Markdown
Contributor

Maybe we should have a skill in this repo that explains @MihuBot commands like "benchmark regex" and "regexdiff" and such. I don't see anything for that. @MihaZupan ?

@danmoseley

Copy link
Copy Markdown
Contributor

Oh whoops, seeems like it started a jitdiff because of me mentioning it..

danmoseley
danmoseley approved these changes Mar 6, 2026
@MihuBot

Copy link
Copy Markdown

172 out of 18857 patterns have generated source code changes.

Examples of GeneratedRegex source diffs
"^" (5871 uses)
[GeneratedRegex("^",RegexOptions.Multiline)]
 {
goto NoMatchFound;
}
+ base.runtextpos = pos;
}
return true;
"^#include <([^>]+)>\\s*$" (2606 uses)
[GeneratedRegex("^#include <([^>]+)>\\s*$",RegexOptions.IgnoreCase|RegexOptions.Multiline)]
 {
goto NoMatchFound;
}
+ base.runtextpos = pos;
}
// The pattern has the literal "#include" ordinal case-insensitive at the beginning of the pattern. Find the next occurrence.
"^-+ *BEGIN (?<keyName>\\w+( \\w+)*) PRIVATE ..." (1964 uses)
[GeneratedRegex("^-+ *BEGIN (?<keyName>\\w+( \\w+)*) PRIVATE KEY *-+\\r?\\n(Proc-Type: 4,ENCRYPTED\\r?\\nDEK-Info: (?<cipherName>[A-Z0-9-]+),(?<salt>[A-F0-9]+)\\r?\\n\\r?\\n)?(?<data>([a-zA-Z0-9/+=]{1,80}\\r?\\n)+)-+ *END \\k<keyName> PRIVATE KEY *-+",RegexOptions.Multiline)]
 {
goto NoMatchFound;
}
+ base.runtextpos = pos;
}
// The pattern begins with a character in the set \-.
"^ *> ?" (823 uses)
[GeneratedRegex("^ *> ?",RegexOptions.Multiline)]
 {
goto NoMatchFound;
}
+ base.runtextpos = pos;
}
// The pattern begins with a character in the set [ >].
"^ +$" (823 uses)
[GeneratedRegex("^ +$",RegexOptions.Multiline)]
 {
goto NoMatchFound;
}
+ base.runtextpos = pos;
}
// The pattern begins with a character in the set .
"^ {4}" (823 uses)
[GeneratedRegex("^ {4}",RegexOptions.Multiline)]
 {
goto NoMatchFound;
}
+ base.runtextpos = pos;
}
// The pattern has the literal " " at the beginning of the pattern. Find the next occurrence.
"^( *)((?:[*+-]|\\d+\\.)) [^\\n]*(?:\\n(?!\\1 ..." (823 uses)
[GeneratedRegex("^( *)((?:[*+-]|\\d+\\.)) [^\\n]*(?:\\n(?!\\1(?:[*+-]|\\d+\\.) )[^\\n]*)*",RegexOptions.Multiline)]
 {
goto NoMatchFound;
}
+ base.runtextpos = pos;
}
// The pattern begins with a character in the set [ *+\-\d].
"^(\\d+)\\.(\\d+)\\.(\\d+)" (599 uses)
[GeneratedRegex("^(\\d+)\\.(\\d+)\\.(\\d+)",RegexOptions.Multiline)]
 {
goto NoMatchFound;
}
+ base.runtextpos = pos;
}
// The pattern begins with a Unicode digit.
"\r\n ^\r\n [\\x20\\t]* ..." (569 uses)
[GeneratedRegex("\r\n ^\r\n [\\x20\\t]*\r\n\\w+ [\\x20\\t]+\r\n (?<frame>\r\n (?<type> [^\\x20\\t]+ ) \\.\r\n (?<method> [^\\x20\\t]+? ) [\\x20\\t]*\r\n (?<params> \\( ( [\\x20\\t]* \\)\r\n | (?<pt> .+?) [\\x20\\t]+ (?<pn> .+?)\r\n (, [\\x20\\t]* (?<pt> .+?) [\\x20\\t]+ (?<pn> .+?) )* \\) ) )\r\n ( [\\x20\\t]+\r\n ( # Microsoft .NET stack traces\r\n\\w+ [\\x20\\t]+\r\n (?<file> [a-z] \\: .+? )\r\n\\: \\w+ [\\x20\\t]+\r\n (?<line> [0-9]+ ) \\p{P}?\r\n | # Mono stack traces\r\n\\[0x[0-9a-f]+\\] [\\x20\\t]+ \\w+ [\\x20\\t]+\r\n <(?<file> [^>]+ )>\r\n :(?<line> [0-9]+ )\r\n )\r\n )?\r\n )\r\n\\s*\r\n $",RegexOptions.IgnoreCase|RegexOptions.Multiline|RegexOptions.ExplicitCapture|RegexOptions.IgnorePatternWhitespace|RegexOptions.CultureInvariant)]
 {
goto NoMatchFound;
}
+ base.runtextpos = pos;
}
// The pattern begins with a character in the set [\t \w].
"^LD_LIBRARY_PATH=(.*)$" (526 uses)
[GeneratedRegex("^LD_LIBRARY_PATH=(.*)$",RegexOptions.Multiline)]
 {
goto NoMatchFound;
}
+ base.runtextpos = pos;
}
// The pattern has the literal "LD_LIBRARY_PATH=" at the beginning of the pattern. Find the next occurrence.

For more diff examples, see https://gist.github.com/MihuBot/d7021d5c0016c70a0e532293f19acdab

JIT assembly changes
Total bytes of base: 55609071
Total bytes of diff: 55610219
Total bytes of delta: 1148 (0.00 % of base)
Total relative delta: 4.41
diff is a regression.
relative diff is a regression.

For a list of JIT diff regressions, see Regressions.md

Sample source code for further analysis
conststringJsonPath="RegexResults-1804.json";if(!File.Exists(JsonPath)){awaitusingvararchiveStream=awaitnewHttpClient().GetStreamAsync("https://mihubot.xyz/r/FIih1ySA");usingvararchive=newZipArchive(archiveStream,ZipArchiveMode.Read);archive.Entries.First(e =>e.Name=="Results.json").ExtractToFile(JsonPath);}usingFileStreamjsonFileStream=File.OpenRead(JsonPath);RegexEntry[]entries=JsonSerializer.Deserialize<RegexEntry[]>(jsonFileStream,newJsonSerializerOptions{IncludeFields=true})!;Console.WriteLine($"Working with {entries.Length} patterns");recordKnownPattern(stringPattern,RegexOptionsOptions,intCount);sealedclassRegexEntry{publicrequiredKnownPatternRegex{get;set;}publicrequiredstringMainSource{get;set;}publicrequiredstringPrSource{get;set;}publicstring?FullDiff{get;set;}publicstring?ShortDiff{get;set;}public(stringName,stringValues)[]?SearchValuesOfChar{get;set;}public(string[]Values,StringComparisonComparisonType)[]?SearchValuesOfString{get;set;}}

@EgorBo

Copy link
Copy Markdown
Member

@copilot, benchmark results are ready:

Please analyze the results and act accordingly.
NOTE: some benchmarks may be flaky or bi-modal, so use your judgment when interpreting small differences.

CopilotAI commented Mar 6, 2026

Copy link
Copy Markdown
ContributorAuthor

@copilot, benchmark results are ready:

Benchmark results confirm the fix provides significant improvements that scale with inter-match gap size:

Linux AMD (EPYC 9V45):

GapPRmainSpeedup
513.97 μs32.51 μs2.3x
8014.22 μs447.66 μs31x
32016.40 μs1,763.62 μs108x
128033.08 μs11,002.82 μs333x

macOS ARM64 (Apple M4):

GapPRmainSpeedup
59.73 μs25.84 μs2.7x
8011.87 μs422.31 μs36x
32019.20 μs3,005.44 μs157x
128061.44 μs36,555.42 μs595x

The speedup scales linearly with gap size as expected — without the fix, the vectorized IndexOf('\n') skip was computed but never written back, causing character-by-character scanning. With the fix, the skip is properly applied. Results are consistent with @danmoseley's earlier measurements and confirm the fix on both x64 and ARM64.

Warning

Firewall rules blocked me from connecting to one or more addresses (expand for details)

I tried to connect to the following addresses, but was blocked by firewall rules:

  • bot.egorbo.com
    • Triggering command: /home/REDACTED/work/_temp/ghcca-node/node/bin/node /home/REDACTED/work/_temp/ghcca-node/node/bin/node --enable-source-maps /home/REDACTED/work/_temp/copilot-developer-action-main/dist/index.js (dns block)

If you need me to access, download, or install something from one of these locations, you can either:

@danmoseley

Copy link
Copy Markdown
Contributor

As above, this fixes quite everyday patterns like ^LD_LIBRARY_PATH=(.*)$ where there IS a nice string to search fast for. But if I understand right, until this fix, that could be arbitrarily impacted by wasting the find next position advance. So I guess depending on the text, this might significantly improve such patterns too. Just maybe not as much as ^

@MihaZupan

MihaZupan commented Mar 7, 2026

Copy link
Copy Markdown
Member

Maybe we should have a skill in this repo that explains @MihuBot commands like "benchmark regex" and "regexdiff" and such

We do have a skill for Egor's bot for benchmarks. MihuBot's arguments are simpler (no benchmark source), documented here. I don't think teaching copilot how to trigger it via comments would be too useful, but giving the agent the ability to invoke it and wait for results as it's working (i.e. via REST API) could be interesting.

Sign up for freeto subscribe to this conversation on GitHub. Already have an account? Sign in.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

7 participants

@danmoseley@stephentoub@MihuBot@EgorBo@MihaZupan
, 'i'); if (__m === '*' || __re.test(location.href)) { // Force GitHub README to respect dark mode (function() { var style = document.createElement('style'); style.textContent = ' .markdown-body { color-scheme: dark light; } .markdown-body pre { background: #161b22 !important; } .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; } .markdown-body table th, .markdown-body table td { border-color: #30363d !important; } .markdown-body img { background: #0d1117; } .markdown-body blockquote { border-left-color: #8b949e; } .markdown-body hr { border-color: #30363d; } '; document.head.appendChild(style); })(); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ' Fix BOL anchor not writing back updated position in TryFindNextPossibleStartingPosition by Copilot · Pull Request #125280 · dotnet/runtime · GitHub
Skip to content

Fix BOL anchor not writing back updated position in TryFindNextPossibleStartingPosition - #125280

Merged
stephentoub merged 2 commits into
mainfrom
copilot/fix-regex-pattern-performance
Mar 7, 2026
Merged

Fix BOL anchor not writing back updated position in TryFindNextPossibleStartingPosition#125280
stephentoub merged 2 commits into
mainfrom
copilot/fix-regex-pattern-performance

Conversation

CopilotAI commented Mar 6, 2026

Copy link
Copy Markdown
Contributor

Description

The BOL (^ with Multiline) handler in TryFindNextPossibleStartingPosition uses vectorized IndexOf('\n') to advance pos to the next line start, but never writes the result back to base.runtextpos. For patterns where BOL is the only optimization (e.g., bare ^), the method returns true with NoSearch mode and the caller retries from the original position — negating the skip entirely.

The generated code was:

intpos=base.runtextpos;if(pos>0&&inputSpan[pos-1]!='\n'){intnewlinePos=inputSpan.Slice(pos).IndexOf('\n');if((uint)newlinePos>inputSpan.Length-pos-1)gotoNoMatchFound;pos+=newlinePos+1;// ← base.runtextpos never set}returntrue;
  • Source generator (RegexGenerator.Emitter.cs): Emit base.runtextpos = pos; after the BOL position advance and length check
  • IL compiler (RegexCompiler.cs): Emit equivalent Ldthis(); Ldloc(pos); Stfld(RuntextposField);

All 30,817 functional and 1,034 unit regex tests pass.

Benchmark Results

EgorBot benchmarks confirm the fix on both x64 and ARM64 (Regex.Count() on 1000 matches of ^ with Multiline):

Linux AMD (EPYC 9V45):

Gap (chars)PRmainSpeedup
513.97 μs32.51 μs2.3x
8014.22 μs447.66 μs31x
32016.40 μs1,763.62 μs108x
128033.08 μs11,002.82 μs333x

macOS ARM64 (Apple M4):

Gap (chars)PRmainSpeedup
59.73 μs25.84 μs2.7x
8011.87 μs422.31 μs36x
32019.20 μs3,005.44 μs157x
128061.44 μs36,555.42 μs595x

Speedup scales linearly with inter-match gap size as expected — without the fix, the vectorized IndexOf('\n') skip was computed but never written back, causing character-by-character scanning.


💬 We'd love your input! Share your thoughts on Copilot coding agent in our 2 minute survey.

@danmoseley

Copy link
Copy Markdown
Contributor

I'll let this run since it's going, but I assume it will produce the exact same as this
de0549f

with that change, perf:

1000 matches, Regex.Count(), varying inter-match gap (ns/match):

Gap (chars)Engine^ Before^ After(?=\n) (control)Improvement
5Compiled304442(noise)
5SourceGenerated--5353(noise)
80Compiled34144458x
80SourceGenerated--5655~6x
320Compiled2,008----~87x
1,280Compiled16,8057473227x
1,280SourceGenerated--8785~193x

After the fix, ^ matches (?=\n) performance for both Compiled and SourceGenerated engines across all gap sizes. It is also now faster than (?<=\n|\A) (which has no FindFirstChar skip and must check every position).

I guess (?<=\n|\A) could be lowered to ^ but that's not very intersting.

@dotnet-policy-service

Copy link
Copy Markdown
Contributor

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

When the beginning-of-line (BOL) anchor in TryFindNextPossibleStartingPosition
finds a newline via IndexOf and advances pos, the updated position was never
written back to base.runtextpos. This caused the match engine to retry from
the original position, negating the vectorized IndexOf optimization for
patterns like bare '^' with Multiline.
Fix both the source generator (RegexGenerator.Emitter.cs) and the IL compiler
(RegexCompiler.cs) to set base.runtextpos = pos after the BOL position update.
Co-authored-by: stephentoub <2642209+stephentoub@users.noreply.github.com>
CopilotAI changed the title [WIP] Fix regex pattern performance issue with '^' constructFix BOL anchor not writing back updated position in TryFindNextPossibleStartingPositionMar 6, 2026
@stephentoub

Copy link
Copy Markdown
Member

@copilot use the performance benchmark skill to validate the improvement from this change

@stephentoub
stephentoub marked this pull request as ready for review March 6, 2026 22:17
CopilotAI review requested due to automatic review settings March 6, 2026 22:17
@stephentoub
stephentoub enabled auto-merge (squash) March 6, 2026 22:17

CopilotAI commented Mar 6, 2026

Copy link
Copy Markdown
ContributorAuthor

@copilot use the performance benchmark skill to validate the improvement from this change

@EgorBot -linux_amd -osx_arm64

usingSystem.Text;usingSystem.Text.RegularExpressions;usingBenchmarkDotNet.Attributes;usingBenchmarkDotNet.Running;BenchmarkSwitcher.FromAssembly(typeof(Bench).Assembly).Run(args);publicclassBench{privatestring_input=default!;privateRegex_bolCompiled=default!;[Params(5,80,320,1280)]publicintGap{get;set;}[GlobalSetup]publicvoidSetup(){varsb=newStringBuilder();for(inti=0;i<1000;i++){sb.Append('a',Gap);sb.Append('\n');}_input=sb.ToString();_bolCompiled=newRegex("^",RegexOptions.Multiline|RegexOptions.Compiled);}[Benchmark]publicintBolCount()=>_bolCompiled.Count(_input);}

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

Fixes an issue in the regex “find next starting position” anchor handling where the computed position bump for ^ (BOL with Multiline) wasn’t persisted back to base.runtextpos, causing the caller to retry from the original position and lose the intended skip.

Changes:

  • Update the IL-emitting compiler (RegexCompiler.cs) to store the advanced pos back into base.runtextpos for the BOL optimization path.
  • Update the source generator emitter (RegexGenerator.Emitter.cs) to emit base.runtextpos = pos; after advancing past the next newline for BOL.

Reviewed changes

Copilot reviewed 2 out of 2 changed files in this pull request and generated 2 comments.

FileDescription
src/libraries/System.Text.RegularExpressions/src/System/Text/RegularExpressions/RegexCompiler.csPersists the BOL-advanced pos into RuntextposField so NoSearch mode uses the updated starting position.
src/libraries/System.Text.RegularExpressions/gen/RegexGenerator.Emitter.csEmits base.runtextpos = pos; after BOL-based pos advancement so generated code doesn’t lose the skip.

@danmoseley

Copy link
Copy Markdown
Contributor

@MihuBot regexdiff

@danmoseley

Copy link
Copy Markdown
Contributor

Maybe we should have a skill in this repo that explains @MihuBot commands like "benchmark regex" and "regexdiff" and such. I don't see anything for that. @MihaZupan ?

@danmoseley

Copy link
Copy Markdown
Contributor

Oh whoops, seeems like it started a jitdiff because of me mentioning it..

danmoseley
danmoseley approved these changes Mar 6, 2026
@MihuBot

Copy link
Copy Markdown

172 out of 18857 patterns have generated source code changes.

Examples of GeneratedRegex source diffs
"^" (5871 uses)
[GeneratedRegex("^",RegexOptions.Multiline)]
 {
goto NoMatchFound;
}
+ base.runtextpos = pos;
}
return true;
"^#include <([^>]+)>\\s*$" (2606 uses)
[GeneratedRegex("^#include <([^>]+)>\\s*$",RegexOptions.IgnoreCase|RegexOptions.Multiline)]
 {
goto NoMatchFound;
}
+ base.runtextpos = pos;
}
// The pattern has the literal "#include" ordinal case-insensitive at the beginning of the pattern. Find the next occurrence.
"^-+ *BEGIN (?<keyName>\\w+( \\w+)*) PRIVATE ..." (1964 uses)
[GeneratedRegex("^-+ *BEGIN (?<keyName>\\w+( \\w+)*) PRIVATE KEY *-+\\r?\\n(Proc-Type: 4,ENCRYPTED\\r?\\nDEK-Info: (?<cipherName>[A-Z0-9-]+),(?<salt>[A-F0-9]+)\\r?\\n\\r?\\n)?(?<data>([a-zA-Z0-9/+=]{1,80}\\r?\\n)+)-+ *END \\k<keyName> PRIVATE KEY *-+",RegexOptions.Multiline)]
 {
goto NoMatchFound;
}
+ base.runtextpos = pos;
}
// The pattern begins with a character in the set \-.
"^ *> ?" (823 uses)
[GeneratedRegex("^ *> ?",RegexOptions.Multiline)]
 {
goto NoMatchFound;
}
+ base.runtextpos = pos;
}
// The pattern begins with a character in the set [ >].
"^ +$" (823 uses)
[GeneratedRegex("^ +$",RegexOptions.Multiline)]
 {
goto NoMatchFound;
}
+ base.runtextpos = pos;
}
// The pattern begins with a character in the set .
"^ {4}" (823 uses)
[GeneratedRegex("^ {4}",RegexOptions.Multiline)]
 {
goto NoMatchFound;
}
+ base.runtextpos = pos;
}
// The pattern has the literal " " at the beginning of the pattern. Find the next occurrence.
"^( *)((?:[*+-]|\\d+\\.)) [^\\n]*(?:\\n(?!\\1 ..." (823 uses)
[GeneratedRegex("^( *)((?:[*+-]|\\d+\\.)) [^\\n]*(?:\\n(?!\\1(?:[*+-]|\\d+\\.) )[^\\n]*)*",RegexOptions.Multiline)]
 {
goto NoMatchFound;
}
+ base.runtextpos = pos;
}
// The pattern begins with a character in the set [ *+\-\d].
"^(\\d+)\\.(\\d+)\\.(\\d+)" (599 uses)
[GeneratedRegex("^(\\d+)\\.(\\d+)\\.(\\d+)",RegexOptions.Multiline)]
 {
goto NoMatchFound;
}
+ base.runtextpos = pos;
}
// The pattern begins with a Unicode digit.
"\r\n ^\r\n [\\x20\\t]* ..." (569 uses)
[GeneratedRegex("\r\n ^\r\n [\\x20\\t]*\r\n\\w+ [\\x20\\t]+\r\n (?<frame>\r\n (?<type> [^\\x20\\t]+ ) \\.\r\n (?<method> [^\\x20\\t]+? ) [\\x20\\t]*\r\n (?<params> \\( ( [\\x20\\t]* \\)\r\n | (?<pt> .+?) [\\x20\\t]+ (?<pn> .+?)\r\n (, [\\x20\\t]* (?<pt> .+?) [\\x20\\t]+ (?<pn> .+?) )* \\) ) )\r\n ( [\\x20\\t]+\r\n ( # Microsoft .NET stack traces\r\n\\w+ [\\x20\\t]+\r\n (?<file> [a-z] \\: .+? )\r\n\\: \\w+ [\\x20\\t]+\r\n (?<line> [0-9]+ ) \\p{P}?\r\n | # Mono stack traces\r\n\\[0x[0-9a-f]+\\] [\\x20\\t]+ \\w+ [\\x20\\t]+\r\n <(?<file> [^>]+ )>\r\n :(?<line> [0-9]+ )\r\n )\r\n )?\r\n )\r\n\\s*\r\n $",RegexOptions.IgnoreCase|RegexOptions.Multiline|RegexOptions.ExplicitCapture|RegexOptions.IgnorePatternWhitespace|RegexOptions.CultureInvariant)]
 {
goto NoMatchFound;
}
+ base.runtextpos = pos;
}
// The pattern begins with a character in the set [\t \w].
"^LD_LIBRARY_PATH=(.*)$" (526 uses)
[GeneratedRegex("^LD_LIBRARY_PATH=(.*)$",RegexOptions.Multiline)]
 {
goto NoMatchFound;
}
+ base.runtextpos = pos;
}
// The pattern has the literal "LD_LIBRARY_PATH=" at the beginning of the pattern. Find the next occurrence.

For more diff examples, see https://gist.github.com/MihuBot/d7021d5c0016c70a0e532293f19acdab

JIT assembly changes
Total bytes of base: 55609071
Total bytes of diff: 55610219
Total bytes of delta: 1148 (0.00 % of base)
Total relative delta: 4.41
diff is a regression.
relative diff is a regression.

For a list of JIT diff regressions, see Regressions.md

Sample source code for further analysis
conststringJsonPath="RegexResults-1804.json";if(!File.Exists(JsonPath)){awaitusingvararchiveStream=awaitnewHttpClient().GetStreamAsync("https://mihubot.xyz/r/FIih1ySA");usingvararchive=newZipArchive(archiveStream,ZipArchiveMode.Read);archive.Entries.First(e =>e.Name=="Results.json").ExtractToFile(JsonPath);}usingFileStreamjsonFileStream=File.OpenRead(JsonPath);RegexEntry[]entries=JsonSerializer.Deserialize<RegexEntry[]>(jsonFileStream,newJsonSerializerOptions{IncludeFields=true})!;Console.WriteLine($"Working with {entries.Length} patterns");recordKnownPattern(stringPattern,RegexOptionsOptions,intCount);sealedclassRegexEntry{publicrequiredKnownPatternRegex{get;set;}publicrequiredstringMainSource{get;set;}publicrequiredstringPrSource{get;set;}publicstring?FullDiff{get;set;}publicstring?ShortDiff{get;set;}public(stringName,stringValues)[]?SearchValuesOfChar{get;set;}public(string[]Values,StringComparisonComparisonType)[]?SearchValuesOfString{get;set;}}

@EgorBo

Copy link
Copy Markdown
Member

@copilot, benchmark results are ready:

Please analyze the results and act accordingly.
NOTE: some benchmarks may be flaky or bi-modal, so use your judgment when interpreting small differences.

CopilotAI commented Mar 6, 2026

Copy link
Copy Markdown
ContributorAuthor

@copilot, benchmark results are ready:

Benchmark results confirm the fix provides significant improvements that scale with inter-match gap size:

Linux AMD (EPYC 9V45):

GapPRmainSpeedup
513.97 μs32.51 μs2.3x
8014.22 μs447.66 μs31x
32016.40 μs1,763.62 μs108x
128033.08 μs11,002.82 μs333x

macOS ARM64 (Apple M4):

GapPRmainSpeedup
59.73 μs25.84 μs2.7x
8011.87 μs422.31 μs36x
32019.20 μs3,005.44 μs157x
128061.44 μs36,555.42 μs595x

The speedup scales linearly with gap size as expected — without the fix, the vectorized IndexOf('\n') skip was computed but never written back, causing character-by-character scanning. With the fix, the skip is properly applied. Results are consistent with @danmoseley's earlier measurements and confirm the fix on both x64 and ARM64.

Warning

Firewall rules blocked me from connecting to one or more addresses (expand for details)

I tried to connect to the following addresses, but was blocked by firewall rules:

  • bot.egorbo.com
    • Triggering command: /home/REDACTED/work/_temp/ghcca-node/node/bin/node /home/REDACTED/work/_temp/ghcca-node/node/bin/node --enable-source-maps /home/REDACTED/work/_temp/copilot-developer-action-main/dist/index.js (dns block)

If you need me to access, download, or install something from one of these locations, you can either:

@danmoseley

Copy link
Copy Markdown
Contributor

As above, this fixes quite everyday patterns like ^LD_LIBRARY_PATH=(.*)$ where there IS a nice string to search fast for. But if I understand right, until this fix, that could be arbitrarily impacted by wasting the find next position advance. So I guess depending on the text, this might significantly improve such patterns too. Just maybe not as much as ^

@MihaZupan

MihaZupan commented Mar 7, 2026

Copy link
Copy Markdown
Member

Maybe we should have a skill in this repo that explains @MihuBot commands like "benchmark regex" and "regexdiff" and such

We do have a skill for Egor's bot for benchmarks. MihuBot's arguments are simpler (no benchmark source), documented here. I don't think teaching copilot how to trigger it via comments would be too useful, but giving the agent the ability to invoke it and wait for results as it's working (i.e. via REST API) could be interesting.

Sign up for freeto subscribe to this conversation on GitHub. Already have an account? Sign in.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

7 participants

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

Fix BOL anchor not writing back updated position in TryFindNextPossibleStartingPosition - #125280

Merged
stephentoub merged 2 commits into
mainfrom
copilot/fix-regex-pattern-performance
Mar 7, 2026
Merged

Fix BOL anchor not writing back updated position in TryFindNextPossibleStartingPosition#125280
stephentoub merged 2 commits into
mainfrom
copilot/fix-regex-pattern-performance

Conversation

CopilotAI commented Mar 6, 2026

Copy link
Copy Markdown
Contributor

Description

The BOL (^ with Multiline) handler in TryFindNextPossibleStartingPosition uses vectorized IndexOf('\n') to advance pos to the next line start, but never writes the result back to base.runtextpos. For patterns where BOL is the only optimization (e.g., bare ^), the method returns true with NoSearch mode and the caller retries from the original position — negating the skip entirely.

The generated code was:

intpos=base.runtextpos;if(pos>0&&inputSpan[pos-1]!='\n'){intnewlinePos=inputSpan.Slice(pos).IndexOf('\n');if((uint)newlinePos>inputSpan.Length-pos-1)gotoNoMatchFound;pos+=newlinePos+1;// ← base.runtextpos never set}returntrue;
  • Source generator (RegexGenerator.Emitter.cs): Emit base.runtextpos = pos; after the BOL position advance and length check
  • IL compiler (RegexCompiler.cs): Emit equivalent Ldthis(); Ldloc(pos); Stfld(RuntextposField);

All 30,817 functional and 1,034 unit regex tests pass.

Benchmark Results

EgorBot benchmarks confirm the fix on both x64 and ARM64 (Regex.Count() on 1000 matches of ^ with Multiline):

Linux AMD (EPYC 9V45):

Gap (chars)PRmainSpeedup
513.97 μs32.51 μs2.3x
8014.22 μs447.66 μs31x
32016.40 μs1,763.62 μs108x
128033.08 μs11,002.82 μs333x

macOS ARM64 (Apple M4):

Gap (chars)PRmainSpeedup
59.73 μs25.84 μs2.7x
8011.87 μs422.31 μs36x
32019.20 μs3,005.44 μs157x
128061.44 μs36,555.42 μs595x

Speedup scales linearly with inter-match gap size as expected — without the fix, the vectorized IndexOf('\n') skip was computed but never written back, causing character-by-character scanning.


💬 We'd love your input! Share your thoughts on Copilot coding agent in our 2 minute survey.

@danmoseley

Copy link
Copy Markdown
Contributor

I'll let this run since it's going, but I assume it will produce the exact same as this
de0549f

with that change, perf:

1000 matches, Regex.Count(), varying inter-match gap (ns/match):

Gap (chars)Engine^ Before^ After(?=\n) (control)Improvement
5Compiled304442(noise)
5SourceGenerated--5353(noise)
80Compiled34144458x
80SourceGenerated--5655~6x
320Compiled2,008----~87x
1,280Compiled16,8057473227x
1,280SourceGenerated--8785~193x

After the fix, ^ matches (?=\n) performance for both Compiled and SourceGenerated engines across all gap sizes. It is also now faster than (?<=\n|\A) (which has no FindFirstChar skip and must check every position).

I guess (?<=\n|\A) could be lowered to ^ but that's not very intersting.

@dotnet-policy-service

Copy link
Copy Markdown
Contributor

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

When the beginning-of-line (BOL) anchor in TryFindNextPossibleStartingPosition
finds a newline via IndexOf and advances pos, the updated position was never
written back to base.runtextpos. This caused the match engine to retry from
the original position, negating the vectorized IndexOf optimization for
patterns like bare '^' with Multiline.
Fix both the source generator (RegexGenerator.Emitter.cs) and the IL compiler
(RegexCompiler.cs) to set base.runtextpos = pos after the BOL position update.
Co-authored-by: stephentoub <2642209+stephentoub@users.noreply.github.com>
CopilotAI changed the title [WIP] Fix regex pattern performance issue with '^' constructFix BOL anchor not writing back updated position in TryFindNextPossibleStartingPositionMar 6, 2026
@stephentoub

Copy link
Copy Markdown
Member

@copilot use the performance benchmark skill to validate the improvement from this change

@stephentoub
stephentoub marked this pull request as ready for review March 6, 2026 22:17
CopilotAI review requested due to automatic review settings March 6, 2026 22:17
@stephentoub
stephentoub enabled auto-merge (squash) March 6, 2026 22:17

CopilotAI commented Mar 6, 2026

Copy link
Copy Markdown
ContributorAuthor

@copilot use the performance benchmark skill to validate the improvement from this change

@EgorBot -linux_amd -osx_arm64

usingSystem.Text;usingSystem.Text.RegularExpressions;usingBenchmarkDotNet.Attributes;usingBenchmarkDotNet.Running;BenchmarkSwitcher.FromAssembly(typeof(Bench).Assembly).Run(args);publicclassBench{privatestring_input=default!;privateRegex_bolCompiled=default!;[Params(5,80,320,1280)]publicintGap{get;set;}[GlobalSetup]publicvoidSetup(){varsb=newStringBuilder();for(inti=0;i<1000;i++){sb.Append('a',Gap);sb.Append('\n');}_input=sb.ToString();_bolCompiled=newRegex("^",RegexOptions.Multiline|RegexOptions.Compiled);}[Benchmark]publicintBolCount()=>_bolCompiled.Count(_input);}

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

Fixes an issue in the regex “find next starting position” anchor handling where the computed position bump for ^ (BOL with Multiline) wasn’t persisted back to base.runtextpos, causing the caller to retry from the original position and lose the intended skip.

Changes:

  • Update the IL-emitting compiler (RegexCompiler.cs) to store the advanced pos back into base.runtextpos for the BOL optimization path.
  • Update the source generator emitter (RegexGenerator.Emitter.cs) to emit base.runtextpos = pos; after advancing past the next newline for BOL.

Reviewed changes

Copilot reviewed 2 out of 2 changed files in this pull request and generated 2 comments.

FileDescription
src/libraries/System.Text.RegularExpressions/src/System/Text/RegularExpressions/RegexCompiler.csPersists the BOL-advanced pos into RuntextposField so NoSearch mode uses the updated starting position.
src/libraries/System.Text.RegularExpressions/gen/RegexGenerator.Emitter.csEmits base.runtextpos = pos; after BOL-based pos advancement so generated code doesn’t lose the skip.

@danmoseley

Copy link
Copy Markdown
Contributor

@MihuBot regexdiff

@danmoseley

Copy link
Copy Markdown
Contributor

Maybe we should have a skill in this repo that explains @MihuBot commands like "benchmark regex" and "regexdiff" and such. I don't see anything for that. @MihaZupan ?

@danmoseley

Copy link
Copy Markdown
Contributor

Oh whoops, seeems like it started a jitdiff because of me mentioning it..

danmoseley
danmoseley approved these changes Mar 6, 2026
@MihuBot

Copy link
Copy Markdown

172 out of 18857 patterns have generated source code changes.

Examples of GeneratedRegex source diffs
"^" (5871 uses)
[GeneratedRegex("^",RegexOptions.Multiline)]
 {
goto NoMatchFound;
}
+ base.runtextpos = pos;
}
return true;
"^#include <([^>]+)>\\s*$" (2606 uses)
[GeneratedRegex("^#include <([^>]+)>\\s*$",RegexOptions.IgnoreCase|RegexOptions.Multiline)]
 {
goto NoMatchFound;
}
+ base.runtextpos = pos;
}
// The pattern has the literal "#include" ordinal case-insensitive at the beginning of the pattern. Find the next occurrence.
"^-+ *BEGIN (?<keyName>\\w+( \\w+)*) PRIVATE ..." (1964 uses)
[GeneratedRegex("^-+ *BEGIN (?<keyName>\\w+( \\w+)*) PRIVATE KEY *-+\\r?\\n(Proc-Type: 4,ENCRYPTED\\r?\\nDEK-Info: (?<cipherName>[A-Z0-9-]+),(?<salt>[A-F0-9]+)\\r?\\n\\r?\\n)?(?<data>([a-zA-Z0-9/+=]{1,80}\\r?\\n)+)-+ *END \\k<keyName> PRIVATE KEY *-+",RegexOptions.Multiline)]
 {
goto NoMatchFound;
}
+ base.runtextpos = pos;
}
// The pattern begins with a character in the set \-.
"^ *> ?" (823 uses)
[GeneratedRegex("^ *> ?",RegexOptions.Multiline)]
 {
goto NoMatchFound;
}
+ base.runtextpos = pos;
}
// The pattern begins with a character in the set [ >].
"^ +$" (823 uses)
[GeneratedRegex("^ +$",RegexOptions.Multiline)]
 {
goto NoMatchFound;
}
+ base.runtextpos = pos;
}
// The pattern begins with a character in the set .
"^ {4}" (823 uses)
[GeneratedRegex("^ {4}",RegexOptions.Multiline)]
 {
goto NoMatchFound;
}
+ base.runtextpos = pos;
}
// The pattern has the literal " " at the beginning of the pattern. Find the next occurrence.
"^( *)((?:[*+-]|\\d+\\.)) [^\\n]*(?:\\n(?!\\1 ..." (823 uses)
[GeneratedRegex("^( *)((?:[*+-]|\\d+\\.)) [^\\n]*(?:\\n(?!\\1(?:[*+-]|\\d+\\.) )[^\\n]*)*",RegexOptions.Multiline)]
 {
goto NoMatchFound;
}
+ base.runtextpos = pos;
}
// The pattern begins with a character in the set [ *+\-\d].
"^(\\d+)\\.(\\d+)\\.(\\d+)" (599 uses)
[GeneratedRegex("^(\\d+)\\.(\\d+)\\.(\\d+)",RegexOptions.Multiline)]
 {
goto NoMatchFound;
}
+ base.runtextpos = pos;
}
// The pattern begins with a Unicode digit.
"\r\n ^\r\n [\\x20\\t]* ..." (569 uses)
[GeneratedRegex("\r\n ^\r\n [\\x20\\t]*\r\n\\w+ [\\x20\\t]+\r\n (?<frame>\r\n (?<type> [^\\x20\\t]+ ) \\.\r\n (?<method> [^\\x20\\t]+? ) [\\x20\\t]*\r\n (?<params> \\( ( [\\x20\\t]* \\)\r\n | (?<pt> .+?) [\\x20\\t]+ (?<pn> .+?)\r\n (, [\\x20\\t]* (?<pt> .+?) [\\x20\\t]+ (?<pn> .+?) )* \\) ) )\r\n ( [\\x20\\t]+\r\n ( # Microsoft .NET stack traces\r\n\\w+ [\\x20\\t]+\r\n (?<file> [a-z] \\: .+? )\r\n\\: \\w+ [\\x20\\t]+\r\n (?<line> [0-9]+ ) \\p{P}?\r\n | # Mono stack traces\r\n\\[0x[0-9a-f]+\\] [\\x20\\t]+ \\w+ [\\x20\\t]+\r\n <(?<file> [^>]+ )>\r\n :(?<line> [0-9]+ )\r\n )\r\n )?\r\n )\r\n\\s*\r\n $",RegexOptions.IgnoreCase|RegexOptions.Multiline|RegexOptions.ExplicitCapture|RegexOptions.IgnorePatternWhitespace|RegexOptions.CultureInvariant)]
 {
goto NoMatchFound;
}
+ base.runtextpos = pos;
}
// The pattern begins with a character in the set [\t \w].
"^LD_LIBRARY_PATH=(.*)$" (526 uses)
[GeneratedRegex("^LD_LIBRARY_PATH=(.*)$",RegexOptions.Multiline)]
 {
goto NoMatchFound;
}
+ base.runtextpos = pos;
}
// The pattern has the literal "LD_LIBRARY_PATH=" at the beginning of the pattern. Find the next occurrence.

For more diff examples, see https://gist.github.com/MihuBot/d7021d5c0016c70a0e532293f19acdab

JIT assembly changes
Total bytes of base: 55609071
Total bytes of diff: 55610219
Total bytes of delta: 1148 (0.00 % of base)
Total relative delta: 4.41
diff is a regression.
relative diff is a regression.

For a list of JIT diff regressions, see Regressions.md

Sample source code for further analysis
conststringJsonPath="RegexResults-1804.json";if(!File.Exists(JsonPath)){awaitusingvararchiveStream=awaitnewHttpClient().GetStreamAsync("https://mihubot.xyz/r/FIih1ySA");usingvararchive=newZipArchive(archiveStream,ZipArchiveMode.Read);archive.Entries.First(e =>e.Name=="Results.json").ExtractToFile(JsonPath);}usingFileStreamjsonFileStream=File.OpenRead(JsonPath);RegexEntry[]entries=JsonSerializer.Deserialize<RegexEntry[]>(jsonFileStream,newJsonSerializerOptions{IncludeFields=true})!;Console.WriteLine($"Working with {entries.Length} patterns");recordKnownPattern(stringPattern,RegexOptionsOptions,intCount);sealedclassRegexEntry{publicrequiredKnownPatternRegex{get;set;}publicrequiredstringMainSource{get;set;}publicrequiredstringPrSource{get;set;}publicstring?FullDiff{get;set;}publicstring?ShortDiff{get;set;}public(stringName,stringValues)[]?SearchValuesOfChar{get;set;}public(string[]Values,StringComparisonComparisonType)[]?SearchValuesOfString{get;set;}}

@EgorBo

Copy link
Copy Markdown
Member

@copilot, benchmark results are ready:

Please analyze the results and act accordingly.
NOTE: some benchmarks may be flaky or bi-modal, so use your judgment when interpreting small differences.

CopilotAI commented Mar 6, 2026

Copy link
Copy Markdown
ContributorAuthor

@copilot, benchmark results are ready:

Benchmark results confirm the fix provides significant improvements that scale with inter-match gap size:

Linux AMD (EPYC 9V45):

GapPRmainSpeedup
513.97 μs32.51 μs2.3x
8014.22 μs447.66 μs31x
32016.40 μs1,763.62 μs108x
128033.08 μs11,002.82 μs333x

macOS ARM64 (Apple M4):

GapPRmainSpeedup
59.73 μs25.84 μs2.7x
8011.87 μs422.31 μs36x
32019.20 μs3,005.44 μs157x
128061.44 μs36,555.42 μs595x

The speedup scales linearly with gap size as expected — without the fix, the vectorized IndexOf('\n') skip was computed but never written back, causing character-by-character scanning. With the fix, the skip is properly applied. Results are consistent with @danmoseley's earlier measurements and confirm the fix on both x64 and ARM64.

Warning

Firewall rules blocked me from connecting to one or more addresses (expand for details)

I tried to connect to the following addresses, but was blocked by firewall rules:

  • bot.egorbo.com
    • Triggering command: /home/REDACTED/work/_temp/ghcca-node/node/bin/node /home/REDACTED/work/_temp/ghcca-node/node/bin/node --enable-source-maps /home/REDACTED/work/_temp/copilot-developer-action-main/dist/index.js (dns block)

If you need me to access, download, or install something from one of these locations, you can either:

@danmoseley

Copy link
Copy Markdown
Contributor

As above, this fixes quite everyday patterns like ^LD_LIBRARY_PATH=(.*)$ where there IS a nice string to search fast for. But if I understand right, until this fix, that could be arbitrarily impacted by wasting the find next position advance. So I guess depending on the text, this might significantly improve such patterns too. Just maybe not as much as ^

@MihaZupan

MihaZupan commented Mar 7, 2026

Copy link
Copy Markdown
Member

Maybe we should have a skill in this repo that explains @MihuBot commands like "benchmark regex" and "regexdiff" and such

We do have a skill for Egor's bot for benchmarks. MihuBot's arguments are simpler (no benchmark source), documented here. I don't think teaching copilot how to trigger it via comments would be too useful, but giving the agent the ability to invoke it and wait for results as it's working (i.e. via REST API) could be interesting.

Sign up for freeto subscribe to this conversation on GitHub. Already have an account? Sign in.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

7 participants

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

Fix BOL anchor not writing back updated position in TryFindNextPossibleStartingPosition - #125280

Merged
stephentoub merged 2 commits into
mainfrom
copilot/fix-regex-pattern-performance
Mar 7, 2026
Merged

Fix BOL anchor not writing back updated position in TryFindNextPossibleStartingPosition#125280
stephentoub merged 2 commits into
mainfrom
copilot/fix-regex-pattern-performance

Conversation

CopilotAI commented Mar 6, 2026

Copy link
Copy Markdown
Contributor

Description

The BOL (^ with Multiline) handler in TryFindNextPossibleStartingPosition uses vectorized IndexOf('\n') to advance pos to the next line start, but never writes the result back to base.runtextpos. For patterns where BOL is the only optimization (e.g., bare ^), the method returns true with NoSearch mode and the caller retries from the original position — negating the skip entirely.

The generated code was:

intpos=base.runtextpos;if(pos>0&&inputSpan[pos-1]!='\n'){intnewlinePos=inputSpan.Slice(pos).IndexOf('\n');if((uint)newlinePos>inputSpan.Length-pos-1)gotoNoMatchFound;pos+=newlinePos+1;// ← base.runtextpos never set}returntrue;
  • Source generator (RegexGenerator.Emitter.cs): Emit base.runtextpos = pos; after the BOL position advance and length check
  • IL compiler (RegexCompiler.cs): Emit equivalent Ldthis(); Ldloc(pos); Stfld(RuntextposField);

All 30,817 functional and 1,034 unit regex tests pass.

Benchmark Results

EgorBot benchmarks confirm the fix on both x64 and ARM64 (Regex.Count() on 1000 matches of ^ with Multiline):

Linux AMD (EPYC 9V45):

Gap (chars)PRmainSpeedup
513.97 μs32.51 μs2.3x
8014.22 μs447.66 μs31x
32016.40 μs1,763.62 μs108x
128033.08 μs11,002.82 μs333x

macOS ARM64 (Apple M4):

Gap (chars)PRmainSpeedup
59.73 μs25.84 μs2.7x
8011.87 μs422.31 μs36x
32019.20 μs3,005.44 μs157x
128061.44 μs36,555.42 μs595x

Speedup scales linearly with inter-match gap size as expected — without the fix, the vectorized IndexOf('\n') skip was computed but never written back, causing character-by-character scanning.


💬 We'd love your input! Share your thoughts on Copilot coding agent in our 2 minute survey.

@danmoseley

Copy link
Copy Markdown
Contributor

I'll let this run since it's going, but I assume it will produce the exact same as this
de0549f

with that change, perf:

1000 matches, Regex.Count(), varying inter-match gap (ns/match):

Gap (chars)Engine^ Before^ After(?=\n) (control)Improvement
5Compiled304442(noise)
5SourceGenerated--5353(noise)
80Compiled34144458x
80SourceGenerated--5655~6x
320Compiled2,008----~87x
1,280Compiled16,8057473227x
1,280SourceGenerated--8785~193x

After the fix, ^ matches (?=\n) performance for both Compiled and SourceGenerated engines across all gap sizes. It is also now faster than (?<=\n|\A) (which has no FindFirstChar skip and must check every position).

I guess (?<=\n|\A) could be lowered to ^ but that's not very intersting.

@dotnet-policy-service

Copy link
Copy Markdown
Contributor

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

When the beginning-of-line (BOL) anchor in TryFindNextPossibleStartingPosition
finds a newline via IndexOf and advances pos, the updated position was never
written back to base.runtextpos. This caused the match engine to retry from
the original position, negating the vectorized IndexOf optimization for
patterns like bare '^' with Multiline.
Fix both the source generator (RegexGenerator.Emitter.cs) and the IL compiler
(RegexCompiler.cs) to set base.runtextpos = pos after the BOL position update.
Co-authored-by: stephentoub <2642209+stephentoub@users.noreply.github.com>
CopilotAI changed the title [WIP] Fix regex pattern performance issue with '^' constructFix BOL anchor not writing back updated position in TryFindNextPossibleStartingPositionMar 6, 2026
@stephentoub

Copy link
Copy Markdown
Member

@copilot use the performance benchmark skill to validate the improvement from this change

@stephentoub
stephentoub marked this pull request as ready for review March 6, 2026 22:17
CopilotAI review requested due to automatic review settings March 6, 2026 22:17
@stephentoub
stephentoub enabled auto-merge (squash) March 6, 2026 22:17

CopilotAI commented Mar 6, 2026

Copy link
Copy Markdown
ContributorAuthor

@copilot use the performance benchmark skill to validate the improvement from this change

@EgorBot -linux_amd -osx_arm64

usingSystem.Text;usingSystem.Text.RegularExpressions;usingBenchmarkDotNet.Attributes;usingBenchmarkDotNet.Running;BenchmarkSwitcher.FromAssembly(typeof(Bench).Assembly).Run(args);publicclassBench{privatestring_input=default!;privateRegex_bolCompiled=default!;[Params(5,80,320,1280)]publicintGap{get;set;}[GlobalSetup]publicvoidSetup(){varsb=newStringBuilder();for(inti=0;i<1000;i++){sb.Append('a',Gap);sb.Append('\n');}_input=sb.ToString();_bolCompiled=newRegex("^",RegexOptions.Multiline|RegexOptions.Compiled);}[Benchmark]publicintBolCount()=>_bolCompiled.Count(_input);}

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

Fixes an issue in the regex “find next starting position” anchor handling where the computed position bump for ^ (BOL with Multiline) wasn’t persisted back to base.runtextpos, causing the caller to retry from the original position and lose the intended skip.

Changes:

  • Update the IL-emitting compiler (RegexCompiler.cs) to store the advanced pos back into base.runtextpos for the BOL optimization path.
  • Update the source generator emitter (RegexGenerator.Emitter.cs) to emit base.runtextpos = pos; after advancing past the next newline for BOL.

Reviewed changes

Copilot reviewed 2 out of 2 changed files in this pull request and generated 2 comments.

FileDescription
src/libraries/System.Text.RegularExpressions/src/System/Text/RegularExpressions/RegexCompiler.csPersists the BOL-advanced pos into RuntextposField so NoSearch mode uses the updated starting position.
src/libraries/System.Text.RegularExpressions/gen/RegexGenerator.Emitter.csEmits base.runtextpos = pos; after BOL-based pos advancement so generated code doesn’t lose the skip.

@danmoseley

Copy link
Copy Markdown
Contributor

@MihuBot regexdiff

@danmoseley

Copy link
Copy Markdown
Contributor

Maybe we should have a skill in this repo that explains @MihuBot commands like "benchmark regex" and "regexdiff" and such. I don't see anything for that. @MihaZupan ?

@danmoseley

Copy link
Copy Markdown
Contributor

Oh whoops, seeems like it started a jitdiff because of me mentioning it..

danmoseley
danmoseley approved these changes Mar 6, 2026
@MihuBot

Copy link
Copy Markdown

172 out of 18857 patterns have generated source code changes.

Examples of GeneratedRegex source diffs
"^" (5871 uses)
[GeneratedRegex("^",RegexOptions.Multiline)]
 {
goto NoMatchFound;
}
+ base.runtextpos = pos;
}
return true;
"^#include <([^>]+)>\\s*$" (2606 uses)
[GeneratedRegex("^#include <([^>]+)>\\s*$",RegexOptions.IgnoreCase|RegexOptions.Multiline)]
 {
goto NoMatchFound;
}
+ base.runtextpos = pos;
}
// The pattern has the literal "#include" ordinal case-insensitive at the beginning of the pattern. Find the next occurrence.
"^-+ *BEGIN (?<keyName>\\w+( \\w+)*) PRIVATE ..." (1964 uses)
[GeneratedRegex("^-+ *BEGIN (?<keyName>\\w+( \\w+)*) PRIVATE KEY *-+\\r?\\n(Proc-Type: 4,ENCRYPTED\\r?\\nDEK-Info: (?<cipherName>[A-Z0-9-]+),(?<salt>[A-F0-9]+)\\r?\\n\\r?\\n)?(?<data>([a-zA-Z0-9/+=]{1,80}\\r?\\n)+)-+ *END \\k<keyName> PRIVATE KEY *-+",RegexOptions.Multiline)]
 {
goto NoMatchFound;
}
+ base.runtextpos = pos;
}
// The pattern begins with a character in the set \-.
"^ *> ?" (823 uses)
[GeneratedRegex("^ *> ?",RegexOptions.Multiline)]
 {
goto NoMatchFound;
}
+ base.runtextpos = pos;
}
// The pattern begins with a character in the set [ >].
"^ +$" (823 uses)
[GeneratedRegex("^ +$",RegexOptions.Multiline)]
 {
goto NoMatchFound;
}
+ base.runtextpos = pos;
}
// The pattern begins with a character in the set .
"^ {4}" (823 uses)
[GeneratedRegex("^ {4}",RegexOptions.Multiline)]
 {
goto NoMatchFound;
}
+ base.runtextpos = pos;
}
// The pattern has the literal " " at the beginning of the pattern. Find the next occurrence.
"^( *)((?:[*+-]|\\d+\\.)) [^\\n]*(?:\\n(?!\\1 ..." (823 uses)
[GeneratedRegex("^( *)((?:[*+-]|\\d+\\.)) [^\\n]*(?:\\n(?!\\1(?:[*+-]|\\d+\\.) )[^\\n]*)*",RegexOptions.Multiline)]
 {
goto NoMatchFound;
}
+ base.runtextpos = pos;
}
// The pattern begins with a character in the set [ *+\-\d].
"^(\\d+)\\.(\\d+)\\.(\\d+)" (599 uses)
[GeneratedRegex("^(\\d+)\\.(\\d+)\\.(\\d+)",RegexOptions.Multiline)]
 {
goto NoMatchFound;
}
+ base.runtextpos = pos;
}
// The pattern begins with a Unicode digit.
"\r\n ^\r\n [\\x20\\t]* ..." (569 uses)
[GeneratedRegex("\r\n ^\r\n [\\x20\\t]*\r\n\\w+ [\\x20\\t]+\r\n (?<frame>\r\n (?<type> [^\\x20\\t]+ ) \\.\r\n (?<method> [^\\x20\\t]+? ) [\\x20\\t]*\r\n (?<params> \\( ( [\\x20\\t]* \\)\r\n | (?<pt> .+?) [\\x20\\t]+ (?<pn> .+?)\r\n (, [\\x20\\t]* (?<pt> .+?) [\\x20\\t]+ (?<pn> .+?) )* \\) ) )\r\n ( [\\x20\\t]+\r\n ( # Microsoft .NET stack traces\r\n\\w+ [\\x20\\t]+\r\n (?<file> [a-z] \\: .+? )\r\n\\: \\w+ [\\x20\\t]+\r\n (?<line> [0-9]+ ) \\p{P}?\r\n | # Mono stack traces\r\n\\[0x[0-9a-f]+\\] [\\x20\\t]+ \\w+ [\\x20\\t]+\r\n <(?<file> [^>]+ )>\r\n :(?<line> [0-9]+ )\r\n )\r\n )?\r\n )\r\n\\s*\r\n $",RegexOptions.IgnoreCase|RegexOptions.Multiline|RegexOptions.ExplicitCapture|RegexOptions.IgnorePatternWhitespace|RegexOptions.CultureInvariant)]
 {
goto NoMatchFound;
}
+ base.runtextpos = pos;
}
// The pattern begins with a character in the set [\t \w].
"^LD_LIBRARY_PATH=(.*)$" (526 uses)
[GeneratedRegex("^LD_LIBRARY_PATH=(.*)$",RegexOptions.Multiline)]
 {
goto NoMatchFound;
}
+ base.runtextpos = pos;
}
// The pattern has the literal "LD_LIBRARY_PATH=" at the beginning of the pattern. Find the next occurrence.

For more diff examples, see https://gist.github.com/MihuBot/d7021d5c0016c70a0e532293f19acdab

JIT assembly changes
Total bytes of base: 55609071
Total bytes of diff: 55610219
Total bytes of delta: 1148 (0.00 % of base)
Total relative delta: 4.41
diff is a regression.
relative diff is a regression.

For a list of JIT diff regressions, see Regressions.md

Sample source code for further analysis
conststringJsonPath="RegexResults-1804.json";if(!File.Exists(JsonPath)){awaitusingvararchiveStream=awaitnewHttpClient().GetStreamAsync("https://mihubot.xyz/r/FIih1ySA");usingvararchive=newZipArchive(archiveStream,ZipArchiveMode.Read);archive.Entries.First(e =>e.Name=="Results.json").ExtractToFile(JsonPath);}usingFileStreamjsonFileStream=File.OpenRead(JsonPath);RegexEntry[]entries=JsonSerializer.Deserialize<RegexEntry[]>(jsonFileStream,newJsonSerializerOptions{IncludeFields=true})!;Console.WriteLine($"Working with {entries.Length} patterns");recordKnownPattern(stringPattern,RegexOptionsOptions,intCount);sealedclassRegexEntry{publicrequiredKnownPatternRegex{get;set;}publicrequiredstringMainSource{get;set;}publicrequiredstringPrSource{get;set;}publicstring?FullDiff{get;set;}publicstring?ShortDiff{get;set;}public(stringName,stringValues)[]?SearchValuesOfChar{get;set;}public(string[]Values,StringComparisonComparisonType)[]?SearchValuesOfString{get;set;}}

@EgorBo

Copy link
Copy Markdown
Member

@copilot, benchmark results are ready:

Please analyze the results and act accordingly.
NOTE: some benchmarks may be flaky or bi-modal, so use your judgment when interpreting small differences.

CopilotAI commented Mar 6, 2026

Copy link
Copy Markdown
ContributorAuthor

@copilot, benchmark results are ready:

Benchmark results confirm the fix provides significant improvements that scale with inter-match gap size:

Linux AMD (EPYC 9V45):

GapPRmainSpeedup
513.97 μs32.51 μs2.3x
8014.22 μs447.66 μs31x
32016.40 μs1,763.62 μs108x
128033.08 μs11,002.82 μs333x

macOS ARM64 (Apple M4):

GapPRmainSpeedup
59.73 μs25.84 μs2.7x
8011.87 μs422.31 μs36x
32019.20 μs3,005.44 μs157x
128061.44 μs36,555.42 μs595x

The speedup scales linearly with gap size as expected — without the fix, the vectorized IndexOf('\n') skip was computed but never written back, causing character-by-character scanning. With the fix, the skip is properly applied. Results are consistent with @danmoseley's earlier measurements and confirm the fix on both x64 and ARM64.

Warning

Firewall rules blocked me from connecting to one or more addresses (expand for details)

I tried to connect to the following addresses, but was blocked by firewall rules:

  • bot.egorbo.com
    • Triggering command: /home/REDACTED/work/_temp/ghcca-node/node/bin/node /home/REDACTED/work/_temp/ghcca-node/node/bin/node --enable-source-maps /home/REDACTED/work/_temp/copilot-developer-action-main/dist/index.js (dns block)

If you need me to access, download, or install something from one of these locations, you can either:

@danmoseley

Copy link
Copy Markdown
Contributor

As above, this fixes quite everyday patterns like ^LD_LIBRARY_PATH=(.*)$ where there IS a nice string to search fast for. But if I understand right, until this fix, that could be arbitrarily impacted by wasting the find next position advance. So I guess depending on the text, this might significantly improve such patterns too. Just maybe not as much as ^

@MihaZupan

MihaZupan commented Mar 7, 2026

Copy link
Copy Markdown
Member

Maybe we should have a skill in this repo that explains @MihuBot commands like "benchmark regex" and "regexdiff" and such

We do have a skill for Egor's bot for benchmarks. MihuBot's arguments are simpler (no benchmark source), documented here. I don't think teaching copilot how to trigger it via comments would be too useful, but giving the agent the ability to invoke it and wait for results as it's working (i.e. via REST API) could be interesting.

Sign up for freeto subscribe to this conversation on GitHub. Already have an account? Sign in.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

7 participants

@danmoseley@stephentoub@MihuBot@EgorBo@MihaZupan
, 'i'); if (__m === '*' || __re.test(location.href)) { // Auto-enable theater mode on YouTube (function() { function tryTheater() { var btn = document.querySelector('button[aria-label="Theater mode"], ytd-player #player button[title="Theater mode"]'); if (btn && !btn.classList.contains('activated')) { btn.click(); } } // Try immediately tryTheater(); // Try after navigation (SPA) var lastUrl = location.href; setInterval(function() { if (location.href !== lastUrl) { lastUrl = location.href; setTimeout(tryTheater, 500); } }, 1000); // Also try on player load var observer = new MutationObserver(tryTheater); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ' Fix BOL anchor not writing back updated position in TryFindNextPossibleStartingPosition by Copilot · Pull Request #125280 · dotnet/runtime · GitHub
Skip to content

Fix BOL anchor not writing back updated position in TryFindNextPossibleStartingPosition - #125280

Merged
stephentoub merged 2 commits into
mainfrom
copilot/fix-regex-pattern-performance
Mar 7, 2026
Merged

Fix BOL anchor not writing back updated position in TryFindNextPossibleStartingPosition#125280
stephentoub merged 2 commits into
mainfrom
copilot/fix-regex-pattern-performance

Conversation

CopilotAI commented Mar 6, 2026

Copy link
Copy Markdown
Contributor

Description

The BOL (^ with Multiline) handler in TryFindNextPossibleStartingPosition uses vectorized IndexOf('\n') to advance pos to the next line start, but never writes the result back to base.runtextpos. For patterns where BOL is the only optimization (e.g., bare ^), the method returns true with NoSearch mode and the caller retries from the original position — negating the skip entirely.

The generated code was:

intpos=base.runtextpos;if(pos>0&&inputSpan[pos-1]!='\n'){intnewlinePos=inputSpan.Slice(pos).IndexOf('\n');if((uint)newlinePos>inputSpan.Length-pos-1)gotoNoMatchFound;pos+=newlinePos+1;// ← base.runtextpos never set}returntrue;
  • Source generator (RegexGenerator.Emitter.cs): Emit base.runtextpos = pos; after the BOL position advance and length check
  • IL compiler (RegexCompiler.cs): Emit equivalent Ldthis(); Ldloc(pos); Stfld(RuntextposField);

All 30,817 functional and 1,034 unit regex tests pass.

Benchmark Results

EgorBot benchmarks confirm the fix on both x64 and ARM64 (Regex.Count() on 1000 matches of ^ with Multiline):

Linux AMD (EPYC 9V45):

Gap (chars)PRmainSpeedup
513.97 μs32.51 μs2.3x
8014.22 μs447.66 μs31x
32016.40 μs1,763.62 μs108x
128033.08 μs11,002.82 μs333x

macOS ARM64 (Apple M4):

Gap (chars)PRmainSpeedup
59.73 μs25.84 μs2.7x
8011.87 μs422.31 μs36x
32019.20 μs3,005.44 μs157x
128061.44 μs36,555.42 μs595x

Speedup scales linearly with inter-match gap size as expected — without the fix, the vectorized IndexOf('\n') skip was computed but never written back, causing character-by-character scanning.


💬 We'd love your input! Share your thoughts on Copilot coding agent in our 2 minute survey.

@danmoseley

Copy link
Copy Markdown
Contributor

I'll let this run since it's going, but I assume it will produce the exact same as this
de0549f

with that change, perf:

1000 matches, Regex.Count(), varying inter-match gap (ns/match):

Gap (chars)Engine^ Before^ After(?=\n) (control)Improvement
5Compiled304442(noise)
5SourceGenerated--5353(noise)
80Compiled34144458x
80SourceGenerated--5655~6x
320Compiled2,008----~87x
1,280Compiled16,8057473227x
1,280SourceGenerated--8785~193x

After the fix, ^ matches (?=\n) performance for both Compiled and SourceGenerated engines across all gap sizes. It is also now faster than (?<=\n|\A) (which has no FindFirstChar skip and must check every position).

I guess (?<=\n|\A) could be lowered to ^ but that's not very intersting.

@dotnet-policy-service

Copy link
Copy Markdown
Contributor

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

When the beginning-of-line (BOL) anchor in TryFindNextPossibleStartingPosition
finds a newline via IndexOf and advances pos, the updated position was never
written back to base.runtextpos. This caused the match engine to retry from
the original position, negating the vectorized IndexOf optimization for
patterns like bare '^' with Multiline.
Fix both the source generator (RegexGenerator.Emitter.cs) and the IL compiler
(RegexCompiler.cs) to set base.runtextpos = pos after the BOL position update.
Co-authored-by: stephentoub <2642209+stephentoub@users.noreply.github.com>
CopilotAI changed the title [WIP] Fix regex pattern performance issue with '^' constructFix BOL anchor not writing back updated position in TryFindNextPossibleStartingPositionMar 6, 2026
@stephentoub

Copy link
Copy Markdown
Member

@copilot use the performance benchmark skill to validate the improvement from this change

@stephentoub
stephentoub marked this pull request as ready for review March 6, 2026 22:17
CopilotAI review requested due to automatic review settings March 6, 2026 22:17
@stephentoub
stephentoub enabled auto-merge (squash) March 6, 2026 22:17

CopilotAI commented Mar 6, 2026

Copy link
Copy Markdown
ContributorAuthor

@copilot use the performance benchmark skill to validate the improvement from this change

@EgorBot -linux_amd -osx_arm64

usingSystem.Text;usingSystem.Text.RegularExpressions;usingBenchmarkDotNet.Attributes;usingBenchmarkDotNet.Running;BenchmarkSwitcher.FromAssembly(typeof(Bench).Assembly).Run(args);publicclassBench{privatestring_input=default!;privateRegex_bolCompiled=default!;[Params(5,80,320,1280)]publicintGap{get;set;}[GlobalSetup]publicvoidSetup(){varsb=newStringBuilder();for(inti=0;i<1000;i++){sb.Append('a',Gap);sb.Append('\n');}_input=sb.ToString();_bolCompiled=newRegex("^",RegexOptions.Multiline|RegexOptions.Compiled);}[Benchmark]publicintBolCount()=>_bolCompiled.Count(_input);}

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

Fixes an issue in the regex “find next starting position” anchor handling where the computed position bump for ^ (BOL with Multiline) wasn’t persisted back to base.runtextpos, causing the caller to retry from the original position and lose the intended skip.

Changes:

  • Update the IL-emitting compiler (RegexCompiler.cs) to store the advanced pos back into base.runtextpos for the BOL optimization path.
  • Update the source generator emitter (RegexGenerator.Emitter.cs) to emit base.runtextpos = pos; after advancing past the next newline for BOL.

Reviewed changes

Copilot reviewed 2 out of 2 changed files in this pull request and generated 2 comments.

FileDescription
src/libraries/System.Text.RegularExpressions/src/System/Text/RegularExpressions/RegexCompiler.csPersists the BOL-advanced pos into RuntextposField so NoSearch mode uses the updated starting position.
src/libraries/System.Text.RegularExpressions/gen/RegexGenerator.Emitter.csEmits base.runtextpos = pos; after BOL-based pos advancement so generated code doesn’t lose the skip.

@danmoseley

Copy link
Copy Markdown
Contributor

@MihuBot regexdiff

@danmoseley

Copy link
Copy Markdown
Contributor

Maybe we should have a skill in this repo that explains @MihuBot commands like "benchmark regex" and "regexdiff" and such. I don't see anything for that. @MihaZupan ?

@danmoseley

Copy link
Copy Markdown
Contributor

Oh whoops, seeems like it started a jitdiff because of me mentioning it..

danmoseley
danmoseley approved these changes Mar 6, 2026
@MihuBot

Copy link
Copy Markdown

172 out of 18857 patterns have generated source code changes.

Examples of GeneratedRegex source diffs
"^" (5871 uses)
[GeneratedRegex("^",RegexOptions.Multiline)]
 {
goto NoMatchFound;
}
+ base.runtextpos = pos;
}
return true;
"^#include <([^>]+)>\\s*$" (2606 uses)
[GeneratedRegex("^#include <([^>]+)>\\s*$",RegexOptions.IgnoreCase|RegexOptions.Multiline)]
 {
goto NoMatchFound;
}
+ base.runtextpos = pos;
}
// The pattern has the literal "#include" ordinal case-insensitive at the beginning of the pattern. Find the next occurrence.
"^-+ *BEGIN (?<keyName>\\w+( \\w+)*) PRIVATE ..." (1964 uses)
[GeneratedRegex("^-+ *BEGIN (?<keyName>\\w+( \\w+)*) PRIVATE KEY *-+\\r?\\n(Proc-Type: 4,ENCRYPTED\\r?\\nDEK-Info: (?<cipherName>[A-Z0-9-]+),(?<salt>[A-F0-9]+)\\r?\\n\\r?\\n)?(?<data>([a-zA-Z0-9/+=]{1,80}\\r?\\n)+)-+ *END \\k<keyName> PRIVATE KEY *-+",RegexOptions.Multiline)]
 {
goto NoMatchFound;
}
+ base.runtextpos = pos;
}
// The pattern begins with a character in the set \-.
"^ *> ?" (823 uses)
[GeneratedRegex("^ *> ?",RegexOptions.Multiline)]
 {
goto NoMatchFound;
}
+ base.runtextpos = pos;
}
// The pattern begins with a character in the set [ >].
"^ +$" (823 uses)
[GeneratedRegex("^ +$",RegexOptions.Multiline)]
 {
goto NoMatchFound;
}
+ base.runtextpos = pos;
}
// The pattern begins with a character in the set .
"^ {4}" (823 uses)
[GeneratedRegex("^ {4}",RegexOptions.Multiline)]
 {
goto NoMatchFound;
}
+ base.runtextpos = pos;
}
// The pattern has the literal " " at the beginning of the pattern. Find the next occurrence.
"^( *)((?:[*+-]|\\d+\\.)) [^\\n]*(?:\\n(?!\\1 ..." (823 uses)
[GeneratedRegex("^( *)((?:[*+-]|\\d+\\.)) [^\\n]*(?:\\n(?!\\1(?:[*+-]|\\d+\\.) )[^\\n]*)*",RegexOptions.Multiline)]
 {
goto NoMatchFound;
}
+ base.runtextpos = pos;
}
// The pattern begins with a character in the set [ *+\-\d].
"^(\\d+)\\.(\\d+)\\.(\\d+)" (599 uses)
[GeneratedRegex("^(\\d+)\\.(\\d+)\\.(\\d+)",RegexOptions.Multiline)]
 {
goto NoMatchFound;
}
+ base.runtextpos = pos;
}
// The pattern begins with a Unicode digit.
"\r\n ^\r\n [\\x20\\t]* ..." (569 uses)
[GeneratedRegex("\r\n ^\r\n [\\x20\\t]*\r\n\\w+ [\\x20\\t]+\r\n (?<frame>\r\n (?<type> [^\\x20\\t]+ ) \\.\r\n (?<method> [^\\x20\\t]+? ) [\\x20\\t]*\r\n (?<params> \\( ( [\\x20\\t]* \\)\r\n | (?<pt> .+?) [\\x20\\t]+ (?<pn> .+?)\r\n (, [\\x20\\t]* (?<pt> .+?) [\\x20\\t]+ (?<pn> .+?) )* \\) ) )\r\n ( [\\x20\\t]+\r\n ( # Microsoft .NET stack traces\r\n\\w+ [\\x20\\t]+\r\n (?<file> [a-z] \\: .+? )\r\n\\: \\w+ [\\x20\\t]+\r\n (?<line> [0-9]+ ) \\p{P}?\r\n | # Mono stack traces\r\n\\[0x[0-9a-f]+\\] [\\x20\\t]+ \\w+ [\\x20\\t]+\r\n <(?<file> [^>]+ )>\r\n :(?<line> [0-9]+ )\r\n )\r\n )?\r\n )\r\n\\s*\r\n $",RegexOptions.IgnoreCase|RegexOptions.Multiline|RegexOptions.ExplicitCapture|RegexOptions.IgnorePatternWhitespace|RegexOptions.CultureInvariant)]
 {
goto NoMatchFound;
}
+ base.runtextpos = pos;
}
// The pattern begins with a character in the set [\t \w].
"^LD_LIBRARY_PATH=(.*)$" (526 uses)
[GeneratedRegex("^LD_LIBRARY_PATH=(.*)$",RegexOptions.Multiline)]
 {
goto NoMatchFound;
}
+ base.runtextpos = pos;
}
// The pattern has the literal "LD_LIBRARY_PATH=" at the beginning of the pattern. Find the next occurrence.

For more diff examples, see https://gist.github.com/MihuBot/d7021d5c0016c70a0e532293f19acdab

JIT assembly changes
Total bytes of base: 55609071
Total bytes of diff: 55610219
Total bytes of delta: 1148 (0.00 % of base)
Total relative delta: 4.41
diff is a regression.
relative diff is a regression.

For a list of JIT diff regressions, see Regressions.md

Sample source code for further analysis
conststringJsonPath="RegexResults-1804.json";if(!File.Exists(JsonPath)){awaitusingvararchiveStream=awaitnewHttpClient().GetStreamAsync("https://mihubot.xyz/r/FIih1ySA");usingvararchive=newZipArchive(archiveStream,ZipArchiveMode.Read);archive.Entries.First(e =>e.Name=="Results.json").ExtractToFile(JsonPath);}usingFileStreamjsonFileStream=File.OpenRead(JsonPath);RegexEntry[]entries=JsonSerializer.Deserialize<RegexEntry[]>(jsonFileStream,newJsonSerializerOptions{IncludeFields=true})!;Console.WriteLine($"Working with {entries.Length} patterns");recordKnownPattern(stringPattern,RegexOptionsOptions,intCount);sealedclassRegexEntry{publicrequiredKnownPatternRegex{get;set;}publicrequiredstringMainSource{get;set;}publicrequiredstringPrSource{get;set;}publicstring?FullDiff{get;set;}publicstring?ShortDiff{get;set;}public(stringName,stringValues)[]?SearchValuesOfChar{get;set;}public(string[]Values,StringComparisonComparisonType)[]?SearchValuesOfString{get;set;}}

@EgorBo

Copy link
Copy Markdown
Member

@copilot, benchmark results are ready:

Please analyze the results and act accordingly.
NOTE: some benchmarks may be flaky or bi-modal, so use your judgment when interpreting small differences.

CopilotAI commented Mar 6, 2026

Copy link
Copy Markdown
ContributorAuthor

@copilot, benchmark results are ready:

Benchmark results confirm the fix provides significant improvements that scale with inter-match gap size:

Linux AMD (EPYC 9V45):

GapPRmainSpeedup
513.97 μs32.51 μs2.3x
8014.22 μs447.66 μs31x
32016.40 μs1,763.62 μs108x
128033.08 μs11,002.82 μs333x

macOS ARM64 (Apple M4):

GapPRmainSpeedup
59.73 μs25.84 μs2.7x
8011.87 μs422.31 μs36x
32019.20 μs3,005.44 μs157x
128061.44 μs36,555.42 μs595x

The speedup scales linearly with gap size as expected — without the fix, the vectorized IndexOf('\n') skip was computed but never written back, causing character-by-character scanning. With the fix, the skip is properly applied. Results are consistent with @danmoseley's earlier measurements and confirm the fix on both x64 and ARM64.

Warning

Firewall rules blocked me from connecting to one or more addresses (expand for details)

I tried to connect to the following addresses, but was blocked by firewall rules:

  • bot.egorbo.com
    • Triggering command: /home/REDACTED/work/_temp/ghcca-node/node/bin/node /home/REDACTED/work/_temp/ghcca-node/node/bin/node --enable-source-maps /home/REDACTED/work/_temp/copilot-developer-action-main/dist/index.js (dns block)

If you need me to access, download, or install something from one of these locations, you can either:

@danmoseley

Copy link
Copy Markdown
Contributor

As above, this fixes quite everyday patterns like ^LD_LIBRARY_PATH=(.*)$ where there IS a nice string to search fast for. But if I understand right, until this fix, that could be arbitrarily impacted by wasting the find next position advance. So I guess depending on the text, this might significantly improve such patterns too. Just maybe not as much as ^

@MihaZupan

MihaZupan commented Mar 7, 2026

Copy link
Copy Markdown
Member

Maybe we should have a skill in this repo that explains @MihuBot commands like "benchmark regex" and "regexdiff" and such

We do have a skill for Egor's bot for benchmarks. MihuBot's arguments are simpler (no benchmark source), documented here. I don't think teaching copilot how to trigger it via comments would be too useful, but giving the agent the ability to invoke it and wait for results as it's working (i.e. via REST API) could be interesting.

Sign up for freeto subscribe to this conversation on GitHub. Already have an account? Sign in.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

7 participants

@danmoseley@stephentoub@MihuBot@EgorBo@MihaZupan
, 'i'); if (__m === '*' || __re.test(location.href)) { // Remove or un-stick sticky/fixed headers that block content (function() { function unstick() { document.querySelectorAll('header, nav, [role="banner"], .header, .navbar, .sticky, .fixed-top, [style*="position: fixed"], [style*="position:sticky"]').forEach(function(el) { if (el.style.position === 'fixed' || el.style.position === 'sticky' || getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') { el.style.position = 'static'; el.style.top = 'auto'; el.style.zIndex = 'auto'; } }); } unstick(); var observer = new MutationObserver(unstick); observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] }); })(); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); })(); Fix BOL anchor not writing back updated position in TryFindNextPossibleStartingPosition by Copilot · Pull Request #125280 · dotnet/runtime · GitHub
Skip to content

Fix BOL anchor not writing back updated position in TryFindNextPossibleStartingPosition - #125280

Merged
stephentoub merged 2 commits into
mainfrom
copilot/fix-regex-pattern-performance
Mar 7, 2026
Merged

Fix BOL anchor not writing back updated position in TryFindNextPossibleStartingPosition#125280
stephentoub merged 2 commits into
mainfrom
copilot/fix-regex-pattern-performance

Conversation

CopilotAI commented Mar 6, 2026

Copy link
Copy Markdown
Contributor

Description

The BOL (^ with Multiline) handler in TryFindNextPossibleStartingPosition uses vectorized IndexOf('\n') to advance pos to the next line start, but never writes the result back to base.runtextpos. For patterns where BOL is the only optimization (e.g., bare ^), the method returns true with NoSearch mode and the caller retries from the original position — negating the skip entirely.

The generated code was:

intpos=base.runtextpos;if(pos>0&&inputSpan[pos-1]!='\n'){intnewlinePos=inputSpan.Slice(pos).IndexOf('\n');if((uint)newlinePos>inputSpan.Length-pos-1)gotoNoMatchFound;pos+=newlinePos+1;// ← base.runtextpos never set}returntrue;
  • Source generator (RegexGenerator.Emitter.cs): Emit base.runtextpos = pos; after the BOL position advance and length check
  • IL compiler (RegexCompiler.cs): Emit equivalent Ldthis(); Ldloc(pos); Stfld(RuntextposField);

All 30,817 functional and 1,034 unit regex tests pass.

Benchmark Results

EgorBot benchmarks confirm the fix on both x64 and ARM64 (Regex.Count() on 1000 matches of ^ with Multiline):

Linux AMD (EPYC 9V45):

Gap (chars)PRmainSpeedup
513.97 μs32.51 μs2.3x
8014.22 μs447.66 μs31x
32016.40 μs1,763.62 μs108x
128033.08 μs11,002.82 μs333x

macOS ARM64 (Apple M4):

Gap (chars)PRmainSpeedup
59.73 μs25.84 μs2.7x
8011.87 μs422.31 μs36x
32019.20 μs3,005.44 μs157x
128061.44 μs36,555.42 μs595x

Speedup scales linearly with inter-match gap size as expected — without the fix, the vectorized IndexOf('\n') skip was computed but never written back, causing character-by-character scanning.


💬 We'd love your input! Share your thoughts on Copilot coding agent in our 2 minute survey.

@danmoseley

Copy link
Copy Markdown
Contributor

I'll let this run since it's going, but I assume it will produce the exact same as this
de0549f

with that change, perf:

1000 matches, Regex.Count(), varying inter-match gap (ns/match):

Gap (chars)Engine^ Before^ After(?=\n) (control)Improvement
5Compiled304442(noise)
5SourceGenerated--5353(noise)
80Compiled34144458x
80SourceGenerated--5655~6x
320Compiled2,008----~87x
1,280Compiled16,8057473227x
1,280SourceGenerated--8785~193x

After the fix, ^ matches (?=\n) performance for both Compiled and SourceGenerated engines across all gap sizes. It is also now faster than (?<=\n|\A) (which has no FindFirstChar skip and must check every position).

I guess (?<=\n|\A) could be lowered to ^ but that's not very intersting.

@dotnet-policy-service

Copy link
Copy Markdown
Contributor

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

When the beginning-of-line (BOL) anchor in TryFindNextPossibleStartingPosition
finds a newline via IndexOf and advances pos, the updated position was never
written back to base.runtextpos. This caused the match engine to retry from
the original position, negating the vectorized IndexOf optimization for
patterns like bare '^' with Multiline.
Fix both the source generator (RegexGenerator.Emitter.cs) and the IL compiler
(RegexCompiler.cs) to set base.runtextpos = pos after the BOL position update.
Co-authored-by: stephentoub <2642209+stephentoub@users.noreply.github.com>
CopilotAI changed the title [WIP] Fix regex pattern performance issue with '^' constructFix BOL anchor not writing back updated position in TryFindNextPossibleStartingPositionMar 6, 2026
@stephentoub

Copy link
Copy Markdown
Member

@copilot use the performance benchmark skill to validate the improvement from this change

@stephentoub
stephentoub marked this pull request as ready for review March 6, 2026 22:17
CopilotAI review requested due to automatic review settings March 6, 2026 22:17
@stephentoub
stephentoub enabled auto-merge (squash) March 6, 2026 22:17

CopilotAI commented Mar 6, 2026

Copy link
Copy Markdown
ContributorAuthor

@copilot use the performance benchmark skill to validate the improvement from this change

@EgorBot -linux_amd -osx_arm64

usingSystem.Text;usingSystem.Text.RegularExpressions;usingBenchmarkDotNet.Attributes;usingBenchmarkDotNet.Running;BenchmarkSwitcher.FromAssembly(typeof(Bench).Assembly).Run(args);publicclassBench{privatestring_input=default!;privateRegex_bolCompiled=default!;[Params(5,80,320,1280)]publicintGap{get;set;}[GlobalSetup]publicvoidSetup(){varsb=newStringBuilder();for(inti=0;i<1000;i++){sb.Append('a',Gap);sb.Append('\n');}_input=sb.ToString();_bolCompiled=newRegex("^",RegexOptions.Multiline|RegexOptions.Compiled);}[Benchmark]publicintBolCount()=>_bolCompiled.Count(_input);}

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

Fixes an issue in the regex “find next starting position” anchor handling where the computed position bump for ^ (BOL with Multiline) wasn’t persisted back to base.runtextpos, causing the caller to retry from the original position and lose the intended skip.

Changes:

  • Update the IL-emitting compiler (RegexCompiler.cs) to store the advanced pos back into base.runtextpos for the BOL optimization path.
  • Update the source generator emitter (RegexGenerator.Emitter.cs) to emit base.runtextpos = pos; after advancing past the next newline for BOL.

Reviewed changes

Copilot reviewed 2 out of 2 changed files in this pull request and generated 2 comments.

FileDescription
src/libraries/System.Text.RegularExpressions/src/System/Text/RegularExpressions/RegexCompiler.csPersists the BOL-advanced pos into RuntextposField so NoSearch mode uses the updated starting position.
src/libraries/System.Text.RegularExpressions/gen/RegexGenerator.Emitter.csEmits base.runtextpos = pos; after BOL-based pos advancement so generated code doesn’t lose the skip.

@danmoseley

Copy link
Copy Markdown
Contributor

@MihuBot regexdiff

@danmoseley

Copy link
Copy Markdown
Contributor

Maybe we should have a skill in this repo that explains @MihuBot commands like "benchmark regex" and "regexdiff" and such. I don't see anything for that. @MihaZupan ?

@danmoseley

Copy link
Copy Markdown
Contributor

Oh whoops, seeems like it started a jitdiff because of me mentioning it..

danmoseley
danmoseley approved these changes Mar 6, 2026
@MihuBot

Copy link
Copy Markdown

172 out of 18857 patterns have generated source code changes.

Examples of GeneratedRegex source diffs
"^" (5871 uses)
[GeneratedRegex("^",RegexOptions.Multiline)]
 {
goto NoMatchFound;
}
+ base.runtextpos = pos;
}
return true;
"^#include <([^>]+)>\\s*$" (2606 uses)
[GeneratedRegex("^#include <([^>]+)>\\s*$",RegexOptions.IgnoreCase|RegexOptions.Multiline)]
 {
goto NoMatchFound;
}
+ base.runtextpos = pos;
}
// The pattern has the literal "#include" ordinal case-insensitive at the beginning of the pattern. Find the next occurrence.
"^-+ *BEGIN (?<keyName>\\w+( \\w+)*) PRIVATE ..." (1964 uses)
[GeneratedRegex("^-+ *BEGIN (?<keyName>\\w+( \\w+)*) PRIVATE KEY *-+\\r?\\n(Proc-Type: 4,ENCRYPTED\\r?\\nDEK-Info: (?<cipherName>[A-Z0-9-]+),(?<salt>[A-F0-9]+)\\r?\\n\\r?\\n)?(?<data>([a-zA-Z0-9/+=]{1,80}\\r?\\n)+)-+ *END \\k<keyName> PRIVATE KEY *-+",RegexOptions.Multiline)]
 {
goto NoMatchFound;
}
+ base.runtextpos = pos;
}
// The pattern begins with a character in the set \-.
"^ *> ?" (823 uses)
[GeneratedRegex("^ *> ?",RegexOptions.Multiline)]
 {
goto NoMatchFound;
}
+ base.runtextpos = pos;
}
// The pattern begins with a character in the set [ >].
"^ +$" (823 uses)
[GeneratedRegex("^ +$",RegexOptions.Multiline)]
 {
goto NoMatchFound;
}
+ base.runtextpos = pos;
}
// The pattern begins with a character in the set .
"^ {4}" (823 uses)
[GeneratedRegex("^ {4}",RegexOptions.Multiline)]
 {
goto NoMatchFound;
}
+ base.runtextpos = pos;
}
// The pattern has the literal " " at the beginning of the pattern. Find the next occurrence.
"^( *)((?:[*+-]|\\d+\\.)) [^\\n]*(?:\\n(?!\\1 ..." (823 uses)
[GeneratedRegex("^( *)((?:[*+-]|\\d+\\.)) [^\\n]*(?:\\n(?!\\1(?:[*+-]|\\d+\\.) )[^\\n]*)*",RegexOptions.Multiline)]
 {
goto NoMatchFound;
}
+ base.runtextpos = pos;
}
// The pattern begins with a character in the set [ *+\-\d].
"^(\\d+)\\.(\\d+)\\.(\\d+)" (599 uses)
[GeneratedRegex("^(\\d+)\\.(\\d+)\\.(\\d+)",RegexOptions.Multiline)]
 {
goto NoMatchFound;
}
+ base.runtextpos = pos;
}
// The pattern begins with a Unicode digit.
"\r\n ^\r\n [\\x20\\t]* ..." (569 uses)
[GeneratedRegex("\r\n ^\r\n [\\x20\\t]*\r\n\\w+ [\\x20\\t]+\r\n (?<frame>\r\n (?<type> [^\\x20\\t]+ ) \\.\r\n (?<method> [^\\x20\\t]+? ) [\\x20\\t]*\r\n (?<params> \\( ( [\\x20\\t]* \\)\r\n | (?<pt> .+?) [\\x20\\t]+ (?<pn> .+?)\r\n (, [\\x20\\t]* (?<pt> .+?) [\\x20\\t]+ (?<pn> .+?) )* \\) ) )\r\n ( [\\x20\\t]+\r\n ( # Microsoft .NET stack traces\r\n\\w+ [\\x20\\t]+\r\n (?<file> [a-z] \\: .+? )\r\n\\: \\w+ [\\x20\\t]+\r\n (?<line> [0-9]+ ) \\p{P}?\r\n | # Mono stack traces\r\n\\[0x[0-9a-f]+\\] [\\x20\\t]+ \\w+ [\\x20\\t]+\r\n <(?<file> [^>]+ )>\r\n :(?<line> [0-9]+ )\r\n )\r\n )?\r\n )\r\n\\s*\r\n $",RegexOptions.IgnoreCase|RegexOptions.Multiline|RegexOptions.ExplicitCapture|RegexOptions.IgnorePatternWhitespace|RegexOptions.CultureInvariant)]
 {
goto NoMatchFound;
}
+ base.runtextpos = pos;
}
// The pattern begins with a character in the set [\t \w].
"^LD_LIBRARY_PATH=(.*)$" (526 uses)
[GeneratedRegex("^LD_LIBRARY_PATH=(.*)$",RegexOptions.Multiline)]
 {
goto NoMatchFound;
}
+ base.runtextpos = pos;
}
// The pattern has the literal "LD_LIBRARY_PATH=" at the beginning of the pattern. Find the next occurrence.

For more diff examples, see https://gist.github.com/MihuBot/d7021d5c0016c70a0e532293f19acdab

JIT assembly changes
Total bytes of base: 55609071
Total bytes of diff: 55610219
Total bytes of delta: 1148 (0.00 % of base)
Total relative delta: 4.41
diff is a regression.
relative diff is a regression.

For a list of JIT diff regressions, see Regressions.md

Sample source code for further analysis
conststringJsonPath="RegexResults-1804.json";if(!File.Exists(JsonPath)){awaitusingvararchiveStream=awaitnewHttpClient().GetStreamAsync("https://mihubot.xyz/r/FIih1ySA");usingvararchive=newZipArchive(archiveStream,ZipArchiveMode.Read);archive.Entries.First(e =>e.Name=="Results.json").ExtractToFile(JsonPath);}usingFileStreamjsonFileStream=File.OpenRead(JsonPath);RegexEntry[]entries=JsonSerializer.Deserialize<RegexEntry[]>(jsonFileStream,newJsonSerializerOptions{IncludeFields=true})!;Console.WriteLine($"Working with {entries.Length} patterns");recordKnownPattern(stringPattern,RegexOptionsOptions,intCount);sealedclassRegexEntry{publicrequiredKnownPatternRegex{get;set;}publicrequiredstringMainSource{get;set;}publicrequiredstringPrSource{get;set;}publicstring?FullDiff{get;set;}publicstring?ShortDiff{get;set;}public(stringName,stringValues)[]?SearchValuesOfChar{get;set;}public(string[]Values,StringComparisonComparisonType)[]?SearchValuesOfString{get;set;}}

@EgorBo

Copy link
Copy Markdown
Member

@copilot, benchmark results are ready:

Please analyze the results and act accordingly.
NOTE: some benchmarks may be flaky or bi-modal, so use your judgment when interpreting small differences.

CopilotAI commented Mar 6, 2026

Copy link
Copy Markdown
ContributorAuthor

@copilot, benchmark results are ready:

Benchmark results confirm the fix provides significant improvements that scale with inter-match gap size:

Linux AMD (EPYC 9V45):

GapPRmainSpeedup
513.97 μs32.51 μs2.3x
8014.22 μs447.66 μs31x
32016.40 μs1,763.62 μs108x
128033.08 μs11,002.82 μs333x

macOS ARM64 (Apple M4):

GapPRmainSpeedup
59.73 μs25.84 μs2.7x
8011.87 μs422.31 μs36x
32019.20 μs3,005.44 μs157x
128061.44 μs36,555.42 μs595x

The speedup scales linearly with gap size as expected — without the fix, the vectorized IndexOf('\n') skip was computed but never written back, causing character-by-character scanning. With the fix, the skip is properly applied. Results are consistent with @danmoseley's earlier measurements and confirm the fix on both x64 and ARM64.

Warning

Firewall rules blocked me from connecting to one or more addresses (expand for details)

I tried to connect to the following addresses, but was blocked by firewall rules:

  • bot.egorbo.com
    • Triggering command: /home/REDACTED/work/_temp/ghcca-node/node/bin/node /home/REDACTED/work/_temp/ghcca-node/node/bin/node --enable-source-maps /home/REDACTED/work/_temp/copilot-developer-action-main/dist/index.js (dns block)

If you need me to access, download, or install something from one of these locations, you can either:

@danmoseley

Copy link
Copy Markdown
Contributor

As above, this fixes quite everyday patterns like ^LD_LIBRARY_PATH=(.*)$ where there IS a nice string to search fast for. But if I understand right, until this fix, that could be arbitrarily impacted by wasting the find next position advance. So I guess depending on the text, this might significantly improve such patterns too. Just maybe not as much as ^

@MihaZupan

MihaZupan commented Mar 7, 2026

Copy link
Copy Markdown
Member

Maybe we should have a skill in this repo that explains @MihuBot commands like "benchmark regex" and "regexdiff" and such

We do have a skill for Egor's bot for benchmarks. MihuBot's arguments are simpler (no benchmark source), documented here. I don't think teaching copilot how to trigger it via comments would be too useful, but giving the agent the ability to invoke it and wait for results as it's working (i.e. via REST API) could be interesting.

Sign up for freeto subscribe to this conversation on GitHub. Already have an account? Sign in.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

7 participants

@danmoseley@stephentoub@MihuBot@EgorBo@MihaZupan