Comparativa de muestras de código pequeño en C #, ¿se puede mejorar esta implementación?

Con bastante frecuencia, en SO me encuentro evaluando pequeños trozos de código para ver qué implementación es la más rápida.

Muy a menudo veo comentarios de que el código de evaluación comparativa no tiene en cuenta el jitting o el recolector de basura.

Tengo la siguiente función simple de evaluación comparativa que he desarrollado lentamente:

static void Profile(string description, int iterations, Action func) { // warm up func(); // clean up GC.Collect(); var watch = new Stopwatch(); watch.Start(); for (int i = 0; i < iterations; i++) { func(); } watch.Stop(); Console.Write(description); Console.WriteLine(" Time Elapsed {0} ms", watch.ElapsedMilliseconds); } 

Uso:

 Profile("a descriptions", how_many_iterations_to_run, () => { // ... code being profiled }); 

¿Esta implementación tiene algún defecto? ¿Es lo suficientemente bueno para mostrar que la implementación X es más rápida que la implementación Y sobre las iteraciones Z? ¿Puedes pensar en alguna forma de mejorar esto?

EDITAR Es bastante claro que se prefiere un enfoque basado en el tiempo (a diferencia de las iteraciones), ¿alguien tiene alguna implementación donde las comprobaciones de tiempo no afecten el rendimiento?

Aquí está la función modificada: según lo recomendado por la comunidad, siéntase libre de modificar esta es una wiki de la comunidad.

 static double Profile(string description, int iterations, Action func) { //Run at highest priority to minimize fluctuations caused by other processes/threads Process.GetCurrentProcess().PriorityClass = ProcessPriorityClass.High; Thread.CurrentThread.Priority = ThreadPriority.Highest; // warm up func(); var watch = new Stopwatch(); // clean up GC.Collect(); GC.WaitForPendingFinalizers(); GC.Collect(); watch.Start(); for (int i = 0; i < iterations; i++) { func(); } watch.Stop(); Console.Write(description); Console.WriteLine(" Time Elapsed {0} ms", watch.Elapsed.TotalMilliseconds); return watch.Elapsed.TotalMilliseconds; } 

Asegúrese de comstackr en Release con optimizaciones habilitadas y ejecutar las pruebas fuera de Visual Studio . Esta última parte es importante porque el JIT limita sus optimizaciones con un depurador conectado, incluso en el modo de lanzamiento.

La finalización no necesariamente se completará antes de GC.Collect regresa. La finalización se pone en cola y luego se ejecuta en un hilo separado. Este hilo aún podría estar activo durante tus pruebas, afectando los resultados.

Si desea asegurarse de que la finalización se haya completado antes de comenzar las pruebas, puede llamar a GC.WaitForPendingFinalizers , que se bloqueará hasta que se GC.WaitForPendingFinalizers la cola de finalización:

 GC.Collect(); GC.WaitForPendingFinalizers(); GC.Collect(); 

Si desea sacar las interacciones de GC de la ecuación, es posible que desee ejecutar su llamada de “calentamiento” después de la llamada GC.Collect, no antes. De esta forma, usted sabe que .NET ya tendrá suficiente memoria asignada desde el sistema operativo para el conjunto de trabajo de su función.

Tenga en cuenta que está realizando una llamada a un método no alineado para cada iteración, así que asegúrese de comparar las cosas que está probando con un cuerpo vacío. También tendrá que aceptar que solo puede controlar de manera confiable cosas que son varias veces más largas que una llamada a un método.

Además, dependiendo del tipo de material que esté perfilando, es posible que desee realizar su ejecución basada en el tiempo durante un cierto período de tiempo en lugar de hacerlo en un determinado número de iteraciones; puede tender a generar números más fácilmente comparables sin tener que tener una carrera muy corta para la mejor implementación y / o una muy larga para la peor.

Evitaría pasar al delegado en absoluto:

  1. La llamada de delegado es ~ llamada de método virtual. No es barato: ~ 25% de la asignación de memoria más pequeña en .NET. Si le interesan los detalles, consulte, por ejemplo, este enlace .
  2. Los delegates anónimos pueden llevar al uso de cierres, que ni siquiera notará. De nuevo, el acceso a los campos de cierre es notablemente mayor que, por ejemplo, el acceso a una variable en la stack.

Un código de ejemplo que conduce al uso de cierre:

 public void Test() { int someNumber = 1; Profiler.Profile("Closure access", 1000000, () => someNumber + someNumber); } 

Si no conoce los cierres, eche un vistazo a este método en .NET Reflector.

Creo que el problema más difícil de superar con métodos de evaluación comparativa como este es dar cuenta de los casos límite y lo inesperado. Por ejemplo: “¿Cómo funcionan los dos fragmentos de código con una carga de CPU / uso de red / agolpamiento de disco / etc.?”. Son excelentes para verificaciones lógicas básicas para ver si un algoritmo en particular funciona mucho más rápido que otro. Pero para probar correctamente la mayoría del rendimiento del código, debe crear una prueba que mida los cuellos de botella específicos de ese código en particular.

Todavía diría que probar pequeños bloques de código a menudo tiene poco retorno de la inversión y puede alentar el uso de código excesivamente complejo en lugar de código simple de mantenimiento. Escribir un código claro que otros desarrolladores, o yo mismo 6 meses después, podamos entender rápidamente, tendrá más beneficios de rendimiento que un código altamente optimizado.

func() a func() varias veces para el calentamiento, no solo uno.

Sugerencias para mejorar

  1. Detectando si el entorno de ejecución es bueno para la evaluación comparativa (como detectar si hay un depurador conectado o si la optimización de jit está deshabilitada, lo que daría como resultado mediciones incorrectas).

  2. Medir partes del código de forma independiente (para ver exactamente dónde está el cuello de botella).

  3. Comparando diferentes versiones / componentes / fragmentos de código (En tu primera frase dices ‘… comparando pequeños trozos de código para ver qué implementación es la más rápida’).

Respecto al # 1:

  • Para detectar si hay un depurador conectado, lea la propiedad System.Diagnostics.Debugger.IsAttached (Recuerde manejar también el caso donde el depurador no está conectado inicialmente, pero se adjunta después de un tiempo).

  • Para detectar si la optimización de jit está desactivada, lea la propiedad DebuggableAttribute.IsJITOptimizerDisabled de los ensamblajes relevantes:

     private bool IsJitOptimizerDisabled(Assembly assembly) { return assembly.GetCustomAttributes(typeof (DebuggableAttribute), false) .Select(customAttribute => (DebuggableAttribute) customAttribute) .Any(attribute => attribute.IsJITOptimizerDisabled); } 

Respecto al # 2:

Esto puede hacerse de muchas maneras. Una forma es permitir que se suministren varios delegates y luego medirlos individualmente.

En cuanto a # 3:

Esto también podría hacerse de muchas maneras, y los diferentes casos de uso exigirían soluciones muy diferentes. Si el índice de referencia se invoca manualmente, entonces escribir en la consola podría estar bien. Sin embargo, si el benchmark se lleva a cabo automáticamente por el sistema de comstackción, entonces escribir en la consola probablemente no sea tan bueno.

Una forma de hacerlo es devolver el resultado de referencia como un objeto fuertemente tipado que puede consumirse fácilmente en diferentes contextos.


Etimo.Benchmarks

Otro enfoque es usar un componente existente para realizar los puntos de referencia. De hecho, en mi empresa decidimos lanzar nuestra herramienta de referencia al dominio público. En esencia, gestiona el recolector de basura, la inestabilidad, los calentamientos, etc., tal como sugieren algunas de las otras respuestas aquí. También tiene las tres características que sugerí arriba. Gestiona varios de los temas tratados en el blog de Eric Lippert .

Este es un resultado de ejemplo donde se comparan dos componentes y los resultados se escriben en la consola. En este caso, los dos componentes comparados se llaman ‘KeyedCollection’ y ‘MultiplyIndexedKeyedCollection’:

Etimo.Benchmarks - Ejemplo de salida de consola

Hay un paquete NuGet , un paquete NuGet de muestra y el código fuente está disponible en GitHub . También hay una publicación de blog .

Si tiene prisa, le sugiero que obtenga el paquete de muestra y simplemente modifique los delegates de muestra según sea necesario. Si no tiene prisa, puede ser una buena idea leer la publicación del blog para comprender los detalles.

También debe ejecutar un pase de “calentamiento” antes de la medición real para excluir el tiempo que el comstackdor JIT gasta en el registro de su código.

Dependiendo del código que está evaluando y de la plataforma en la que se ejecuta, es posible que deba explicar cómo afecta la alineación del código al rendimiento . Para hacerlo, probablemente necesitaría un contenedor externo que ejecutara la prueba varias veces (en dominios o procesos de aplicaciones separados), algunas veces llamando primero “código de relleno” para forzarlo a ser comstackdo JIT, a fin de causar que el código sea punto de referencia para alinearse de manera diferente. Un resultado de prueba completo daría los mejores tiempos y peores momentos para las diversas alineaciones de código.

Si está tratando de eliminar el impacto de la recolección de basura de la evaluación comparativa completa, ¿vale la pena establecer GCSettings.LatencyMode ?

Si no es así, y desea que el impacto de la basura creada en el func forme parte del benchmark, ¿no debería forzar la recolección al final de la prueba (dentro del temporizador)?

El problema básico con su pregunta es la suposición de que una sola medida puede responder todas sus preguntas. Necesita medir varias veces para obtener una imagen efectiva de la situación y especialmente en un lenguaje recogido como C #.

Otra respuesta da una buena forma de medir el rendimiento básico.

 static void Profile(string description, int iterations, Action func) { // warm up func(); var watch = new Stopwatch(); // clean up GC.Collect(); GC.WaitForPendingFinalizers(); GC.Collect(); watch.Start(); for (int i = 0; i < iterations; i++) { func(); } watch.Stop(); Console.Write(description); Console.WriteLine(" Time Elapsed {0} ms", watch.Elapsed.TotalMilliseconds); } 

Sin embargo, esta única medida no representa la recolección de basura. Un perfil adecuado también representa el peor de los casos de recolección de basura diseminada en muchas llamadas (este número es inútil ya que la VM puede terminar sin recolectar basura sobrante, pero sigue siendo útil para comparar dos implementaciones diferentes de func ).

 static void ProfileGarbageMany(string description, int iterations, Action func) { // warm up func(); var watch = new Stopwatch(); // clean up GC.Collect(); GC.WaitForPendingFinalizers(); GC.Collect(); watch.Start(); for (int i = 0; i < iterations; i++) { func(); } GC.Collect(); GC.WaitForPendingFinalizers(); GC.Collect(); watch.Stop(); Console.Write(description); Console.WriteLine(" Time Elapsed {0} ms", watch.Elapsed.TotalMilliseconds); } 

Y uno también podría querer medir el peor de los casos de recolección de basura para un método que solo se llama una vez.

 static void ProfileGarbage(string description, int iterations, Action func) { // warm up func(); var watch = new Stopwatch(); // clean up GC.Collect(); GC.WaitForPendingFinalizers(); GC.Collect(); watch.Start(); for (int i = 0; i < iterations; i++) { func(); GC.Collect(); GC.WaitForPendingFinalizers(); GC.Collect(); } watch.Stop(); Console.Write(description); Console.WriteLine(" Time Elapsed {0} ms", watch.Elapsed.TotalMilliseconds); } 

Pero más importante que recomendar cualquier medición adicional posible específica al perfil es la idea de que uno debe medir múltiples estadísticas diferentes y no solo un tipo de estadística.