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

104

Muy a menudo en SO me encuentro comparando pequeños fragmentos de código para ver qué implementación es 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 de evaluación comparativa simple que he evolucionado 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
});

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

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

Sam Saffron
fuente
Consulte también BenchmarkDotNet .
Ben Hutchison

Respuestas:

95

Aquí está la función modificada: según lo recomendado por la comunidad, siéntase libre de modificar esto, 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 compilar en Release con las optimizaciones habilitadas y ejecute las pruebas fuera de Visual Studio . Esta última parte es importante porque el JIT limita sus optimizaciones con un depurador adjunto, incluso en el modo de lanzamiento.

Sam Saffron
fuente
Es posible que desee desenrollar el bucle varias veces, como 10, para minimizar la sobrecarga del bucle.
Mike Dunlavey
2
Acabo de actualizar para usar Stopwatch.StartNew. No es un cambio funcional, pero guarda una línea de código.
LukeH
1
@ Luke, gran cambio (me gustaría poder hacer +1). @ Mike no estoy seguro, sospecho que la sobrecarga de la llamada virtual será mucho mayor que la comparación y la asignación, por lo que la diferencia de rendimiento será insignificante
Sam Saffron
Le propongo que pase el recuento de iteraciones a la Acción y cree el bucle allí (posiblemente, incluso desenrollado). En caso de que esté midiendo una operación relativamente corta, esta es la única opción. Y prefiero ver una métrica inversa, por ejemplo, recuento de pases / seg.
Alex Yakunin
2
¿Qué opinas sobre mostrar el tiempo medio? Algo como esto: Console.WriteLine ("Tiempo promedio transcurrido {0} ms", watch.ElapsedMilliseconds / iterations);
rudimenter
22

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

Si desea asegurarse de que la finalización se haya completado antes de comenzar sus pruebas, es posible que desee llamar GC.WaitForPendingFinalizers, que se bloqueará hasta que se borre la cola de finalización:

GC.Collect();
GC.WaitForPendingFinalizers();
GC.Collect();
LukeH
fuente
10
¿Por qué GC.Collect()una vez más?
colinfang
7
@colinfang Porque los objetos que se "finalizan" no son GC'ed por el finalizador. Entonces, el segundo Collectestá ahí para asegurarse de que los objetos "finalizados" también se recopilen.
MAV
15

Si desea eliminar las interacciones GC de la ecuación, es posible que desee ejecutar su llamada de 'calentamiento' después de la llamada GC.Collect, no antes. De esa manera, sabrá que .NET ya tendrá suficiente memoria asignada del sistema operativo para el conjunto de trabajo de su función.

Tenga en cuenta que está realizando una llamada de método no en línea 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 cronometrar de manera confiable las cosas que son varias veces más largas que una llamada a un método.

Además, dependiendo del tipo de cosas que esté perfilando, es posible que desee ejecutar su ejecución basada en el tiempo durante una cierta cantidad de tiempo en lugar de una cierta cantidad de iteraciones; puede tender a llevar a números más fácilmente comparables sin tener que tener una ejecución muy corta para la mejor implementación y / o una muy larga para la peor.

Jonathan Rupp
fuente
1
buenos puntos, ¿tendría en mente una implementación basada en el tiempo?
Sam Saffron
6

Evitaría pasar al delegado en absoluto:

  1. La llamada de delegado es una llamada a un método virtual. No es barato: ~ 25% de la asignación de memoria más pequeña en .NET. Si está interesado en los detalles, consulte, por ejemplo, este enlace .
  2. Los delegados anónimos pueden llevar al uso de cierres, que ni siquiera notará. Nuevamente, acceder a los campos de cierre es más notable que, por ejemplo, acceder a una variable en la pila.

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

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

Si no está al tanto de los cierres, eche un vistazo a este método en .NET Reflector.

Alex Yakunin
fuente
Puntos interesantes, pero ¿cómo crearía un método Profile () reutilizable si no aprueba un delegado? ¿Hay otras formas de pasar código arbitrario a un método?
Ash
1
Usamos "usando (nueva medición (...)) {... código medido ...}". Entonces obtenemos el objeto de medición que implementa IDisposable en lugar de pasar el delegado. Ver code.google.com/p/dataobjectsdotnet/source/browse/Xtensive.Core/…
Alex Yakunin
Esto no dará lugar a problemas con los cierres.
Alex Yakunin
3
@AlexYakunin: su enlace parece estar roto. ¿Podría incluir el código de la clase de medición en su respuesta? Sospecho que no importa cómo lo implementes, no podrás ejecutar el código para ser perfilado varias veces con este enfoque IDisposable. Sin embargo, es muy útil en situaciones en las que desea medir el rendimiento de diferentes partes de una aplicación compleja (entrelazada), siempre que tenga en cuenta que las mediciones pueden ser inexactas e inconsistentes cuando se ejecutan en diferentes momentos. Estoy usando el mismo enfoque en la mayoría de mis proyectos.
ShdNx
1
El requisito de ejecutar la prueba de rendimiento varias veces es realmente importante (calentamiento + mediciones múltiples), por lo que también cambié a un enfoque con delegado. Además, si no usa cierres, la invocación de delegados es más rápida que la llamada al método de interfaz en el caso de IDisposable.
Alex Yakunin
6

