65.9K
CodeProject 正在变化。 阅读更多。
Home

动态方法和 CLR 的一些乐趣(第一部分)

starIconstarIconstarIconstarIconstarIcon

5.00/5 (4投票s)

2017 年 12 月 1 日

CPOL

7分钟阅读

viewsIcon

22661

downloadIcon

222

使用动态方法和动态程序集来规避语言障碍并优化性能。

引言

在本文中,将演示动态方法和动态程序集如何规避 C# 编译器的限制。此外,还将进行基准测试,以评估动态实现与硬编码实现的性能。

*注意:本文已于一年前发布,我为其重命名以便为第二部分做准备。

背景

故事从以下代码开始。

if ((member.MemberType & System.Refleciton.MemberTypes.Method)
      == System.Refleciton.MemberTypes.Method) {
   Console.WriteLine ("The member is a method.");
}

我们非常习惯于对带有 FlagsAttribute 标记的 Enum 类型使用按位运算,就像上面的代码一样,它检查 MemberInfo 实例 member.MemberTypeMemberType 属性是否设置了 MemberTypes.Method 位。

我们大多数人可能都尝试过编写一个通用方法来简化上述代码。乍一看,该方法可能看起来像这样,但它永远无法编译。编译器会说 Enum 不能作为约束类型

public bool HasFlag<TEnum>(this TEnum value, TEnum flag)
  where TEnum : struct, Enum
{
  return (value & flag) == flag;
}
注意

从 C# 7.3 开始,编译器允许 Enum 作为类型约束。但是,仍然不可能对泛型 enum 类型执行算术运算。

解决方案

StackOverflow.com 上已经有类似的 问题和答案,提供了一些变通方法:使用 IL Support 编码,使用 F# 等。我喜欢 IL support 方法。但是,当我将其应用到我拥有几十个代码文件的项目时,Visual Studio 会不断冻结,除非撤销更改。F# 方法目前不是我的选择,因为它会要求我们在 Visual Studio 中安装 F# 支持,并且会使编译后的程序集不必要地依赖于 FSharp.Core,而这是可以避免的。

还有其他解决方案吗?它们的性能如何?我尝试了以下解决方案并进行了基准测试。

解决方案 1:IConvertible

以下代码片段是几年前编写的。由于 Enum 值实现了 IConvertible 接口,并且它们实际上是整数,因此可以将它们转换为 UInt64(可能的最大值类型)并对其执行按位运算。此外,此方法还可以应用于其他整数类型。

static bool MatchFlags<TEnum>(TEnum value, TEnum flags)
  where TEnum : struct, IConvertible {
    var v = flags.ToUInt64(CultureInfo.InvariantCulture);
    return (value.ToUInt64(CultureInfo.InvariantCulture) & v) == v;
}

性能如何?很差。

基准测试使用 BenchmarkDotNet 进行。为了防止 Enum 实例被优化掉,我设置了一个类和一个静态变量 _C 来保存一个 Enum 类型。

static readonly C _C = new C();

class C
{
    public MemberTypes M { get; set; }
}

然后,我使用手动编写的计算代码作为基线。

[Benchmark(Baseline = true)]
public void DirectCalculation() {
    _C.M = MemberTypes.Event | MemberTypes.Method;
    var b = (_C.M & MemberTypes.Method) == MemberTypes.Method;
    if (b == false) {
        throw new InvalidOperationException();
    }
}

以下代码使用上面的 MatchFlags 方法进行计算。

[Benchmark]
public void MatchWithConvert() {
    _C.M = MemberTypes.Event | MemberTypes.Method;
    var b = MatchFlags(_C.M, MemberTypes.Method);
    if (b == false) {
        throw new InvalidOperationException();
    }
}

基准测试结果表明,MatchFlags 方法大约需要 102 ns 完成计算,而硬编码的按位与运算仅需不到 0.4 nsIConvertible 解决方案比基线慢约 250 倍

性能不佳的原因是,在 ToUInt64 类型转换内部发生了许多类型比较,这严重减慢了代码的速度。

解决方案 2:Enum.HasFlag

Enum.HasFlag 方法随 .NET 4.0 一起提供,这意味着它在早期版本的 .NET Framework 中不可用。

