¿Cómo de eficiente es tu código?, ¿Los cambios que estas haciendo mejoran o empeoran tu aplicación en términos de rendimiento?. Tomar puntos de referencia comparativos (benchmarking) es importante para comprender como tu aplicación mejora o empeora y permitir así evaluar tu código.
Al momento de hacer pruebas evaluativas piensa en ellas como si fueran pruebas unitarias, no quieres evaluar interacciones complicadas a menos que este sea tu objetivo, por ello, un simple proyecto de consola es útil para poder visualizar las comparaciones del código de tu aplicación.
En esta ocasión haremos un overview de BenchmarkDotNet y como este nos ayuda a realizar pruebas comparativas o de referencia en nuestro código para tomar decisiones informadas desde el diseño hasta la implementación.
Primeros pasos
Lo primero que debemos realizar es tener una aplicación de consola, esta puedes crearla como cualquier otra aplicación de consola que hayas creado antes, solo ten en cuenta el soporte del paquete (puedes verlo en la pagina oficial BenchmarkDotNet):
- Runtimes: Full .NET Framework (4.6+), .NET Core (2.0+), Mono, NativeAOT
- OS: Windows, Linux, MacOS
- Languages: C#, F#, VB
- BenchmarkDotNet solo funciona con aplicaciones de consola (How to Run).
Una vez tengas lista la aplicación procede a instalar el paquete utilizando alguna de las siguientes formas:
-
Package Manager Console:
Install-Package BenchmarkDotNet -Version 0.14.0
-
.Net CLI:
dotnet add package BenchmarkDotNet --version 0.14.0
. Recuerda que este comando debes especificar el archivo de proyecto.csproj
al que instalaras el paquete o ubicar tu consola en la ruta donde se encuentre el.csproj
del proyecto al que instalaras el paquete. - Interfaz del Package Manage: Si usas Visual Studio puedes instalar el paquete dando clic derecho sobre tu proyecto en el explorador de soluciones > Administrar Paquetes NuGet (Manage NuGet Packages) > Examinar > Buscar BenchmarkDotNet > Instalar.
Una vez listo esto podemos comenzar a escribir nuestro código.
Nuestro primer Benchmark
Lo primero que necesitamos es tener código que evaluar, en este caso haremos una simple concatenación de cadenas de texto, para ello creamos la siguiente clase:
public class BenchmarkDemo
{
[Benchmark]
public string ConcatenacionSimple()
{
string texto = "";
for (int i = 0; i < 100; i++)
{
texto += i;
}
return texto;
}
}
Esta clase contiene un método llamado ConcatenacionSimple
que ejecuta un ciclo for
100 veces y concatena cada numero, aunque esto no es lo primordial, podría ser cualquier código que tu quieras comparar, lo importante a notar es el atributo BenchmarkAttribute
que decora el método [Benchmark]
, este atributo permite marcar un método como método a comparar dentro de la clase, ahora solo necesitamos ejecutar nuestro benchmark, para ello escribimos el siguiente código:
var result = BenchmarkRunner.Run<BenchmarkDemo>();
Si utilizamos TLS (Top-Level Statements) nuestra clase Program.cs se veria de la siguiente forma:
using BenchmarkDotNet.Attributes;
using BenchmarkDotNet.Running;
var result = BenchmarkRunner.Run<BenchmarkDemo>();
public class BenchmarkDemo
{
[Benchmark]
public string ConcatenacionSimple()
{
string texto = "";
for (int i = 0; i < 100; i++)
{
texto += i;
}
return texto;
}
}
Esto suponiendo que nuestra clase BenchmarkDemo
este dentro del mismo archivo pero puede vivir en cualquier parte de nuestra solución (ensamblado, namespace, etc). Si no utilizamos TLS entonces tendríamos la estructura convencional de nuestra clase Program
y dentro del método Main
escribiríamos el runner de nuestro benchmark:
internal class Program
{
static void Main(string[] args)
{
var result = BenchmarkRunner.Run<BenchmarkDemo>();
}
}
Una vez listo esto podemos ejecutar nuestro código, sin embargo, el código debe de ser compilado en modo Release
, esto es porque el modo Debug
es una compilación que no esta totalmente optimizado para realizar las configuraciones ya que este modo utiliza y reserva algunos recursos adicionales para poder permitir depurar nuestro código durante el desarrollo, lo cual esta muy bien durante el desarrollo pero para comparar de manera eficiente nuestro código es necesario optimizar el uso de los recursos por ende utilizaremos el modo Release
, podemos hacerlo de las siguientes dos formas:
-
.NET CLI: Ejecutamos el comando
dotnet run --configuration Release
. Al igual que durante la instalación del paquete recuerda que este comando debes especificar el archivo de proyecto .csproj
al que instalaras el paquete o ubicar tu consola en la ruta donde se encuentre el.csproj
del proyecto al que instalaras el paquete. -
Visual Studio: Modificaremos el modo de ejecución desde el menú de herramientas en el IDE para seleccionar
Release
en el selector de Configuraciones de Soluciones. Luego, podemos ejecutar nuestra aplicación de consola.
Cualquiera de las opciones que sigas, ejecutara el proyecto en la configuración adecuada. Siempre que ejecutamos una aplicación o sistema por primera vez existe un costo de levantamiento o arranque inicial de esta, esto es debido a que todos los servicios, recursos, etc. que necesita la aplicación son “levantados” o inicializados durante este arranque inicial por lo que Benchmark.net lo que hace es ejecutarse unas cuantas veces como “calentamientos” antes de comenzar a tomar las ejecuciones como referencia descartando estas ejecuciones y luego comenzar a tomar los resultados una vez la aplicación ya haya pasado este periodo inicial de ejecución, piensa en ello como cuando tu harás tu rutina de ejercicio en casa, en el gimnasio o cualquier otro lugar y calientas un poco antes de comenzar dicha rutina.
El resultado te muestra la versión del paquete, el sistema operativo y la actualización de este, el procesador y tarjeta grafica, así como la cantidad de CPUs y procesadores lógicos y físicos y el entorno de ejecución. Esto es importante, porque de esta manera conocemos el entorno y recursos en donde nuestra aplicación o en este caso las pruebas están siendo ejecutadas para tenerlo como referencia o replicar estas pruebas.
Esto solo te esta haciendo saber respecto a donde se esta ejecutando esta prueba, pero no será lo mismo si lo ejecutas en otra máquina, sin embargo, en términos generales no te están diciendo el rendimiento general de tu código, solo te dicen que esta ejecutándose en tu maquina y que tan rápido se ejecuta en tu maquina, por lo que, no es tan útil si consideramos el proyecto ejecutándose en producción, pero primeros pasos son primeros pasos y estos son importantes.
Podemos agregar el atributo [MemoryDiagnoser]
a nuestra clase BenchmarkDemo
:
[MemoryDiagnoser]
public class BenchmarkDemo
{
[Benchmark]
public string ConcatenacionSimple()
{
string texto = "";
for (int i = 0; i < 100; i++)
{
texto += i;
}
return texto;
}
}
Este atributo permite agregar un diagnosticador para conocer cuanta memoria esta siendo utilizada o alojada por el método así como cada una de las generaciones que son creadas por el Garbage Collector
y las recolecciones que este realiza para las operaciones:
De esta forma el benchmark identificara que tan eficiente es un método base en la ejecución del mismo un numero determinado de veces.
El resultado además nos indica las etiquetas o leyendas del resultado que pueden ser mas interesantes o menos según que tanto te gusten las matemáticas:
Quizá las mas relevantes son la media del tiempo de ejecución (Mean
) la desviación estándar (StdDev
) que nos permite estar seguros de si el tiempo de la media es significativo o si el tiempo de cada ejecución esta alejado de esta y la memoria alojada por ejecución (Allocated
) nos dice cuanta memoria es alojada por llamada del método, en este caso, seria todo lo que almacenamos en nuestras variables.
Respecto a la columna Gen0
tiene que ver con el funcionamiento interno del Garbage Collector
mas que con una simple métrica de BenchmarkDotNet. Gen0
es el GC
diciendo “me ejecutare y me fijare en de las cosas que no son mas necesarias” luego de marcar todo aquello que no es necesario marca además los recursos que no puede eliminar porque están siendo utilizadas, de esta forma limpia la memoria de lo que ya no es útil y lo que sigue siendo útil lo marca o lo pasa a Gen1, lo que significa que sobrevivió una limpieza, la limpieza de Gen0
, y cada uno de los recursos adicionales que sigan siendo necesarios los seguirá alojando en Gen0
nuevamente, y volverá a verificar si hay cosas que no se estén utilizando para eliminar el uso de esos recursos y moverá los restantes a Gen1
, de esta forma hasta que todos se liberen, y hará lo mismo con Gen1
para limpiar la memoria liberando los recursos de todo aquello que ya no sea necesario marcado como Gen1
y todo lo que no pueda eliminar se ira a Gen2
y lo enviara ahí, y hará esto una y otra vez. Esto solo es una visión general de manera simplificada de como funciona el Garbage Collector (GC)
.
Benchmarking comparativo
Como ya vimos anteriormente podemos extraer distintas métricas sobre la eficiencia y rendimiento en ejecución de nuestro código con información adicional que nos permite comprender lo que hace la herramienta a través de un vistazo a una ejecución simplificada.
Ahora bien, el benchmarking no es solo sobre ver que tan rápido se ejecuta tu código, es útil cuando ejecutas código y empiezas a realizar comparaciones, si bien pueden estarse ejecutando en tu maquina, un código que utilizando el mismo entorno y con los mismos recursos tiene peor rendimiento, es altamente probable que vaya a suceder lo mismo en un entorno productivo. Aun así, estas pruebas puedes enviarlas a un entorno similar a producción y podrías utilizar los resultados como un buen punto de entrada para describir fenómenos relacionados al rendimiento en determinada pieza de código.
Hasta el momento hemos obtenido información pero esta es información sin contexto por lo tanto lo siguiente es, como dar contexto a nuestras comparaciones, ya que si este es un método bien escrito o no, no nos lo puede decir por su cuenta.
No podemos saber si un método es bueno o malo por si solo, no importa lo que obtengamos, para determinar si un método es bueno o no depende de los requerimientos no funcionales (atributos de calidad) establecidos para ese caso de uso o incluso nuestra aplicación y además otro método adicional que nos permita compararlos.
Para lograr este objetivo agregaremos el siguiente método a nuestra clase BenchmarkDemo
y ejecutaremos la aplicación:
[Benchmark]
public string ConcatenacionConStringBuilder()
{
var builder = new StringBuilder();
for (int i = 0; i < 100; i++)
{
builder.Append(i);
}
return builder.ToString();
}
Cuando visualizamos las cosas por si solas, si, nos da información, obtenemos una premisa de lo que nuestro método puede estar utilizando en términos de recursos, pero esta información es vacía, sin embargo, al comparar un mismo proceso con distintas formas de hacerlo, podemos obtener una imagen completa del rendimiento y ejecución de estos, con esto podemos saber si es eficiente o no y si cumple o no con los atributos de calidad establecidos.
Linea base de ejecución
Podemos realizar comparaciones entre métodos tomando como elemento de referencia o linea base uno de los métodos agregando la propiedad Baseline
con valor true
al atributo Benchmark: [Benchmark(Baseline = true)]
Esto nos permite conocer el método sobre el cual vamos a comparar las métricas obtenidas de los otros métodos. La forma normal de hacer las cosas es el método marcado con esta propiedad en el atributo, comparando así las ejecuciones de los otros métodos con este, de ahí el nombre de linea base.
El ratio o proporción de 1.00
es el método considerado o marcado con Baseline
a true
, determinando así en los demás métodos el rendimiento proporcional a esto que este tiene, es decir, si es menos a 1.00
es mejor, y la mejora es de 1.00
menos el ratio del método en cuestión, por ejemplo, 1.00
menos 0.27
da 0.73
, lo que significa que es 73%
mas eficiente en términos de tiempo, etc. Cualquier cosa menor que 1.00
es mejor, y si es mayor a 1.00
entonces es peor.
Benchmarks entre distintas versiones de .NET
BenchmarkDotNet no solo nos permite comparar ejecución de métodos utilizando un solo entorno, pero además nos permite ejecutar estos métodos y compararlos entre versiones de .NET sin necesidad de crear una nueva aplicación.
Para lograr esto utilizamos el atributo SimpleJobAttribute
sobre la definición de nuestra clase:
[SimpleJob(RuntimeMoniker.Net48, baseline: true)]
[SimpleJob(RuntimeMoniker.Net80)]
[MemoryDiagnoser]
public class BenchmarkDemo{}
RuntimeMoniker
contiene diversos miembros que permiten indicar la versión de .NET que vamos a utilizar; RuntimeMoniker.Net48
se refiere a la versión de .NET Framework 4.8.0 y RuntimeMoniker.Net80
a .NET (.NET Core) 8.0, el argumentobaseline: true
hace referencia a que la linea base o con la cual se va a comparar las ejecuciones entre frameworks es con .NET Framework 4.8.0.
Antes de ejecutar nuestra aplicación de consola para comparar entre versiones de .NET tenemos que realizar lo siguiente:
- Tener instaladas cada una de las versiones de .NET que vayamos a comparar, es decir, en el caso del ejemplo, debemos tener instalado .NET Framework 4.8.0 y .NET 8.0 (.NET Core 8.0) .
- Modificar el proyecto, en mi caso estoy compilando a .NET 8.0 no para .NET Framework 4.8.0 para ello podemos dar doble clic sobre el proyecto en Visual Studio o Clic Derecho > Editar Archivo del proyecto, esto abrirá el archivo
.csproj
del proyecto para editarlo, en caso que no uses Visual Studio solo busca dicho archivo en los archivos de la solución. - Cambiaremos la etiqueta que diga
<TargetFramework>net8.0</TargetFramework>
a<TargetFrameworks>net8.0;net48</TargetFrameworks>
(agregando una s) separando por punto y coma cada una de las versiones a las que queramos compilar. Ten en cuenta que el valor puede ser diferente considerando las versiones que estemos utilizando. - Nos alertara de que necesita recargar algunos proyectos, le damos a recargar y luego vamos al menú Compilar > Recompilar Solucion (Build > Rebuild Solution).
Esto limpiara nuestro proyecto e inicializara y compilara todo de nuevo. Ten en cuenta que tener multiples frameworks como objetivo de compilación puede ocasionar algunos problemas de incompatibilidad entre versiones de paquetes y sintaxis del lenguaje C# que no este en versiones anteriores, por ende, utiliza estas comparativas con mucho cuidado, por ejemplo, los TLS no están disponibles en .NET Framework 4.8 ya que este es compatible con C# 7.3 y los TLS están disponibles a partir de C#9 que vino con .NET 5 significando que, nuestra clase Program
debe regresar a su estructura convencional para poder ejecutar el código del benchmark:
internal class Program
{
static void Main(string[] args)
{
var result = BenchmarkRunner.Run<BenchmarkDemo>();
}
}
En términos generales puedes utilizar la compilación a múltiples versiones si te interesa dar el salto de una versión a otra de .NET Framework o .NET Core, por ejemplo, .NET 6 a .NET 8 ya que este año (2024) .NET 6 dejara de tener soporte en noviembre y quieres comparar si vale la pena hacer el salto a .NET 8 o a .NET 9 (con salida en noviembre de 2024) en términos de rendimiento si mejora o no tu aplicación e incluso si una nueva característica incorporada al lenguaje te permite mejorar el rendimiento de tu aplicación.
Utiliza con precaución esta posibilida de BenchmarkDotNet aun mas si estas comparando versiones de .NET Framework con versiones de .NET Core, por ejemplo, los usings globales, las advertencias y anotaciones nullable, entre otras características no son compatibles con versiones anteriores del lenguaje.
Algunas de estas características se deben modificar o suprimir en el archivo .csproj
del proyecto, el mío luce de la siguiente forma antes de los cambios:
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net8.0</TargetFrameworks>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="BenchmarkDotNet" Version="0.14.0" />
</ItemGroup>
</Project>
Y ya con los cambios necesarios luce de la siguiente forma:
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFrameworks>net8.0;net48</TargetFrameworks>
<ImplicitUsings>disable</ImplicitUsings>
<Nullable>disable</Nullable>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="BenchmarkDotNet" Version="0.14.0" />
</ItemGroup>
</Project>
Cambiando <ImplicitUsings>enable</ImplicitUsings>
a <ImplicitUsings>disable</ImplicitUsings>
para desactivar los using
globales implícitos y <Nullable>enable</Nullable>
a <Nullable>disable</Nullable>
para desactivar las anotaciones de tipos de referencia nulos en tiempo de compilación.
Si ejecutamos ahora el benchmark será el doble o el triple según la cantidad de frameworks con los que estés evaluando tus métodos y tomara mucho mas tiempo teniendo en consideración cada una de las ejecuciones además el Baseline
ahora será el framework y no el método.
Y en los resultados veras las ejecuciones de cada uno de los métodos:
En la imagen anterior podemos observar una de las mayores razones para cambiar de .NET Framework a .NET Core, y es, el rendimiento de las aplicaciones en .NET Core es muy superior en términos de velocidad, administración de memoria y CPU. Obviamente existen otras razones como que .NET Core es multiplataforma, entre mas, aunque no es el objetivo principal de BenchmarkDotNet determinar esto.
Tengamos en cuenta que estamos evaluando el mismo código, los mismos métodos, ningún cambio entre plataforma, es una comparativa fiel de ambos entornos, no solo estamos comparando nuestros métodos entre si, sino que además, estamos comparando su entorno de ejecución, lo cual puede ayudarnos a tomar decisiones de manera informada respecto a nuestras aplicaciones.
Podemos comparar que incluso la versión con StringBuilder
en .NET Framework tiene peor rendimiento que la versión normal en .NET Core.
De nuevo, no podemos establecer que estas métricas obtenidas van a tener ese rendimiento, eso solo ocurre tomando el hardware como un hecho, es decir, siempre y cuando el hardware sea igual o muy cercano obtendremos métricas similares, lo cual es muy difícil conseguir en un servidor de producción, pero si no es posible probarlo, lo que si es posible determinar es la evidente comparativa en el rendimiento entre métodos o incluso entre versiones de .NET, esto permite orientar no solo la decisión sobre los cambios de nuestro código sino además la toma de decisiones de diseño relacionadas al entorno de ejecución.
De esta forma podemos tomar decisiones informadas y además establecer la relación económica entre el rendimiento de la aplicación, el uso de esta y los beneficios del negocio. Si bien estamos hablando de micro o nano segundos y solo de unos cuantos kilobytes, debemos tener en cuenta que las aplicaciones no son solo utilizadas por un par de usuarios, pueden ser cientos o miles, y ahí es donde las cosas se notan en realidad.
Conclusión
Muchas veces realizamos cambios a nuestro código pensando que son cambios beneficiosos a nuestra aplicación, cambios de librerías, refactorización, abstracciones, etc, pero a lo mejor, estamos introduciendo componentes que pueden afectar directa o indirectamente el rendimiento, pero, tomando las métricas adecuadas podemos saber si nuestra implementación tiene tal valor o es mejor dejar nuestro código tal como esta, o incluso si el intercambio de rendimiento por mejorar la experiencia de de desarrollo es conveniente.
Obviamente, debemos utilizar esto con mucha precaución porque requiere trabajo adicional que no puede ser realmente justificado, por ello, evita realizar optimizaciones prematuras o incorporar flujos de trabajo que realmente no puede verse su valor en las primeras etapas de desarrollo del sistema, si llegas a identificar algo extraño una vez tu aplicación este en uso, ahí es el momento correcto para actuar.
Si estas realizando pruebas comparativas y vas a decirle a tu jefe algo respecto a los números obtenidos, recuerda no hacerlo en lenguaje técnico diciendo “Este método reduce el uso de memoria de 4kb a 1kb”, puedes decir algo como “Realizando algunos cambios en la aplicación se puede reducir el uso de memoria en 4 veces”, mientras mas destacable sea la mejora mas les puede gustar escucharte, y si puedes demostrar tus resultados en términos económicos, ten por seguro que hasta los jefes de tus jefes les encantara, especialmente si son personas mas del negocio que del área técnica.
Nota: Se cuidadoso, porque cuando haces benchmark estas ejecutando tu aplicación, tus métodos muchas veces, asegúrate de estar preparado para ello, por ejemplo, si tienes código que ejecuta operaciones hacia la base de datos, asegúrate de que esta este preparada para manejar múltiples operaciones a la vez y además de no utilizar la base de datos de producción para ello, esto mismo aplica para cualquier sistema externo a nuestra aplicación (una API, por ejemplo), además de disponer de los recursos suficientes en tu equipo. No utilices BenchmarkDotNet como un sistema de pruebas de stress, no es su propósito, utilízalo especialmente para las piezas de lógica de tu aplicación, aquellas que se ejecutan por su cuenta, no lo utilices para código que requiere interacción con la UI o sistemas con interacciones complejas.
Código fuente
Si el proyecto no te compila como parte de toda la solución copia el código del archivo Program.cs
y pégalo en un proyecto aparte de la solución.
Recursos adicionales
https://dotnet.microsoft.com/en-us/platform/support/policy
Top comments (0)