runtime: Enum.ToString is slower than explicit implementation

Description

Since Enum.ToString usually accesses Enum.GetEnumName, which accesses it in the runtime.

https://github.com/dotnet/runtime/blob/895c99cc502913fd7393b95af7cae5b9e6fbdea3/src/libraries/System.Private.CoreLib/src/System/Enum.cs#L1136-L1146

https://github.com/dotnet/runtime/blob/895c99cc502913fd7393b95af7cae5b9e6fbdea3/src/libraries/System.Private.CoreLib/src/System/Enum.cs#L135-L147

https://github.com/dotnet/runtime/blob/895c99cc502913fd7393b95af7cae5b9e6fbdea3/src/libraries/System.Private.CoreLib/src/System/Enum.cs#L124-L133

However, since the name is already given at compile time I think this should be treated as a constant and assembled at compile time.

Configuration

BenchmarkDotNet=v0.13.1, OS=Windows 10.0.19043.1165 (21H1/May2021Update)
AMD Ryzen 5 3600, 1 CPU, 12 logical and 6 physical cores
.NET SDK=5.0.102
  [Host]     : .NET 5.0.2 (5.0.220.61120), X64 RyuJIT
  DefaultJob : .NET 5.0.2 (5.0.220.61120), X64 RyuJIT

Data

Benchmarks

Method Mean Error StdDev Code Size Gen 0 Allocated
RegularToString 35.004 ns 0.5859 ns 0.5481 ns 104 B 0.0029 24 B
EnumGetName 72.600 ns 0.8403 ns 0.7860 ns 68 B 0.0029 24 B
ExplicitToStringImplementation 1.764 ns 0.0613 ns 0.0573 ns 209 B - -
References

Enum.GetEnumName IL

.method assembly hidebysig static string
    GetEnumName(
      class System.RuntimeType enumType,
      unsigned int64 ulValue
    ) cil managed
  {
    .maxstack 8

    // [108 13 - 108 64]
    IL_0000: ldarg.0      // enumType
    IL_0001: ldc.i4.1
    IL_0002: call         class System.Enum/EnumInfo System.Enum::GetEnumInfo(class System.RuntimeType, bool)
    IL_0007: ldarg.1      // ulValue
    IL_0008: call         string System.Enum::GetEnumName(class System.Enum/EnumInfo, unsigned int64)
    IL_000d: ret

  }

Extension method IL

  .method public hidebysig static string
    ExplicitToString(
      valuetype ToStringBenchmarks.ExampleEnum 'enum'
    ) cil managed
  {
    .custom instance void [System.Runtime]System.Runtime.CompilerServices.ExtensionAttribute::.ctor()
      = (01 00 00 00 )
    .maxstack 3
    .locals init (
      [0] string V_0
    )

    // [9 13 - 16 15]
    IL_0000: ldarg.0      // 'enum'
    IL_0001: switch       (IL_001c, IL_0024, IL_002c, IL_0034, IL_003c)
    IL_001a: br.s         IL_0044

    // [10 41 - 10 69]
    IL_001c: ldstr        "VariantA"
    IL_0021: stloc.0      // V_0
    IL_0022: br.s         IL_0056

    // [11 41 - 11 69]
    IL_0024: ldstr        "VariantB"
    IL_0029: stloc.0      // V_0
    IL_002a: br.s         IL_0056

    // [12 41 - 12 69]
    IL_002c: ldstr        "VariantC"
    IL_0031: stloc.0      // V_0
    IL_0032: br.s         IL_0056

    // [13 41 - 13 69]
    IL_0034: ldstr        "VariantD"
    IL_0039: stloc.0      // V_0
    IL_003a: br.s         IL_0056

    // [14 41 - 14 69]
    IL_003c: ldstr        "VariantE"
    IL_0041: stloc.0      // V_0
    IL_0042: br.s         IL_0056

    // [15 22 - 15 87]
    IL_0044: ldstr        "enum"
    IL_0049: ldarg.0      // 'enum'
    IL_004a: box          ToStringBenchmarks.ExampleEnum
    IL_004f: ldnull
    IL_0050: newobj       instance void [System.Runtime]System.ArgumentOutOfRangeException::.ctor(string, object, string)
    IL_0055: throw

    IL_0056: ldloc.0      // V_0
    IL_0057: ret

  }

