ztzoff.tech

30 jul 2026

Una optimización local dejó mi código 6 veces más lento, y solo el desensamblado lo explicó

Vectorizar la activación de una capa densa la volvió 6,3× más lenta, de 12,79 µs a 80 µs. La causa no estaba en el código fuente sino en el desensamblado.

Vectoricé la función de activación de una capa densa y la capa se volvió 6,3× más lenta. No un poco más lenta: de 12,79 µs a 80 µs, con la misma aritmética y con la parte vectorizada midiendo el doble de rápido cuando la medía por separado. Nada en el código fuente insinuaba el motivo. La explicación estaba en el desensamblado, y era una sola instrucción repetida en un sitio donde no debería aparecer: una comprobación de límites delante de cada carga vectorial.

Este es un artículo de rendimiento en .NET. La red neuronal es solo el caso de estudio; no hace falta saber nada de redes neuronales para seguirlo.

Entorno de todas las mediciones: Apple M3 Pro (11 núcleos), macOS 26.3, .NET 10.0.10, Arm64 RyuJIT armv8.0-a, BenchmarkDotNet 0.15.8, job por defecto. Aquí Vector<float> tiene ancho 4.

Qué hace Dense.Forward: un producto escalar con SIMD en C#

Una capa densa calcula, para cada unidad j, el producto escalar de sus pesos con la entrada, le suma el sesgo y aplica la activación. Los pesos viven en un único arreglo plano en orden por unidad: los de la unidad j ocupan Weights[j * Inputs .. (j+1) * Inputs].

La versión original hacía una sola pasada y activaba unidad por unidad:

for (int j = 0; j < Units; j++)
{
    float z = SimdOps.Dot(w.Slice(j * Inputs, Inputs), aIn) + Bias[j];
    aOut[j] = TActivation.Apply(z);
}

exp y tanh cuestan decenas de ciclos por llamada, y ahí hay una llamada por unidad. La optimización parecía obvia: separar en dos pasadas —primero todas las preactivaciones, después la activación sobre el vector entero con TensorPrimitives— para procesar cuatro valores por instrucción.

SimdOps.MatVec(Weights, aIn, Bias, aOut);   // dest[j] = dot(weights_j, x) + bias[j]
TActivation.ApplyAll(aOut);                 // activa el vector entero de una vez

Medida aislada, la activación vectorizada funcionaba

Antes de tocar la capa medí las activaciones solas, en nanosegundos, escalar → vectorizado:

AnchoSigmoidTanhSoftmax
1016,6 → 16,0 ns (1,04×)19,9 → 18,2 ns (1,09×)24,3 → 34,6 ns (0,70×)
128208 → 92,8 ns (2,24×)242 → 121 ns (2,00×)289 → 134 ns (2,15×)
10241643 → 749 ns (2,21×)1914 → 956 ns (2,00×)2219 → 1070 ns (2,07×)

En los anchos que importan, alrededor del doble de rápido. En ancho 10 la ganancia se diluye y Softmax incluso pierde —0,70×—, porque a esa escala el coste fijo de la llamada domina. Resultado esperable y, en principio, buena señal.

Integrada en la capa, fue un desastre

La capa de 784×128 pasó de 12,79 µs a 80 µs. La aritmética era idéntica; solo había cambiado el orden en que se recorría.

Y entonces apareció el dato que no encajaba con ninguna explicación razonable: Dense<ReLU>, cuya activación no se había tocado, también medía 80 µs.

Peor todavía, el mismo código llamado directamente desde un benchmark medía 10,7 µs. SimdOps.MatVec(_w, _in, _bias, _out) invocado desde el benchmark tardaba 10,51 µs. Y _dense.Forward(_in, _out) —que no hace otra cosa que llamar a MatVec y luego a la activación— tardaba 79,78 µs.

Para aislar eso escribí un banco de pruebas temporal con siete variantes de la misma capa de 784×128:

VarianteTiempo
Fused (bucle original, reproducido en el benchmark)12,91 µs
TwoPassSpan (dos pasadas, reproducido en el benchmark)11,49 µs
TwoPassNoActivation11,34 µs
MatVecOnly10,51 µs
MatVecPlusTanh10,70 µs
RealDense (el tipo real de la biblioteca)79,78 µs
RealReference (clase de referencia, misma aritmética)12,78 µs

Esta tabla procede de un banco de pruebas de diagnóstico temporal, escrito para aislar el problema y eliminado una vez resuelto. Las cifras se midieron de verdad, pero ese archivo ya no está en el repositorio: no es reproducible clonándolo. El resto de mediciones del artículo sí lo son.

La lectura es inequívoca. La forma de dos pasadas no era el problema: aislada era incluso más rápida que la original —11,49 µs frente a 12,91 µs—. RealReference, una clase con la misma aritmética, medía 12,78 µs. El desastre aparecía solo dentro del tipo genérico real.

Tres hipótesis, tres callejones sin salida

El proceso importa tanto como la conclusión, así que dejo constancia de lo que probé y falló.

