blog post image
Andrew Lock avatar

Andrew Lock

~8 min read

NetEscapades.EnumGenerators: a source generator for enum performance

In this post I describe a source generator I created to improve the performance of enum operations. It is available as a NuGet package, so you're free to use it in your projects too!

The NetEscapades.EnumGenerators NuGet package currently generates 7 useful enum methods that are much faster than their built-in equivalents:

  • ToStringFast() (replaces ToString())
  • IsDefined(T value) (replaces Enum.IsDefined<T>(T value))
  • IsDefined(string name) (new, is the provided string a known name of an enum)
  • TryParse(string? name, bool ignoreCase, out T value) (replaces Enum.TryParse())
  • TryParse(string? name, out T value) (replaces Enum.TryParse())
  • GetValues() (replaces Enum.GetValues())
  • GetNames() (replaces Enum.GetNames())

You can see the benchmarks for these methods below, or read on to learn why you should use them, and how to use the source generator in your project.

Why use a source generator for enums? Performance

One of the first questions you should be asking yourself is why use a source generator? The simple answer is that enums can be very slow in some cases. By using a source generator you can get some of this performance back.

For example, let's say you have this simple enum:

public enum Colour
{
    Red = 0,
    Blue = 1,
}

At some point, you want to print out the name of the enum using ToString(). No problem, right?

public void PrintColour(Colour colour)
{
    Console.WriteLine("You chose "+ colour.ToString()); // You chose Red
}

So what's the problem? Well, unfortunately, calling ToString() on an enum is really slow. We'll look at how slow shortly, but first we'll look at a fast implementation, using modern C#:

public static class ColourExtensions
{
    public string ToStringFast(this Colour colour)
        => colour switch
        {
            Colour.Red => nameof(Colour.Red),
            Colour.Blue => nameof(Colour.Blue),
            _ => colour.ToString(),
        }
    }
}

This simple switch statement checks for each of the known values of Colour and uses nameof to return the textual representation of the enum. If it's an unknown value, then the underlying value is returned using the built-in ToString() implementation.

You always have to be careful about these unknown values: for example this is valid C# PrintColour((Colour)123)

If we compare this simple switch statement to the default ToString() implementation using BenchmarkDotNet for a known colour, you can see how much faster our implementation is:

BenchmarkDotNet=v0.13.1, OS=Windows 10.0.19042.1348 (20H2/October2020Update)
Intel Core i7-7500U CPU 2.70GHz (Kaby Lake), 1 CPU, 4 logical and 2 physical cores
  DefaultJob : .NET Framework 4.8 (4.8.4420.0), X64 RyuJIT
.NET SDK=6.0.100
  DefaultJob : .NET 6.0.0 (6.0.21.52210), X64 RyuJIT
MethodFXMeanErrorStdDevRatioGen 0Allocated
ToStringnet48578.276 ns3.3109 ns3.0970 ns1.0000.045896 B
ToStringFastnet483.091 ns0.0567 ns0.0443 ns0.005--
ToStringnet6.017.9850 ns0.1230 ns0.1151 ns1.0000.011524 B
ToStringFastnet6.00.1212 ns0.0225 ns0.0199 ns0.007--

First off, it's worth pointing out that ToString() in .NET 6 is over 30× faster and allocates only a quarter of the bytes than the method in .NET Framework! Compare that to the "fast" version though, and it's still super slow!

As fast as it is, creating the ToStringFast() method is a bit of a pain, as you have to make sure to keep it up to date as your enum changes. That's where the NetEscapades.EnumGenerators source generator comes in!

Installing the NetEscapades.EnumGenerators source generator

You can install the NetEscapades.EnumGenerators NuGet package containing the source generator by running the following from your project directory:

dotnet add package NetEscapades.EnumGenerators --prerelease

Note that this NuGet package uses the .NET 6 incremental generator APIs, so you must have the .NET 6 SDK installed, though you can target earlier frameworks.

This adds the package to your project file:

<PackageReference Include="NetEscapades.EnumGenerators" Version="1.0.0-beta04" />

I suggest you update this to set PrivateAssets="all", and ExcludeAssets="runtime":

