什么原因导致Enum.HasFlag如此缓慢?

76

我进行了一些速度测试,发现使用Enum.HasFlag比使用按位操作慢了约16倍。

有人知道Enum.HasFlag的内部机制以及为什么它如此慢吗?如果它只是慢两倍那还好,但当它慢16倍时,这个函数就无法使用了。

如果有人想知道,这是我用来测试速度的代码。

using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.Linq;

namespace app
{
    public class Program
    {
        [Flags]
        public enum Test
        {
            Flag1 = 1,
            Flag2 = 2,
            Flag3 = 4,
            Flag4 = 8
        }
        static int num = 0;
        static Random rand;
        static void Main(string[] args)
        {
            int seed = (int)DateTime.UtcNow.Ticks;

            var st1 = new SpeedTest(delegate
            {
                Test t = Test.Flag1;
                t |= (Test)rand.Next(1, 9);
                if (t.HasFlag(Test.Flag4))
                    num++;
            });

            var st2 = new SpeedTest(delegate
            {
                Test t = Test.Flag1;
                t |= (Test)rand.Next(1, 9);
                if (HasFlag(t , Test.Flag4))
                    num++;
            });

            rand = new Random(seed);
            st1.Test();
            rand = new Random(seed);
            st2.Test();

            Console.WriteLine("Random to prevent optimizing out things {0}", num);
            Console.WriteLine("HasFlag: {0}ms {1}ms {2}ms", st1.Min, st1.Average, st1.Max);
            Console.WriteLine("Bitwise: {0}ms {1}ms {2}ms", st2.Min, st2.Average, st2.Max);
            Console.ReadLine();
        }
        static bool HasFlag(Test flags, Test flag)
        {
            return (flags & flag) != 0;
        }
    }
    [DebuggerDisplay("Average = {Average}")]
    class SpeedTest
    {
        public int Iterations { get; set; }

        public int Times { get; set; }

        public List<Stopwatch> Watches { get; set; }

        public Action Function { get; set; }

        public long Min { get { return Watches.Min(s => s.ElapsedMilliseconds); } }

        public long Max { get { return Watches.Max(s => s.ElapsedMilliseconds); } }

        public double Average { get { return Watches.Average(s => s.ElapsedMilliseconds); } }

        public SpeedTest(Action func)
        {
            Times = 10;
            Iterations = 100000;
            Function = func;
            Watches = new List<Stopwatch>();
        }

        public void Test()
        {
            Watches.Clear();
            for (int i = 0; i < Times; i++)
            {
                var sw = Stopwatch.StartNew();
                for (int o = 0; o < Iterations; o++)
                {
                    Function();
                }
                sw.Stop();
                Watches.Add(sw);
            }
        }
    }
}

结果:

HasFlag: 52ms 53.6ms 55ms
Bitwise: 3ms 3ms 3ms

6
因为枚举类型可以有不同的基础类型作为支持,所以Enum.HasValue无法对该基础类型进行任何假设,它必须假设最坏的情况。这涉及到使用UInt64和装箱值。而你的HashType函数是类型安全的。 - Hans Passant
1
你可能也想看看这个:为什么枚举的HasFlag方法需要装箱 - nawfal
3
我用.NET 4.6测试了一下:HasFlag: 8ms, 8.7ms, 11msBitwise: 4ms, 4ms, 4ms。看起来它们改进了实现。 - jeyk
在.NET Fiddle中进行了基准测试,使用的是4.7.2版本。HasFlag: 8毫秒 9.4毫秒 16毫秒 位运算:5毫秒 5毫秒 5毫秒而.NET Core 2.2则更糟糕:HasFlag: 17毫秒 22.7毫秒 26毫秒 位运算:9毫秒 10.2毫秒 15毫秒 - Bil Simser
4个回答

85
有人了解 Enum.HasFlag 的内部实现,并知道它为什么很慢吗?实际检查只是在 Enum.HasFlag 中进行简单的位检查 - 这不是问题所在。尽管如此,它比自己的位检查要慢...这种减速有几个原因:首先,Enum.HasFlag 对枚举的类型和标志的类型都进行显式检查,以确保它们是相同的类型,来自同一枚举。这种检查有一定的代价。其次,在 HasFlag 内部发生了值的装箱和拆箱,将其转换为 UInt64。我认为,这是由于 Enum.HasFlag 要使用所有枚举类型,而不管其底层存储类型而导致的。尽管如此,Enum.HasFlag 有一个巨大的优点-它可靠、干净,使代码非常明显和表达。大多数情况下,我认为这使得它值得代价,但如果您在非常性能关键的循环中使用它,可能值得自己做检查。

