Saltar al contenido

Artículos, tutoriales, trucos, curiosidades, reflexiones y links sobre programación web ASP.NET Core, MVC, Blazor, SignalR, Entity Framework, C#, Azure, Javascript... y lo que venga ;)

18 años online

el blog de José M. Aguilar

Inicio El autor Contactar

Artículos, tutoriales, trucos, curiosidades, reflexiones y links sobre programación web
ASP.NET Core, MVC, Blazor, SignalR, Entity Framework, C#, Azure, Javascript...

¡Microsoft MVP!
martes, 18 de septiembre de 2018
Tools Los que peinamos ya bastantes canas recordamos con nostalgia aquellas intensas sesiones de programación intentando arañar ciclos de CPU y bytes en nuestro flamante Spectrum para sacar el máximo provecho de, en el mejor de los casos, un Zilog Z80A a 3,5MHz con 48K de memoria RAM. ¡Ah, el maravilloso mundo de las micro-optimizaciones!

Hoy en día, salvo en contadas ocasiones, ha dejado de tener sentido invertir demasiado tiempo en estas labores. Tenemos máquinas potentes, con micros cuya velocidad se mide en GHz capaces de ejecutar bastantes tareas de forma concurrente, y muchos Gigabytes libres de memoria RAM en los que guardar información. Además, los frameworks actuales como .NET permiten despreocuparse de asuntos como la reserva o liberación de memoria porque ya hay sistemas de más bajo nivel que se encargan de eso por nosotros.

Indudablemente es un gran avance, pero esto ha llevado a que, con el tiempo, se nos esté atrofiando ese sentido arácnido que antes nos disparaba las alertas cuando cierto código podía ser optimizado para consumir menos recursos.

En la mayoría de escenarios, y sobre todo cuando trabajamos en entornos empresariales, aplicaciones de escritorio o webs de poca carga, está bien así. Sin embargo, es cierto también que las necesidades han cambiado.

Por ejemplo, ahora creamos frecuentemente aplicaciones mucho más complejas que pueden ser utilizadas a través de Internet por miles de usuarios de forma simultánea y todos ellos esperan respuestas rápidas. Estas aplicaciones se ejecutan en servidores cuyos recursos son compartidos entre todos los usuarios que pueden llegar a tener un coste importante y debemos exprimir al máximo. Aquí, y en otros escenarios similares, es donde aparece de nuevo la necesidad de introducir optimizaciones en el código.

En este post vamos a hacer una introducción al uso de BenchmarkDotNet, una magnífica herramienta que nos permitirá medir el rendimiento de nuestro código .NET para hacerlo más eficiente en términos de uso de procesador y memoria.

Pero antes de empezar, no olvidéis la famosa frase de Donald Knuth:
“Los programadores consumen una gran cantidad de tiempo pensando, o preocupándose, sobre la velocidad de partes no críticas de sus programas, y esos intentos de mejorar la eficiencia tienen posteriormente un gran impacto negativo sobre la facilidad de depuración o mantenimiento. Deberíamos olvidarnos de las pequeñas mejoras de eficiencia, digamos en un 97% de los casos: la optimización prematura es el origen de todos los males. Sin embargo, no debemos dejar pasar la oportunidad de mejorar ese crítico 3% restante”

Introducing BenchmarkDotNet

BenchmarkDotNet BenchmarkDotNet es un proyecto de software libre miembro de la .NET Foundation que facilita la realización de pruebas de rendimiento de código, o benchmarking, a bajo nivel, aportándonos una visión muy interesante sobre cómo funcionan las cosas por dentro y ayudándonos a exprimir al máximo los recursos disponibles.

Se distribuye en forma de paquete Nuget bajo la denominación BenchmarkDotNet, y es compatible con .NET Framework, .NET Core, Mono y CoreRT, corriendo sobre Windows, Linux y MacOS.
Como suele ser habitual, el proyecto se encuentra en Github, está abierto a todo tipo de colaboraciones y tiene bastante actividad.

