Skip to content

Speed up case-insensitive Enum parsing#131030

Draft
artl93 wants to merge 3 commits into
dotnet:mainfrom
artl93:artl93-enum-tryparse-ignorecase-perf
Draft

Speed up case-insensitive Enum parsing#131030
artl93 wants to merge 3 commits into
dotnet:mainfrom
artl93:artl93-enum-tryparse-ignorecase-perf

Conversation

@artl93

@artl93 artl93 commented Jul 19, 2026

Copy link
Copy Markdown
Member

Fixes #118548

Motivation

Case-insensitive Enum.TryParse/Enum.Parse did an O(N) linear scan, calling EqualsOrdinalIgnoreCase against every enum name for each token. That's a hot path for web/query-string parsing and scales poorly as enum size grows.

Change

Replace the per-token linear scan in TryParseByName with a lazily-built, cached Dictionary<string, TStorage> (StringComparer.OrdinalIgnoreCase) on the per-type EnumInfo, consumed via AlternateLookup<ReadOnlySpan<char>> so span tokens match without allocating strings. Names are inserted with TryAdd in value-sorted order, preserving the original first-match (lowest-value-wins) semantics for names that differ only by case. The exact-case path is unchanged. The cache is published with Interlocked.CompareExchange. The lookup is added to both EnumInfo implementations — the shared one (CoreCLR/Mono) and NativeAOT's System.Reflection.EnumInfo.

Benchmarks (BDN + EgorBot)

Isolated A/B of the name-matching step (Release, .NET 11, Apple M5 Pro), scan vs. cached dictionary — 0 B allocated in every case:

Token shape Crossover (dictionary starts winning) N = 256
match last member (scan worst case) ~N = 3-4 564 ns → 9 ns
miss / invalid token ~N = 16 119 ns → 6 ns
match first member never (fixed ~5 ns dictionary overhead) 3 ns → 10 ns

The dictionary is O(1) (~8-9 ns) regardless of size; the scan is O(N). The full N = 2…256 sweep and the tiny-enum tradeoff are in the discussion below. @EgorBot runs the real end-to-end Enum.TryParse A/B on Linux AMD64 + macOS arm64.

Validation

  • 70,741 System.Runtime tests pass (0 related failures), including 821 Enum cases (case-insensitive parse, flags, case-collision edge cases).
  • A baseline-vs-patched correctness probe (case-collision, flags, exact-case, whitespace, miss) produced identical results.

Risks

  • One reference-sized field plus a lazily-built dictionary per enum type that is parsed case-insensitively (built on first use only; enums never parsed case-insensitively pay nothing).
  • Tiny enums (≤ ~8 members) matched near the front are a few ns slower and incur the one-time dictionary allocation; a size threshold could recover that case if preferred (see discussion).
  • Thread safety: the cache is published via Interlocked.CompareExchange; a first-parse race only wastes a transient dictionary and never publishes a partial one.
  • No public API change.

Note

This pull request description was created with GitHub Copilot.

Replace the per-token O(N) linear EqualsOrdinalIgnoreCase scan in TryParseByName
with a lazily-built, cached OrdinalIgnoreCase Dictionary<string,TStorage> on the
per-type EnumInfo, consumed via AlternateLookup<ReadOnlySpan<char>> to avoid string
allocation. Names are inserted via TryAdd in value-sorted order, preserving the
original first-match (lowest-value-wins) behavior for names differing only by case.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot-Session: d7060115-2ee5-46f4-b6e0-acad31d5a484
Copilot AI review requested due to automatic review settings July 19, 2026 07:15
@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-numerics
See info in area-owners.md if you want to be subscribed.

@artl93
artl93 marked this pull request as draft July 19, 2026 07:16

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.

Pull request overview

Optimizes case-insensitive Enum.TryParse / Enum.Parse name matching by replacing the per-token linear scan over enumInfo.Names with a per-enum-type cached Dictionary<string, TStorage> using StringComparer.OrdinalIgnoreCase, and using AlternateLookup<ReadOnlySpan<char>> to avoid per-token string allocations.

