.NET8New posture for accessing private members UnsafeAccessor (Part 2)

Foreword

Continuing from the previous chapter, we discussed the new UnsafeAccessor in .NET8, and accessed private members through UnsafeAccessor, which greatly facilitated the writing of our code. Of course, we also talked about some of its current limitations, so what is its performance? Let’s actually test it today.

Test code

Without further ado, let’s get straight to the code. The code for this test is as follows:

using System.Linq.Expressions;
using System.Reflection;
using System.Reflection.Emit;
using System.Runtime.CompilerServices;
using BenchmarkDotNet.Attributes;
using BenchmarkDotNet.Columns;
using BenchmarkDotNet.Configs;
using BenchmarkDotNet.Order;
using BenchmarkDotNet.Reports;
using BenchmarkDotNet.Running;
usingPerfolizer.Horology;
[MemoryDiagnoser]
[Orderer(SummaryOrderPolicy.FastestToSlowest)]
public class AccessBenchmarks
{
    public static readonly A TestInstance = new();
    public static readonly Action<A, int> SetDelegate;
    public static readonly Func<A, int> GetDelegate;
    public static readonly PropertyInfo ValueProperty;
    public static readonly MethodInfo SetValueMethod;
    public static readonly MethodInfo GetValueMethod;
    public static readonly Func<A, int> GetValueExpressionFunc;
    public static readonly Action<A, int> SetValueExpressionAction;
    static AccessBenchmarks()
    {
        TestInstance = new();
        ValueProperty = typeof(A).GetProperty("Value");
        SetValueMethod = ValueProperty.GetSetMethod();
        GetValueMethod = ValueProperty.GetGetMethod();
        SetDelegate = CreateSetDelegate();
        GetDelegate = CreateGetDelegate();
        GetValueExpressionFunc = CreateGetValueExpressionFunc();
        SetValueExpressionAction = CreateSetValueExpressionAction();
    }
    [UnsafeAccessor(UnsafeAccessorKind.Method, Name = "get_Value")]
    static extern int GetValueUnsafe(A a);
    [UnsafeAccessor(UnsafeAccessorKind.Method, Name = "set_Value")]
    static extern void SetValueUnsafe(A a, int value);
    [Benchmark]
    public void UnsafeAccessor()
    {
        SetValueUnsafe(TestInstance, 10);
        var value = GetValueUnsafe(TestInstance);
    }
    [Benchmark]
    public void Reflection()
    {
        SetValueMethod.Invoke(TestInstance, new object[] { 10 });
        var value = GetValueMethod.Invoke(TestInstance, new object[] { });
    }
    [Benchmark]
    public void Emit()
    {
        SetDelegate(TestInstance, 10);
        var value = GetDelegate(TestInstance);
    }
    [Benchmark]
    public void ExpressionTrees()
    {
        SetValueExpressionAction(TestInstance, 10);
        var value = GetValueExpressionFunc(TestInstance);
    }
    [Benchmark]
    public void Direct()
    {
        TestInstance.Value = 10;
        var value = TestInstance.Value;
    }
    private static Action<A, int> CreateSetDelegate()
    {
        var dynamicMethod = new DynamicMethod("SetValue", null, new[] { typeof(A), typeof(int) }, typeof(A));
        var ilGenerator = dynamicMethod.GetILGenerator();
        ilGenerator.Emit(OpCodes.Ldarg_0);
        ilGenerator.Emit(OpCodes.Ldarg_1);
        ilGenerator.EmitCall(OpCodes.Call, SetValueMethod, null);
        ilGenerator.Emit(OpCodes.Ret);
        return (Action<A, int>)dynamicMethod.CreateDelegate(typeof(Action<A, int>));
    }
    private static Func<A, int> CreateGetDelegate()
    {
        var dynamicMethod = new DynamicMethod("GetValue", typeof(int), new[] { typeof(A) }, typeof(A));
        var ilGenerator = dynamicMethod.GetILGenerator();
        ilGenerator.Emit(OpCodes.Ldarg_0);
        ilGenerator.EmitCall(OpCodes.Call, GetValueMethod, null);
        ilGenerator.Emit(OpCodes.Ret);
        return (Func<A, int>)dynamicMethod.CreateDelegate(typeof(Func<A, int>));
    }
    private static Func<A, int> CreateGetValueExpressionFunc()
    {
        var instance = Expression.Parameter(typeof(A), "instance");
        var getValueExpression = Expression.Lambda<Func<A, int>>(
            Expression.Property(instance, ValueProperty),
            instance);
        return getValueExpression.Compile();
    }
    private static Action<A, int> CreateSetValueExpressionAction()
    {
        var instance = Expression.Parameter(typeof(A), "instance");
        var value = Expression.Parameter(typeof(int), "value");
        var setValueExpression = Expression.Lambda<Action<A, int>>(
            Expression.Call(instance, ValueProperty.GetSetMethod(true), value),
            instance, value);
        return setValueExpression.Compile();
    }
}
public class A
{
    public int Value { get; set; }
}
public class Program
{
    public static void Main()
    {
        Console.WriteLine(AccessBenchmarks.TestInstance);
        var summary = BenchmarkRunner.Run<AccessBenchmarks>(DefaultConfig.Instance.WithSummaryStyle(new SummaryStyle(
            cultureInfo: null, // use default
            printUnitsInHeader: true,
            printUnitsInContent: true,
            sizeUnit: SizeUnit.B,
            timeUnit: TimeUnit.Nanosecond,
            printZeroValuesInContent: true,
            ratioStyle: RatioStyle.Trend // this will print the ratio column
        )));
    }
}

In the test code, we used BenchmarkDotNet for testing. The test content includes:

  • UnsafeAccessor: Use the UnsafeAccessor attribute to access private members

  • Reflection: Use reflection to access private members

  • Emit: Use Emit + dynamic method to access private members

  • ExpressionTrees: Use expression trees + delegates to access private members

  • Direct: direct access to private members

The test results are shown in the figure below. It can be seen that the performance of using UnsafeAccessor is the best, followed by direct access to private members, and reflection is the worst. This is actually beyond my expectation, because I thought that its performance is at most similar to that of direct access to private members, but in fact its performance is better than direct access to private members. Of course, it may also be a statistical error, which is on the scale of 0.0000ns. Already very small.

a60279a935079b8238ee333c1feebd68.png

Deep dive

I think everyone has a lot of questions after seeing this. In fact, the author himself also has a lot of questions after seeing this, mainly these two:

  • What made the .NET community want to join this API?

  • How does it access private members?

  • Why is the performance so good?

Reasons for new features

If we want to understand the things behind this function, then we must first find the Issues corresponding to this API. According to the specifications of the .NET community, all APIs need to submit Issues, and then go through API Review and multiple rounds of discussion and design before we start. development.

First, we locate the Issue. In the Issue, we can learn that this API is mainly used by frameworks such as System.Text.Json or EF Core that need to access private members, because currently they are all based on Emit dynamic code generation. Implemented, but Emit cannot be used in AOT. At this stage, only the slow reflection API can be used, so a zero-overhead private member access mechanism is urgently introduced.

https://github.com/dotnet/runtime/issues/86161

How to access private members?

Looking through the discussion of the entire API proposal Issue, we can find the specific implementation of the Issue, so if we want to understand the principles behind it, we need to jump to the corresponding Issue.

Here you can see that there is no generic implementation yet. Non-generic implementations have been implemented in the links below. One is for CoreCLR and the other is for Mono.

85da83b46df9fb7854b80624619933dc.png

We are currently only focusing on CoreCLR, click on this Issue.

https://github.com/dotnet/runtime/issues/86161

bd27ca999afdae1c9aa258515a3f78b1.png

You can see that this task is split into several parts, and they are all completed in one PR, including defining the UnsafeAccessor feature, implementing it in JIT, and supporting it in NativeAOT. In addition, unit tests were written and effective diagnostic solutions were added.

So let’s take a look at what was done in this PR.

https://github.com/dotnet/runtime/pull/86932

Since the PR is very long, if you are interested, you can click in and take a look. Friends with less than 8GB of memory should be careful. To put it simply, this modification mainly consists of two parts. One is JIT-related modifications. JIT mainly supports the usage of UnsafeAccessor and staticexternint declaration functions and needs to support methods. The IL Body is empty, and then code is inserted for it according to the characteristics during JIT.

First, let’s look at JIT processing. This code mainly modifies jitinterface.cpp. You can see that it calls the TryGenerateUnsafeAccessor method:

db30e24c0877753f808d8b74e2773696.png

This TryGenerateUnsafeAccessor method is implemented in prestub.cpp. This prestub.cpp implements some pre-instrumentation operations, TryGenerateUnsafeAccessor The code> method implementation is as follows:

ef9989cb7670528f218be8639f391ca8.png

It checks different enumerations of UnsafeAccessorKind to prevent runtime crashes:

6c173e81fd45490ea5c0b403aee6781a.png

Then the GenerateAccessor method is called to generate IL:

599b99ca9f5e41ae47eab595980db918.png

In GenerateAccessor, Emit is used for code generation:

7adca43a84a31870cf83624538197998.png

So from the perspective of JIT implementation, its core principle is Emit code generation, and there is not much special stuff.

In addition, regarding the implementation of NativeAOT, we first modified the NativeAotILProvider.cs class. The main function of this class is to provide IL for JIT to pre-compile and use when NativeAot is performed:

84f4e442c74b5d444533ddb77606f617.png

The key is also in the GenerateAccessor method, where the corresponding IL code is generated:

93c8a5dfcb1db0e232ccc04935bb5124.png

To summarize, the implementation principle of UnsafeAccessor still uses IL dynamic generation technology, but it is implemented within the JIT.

Why is the performance so good?

So why is its performance better than writing Emit ourselves in C# code? In fact, the reason is also obvious. There is a layer of DynamicMethod delegate calls in the middle of the Emit code we wrote, which increases the overhead, and UnsafeAccessor is directly a staticexternintGetValueUnsafe(A a); method has no intermediate overhead, and its IL Body is small and can be inlined.

Summary

Through in-depth exploration of the new UnsafeAccessor feature in .NET8, we have gained some enlightenment and understanding. First of all, the introduction of UnsafeAccessor did not come out of nowhere, but came into being. It is to meet the needs of frameworks such as System.Text.Json or EF Core when accessing private members, because they are currently mostly based on Emit Dynamic code generation is implemented, but Emit cannot be used in an AOT environment and can only rely on the less efficient reflection API. Therefore, the introduction of UnsafeAccessor provides us with a zero-overhead private member access mechanism.

In general, the introduction of UnsafeAccessor undoubtedly adds a bright spot to the development of .NET. It not only improves the execution efficiency of the code, but also provides new possibilities for our programming methods. We look forward to seeing more innovations and breakthroughs like this in future .NET versions.

-Technical Group: Add the editor's WeChat and make a note to join the group
Editor's WeChat: mm1552923 Public account: dotNet Programming Encyclopedia