¿Como medimos el rendimiento con BenchmarkDotNet?

BenchmarkDotNet permite medir el rendimiento de código que puede provenir de distintas fuentes. Por ejemplo, podemos suministrarle directamente un código C#, VB o F# en forma de cadena de texto, una URL donde se encuentra el mismo (por ejemplo, en un Gist de GitHub) o realizarlas sobre código existente.

En nuestro caso veremos el último de los escenarios, pues probablemente será el que utilicemos con mayor frecuencia. Por tanto, imaginemos que queremos determinar el rendimiento del siguiente código, que podría encontrarse en una biblioteca de clases:
public static class HashUtils
{
    public static byte[] GetMd5(byte[] bytes)
    {
        var md5 = MD5.Create();
        return md5.ComputeHash(bytes);
    }
}
Lo primero que vamos a hacer es crear una aplicación de consola, a la que añadimos referencias al proyecto o el ensamblado que contiene el código al que queremos hacer el benchmark. Tras ello, le añadiremos también el paquete Nuget BenchmarkDotNet.

A continuación, crearemos la clase que definirá las pruebas que vamos a hacer, a la que llamaremos por ejemplo HashUtilsBenchmarks:
public class HashUtilsBenchmarks
{
    private readonly byte[] _buffer;
    public HashUtilsBenchmarks()
    {
        _buffer = new byte[1024];
        var rnd = new Random();
        rnd.NextBytes(_buffer);
    }

    [Benchmark]
    public void Md5Benchmark()
    {
        HashUtils.GetMd5(_buffer);
    }
}
Observad que en el constructor inicializamos todo lo que necesitamos para hacer la prueba, pues se ejecutará una única vez antes de realizar el benchmark. Lo que realmente vamos a medir es el rendimiento de Md5BenchMark() y por eso este método lo decoramos con el atributo [Benchmark].

Finalmente, sólo debemos iniciar el proceso indicando a BenchmarkDotNet la clase que debe ejecutar en el Main() de nuestra aplicación de consola:
class Program
{
    static void Main(string[] args)
    {
        BenchmarkRunner.Run<HashUtilsBenchmarks>(); // Go!
        Console.ReadKey();
    }
}
Al ejecutar la aplicación, BenchmarkDotNet creará un proyecto independiente y aislado por cada método que deseamos comprobar, lo lanzará varias veces, incluyendo un periodo de calentamiento o warm up, y ejecutará los métodos a comprobar tantas veces como estime necesario para obtener los resultados con un nivel de precisión fiable.
Nota: Es importante ejecutar el proyecto sin depurar y compilado en modo release, para que los “extras” añadidos por la depuración no afecten a los resultados. Asimismo, para mejorar los resultados, en la documentación oficial se recomienda establecer los ajustes de energía a máxima potencia, parar aplicaciones o servicios que no sean necesarios y evitar la anulación de dead code por parte del compilador.
En el caso anterior, el resultado obtenido por consola podría ser el siguiente:
// ***** BenchmarkRunner: Start   *****
// Found benchmarks:
//   HashUtilsBenchmarks.Md5Benchmark: DefaultJob
[...]

BenchmarkDotNet=v0.10.14, OS=Windows 10.0.17134
Intel Core i7 CPU 950 3.07GHz (Nehalem), 1 CPU, 8 logical and 4 physical cores
Frequency=2992667 Hz, Resolution=334.1501 ns, Timer=TSC
.NET Core SDK=2.1.200
  [Host]     : .NET Core 2.0.7 (CoreCLR 4.6.26328.01, CoreFX 4.6.26403.03), 64bit RyuJIT
  DefaultJob : .NET Core 2.0.7 (CoreCLR 4.6.26328.01, CoreFX 4.6.26403.03), 64bit RyuJIT

       Method |     Mean |     Error |    StdDev |