Changes:

  • Add a lazily-built, cached case-insensitive name→value dictionary on EnumInfo<TStorage>.
  • Update TryParseByName<TStorage> to use the cached dictionary + AlternateLookup<ReadOnlySpan<char>> for ignore-case lookups instead of scanning all names.
Show a summary per file
File Description
src/libraries/System.Private.CoreLib/src/System/Enum.EnumInfo.cs Adds a cached case-insensitive name→value lookup on EnumInfo<TStorage>.
src/libraries/System.Private.CoreLib/src/System/Enum.cs Switches ignore-case parsing to dictionary+span alternate lookup rather than linear scanning.

Copilot's findings

  • Files reviewed: 2/2 changed files
  • Comments generated: 1

Comment thread src/libraries/System.Private.CoreLib/src/System/Enum.EnumInfo.cs
@JakeYallop

Copy link
Copy Markdown
Contributor

I can imagine this was implemented this way deliberately under the assumption that most enums are quite small - I don't think 16 elements for an enum is that small. I know most of the enums I've ever written have been 8 elements or less.

I wonder where the constant costs of the dictionary lookup outweigh the linear scan, and I'm wondering if making this conditional on the number of elements in enumNames could mitigate that.

However, there are enums that are massive (100s of elements), so this will obviously help those cases even more (if someone was calling {Try}Parse on them to begin with).

The shared Enum.cs binds unqualified EnumInfo<TStorage> to
System.Reflection.EnumInfo<TStorage> under NativeAOT (via using System.Reflection;),
so the new GetNamesToValuesIgnoreCase() must exist on that type too — add it to
NativeAOT's EnumInfo to fix the CoreCLR_Bootstrapped/NativeAOT compile break.

Also harden lazy publication of the cached OrdinalIgnoreCase lookup in both
EnumInfo types: replace the non-atomic ??= with Interlocked.CompareExchange so
concurrent first-parse callers observe a single published dictionary instance.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot-Session: d7060115-2ee5-46f4-b6e0-acad31d5a484
Copilot AI review requested due to automatic review settings July 19, 2026 19:14

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.

Copilot's findings

Comments suppressed due to low confidence (1)

src/libraries/System.Private.CoreLib/src/System/Enum.EnumInfo.cs:48

  • The lazy cache uses a plain read of _namesToValuesIgnoreCase on the fast path, but the reference is published via Interlocked.CompareExchange. To safely publish the fully-initialized Dictionary on weak memory models, the fast path should use Volatile.Read (acquire). The current comment claiming a plain read is safe is misleading.

