Skip to content

feat(csharp): add opt-in SpanInputStream variants and OptimizedToken classes - #4941

Open
HarryCordewener wants to merge 1 commit into
antlr:devfrom
HarryCordewener:feature/csharp-extra-classes
Open

HarryCordewener wants to merge 1 commit into
antlr:devfrom
HarryCordewener:feature/csharp-extra-classes

Conversation

@HarryCordewener

@HarryCordewener HarryCordewener commented May 25, 2026

Copy link
Copy Markdown

Summary

This PR adds purely additive, opt-in performance classes to the C# runtime. No existing files are modified — every file here is new. This is a subset extracted from #4938 to make review easier.

Changes

New: CharSpanInputStream

A replacement for AntlrInputStream backed by char[] rather than copying into the base class's internal structure. Intended for inputs arriving from TextReader, Stream, or a pre-built char[].

  • new CharSpanInputStream(char[], length) — zero-copy construction, ~40 B allocated vs 2 KB for AntlrInputStream.
  • Seek() is O(1) — just _index = value. AntlrInputStream.Seek() loops forward.
  • Data property exposes a ReadOnlySpan<char> slice for zero-alloc text access.
  • GetTextSpan(Interval) — zero-alloc ReadOnlySpan<char> alternative to GetText.
  • Targets net8.0 (direct ICharStream — no virtual dispatch) and netstandard2.x (extends BaseInputCharStream).

New: StringSpanInputStream

A replacement for AntlrInputStream backed directly by a string. Intended for the common case of parsing an in-memory string.

  • new StringSpanInputStream(string) — stores the reference, zero copy, 40 B allocated.
  • Hot-path indexer uses the string's immutable length, enabling stronger JIT bounds-check elimination than a mutable _length field (JIT BCE patterns).
  • ToString() returns the original string reference — zero alloc.
  • GetText uses Substring() directly, which shares the same BCL path as new string(ReadOnlySpan<char>) (dotnet/runtime source).

New: OptimizedToken / OptimizedTokenFactory

An IToken implementation with cached Text and a [StructLayout(Sequential)] field order to reduce GC header overhead. OptimizedTokenFactory.Default is a singleton drop-in for CommonTokenFactory.Default.

Micro-optimizations on existing types

  • ArrayList<T>.Equals — zero-alloc equality via span comparison.
  • IntervalSet — constructor param narrowed from IList<Interval> to List<Interval> where the implementation already required it.
  • ATNConfig hash — no change (ATNConfigSet caches at the set level; per-config caching was benchmarked and found to regress).

Benchmarks

A new tests/benchmarks/ project (BenchmarkDotNet) provides head-to-head comparisons. Run with:

cd runtime/CSharp/tests/benchmarks
dotnet run -c Release -- --filter '*'

Benchmark Results

Hardware: Intel Core Ultra 7 265F, .NET 10.0.8, RyuJIT AVX2. All ratios vs AntlrInputStream.

Construction

Stream 1 k chars Alloc 100 k chars Alloc
AntlrInputStream 81 ns 2,064 B 70,754 ns 200 kB
CharSpanInputStream(char[]) 3.7 ns 40 B 3.7 ns 40 B
StringSpanInputStream 4.0 ns 40 B 4.2 ns 40 B
CodePointCharStream 839 ns 4,064 B 148,812 ns 400 kB

CharSpanInputStream and StringSpanInputStream construction cost is flat — independent of input length. AntlrInputStream and CodePointCharStream scale linearly because they copy the entire input at construction.

ConsumeAll (simulated lexer hot loop)

Stream 1 k chars Alloc 100 k chars Alloc
AntlrInputStream 379 ns 2,024 B 99,404 ns 200 kB
CharSpanInputStream 455 ns 2,024 B 98,705 ns 200 kB
StringSpanInputStream 360 ns 0 B 27,256 ns 0 B
CodePointCharStream 979 ns 4,024 B 181,386 ns 400 kB

StringSpanInputStream is the standout: 3.6× faster than AntlrInputStream at 100 k chars, zero allocation. The zero-alloc result is because the string already lives on the heap — the stream object is the only allocation and the benchmark reuses it.

CharSpanInputStream matches AntlrInputStream in the hot loop (same char[] indexer speed) but saves the construction allocation when built from a pre-built array.

Seek

Stream 1 k chars 100 k chars
AntlrInputStream 342 ns / 2,024 B 90,371 ns / 200 kB
CharSpanInputStream 83 ns / 2,024 B 70,566 ns / 200 kB
StringSpanInputStream 6.9 ns / 0 B 6.9 ns / 0 B
CodePointCharStream 1,001 ns / 4,024 B 142,125 ns / 400 kB

