← maurobernal.com.ar

Etiqueta: span

  • Rendimiento extremo en .NET 10: Stack allocation, Native AOT y el GC que trabaja menos

    El rendimiento de .NET siempre fue bueno. Pero en los últimos dos años, con .NET 9 y .NET 10, el equipo fue más allá: Stack allocation inteligente para objetos pequeños, Native AOT que genera binarios sin runtime, y un GC que hace menos trabajo porque el runtime hace más con menos memoria. En APIs que proceso millones de requests por día, estos cambios se notan.

    Stack vs Heap: la distinción que importa para el rendimiento

    El Garbage Collector de .NET es excelente, pero tiene un costo: cada objeto en el heap eventual­mente debe ser recolectado. La forma más eficiente de evitar ese costo es no poner cosas en el heap en primer lugar. .NET 10 hace esto automáticamente en más situaciones.

    // .NET 10: el runtime puede asignar arrays pequeños en el stack automáticamente
    // cuando el compilador puede probar que no "escapan" del scope
    
    // Caso 1: array de tamaño conocido en compilación que no escapa
    void ProcesarLote()
    {
        // .NET 10 puede convertir esto en stack allocation transparentemente
        int[] buffer = new int[8];  // pequeño, scope limitado
        for (int i = 0; i < buffer.Length; i++)
            buffer[i] = i * 2;
        // buffer no escapa a este método → candidato a stack allocation
    }
    
    // Caso 2: stackalloc explícito para control total
    void ProcesarDatos(ReadOnlySpan<byte> datos)
    {
        // Siempre en el stack, sin GC pressure
        Span<byte> buffer = stackalloc byte[256];
        datos.CopyTo(buffer);
        // Procesamiento...
    }
    
    // Caso 3: ArrayPool para buffers grandes que SÍ van al heap
    // (cuando stackalloc no alcanza)
    var pool = ArrayPool<byte>.Shared;
    byte[] rentado = pool.Rent(4096);
    try
    {
        // usar rentado...
    }
    finally
    {
        pool.Return(rentado);  // devolver al pool en lugar de GC
    }

    Native AOT: binarios sin runtime instalado

    Native AOT (Ahead-Of-Time compilation) compila tu aplicación .NET a código nativo — un ejecutable que no requiere el runtime de .NET instalado. En .NET 10, el soporte mejoró significativamente: más APIs compatibles, binarios más pequeños y arranque más rápido.

    # Publicar con Native AOT
    # En el .csproj:
    <PropertyGroup>
      <PublishAot>true</PublishAot>
      <AllowUnsafeBlocks>true</AllowUnsafeBlocks>
    </PropertyGroup>
    
    # Publicar para Linux x64
    dotnet publish -r linux-x64 -c Release
    
    # Resultado típico en .NET 10:
    # Tamaño del binario: ~8-12 MB (vs ~200 MB con runtime incluido)
    # Tiempo de arranque: ~5ms (vs ~100-300ms con JIT)
    # Memoria RSS inicial: ~15 MB (vs ~50 MB con JIT)

    Las limitaciones de Native AOT siguen existiendo — no soporta reflection dinámica, ciertas operaciones de serialización requieren source generators — pero en .NET 10 el porcentaje de código compatible aumentó considerablemente. Mis Minimal APIs y Workers services migran sin problemas.

    Mejoras del GC en .NET 10

    // Configurar el GC para workloads específicos
    // En appsettings.json o como variables de entorno:
    
    // Para APIs de alta frecuencia (latencia baja):
    // DOTNET_GCConserveMemory=0 (default — más GC, menos memoria)
    
    // Para servicios de larga duración (memoria controlada):
    // DOTNET_GCConserveMemory=9 (agresivo en liberar memoria)
    
    // Programmaticamente:
    GCSettings.LatencyMode = GCLatencyMode.SustainedLowLatency; // Para APIs
    
    // .NET 10: Dynamic Heap Regions
    // El GC puede ajustar el tamaño de las regiones del heap en tiempo de ejecución
    // según los patrones de uso observados — sin configuración manual

    Tensor Primitives: operaciones SIMD de alto nivel

    Introducido en .NET 9 y expandido en .NET 10, System.Numerics.TensorPrimitives expone operaciones vectorizadas (SIMD) con una API de alto nivel. Útil para procesamiento de señales, ML, operaciones financieras en bulk.

    using System.Numerics.Tensors;
    
    float[] precios = [100f, 200f, 150f, 300f, 250f];
    float[] descuentos = [0.1f, 0.2f, 0.15f, 0.05f, 0.25f];
    float[] preciosFinales = new float[precios.Length];
    
    // Multiplicación vectorizada — usa SIMD automáticamente
    TensorPrimitives.Multiply(precios, descuentos, preciosFinales);
    
    // Suma de todos los elementos — también vectorizada
    float sumaTotal = TensorPrimitives.Sum(precios);
    
    // Operaciones estadísticas
    float maximo = TensorPrimitives.Max(precios);
    float minimo = TensorPrimitives.Min(precios);

    Números reales de mis APIs

    Métrica.NET 8.NET 10Mejora
    Memoria RSS (pod idle)~85 MB~65 MB~24% menos
    P99 latencia (API simple)~12 ms~9 ms~25% menos
    Throughput (req/s)~45.000~58.000~29% más
    Tiempo arranque cold~280 ms~200 ms~28% menos

    Estos números son de una API de gestión interna con carga moderada. En workloads más intensivos los delta son mayores. En workloads livianos, menores. Pero la dirección es siempre la misma: .NET 10 hace más con menos.


    ← LINQ en .NET 9 y .NET 10: CountBy, AggregateBy, Index() y las mejoras que cambian cómo consultás datos | Serie .NET 8 → .NET 10 | Próximo: dotnet run script.cs y las nuevas herramientas de .NET 10: OpenAPI, HttpClient y más →

  • C# 13: params modernos, Lock de primera clase y Task.WhenEach

    C# 13 con .NET 9 llegó con mejoras que parecen pequeñas pero cambian patrones que usamos hace años. Los params con Span eliminan allocations que ni sabíamos que estábamos haciendo. El Lock de primera clase hace que el threading sea más seguro. Y Task.WhenEach resuelve un patrón asíncrono que antes requería código contorsionado.

    params ReadOnlySpan<T>: cero allocations en métodos variádicos

    Antes de C# 13, params solo funcionaba con arrays. Cada vez que llamabas a un método con params int[], el compilador creaba un array en el heap aunque los datos pudieran vivir perfectamente en el stack. C# 13 cambia eso.

    // ❌ C# 12 y anteriores: params siempre crea un array en el heap
    public static int Sumar(params int[] numeros)
    {
        int total = 0;
        foreach (var n in numeros) total += n;
        return total;
    }
    
    // Cada llamada alloca un int[] en el heap:
    int r1 = Sumar(1, 2, 3);      // new int[] { 1, 2, 3 }
    int r2 = Sumar(10, 20);        // new int[] { 10, 20 }
    
    // ✅ C# 13: params con ReadOnlySpan — cero allocations
    public static int Sumar(params ReadOnlySpan<int> numeros)
    {
        int total = 0;
        foreach (var n in numeros) total += n;
        return total;
    }
    
    // El compilador puede poner los valores directamente en el stack
    int r1 = Sumar(1, 2, 3);   // Sin heap allocation
    int r2 = Sumar(10, 20);     // Sin heap allocation
    
    // También funciona con otros tipos de colección
    public static void LogTodos(params IEnumerable<string> mensajes) { ... }
    public static T Primero<T>(params ReadOnlySpan<T> items) => items[0];

    System.Threading.Lock: el tipo de primera clase para sincronización

    Durante años, el patrón estándar para sincronizar hilos en C# era lock (new object()). Funcionaba, pero tenía un problema: object no comunica la intención. Cualquier referencia podía usarse accidentalmente como lock. System.Threading.Lock resuelve eso con un tipo dedicado que el compilador trata de forma especial.

    // ❌ Antes: object genérico como lock
    public class Cache
    {
        private readonly object _lock = new object();
        private Dictionary<string, object> _datos = new();
    
        public void Agregar(string clave, object valor)
        {
            lock (_lock)
            {
                _datos[clave] = valor;
            }
        }
    
        public bool TryGet(string clave, out object? valor)
        {
            lock (_lock)
            {
                return _datos.TryGetValue(clave, out valor);
            }
        }
    }
    
    // ✅ C# 13 / .NET 9: System.Threading.Lock
    public class Cache
    {
        // Semánticamente claro: esto ES un lock, no cualquier objeto
        private readonly Lock _lock = new();
        private Dictionary<string, object> _datos = new();
    
        public void Agregar(string clave, object valor)
        {
            // El compilador emite código optimizado para Lock
            // Usa el patrón Dispose internamente (EnterScope)
            lock (_lock)
            {
                _datos[clave] = valor;
            }
        }
    
        // También disponible: API explícita para casos avanzados
        public bool TryAgregar(string clave, object valor)
        {
            using (_lock.EnterScope())
            {
                if (_datos.ContainsKey(clave)) return false;
                _datos[clave] = valor;
                return true;
            }
        }
    }

    El compilador emite una advertencia si intentás pasar un Lock como object a otro método — protección contra el antipatrón de usar el mismo lock para múltiples propósitos.

    Task.WhenEach: procesar tareas a medida que terminan

    Tenía un patrón que repetía en muchos proyectos: lanzar varias tareas en paralelo y procesar cada resultado apenas llegaba, sin esperar a que todas terminen. Antes requería Task.WhenAny en un loop con manejo manual. Task.WhenEach lo vuelve trivial.

    // ❌ Antes: patrón verbose con Task.WhenAny
    var tareas = new List<Task<Reporte>> { 
        ObtenerReporteAsync("ventas"), 
        ObtenerReporteAsync("stock"), 
        ObtenerReporteAsync("clientes") 
    };
    
    var pendientes = new HashSet<Task<Reporte>>(tareas);
    while (pendientes.Count > 0)
    {
        var completada = await Task.WhenAny(pendientes);
        pendientes.Remove(completada);
        var reporte = await completada;
        Console.WriteLine($"Reporte listo: {reporte.Nombre}");
    }
    
    // ✅ C# 13 / .NET 9: Task.WhenEach — elegante y eficiente
    var tareas = new List<Task<Reporte>> { 
        ObtenerReporteAsync("ventas"), 
        ObtenerReporteAsync("stock"), 
        ObtenerReporteAsync("clientes") 
    };
    
    await foreach (var completada in Task.WhenEach(tareas))
    {
        var reporte = await completada;
        Console.WriteLine($"Reporte listo: {reporte.Nombre}");
        // Procesa cada reporte apenas llega, sin bloquear a los demás
    }

    LINQ: CountBy, AggregateBy e Index

    var pedidos = new[] {
        new { Cliente = "Ana",  Monto = 100m },
        new { Cliente = "Juan", Monto = 200m },
        new { Cliente = "Ana",  Monto = 150m },
        new { Cliente = "Juan", Monto = 300m },
    };
    
    // CountBy: contar elementos agrupados por clave
    var pedidosPorCliente = pedidos.CountBy(p => p.Cliente);
    // { "Ana": 2, "Juan": 2 }
    
    // AggregateBy: acumular valores agrupados por clave
    var totalPorCliente = pedidos.AggregateBy(
        p => p.Cliente,
        seed: 0m,
        (acum, p) => acum + p.Monto
    );
    // { "Ana": 250, "Juan": 500 }
    
    // Index(): obtener índice sin usar Select((x, i) => ...)
    foreach (var (indice, pedido) in pedidos.Index())
    {
        Console.WriteLine($"Pedido #{indice}: {pedido.Cliente} - ${pedido.Monto}");
    }

    Nuevos miembros partial (C# 13)

    // C# 13: propiedades y constructores pueden ser partial
    // Muy útil con source generators (EF Core, Roslyn generators)
    
    public partial class Entidad
    {
        // Declaración en el archivo principal
        public partial string Nombre { get; set; }
        public partial void OnNombreCambiado();
    }
    
    public partial class Entidad
    {
        // Implementación en el archivo generado (o viceversa)
        public partial string Nombre
        {
            get => field;
            set { field = value; OnNombreCambiado(); }
        }
        
        public partial void OnNombreCambiado() 
            => Console.WriteLine($"Nombre cambió a: {Nombre}");
    }

    ← C# 12: Primary Constructors, Collection Expressions y el código que debería haber existido siempre | Serie .NET 8 → .NET 10 | Próximo: La palabra clave field en C# 14: adiós para siempre a los backing fields repetitivos →

Tags

tsql (27)mssql (26)sql (20)devops (20)dotnet (18)docker (15)performance (14)contenedores (11)dotnet10 (10)linux (9)csharp (8)microservicios (7)angular (7)angular21 (7)sql server (6)issabel (6)docker-compose (6)typescript (6)mysql (5).NET (5)