14
可以编写一个静态泛型方法,接受两个类型相同的参数,并且如果这个类型派生自“enum”,则执行与 Enum.HasFlag 相同的测试;这样的方法可以比 Enum.HasFlag 快 30 倍。通过一些 CIL 调整,可以创建一个扩展方法,它会在 IDE 中弹出,但仅适用于派生自 System.Enum 的类型。我想知道为什么微软会费心编写 HasFlag,却没有使它的性能稍微好一点? - supercat
7
这太可怕了!我在一个创建数百万个对象并进行大量算法处理的数据集上对一个大型应用程序进行了分析,但该应用程序与枚举类型几乎无关。但是,结果显示最耗时的功能却是Enum.HasFlag!我一直以为在Release版本中,这只是一个单独的内联位测试!如果我负责这个领域,除非解决了这个问题,否则我晚上睡不着觉。 - Ken Beckett
4
我认为这部分原因在于微软过度依赖使用C#和.NET框架。由于C#不允许在枚举类型上设置泛型约束,所以你不能在C#中编写此代码而不使用装箱操作,这会导致问题的出现。 - Reed Copsey
1
Mono 4将a.HasFlag(b)转换为实际的按位AND,跳过通常在该方法中执行的所有重型反射,其中ab是完全相同的类型 - 就像他们所说的那样 - Şafak Gür
1
@ReedCopsey 提出了一个问题,为什么 C# 不能将泛型类型参数限制为“任何枚举”(而据称,C++/CLI 可以)。 - Medinoc
20
.NET Core 中的 Enum.HasFlag 现在已经被即时编译器进行了优化。 - Justin Van Patten

32

Enum.HasFlags()的反编译代码如下:

public bool HasFlag(Enum flag)
{
    if (!base.GetType().IsEquivalentTo(flag.GetType()))
    {
        throw new ArgumentException(Environment.GetResourceString("Argument_EnumTypeDoesNotMatch", new object[] { flag.GetType(), base.GetType() }));
    }
    ulong num = ToUInt64(flag.GetValue());
    return ((ToUInt64(this.GetValue()) & num) == num);
}

如果我要猜的话,我会说检查类型是最拖慢它的部分。

请注意,在最近版本的.Net Core中,这已经得到改善,Enum.HasFlag编译成与使用按位比较相同的代码。


8
我猜,尽管我需要做一些分析,但 ToUInt64(xxx.GetValue()) 可能是最糟糕的地方,因为它需要进行装箱/拆箱和 Convert.ToUInt64 的操作... - Reed Copsey
1
感谢代码片段。出于某种原因,.net反编译器无法显示 .net 4程序集的代码。 - Will
8
在 .NET 4.0 中,这个实现方式有所不同,使用了一个 extern 方法。 - BornToCode

5
本页讨论的装箱带来的性能惩罚也会影响公共的.NET函数Enum.GetValuesEnum.GetNames,它们都转发到(Runtime)Type.GetEnumValues(Runtime)Type.GetEnumNames

所有这些函数都使用(非泛型)Array作为返回类型——对于名称来说还不太糟糕(因为String是引用类型),但对于ulong[]值来说则相当不适当。

以下是有问题的代码片段(.NET 4.7):

public override Array /* RuntimeType.*/ GetEnumValues()
{
    if (!this.IsEnum)
        throw new ArgumentException();

    ulong[] values = Enum.InternalGetValues(this);
    Array array = Array.UnsafeCreateInstance(this, values.Length);
    for (int i = 0; i < values.Length; i++)
    {
        var obj = Enum.ToObject(this, values[i]);   // ew. boxing.
        array.SetValue(obj, i);                     // yuck
    }
    return array;              // Array of object references, bleh.
}

我们可以看到,在进行复制之前,RuntimeType 再次返回到 System.Enum 来获取一个内部数组,这是一个单例,为每个特定的 Enum 按需缓存。请注意,这个值数组的版本确实使用了正确的强签名 ulong[]
以下是 .NET 函数(我们现在回到了 System.Enum)。获取名称的函数也有类似的功能(未显示)。
internal static ulong[] InternalGetValues(RuntimeType enumType) => 
    GetCachedValuesAndNames(enumType, false).Values;

