动态方法和 CLR 的一些乐趣(第一部分)
使用动态方法和动态程序集来规避语言障碍并优化性能。
引言
在本文中,将演示动态方法和动态程序集如何规避 C# 编译器的限制。此外,还将进行基准测试,以评估动态实现与硬编码实现的性能。
*注意:本文已于一年前发布,我为其重命名以便为第二部分做准备。
背景
故事从以下代码开始。
if ((member.MemberType & System.Refleciton.MemberTypes.Method)
== System.Refleciton.MemberTypes.Method) {
Console.WriteLine ("The member is a method.");
}
我们非常习惯于对带有 FlagsAttribute
标记的 Enum
类型使用按位运算,就像上面的代码一样,它检查 MemberInfo
实例 member.MemberType
的 MemberType
属性是否设置了 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 ns。IConvertible
解决方案比基线慢约 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
将用于对value
和flags
参数执行直接的按位计算。
由于类型比较和转换的缺失,基准测试结果得到了极大的改善,显示动态方法仅运行了大约 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,这与直接计算几乎相同。
结论
通过使用保存的动态程序集,发生了好事:
- 运行时可重编程性。
- 更好的性能(避免重复的类型比较、反射等),有时性能会优于手动编写的 C# 程序。
- 更大的灵活性,可以利用 .NET 运行时中隐藏的功能。创建普通 C# 代码和编译器无法编译的内容。
动态方法和动态程序集的缺点是:
- 通常更难编程。编译器无法帮助检查动态代码的正确性或指出代码出错的位置。
- 需要对 IL 有相当的了解。
- 调试难度大得多,因为在大多数情况下,您将没有动态生成的方法或类型的源代码。
- 代码安全性可能会受到损害。
- 有时程序会被某些病毒杀毒软件删除,仅仅因为其生成 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 |
有趣的是,解决方案 4(MatchWithSavedDynamicAssembly
),它引用了从动态程序集生成的程序集,其性能优于直接计算代码(最后一个,基线)。上述结果并非偶然。每次我运行基准测试时,解决方案 4 总是以微弱优势胜过基线。
以下是我对此现象的假设。
- 在生成的动态程序集中,
MatchFlags
方法的代码大小仅为 7 字节。JIT 编译器可能会将其极小的代码内联到MatchWithSavedDynamicAssembly
方法中。 - 使用
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
的开销,它的速度无法比动态方法快多少。
历史
- 2017 年 12 月 1 日:初次发布,标题为《使用动态程序集提升性能》。
- 2018 年 9 月 15 日:重命名为《动态方法与 CLR 的趣味探索(第一部分)》。