Skip to content

Reduce NonBacktracking regex GC overhead with arena-allocated nodes - #136375

Open
cshung wants to merge 4 commits into
dotnet:mainfrom
cshung:cshung/optimize-regex-allocation
Open

cshung wants to merge 4 commits into
dotnet:mainfrom
cshung:cshung/optimize-regex-allocation

Conversation

@cshung

@cshung cshung commented Oct 23, 2026

Copy link
Copy Markdown
Contributor

Summary

The NonBacktracking regex engine currently represents its symbolic regex as a DAG of individually allocated SymbolicRegexNode<TSet> objects. Complex expressions can generate more than 100,000 retained nodes while lazily constructing DFA/NFA states. These objects and their child references must be traversed during every full GC.

This change stores symbolic nodes as immutable records in an array owned by SymbolicRegexBuilder<TSet>. References between nodes are represented by four-byte, builder-relative integer IDs.

For node-heavy expressions, this reduces retained memory by approximately 16% and forced full-GC time by 75–91%. Warmed matching performance on representative Email, URL, and IPv4 expressions remains within approximately ±2%.

Design

Previously, each symbolic node was a separate object containing references to its child nodes:

SymbolicRegexNode object
├── Left  -> SymbolicRegexNode object
├── Right -> SymbolicRegexNode object
└── Node data

The new representation is:

SymbolicRegexBuilder
└── NodeData[]
    ├── NodeData { LeftId, RightId, ... }
    ├── NodeData { LeftId, RightId, ... }
    └── ...

SymbolicRegexNode<TSet> = 4-byte node ID

SymbolicRegexNode<TSet> is now a four-byte value-type handle. ID 0 represents the null/default handle; positive IDs index immutable NodeData records in the owning builder.

Node IDs are builder-relative. Operations that interpret an ID therefore also receive the owning builder. Temporary SymbolicRegexNodeContext<TSet> values carry:

  • The owning builder.
  • An acquired arena-array snapshot.
  • The current node ID.

Child traversal reuses the same snapshot rather than reacquiring the arena for each field.

MatchingState<TSet> remains a reference type, but stores a node ID instead of a node reference. It precomputes nullability, dead-end, anchor, and state-flag information so established warmed matching loops do not repeatedly access the arena.

Node interning and recursive-operation caches now store node IDs. Structurally identical expressions therefore continue to share the same node, and the symbolic representation remains a maximally shared DAG.

Concurrent publication

Nodes are immutable once published and are appended under the matcher's existing synchronization.

Publication follows this order:

  1. Construct the complete NodeData.
  2. Store it in the current or replacement array.
  3. If the arena grew, release-publish the replacement array.
  4. Release-publish the new node count.

Readers acquire-read the node count before capturing the arena array.

Children are always published before their parent. Consequently, any array snapshot containing a parent also contains its complete subtree. A context may continue using an older array after the arena grows because published records are immutable and old arrays remain valid snapshots.

A regular List<NodeData> cannot provide these semantics: List<T>.Add updates its count before storing the new item, does not release-publish either its count or backing array, and does not expose a stable backing-array snapshot.

Node-heavy results

The stress cases construct approximately 125,000 symbolic nodes by matching against a 50,000-character input.

Patterns:

(?:a|AA|BCD).{20}$
(?:a.{20}|a.{10})bc$

Each result below is the median of three independent processes. The regex and input were retained while 15 forced full GCs were measured per process.

Memory

Pattern Metric Baseline Arena Change
Case 1 Allocated 89.69 MiB 80.49 MiB -10.3%
Case 1 Retained 30.00 MiB 25.32 MiB -15.6%
Case 2 Allocated 94.03 MiB 83.52 MiB -11.2%
Case 2 Retained 27.07 MiB 22.53 MiB -16.8%

Forced full-GC median

GC mode Pattern Baseline Arena Change
Workstation GC Case 1 14.15 ms 2.56 ms -81.9%
Workstation GC Case 2 14.25 ms 1.23 ms -91.4%
Server GC Case 1 14.38 ms 3.55 ms -75.3%
Server GC Case 2 12.60 ms 2.65 ms -79.0%

The reduction in GC time is larger than the reduction in retained bytes because the old representation required marking many separate node objects and traversing their reference edges. The new representation concentrates the node data in a small number of arrays with integer child links.

Representative regex benchmarks

BenchmarkDotNet results using NonBacktracking Email, URL, and IPv4 expressions:

Scenario Constructor Constructor + first match Warmed IsMatch
Email -0.5% +0.1% -1.9%
URL -1.7% -1.0% +2.0%
IPv4 -3.4% -17.1% -2.2%

Negative values indicate improvement. Allocations were effectively unchanged for these comparatively small expressions.

These results indicate that arena lookup does not introduce a warmed matching regression. Small expressions also do not receive a meaningful memory benefit because their symbolic graphs are not large enough for node objects to dominate.

Cold-path tradeoff

The pathological 125,000-node stress cases have a variable one-time construction/first-match cost. Across independent process measurements, results ranged from approximately neutral to a 30–37% regression, corresponding to roughly 100–200 ms.

CPU traces indicate that most of this cold cost is additional JIT compilation and generic code expansion in the symbolic-engine methods, rather than arena resizing or warmed node access:

Metric Baseline Arena
JIT compilation events ~908 ~3,413
Generated native code ~324 KiB ~909 KiB

This is runtime JIT compilation of the generic symbolic engine, not generated or compiled regex code. Repeated constructions in the same process do not continue paying the full cost, and warmed matching remains neutral.