代码基准测试与上面非常相似。

[Benchmark]
public void MatchWithHasFlag() {
    _C.M = MemberTypes.Event | MemberTypes.Method;
    var b = _C.M.HasFlag(MemberTypes.Method);
    if (b == false) {
        throw new InvalidOperationException();
    }
}

性能基准测试表明,该方法大约需要 28 ns 完成,仍然比硬编码计算慢约 70 倍。结果仍然不太理想。

解决方案 3:动态方法

动态方法方法需要对 IL 和执行堆栈有相当的了解,并且需要更多的编码。

动态方法可以通过 DynamicMethod 生成并在运行时编译。编译可能需要相当长的时间,并且编译后的代码将驻留在内存中。因此,编译只能执行一次,并且应缓存编译后的方法以避免内存泄漏。

我通常使用静态类来初始化编译并存储结果。CLR 将自动确保即使程序在多线程环境中运行,它也最多只发生两次。

这是我编写的用于生成和缓存动态方法的类。

using System.Reflection;
using System.Reflection.Emit;

static class EnumManipulator<TEnum> where TEnum : struct, IComparable, IConvertible
{
    internal static readonly bool IsEnum = typeof(TEnum).IsEnum;
    internal static readonly Func<TEnum, TEnum, bool> MatchFlags = CreateMatchFlagsMethod();

    private static Func<TEnum, TEnum, bool> CreateMatchFlagsMethod() {
        var et = typeof(TEnum).GetEnumUnderlyingType();
        var isLong = et == typeof(long) || et == typeof(ulong);
        var m = new DynamicMethod(typeof(TEnum).Name + "MatchFlags", typeof(bool),
                    new[] { typeof(TEnum), typeof(TEnum) }, true);
        var il = m.GetILGenerator();
        il.Emit(OpCodes.Ldarg_1);
        il.Emit(OpCodes.Dup);
        il.Emit(OpCodes.Ldarg_0);
        il.Emit(OpCodes.And);
        il.Emit(OpCodes.Ceq);
        il.Emit(OpCodes.Ret);
        return (Func<TEnum, TEnum, bool>)m.CreateDelegate(typeof(Func<TEnum, TEnum, bool>));
    }
}

该类包含两个 internal 字段,IsEnum 将检查类型 TEnum 是否为 Enum 类型,而 MatchFlags 是从动态方法创建的 Delegate,可用于进行计算。

基准测试中使用的代码如下所示。

[Benchmark]
public void MatchWithDynamicMethod() {
    _C.M = MemberTypes.Event | MemberTypes.Method;
    var b = EnumManipulator<TEnum>.MatchFlags(_C.M, MemberTypes.Method);
    if (b == false) {
        throw new InvalidOperationException();
    }
}
注意

在生产代码中,您应该使用 EnumManipulator<TEnum>.IsEnum 字段来检查传递给泛型类的类型是否为 Enum 类型。

由于泛型静态类 EnumManipulator<TEnum> 仅为每个 TEnum 类型初始化一次,因此检查 TEnum 是否为 Enum 类型将只发生一次,并且结果将缓存到 IsEnum 字段供后续使用,而预先生成的 MatchFlags 将用于对 valueflags 参数执行直接的按位计算。

由于类型比较和转换的缺失,基准测试结果得到了极大的改善,显示动态方法仅运行了大约 2.7 ns,尽管由于委托调用和安全验证的开销,它仍然比直接计算慢 7 倍,但它比 Enum.HasFlag 快了大约 10 倍。

解决方案 4:保存的动态程序集

为了消除动态方法的开销并进一步提高性能,我尝试创建一个动态程序集,将其保存到磁盘,并让基准测试项目使用它。

下面的 EnumAsm 类用于创建这样一个动态程序集。要将程序集保存到磁盘,请创建另一个项目,或使用 Visual Studio 的C# 交互功能,并让它调用 SaveAssembly 方法(注意:内存中的动态程序集只能保存一次,请勿调用此方法两次,否则将抛出 Exception)。

