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

A failed experiment with interceptors in C# 12 and .NET 8

$
0
0

In this post I describe how I tried (and failed) to add an interceptor to my NetEscapades.EnumExtensions NuGet package. The bad news is I didn't get it to work. The good news is that the experiment unearthed a bug in the Roslyn compiler. I start by providing a little background to the NuGet package, then talk about interceptors, and describe what happened when I tried to create one!

Working with enum can be slow

The humble enum is a mainstay of a lot of .NET code. In many ways it's a glorified integer (and pretty much behaves as such at runtime), but it allows you to give understandable names to specific integer values and provides a certain level of type-safety.

However, one of the unfortunate realities of working with enum types is that some apparently simple operations, such as calling ToString() on an enum value, are surprisingly slow.

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
}

This works, but it's surprisingly slow. To see how slow, let's compare it to a simple alternative:

public static class ColourExtensions
{
    public static string ToStringFast(this Colour colour)
        => colour switch
        {
            Colour.Red => nameof(Colour.Red),
            Colour.Blue => nameof(Colour.Blue),
            _ => colour.ToString(), // fallback to the built-in method for unknown values
        }
}

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 for simplicity. This is necessary because enums really are just wrappers for int (or another integer backing type), so doing (Color)123 is value C#!

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

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.276ns3.3109ns3.0970ns1.0000.045896 B
ToStringFastnet483.091ns0.0567ns0.0443ns0.005--
ToStringnet6.017.9850ns0.1230ns0.1151ns1.0000.011524 B
ToStringFastnet6.00.1212ns0.0225ns0.0199ns0.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!

These all got even faster in .NET 8, both the built-in and source-generated versions, but I don't have the exact numbers to hand.

This pattern isn't just the case for ToString(). Common methods like Enum.TryParse<T>(), Enum.GetValues<T>() and Enum.GetNames<T> are all slow compared to the "naïve" approach. The trouble is that maintaining methods like ToStringFast() manually are doomed to failure. It's just a matter of time until you change the definition of enum, and forget to update your helper methods.

That's where the NetEscapades.EnumGenerators package comes in.

Generating fast methods with NetEscapades.EnumGenerators

The NetEscapades.EnumGenerators NuGet package contains a source generator that writes the "by-hand" version of ToStringFast() shown in the previous section for you. The key advantage of the source-generator over the by-hand version is that if you change the enum definition, the generated ToStringFast() method is also regenerated. This alleviates the main pain point with using by-hand versions.

To use the package, add it to your application using

dotnet add package NetEscapades.EnumGenerators --prerelease

This makes the [EnumExtensions] attribute available. If you place this on your enum, the source generator will generate an extension class containing ToStringFast(). For example, add the attribute like this:

using NetEscapades.EnumGenerators;

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

And the source generator generates a ColourExtensions class containing ToStringFast() (and other helper methods):

The ToStringFast definition for Colour

You can read more about the additional helper methods on Github or in the announcement blog post.

The one problem that the NetEscapades.EnumGenerators source generator can't solve is that you have to remember to use the extension methods in your code. That means you have to remember to call myValue.ToStringFast() instead of myValue.ToString(), or ColourExtensions.IsDefined(value) instead of Enum.IsDefined<Colour(value). That's where interceptors come in.

Intercepting and replacing call site invocations

Interceptors are a preview feature in .NET 8. I wrote about them previously in more detail, but essentially they allow you to provide an alternative implementation for a method. This is particularly useful for AOT, as it means library authors can provide alternative AOT-friendly implementations for methods, without the user (you) having to change your code at all.

Interceptors are designed to be used with source generators, in that they require you to specify the exact file, line, and character number of the method you're going to replace. On top of that, the method you substitute in has to have the exact same signature as the method you're intercepting.

In it's simplest form, this means a program something like this:

Console.WriteLine("Hello, world!");

static class Interception
{
    [InterceptsLocation("/some/path/Program.cs", line: 1, column: 9)]
    public static void CustomWriteLine(string line)
        => Console.WriteLine("Intercepted: " + line);
}

Will print "Intercepted: Hello, world!", becase the CustomWriteLine() method is called.

So where do interceptors come in for NetEscapades.EnumGenerators? Well, they have the potential to remove the biggest hurdle to using the library: remembering to use the library.

My thought was that I could create an interceptor that looked for anywhere some writes Colour.ToString() and replace it with a call to Colour.ToStringFast() instead. That way you wouldn't need to do anything to make use of the library. You could literally install the library, add [EnumExtensions] to your enum, and 💥boom💥, free performance.

I spent a while going down this rabbit hole to get a PoC concept that was producing the required [IntercepsLocation] attributes and implementations and… it didn't work. So I backtracked, and tried to produce a "manual" example that did what I wanted, and it still didn't work. Hmmm…

A minimum viable interceptor for Enum.ToString()

If you read the intro to this post, you already know where this is going, but I'm documenting exactly what I did here for proseprity, and hopefully noone else goes down the same dead end!

I started with a very simple program in which I define an enum Color and call ToString() on and instance of it:

using System.Runtime.CompilerServices;

var value = Colour.Blue;

Console.WriteLine(value.ToString());

public enum Colour
{
    Red,
    Blue,
}

Nothing exciting yet, this prints "Blue" to the console when it's run as you would expect. Now lets add the interceptor.

First, we need to make sure the [InterceptsLocation] is available. The easiest way to do that is to define it somewhere in your project like this:

namespace System.Runtime.CompilerServices
{
    [AttributeUsage(AttributeTargets.Method, AllowMultiple = true)]
    sealed class InterceptsLocationAttribute : Attribute
    {
        public InterceptsLocationAttribute(string filePath, int line, int column)
        {
            _ = filePath; // These stop the compiler complaining about unused fields
            _ = line;
            _ = column;
        }
    }
}

