Start Debugging

RyuJIT poda más bounds checks en .NET 11 Preview 3: index-from-end y i + constante

.NET 11 Preview 3 enseña a RyuJIT a eliminar bounds checks redundantes en accesos consecutivos index-from-end y en patrones i + constante < length, reduciendo presión de branches en loops apretados.

La eliminación de bounds check es la optimización del JIT que decide silenciosamente cuán rápido es mucho del código .NET. Cada array[i] y span[i] en código managed lleva un compare-and-branch implícito, y cuando RyuJIT puede probar que el índice está en rango, ese branch desaparece. .NET 11 Preview 3 extiende esa prueba a dos patrones comunes que antes pagaban el check igual.

Ambos cambios están documentados en las release notes del runtime y destacados en el anuncio de .NET 11 Preview 3 del 14 de abril de 2026.

Acceso back-to-back index-from-end

El operador index-from-end ^1, ^2, introducido con C# 8, es syntactic sugar para Length - 1, Length - 2. El JIT ha podido elidir el bounds check en el primer acceso por un tiempo, pero un segundo acceso justo después era a menudo tratado independientemente y forzaba un compare-and-branch redundante.

En .NET 11 Preview 3, el análisis de rango reusa la prueba de length a través de accesos consecutivos index-from-end:

static int TailSum(int[] values)
{
    // .NET 10: two bounds checks, one per access.
    // .NET 11 Preview 3: the JIT proves both are in range from a single length test.
    return values[^1] + values[^2];
}

Si desensamblas TailSum en el ASM viewer de Rider 2026.1, puedes ver que el segundo par cmp/ja simplemente desaparece. Código que recorre la cola de un buffer, accessors de ring-buffer, parsers que espían el último token, o comparadores de ventana fija, todos se benefician sin cambio de source.

Loops i + constante < length

La segunda mejora apunta a un patrón que aparece constantemente en código numérico y de parsing. Un loop de stride-2 solía lucir bien en papel pero seguía pagando un bounds check en el segundo acceso:

static int SumPairs(ReadOnlySpan<int> buffer)
{
    int sum = 0;
    for (int i = 0; i + 1 < buffer.Length; i += 2)
    {
        // buffer[i] is trivially safe, but buffer[i + 1] used to
        // get its own bounds check, even though the loop condition
        // already proved it.
        sum += buffer[i] + buffer[i + 1];
    }
    return sum;
}

La condición de loop i + 1 < buffer.Length ya prueba que buffer[i + 1] está en rango, pero RyuJIT solía tratar los dos accesos independientemente. Preview 3 enseña al análisis a razonar sobre un índice más una constante pequeña contra un length, así que ambos buffer[i] y buffer[i + 1] compilan a un load plano.

La misma reescritura aplica a i + 2, i + 3, y así, mientras el offset constante coincida con lo que garantiza la condición de loop. Ensancha la condición de loop a i + 3 < buffer.Length, y un inner loop stride-4 se vuelve bounds-check-free en los cuatro accesos.

Por qué branches pequeños suman

Un único bounds check cuesta menos de un nanosegundo en CPUs modernas. La presión real es de segundo orden: el slot de branch que consume, las decisiones de loop-unrolling que bloquea, las oportunidades de vectorización que derrota. Cuando RyuJIT prueba que un inner loop completo es bounds-safe, es libre de desenrollar más agresivamente y entregar el bloque al auto-vectorizador. Ahí es donde una micro-ganancia de 1% en papel se convierte en una mejora de 10 a 20% en un kernel numérico real.

Probándolo hoy

Ninguna optimización necesita un feature flag. Corre cualquier SDK de .NET 11 Preview 3 y se activan automáticamente. Setea DOTNET_JitDisasm=TailSum para dumpear el código generado, corre una vez en .NET 10 y una en Preview 3, y diff. Si mantienes hot loops sobre arrays o spans, especialmente cualquier cosa que espíe el final de un buffer o camine con un stride fijo, este es un speedup gratis esperando en Preview 3.

< Volver