Speed up case-insensitive Enum parsing#131030
Conversation
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
|
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. |
|
Tagging subscribers to this area: @dotnet/area-system-numerics |
There was a problem hiding this comment.
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
|
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 |
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
There was a problem hiding this comment.
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
_namesToValuesIgnoreCaseon the fast path, but the reference is published viaInterlocked.CompareExchange. To safely publish the fully-initializedDictionaryon weak memory models, the fast path should useVolatile.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
| 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; | ||
| } | ||
| } |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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.
|
Good question — I measured it. Here's an isolated A/B of just the name-matching step (previous linear
Reading it:
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 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
|
@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. |
| // 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) |
| // 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++) |
| 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() |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
How is it so much since we get like 8 instantiations max for the common TStorages?
There was a problem hiding this comment.
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. | |||
There was a problem hiding this comment.
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; | |||
There was a problem hiding this comment.
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(); |
There was a problem hiding this comment.
| 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; | |||
There was a problem hiding this comment.
| using System.Threading; |
| // 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; |
There was a problem hiding this comment.
| // 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
|
Would FrozenDictionary be better here? |
This is transient cache, potentially recreated after every GC... |
Fixes #118548
Motivation
Case-insensitive
Enum.TryParse/Enum.Parsedid an O(N) linear scan, callingEqualsOrdinalIgnoreCaseagainst 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
TryParseByNamewith a lazily-built, cachedDictionary<string, TStorage>(StringComparer.OrdinalIgnoreCase) on the per-typeEnumInfo, consumed viaAlternateLookup<ReadOnlySpan<char>>so span tokens match without allocating strings. Names are inserted withTryAddin 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 withInterlocked.CompareExchange. The lookup is added to bothEnumInfoimplementations — the shared one (CoreCLR/Mono) and NativeAOT'sSystem.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:
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.TryParseA/B on Linux AMD64 + macOS arm64.Validation
System.Runtimetests pass (0 related failures), including 821Enumcases (case-insensitive parse, flags, case-collision edge cases).Risks
Interlocked.CompareExchange; a first-parse race only wastes a transient dictionary and never publishes a partial one.Note
This pull request description was created with GitHub Copilot.