Desmentiendo el mito sobre el rendimiento de las estructuras en C# / Sudo Null IT News

De forma predeterminada, al pasar o regresar de un método, las instancias de tipos de valor se copian mientras que las instancias de tipos de referencia se pasan por referencia. En 2008, se publicó el libro “Pautas de diseño de marcos: convenciones, modismos y patrones para bibliotecas .NET reutilizables”. En este libro recomendado no utilice estructuras de más de 16 bytes, ya que, obviamente, las estructuras más grandes se copian más lentamente. Aunque han pasado 16 años, todavía existe la creencia popular en la comunidad de desarrolladores de C# de que el rendimiento de estructuras de más de 16 bytes es peor. Incluso Google para la consulta “tamaño de estructura recomendado c#” dice que no tiene más de 16 bytes. En este artículo intentaremos llegar al fondo de la verdad.

Descargo de responsabilidad

La información contenida en este artículo sólo es correcta bajo ciertas condiciones. Acepto que el benchmark pueda mostrar resultados diferentes en una PC diferente, con una CPU diferente, con un compilador diferente o usando clases y estructuras diferentes. Pruebe siempre su código en su hardware específico y no confíe únicamente en artículos de Internet. 🙂

Punto de referencia

en el punto de referencia muy simple. Contiene estructuras y clases que varían en tamaño de 4 a 160 bytes, en incrementos de 4 bytes.

public record struct Struct04(int Param);

public record struct Struct160(
    int Param1, int Param2, int Param3, int Param4, 
    int Param5, int Param6, int Param7, int Param8, 
    int Param9, int Param10, int Param11, int Param12, 
    int Param13, int Param14, int Param15, int Param16, 
    int Param17, int Param18, int Param19, int Param20, 
    int Param21, int Param22, int Param23, int Param24, 
    int Param25, int Param26, int Param27, int Param28,
    int Param29, int Param30, int Param31, int Param32, 
    int Param33, int Param34, int Param35, int Param36, 
    int Param37, int Param38, int Param39, int Param40);

public record struct Class04(int Param);

public record class Class160(
    int Param1, int Param2, int Param3, int Param4, 
    int Param5, int Param6, int Param7, int Param8, 
    int Param9, int Param10, int Param11, int Param12,
    int Param13, int Param14, int Param15, int Param16, 
    int Param17, int Param18, int Param19, int Param20, 
    int Param21, int Param22, int Param23, int Param24, 
    int Param25, int Param26, int Param27, int Param28,
    int Param29, int Param30, int Param31, int Param32, 
    int Param33, int Param34, int Param35, int Param36, 
    int Param37, int Param38, int Param39, int Param40);

Para cada estructura y clase existe un método correspondiente, que a partir del parámetro tipo int crea la instancia correspondiente y la devuelve.

public static Struct20 GetStruct20(int value) => new(value, value, value, value, value);

public static Class20 GetClass20(int value) => new(value, value, value, value, value);

Y, lo más importante, existen métodos de referencia directamente, cada uno de los cuales crea una lista de estructuras o clases. El tamaño de la lista es de 1000 elementos.

public int Iterations { get; set; } = 1000;

private static void Add<T>(List<T> list, T value) => list.Add(value);

(Benchmark(Baseline = true))
public List<Struct04> GetStruct4()
{
    var list = new List<Struct04>(Iterations);
    for (int i = 0; i < Iterations; i++) Add(list, GetStruct04(i));
    return list;
}

(Benchmark(Baseline = true))
public List<Class04> GetClass4()
{
    var list = new List<Class04>(Iterations);
    for (int i = 0; i < Iterations; i++) Add(list, GetClass04(i));
    return list;
}

Para mediciones de rendimiento utilicé la biblioteca. Punto de referenciaDotNet.

resultados

Medidas de tiempo

Como puede ver en el gráfico, crear estructuras que no superen los 64 bytes es más rápido que crear instancias de clase.

Tiempos de ejecución de referencia absolutos (izquierda) y relativos (derecha).

Tiempos de ejecución de referencia absolutos (izquierda) y relativos (derecha).

Si amplía el gráfico, notará que el uso de estructuras es entre un 40% y un 70% más rápido.

Tiempos de ejecución de referencia absolutos (izquierda) y relativos (derecha).

Tiempos de ejecución de referencia absolutos (izquierda) y relativos (derecha).

Para comprender por qué CLR se comporta como lo hace, debemos profundizar en el código en el que se compila C#. Si miras Código IL para los métodos GetStruct64 y GetStruct128entonces podrás notar que las diferencias están solo en los nombres.

Código IL para los métodos GetStruct64 y GetStruct128

Código IL para los métodos GetStruct64 y GetStruct128

Esto significa que la razón es otra y necesitamos profundizar aún más, concretamente en el código de máquina generado por el compilador JIT. Afortunadamente, la biblioteca BenchmarkDotNet tiene la funcionalidad necesaria para esto. Comparando el código de máquina, puedes ver que el método GetStruct64(int value) no llamado. En cambio, se realizan muchas operaciones. mov (mov mueve datos entre registros y memoria). Resulta que estas operaciones se inicializan Struct64. ¿Pero a dónde fue la llamada al método? GetStruct64(int value)? El compilador JIT optimizó nuestro código y reemplazó la llamada al método con su cuerpo. Esta optimización se conoce como función en línea o método en línea. Gracias a esta optimización, el CLR no tuvo que copiar la instancia de la estructura al regresar de un método.

Código de máquina para los métodos GetStruct64 y GetStruct128

Código de máquina para los métodos GetStruct64 y GetStruct128

Otra observación interesante es que no se asignó ninguna memoria para las estructuras de la pila. Aunque es poco probable que esto sea una revelación para los desarrolladores experimentados de C#. El compilador JIT optimizó nuevamente el código para que solo se usaran registros, incluso para estructuras de 128 bytes y mayores (gracias a los registros AVX-256).

Medidas de memoria

Los gráficos de uso de memoria son fluidos y sin saltos bruscos. Esto sugiere que la diferencia entre la cantidad de memoria asignada para clases y estructuras es constante. El código que utiliza estructuras de no más de 64 bytes consume entre un 27% y un 87% menos de memoria.

Valor absoluto (izquierda) y relativo (derecha) de la memoria asignada para 1 operación.

Valor absoluto (izquierda) y relativo (derecha) de la memoria asignada para 1 operación.

Conclusión

En este artículo, verificamos que el uso de estructuras de más de 16 bytes no degrada el rendimiento. Actualmente este límite es de 64 bytes. El compilador JIT es lo suficientemente inteligente como para optimizar el código en tiempo de ejecución, por lo que bajo ciertas condiciones las estructuras son mejores que las clases, tanto en términos de tiempo de ejecución como de consumo de memoria.

Publicaciones Similares

Deja una respuesta

Tu dirección de correo electrónico no será publicada. Los campos obligatorios están marcados con *