¿Qué es lo que hace que Enum.HasFlag sea tan lento?

Estaba haciendo algunas pruebas de velocidad y noté que Enum.HasFlag es aproximadamente 16 veces más lento que con la operación bit a bit.

¿Alguien conoce las partes internas de Enum.HasFlag y por qué es tan lento? Quiero decir que el doble de lento no sería tan malo, pero hace que la función no se pueda usar cuando es 16 veces más lenta.

En caso de que alguien se esté preguntando, aquí está el código que estoy usando para probar su velocidad.

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 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(); } 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); } } } } 

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

¿Alguien conoce las partes internas de Enum.HasFlag y por qué es tan lento?

El cheque real es solo un simple control de bit en Enum.HasFlag – aquí no es el problema. Dicho esto, es más lento que tu propio cheque de bits …

Hay un par de razones para esta desaceleración:

En primer lugar, Enum.HasFlag hace una comprobación explícita para asegurarse de que el tipo de la enumeración y el tipo de la bandera son del mismo tipo y del mismo Enum. Hay algún costo en este cheque.

En segundo lugar, hay una desafortunada caja y unbox del valor durante una conversión a UInt64 que ocurre dentro de HasFlag . Esto es, creo, debido al requisito de que Enum.HasFlag funcione con todas las enumeraciones, independientemente del tipo de almacenamiento subyacente.

Una vez dicho esto, Enum.HasFlag tiene una gran ventaja: es confiable, limpio y hace que el código sea muy obvio y expresivo. En su mayor parte, creo que esto hace que valga la pena el costo, pero si está utilizando esto en un ciclo de rendimiento muy crítico, puede valer la pena hacer su propio control.

El código Enum.HasFlags() de Enum.HasFlags() ve así:

 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); } 

Si tuviera que adivinar, diría que verificar el tipo fue lo que más ralentizó.

El JITter debería estar resumiendo esto como una simple operación bit a bit. JITter es lo suficientemente consciente como para manejar de forma personalizada incluso ciertos métodos de framework (a través de MethodImplOptions.InternalCall, ¿no?), Pero HasFlag parece haber escapado a la seria atención de Microsoft.

La penalización de rendimiento debida al boxeo analizada en esta página también afecta a las funciones .NET públicas Enum.GetValues y Enum.GetNames , que reenvían a (Runtime)Type.GetEnumValues y (Runtime)Type.GetEnumNames respectivamente.

Todas estas funciones usan una Array (no genérica) como tipo de devolución, lo cual no es tan malo para los nombres (ya que String es un tipo de referencia), pero es bastante inapropiada para los valores ulong[] .

Aquí hay un vistazo al código ofensivo (.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. } 

Podemos ver que antes de hacer la copia, RuntimeType vuelve a System.Enum para obtener una matriz interna, una singleton que se almacena en caché, a petición, para cada Enum específico. Observe también que esta versión de la matriz de valores usa la firma fuerte apropiada, ulong[] .

Aquí está la función .NET (nuevamente estamos de nuevo en System.Enum ahora). Hay una función similar para obtener los nombres (no se muestra).

 internal static ulong[] InternalGetValues(RuntimeType enumType) => GetCachedValuesAndNames(enumType, false).Values; 

Ver el tipo de devolución? Esto parece una función que nos gustaría usar ... Pero primero considere que una segunda razón por la que .NET vuelve a copiar la matriz cada vez (como vimos anteriormente) es que .NET debe asegurarse de que cada persona que llama obtenga una copia inalterada de los datos originales, dado que un codificador malévolo podría cambiar su copia de la Array devuelta, introduciendo una corrupción persistente. Por lo tanto, la precaución de volver a copiar está especialmente destinada a proteger la copia maestra interna en caché.

Si no está preocupado por ese riesgo, tal vez porque confía en que no cambiará accidentalmente la matriz, o tal vez solo para completar algunos ciclos de optimización (lo que es seguro prematuro), es sencillo buscar la matriz en caché interna. copia de los nombres o valores para cualquier Enum :

→ Las siguientes dos funciones comprenden la contribución de la sum de este artículo ←
→ (pero vea la edición debajo para la versión mejorada) ←

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