static class EnumAsm
{
    // define a dynamic assembly
    static readonly AssemblyBuilder __Assembly = AssemblyBuilder.DefineDynamicAssembly(
        new AssemblyName { Name = nameof(EnumAsm) }, AssemblyBuilderAccess.RunAndSave);
    // add a module in the assembly
    static readonly ModuleBuilder __Module = __Assembly.DefineDynamicModule(nameof(EnumAsm) + ".dll");
    // define the type which holds the methods
    static readonly TypeBuilder __Manipulator = DefineType();
    // define the method in the type
    static readonly MethodBase __MatchFlags = DefineMatchFlagsMethod(false);
    // create the type for run-time use
    internal static readonly Type Type = __Manipulator.CreateType();

    /// <summary>Saves the assembly to disk.</summary>
    internal static void SaveAssembly() {
        __Assembly.Save(nameof(EnumAsm) + ".dll");
    }

    private static TypeBuilder DefineType() {
        // define a static type
        var t = __Module.DefineType("DynamicAssembly." + nameof(EnumAsm),
            TypeAttributes.Public | TypeAttributes.Sealed
            | TypeAttributes.Class | TypeAttributes.Abstract);
        // mark the type, allow it to have extension methods
        t.SetCustomAttribute(new CustomAttributeBuilder(
            typeof(System.Runtime.CompilerServices.ExtensionAttribute).GetConstructor(Type.EmptyTypes),
            new object[0]));
        return t;
    }

    static MethodBuilder DefineMatchFlagsMethod(bool isLong) {
        var m = __Manipulator.DefineMethod("MatchFlags",
            MethodAttributes.Static | MethodAttributes.Public | MethodAttributes.HideBySig);
        // define a generic type argument for the method
        var gps = m.DefineGenericParameters("TEnum");
        // constraint the generic type to be: struct, Enum
        foreach (var item in gps) {
            item.SetGenericParameterAttributes(GenericParameterAttributes.NotNullableValueTypeConstraint);
            item.SetBaseTypeConstraint(typeof(Enum));
        }
        // set argument type of the method to (TEnum, TEnum)
        m.SetParameters(gps[0], gps[0]);
        m.SetReturnType(typeof(bool));
        // name the parameter of the method to (TEnum value, TEnum flags)
        m.DefineParameter(1, ParameterAttributes.HasDefault, "value");
        m.DefineParameter(2, ParameterAttributes.HasDefault, "flags");
        // mark the method to be an extension method
        m.SetCustomAttribute(new CustomAttributeBuilder(
            typeof(System.Runtime.CompilerServices.ExtensionAttribute).GetConstructor(Type.EmptyTypes),
            new object[0]));
        var il = m.GetILGenerator();
        il.Emit(OpCodes.Ldarg_1);
        il.Emit(OpCodes.Dup);
        il.Emit(OpCodes.Ldarg_0);
        il.Emit(OpCodes.And);
        il.Emit(OpCodes.Ceq);
        il.Emit(OpCodes.Ret);
        return m;
    }
}

上面的代码创建了一个名为 MatchFlags 的泛型方法的类型。该方法中的泛型类型参数 TEnum 将受 Enum 约束,这是 C# 编译器不可能实现的结果。相应的 C# 代码可能看起来像这样。

namespace DynamicAssembly
{
    public static class EnumAsm
    {
        public static bool MatchFlags<TEnum>(this TEnum value, TEnum flags)
            where TEnum : struct, Enum
        {
            return flags == (flags & value);
        }
    }
}

我在基准测试中使用了以下代码。

[Benchmark]
public void MatchWithSavedDynamicAssembly() {
    _C.M = MemberTypes.Event | MemberTypes.Method;
    var b = DynamicAssembly.EnumAsm.MatchFlags(_C.M, MemberTypes.Method);
    if (b == false) {
        throw new InvalidOperationException();
    }
}

在消除了动态方法的开销后,此解决方案的基准测试结果显示执行时间为 0.4 ns,这与直接计算几乎相同。

结论

通过使用保存的动态程序集,发生了好事:

  1. 运行时可重编程性。
  2. 更好的性能(避免重复的类型比较、反射等),有时性能会优于手动编写的 C# 程序。
  3. 更大的灵活性,可以利用 .NET 运行时中隐藏的功能。创建普通 C# 代码和编译器无法编译的内容。

