Compartir a través de


Novedades del entorno de ejecución de .NET 9

En este artículo se describen las nuevas características y mejoras de rendimiento en el entorno de ejecución de .NET para .NET 9.

Modelo de atributo para modificadores de características con compatibilidad con recortes

Dos nuevos atributos permiten definir modificadores de características que las bibliotecas de .NET (y usted) pueden usar para alternar áreas de funcionalidad. Si no se admite una característica, las características no admitidas (y, por tanto, no se usan) se quitan al recortar o compilar con AOT nativo, lo que mantiene el tamaño de la aplicación más pequeño.

  • FeatureSwitchDefinitionAttribute se usa para tratar una propiedad feature-switch como una constante al recortar y el código fallido protegido por el modificador se puede quitar:

    if (Feature.IsSupported)
        Feature.Implementation();
    
    public class Feature
    {
        [FeatureSwitchDefinition("Feature.IsSupported")]
        internal static bool IsSupported => AppContext.TryGetSwitch("Feature.IsSupported", out bool isEnabled) ? isEnabled : true;
    
        internal static void Implementation() => ...;
    }
    

    Cuando la aplicación se recorta con la siguiente configuración de características en el archivo del proyecto, Feature.IsSupported se trata como falsey Feature.Implementation se quita el código.

    <ItemGroup>
      <RuntimeHostConfigurationOption Include="Feature.IsSupported" Value="false" Trim="true" />
    </ItemGroup>
    
  • FeatureGuardAttribute se usa para tratar una propiedad feature-switch como protección para el código anotado con RequiresUnreferencedCodeAttribute, RequiresAssemblyFilesAttributeo RequiresDynamicCodeAttribute. Por ejemplo:

    if (Feature.IsSupported)
        Feature.Implementation();
    
    public class Feature
    {
        [FeatureGuard(typeof(RequiresDynamicCodeAttribute))]
        internal static bool IsSupported => RuntimeFeature.IsDynamicCodeSupported;
    
        [RequiresDynamicCode("Feature requires dynamic code support.")]
        internal static void Implementation() => ...; // Uses dynamic code
    }
    

    Cuando se compila con <PublishAot>true</PublishAot>, la llamada a Feature.Implementation() no genera la advertencia del analizador IL3050 y Feature.Implementation el código se quita al publicar.

UnsafeAccessorAttribute admite parámetros genéricos

La UnsafeAccessorAttribute característica permite el acceso no seguro a los miembros de tipo que no son accesibles para el autor de la llamada. Esta característica se diseñó en .NET 8, pero se implementó sin compatibilidad con parámetros genéricos. .NET 9 agrega compatibilidad con parámetros genéricos para escenarios de CoreCLR y AOT nativos. En el código siguiente se muestra el uso de ejemplo.

using System.Runtime.CompilerServices;

public class Class<T>
{
    private T? _field;
    private void M<U>(T t, U u) { }
}

class Accessors<V>
{
    [UnsafeAccessor(UnsafeAccessorKind.Field, Name = "_field")]
    public extern static ref V GetSetPrivateField(Class<V> c);

    [UnsafeAccessor(UnsafeAccessorKind.Method, Name = "M")]
    public extern static void CallM<W>(Class<V> c, V v, W w);
}

internal class UnsafeAccessorExample
{
    public void AccessGenericType(Class<int> c)
    {
        ref int f = ref Accessors<int>.GetSetPrivateField(c);

        Accessors<int>.CallM<string>(c, 1, string.Empty);
    }
}

Recolección

La adaptación dinámica a tamaños de aplicación (DATAS) ahora está habilitada de forma predeterminada. Tiene como objetivo adaptarse a los requisitos de memoria de la aplicación, lo que significa que el tamaño del montón de la aplicación debe ser aproximadamente proporcional al tamaño de los datos de larga duración. DATAS se introdujo como una característica de participación en .NET 8 y se ha actualizado y mejorado significativamente en .NET 9.

Para obtener más información, consulte Adaptación dinámica a tamaños de aplicación (DATAS).