StringSpanInputStream seek is O(1) and completely flat — purely _index = value.

GetText

Stream 1 k chars Alloc 100 k chars Alloc
AntlrInputStream 109 ns 2,848 B 13,105 ns 300 kB
StringSpanInputStream 31 ns 824 B 35,546 ns 100 kB
CodePointCharStream 2,040 ns 15,392 B 506,776 ns 1.8 MB

StringSpanInputStream.GetText is 3.5× faster and 3.4× less memory at 1 k. At 100 k it is slower in time (but still uses 3× less memory) because the extracted text crosses the LOH threshold (~85,000 bytes), where .NET switches from bump-pointer to free-list allocation and batches memmove in 16 KB chunks with GC polls between them. This is a BCL constraint, not a code issue. The GetTextSpan() method avoids this entirely for callers that can consume a span.

LookBack

Stream 1 k chars Alloc 100 k chars Alloc
AntlrInputStream 223 ns 2,024 B 80,480 ns 200 kB
CharSpanInputStream 129 ns 2,024 B 70,877 ns 200 kB
StringSpanInputStream 59 ns 0 B 59 ns 0 B
CodePointCharStream 829 ns 4,024 B 134,519 ns 400 kB

StringSpanInputStream lookback is flat regardless of input size.


Design Decisions

Why char[] for CharSpanInputStream, not string or ReadOnlyMemory<char>?

string doesn't support MemoryMarshal.GetArrayDataReference and can't be used as TextReader/Stream source. ReadOnlyMemory<char> has no indexer — hot-path reads require .Span property access, which constructs a new ReadOnlySpan<char> struct on every call. char[] gives a direct JIT intrinsic indexer with full span support. See Span<T> design notes.

Why string for StringSpanInputStream?

For string inputs, ToCharArray() (used by AntlrInputStream and CharSpanInputStream) copies every character. Storing the string reference directly is zero-copy. The string indexer is devirtualized by the JIT and compiles to the same ldelem instruction as array access. The immutable string length enables stronger bounds-check elimination than a mutable _length field. Substring() and new string(ReadOnlySpan<char>) share the same BCL code path (FastAllocateString + Buffer.Memmove) so there is no advantage to the span ctor for GetText.

Why not MemoryMarshal.GetArrayDataReference + Unsafe.Add?

The (uint)i < (uint)_length guard is the standard pattern the JIT is specifically trained to recognise. The branchless >> 31 sign-bit trick and the Unsafe pointer path were evaluated and benchmarked — both either matched or regressed vs the simple if/else + _data[pos]. The branch predictor handles LA(1) (always positive) perfectly, making branches essentially free. Unsafe adds maintenance cost with no measured gain.

Why not seal SingletonPredictionContext?

EmptyPredictionContext inherits from it. Sealing would break the existing class hierarchy.

Public API compatibility

All existing types (AntlrInputStream, CommonToken, CommonTokenFactory, etc.) are untouched. New types are purely additive. CharSpanInputStream and StringSpanInputStream implement ICharStream; OptimizedToken implements IToken; OptimizedTokenFactory implements ITokenFactory. Any call site that accepts an interface can adopt these with no other changes.


Testing

  • 149 tests added to tests/perf-optimizations/, covering all ICharStream operations, parity with AntlrInputStream, edge cases (empty input, EOF, unicode BMP, seek/reset), and integration scenarios.
  • All 86 existing runtime tests continue to pass.
  • Unicode note: both new stream classes operate at the UTF-16 code-unit level, matching AntlrInputStream's behaviour. Supplementary code points (U+10000+) are two positions, same as AntlrInputStream. Use CodePointCharStream (via CharStreams.fromString) for grammars that target supplementary characters.

Add new additive-only classes extracted from feature/csharp-runtime-perf-optimizations:

- SpanInputStream: base Span-backed char stream (ICharStream adapter)
- CharSpanInputStream: char[]-backed stream with Span/Memory API
- StringSpanInputStream: string-backed stream with Span/Memory API
- OptimizedToken: struct-like token reducing per-token allocations
- OptimizedTokenFactory: factory producing OptimizedToken instances

Includes xUnit test project (perf-optimizations) covering all new classes.

These are purely additive — no modifications to existing runtime files.

Signed-off-by: Harry Cordewener <[email protected]>
@ericvergnaud

Copy link
Copy Markdown
Contributor

@HarryCordewener Sorry I've been busy. I need to invest time fixing the CI before progressing this...

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants