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()
(replacesToString()
)IsDefined(T value)
(replacesEnum.IsDefined<T>(T value)
)IsDefined(string name)
(new, is the providedstring
a known name of anenum
)TryParse(string? name, bool ignoreCase, out T value)
(replacesEnum.TryParse()
)TryParse(string? name, out T value)
(replacesEnum.TryParse()
)GetValues()
(replacesEnum.GetValues()
)GetNames()
(replacesEnum.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:
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 enum
s, it's one many people know about. But many of the methods around enum
s 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)
:
The enum type in C# .NET has a cool method to check if an numeric type is actually an Enum, but unfortunately it's performance is orders of magnitude slower then a simple switch or if check.
— Bartosz Adamczewski (@badamczewski01) February 5, 2022
Credit goes to @hypeartistmusic for finding the problem.#dotnet pic.twitter.com/pMFSVEmFQB
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!