Tecnología de cumplimiento de flujo de control

La tecnología de cumplimiento de flujo de control (CET) está habilitada de forma predeterminada para las aplicaciones en Windows. Mejora significativamente la seguridad mediante la adición de protección de pila aplicada por hardware frente a vulnerabilidades de programación orientadas a retorno (ROP). Es la última mitigación de seguridad en tiempo de ejecución de .NET.

CET impone algunas limitaciones en los procesos habilitados para CET y puede dar lugar a una regresión de rendimiento pequeña. Hay varios controles para no participar en CET.

Comportamiento de búsqueda de instalación de .NET

Las aplicaciones .NET ahora se pueden configurar para saber cómo deben buscar el entorno de ejecución de .NET. Esta funcionalidad se puede usar con instalaciones privadas en tiempo de ejecución o para controlar más fuertemente el entorno de ejecución.

Mejoras de rendimiento

Se han realizado las siguientes mejoras de rendimiento para .NET 9:

Optimizaciones de bucles

La mejora de la generación de código para bucles es una prioridad para .NET 9. Las siguientes mejoras ya están disponibles:

Nota:

La ampliación de variables de inducción y el direccionamiento posterior a la indexación son similares: optimizan los accesos a memoria con variables de índice de bucle. Sin embargo, toman diferentes enfoques, ya que Arm64 ofrece una funcionalidad de CPU y x64 no. La ampliación de variables de inducción se implementó para x64 debido a las diferencias en la capacidad y las necesidades de CPU/ISA.

Ampliación de variables de inducción

El compilador de 64 bits incluye una nueva optimización denominada ampliación de la variable de inducción (IV).

Un IV es una variable cuyo valor cambia a medida que el bucle contenedor recorre en iteración. En el siguiente for bucle, i es un IV: for (int i = 0; i < 10; i++). Si el compilador puede analizar cómo evoluciona el valor de un IV a través de las iteraciones de su bucle, puede generar código más eficaz para expresiones relacionadas.

Considere el ejemplo siguiente que recorre en iteración una matriz:

static int Sum(int[] nums)
{
    int sum = 0;
    for (int i = 0; i < nums.Length; i++)
    {
        sum += nums[i];
    }

    return sum;
}

La variable de índice, i, tiene un tamaño de 4 bytes. En el nivel de ensamblado, los registros de 64 bits se usan normalmente para contener índices de matriz en x64 y en versiones anteriores de .NET, el compilador generó código que se extendió i cero a 8 bytes para el acceso a la matriz, pero continuó tratando i como un entero de 4 bytes en otro lugar. Sin embargo, la extensión i a 8 bytes requiere una instrucción adicional en x64. Con la ampliación iv, el compilador JIT de 64 bits ahora amplía i a 8 bytes en todo el bucle, omitiendo la extensión cero. El bucle sobre matrices es muy común y las ventajas de esta eliminación de instrucciones se suman rápidamente.

Direccionamiento posterior a la indexación en Arm64

Las variables de índice se usan con frecuencia para leer regiones secuenciales de memoria. Considere el bucle idiomático for :

static int Sum(int[] nums)
{
    int sum = 0;
    for (int i = 0; i < nums.Length; i++)
    {
        sum += nums[i];
    }

    return sum;
}

Para cada iteración del bucle, la variable i de índice se usa para leer un entero en numsy, a continuación i , se incrementa. En el ensamblado Arm64, estas dos operaciones tienen el siguiente aspecto:

ldr w0, [x1]
add x1, x1, #4

ldr w0, [x1] carga el entero en la dirección de memoria de en x1w0; esto corresponde al acceso de nums[i] en el código fuente. A continuación, add x1, x1, #4 aumenta la dirección en x1 en cuatro bytes (el tamaño de un entero), pasando al siguiente entero de nums. Esta instrucción corresponde a la i++ operación ejecutada al final de cada iteración.