We see the IL is bigger, but the code is faster.

Analysis

It is because in runtime the value is resolved although it is already known at compile time.

Perhaps it would also make sense to add a valuenameof keyword similar to nameof with the difference that valuenameof is taken the name of the corresponding enum variant.

External references

About this issue

  • Original URL
  • State: open
  • Created 3 years ago
  • Reactions: 4
  • Comments: 28 (21 by maintainers)

Most upvoted comments

Hey everyone, just jumping in to give my two cents since I made the video in the first place and I don’t wanna be taken out of context.

Bit of a background first. I didn’t discover this, I actually attended @davidfowl’s talk at IO Associates and he mentions this very use-case, the switch being a solution and addresses the O(n) and O(log(n)) thing. All I did is take that, wrap it in a good story and a clickable title and publish it.

Now as I mentioned in the video, people don’t have to do this until the need to, which basically eliminates the vest majority of C# devs. Do I have usecases for it personally? Sure but that’s just another thing in my micro optimization “checklist”. So is not using LINQ, eliminating heap allocations when possible, preventing boxing and a bunch of other stuff.

Realistically the only thing I wanted out of that video was for someone to take the time and create a source generator that can do this automatically at compile time so I don’t have to create switches by hand, and I got that. That’s all there is to it.

I can understand that there are deep CLR reasons why this won’t work, but a runtime InvalidProgramException doesn’t feel like the right behaviour here

This is a bug in Roslyn compiler. I have opened an issue for it: https://github.com/dotnet/roslyn/issues/70841

In any case, the implementation does no harm. It is nothing performance critical, but we are all generally interested in improving the performance of .NET.

To place things in context I created a small test. Of course, when comparing the switched optimization directly to Enum.ToString (‘from which your code is suffering’), the 6x difference seems significant. But please note that once you put it into some real-life scenario (eg. logging), the cost of the conversion becomes negligible:

==[Performance Test Results]================================================
Iterations: 1,000,000
Warming up: Yes
Test cases: 4
Repeats: 3
Calling GC.Collect: Yes
Forced CPU Affinity: No
Cases are sorted by time (quickest first)
--------------------------------------------------
1. 'switched' ToString: average time: 7.24 ms
  #1           7.26 ms
  #2           7.31 ms     <---- Worst
  #3           7.15 ms     <---- Best
  Worst-Best difference: 0.16 ms (2.21 %)
2. Enum.ToString: average time: 46.77 ms (+39.53 ms / 645.80 %)
  #1          49.52 ms
  #2          49.76 ms     <---- Worst
  #3          41.02 ms     <---- Best
  Worst-Best difference: 8.74 ms (21.31 %)
3. Interpolation with 'switched' ToString: average time: 147.77 ms (+140.52 ms / 2,040.44 %)
  #1         157.81 ms     <---- Worst
  #2         143.99 ms
  #3         141.50 ms     <---- Best
  Worst-Best difference: 16.31 ms (11.53 %)
4. Interpolation with Enum.ToString: average time: 201.91 ms (+194.67 ms / 2,788.09 %)
  #1         228.40 ms     <---- Worst
  #2         188.37 ms     <---- Best
  #3         188.96 ms
  Worst-Best difference: 40.03 ms (21.25 %)

Please also note that:

  • You should divide those milliseconds by 1 million to get the time of one iteration
  • The performance of the switched ‘optimization’ degrades for larger enums (eg. KnownColor instead of the used ConsoleColor) because of the sequential execution of the cases
  • The ‘real-life’ context was just a simple interpolation without doing anything with the ‘log entry’. Adding it to some collection, saving in some database, or just printing it out to the console makes the difference almost unnoticeable
  • The interpolated results do not even reflect the time stamp evaluation (only a pre-stored value is formatted). And a real log entry may contain much more embedded values
  • Pre-.NET 6 string interpolation without the latest optimizations or some templating framework (eg. Serilog) would provide even worse results, making the differences even more negligible

