Una de las características clave de .NET es su enfoque en la seguridad. El runtime protege el acceso a arreglos, cadenas y spans para prevenir accesos de memoria inválidos. Si se intenta acceder a una posición fuera de los límites, en lugar de corromper la memoria, se lanzará una excepción. Esta protección se implementa mediante comprobaciones de límites insertadas por el compilador Just-In-Time (JIT) en cada acceso a estos tipos de datos.
Por ejemplo, el siguiente código:
[MethodImpl(MethodImplOptions.NoInlining)]
static int Read0thElement(int[] array) => array[0];
Genera código ensamblador que incluye una comparación de la longitud del arreglo con 0 antes de acceder al primer elemento. Si la longitud es 0, se llama a una función de ayuda del JIT para lanzar una IndexOutOfRangeException.
Si bien estas comprobaciones de límites no son intrínsecamente costosas, su acumulación puede tener un impacto notable en el rendimiento. El JIT, además de garantizar la seguridad, intenta optimizar el código identificando accesos que están garantizados para estar dentro de los límites y, por lo tanto, elimina las comprobaciones redundantes. .NET 7 ha introducido mejoras significativas en esta área.
Una mejora notable (dotnet/runtime#61662) permite al JIT comprender operaciones aritméticas binarias como parte de las comprobaciones de rango. Consideremos este método:
[MethodImpl(MethodImplOptions.NoInlining)]
private static ushort[]? Convert(ReadOnlySpan<byte> bytes)
{
if (bytes.Length != 16)
{
return null;
}
var result = new ushort[8];
for (int i = 0; i < result.Length; i++)
{
result[i] = (ushort)(bytes[i * 2] * 256 + bytes[i * 2 + 1]);
}
return result;
}
</byte>
Este código verifica que el span de entrada tenga 16 bytes y luego crea un arreglo de ushort de 8 elementos. Cada ushort se construye a partir de dos bytes del span de entrada. Para los accesos a bytes[i * 2] y bytes[i * 2 + 1], en .NET 6, se realizaban comprobaciones de límites. Sin embargo, en .NET 7, el JIT puede inferir que dado que result.Length es 8 y el bucle itera desde i = 0 hasta 7, los índices calculados (i * 2 y i * 2 + 1) siempre estarán dentro de los límites válidos del span de entrada (0 a 15). Por lo tanto, las comprobaciones de límites se eliminan, resultando en un código ensamblador más eficiente.
Otras mejoras (dotnet/runtime#61569 y dotnet/runtime#62864) se centran en la eliminación de comprobaciones de límites al inicializar cadenas y spans constantes a partir de campos estáticos RVA (Relative Virtual Address). Esto significa que el JIT puede optimizar accesos a datos estáticos que residen en la sección de datos del módulo.
Consideremos un ejemplo de benchmrak:
[Benchmark]
[Arguments(1)]
public char GetChar(int i)
{
const string Text = "hello";
return (uint)i < Text.Length ? Text[i] : '\0';
}
En .NET 6, incluso sabiendo que la longitud de Text es 5, el JIT generaba una comprobación de límites redundante entes de acceder a Text[i]. En .NET 7, esta comprobación ha sido eliminada, simplificando el código generado.
La optimización de comprobaciones de límites también se ha extendido a escenarios más complejos, como el manejo de clases de caracteres en expresiones regulares. Un ejemplo involucra el uso de una cadena como tabla de búsqueda de bits:
[Benchmark]
[Arguments('a')]
public bool IsInSet(char c) =>
c < 128 && ("\0\0\0\u03FF\uFFFE\u87FF\uFFFE\u07FF"[c >> 4] & (1 << (c & 0xF))) != 0;
En .NET 6, el JIT no optimizaba completamente las comprobaciones de límites y operaciones de bits en este contexto. Sin embargo, en .NET 7, gracias a mejoras específicas, se eliminan las llamadas a CORINFO_HELP_RNGCHKFAIL, lo que resulta en un código más conciso y rápido, especialmente relevante para operaciones intensivas como las de Regex.
Más allá de las comprobaciones de límites, .NET 7 también optimiza las instrucciones utilizadas en accesos a arreglos. Para un método simple como:
[MethodImpl(MethodImplOptions.NoInlining)]
private static int Get(int[] values, int i) => values[i];
En .NET 6, la instrucción movsxd se utilizaba para mover el índice i a un registro de 64 bits, realizando una extensión de signo. Dado que se sabe que el índice i es no negativo (debido a la comprobación de límites previa y a la naturaleza de los índices), esta extensión de signo es innecesaria. .NET 7 ha corregido esto, eliminando la instrucción de extensión de signo y utilizando una simple instrucción de movimiento, lo que resulta en un código más eficiente.
Un área donde las optimizaciones han tenido un impacto significativo es en el acceso a arreglos multidimensionales. En versiones anteriores, el acceso a arreglos multidimensionales no se optimizaba de manera tan agresiva. Con la mejora en dotnet/runtime#70271, el JIT ahora expande los accesos a arreglos multidimensionales más temprano en su pipeline de optimización, permitiendo que otras optimizaciones posteriores (como Constant Folding y Loop-Invariant Code Motion) actúen sobre ellos.
La mejora del rendimiento en arreglos multidimensionales se observa claramente en un benchmark de suma de elementos:
// Benchmark para sumar elementos de un arreglo 2D
// ... (código de setup y benchmark omitido por brevedad)
Los resultados muestran una mejora sustancial en .NET 7 en copmaración con .NET 6.
Además, las llamadas a Array.GetLowerBound y Array.GetUpperBound se han convertido en "intrínsecos" para el JIT en .NET 7 (dotnet/runtime#60816). Esto significa que el JIT puede reemplazarlas directamente con las instrucciones de ensamblador más eficientes para leer los límites del arreglo desde memoria, en lugar de realizar llamadas a métodos. Esto reduce drásticamente la sobrecarga, especialmente en bucles donde estos métodos se llamaban repetidamente.
El código ensamblador resultante para el benchmark de suma de arreglos multidimensionales en .NET 7 muestra la ausencia de llamadas a métodos para obtener los límites, reemplazadas por operaciones directas de memoria, lo que conduce a una ejecución significativamente más rápida.