Arm64 admite el direccionamiento posterior a la indexación, donde el registro de "índice" se incrementa automáticamente después de usar su dirección. Esto significa que se pueden combinar dos instrucciones en una, lo que hace que el bucle sea más eficaz. La CPU solo necesita descodificar una instrucción en lugar de dos y el código del bucle ahora es más fácil de almacenar en caché.

Este es el aspecto del ensamblado actualizado:

ldr w0, [x1], #0x04

Al #0x04 final, significa que la dirección de x1 se incrementa en cuatro bytes después de usarse para cargar un entero en w0. El compilador de 64 bits ahora usa direccionamiento posterior al generar código Arm64.

Reducción de fuerza

La reducción de la fuerza es una optimización del compilador donde una operación se reemplaza por una operación más rápida y lógicamente equivalente. Esta técnica es especialmente útil para optimizar bucles. Considere el bucle idiomático for :

static int Sum(int[] nums)
{
    int sum = 0;
    for (int i = 0; i < nums.Length; i++)
    {
        sum += nums[i];
    }

    return sum;
}

El siguiente código de ensamblado x64 muestra un fragmento de código que se genera para el cuerpo del bucle:

add ecx, dword ptr [rax+4*rdx+0x10]
inc edx

Estas instrucciones corresponden a las expresiones sum += nums[i] y i++, respectivamente. rcx (ecx contiene los 32 bits inferiores de este registro) contiene el valor de sum, rax contiene la dirección base de numsy rdx contiene el valor de i. Para calcular la dirección de nums[i], el índice de rdx se multiplica por cuatro (el tamaño de un entero). A continuación, este desplazamiento se agrega a la dirección base en rax, además de algunos rellenos. (Después de leer el entero en nums[i] , se agrega al rcx índice de rdx y se incrementa). En otras palabras, cada acceso de matriz requiere una multiplicación y una operación de suma.

La multiplicación es más costosa que la suma, y reemplazar la primera por la última es una motivación clásica para la reducción de la fuerza. Para evitar el cálculo de la dirección del elemento en cada acceso a memoria, podría volver a escribir el ejemplo para acceder a los enteros en nums mediante un puntero en lugar de una variable de índice:

static int Sum2(Span<int> nums)
{
    int sum = 0;
    ref int p = ref MemoryMarshal.GetReference(nums);
    ref int end = ref Unsafe.Add(ref p, nums.Length);
    while (Unsafe.IsAddressLessThan(ref p, ref end))
    {
        sum += p;
        p = ref Unsafe.Add(ref p, 1);
    }

    return sum;
}

El código fuente es más complicado, pero es lógicamente equivalente a la implementación inicial. Además, el ensamblado tiene un aspecto mejor:

add ecx, dword ptr [rdx]
add rdx, 4

rcx (ecx contiene los 32 bits inferiores de este registro) todavía contiene el valor de sum, pero rdx ahora contiene la dirección a la que apunta , por lo que el acceso a plos elementos en nums solo requiere que desreferenciamos rdx. Todas las multiplicaciones y sumas del primer ejemplo se han reemplazado por una sola add instrucción para mover el puntero hacia delante.

En .NET 9, el compilador JIT transforma automáticamente el primer patrón de indexación en el segundo sin necesidad de volver a escribir ningún código.

Dirección de variable del contador de bucles

El compilador de 64 bits ahora reconoce cuando la variable de contador de un bucle solo se usa para controlar el número de iteraciones y transforma el bucle para contar hacia abajo en lugar de hacia arriba.

En el patrón idiomático for (int i = ...) , la variable de contador suele aumentar. Considere el ejemplo siguiente:

for (int i = 0; i < 100; i++)
{
    DoSomething();
}

Sin embargo, en muchas arquitecturas, es más eficaz reducir el contador del bucle, como así:

for (int i = 100; i > 0; i--)
{
    DoSomething();
}

En el primer ejemplo, el compilador debe emitir una instrucción para incrementar i, seguida de una instrucción para realizar la i < 100 comparación, seguida de un salto condicional para continuar el bucle si la condición sigue siendo true, es decir, tres instrucciones en total. Sin embargo, si se voltea la dirección del contador, se necesita una instrucción menos. Por ejemplo, en x64, el compilador puede usar la dec instrucción para disminuir i; cuando i alcanza cero, la dec instrucción establece una marca de CPU que se puede usar como condición para una instrucción de salto inmediatamente después de dec.