<PackageReference Include="NetEscapades.EnumGenerators" Version="1.0.0-beta04" 
    PrivateAssets="all" ExcludeAssets="runtime" />

Setting PrivateAssets="all" means any projects referencing this one won't get a reference to the NetEscapades.EnumGenerators package. Setting ExcludeAssets="runtime" ensures the NetEscapades.EnumGenerators.Attributes.dll file used by the source generator is not copied to your build output (it is not required at runtime).

This package uses the marker-attribute approach I described in my previous post to avoid transitive project reference issues.

Using the source generator

Adding the package to your project automatically adds a marker attribute, [EnumExtensions], to your project. To use the generator, add the [EnumExtensions] attribute to an enum. For example:

using NetEscapades.EnumGenerators;

[EnumExtensions]
public enum Colour
{
    Red = 0,
    Blue = 1,
}

This generates various extension methods for your enum, including ToStringFast(). You can use this method anywhere you would ordinarily call ToString() on the enum, and benefit from the performance improvement for known values:

public void PrintColour(Colour colour)
{
    Console.WriteLine("You chose "+ colour.ToStringFast()); // You chose Red
}

You can view the definition of ToStringFast() by navigating to it's definition:

The ToStringFast definition for Colour

By default, source generators don't write their output to disk. In a previous post I described how you can set <EmitCompilerGeneratedFiles> and <CompilerGeneratedFilesOutputPath> to persist this files to disk.

The ToStringFast() method above is low-hanging fruit for speeding up enums, it's one many people know about. But many of the methods around enums are quite slow. The source generator can help with those too!

Source generating other helper methods

A recent tweet from Bartosz Adamczewski highlighted how slow another enum method is, Enum.IsDefined<T>(T value):

As shown in the benchmarks above, calling Enum.IsDefined<T>(T value) can be slower than you might expect! Luckily, if you're using NetEscapades.EnumGenerators you get a fast version of this method generated for free:

internal static partial class ColourExtensions
    public static bool IsDefined(Colour value)
        => value switch
        {
            Colour.Red => true,
            Colour.Blue => true,
            _ => false,
        };

Rather than generate this as an extension method, this method is exposed as a static on the generated static class. The same is true for all the additional helper functions generated by the source generator.

The benchmarks for this method are in-line with those shown by Bartosz:

BenchmarkDotNet=v0.13.1, OS=Windows 10.0.19042.1348 (20H2/October2020Update)
Intel Core i7-7500U CPU 2.70GHz (Kaby Lake), 1 CPU, 4 logical and 2 physical cores
.NET SDK=6.0.100
  [Host]     : .NET 6.0.0 (6.0.21.52210), X64 RyuJIT
  DefaultJob : .NET 6.0.0 (6.0.21.52210), X64 RyuJIT
MethodMeanErrorStdDevMedianRatioGen 0Allocated
EnumIsDefined123.6001 ns1.0314 ns0.9648 ns123.7756 ns1.0000.011424 B
ExtensionsIsDefined0.0016 ns0.0044 ns0.0039 ns0.0000 ns0.000--

This shows the benefit of two of the source-generated methods, ToStringFast() and IsDefined(). The code below shows the complete generated code for the ColourExtensions class generated by the source generator, including all 7 methods:

#nullable enable
internal static partial class ColourExtensions
{
    public static string ToStringFast(this Colour value)
        => value switch
        {
            Colour.Red => nameof(Colour.Red),
            Colour.Blue => nameof(Colour.Blue),
            _ => value.ToString(),
        };

    public static bool IsDefined(Colour value)
        => value switch
        {
            Colour.Red => true,
            Colour.Blue => true,
            _ => false,
        };

    public static bool IsDefined(string name)
        => name switch
        {
            nameof(Colour.Red) => true,
            nameof(Colour.Blue) => true,
            _ => false,
        };

    public static bool TryParse(
#if NETCOREAPP3_0_OR_GREATER
        [System.Diagnostics.CodeAnalysis.NotNullWhen(true)]
#endif
        string? name, 
        bool ignoreCase, 
        out Colour value)
        => ignoreCase ? TryParseIgnoreCase(name, out value) : TryParse(name, out value);