看到返回类型了吗?这看起来像是我们想要使用的函数...但首先需要考虑一点,即.NET每次重新复制数组的第二个原因(如上所述)是,.NET必须确保每个调用者都获得原始数据的未更改副本,因为恶意编码人员可能会更改她的Array副本,引入持久性损坏。因此,重新复制的预防措施特别旨在保护缓存的内部主副本。
如果您不担心这种风险,也许是因为您有信心不会意外更改数组,或者只是为了挤出一些(肯定过早的)优化周期,那么很容易获取任何Enum的名称或值的内部缓存数组副本:
→以下两个函数构成了本文的总体贡献←          →(但请参见下面的编辑以获取改进版本)←
static ulong[] GetEnumValues<T>() where T : struct =>
        (ulong[])typeof(System.Enum)
            .GetMethod("InternalGetValues", BindingFlags.Static | BindingFlags.NonPublic)
            .Invoke(null, new[] { typeof(T) });

static String[] GetEnumNames<T>() where T : struct =>
        (String[])typeof(System.Enum)
            .GetMethod("InternalGetNames", BindingFlags.Static | BindingFlags.NonPublic)
            .Invoke(null, new[] { typeof(T) });

请注意,对于 T 的泛型约束并不完全足以保证其为 Enum。为简单起见,我省略了除 struct 之外的任何进一步检查,但您可能希望在此基础上进行改进。同样出于简单起见,每次都是直接从 MethodInfo 反射获取,而不是尝试构建和缓存 Delegate。原因是使用非公共类型 RuntimeType 作为第一个参数创建正确的委托很繁琐。下面再做一些解释。
首先,我将以用法示例结束:
var values = GetEnumValues<DayOfWeek>();
var names = GetEnumNames<DayOfWeek>();

和调试器结果:

'values'    ulong[7]
[0] 0
[1] 1
[2] 2
[3] 3
[4] 4
[5] 5
[6] 6

'names' string[7]
[0] "Sunday"
[1] "Monday"
[2] "Tuesday"
[3] "Wednesday"
[4] "Thursday"
[5] "Friday"
[6] "Saturday"

我提到Func<RuntimeType,ulong[]>的“第一个参数”很难反射。然而,由于这个“问题”参数恰好是第一个,有一个巧妙的解决方法,您可以将每个特定的Enum类型绑定为其自己委托的Target,然后每个都缩减Func<ulong[]>

显然,制作任何这些委托都是没有意义的,因为每个委托只是一个总是返回相同值的函数...但是相同的逻辑似乎也适用于原始情况(即Func<RuntimeType,ulong[]>),尽管我们仅使用一个委托,但您永远不会真正想要多次调用它每个枚举类型。无论如何,所有这些都导致了更好的解决方案,该解决方案包含在下面的编辑中。

[编辑:]
这是同样功能的稍微更加优雅的版本。如果您将会为同一个Enum类型重复调用函数,那么这里展示的版本只会对每个Enum类型使用一次反射。它将结果保存在本地可访问的缓存中,以便随后极快速地访问。

static class enum_info_cache<T> where T : struct
{
    static _enum_info_cache()
    {
        values = (ulong[])typeof(System.Enum)
            .GetMethod("InternalGetValues", BindingFlags.Static | BindingFlags.NonPublic)
            .Invoke(null, new[] { typeof(T) });

        names = (String[])typeof(System.Enum)
            .GetMethod("InternalGetNames", BindingFlags.Static | BindingFlags.NonPublic)
            .Invoke(null, new[] { typeof(T) });
    }
    public static readonly ulong[] values;
    public static readonly String[] names;
};

这两个函数变得微不足道:

static ulong[] GetEnumValues<T>() where T : struct => enum_info_cache<T>.values;
static String[] GetEnumNames<T>() where T : struct => enum_info_cache<T>.names;

这里展示的代码演示了一种将三个特定技巧相结合的模式,似乎互相产生了一种不寻常优雅的惰性缓存方案。我发现这种特殊技术具有出乎意料的广泛应用。
  1. 使用一个通用静态类来缓存每个不同枚举的独立数组副本。值得注意的是,这会自动并按需发生;
  2. 与此相关的是,loader lock保证了唯一原子初始化,而没有附带条件检查结构的混乱。我们还可以使用readonly来保护静态字段(通常不能与其他懒惰/延迟/需求方法一起使用,因为它们很明显有特殊原因);
  3. 最后,我们可以利用C# type inference自动将通用函数(入口点)映射到其相应的通用静态类中,从而使需求缓存最终甚至被隐含地驱动(viz.,最好的代码是不存在的代码--因为它永远不可能有错误)
