Tenía dos primitivos vectorizados escritos a mano con Vector<float> y la misma sospecha sobre ambos: que la versión de la biblioteca estándar sería mejor. Sustituí los dos por System.Numerics.Tensors y medí. Uno ganó 2,55×. El otro perdió 1,47×.
No es una cuestión de calidad de implementación —es el mismo equipo, el mismo kernel, la misma versión—. Es estructural, y una vez visto el patrón se predice de antemano. Pero no se predice leyendo la API.
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,
jobpor defecto. AquíVector<float>tiene ancho 4.
Los dos primitivos SIMD escritos a mano en C#
La biblioteca —una red neuronal feed-forward escrita desde cero, que aquí solo sirve de banco de pruebas— apoya casi todo su tiempo de cálculo sobre dos operaciones:
Dot(a, b)— producto escalar, escrito con dos acumuladores para romper la cadena de dependencias de la suma.AddScaled(dest, src, scale)—dest += src * scale, un multiplicar-y-acumular elemento a elemento.
Ambas parecen hermanas: recorren dos arreglos de float, multiplican y suman. La sustitución obvia era TensorPrimitives.Dot y TensorPrimitives.MultiplyAdd. La hipótesis de trabajo, igual de obvia: la biblioteca gana las dos.
Resultado 1 — Dot pierde
| Longitud | Escalar | 1 acumulador | 2 acumuladores (a mano) | TensorPrimitives.Dot |
|---|---|---|---|---|
| 8 | 4,23 ns | 1,27 ns | 1,03 ns | 2,35 ns (2,3× más lento) |
| 64 | 38,3 ns | 9,92 ns | 7,91 ns | 5,98 ns (1,32× más rápido) |
| 512 | 360 ns | 82,4 ns | 63,5 ns | 61,4 ns (empate) |
| 4096 | 2937 ns | 723 ns | 500 ns | 734 ns (1,47× más lento) |
La fila interesante no es la primera sino la última, y la clave está en compararla con la columna que casi nadie mira.
TensorPrimitives.Dot con 4096 elementos tarda 734 ns. La versión de un solo acumulador tarda 723 ns. Menos de un 2 % de diferencia. El kernel de la biblioteca está rindiendo exactamente como mi versión ingenua de una sola cadena de suma, mientras que mi versión de dos acumuladores termina en 500 ns.
El mecanismo: un producto escalar termina en una reducción. Todos los productos parciales tienen que colapsar en un único escalar, y si se acumulan en un solo registro cada suma depende del resultado de la anterior. Esa es una cadena serie, y su longitud —no el ancho SIMD ni el número de multiplicaciones— fija el tiempo. El segundo acumulador existe precisamente para partirla en dos cadenas independientes que la unidad de ejecución puede solapar. El kernel de TensorPrimitives arrastra una única cadena a través de la reducción, así que paga el mismo precio.
En longitud 8 pierde por otro motivo, más prosaico: es una llamada real, mientras que mi versión se inserta en línea (inlining). Con 8 float el coste de la llamada es comparable al del trabajo.
Resultado 2 — AddScaled gana
| Longitud | Vector<float> a mano | TensorPrimitives.MultiplyAdd |
|---|---|---|
| 8 | 1,29 ns | 2,84 ns (2,19× más lento) |
| 64 | 9,87 ns | 8,66 ns (1,14× más rápido) |
| 512 | 74,6 ns | 29,6 ns (2,52× más rápido) |
| 4096 | 566 ns | 222 ns (2,55× más rápido) |
La misma penalización de llamada en longitud 8, y a partir de ahí una victoria que crece hasta más del doble y se estabiliza.
El porqué estructural —y es el corazón del asunto—: AddScaled es una operación de flujo puro. Un valor de salida por cada valor de entrada, sin reducción. No hay ninguna cadena de dependencias que arrastrar de una iteración a la siguiente, así que el kernel puede desenrollar el bucle tanto como quiera y mantener llenas todas las unidades de ejecución. Mi bucle escrito a mano no desenrolla.
Hay un segundo factor que suma: MultiplyAdd emite una multiplicación-suma fusionada real, mientras que dest[i] += src[i] * scale se compila como una multiplicación y una suma separadas.
Reducción frente a flujo. Ese es todo el criterio.
Resultado 3 — ¿se nota en el entrenamiento?
Un microbenchmark no demuestra nada hasta que se pesa frente a todo lo que comparte trabajo con él. Ganar 344 ns en una operación que se ejecuta cien veces por segundo no cambia nada.
Así que medí un mini-batch completo —forward, backward y actualización de pesos— sobre una red 784 → 128 tanh → 10 softmax con lote de 32:
Con TensorPrimitives | AddScaled a mano | ||
|---|---|---|---|
| Paso completo | 607,5 µs | 815,0 µs | 1,34× más rápido |
| Solo forward (control) | 318,9 µs | 317,9 µs | 1,00× — sin cambio |
| Mitad backward (por resta) | 288,6 µs | 497,1 µs | 1,72× más rápido |
Un tercio de mejora en el paso completo. Pero la fila que hace fiable el experimento es la del medio.
AddScaled no aparece en el forward pass. Por construcción, ese número tenía que quedar igual con y sin el cambio. Si esos dos valores hubieran divergido, la conclusión correcta no habría sido «la mejora es aún mayor» sino «se movió algo distinto de lo que creo haber cambiado» —una compilación diferente, otro estado de la máquina, un benchmark mal aislado—.
Difieren un 0,3 %, dentro del ruido. Eso sitúa la totalidad de los 207 µs de diferencia en el backward pass, exactamente donde vive el primitivo. Una prueba de control cuesta una fila de la tabla y convierte una correlación en una atribución.
La lección: reducción frente a flujo
La misma biblioteca, la misma versión, dos operaciones que en el código fuente parecen hermanas, y respuestas opuestas. La diferencia no está en cuánto esfuerzo puso nadie en cada kernel: está en si la operación termina en una reducción o no.
De ahí sale una heurística utilizable:
- Operaciones de flujo —mapas elemento a elemento,
axpy, escalados, sumas de vectores— tienden a favorecer alkernelde la biblioteca, que desenrolla mejor de lo que uno va a escribir a mano. - Reducciones —productos escalares, normas, sumas, máximos— dependen de cuántas cadenas de acumulación independientes mantenga la implementación. Ahí una versión propia con varios acumuladores puede ganar, y conviene comprobarlo.
- En longitudes pequeñas, el coste de la llamada domina y la versión insertada en línea gana casi siempre. Si el tamaño típico es de unas pocas decenas de elementos, mídelo antes de sustituir nada.
Y sobre todo: «usa la función optimizada de la biblioteca» es una hipótesis, no una conclusión. Es una buena hipótesis —acertó en una de las dos— pero ninguna de las dos decisiones era predecible leyendo la API, y la única forma de saber cuál era cuál fue medir las dos.
Como siempre, la salvedad de plataforma: todas las cifras son de ARM64 —M3 Pro, NEON, Vector<float> de ancho 4— con .NET 10.0.10. En x86 con AVX2 o AVX-512, donde los vectores son de 8 o 16 elementos, las proporciones pueden cambiar; el argumento estructural sobre reducciones se mantiene, los números concretos no.
Conviene añadir que esta biblioteca no es rápida en términos absolutos: es de un solo hilo, no tiene procesamiento por lotes real y no tiene GEMM por bloques. Sirve como banco de pruebas honesto de estos primitivos, no como referencia de rendimiento.
El código
Todo lo anterior es reproducible: neural-network-csharp. Los benchmarks están en bench/, con los resultados comentados en bench/README.es.md.