    private static bool TryParseIgnoreCase(
#if NETCOREAPP3_0_OR_GREATER
        [System.Diagnostics.CodeAnalysis.NotNullWhen(true)]
#endif
        string? name, 
        out Colour value)
    {
        switch (name)
        {
            case { } s when s.Equals(nameof(Colour.Red), System.StringComparison.OrdinalIgnoreCase):
                value = Colour.Red;
                return true;
            case { } s when s.Equals(nameof(Colour.Blue), System.StringComparison.OrdinalIgnoreCase):
                value = Colour.Blue;
                return true;
            case { } s when int.TryParse(name, out var val):
                value = (Colour)val;
                return true;
            default:
                value = default;
                return false;
        }
    }

    public static bool TryParse(
#if NETCOREAPP3_0_OR_GREATER
        [System.Diagnostics.CodeAnalysis.NotNullWhen(true)]
#endif
        string? name, 
        out Colour value)
    {
        switch (name)
        {
            case nameof(Colour.Red):
                value = Colour.Red;
                return true;
            case nameof(Colour.Blue):
                value = Colour.Blue;
                return true;
            case { } s when int.TryParse(name, out var val):
                value = (Colour)val;
                return true;
            default:
                value = default;
                return false;
        }
    }

    public static Colour[] GetValues()
    {
        return new[]
        {
            Colour.Red,
            Colour.Blue,
        };
    }

    public static string[] GetNames()
    {
        return new[]
        {
            nameof(Colour.Red),
            nameof(Colour.Blue),
        };
    }
}

As you can see, there's a lot of code being generated for free here! And just for completeness, the following shows some benchmarks comparing the source-generated methods to their framework equivalents:

BenchmarkDotNet=v0.13.1, OS=Windows 10.0.19042.1348 (20H2/October2020Update)
Intel Core i7-7500U CPU 2.70GHz (Kaby Lake), 1 CPU, 4 logical and 2 physical cores
.NET SDK=6.0.100
  [Host]     : .NET 6.0.0 (6.0.21.52210), X64 RyuJIT
  DefaultJob : .NET 6.0.0 (6.0.21.52210), X64 RyuJIT
MethodMeanErrorStdDevRatioGen 0Allocated
EnumToString17.9850 ns0.1230 ns0.1151 ns1.0000.011524 B
ToStringFast0.1212 ns0.0225 ns0.0199 ns0.007--
MethodMeanErrorStdDevMedianRatioGen 0Allocated
EnumIsDefined123.6001 ns1.0314 ns0.9648 ns123.7756 ns1.0000.011424 B
ExtensionsIsDefined0.0016 ns0.0044 ns0.0039 ns0.0000 ns0.000--
MethodMeanErrorStdDevRatioAllocated
EnumIsDefinedName60.735 ns0.3510 ns0.3284 ns1.00-
ExtensionsIsDefinedName5.757 ns0.0875 ns0.0730 ns0.09-
MethodMeanErrorStdDevMedianRatioRatioSDAllocated
EnumTryParseIgnoreCase75.20 ns3.956 ns10.962 ns70.55 ns1.000.00-
ExtensionsTryParseIgnoreCase14.27 ns0.486 ns1.371 ns13.91 ns0.190.03-
MethodMeanErrorStdDevRatioGen 0Allocated
EnumGetValues470.613 ns9.3125 ns16.3101 ns1.000.0534112 B
ExtensionsGetValues4.705 ns0.1455 ns0.1290 ns0.010.019140 B
MethodMeanErrorStdDevRatioRatioSDGen 0Allocated
EnumGetNames27.88 ns1.557 ns4.540 ns1.000.000.022948 B
ExtensionsGetNames12.28 ns0.315 ns0.323 ns0.420.080.022948 B

Basically, all the benchmarks show improved execution times, and most show reduced allocations. These are all Good Things™

Summary

In this post, I described the NetEscapades.EnumGenerators NuGet package. This provides a number of helper methods for working with enums that have better performance than the built-in methods, without requiring anything more than adding a package, and adding an [EnumExtensions] attribute. If it looks interesting, please give it a try, and feel free to raise issues/PRs on GitHub!

Andrew Lock | .Net Escapades
Want an email when
there's new posts?