Disclaimer: The linked test uses a very short warm-up period to spare the resources of the .NET Fiddle site. To get more reliable results (with low worst-best differences) execute it on your computer with default configuration. The test needs this package (and as I mentioned in my last comment it happens to have some solution for faster enum handling but on recent .NET platforms I would not say it means a huge benefit).

Can’t this be a Roslyn code analyzer and fixer under the “performance” category? If the analyzer sees SomeEnum.SomeValue.ToString(), it recommends a replacement of nameof(SomeEnum.SomeValue)? Ignore flags enums for now since they complicate matters a bit.

Note: This could in theory be a behavioral breaking change if new values are introduced to the enum between compile time and runtime. If the enum is defined in the same assembly as the call site, this obviously isn’t an issue. But I don’t think this would happen in practice often enough to be worth worrying about.

This could simply be a source generator.

So I understand this issue doesn’t have much love, but just wanted to add something I was experimenting with.

@stephentoub mentioned:

a source generator could output an extension method for a specific enum, based for example on attributing that enum with some attribute the source generator knows about, and any code paths that need the super-fast access could call that extension method.

That’s exactly what I did in this NuGet Package and it works great 🙂

However, I was interested playing with the interceptors support in .NET 8. So I put together a simple example program:

var value = MyEnum.Second;
Console.WriteLine(value.ToString());

public enum MyEnum
{
    First,
    Second,
}

public static class Interceptors
{
    [InterceptsLocation(@"Program.cs", line: 5, column: 25)] // these are correct in my test
    public static string OtherToString(this System.Enum value)
        => "Wrong Value" + value;
}

There’s a couple of interesting things here:

  • The compiler forces using System.Enum as the interceptor argument, not MyEnum
  • When you run the program you get an InvalidProgramException
InvalidProgramException: Common Language Runtime detected an invalid program

I can understand that there are deep CLR reasons why this won’t work, but a runtime InvalidProgramException doesn’t feel like the right behaviour here 😅

I haven’t dealt with the implementation exactly yet, but was thinking of implementing a simple switch construct.

You can’t override ToString on an enum in C#, which is what a C# source generator would output. And we can’t / wouldn’t want to modify the base Enum.ToString to know about every enum in the universe, nor would we want it to use some kind of reflection to find and hook up to some well-known-named method that provided this functionality. Having a heavily-optimized ToString implementation that comes at the expense of a non-trivial amount of additional IL (especially for larger enums) is not something that 99.9% of enums require. As @KalleOlaviNiemitalo outlined, a source generator could output an extension method for a specific enum, based for example on attributing that enum with some attribute the source generator knows about, and any code paths that need the super-fast access could call that extension method. I do not currently see a strong need for such a source generator to be in the .NET SDK; if a source generator here is something you’re interested in pursuing, I suggest you do it in some other repo, release it as a nuget for others to consume, etc. Only a small percentage of valuable source generators need actually be in the .NET SDK.

AFAIK, Roslyn source generators currently can only add source code, not remove or modify any. So if the original source code contains an enum type definition, then it will be compiled. C# enum type definitions cannot be partial, and according to ECMA-335 6th edition sections I.8.5.2 (Assemblies and scoping) and II.22.37 (TypeDef : 0x02), enum types cannot define methods of their own. So the source generator cannot inject an override ToString() method in the enum type. It could generate a type with an extension method, but if the extension method were named ToString(), then C# would not use it because the enum type inherits Enum.ToString() already. So the best that the source generator could do in the current SDK would be to generate a class with a differently-named extension method, e.g. ToStringFast(), and recommend its use with an analyzer and a code fixer. It might be possible to automatically use this for interpolated strings, though.

Yeah, in 6.0 source generators were added for JSON serializers and for logging.