The same issue exists in the NativeAOT System.Reflection.EnumInfo<TStorage> implementation.

            public Dictionary<string, TStorage> GetNamesToValuesIgnoreCase()
            {
                return _namesToValuesIgnoreCase ?? Initialize();

                Dictionary<string, TStorage> Initialize()
  • Files reviewed: 3/3 changed files
  • Comments generated: 1

Comment on lines +52 to +76
public Dictionary<string, TStorage> GetNamesToValuesIgnoreCase()
{
return _namesToValuesIgnoreCase ?? Initialize();

Dictionary<string, TStorage> Initialize()
{
string[] names = Names;
TStorage[] values = Values;
var lookup = new Dictionary<string, TStorage>(names.Length, StringComparer.OrdinalIgnoreCase);

// Insert in Names order (which is sorted by value). For names that differ only by case,
// TryAdd keeps the first (lowest-value) entry, matching the original linear scan's
// "first match wins" behavior.
for (int i = 0; i < names.Length; i++)
{
lookup.TryAdd(names[i], values[i]);
}

// Publish atomically. If another thread raced and already published a lookup,
// reuse theirs so every caller observes a single cached instance. The plain
// read on the fast path above is safe: the reference is published with release
// semantics and readers reach the contents through a data-dependent access.
return Interlocked.CompareExchange(ref _namesToValuesIgnoreCase, lookup, null) ?? lookup;
}
}

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Good catch — done. Both EnumInfo<TStorage> implementations (this NativeAOT one and the shared CoreCLR/Mono Enum.EnumInfo) now read the cache with Volatile.Read on the fast path, pairing an acquire load with the Interlocked.CompareExchange release publish. That matches the runtime convention for lazily published cross-thread fields and removes any dependence on address/consume ordering. Pushed in 8e0a149.

Note

This reply was created with GitHub Copilot.

@jkotas jkotas Jul 20, 2026

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

This is unnecessary. .NET memory model guarantees the required semantics without Volatile.Read. We have actually done cleanup passes before to delete unnecessary volatile uses like this one before - #125274 .

The whole line of copilot reasoning that Interlocked.CompareChange and Volatile are required here is flawed. It certainly is not wrong to use Interlocked.CompareChange and Volatile, but it is not required.

@artl93

artl93 commented Jul 19, 2026

Copy link
Copy Markdown
Member Author

Good question — I measured it. Here's an isolated A/B of just the name-matching step (previous linear OrdinalIgnoreCase scan vs. the cached dictionary + AlternateLookup<ReadOnlySpan<char>>, steady state, dictionary already built) across N = 2…256 for three token shapes: match the first member, match the last member (worst case for the scan), and a miss. Release, .NET 11, Apple M5 Pro, BDN ShortRun. Absolute ns, mean; 0 B allocated in every row:

N First: scan / dict Last: scan / dict Miss: scan / dict
2 3.1 / 8.1 6.5 / 8.6 1.5 / 6.0
4 3.5 / 9.3 14.1 / 8.4 3.2 / 5.7
8 3.2 / 8.9 27.1 / 8.4 3.5 / 5.8
16 3.2 / 9.6 41.2 / 8.4 6.3 / 6.4
32 3.4 / 9.8 76.6 / 8.5 13.5 / 6.4
64 3.2 / 9.9 154.3 / 8.8 27.8 / 7.6
128 3.4 / 8.6 303.5 / 8.6 55.2 / 5.9
256 3.2 / 9.9 564.6 / 9.0 119.2 / 6.1

Reading it:

  • The dictionary is essentially flat at ~8-9 ns regardless of size (O(1)); the scan is O(N).
  • Match at the last member: crossover is ~N = 3-4. For a uniformly-distributed match (~N/2 comparisons) it's roughly N = 6-8.
  • Miss / invalid token: the scan stays cheap because the first character rejects almost immediately, so the crossover is later, ~N = 16; past that the dictionary pulls far ahead.
  • Match on the very first member is the one shape the dictionary never wins — a fixed ~5 ns hash-vs-first-compare overhead, independent of N.

So you're right that for genuinely tiny enums (≤ ~8) whose tokens tend to hit near the front, the linear scan is a few ns faster and avoids the one-time dictionary allocation. The regression is small in absolute terms (single-digit ns on an already-single-digit-ns op), while the win for medium/large enums and miss-heavy input is large and unbounded.

Gating on enumNames.Length (keep the scan below a small threshold, build the dictionary above it) would recover the tiny-enum case and skip the allocation entirely for small enums — I'm happy to add that if the area owners prefer it. It trades one branch + a second code path for protecting the few-ns tiny-enum case, so I've left the exact policy to maintainer preference rather than guessing at a cutoff.

Note

This reply was created with GitHub Copilot.

Pairs the fast-path acquire read with the existing Interlocked.CompareExchange
release publish in both EnumInfo implementations (shared CoreCLR/Mono and
NativeAOT), matching the runtime convention for lazily published cross-thread
fields and resolving the reviewer's memory-model concern.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot-Session: d7060115-2ee5-46f4-b6e0-acad31d5a484
Copilot AI review requested due to automatic review settings July 20, 2026 01:55
@artl93

artl93 commented Jul 20, 2026

Copy link
Copy Markdown
Member Author

@EgorBot -amd -osx_arm64

using System;
using BenchmarkDotNet.Attributes;

[MemoryDiagnoser]
public class EnumParseBench
{
    // 16-member enum (small, common case).
    public enum E16 { A, B, C, D, E, F, G, H, I, J, K, L, M, N, O, P }

    // 40-member enum (medium).
    public enum E40
    {
        M00, M01, M02, M03, M04, M05, M06, M07, M08, M09,
        M10, M11, M12, M13, M14, M15, M16, M17, M18, M19,
        M20, M21, M22, M23, M24, M25, M26, M27, M28, M29,
        M30, M31, M32, M33, M34, M35, M36, M37, M38, M39
    }

    // 128-member enum (large, e.g. protocol/opcode tables).
    public enum E128
    {
        V000, V001, V002, V003, V004, V005, V006, V007, V008, V009, V010, V011, V012, V013, V014, V015,
        V016, V017, V018, V019, V020, V021, V022, V023, V024, V025, V026, V027, V028, V029, V030, V031,
        V032, V033, V034, V035, V036, V037, V038, V039, V040, V041, V042, V043, V044, V045, V046, V047,
        V048, V049, V050, V051, V052, V053, V054, V055, V056, V057, V058, V059, V060, V061, V062, V063,
        V064, V065, V066, V067, V068, V069, V070, V071, V072, V073, V074, V075, V076, V077, V078, V079,
        V080, V081, V082, V083, V084, V085, V086, V087, V088, V089, V090, V091, V092, V093, V094, V095,
        V096, V097, V098, V099, V100, V101, V102, V103, V104, V105, V106, V107, V108, V109, V110, V111,
        V112, V113, V114, V115, V116, V117, V118, V119, V120, V121, V122, V123, V124, V125, V126, V127
    }

    // Wrong-case tokens that match the FIRST member (best case for the old linear scan).
    private const string First16 = "a";
    private const string First40 = "m00";
    private const string First128 = "v000";

    // Wrong-case tokens that match the LAST member (worst case for the old linear scan).
    private const string Last16 = "p";
    private const string Last40 = "m39";
    private const string Last128 = "v127";

    // Tokens that never match (scan must reject against every name).
    private const string Miss = "zzz";

    [Benchmark] public E16 E16_MatchFirst() { Enum.TryParse<E16>(First16, ignoreCase: true, out var r); return r; }
    [Benchmark] public E16 E16_MatchLast()  { Enum.TryParse<E16>(Last16, ignoreCase: true, out var r); return r; }
    [Benchmark] public bool E16_Miss()      => Enum.TryParse<E16>(Miss, ignoreCase: true, out _);

    [Benchmark] public E40 E40_MatchFirst() { Enum.TryParse<E40>(First40, ignoreCase: true, out var r); return r; }
    [Benchmark] public E40 E40_MatchLast()  { Enum.TryParse<E40>(Last40, ignoreCase: true, out var r); return r; }
    [Benchmark] public bool E40_Miss()      => Enum.TryParse<E40>(Miss, ignoreCase: true, out _);

    [Benchmark] public E128 E128_MatchFirst() { Enum.TryParse<E128>(First128, ignoreCase: true, out var r); return r; }
    [Benchmark] public E128 E128_MatchLast()  { Enum.TryParse<E128>(Last128, ignoreCase: true, out var r); return r; }
    [Benchmark] public bool E128_Miss()       => Enum.TryParse<E128>(Miss, ignoreCase: true, out _);
}

Note

This benchmark request was created with GitHub Copilot.

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.

Copilot's findings

  • Files reviewed: 3/3 changed files
  • Comments generated: 3

Comment on lines +1057 to 1062
// For case-insensitive parsing, use a cached name->value lookup and match spans without allocating.
Dictionary<string, TStorage>.AlternateLookup<ReadOnlySpan<char>> ignoreCaseLookup = ignoreCase ?
enumInfo.GetNamesToValuesIgnoreCase().GetAlternateLookup<ReadOnlySpan<char>>() :
default;

while (value.Length > 0)
Comment on lines +54 to +57
// Insert in Names order (which is sorted by value). For names that differ only by case,
// TryAdd keeps the first (lowest-value) entry, matching the original linear scan's
// "first match wins" behavior.
for (int i = 0; i < names.Length; i++)
Comment on lines +52 to +60
public Dictionary<string, TStorage> GetNamesToValuesIgnoreCase()
{
return Volatile.Read(ref _namesToValuesIgnoreCase) ?? Initialize();

Dictionary<string, TStorage> Initialize()
{
string[] names = Names;
TStorage[] values = Values;
var lookup = new Dictionary<string, TStorage>(names.Length, StringComparer.OrdinalIgnoreCase);
{
return Volatile.Read(ref _namesToValuesIgnoreCase) ?? Initialize();

Dictionary<string, TStorage> Initialize()

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

These extra dictionary instantiations cost up to 70kB binary size of Native AOT compiled apps: MichalStrehovsky/rt-sz#242

It should be acceptable binary size regression.

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.

How is it so much since we get like 8 instantiations max for the common TStorages?

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

You can drill into it with sizescope if you really want to know.

My guess is that there are unrelated places in the app that use interfaces implemented by Dictionary, like IDictionary. We are not able to track the data flow and tell that IDictionary won't be ever used on these Dictionary instantiations. It makes us to generate the code for IDictionary methods on these instantiationw even though they may be ever called. This can be worked around by using unique collection types without unnecessary interfaces and other baggage for these sorts of implementation details. It stops the surprising interactions, but that comes with other issues.

@@ -1052,6 +1053,12 @@ private static bool TryParseByName<TStorage>(RuntimeType enumType, ReadOnlySpan<

bool parsed = true;
TStorage localResult = default;

// For case-insensitive parsing, use a cached name->value lookup and match spans without allocating.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

It is counter-intuitive that case-insensitive is faster with this change, but case-sensitive is not. The general expectation is that case-sensitive operations are faster.

Should this be done for case-sensitive parsing as well?

@@ -41,6 +42,39 @@ public EnumInfo(Type underlyingType, TStorage[] values, string[] names, bool isF
internal TStorage[] Values { get; }
internal bool ValuesAreSequentialFromZero { get; }

// Lazily-built, cached case-insensitive name-to-value lookup used to accelerate
// case-insensitive parsing. Null until the first case-insensitive parse of this enum type.
private Dictionary<string, TStorage>? _namesToValuesIgnoreCase;

@MihaZupan MihaZupan Jul 20, 2026

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

You can store the result of GetAlternateLookup instead to avoid the cost of calling it for every lookup.

The difference in the fixed per-lookup overhead may also change where the crossover between a linear scan vs. dictionary would be.

/// </summary>
public Dictionary<string, TStorage> GetNamesToValuesIgnoreCase()
{
return Volatile.Read(ref _namesToValuesIgnoreCase) ?? Initialize();

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Suggested change
return Volatile.Read(ref _namesToValuesIgnoreCase) ?? Initialize();
return _namesToValuesIgnoreCase ?? Initialize();

@@ -8,6 +8,7 @@
using System.Runtime;
using System.Runtime.CompilerServices;
using System.Runtime.InteropServices;
using System.Threading;

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Suggested change
using System.Threading;

Comment on lines +70 to +74
// Publish atomically. If another thread raced and already published a lookup,
// reuse theirs so every caller observes a single cached instance. The fast-path
// Volatile.Read pairs an acquire load with this release publish so readers always
// observe a fully-initialized dictionary.
return Interlocked.CompareExchange(ref _namesToValuesIgnoreCase, lookup, null) ?? lookup;

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Suggested change
// Publish atomically. If another thread raced and already published a lookup,
// reuse theirs so every caller observes a single cached instance. The fast-path
// Volatile.Read pairs an acquire load with this release publish so readers always
// observe a fully-initialized dictionary.
return Interlocked.CompareExchange(ref _namesToValuesIgnoreCase, lookup, null) ?? lookup;
_namesToValuesIgnoreCase = lookup;
return lookup;

There should be no need to synchronize here, we're the only caller and we don't care which exact instance we got

@MichalPetryka

Copy link
Copy Markdown
Contributor

Would FrozenDictionary be better here?

@jkotas

jkotas commented Jul 21, 2026

Copy link
Copy Markdown
Member

Would FrozenDictionary be better here?

This is transient cache, potentially recreated after every GC...

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

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Case-insensitive Enum.TryParse is very slow compared to alternative approaches

6 participants