------------- |---------:|----------:|----------:|
 Md5Benchmark | 3.738 us | 0.0093 us | 0.0078 us |
A la vista de estos datos, podemos concluir que nuestro método se ejecuta en .NET Core 2.0, de media, en 3.7 microsegundos. Recordad que un microsegundo es la millonésima parte de un segundo… ¡es brutal!

¿Y es más rápido en .NET framework o en .NET Core?

Si nuestro proyecto tiene activo el multi-targeting, es muy sencillo probar en una misma atacada el rendimiento de nuestros métodos en los distintos runtimes. Por ejemplo, supongamos que nuestro archivo de proyecto .csproj está configurado de la siguiente forma, por lo que nuestro proyecto será compilado para .NET Core 2.0 y .NET Framework 4.7.1 al mismo tiempo:
  <PropertyGroup>
    <OutputType>Exe</OutputType>
    <TargetFrameworks>netcoreapp2.0;net471</TargetFrameworks>
  </PropertyGroup>
Si queremos que BenchmarkDotNet genere y ejecute mediciones para ambos entornos, sólo deberíamos incluir los atributos [ClrJob] y de [CoreJob] en la clase de pruebas:
[ClrJob, CoreJob]
public class HashUtilsBenchmarks
{
    [...] // Todo igual
}
Esta vez las pruebas tardarán el doble porque se ejecutarán dos veces, una en cada entorno, pero al finalizar el resultado que obtendremos será muy esclarecedor:
// ***** BenchmarkRunner: Start   *****
// Found benchmarks:
//   HashUtilsBenchmarks.Md5Benchmark: Clr(Runtime=Clr)
//   HashUtilsBenchmarks.Md5Benchmark: Core(Runtime=Core)
[...]

BenchmarkDotNet=v0.10.14, OS=Windows 10.0.17134
Intel Core i7 CPU 950 3.07GHz (Nehalem), 1 CPU, 8 logical and 4 physical cores
Frequency=2992667 Hz, Resolution=334.1501 ns, Timer=TSC
.NET Core SDK=2.1.200
  [Host] : .NET Core 2.0.7 (CoreCLR 4.6.26328.01, CoreFX 4.6.26403.03), 64bit RyuJIT
  Clr    : .NET Framework 4.7.1 (CLR 4.0.30319.42000), 64bit RyuJIT-v4.7.3101.0
  Core   : .NET Core 2.0.7 (CoreCLR 4.6.26328.01, CoreFX 4.6.26403.03), 64bit RyuJIT


       Method |  Job | Runtime |     Mean |     Error |    StdDev |
------------- |----- |-------- |---------:|----------:|----------:|
 Md5Benchmark |  Clr |     Clr | 8.225 us | 0.0456 us | 0.0426 us |
 Md5Benchmark | Core |    Core | 3.854 us | 0.0444 us | 0.0416 us |
Pues como podíamos sospechar, ¡.NET Core 2.0 es el doble de rápido que .NET 4.7.1 calculando hashes MD5!

Pero lo que mola es comparar distintas implementaciones, ¿verdad?

De poco sirve medir el rendimiento de un código si no podemos compararlo con algo. Por esta razón, supongamos ahora que en la clase original hemos añadido un nuevo método para obtener hashes utilizando SHA256:
public static class HashUtils
{
    public static byte[] GetMd5(byte[] bytes) { ... } // Igual que antes

    public static byte[] GetSha256(byte[] bytes)
    {
        var sha256 = SHA256.Create();
        return sha256.ComputeHash(bytes);
    }
}
Para comparar el rendimiento de ambos métodos, deberíamos extender un poco la clase de pruebas que creamos previamente y añadir la prueba correspondiente al nuevo método:
public class HashUtilsBenchmarks
{
    [...] // Todo igual que antes

    [Benchmark]
    public void Sha256Benchmark()
    {
        HashUtils.GetSha256(_buffer);
    }
}
Y sin cambiar nada más, al ejecutar la prueba podemos obtener el siguiente resultado:
          Method |     Mean |     Error |    StdDev |