La reducción del tamaño de código es pequeña, pero si el bucle se ejecuta para un número notrivial de iteraciones, la mejora del rendimiento puede ser significativa.

Mejoras de procesamiento en línea

Uno de . Los objetivos de NET para el insertador del compilador JIT es quitar tantas restricciones que bloqueen la inserción de un método como sea posible. .NET 9 habilita la inserción de:

  • Genéricos compartidos que requieren búsquedas en tiempo de ejecución.

    Por ejemplo, tenga en cuenta los métodos siguientes:

    static bool Test<T>() => Callee<T>();
    static bool Callee<T>() => typeof(T) == typeof(int);
    

    Cuando T es un tipo de referencia como string, el entorno de ejecución crea genéricos compartidos, que son instancias especiales de Test y Callee que comparten todos los tipos de tipo T ref. Para que esto funcione, el entorno de ejecución compila diccionarios que asignan tipos genéricos a tipos internos. Estos diccionarios son especializados por tipo genérico (o por método genérico), y se accede a ellos en tiempo de ejecución para obtener información sobre T y los tipos que dependen de T. Históricamente, el código compilado Just-In-Time solo era capaz de realizar estas búsquedas en tiempo de ejecución en el diccionario del método raíz. Esto significaba que el compilador JIT no podía insertarse Callee en Test, no había ninguna manera de que el código insertado acceda Callee al diccionario adecuado, aunque se crearon instancias de ambos métodos sobre el mismo tipo.

    .NET 9 ha elevado esta restricción habilitando libremente las búsquedas de tipos en tiempo de ejecución en las llamadas, lo que significa que el compilador JIT ahora puede insertar métodos como Callee en Test.

    Supongamos que llamamos Test<string> a en otro método. En pseudocódigo, la inserción tiene el siguiente aspecto:

    static bool Test<string>() => typeof(string) == typeof(int);
    

    Esa comprobación de tipo se puede calcular durante la compilación, por lo que el código final tiene este aspecto:

    static bool Test<string>() => false;
    

    Las mejoras en el insertador del compilador JIT pueden tener efectos compuestos en otras decisiones de inserción, lo que da lugar a importantes victorias de rendimiento. Por ejemplo, la decisión de insertar Callee podría permitir que también se inserte la llamada Test<string> , etc. Esto produjo cientos de mejoras comparativas, con al menos 80 pruebas comparativas que mejoran en 10% o más.

  • Obtiene acceso a estáticas locales de subprocesos en Windows x64, Linux x64 y Linux Arm64.

    Para static los miembros de clase, existe exactamente una instancia del miembro en todas las instancias de la clase , que "comparten" el miembro. Si el valor de un static miembro es único para cada subproceso, hacer que ese valor local de subproceso mejore el rendimiento, ya que elimina la necesidad de que un primitivo de simultaneidad acceda de forma segura al static miembro desde su subproceso contenedor.

    Anteriormente, los accesos a estáticos locales de subprocesos en programas compilados con AOT nativos requerían que el compilador emita una llamada al entorno de ejecución para obtener la dirección base del almacenamiento local del subproceso. Ahora, el compilador puede insertar estas llamadas, lo que da lugar a muchas menos instrucciones para acceder a estos datos.

Mejoras de PGO: comprobaciones de tipos y conversiones

Optimización dinámica guiada por perfiles (PGO) habilitada para .NET 8 de forma predeterminada. NET 9 expande la implementación de PGO del compilador JIT para generar perfiles de más patrones de código. Cuando la compilación en capas está habilitada, el compilador JIT ya inserta instrumentación en el programa para generar perfiles de su comportamiento. Cuando se vuelve a compilar con optimizaciones, el compilador aprovecha el perfil que creó en tiempo de ejecución para tomar decisiones específicas de la ejecución actual del programa. En .NET 9, el compilador JIT usa datos PGO para mejorar el rendimiento de las comprobaciones de tipos.