你可能已经注意到,这里展示的例子并没有很好地说明第(3)点。与其依赖类型推断,void函数必须手动向前传递类型参数T。我选择不公开这些简单的函数,以便有机会展示C#类型推断如何使整个技术更加出色...
然而,你可以想象当你结合一个静态泛型函数,它可以推断其类型参数时——也就是说,你甚至不需要在调用时提供它们——那么它变得非常强大。
关键见解是,虽然泛型函数具有完全的类型推断能力,但泛型则没有,也就是说,如果你尝试调用以下第一行,编译器永远不会推断T。但我们仍然可以通过通​​过泛型函数隐式类型化进入它们来完全推断访问泛型类,并获得所有相关的好处(最后一行)。
int t = 4;
typed_cache<int>.MyTypedCachedFunc(t);  // no inference from 't', explicit type required

MyTypedCacheFunc<int>(t);               // ok, (but redundant)

MyTypedCacheFunc(t);                    // ok, full inference

设计良好的推断类型可以轻松地将您引入适当的自动需求缓存数据和行为,针对每种类型进行定制(请回想第1点和第2点)。正如所述,我发现这种方法非常有用,特别是考虑到它的简单性。


我觉得在试图理解所有这些内容时,我的大脑已经崩溃了...并且由于不得不使用字典,学到了一些新的英语单词。呵呵!但基本上枚举的东西可以归结为:当速度至关重要时,不要使用HasFlag(或任何类似的东西)- 转而使用位运算? - Scre

3
JIT编译器应该将其作为简单的位运算内联。 JIT编译器足够智能,甚至可以通过方法ImplOptions.InternalCall来自定义处理某些框架方法,但似乎HasFlag方法并未引起微软的严重关注。

1
函数的编写方式使得JITter无法对其进行优化。如果Enum1Enum2是枚举类型,则需要使用代码Enum1.HasFlag(Enum2)来创建新的堆对象实例,这些实例保存了Enum1Enum2所持有的值,然后将这些对象传递给一些过于庞大而无法分析的程序。JITter确实别无选择,只能生成创建这些堆对象的代码,而这些对象的创建完全拖慢了性能。 - supercat
如果JITter知道Enum1和Enum2具有相同的基础类型,它可以放弃框定指令并内联该特定调用。没有理由每次都执行装箱操作。 - Joshua A. Schaeffer
2
没有泛型的 HasFlag 方法,就无法避免装箱。问题在于,如果 Enum1Enum2 是相同的具体枚举类型,但 Enum3System.Enum,那么 Enum1.HasFlag(Enum2) 必须调用与 Enum1.HasFlag(Enum3) 相同的 JITted 代码。如果非泛型方法重载可以接受堆引用到装箱值类型,则该重载除了堆引用之外,无法接受任何其他内容,也没有任何方式可以将值类型隐式转换为兼容类型的堆引用,除了通过装箱。 - supercat
如果JITter不知道,那么编译器应该知道。其中一个具有优化Enum1.HasFlag(Enum2)特殊情况所需的信息。当实际使用装箱时,可以单独构建装箱操作。 - Joshua A. Schaeffer
我希望不仅有一个 System.Enum,而是有一组 System.Int32EnumSystem.Int64Enum 等等,每个枚举类型都有一个合适的 Value 成员,或者类型系统允许从值类型派生类型,只要派生类型没有添加任何新字段(在这种情况下,enum 类型可以从 Int32Int64 等派生)。然而,现在对于 enum1.HasFlag(enum2) 生成的代码需要根据所涉及的类型进行变化,而没有办法做到这一点,除非... - supercat
明确编码每个“枚举”类型的方法,或者通过委托分派。就我个人而言,我最希望的是&运算符的结果是一个编译器临时类型,如果需要,可以隐式转换为布尔类型或适合更小的无符号操作数的整数类型(在我的观点中,在语句if ((someEnum&EnumModes.FancyMode)!=0)doSomething();中,需要!= 0这一部分使代码不如没有它那么清晰)。 - supercat

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接