---------------- |---------:|----------:|----------:|
    Md5Benchmark | 3.735 us | 0.0584 us | 0.0547 us |
 Sha256Benchmark | 7.990 us | 0.0304 us | 0.0237 us |
Fijaos que ahora tenemos información muy valiosa: ya podemos asegurar que la obtención de hashes usando MD5 es más del doble de rápida que su equivalente SHA256. Eso sí, tened en cuenta que también es más inseguro ;)

Comprobemos un clásico: ¿es más rápido concatenar cadenas o usar StringBuilder?

Seguro que alguna vez ós habéis encontrado con varias alternativas a la hora de implementar una determinada funcionalidad. Muchas veces optamos por la más sencilla de codificar, pero, ¿siempre sabemos realmente las repercusiones que esto tiene? Quizás nosotros no, pero BenchmarkDotNet sí ;)

Por ejemplo, todos hemos escuchado que la concatenación de cadenas de caracteres es una operación terrible desde el punto de vista del rendimiento, pero muchas veces hacemos oídos sordos porque frecuentemente es la implementación más rápida. Pues para tener información de primera mano, haremos un benchmark que compare ambas implementaciones.

Nuestra clase de pruebas, a la que llamaremos StringBenchmarks, incluirá dos implementaciones distintas para concatenar los primeros 1.000 números enteros, separados por un guión. Es decir, el resultado de ambos métodos será una cadena de caracteres del tipo “1-2-3-4-[…]-999-1000”.

Para que veáis otra forma de crear las pruebas, observad que en este caso estamos introduciendo la implementación directamente sobre esta clase de benchmark, y fijaos también que estamos activando el diagnóstico de memoria usando el atributo [MemoryDiagnoser] sobre la misma:
[MemoryDiagnoser]
public class StringBenchmarks
{
    private readonly int _count = 1000;

    [Benchmark]
    public string UsingStringConcat()
    {
        var result = string.Empty;
        for (int i = 1; i <= _count; i++)
        {
            result += i.ToString();
            if (i != _count)
            {
                result += "-";
            }
        }
        return result;
    }

    [Benchmark]
    public string UsingStringBuilder()
    {
        var result = new StringBuilder();
        for (int i = 1; i <= _count; i++)
        {
            result.Append(i);
            if (i != _count)
            {
                result.Append("-");
            }
        }
        return result.ToString();
    }
}
El resultado no deja lugar a dudas:
             Method |       Mean |     Error |    StdDev |     Gen 0 |  Allocated |
------------------- |-----------:|----------:|----------:|----------:|-----------:|
  UsingStringConcat | 1,050.7 us | 23.321 us | 23.949 us | 1828.1250 | 7495.82 KB |
 UsingStringBuilder |   110.3 us |  2.184 us |  2.043 us |   11.4746 |   47.52 KB |
La columna “Gen 0” muestra el número de recolecciones de objetos de primera generación por cada 1.000 llamadas al método; “Allocated”, por otra parte, muestra la memoria que ha sido ocupada en el heap por cada operación.
La alternativa que utiliza StringBuilder, además de ser diez veces más rápida, utiliza 150 veces menos memoria del heap que la alternativa basada en strings, lo que implica un menor stress del recolector de basura (160 veces más recolecciones por cada mil llamadas al método).

Pero además, como se puede intuir, la diferencia entre ambas opciones aumenta cuando incrementamos el número de iteraciones. Es decir, si en lugar de concatenar los mil primeros enteros lo hacemos con diez mil, la cosa se dispara, sobre todo en uso de memoria y trasiego de objetos entre las distintas generaciones del heap:
             Method |       Mean |     Error |    StdDev |       Gen 0 |      Gen 1 |      Gen 2 |    Allocated |
