Utilizando la librería BenchmarkDotNet
Introducción
Muchos programadores tenemos el foco principal (casi único) puesto en la importancia que tiene cubrir funcionalmente la lógica que se demanda de una porción o rutina de código.
Algunos programadores tenemos en consideración además, que el código sea legible, mantenible, con cierto criterio de organización lógica, y aplicando buenas prácticas.
Otros programadores, los menos, tenemos en cuenta adicionalmente, aspectos relativos al rendimiento, fiabilidad y seguridad.
Existen multitud de herramientas y libros que abordan cada una de estas cosas que comento, pero combinar todas de forma eficiente y correcta no es tarea fácil, incluso para programadores experimentados.
El hecho es que muchas empresas contratan a programadores en sus equipos que saben cubrir alguna de estas facetas, pero en las contrataciones casi nunca se muestra especial atención al ciclo completo, y es que la verdad sea dicha, es complicado hacerlo.
En esta entrada, voy a tratar de hablar de tareas relacionadas con el rendimiento, de suma importancia y que muchas veces pasamos por alto o lo dejamos para «ya lo veremos más adelante», no abordándolo casi nunca al final.
Dentro del mundo .NET existen muchas herramientas que nos ayudarán en este propósito.
Mi deseo es poder hablar aquí de una de ellas, Benchmark DotNet.
¿Qué es BenchmarkDotNet?
BenchamarkDotNet es una herramienta a través de la cual podremos analizar el rendimiento de nuestra aplicación y anticiparnos incluso a problemas que podrían arrastrarse a producción.
La idea detrás de esta herramienta es la de ser capaces de medir el rendimiento de nuestro código .NET tratando de que sea lo más eficiente posible.
Este paquete generará un proyecto aislado por cada método que marquemos con el decorador Benchmark, y se encargará de realizar por nosotros, lanzamientos del proyecto ejecutándolo en varias iteraciones.
Eso permitirá ejecutar pruebas de rendimiento, extraer resultados, y analizar los mismos.
Todo en pocas líneas de código.
Los informes que la herramienta extrae los obtendremos en diferentes formatos (CSV, HTML, etc).
Los datos que recogeremos por defecto, son los que la herramienta considera como más importantes.
Para más detalle, lo mejor es consultar la documentación de BenchmarkDotNet.
El código fuente de esta herramienta está abierto y puede ser consultado en este enlace.
¿Cómo usarlo?
Una vez explicado brevemente en qué consiste Benchmark, voy a tratar de explicar su uso con un ejemplo.
Crearemos un proyecto de consola.
Abriremos las opciones del proyecto y nos posicionaremos dentro de la solapa Build.
En esta sección, habilitaremos la opción Optimize code.
Una vez hecho esto, incluiremos el paquete NuGet de BenchmarkDotNet que encontraremos en este enlace dentro del proyecto.
El paquete es compatible con .NET Framework, Mono o .NET Core.
Crearemos una clase dentro de la cual agregaremos los métodos cuyo rendimiento queremos medir.
Agregaremos los métodos que queremos medie y marcaremos cada uno de ellos con el atributo Benchmark.
Por ejemplo, en mi caso voy a medir el rendimiento en operaciones de concatenación de cadenas de texto con Text y con StringBuilder:
[RankColumn] [Orderer(SummaryOrderPolicy.FastestToSlowest)] [MemoryDiagnoser] public class DemoBenchmark { private int iterations = 10000; [Benchmark] public string TextDemo() { var text = String.Empty; for (int i = 0; i < iterations; i++) text += $"text{i}"; return text; } [Benchmark] public string StringBuilderDemo() { var stringBuilder = new StringBuilder(); for (int i = 0; i < iterations; i++) stringBuilder.Append($"text{i}"); return stringBuilder.ToString(); } }
Como podemos apreciar en este código, la clase la he decorado también con diferentes atributos.
RankColumn nos indica el rango o posición en la que se encuentra el resultado obtenido.
Orderer permite personalizar el orden del resultado de pruebas de rendimiento en la tabla resumen.
En nuestro caso, ordenamos los resultados de más rápido a más lento.
Podemos combinar varias ordenaciones separadas por comas.
MemoryDiagnoser nos permite medir el número de bytes asignados y la frecuencia del garbage collection.
Para más información, te sugiero leer esta entrada de Adam Sitnik
Finalmente, en la clase Program agregaré el siguiente código que es el que permitirá ejecutar los procesos que permitan medir el rendimiento de nuestro código.
public class Program { public static void Main(string[] args) { var summary = BenchmarkRunner.Run<DemoBenchmark>(); Console.ForegroundColor = ConsoleColor.Cyan; Console.WriteLine("Benchmark finished!. Press any key to close"); Console.ResetColor(); Console.ReadKey(); } }
Obteniendo los resultados
Podremos iniciar el proceso desde Visual Studio o bien desde la carpeta bin en la que hayamos compilado nuestro código.
Por otro lado, mi recomendación es hacerlo así y desde Release, aunque no es obligatorio, pero los resultados de las pruebas serán más fiables haciéndolo de esta forma.
Los resultados de ejecutar este código que he obtenido en mi máquina son los siguientes:
Combinando Frameworks en los resultados
Mi ejemplo ha sido realizado en .NET Core 3.1, sin embargo, podríamos querer combinar los resultados con diferentes Frameworks al mismo tiempo.
Imaginemos que queremos analizar los resultados en .NET Core 3.1 y .NET Framework 4.7.2 al mismo tiempo.
Bastará con agregar el Framework en el proyecto (.csproj) como por ejemplo de esta forma (puedes indicar la versión o versiones de frameworks que quieres analizar o indicar en tu proyecto en este enlace).
Nota: TargetFramework cambia aquí por TargetFrameworks.
<Project Sdk="Microsoft.NET.Sdk"> <PropertyGroup> <OutputType>Exe</OutputType> <TargetFrameworks>netcoreapp3.1;net472</TargetFrameworks> </PropertyGroup> ... </Project>
Recuerda que puedes descargar e instalar las diferentes versiones de .NET Framework y .NET Core desde este enlace.
Por último, en la clase DemoBenchmark que indiqué anteriormente, voy a agregar una nueva decoración:
... [SimpleJob(RuntimeMoniker.Net472), SimpleJob(RuntimeMoniker.CoreRt31)] public class DemoBenchmark { ... }
De esta forma, cuando ejecutemos nuevamente nuestra aplicación de consola, ésta lanzará las pruebas en diferentes versiones del Framework.
Los resultados que he obtenido en este caso son los siguientes:
Estudiando los resultados
Finalmente quedaría interpretar y estudiar los resultados.
Mean, indica la media medida en este caso en microsegundos.
Como vemos, se trata de una media de tiempo muy baja.
Algo más de 1000x mejor para StringBuilder que para Text.
Gen 0 nos indica el número de recolecciones de objetos de primera generación en elementos de pequeño tamaño.
Aquí se almacenan objetos de corta existencia o variables temporales.
El garbage collection suele actuar de forma frecuente en este nivel.
Gen 1 contiene objetos de corta existencia y sirve como buffer entre los objetos de corta existencia y los objetos de larga duración.
Gen 2 contiene objetos de larga existencia como los datos estáticos que tienen una duración a lo largo de los diferentes procesos que puedan incurrir en la ejecución de una aplicación.
Allocated nos indica la memoria ocupada por el head en cada operación.
Cuanto menor sea este valor, menos carga para el recolector de basura, más optimización de memoria para el proceso, y mejor rendimiento generalmente.
Ejecutando las pruebas de rendimiento con diferentes datos de partida
Imaginemos que sobre el ejemplo expuesto, quiero comparar los resultados en iteraciones de 100, 1000 y 10000 elementos.
Podríamos cambiar la variable que indica las interaciones y ejecutar tres veces las pruebas, o bien, podría hacer uso del atributo Params que nos permitirá ejecutar pruebas de rendimiento con valores de partida distintos.
En nuestro caso, modificaremos la clase de pruebas de rendimiento para que acepte estos datos de entrada de esta forma:
... [Params(100, 1000, 10000)] public int Iterations { get; set; } ...
Si ejecutáramos nuevamente el proyecto para .NET Core 3.1, obtendríamos unos resultados parecidos a los siguientes:
Conclusiones
Como podemos apreciar, trabajar con BenchmarkDotNet es realmente sencillo y rápido, y nos permite obtener una serie de resultados muy valiosos.
Un aspecto adicional que no me gustaría dejar pasar por alto, es que no nos debemos volver unos obsesivos compulsivos con la aplicación y uso de las pruebas de rendimiento.
Aunque todo es susceptible de ser mejorado, lo cierto es que aplicar esfuerzo y tiempo en mejorar una pequeñísima parte de nuestro código, o tratar de mejorar algo que ya de por sí está cerca de tener un muy buen rendimiento, no tiene a priori sentido.
Debemos ser nosotros los que marquemos la prioridad de qué parte o qué partes de nuestro código no son susceptibles de mejorar y cuales sí de acuerdo al rendimiento, y hacer un estudio previo de las posibles combinaciones de código que podrían mejorar nuestra aplicación y porqué.
Finalmente, indicar que toda la documentación, información y otros detalles sobre BenchmarkDotNet, la podrás consultar en este enlace.
Happy Coding!