Creo que el problema más difícil de superar con métodos de evaluación comparativa como este es tener en cuenta los casos extremos y lo inesperado. Por ejemplo: "¿Cómo funcionan los dos fragmentos de código con una alta carga de CPU / uso de la red / destrucción del disco / etc.?" Son excelentes para verificaciones lógicas básicas para ver si un algoritmo en particular funciona de manera significativa más rápido que otro. Pero para probar correctamente la mayor parte del rendimiento del código, tendría que 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 demasiado complejo en lugar de un 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.

Paul Alexander
fuente
1
significativo es uno de esos términos que está realmente cargado. a veces, tener una implementación que es un 20% más rápida es significativo, a veces tiene que ser 100 veces más rápido para ser significativo. De acuerdo con usted en cuanto a la claridad, consulte: stackoverflow.com/questions/1018407/…
Sam Saffron
En este caso, lo significativo no es tan cargado. Está comparando una o más implementaciones simultáneas y si la diferencia en el rendimiento de esas dos implementaciones no es estadísticamente significativa, no vale la pena comprometerse con el método más complejo.
Paul Alexander
5

Llamaría func()varias veces para el calentamiento, no solo una.

Alexey Romanov
fuente
1
La intención era garantizar que se realizara la compilación jit, ¿qué ventaja obtiene al llamar a func varias veces antes de la medición?
Sam Saffron
3
Darle al JIT la oportunidad de mejorar sus primeros resultados.
Alexey Romanov
1
.NET JIT no mejora sus resultados con el tiempo (como lo hace el de Java). Solo convierte un método de IL a Assembly una vez, en la primera llamada.
Matt Warren
4

Sugerencias para mejorar

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

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

  3. Comparación de diferentes versiones / componentes / fragmentos de código (en la primera oración, dice '... comparando pequeños fragmentos de código para ver cuál implementación es más rápida').

Respecto al # 1:

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

  • Para detectar si la optimización de jit está deshabilitada, lea la propiedad DebuggableAttribute.IsJITOptimizerDisabledde los ensamblados 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 proporcionen varios delegados y luego medir a esos delegados individualmente.

Respecto al # 3:

Esto también podría hacerse de muchas formas, y diferentes casos de uso exigirían soluciones muy diferentes. Si el punto de referencia se invoca manualmente, entonces escribir en la consola podría estar bien. Sin embargo, si el sistema de compilación realiza la evaluación comparativa automáticamente, es probable que escribir en la consola no sea tan bueno.

Una forma de hacerlo es devolver el resultado de la evaluación comparativa como un objeto fuertemente tipado que se puede consumir fácilmente en diferentes contextos.


Etimo.

Otro enfoque es utilizar 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, administra el recolector de basura, el jitter, los calentamientos, etc., tal como sugieren algunas de las otras respuestas aquí. También tiene las tres características que sugerí anteriormente. Gestiona varios de los temas tratados en el blog de Eric Lippert .

Esta es una salida de ejemplo donde se comparan dos componentes y los resultados se escriben en la consola. En este caso, los dos componentes comparados se denominan 'KeyedCollection' y 'MultiplyIndexedKeyedCollection':

Etimo.Benchmarks - Salida de consola de muestra

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

Si tiene prisa, le sugiero que obtenga el paquete de muestra y simplemente modifique los delegados 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.

Joakim
fuente
1

También debe ejecutar una pasada de "calentamiento" antes de la medición real para excluir el tiempo que el compilador JIT dedica a modificar su código.

Alex Yakunin
fuente
se realiza antes de la medición
Sam Saffron
1

Según el código que esté evaluando y la plataforma en la que se ejecuta, es posible que deba tener en cuenta cómo la alineación del código afecta el rendimiento . Para hacerlo, probablemente se requiera un contenedor externo que ejecute la prueba varias veces (¿en dominios o procesos de aplicación separados?), Algunas de las veces llamando primero a "código de relleno" para forzar su compilación JIT, de modo que el código sea comparado para alinearse de manera diferente. Un resultado de prueba completo daría los tiempos en el mejor y el peor de los casos para las diversas alineaciones de código.

Edward Brey
fuente
1

Si está intentando eliminar el impacto de la recolección de basura del punto de referencia completo, ¿vale la pena configurarlo GCSettings.LatencyMode?

Si no es así, y desea que el impacto de la basura creada en funcsea ​​parte del punto de referencia, ¿no debería también forzar la recolección al final de la prueba (dentro del temporizador)?

Danny Tuppeny
fuente
0

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

Otra respuesta ofrece una forma correcta 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 tiene en cuenta la recolección de basura. Un perfil adecuado también representa el peor rendimiento de la recolección de basura distribuida en muchas llamadas (este número es algo inútil ya que la VM puede terminar sin recolectar la 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 también se puede querer medir el peor rendimiento de la 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 posible medición adicional específica para el perfil es la idea de que se deben medir múltiples estadísticas diferentes y no solo un tipo de estadística.

Steven Stewart-Gallus
fuente