------------------- |-----------:|----------:|----------:|------------:|-----------:|-----------:|-------------:|
  UsingStringConcat | 147.255 ms | 2.9023 ms | 4.1623 ms | 244437.5000 | 73437.5000 | 73437.5000 | 936218.48 KB |
 UsingStringBuilder |   1.173 ms | 0.0153 ms | 0.0143 ms |    119.1406 |    29.2969 |    29.2969 |    589.22 KB |
Sin embargo, ¿qué ocurre si nos vamos a pocas iteraciones? A continuación podemos observar el resultado para cinco iteraciones, lo que viene a indicar que para pequeñas operaciones de concatenación será prácticamente equivalente, o incluso más rápido algunas veces, sumar directamente string en lugar de hacerlo con StringBuilder:
             Method |     Mean |    Error |   StdDev |  Gen 0 | Allocated |
------------------- |---------:|---------:|---------:|-------:|----------:|
  UsingStringConcat | 675.7 ns | 13.41 ns | 14.34 ns | 0.1135 |     480 B |
 UsingStringBuilder | 536.2 ns | 10.77 ns | 11.97 ns | 0.0734 |     312 B |

Otro caso práctico: ¿es más rápido usar string.Join() o concatenar con StringBuilder?

Bueno, si la pregunta anterior era fácil de responder incluso sin haber efectuado las pruebas, en este caso tenemos otra que no es tan sencilla a priori. Por suerte, con BenchmarkDotNet saldremos de dudas en un instante ;)

Hagamos subir al escenario otra implementación de nuestra funcionalidad de concatenación, esta vez basada en el método string.Join() proporcionado por el framework. Como novedad, incluiremos en la clase de pruebas el atributo [RankColumn] para que BenchmarkDotNet incluya una columna que indique el ranking de cada una de las opciones y [OrderProvider] para ordenar los resultados mostrando los más rápidos primero:
[MemoryDiagnoser]
[RankColumn, OrderProvider(SummaryOrderPolicy.FastestToSlowest)]
public class StringBenchmarks
{
    [...] // Todo igual que antes

    [Benchmark]
    public string UsingStringJoin()
    {
        var result = string.Join("-", Enumerable.Range(1, _count));
        return result;
    }
}
Al ejecutar con mil iteraciones, podemos concluir que StringBuilder sigue siendo la opción más rápida y eficiente, aunque string.Join muestra unos resultados casi idénticos, por lo que podríamos considerar que son prácticamente equivalentes, y ambos muy lejos de la concatenación pura de cadenas:
             Method |       Mean |     Error |    StdDev | Rank |     Gen 0 |  Allocated |
------------------- |-----------:|----------:|----------:|-----:|----------:|-----------:|
 UsingStringBuilder |   109.7 us |  2.140 us |  3.000 us |    1 |   11.4746 |   47.52 KB |
    UsingStringJoin |   130.2 us |  3.082 us |  2.883 us |    2 |   11.4746 |   47.56 KB |
  UsingStringConcat | 1,052.6 us | 19.618 us | 18.351 us |    3 | 1828.1250 | 7495.82 KB |

Conclusión

Sin duda, BenchmarkDotNet es una de esas herramientas que tenemos que llevar siempre en el cinturón. Aunque su uso deberíamos limitarlo a escenarios donde realmente sea necesario realizar optimizaciones milimétricas y en funcionalidades muy críticas, también es interesante por lo que podemos aprender de los resultados para mejorar la forma de resolver determinados problemas.

Obviamente, BenchmarkDotNet es mucho más de lo que hemos contado aquí. Podemos hacer mediciones parametrizadas, inyectar valores como argumentos de métodos, generar archivos de exportación CSV, HTML, JSON, markdown y otros, añadir columnas personalizadas, implementar criterios de ordenación, etc. Pero creo que en este post hemos tratado lo mínimo que debemos conocer para poder empezar a utilizar esta magnífica herramienta.

Publicado en Variable not found.

Aún no hay comentarios, ¡sé el primero!