Quantcast
Channel: Andrew Lock | .NET Escapades
Viewing all articles
Browse latest Browse all 743

NetEscapades.EnumGenerators: a source generator for enum performance

$
0
0
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
Method FX Mean Error StdDev Ratio Gen 0 Allocated
ToString net48 578.276 ns 3.3109 ns 3.0970 ns 1.000 0.0458 96 B
ToStringFast net48 3.091 ns 0.0567 ns 0.0443 ns 0.005 - -
ToString net6.0 17.9850 ns 0.1230 ns 0.1151 ns 1.000 0.0115 24 B
ToStringFast net6.0 0.1212 ns 0.0225 ns 0.0199 ns 0.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
Method Mean Error StdDev Median Ratio Gen 0 Allocated
EnumIsDefined 123.6001 ns 1.0314 ns 0.9648 ns 123.7756 ns 1.000 0.0114 24 B
ExtensionsIsDefined 0.0016 ns 0.0044 ns 0.0039 ns 0.0000 ns 0.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
Method Mean Error StdDev Ratio Gen 0 Allocated
EnumToString 17.9850 ns 0.1230 ns 0.1151 ns 1.000 0.0115 24 B
ToStringFast 0.1212 ns 0.0225 ns 0.0199 ns 0.007 - -
Method Mean Error StdDev Median Ratio Gen 0 Allocated
EnumIsDefined 123.6001 ns 1.0314 ns 0.9648 ns 123.7756 ns 1.000 0.0114 24 B
ExtensionsIsDefined 0.0016 ns 0.0044 ns 0.0039 ns 0.0000 ns 0.000 - -
Method Mean Error StdDev Ratio Allocated
EnumIsDefinedName 60.735 ns 0.3510 ns 0.3284 ns 1.00 -
ExtensionsIsDefinedName 5.757 ns 0.0875 ns 0.0730 ns 0.09 -
Method Mean Error StdDev Median Ratio RatioSD Allocated
EnumTryParseIgnoreCase 75.20 ns 3.956 ns 10.962 ns 70.55 ns 1.00 0.00 -
ExtensionsTryParseIgnoreCase 14.27 ns 0.486 ns 1.371 ns 13.91 ns 0.19 0.03 -
Method Mean Error StdDev Ratio Gen 0 Allocated
EnumGetValues 470.613 ns 9.3125 ns 16.3101 ns 1.00 0.0534 112 B
ExtensionsGetValues 4.705 ns 0.1455 ns 0.1290 ns 0.01 0.0191 40 B
Method Mean Error StdDev Ratio RatioSD Gen 0 Allocated
EnumGetNames 27.88 ns 1.557 ns 4.540 ns 1.00 0.00 0.0229 48 B
ExtensionsGetNames 12.28 ns 0.315 ns 0.323 ns 0.42 0.08 0.0229 48 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!


Viewing all articles
Browse latest Browse all 743

Trending Articles