An experiment reducing inlining lowered generated code size but did not improve end-to-end cold time, so it was not retained.

Scope

This affects only the symbolic representation used by the NonBacktracking engine. It does not change:

  • The parser's short-lived RegexNode tree.
  • Backtracking regex execution.
  • Source-generated or compiled regex output.
  • Public API or observable matching behavior.

The implementation is managed code and contains no architecture-specific branches or intrinsics. Correctness is expected to be portable across operating systems and architectures, although the exact memory, JIT, and GC performance effects will vary.

Validation

  • Release System.Text.RegularExpressions build.
  • 1,066 unit tests passed.
  • 32,161 net11 functional test executions passed.
  • 15,944 net481 functional test executions passed, with one expected skip.
  • Workstation and Server GC stress measurements.
  • BenchmarkDotNet Email, URL, and IPv4 benchmarks.
  • Final code review of publication, stale snapshots, builder-relative IDs, cache keys, and matching-state behavior.

Note

This PR description was prepared with GitHub Copilot.

@dotnet-policy-service dotnet-policy-service Bot added the community-contribution Indicates that the PR has been added by a community member label Oct 23, 2026
@azure-pipelines

Copy link
Copy Markdown
Azure Pipelines:
Successfully started running 3 pipeline(s).
13 pipeline(s) were filtered out due to trigger conditions.
There may be pipelines that require an authorized user to comment /azp run to run.

@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.

cshung and others added 3 commits October 30, 2026 07:56
Store NonBacktracking symbolic nodes in builder-owned arrays and reference them with compact builder-relative IDs. This reduces retained object graph size and recurring GC traversal costs while preserving hot matching-state metadata.

Signed-off-by: Andrew Au <3410332+cshung@users.noreply.github.com>

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Satisfy CA1066 and route object equality through the typed ID-based implementation.

Signed-off-by: Andrew Au <3410332+cshung@users.noreply.github.com>

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Signed-off-by: Andrew Au <3410332+cshung@users.noreply.github.com>
@cshung
cshung force-pushed the cshung/optimize-regex-allocation branch from ba92ff0 to 8edb6fb Compare October 30, 2026 15:33
@cshung

cshung commented Nov 2, 2026 •

Copy link
Copy Markdown
Contributor Author

@stephentoub, please take a look?

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🟡 Changes recommended

Regression coverage does not exercise subtree traversal through a retained parent context after resizing or concurrent node publication across capacity boundaries.

1 open issue
What changed in this PR

Reduces GC overhead in the NonBacktracking regex engine by replacing individually allocated symbolic nodes with compact IDs into a builder-owned arena, while aiming to preserve matching behavior.

Changes:

  • Adds arena storage, immutable node records, and snapshot-based traversal.
  • Converts caches and matching states to node IDs, with precomputed state metadata.
  • Updates symbolic-engine callers and adds handle-size, interning, and basic arena-growth tests.
File Description
src/​libraries/​System.Text.RegularExpressions/​tests/​UnitTests/​SymbolicRegexTests.cs Adds handle and arena tests; updates builder-aware calls.
src/​libraries/​System.Text.RegularExpressions/​src/​System/​Text/​RegularExpressions/​Symbolic/​SymbolicRegexRunnerFactory.cs Passes the builder to size estimation.
src/​libraries/​System.Text.RegularExpressions/​src/​System/​Text/​RegularExpressions/​Symbolic/​SymbolicRegexNode.cs Introduces ID handles, snapshot contexts, and iterative singleton counting.
src/​libraries/​System.Text.RegularExpressions/​src/​System/​Text/​RegularExpressions/​Symbolic/​SymbolicRegexMatcher.Explore.cs Resolves state node IDs during exploration.
src/​libraries/​System.Text.RegularExpressions/​src/​System/​Text/​RegularExpressions/​Symbolic/​SymbolicRegexMatcher.Dgml.cs Uses builder-aware node access for graph output.
src/​libraries/​System.Text.RegularExpressions/​src/​System/​Text/​RegularExpressions/​Symbolic/​SymbolicRegexMatcher.cs Updates traversal and caches pattern anchor metadata.
src/​libraries/​System.Text.RegularExpressions/​src/​System/​Text/​RegularExpressions/​Symbolic/​SymbolicRegexMatcher.Automata.cs Keys states by node ID and updates transition construction.
src/​libraries/​System.Text.RegularExpressions/​src/​System/​Text/​RegularExpressions/​Symbolic/​SymbolicRegexKind.cs Updates comments to describe arena node data.
src/​libraries/​System.Text.RegularExpressions/​src/​System/​Text/​RegularExpressions/​Symbolic/​SymbolicRegexBuilder.cs Owns arena allocation, publication, and ID-based caches.
src/​libraries/​System.Text.RegularExpressions/​src/​System/​Text/​RegularExpressions/​Symbolic/​RegexNodeConverter.cs Supplies the builder for node predicates.
src/​libraries/​System.Text.RegularExpressions/​src/​System/​Text/​RegularExpressions/​Symbolic/​MatchingState.cs Stores node IDs and precomputes matching metadata.

Review effort: Balanced

Cover retained snapshot traversal after arena growth and concurrent publication across multiple capacity boundaries.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Signed-off-by: Andrew Au <3410332+cshung@users.noreply.github.com>

This branch has not been deployed

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

Labels

area-System.Text.RegularExpressions community-contribution Indicates that the PR has been added by a community member

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants