【.NET8】訪問私有成員新姿勢UnsafeAccessor(下)

前言

書接上回,我們討論了在.NET8中新增的UnsafeAccessor,並且通過UnsafeAccessor訪問了私有成員,這極大的方便了我們代碼的編寫,當然也聊到了它當前存在的一些侷限性,那麼它的性能到底如何?我們今天就來實際測試一下。

測試代碼

話不多說,直接上代碼,本次測試代碼如下:

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;
using Perfolizer.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
        )));
    }
}

在測試代碼中,我們使用了BenchmarkDotNet來進行測試,測試的內容包括:

  • UnsafeAccessor:使用UnsafeAccessor特性來訪問私有成員
  • Reflection:使用反射訪問私有成員
  • Emit:使用Emit+動態方法訪問私有成員
  • ExpressionTrees:使用表達式樹+委託來訪問私有成員
  • Direct:直接訪問私有成員

測試結果如下圖所示,可以看到使用UnsafeAccessor的性能是最好的,其次是直接訪問私有成員,最差的是反射。這其實是出乎我的意料的,因爲我認爲它最多和直接訪問私有成員的性能差不多,但是實際上它的性能比直接訪問私有成員還要好,當然也有可能是統計的誤差,0.0000ns這個尺度已經非常小了。

深入探究

看到這裏我想大家都有很多疑問,實際上作者本人看到這裏也是有很多的疑問,主要是這兩個:

  • 是什麼原因讓.NET社區想加入這個API?
  • 它是如何做到訪問私有成員的?
  • 爲什麼性能會這麼好?

新增功能的原因

如果要了解這個功能背後的東西,那麼我們首先就要找到對應這個API的Issues,按照.NET社區的規範,所有的API都需要提交Issues,然後經過API Review,多輪討論設計以後,纔會開始開發。

首先我們定位到Issue是這一個,在Issue中我們可以瞭解到這個API主要是爲了給System.Text.Json或EF Core這種需要訪問私有成員的框架使用,因爲目前它們都是基於Emit動態代碼生成實現的,但是Emit不能在AOT中使用,現階段只能使用慢速的反射API,所以迫切引入了一種零開銷的私有成員訪問機制。

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

如何做到訪問私有成員?

翻閱一下整個API提案Issue的討論,我們可以找到具體實現的Issue,所以我們要了解它背後的原理的話,就需要跳轉到對應的Issue。

在這裏可以看到目前還沒有做泛型的實現,非泛型的已經在下面鏈接中實現了,一個是爲CoreCLR做的實現,另外一個是爲Mono做的實現。

image-20230919223206177

我們目前只關注CoreCLR,點開這個Issue。

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

image-20230919223551807

可以看到將這個任務拆成了幾個部分,他們都在在一個PR中完成的,其中包括定義了UnsafeAccessor特性,在JIT中的實現,以及NativeAOT中進行了支持,另外編寫了單元測試加入了有效的診斷方案。

那麼來看看這個PR裏面做了什麼吧。

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

由於PR非常的長,大家有興趣可以點進去看看,低於8GB內存的小夥伴就要小心了。簡單的來說這次修改主要就是兩塊地方,一塊是JIT相關的修改,JIT這裏主要是支持UnsafeAccessorstatic extern int聲明函數的用法,需要支持方法的IL Body爲空,然後在JIT時根據特性爲它插入代碼。

首先我們來看JIT的處理,這塊代碼主要就是修改了jitinterface.cpp,可以看到它調用了TryGenerateUnsafeAccessor方法:

image-20230919225057007

這個TryGenerateUnsafeAccessor方法實現在prestub.cpp中,這個prestub.cpp實現了一些預插樁的操作,TryGenerateUnsafeAccessor方法實現如下所示:

image-20230919225441624

它針對UnsafeAccessorKind的不同枚舉做了校驗,防止出現運行時崩潰的情況:

image-20230919225638593

然後調用了GenerateAccessor方法來生成IL:

image-20230919225739682

GenerateAccessor裏面就是使用Emit進行代碼生成:

image-20230919225932113

所以從JIT的實現來看,它其實核心原理就是Emit代碼生成,並沒有太多特殊的東西。

另外是關於NativeAOT的實現,首先修改了NativeAotILProvider.cs這個類,這個類的主要作用就是在進行NativeAot的時候提供IL給JIT預先編譯使用:

image-20230919230445386

關鍵也是在GenerateAccessor方法裏面,在這裏生成了對應的IL代碼:

image-20230919230733590

總結一下,UnsafeAccessor實現原理還是使用的IL動態生成技術,只不過它是在JIT內部實現的。

爲什麼性能這麼好?

那麼它爲什麼性能要比我們在C#代碼中自己寫Emit要更好呢?其實原因也是顯而易見的,我們自己編寫的Emit代碼中間有一層DynamicMethod的委託調用,增加了開銷,而UnsafeAccessor它直接就是一個static extern int GetValueUnsafe(A a);方法,沒有中間開銷,而且它IL Body很小,可以被內聯。

總結

通過對.NET8中新增的UnsafeAccessor特性的深入探究,我們得到了一些啓示和理解。首先,UnsafeAccessor的引入並非無中生有,而是應運而生,它是爲了滿足System.Text.Json或EF Core這類框架在訪問私有成員時的需求,因爲它們目前大多基於Emit動態代碼生成實現,但在AOT環境中無法使用Emit,只能依賴於效率較低的反射API。因此,UnsafeAccessor的引入,爲我們提供了一種零開銷的私有成員訪問機制。

總的來說,UnsafeAccessor的引入無疑爲.NET的發展增添了一抹亮色,它不僅提升了代碼的執行效率,也爲我們的編程方式提供了新的可能。我們期待在未來的.NET版本中,看到更多這樣的創新和突破。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章