Tenga en cuenta que la restricción genérica en T no es completamente suficiente para garantizar Enum . Para simplificar, dejé de revisar más allá de struct , pero es posible que desee mejorar en eso. También para simplificar, esto (ref-fetches and) se refleja directamente en MethodInfo todo el tiempo en lugar de intentar crear y almacenar en caché un Delegate . La razón para esto es que crear el delegado adecuado con un primer argumento de tipo no público RuntimeType es tedioso. Un poco más sobre esto a continuación.

Primero, concluiré con ejemplos de uso:

 var values = GetEnumValues(); var names = GetEnumNames(); 

y resultados del depurador:

 '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" 

Así que mencioné que el "primer argumento" de Func es molesto para reflexionar. Sin embargo, debido a que este arg "problema" es el primero, hay una bonita solución donde puede vincular cada tipo de Enum específico como un Target de su propio delegado, donde cada uno se reduce a Func ).

Claramente, es inútil hacer cualquiera de esos delegates, ya que cada uno sería una función que siempre devolvería el mismo valor ... pero la misma lógica parece aplicarse, quizás menos obviamente, a la situación original (es decir, Func ). Aunque lo hacemos con un solo delegado aquí, nunca querrá llamarlo más de una vez por tipo Enum . De todos modos, todo esto conduce a una solución mucho mejor, que se incluye en la edición a continuación.


[editar:]
Aquí hay una versión un poco más elegante de lo mismo. Si va a llamar a las funciones repetidamente para el mismo tipo de Enum , la versión que se muestra aquí solo usará la reflexión una vez por tipo de Enum. Guarda los resultados en un caché accesible localmente para un acceso extremadamente rápido posteriormente.

 static class enum_info_cache 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; }; 

Las dos funciones se vuelven triviales:

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

El código que se muestra aquí ilustra un patrón de combinación de tres trucos específicos que parecen resultar mutuamente en un esquema de almacenamiento en caché lento inusual y elegante. He encontrado que la técnica particular tiene una aplicación sorprendentemente amplia.

  1. utilizando una clase estática genérica para almacenar en caché copias independientes de las matrices para cada Enum distinto. Notablemente, esto sucede automáticamente y bajo demanda;

  2. relacionado con esto, el locking del cargador garantiza la inicialización atómica única y lo hace sin el desorden de las construcciones de comprobación condicional. También podemos proteger campos estáticos con readonly (que, por razones obvias, por lo general no se puede usar con otros métodos lazy / deferred / demand);

  3. finalmente, podemos aprovechar la inferencia de tipo C # para mapear automáticamente la función genérica (punto de entrada) en su clase estática genérica respectiva, de modo que el almacenamiento en caché de la demanda en última instancia, incluso se maneja implícitamente ( es decir , el mejor código es el código que no es allí, ya que nunca puede tener errores)

Probablemente haya notado que el ejemplo particular que se muestra aquí realmente no ilustra muy bien el punto (3). En lugar de confiar en la inferencia de tipo, la función de captura de void debe propagar manualmente el argumento de tipo T No opté por exponer estas funciones simples de modo que hubiera una oportunidad de mostrar cómo la inferencia tipo C # hace que la técnica en general brille ...

Sin embargo, puede imaginarse que cuando combina una función genérica estática que puede inferir su tipo argumento (s), es decir, para que ni siquiera tenga que proporcionarlos en el sitio de la llamada, entonces se vuelve bastante poderoso.

La idea clave es que, aunque las funciones genéricas tienen la capacidad de inferencia de tipos completa, las clases genéricas no lo hacen, es decir, el comstackdor nunca inferirá T si intenta llamar a la primera de las siguientes líneas. Pero aún podemos obtener acceso completamente inferido a una clase genérica, y todos los beneficios que conlleva, al atravesarlos a través de la función genérica de tipeo implícito (última línea):

 int t = 4; typed_cache.MyTypedCachedFunc(t); // no inference from 't', explicit type required MyTypedCacheFunc(t); // ok, (but redundant) MyTypedCacheFunc(t); // ok, full inference 

Diseñado correctamente, la tipificación inferida puede lanzarlo sin esfuerzo a los datos y comportamientos apropiados en caché exigidos automáticamente, personalizados para cada tipo (puntos de recuperación 1.y 2). Como noté, encuentro el acercamiento útil, especialmente considerando su simplicidad.