¿TensorPrimitives.Tanh demasiado grande al insertarse en línea (inlining)? Marqué Tanh.ApplyAll con [MethodImpl(MethodImplOptions.NoInlining)]. Sin cambio: 79,67 µs.

¿Recargas de campos por posible aliasing? Extraje Inputs, Units y Bias a variables locales para que el JIT no tuviera que releerlos. Sin cambio: 80,34 µs.

¿Compilación degradada a MinOpts? El JIT reportaba FullOpts, IL size 318, code size 1752. Optimización completa.

Tres explicaciones plausibles, tres refutadas por la medición. Llegado ese punto ya no quedaba nada que adivinar desde el código fuente.

El desensamblado: una comprobación de límites por cada carga vectorial

Con DOTNET_JitDisasm volcado del método real, el bucle interno estaba vectorizado: los fmul y fadd sobre .4s ahí estaban, exactamente como esperaba. Pero cada carga vectorial iba precedida de una comprobación de límites:

cmp     x21, x11
bhi     G_M000_IG35          ; ← comprobación de rango
lsl     xip0, xip0, #2
add     x22, x10, xip0
ldr     q18, [x22]
cmp     x21, w14, UXTW
bhi     G_M000_IG35          ; ← y otra
add     xip0, x1, xip0
ldr     q19, [xip0]
fmul    v18.4s, v18.4s, v19.4s
fadd    v16.4s, v16.4s, v18.4s

Dos comparaciones y dos saltos condicionales por cada par de cargas, dentro del bucle más caliente del programa. Eso es lo que separa 10,7 µs de 79,78 µs.

El mecanismo: insertado en línea dentro de Dense<TActivation>.Forward —un método genérico que para entonces contenía también el producto escalar y la activación, ambos insertados en línea— el JIT dejaba de eliminar las comprobaciones de límites de las cargas vectoriales internas. Aisladas, esas mismas cargas salían limpias. El método anfitrión, al crecer, perdía la información que permitía demostrar que los índices eran seguros.

Conviene decirlo con precisión: esto es una observación reproducible en una versión y una plataforma concretas, no un defecto declarado de .NET. No lo reporté como bug y no lo presento como tal.

La solución, en una palabra: NoInlining

Extraer el producto matriz-vector a su propio método —pequeño, no genérico— y marcarlo para que el JIT no lo inserte en línea:

[MethodImpl(MethodImplOptions.NoInlining)]
public static void MatVec(ReadOnlySpan<float> weights, ReadOnlySpan<float> x,
                          ReadOnlySpan<float> bias, Span<float> dest)
{
    int n = x.Length;

    for (int j = 0; j < dest.Length; j++)
        dest[j] = Dot(weights.Slice(j * n, n), x) + bias[j];
}

De 79,78 µs a 10,72 µs.

Es el consejo contrario al reflejo habitual. NoInlining suele leerse como una pesimización; aquí es lo que devuelve al JIT un método lo bastante pequeño como para volver a probar que los accesos son seguros.

El premio inesperado

La capa terminó en 9,75 µs frente a los 12,79 µs originales: 1,31× más rápida. Esa era la ganancia que buscaba desde el principio.

Pero el hallazgo real está en la otra fila. Dense<ReLU> ganó 1,29×, de 12,65 a 9,80 µs, sin ningún cambio en su activación. Su bucle original llevaba todo el tiempo pagando la misma penalización de comprobaciones de límites y nadie lo sabía, porque nunca hubo una medición con la que compararla. La regresión de 6× no introdujo el problema: lo hizo lo bastante grande como para que fuera imposible ignorarlo.

(La cifra de 12,65 µs es la medición anterior al cambio y hoy solo consta en el historial de git; la posterior, 9,80 µs, sí está publicada.)

La lección para medir rendimiento en .NET

Una optimización local hizo seis veces más lento justo aquello que pretendía optimizar. Nada en el código fuente lo sugería, todas mis hipótesis razonables resultaron falsas, y lo único que lo detectó fue el benchmark.

Dos reglas prácticas que me llevo:

  1. Si insertas código vectorizado dentro de un método genérico grande, mide. El generador de código no te debe nada: las garantías que da en un método pequeño no sobreviven necesariamente a la inserción en línea dentro de uno grande.
  2. Si el número no cuadra, lee el desensamblado antes de inventar una explicación. Yo tenía tres explicaciones plausibles y las tres eran falsas. DOTNET_JitDisasm las descartó todas en una tarde.

Y la salvedad que no puedo omitir: todas estas cifras son de ARM64 —M3 Pro, NEON, Vector<float> de ancho 4— con .NET 10.0.10. En x86 con AVX2 o AVX-512, o en otra versión del JIT, el efecto puede diferir en magnitud o no aparecer.

El código

Todo lo anterior —salvo la tabla de diagnóstico, que ya no existe— es reproducible: neural-network-csharp. Los benchmarks están en bench/, con los resultados comentados en bench/README.es.md.

Agenda una llamada