La determinación del tipo de un objeto requiere una llamada al tiempo de ejecución, lo que conlleva una penalización de rendimiento. Cuando es necesario comprobar el tipo de un objeto, el compilador JIT emite esta llamada por motivos de corrección (los compiladores normalmente no pueden descartar ninguna posibilidad, aunque parezcan improbables). Sin embargo, si los datos de PGO sugieren que un objeto es probable que sea un tipo específico, el compilador JIT ahora emite una ruta de acceso rápida que comprueba de forma barata ese tipo y vuelve a la ruta de acceso lenta de llamar al entorno de ejecución solo si es necesario.

Vectorización arm64 en bibliotecas de .NET

Una nueva EncodeToUtf8 implementación aprovecha la capacidad del compilador JIT para emitir instrucciones de carga y almacenamiento de varios registros en Arm64. Este comportamiento permite a los programas procesar fragmentos de datos más grandes con menos instrucciones. Las aplicaciones .NET en varios dominios deben ver mejoras de rendimiento en el hardware arm64 que admite estas características. Algunos puntos de referencia reducen su tiempo de ejecución en más de la mitad.

Generación de código Arm64

El compilador JIT ya tiene la capacidad de transformar su representación de cargas contiguas para usar la ldp instrucción (para cargar valores) en Arm64. .NET 9 amplía esta capacidad para almacenar operaciones.

La str instrucción almacena datos de un único registro en la memoria, mientras que la stp instrucción almacena los datos de un par de registros. El uso stp en lugar de significa que la misma tarea se puede realizar con menos operaciones de almacenamiento, lo que mejora el tiempo de str ejecución. El afeitado de una instrucción puede parecer una pequeña mejora, pero si el código se ejecuta en un bucle para un número notrivial de iteraciones, las ganancias de rendimiento pueden agregarse rápidamente.

Por ejemplo, considere el siguiente fragmento de código:

class Body { public double x, y, z, vx, vy, vz, mass; }

static void Advance(double dt, Body[] bodies)
{
    foreach (Body b in bodies)
    {
        b.x += dt * b.vx;
        b.y += dt * b.vy;
        b.z += dt * b.vz;
    }
}

Los valores de b.x, b.yy b.z se actualizan en el cuerpo del bucle. En el nivel de ensamblado, cada miembro se puede almacenar con una str instrucción; o bien usar stp, dos de los almacenes (b.x y b.y, o b.y , b.zporque estos pares son contiguos en memoria) se pueden controlar con una instrucción. Para usar la stp instrucción para almacenar b.x en y b.y al mismo tiempo, el compilador también debe determinar que los cálculos b.x + (dt * b.vx) y b.y + (dt * b.vy) son independientes entre sí y se pueden realizar antes de almacenar en b.x y b.y.

Excepciones más rápidas

El entorno de ejecución de CoreCLR ha adoptado un nuevo enfoque de control de excepciones que mejora el rendimiento del control de excepciones. La nueva implementación se basa en el modelo de control de excepciones del entorno de ejecución de NativeAOT. El cambio quita la compatibilidad con el control de excepciones estructurados de Windows (SEH) y su emulación en Unix. El nuevo enfoque se admite en todo el entorno, excepto Windows x86 (32 bits).

La nueva implementación de control de excepciones es de 2 a 4 veces más rápida, por algunas micro benchmarks de control de excepciones. Las siguientes mejoras de rendimiento se midieron en el laboratorio de rendimiento:

La nueva implementación está habilitada de forma predeterminada. Sin embargo, si necesita volver al comportamiento de control de excepciones heredado, puede hacerlo de cualquiera de las maneras siguientes:

  • Establézcalo System.Runtime.LegacyExceptionHandling en en el true .runtimeconfig.json
  • Establezca la variable DOTNET_LegacyExceptionHandlingde 1 entorno en .

Diseño de código