Next, we create the interceptor itself, using the [InterceptsLocation] we defined, and specifying the correct path, line, and column that corresponds to the ToString() call in our program.

namespace MyInterceptors;
public static class Interceptors
{
    [InterceptsLocation(@"C:\testapp\Program.cs", line: 5, column: 25)]
    public static string OtherToString(this Colour value)
        => "Something else";             // 👆 Extension method on Colour
}

Notes that OtherToString() has the signature of an extension method on Colour. This is the general design you will take to create interceptors. If you attempt to build your app now, you'll get an error like this:

error CS9137: The 'interceptors' experimental feature is not enabled in this namespace.
Add '<InterceptorsPreviewNamespaces>$(InterceptorsPreviewNamespaces);MyInterceptors
</InterceptorsPreviewNamespaces>' to your project.

As you can see from the error, interceptors aren't automatically enabled currently. You have to explicitly opt-in to an interceptors namespace (so that you can conditionally opt in to interceptors) using the InterceptorsPreviewNamespaces property in your csproj file:

<Project Sdk="Microsoft.NET.Sdk">

  <PropertyGroup>
    <OutputType>Exe</OutputType>
    <TargetFramework>net8.0</TargetFramework>
    <ImplicitUsings>enable</ImplicitUsings>
    <Nullable>enable</Nullable>
    <!-- 👇 Add this -->
    <InterceptorsPreviewNamespaces>$(InterceptorsPreviewNamespaces);MyInterceptors</InterceptorsPreviewNamespaces>
  </PropertyGroup>

</Project>

Note that the MyInterceptors value I used is the namespace in which I defined the Interceptors.OtherToString() value.

OK, now we should have everything we need for the interceptor. But that's where things go off the rails.

ToString() weirdness and bugs

After adding the InterceptorsPreviewNamespaces property to the csproj file, I tried to build again. This time I got another build-time error, though the problem here was less obvious:

error CS9148: Interceptor must  have a 'this' parameter 
matching parameter 'Enum this' on 'Enum.ToString()'.

What this error seemed to be saying was that instead of an interceptor with a method signature that looks like this:

                                    // 👇 Extension method on Colour
public static string OtherToString(this Colour value)

I needed one that looks like this:

                                    // 👇 Extension method on System.Enum
public static string OtherToString(this System.Enum value)

That seemed…odd. But I know that enum are a bit weird, because they have special-case compiler support, so it seemed plausible. And sure enough, opening Visual Studio's Syntax Visualizer showed that the "method symbol" being invoked in the expression value.ToString() was indeed Enum.ToString() not Colour.ToString()

The Visual Studio syntax visualizer, showing that value.ToString() invokes System.Enum

On that basis, I updated the method argument for OtherToString() to use System.Enum and left everything else the same:

namespace MyInterceptors;
public static class Interceptors
{
    [InterceptsLocation(@"C:\testapp\Program.cs", line: 5, column: 25)]
    public static string OtherToString(this System.Enum value)
        => "Something else";             // 👆 Extension method on Enum
}

And it compiles!

But if you try to run this application, you get:

> dotnet run
Unhandled exception. System.InvalidProgramException: 
Common Language Runtime detected an invalid program.
   at Program.<Main>$(String[] args)

Oh dear, InvalidProgramException's are generally Bad™ I took to GitHub to describe the issue, and sure enough, it's a bug in Roslyn 😬

No interceptors for me

The problem appears to be a bug in the Roslyn compiler producing invalid IL. If we look at the IL of the compiled application's entrypoint (for example by using ildasm.exe), it looks like this:

.method private hidebysig static void  '<Main>$'(string[] args) cil managed
{
  .entrypoint
  // Code size       15 (0xf)
  .maxstack  1
  .locals init (valuetype Colour V_0)
  IL_0000:  ldc.i4.1
  IL_0001:  stloc.0
  IL_0002:  ldloc.0
  IL_0003:  call       string MyInterceptors.Interceptors::OtherToString(class [System.Runtime]System.Enum)
  IL_0008:  call       void [System.Console]System.Console::WriteLine(string)
  IL_000d:  nop
  IL_000e:  ret
}

I've added a basic explanation of the IL op-codes below (it assumes you have a basic understanding of IL as a stack-based virtual machine):

.locals init (valuetype Colour V_0) // Declare a single variable of type Colour
IL_0000:  ldc.i4.1 // Pushes the value "1" onto the stack
IL_0001:  stloc.0 // Stores the value into the variable at location 0
IL_0002:  ldloc.0 // Loads the variable at location 0 into the stack
IL_0003:  call  string MyInterceptors.Interceptors::OtherToString(class [System.Runtime]System.Enum)
                // 👆 Attempts to call OtherToString, passing in the variable 💥

The problem in the IL is that we have a variable of type Colour which we assign the value 1 (Blue) in the first 2 op codes. We then load the Colour variable and try to directly invoke OtherToString which requires System.Enum. Colour is not assignable to System.Enum in IL, and so we get an InvalidProgramException!

So where does that leave us? Well, until this bug is fixed, there's basically no hope for NetEscapdes.EnumGenerators interceptors. But once it is, I think they should definitely be feasible, which I think would be a really nice bonus feature!

Summary

In this post I described some of the performance concerns related to enum and how NetEscapades.EnumGenerators addresses them by avoiding the need to keep your app constantly updated. I then attempted to address the downside of the NetEscapades.EnumGenerators package—remembering to use it—by creating an interceptor. Unfortunately, my attempt to create an interceptor for Enum.ToString() revealed a bug in the compiler, which means that's off the table for now. I'm hoping to be able to make it work soon, assuming the bug is fixed soon!


Viewing all articles
Browse latest Browse all 743

Trending Articles