Nota:
El acceso a esta página requiere autorización. Puede intentar iniciar sesión o cambiar directorios.
El acceso a esta página requiere autorización. Puede intentar cambiar los directorios.
En este artículo se describen las nuevas características y mejoras de rendimiento en el entorno de ejecución de .NET para .NET 10.
Mejoras del compilador JIT
El compilador JIT de .NET 10 incluye mejoras significativas que mejoran el rendimiento a través de mejores estrategias de generación y optimización de código.
Generación de código mejorada para argumentos de estructura
. El compilador JIT de NET es capaz de una optimización denominada promoción física, donde los miembros de una estructura se colocan en registros en lugar de en la pila, lo que elimina los accesos a memoria. Esta optimización es especialmente útil al pasar una estructura a un método, y la convención de llamada requiere que los miembros de la estructura se pasen a los registros.
.NET 10 mejora la representación interna del compilador JIT para controlar los valores que comparten un registro. Anteriormente, cuando los miembros de la estructura necesitaban empaquetarse en un único registro, el JIT almacenaría los valores en la memoria en primer lugar y, a continuación, los cargaría en un registro. Ahora, el compilador JIT puede colocar los miembros promocionados de argumentos de estructura en registros compartidos directamente, lo que elimina las operaciones de memoria innecesarias.
Considere el ejemplo siguiente:
struct Point
{
public long X;
public long Y;
public Point(long x, long y)
{
X = x;
Y = y;
}
}
[MethodImpl(MethodImplOptions.NoInlining)]
private static void Consume(Point p)
{
Console.WriteLine(p.X + p.Y);
}
private static void Main()
{
Point p = new Point(10, 20);
Consume(p);
}
En x64, los miembros de Point se pasan a Consume en registros independientes y, dado que se ha iniciado la promoción física para el local p, no se asigna nada en la pila primero.
Program:Main() (FullOpts):
mov edi, 10
mov esi, 20
tail.jmp [Program:Consume(Program+Point)]
Supongamos que el tipo de los miembros de Point se cambió a int en lugar de long. Dado que un int tiene cuatro bytes de ancho y los registros son de ocho bytes de ancho en x64, la convención de llamada requiere que los miembros de Point se pasen en un solo registro. Anteriormente, el compilador JIT almacenaría primero los valores en la memoria y, a continuación, cargaría el fragmento de ocho bytes en un registro. Con las mejoras de .NET 10, el compilador JIT ahora puede colocar los miembros promocionados de argumentos de estructura en registros compartidos directamente:
Program:Main() (FullOpts):
mov rdi, 0x140000000A
tail.jmp [Program:Consume(Program+Point)]
Esto elimina la necesidad de almacenamiento intermedio de memoria, lo que da lugar a código de ensamblado más eficaz.
Inversión de bucle mejorada
El compilador JIT puede incorporar la condición de un while bucle y transformar el cuerpo del bucle en un do-while bucle, lo que genera la forma final:
if (loopCondition)
{
do
{
// loop body
} while (loopCondition);
}
Esta transformación se denomina inversión de bucle. Al mover la condición a la parte inferior del bucle, el JIT quita la necesidad de bifurcarse a la parte superior del bucle para probar la condición, mejorando el diseño de código. Numerosas optimizaciones (como la clonación de bucles, la desenrollación de bucles y las optimizaciones de variables de inducción) también dependen de la inversión de bucles para generar esta forma para ayudar al análisis.
.NET 10 mejora la inversión de bucles cambiando de una implementación de análisis léxico a una implementación de reconocimiento de bucle basado en grafos. Este cambio aporta una precisión mejorada considerando todos los bucles naturales (bucles con un único punto de entrada) e ignorando los falsos positivos que se consideraron anteriormente. Esto se traduce en un mayor potencial de optimización para los programas .NET con las instrucciones for y while.
Desvirtualización del método de interfaz de matriz
Una de las áreas de enfoque de .NET 10 es reducir la sobrecarga de abstracción de las características de lenguaje populares. En la búsqueda de este objetivo, la capacidad de JIT para desvirtualizar las llamadas a métodos se ha ampliado para cubrir los métodos de interfaz de matriz.
Tenga en cuenta el enfoque típico de recorrer en bucle una matriz:
static int Sum(int[] array)
{
int sum = 0;
for (int i = 0; i < array.Length; i++)
{
sum += array[i];
}
return sum;
}
Esta forma de código es fácil de optimizar para el JIT, principalmente porque no hay llamadas virtuales que analizar. En su lugar, el JIT puede centrarse en quitar las comprobaciones de límites en el acceso al array y aplicar las optimizaciones de bucle que se agregaron en .NET 9. En el ejemplo siguiente se agregan algunas llamadas virtuales:
static int Sum(int[] array)
{
int sum = 0;
IEnumerable<int> temp = array;
foreach (var num in temp)
{
sum += num;
}
return sum;
}
El tipo de la colección subyacente está claro y el JIT debe poder transformar este fragmento de código en el primero. Sin embargo, las interfaces de matriz se implementan de forma diferente a las interfaces "normales", de modo que el JIT no sabe cómo desvirtualizarlas. Esto significa que las llamadas del enumerador en el bucle foreach permanecen virtuales, bloqueando varias optimizaciones, como la inserción y la asignación de pila.
A partir de .NET 10, JIT puede devirtualizar y insertar métodos de interfaz de matriz. Este es el primero de muchos pasos para lograr la paridad de rendimiento entre las implementaciones, como se detalla en los planes de des abstracción de .NET 10.
Desabstracción de enumeración de matriz
Los esfuerzos para reducir la sobrecarga de abstracción en la iteración de matrices a través de enumeradores han mejorado las capacidades de inlining, asignación de pila y clonación de bucles del JIT. Por ejemplo, la sobrecarga de enumerar matrices a través de IEnumerable se reduce y el análisis de escape condicional ahora habilita la asignación de pila de enumeradores en determinados escenarios.
Diseño de código mejorado
El compilador JIT de .NET 10 presenta un nuevo enfoque para organizar el código de método en bloques básicos para mejorar el rendimiento en tiempo de ejecución. Anteriormente, el JIT usaba un recorrido en postorden inverso (RPO) del grafo de flujo del programa como un diseño inicial, y luego transformaciones iterativas. Aunque es eficaz, este enfoque tenía limitaciones en el modelado de las ventajas entre reducir la bifurcación y aumentar la densidad de código activa.
En .NET 10, el JIT plantea el problema de reordenamiento de bloques como una reducción del problema del vendedor ambulante asimétrico e implementa la heurística 3-opt para encontrar un recorrido casi óptimo. Esta optimización mejora la densidad de la ruta crítica y reduce las distancias de bifurcación, resultando en un mejor rendimiento durante la ejecución.
Mejoras de procesamiento en línea
Se han realizado varias mejoras en línea en .NET 10.
JIT ahora puede integrar métodos que se vuelvan aptos para la desvirtualización debido a la integración anterior. Esta mejora permite al JIT descubrir más oportunidades de optimización, como la inclusión y la desvirtualización adicionales.
Algunos métodos que tienen semántica de control de excepciones, en particular aquellas con bloques try-finally, también se pueden insertar.
Para aprovechar mejor la capacidad de JIT de asignar algunas matrices en la pila, se han ajustado las heurísticas de la inserción para aumentar la ventaja de los candidatos que podrían devolver matrices pequeñas y de tamaño fijo.
Tipos de retorno
Durante la inserción, JIT ahora actualiza el tipo de variables temporales que contienen valores de devolución. Si todos los sitios devueltos en un destinatario producen el mismo tipo, esta información de tipo precisa se usa para desvirtualizar las llamadas subsiguientes. Esta mejora complementa las mejoras en la desvirtualización tardía y la desabstracción de enumeración de arrays.
Generación de perfiles de datos
.NET 10 mejora la política de inserción de JIT para optimizar el uso de los datos del perfil. Entre numerosas heurísticas, en la inserción de JIT no se tienen en cuenta métodos de un tamaño determinado para evitar sobredimensionar el método del autor de la llamada. Cuando el autor de la llamada tiene datos de perfil que sugieren que un candidato de inserción se ejecuta con frecuencia, la inserción aumenta su tolerancia de tamaño para el candidato.
Supongamos que JIT inserta algún destinatario Callee sin datos de perfil en algún destinatario Caller con datos de perfil. Esta discrepancia puede producirse si el destinatario es demasiado pequeño para que merezca instrumentación, o si se inserta demasiado a menudo para tener un recuento de llamadas suficiente. Si Callee tiene sus propios candidatos de inserción, antes JIT no los tenía en cuenta con su límite de tamaño predeterminado debido a la falta de datos de perfil del Callee. Ahora, el JIT se dará cuenta de que Caller tiene datos de perfil y relajará su restricción de tamaño (pero, para tener en cuenta la pérdida de precisión, no en el mismo grado que si Callee tuviera datos de perfil).
Del mismo modo, cuando JIT decide que un sitio de llamada no es rentable para la inserción, marca el método con NoInlining para evitar que no se tenga en cuenta en futuros intentos de inserción. Sin embargo, muchas heurística de inserción son sensibles a los datos de perfil. Por ejemplo, el JIT podría decidir que un método es demasiado grande para justificar su inclusión en ausencia de datos de perfil. Pero cuando el autor de la llamada está lo suficientemente caliente, el JIT podría estar dispuesto a relajar su restricción de tamaño e insertar la llamada. En .NET 10, el JIT ya no marca los elementos en línea no rentables con NoInlining para evitar reducir la eficiencia de los sitios de llamadas con datos de perfil.
Compatibilidad con AVX10.2
.NET 10 presenta compatibilidad con advanced Vector Extensions (AVX) 10.2 para procesadores basados en x64. Los nuevos intrínsecos disponibles en la System.Runtime.Intrinsics.X86.Avx10v2 clase se pueden probar una vez que el hardware compatible esté disponible.
Dado que el hardware habilitado para AVX10.2 aún no está disponible, la compatibilidad de JIT con AVX10.2 está deshabilitada de forma predeterminada.
Asignación de pila
La asignación de pila reduce el número de objetos que GC tiene que rastrear y también permite otras optimizaciones. Por ejemplo, después de destinar un objeto a la pila, el JIT puede considerar reemplazarlo por completo por sus valores escalares. Por este motivo, la asignación en pila es clave para reducir el coste de abstracción de los tipos de referencia. .NET 10 agrega asignación de pila para pequeñas matrices de tipos de valorymatrices pequeñas de tipos de referencia. También incluye el análisis de escape para los campos y delegados de estructura locales. (Los objetos que no se pueden escapar se pueden asignar en la pila).
Matrices pequeñas de tipos de valor
Ahora, JIT asigna en pila pequeñas matrices de tamaño fijo de tipos de valores que no contienen punteros GC cuando se puede garantizar que no sobrevivirán a su método principal. En el ejemplo siguiente, JIT sabe en tiempo de compilación que numbers es un array de solo tres enteros que no sobrevive a una llamada a Sum, y por tanto, la asigna en la pila.
static void Sum()
{
int[] numbers = {1, 2, 3};
int sum = 0;
for (int i = 0; i < numbers.Length; i++)
{
sum += numbers[i];
}
Console.WriteLine(sum);
}
Matrices pequeñas de tipos de referencia
.NET 10 amplía las mejoras de asignación de pila de .NET 9 a pequeñas matrices de tipos de referencia. Anteriormente, las matrices de tipos de referencia siempre se asignaban en el montón, incluso cuando su duración se limitaba a un único método. Ahora, el JIT puede asignar estas matrices cuando determina que no sobrevivirán a su contexto de creación. En el ejemplo siguiente, la matriz words ahora se asigna en la pila.
static void Print()
{
string[] words = {"Hello", "World!"};
foreach (var str in words)
{
Console.WriteLine(str);
}
}
Análisis de escape
El análisis de escape determina si un objeto puede sobrevivir a su método primario. Los objetos "escapan" cuando se asignan a variables no locales o se pasan a funciones que no son optimizadas en línea por el JIT. Si un objeto no puede escapar, se puede asignar en la pila. .NET 10 incluye el análisis de escape para:
Campos de estructura locales
A partir de .NET 10, JIT considera los objetos a los que hacen referencia los campos de estructura, lo que permite más asignaciones de pila y reduce la sobrecarga del montón. Considere el ejemplo siguiente:
public class Program
{
struct GCStruct
{
public int[] arr;
}
public static int Main()
{
int[] x = new int[10];
GCStruct y = new GCStruct() { arr = x };
return y.arr[0];
}
}
Normalmente, la pila JIT asigna matrices pequeñas y de tamaño fijo que no se escapen, como x. Su asignación a y.arr no hace que x se escape, porque y tampoco se escapa. Sin embargo, la implementación anterior del análisis de escape del JIT no modelaba las referencias de campo de estructura. En .NET 9, el ensamblado x64 generado para Main incluye una llamada a CORINFO_HELP_NEWARR_1_VC para asignar x en el montón, lo que indica que se marcó como de escape.
Program:Main():int (FullOpts):
push rax
mov rdi, 0x719E28028A98 ; int[]
mov esi, 10
call CORINFO_HELP_NEWARR_1_VC
mov eax, dword ptr [rax+0x10]
add rsp, 8
ret
En .NET 10, el JIT ya no marca los objetos a los que hacen referencia los campos de estructura local como escape, siempre y cuando la estructura en cuestión no escape. El ensamblado ahora tiene este aspecto (observe que la llamada del asistente de asignación del montón ha desaparecido):
Program:Main():int (FullOpts):
sub rsp, 56
vxorps xmm8, xmm8, xmm8
vmovdqu ymmword ptr [rsp], ymm8
vmovdqa xmmword ptr [rsp+0x20], xmm8
xor eax, eax
mov qword ptr [rsp+0x30], rax
mov rax, 0x7F9FC16F8CC8 ; int[]
mov qword ptr [rsp], rax
lea rax, [rsp]
mov dword ptr [rax+0x08], 10
lea rax, [rsp]
mov eax, dword ptr [rax+0x10]
add rsp, 56
ret
Para obtener más información sobre las mejoras de desabstracción en .NET 10, consulte dotnet/runtime#108913.
Delegados
Cuando el código fuente se compila a IL, cada delegado se transforma en una clase de cierre con un método correspondiente a la definición del delegado y campos que coinciden con las variables capturadas. En tiempo de ejecución, se crea un objeto de cierre para crear instancias de las variables capturadas, junto con un Func objeto para invocar al delegado. Si el análisis de escape determina que el objeto Funcno existirá más allá de su ámbito actual, el JIT lo asigna en la pila.
Observe el método Main siguiente:
public static int Main()
{
int local = 1;
int[] arr = new int[100];
var func = (int x) => x + local;
int sum = 0;
foreach (int num in arr)
{
sum += func(num);
}
return sum;
}
Anteriormente, el JIT generó el siguiente código ensamblado x64 abreviado para Main. Antes de entrar en el bucle, arr, func y la clase de cierre para func al que se llama Program+<>c__DisplayClass0_0 se asignan en el montón, como se indica en las llamadas a CORINFO_HELP_NEW*.
; prolog omitted for brevity
mov rdi, 0x7DD0AE362E28 ; Program+<>c__DisplayClass0_0
call CORINFO_HELP_NEWSFAST
mov rbx, rax
mov dword ptr [rbx+0x08], 1
mov rdi, 0x7DD0AE268A98 ; int[]
mov esi, 100
call CORINFO_HELP_NEWARR_1_VC
mov r15, rax
mov rdi, 0x7DD0AE4A9C58 ; System.Func`2[int,int]
call CORINFO_HELP_NEWSFAST
mov r14, rax
lea rdi, bword ptr [r14+0x08]
mov rsi, rbx
call CORINFO_HELP_ASSIGN_REF
mov rsi, 0x7DD0AE461140 ; code for Program+<>c__DisplayClass0_0:<Main>b__0(int):int:this
mov qword ptr [r14+0x18], rsi
xor ebx, ebx
add r15, 16
mov r13d, 100
G_M24375_IG03: ;; offset=0x0075
mov esi, dword ptr [r15]
mov rdi, gword ptr [r14+0x08]
call [r14+0x18]System.Func`2[int,int]:Invoke(int):int:this
add ebx, eax
add r15, 4
dec r13d
jne SHORT G_M24375_IG03
; epilog omitted for brevity
Ahora, dado que a func nunca se hace referencia fuera del ámbito de Main, también se asigna en la pila:
; prolog omitted for brevity
mov rdi, 0x7B52F7837958 ; Program+<>c__DisplayClass0_0
call CORINFO_HELP_NEWSFAST
mov rbx, rax
mov dword ptr [rbx+0x08], 1
mov rsi, 0x7B52F7718CC8 ; int[]
mov qword ptr [rbp-0x1C0], rsi
lea rsi, [rbp-0x1C0]
mov dword ptr [rsi+0x08], 100
lea r15, [rbp-0x1C0]
xor r14d, r14d
add r15, 16
mov r13d, 100
G_M24375_IG03: ;; offset=0x0099
mov esi, dword ptr [r15]
mov rdi, rbx
mov rax, 0x7B52F7901638 ; address of definition for "func"
call rax
add r14d, eax
add r15, 4
dec r13d
jne SHORT G_M24375_IG03
; epilog omitted for brevity
Observe que hay una llamada a CORINFO_HELP_NEW* restante, que es la asignación del montón para el cierre. El equipo del entorno de ejecución tiene pensado expandir el análisis de escape para admitir la asignación de cierres de pila en una versión futura.
Mejoras del preinicializador de tipos NativeAOT
El preinicializador de tipos de NativeAOT ahora admite todas las variantes de los códigos de operación conv.* y neg. Esta mejora permite la inicialización previa de métodos que incluyen operaciones de conversión o negación, lo que optimiza aún más el rendimiento en tiempo de ejecución.
Mejoras en la barrera de escritura de Arm64
El recolector de elementos basura (GC) de .NET es generacional, lo que significa que separa los objetos activos por edad para mejorar el rendimiento de la recolección. GC recopila con más frecuencia las generaciones más jóvenes bajo el supuesto de que los objetos longevos tienen menos probabilidades de estar sin referencia (o "inactivos") en un momento dado. Sin embargo, supongamos que un objeto antiguo comienza a hacer referencia a un objeto joven; el GC debe saber que no puede recopilar el objeto joven. Sin embargo, la necesidad de analizar objetos antiguos para recopilar un objeto de menos antigüedad anula las mejoras de rendimiento de una GC generacional.
Para solucionar este problema, el JIT inserta barreras de escritura antes de las actualizaciones de referencia de objetos para mantener al tanto al GC. En x64, el tiempo de ejecución puede cambiar dinámicamente entre implementaciones de barrera de escritura para equilibrar las velocidades de escritura y la eficiencia de la recopilación, en función de la configuración del GC. En .NET 10, esta funcionalidad también está disponible en Arm64. En concreto, la nueva implementación predeterminada de barrera de escritura en Arm64 controla las regiones de GC de forma más precisa, lo que mejora el rendimiento de la recolección, aunque con un ligero costo en el rendimiento de la barrera de escritura. Las pruebas comparativas muestran mejoras en las pausas de GC del 8% a más del 20% con los nuevos valores predeterminados de GC.