Los compiladores suelen razonar sobre el flujo de control de un programa mediante bloques básicos, donde cada bloque es un fragmento de código que solo se puede escribir en la primera instrucción y salir a través de la última instrucción. El orden de los bloques básicos es importante. Si un bloque termina con una instrucción de rama, el flujo de control se transfiere a otro bloque. Un objetivo de la reordenación de bloques es reducir el número de instrucciones de rama en el código generado maximizando el comportamiento de caída. Si cada bloque básico va seguido de su sucesor más probable, puede "caer" en su sucesor sin necesidad de un salto.

Hasta hace poco, el reordenamiento de bloques en el compilador JIT estaba limitado por la implementación del diagrama de flujo. En .NET 9, el algoritmo de reordenación de bloques del compilador JIT se ha reemplazado por un enfoque más sencillo y global. Las estructuras de datos de flowgraph se han refactorizado para:

  • Quite algunas restricciones en torno al orden de bloqueo.
  • Ingrain execution probabilidades en cada cambio de flujo de control entre bloques.

Además, los datos de perfil se propagan y mantienen a medida que se transforma el flowgraph del método.

Reducción de la exposición de direcciones

En .NET 9, el compilador JIT puede realizar un seguimiento mejor del uso de direcciones de variables locales y evitar la exposición innecesaria a las direcciones.

Cuando se usa la dirección de una variable local, el compilador JIT debe tomar precauciones adicionales al optimizar el método. Por ejemplo, supongamos que el compilador está optimizando un método que pasa la dirección de una variable local en una llamada a otro método. Dado que el destinatario podría usar la dirección para acceder a la variable local, para mantener la corrección, el compilador evita transformar la variable. Las variables locales expuestas a la dirección pueden impedir significativamente el potencial de optimización del compilador.

Compatibilidad con AVX10v1

Se han agregado nuevas API para AVX10, que es un nuevo conjunto de instrucciones SIMD de Intel. Puede acelerar las aplicaciones .NET en hardware habilitado para AVX10 con operaciones vectorizadas mediante las nuevas Avx10v1 API.

Generación de código intrínseco de hardware

Muchas API intrínsecas de hardware esperan que los usuarios pasen valores constantes para determinados parámetros. Estas constantes se codifican directamente en la instrucción subyacente del intrínseco, en lugar de cargarse en registros o acceder a ellas desde la memoria. Si no se proporciona una constante, el intrínseco se reemplaza por una llamada a una implementación de reserva que es funcionalmente equivalente, pero más lenta.

Considere el ejemplo siguiente:

static byte Test1()
{
    Vector128<byte> v = Vector128<byte>.Zero;
    const byte size = 1;
    v = Sse2.ShiftRightLogical128BitLane(v, size);
    return Sse41.Extract(v, 0);
}

El uso de size en la llamada a Sse2.ShiftRightLogical128BitLane se puede sustituir por la constante 1 y, en circunstancias normales, el compilador JIT ya es capaz de esta optimización de sustitución. Pero al determinar si se va a generar el código acelerado o de reserva para Sse2.ShiftRightLogical128BitLane, el compilador detecta que se pasa una variable en lugar de una constante y decide prematuramente contra la "intrinsificación" de la llamada. A partir de .NET 9, el compilador reconoce más casos como este y sustituye el argumento variable por su valor constante, lo que genera el código acelerado.

Plegado constante para las operaciones de punto flotante y SIMD

El plegado constante es una optimización existente en el compilador JIT. El plegado constante hace referencia a la sustitución de expresiones que se pueden calcular en tiempo de compilación con las constantes que evalúan, lo que elimina los cálculos en tiempo de ejecución. .NET 9 agrega nuevas funcionalidades de plegado de constantes:

  • Para las operaciones binarias de punto flotante, donde uno de los operandos es una constante:
    • x + NaN ahora se dobla en NaN.
    • x * 1.0 ahora se dobla en x.
    • x + -0 ahora se dobla en x.
  • Para los intrínsecos de hardware. Por ejemplo, suponiendo x que es :Vector<T>
    • x + Vector<T>.Zero ahora se dobla en x.
    • x & Vector<T>.Zero ahora se dobla en Vector<T>.Zero.
    • x & Vector<T>.AllBitsSet ahora se dobla en x.