动态方法和动态程序集的缺点是:

  1. 通常更难编程。编译器无法帮助检查动态代码的正确性或指出代码出错的位置。
  2. 需要对 IL 有相当的了解。
  3. 调试难度大得多,因为在大多数情况下,您将没有动态生成的方法或类型的源代码。
  4. 代码安全性可能会受到损害。
  5. 有时程序会被某些病毒杀毒软件删除,仅仅因为其生成 IL 程序集的能力。这个问题通常适用于动态程序集。

关注点

谁是赢家?

以下结果是我在我的机器上进行的所有基准测试中,标准差最小的结果。

BenchmarkDotNet=v0.10.10, OS=Windows 10 Redstone 1 [1607, Anniversary Update] (10.0.14393.1884)
Processor=Intel Core i5-5200U CPU 2.20GHz (Broadwell), ProcessorCount=4
Frequency=2143478 Hz, Resolution=466.5315 ns, Timer=TSC
  [Host]     : .NET Framework 4.6.1 (CLR 4.0.30319.42000), 64bit RyuJIT-v4.7.2117.0
  DefaultJob : .NET Framework 4.6.1 (CLR 4.0.30319.42000), 64bit RyuJIT-v4.7.2117.0

                        Method |        Mean |     Error |    StdDev | Scaled | ScaledSD |
------------------------------ |------------:|----------:|----------:|-------:|---------:|
              MatchWithConvert | 102.6391 ns | 0.5923 ns | 0.4946 ns | 277.76 |    34.34 |
              MatchWithHasFlag |  28.2697 ns | 0.6257 ns | 0.5547 ns |  76.50 |     9.56 |
        MatchWithDynamicMethod |   2.7176 ns | 0.0185 ns | 0.0144 ns |   7.35 |     0.91 |
 MatchWithSavedDynamicAssembly |   0.3310 ns | 0.0122 ns | 0.0102 ns |   0.90 |     0.11 |
             DirectCalculation |   0.3756 ns | 0.0551 ns | 0.0515 ns |   1.00 |     0.00 |

有趣的是,解决方案 4MatchWithSavedDynamicAssembly),它引用了从动态程序集生成的程序集,其性能优于直接计算代码(最后一个,基线)。上述结果并非偶然。每次我运行基准测试时,解决方案 4 总是以微弱优势胜过基线

以下是我对此现象的假设。

  1. 在生成的动态程序集中,MatchFlags 方法的代码大小仅为 7 字节。JIT 编译器可能会将其极小的代码内联到 MatchWithSavedDynamicAssembly 方法中。
  2. 使用 ILGenerator 生成的 MatchFlags 方法中的 MemberTypes.Method 仅由 OpCodes.ldarg_1 加载一次,并在计算堆栈上使用 OpCodes.dup 进行复制。而在直接计算代码中,值加载了两次(一次用于 & 操作,另一次用于 == 操作)。这种手动优化可能节省了少量时间。

内存中的动态程序集呢?

您可能想知道,如果解决方案 4 中的内存动态程序集不保存到磁盘,而是通过方法委托调用,性能会如何,如下面的代码所示。您可以自己尝试一下。

[Benchmark]
public void MatchWithDynamicAssembly() {
    _C.M = MemberTypes.Event | MemberTypes.Method;
    var b = EnumManipulatorCache<MemberTypes>.MatchFlags(_C.M, MemberTypes.Method);
    if (b == false) {
        throw new InvalidOperationException();
    }
}

static class EnumManipulatorCache<TEnum> where TEnum : struct, IComparable, IConvertible
{
    internal static readonly Func<TEnum, TEnum, bool> MatchFlags =
        (Func<TEnum, TEnum, bool>)Delegate.CreateDelegate(
            typeof(Func<TEnum, TEnum, bool>),
            EnumAsm.Type.GetMethod("MatchFlags").MakeGenericMethod(typeof(TEnum))
            );
}

由于 Delegate 的开销,它的速度无法比动态方法快多少。

历史

  1. 2017 年 12 月 1 日:初次发布,标题为《使用动态程序集提升性能》。
  2. 2018 年 9 月 15 日:重命名为《动态方法与 CLR 的趣味探索(第一部分)》。

 

© . All rights reserved.