ztzoff.tech

30 jul 2026

Mi benchmark era ruido, y yo ya había escrito la explicación

Documenté que un segundo acumulador costaba un 17 % con longitud 8. El margen de error era de ±0,26 ns sobre un efecto de 0,27 ns. Al remedir, ganaba 1,23×.

Durante un tiempo la documentación de mi biblioteca afirmaba algo falso, con una tabla al lado y un párrafo explicando por qué era así. El párrafo era bueno. Ese fue el problema.

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. Aquí Vector<float> tiene ancho 4.

La afirmación

Mi producto escalar vectorizado usa dos acumuladores en lugar de uno, para romper la cadena de dependencias que impone la suma y dejar que la CPU solape dos cadenas independientes. En todas las longitudes medidas eso ganaba tiempo… salvo en una.

Con longitud 8, decía la documentación, el segundo acumulador costaba un 17 %: 1,54 ns frente a 1,27 ns de la versión de un solo acumulador. Un 0,83×. Un resultado negativo, publicado honestamente, con su excepción bien delimitada.

La explicación, que era demasiado buena

Y debajo del número había un mecanismo perfectamente razonable, ya escrito:

Ocho float son exactamente dos vectores de ancho 4. El bucle de dos acumuladores procesa dos vectores por iteración, así que ejecuta su cuerpo una sola vez y cae directo en la cola. Paga toda la preparación —inicializar dos registros acumuladores, la suma horizontal final de ambos— y no recupera nada, porque la segmentación no tiene ninguna segunda iteración con la que solapar. En el punto exacto donde el desenrollado deja de amortizarse, el coste fijo se ve entero.

Todo eso es cierto como mecanismo. Encajaba con el número. Y por eso nadie —yo— volvió a mirar el número.

El número era ruido: tres iteraciones de BenchmarkDotNet

Aquellas filas se habían medido con --job short de BenchmarkDotNet, es decir tres iteraciones. Cuando volví sobre la tabla y miré la columna que había ignorado, el margen de error de esas filas era de ±0,26 ns.

El efecto que pretendían describir era de 0,27 ns —la diferencia entre 1,54 y 1,27—.

El margen de error era tan ancho como el efecto. La tabla no decía «el segundo acumulador cuesta un 17 %»; decía «con esta configuración no puedo distinguir estas dos versiones». Yo leí lo primero y escribí un párrafo explicándolo.

Al volver a medir con el job por defecto, el resultado se invierte: el segundo acumulador gana 1,23× —1,03 ns frente a 1,27 ns— igual que en todas las demás longitudes. No había excepción. Nunca la hubo.

Longitud 81 acumulador2 acumuladores
--job short (3 iteraciones, ±0,26 ns)1,27 ns1,54 ns0,83× — dentro del ruido
job por defecto1,27 ns1,03 ns1,23×

Las dos lecciones sobre medir con BenchmarkDotNet

La primera es la obvia y la menos interesante: un job de tres iteraciones sirve para triaje, no para sacar conclusiones. --job short está para saber si un cambio mueve algo en un orden de magnitud mientras iteras. En cuanto un número va a entrar en la documentación, se remide con el job por defecto. Y el margen de error se lee siempre, no solo cuando el resultado sorprende.

La segunda es la que de verdad me costó: una explicación convincente no es evidencia.

La historia sobre la preparación del bucle y las iteraciones de cola era persuasiva, técnicamente correcta como mecanismo, y describía con precisión un fenómeno real que podría haber ocurrido. Precisamente por eso funcionó tan bien como blindaje. Un número suelto y raro invita a repetir la medición. Un número con un mecanismo plausible escrito debajo deja de parecer un dato dudoso y pasa a parecer un hecho entendido, y a los hechos entendidos no se les vuelve a pedir la prueba.

Escribí la explicación después de ver el número, para el número. Eso es exactamente el orden en el que uno se engaña. La pregunta que no me hice —y que ahora intento hacerme siempre— es: si el número fuera el contrario, ¿tendría también una explicación para ese? Con el bucle de dos acumuladores la tenía, y era la que ya estaba escrita en todas las demás filas de la tabla.

El contraejemplo optimista

En el mismo proyecto encontré el caso simétrico, y termina bien.

TensorPrimitives.SoftMax calcula exp(z)/Σexp(z) de forma literal, sin restar el máximo de los logits antes de exponenciar. Es la estabilización numérica estándar, y sin ella la función devuelve NaN para logits por encima de ~88, que es donde exp desborda en precisión simple.

Dos pruebas unitarias que ya existían lo detectaron en la primera ejecución tras la sustitución. Coste total del error: segundos.

Esa es la diferencia entre los dos fallos de este artículo. El del softmax tenía una comprobación esperándolo, así que duró lo que tardó en ejecutarse la batería de pruebas. El del benchmark no tenía ninguna —una explicación bien escrita no es una comprobación, aunque se le parezca mucho desde dentro— y duró hasta que a alguien se le ocurrió releer una columna de márgenes de error.

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.

Agenda una llamada