Compatibilidad con Arm64 SVE

.NET 9 presenta compatibilidad experimental con la extensión de vector escalable (SVE), un conjunto de instrucciones SIMD para CPU ARM64. .NET ya admite el conjunto de instrucciones NEON, por lo que en el hardware compatible con NEON, las aplicaciones pueden aprovechar los registros vectoriales de 128 bits. SVE admite longitudes de vector flexibles hasta 2048 bits, desbloqueando más procesamiento de datos por instrucción. En .NET 9, Vector<T> tiene un ancho de 128 bits al dirigirse a SVE y el trabajo futuro permitirá escalar su ancho para que coincida con el tamaño del registro de vectores de la máquina de destino. Puede acelerar las aplicaciones de .NET en hardware compatible con SVE mediante las nuevas System.Runtime.Intrinsics.Arm.Sve API.

Nota:

La compatibilidad con SVE en .NET 9 es experimental. Las API en System.Runtime.Intrinsics.Arm.Sve se marcan con ExperimentalAttribute, lo que significa que están sujetas a cambios en futuras versiones. Además, es posible que los puntos de interrupción del depurador y los puntos de interrupción a través del código generado por SVE no funcionen correctamente, lo que da lugar a bloqueos de la aplicación o daños en los datos.

Asignación de pila de objetos para cuadros

Los tipos de valor, como int y struct, normalmente se asignan en la pila en lugar del montón. Sin embargo, para habilitar varios patrones de código, se suelen "boxear" en objetos.

Tenga en cuenta el siguiente fragmento de código:

static bool Compare(object? x, object? y)
{
    if ((x == null) || (y == null))
    {
        return x == y;
    }

    return x.Equals(y);
}

public static int RunIt()
{
    bool result = Compare(3, 4);
    return result ? 0 : 100;
}

Compare se escribe convenientemente de modo que si desea comparar otros tipos, como cadenas o double valores, podría reutilizar la misma implementación. Sin embargo, en este ejemplo, también tiene el inconveniente de rendimiento de requerir que los tipos de valor que se le pasen a boxear.

El código de ensamblado x64 generado para RunIt es el siguiente:

push     rbx
sub      rsp, 32
mov      rcx, 0x7FFB9F8074D0      ; System.Int32
call     CORINFO_HELP_NEWSFAST
mov      rbx, rax
mov      dword ptr [rbx+0x08], 3
mov      rcx, 0x7FFB9F8074D0      ; System.Int32
call     CORINFO_HELP_NEWSFAST
mov      dword ptr [rax+0x08], 4
add      rbx, 8
mov      ecx, dword ptr [rbx]
cmp      ecx, dword ptr [rax+0x08]
sete     al
movzx    rax, al
xor      ecx, ecx
mov      edx, 100
test     eax, eax
mov      eax, edx
cmovne   eax, ecx
add      rsp, 32
pop      rbx
ret

Las llamadas a CORINFO_HELP_NEWSFAST son las asignaciones del montón para los argumentos enteros boxed. Además, tenga en cuenta que no hay ninguna llamada a Compare; el compilador decidió insertarla en RunIt. Esta inserción significa que los cuadros nunca "escapen". En otras palabras, a lo largo de la ejecución de Compare, sabe x y y son realmente enteros, y se pueden desencapsular de forma segura sin afectar a la lógica de comparación.

A partir de .NET 9, el compilador de 64 bits asigna cuadros sin escape en la pila, lo que desbloquea otras optimizaciones. En este ejemplo, el compilador ahora omite las asignaciones del montón, pero dado que conoce x y y son 3 y 4, también puede omitir el cuerpo de Compare; el compilador puede determinar x.Equals(y) es false en tiempo de compilación, por lo que RunIt siempre debe devolver 100. Este es el ensamblado actualizado:

mov      eax, 100
ret