wandres.dev
RENDIMIENTO · medir y afinar

Benchmarks honestos: medir sin engañarte

Por qué la mayoría de microbenchmarks miden otra cosa. Las formas en que el optimizador borra tu prueba y cómo impedirlo con sumideros opacos, la anatomía de una medición con swift-benchmark, y cómo leer una distribución de tiempos sin confundir ruido del planificador con una regresión real.

⏱ 22 min

Todo lo anterior en este nivel depende de una capacidad que casi nadie tiene calibrada: la de producir un número en el que se pueda confiar. Un microbenchmark es un experimento diminuto sobre un sistema enorme, ejecutándose sobre un planificador que no controlas, en una máquina que cambia su frecuencia según la temperatura, compilado por un optimizador cuyo trabajo consiste precisamente en eliminar código que no se usa, y tu prueba es, por definición, código que no se usa. La probabilidad de medir accidentalmente otra cosa es muy alta, y el fracaso no es ruidoso: no obtienes un error, obtienes un número plausible y falso. Esta lección trata de las tres defensas que hacen falta —contra el optimizador, contra el entorno y contra la estadística— y de cómo leer el resultado sin sacar conclusiones que los datos no sostienen.

🎯 Al terminar esta lección sabrás
  • Identificar las formas en que el optimizador puede eliminar por completo el código que intentas medir.
  • Construir un sumidero opaco y usarlo para forzar que el resultado y las entradas sean observables.
  • Escribir una medición con swift-benchmark que separe métricas deterministas de métricas ruidosas.
  • Interpretar una distribución de tiempos y decidir si una diferencia observada es una regresión real.

Tu prueba es código muerto y el compilador lo sabe

La eliminación de código muerto es una de las primeras transformaciones que aplica el optimizador. Si construyes un valor, lo transformas y no usas el resultado, la secuencia entera puede desaparecer y dejarte con un bucle vacío que corre en cero nanosegundos. Y como cero es un número que parece un éxito, el fallo pasa desapercibido.

Hay tres variantes del mismo problema, con síntomas distintos. La primera es la que acabas de leer: el resultado no se usa y todo se borra. La segunda es más sutil: el resultado sí se usa, pero la entrada es una constante conocida, así que el compilador evalúa la función en tiempo de compilación y el bucle mide una lectura de constante. La tercera es específica de los bucles: el cuerpo no depende de la variable de iteración, así que el optimizador iza el cálculo fuera y lo ejecuta una sola vez.

// 1. Resultado sin usar: todo el cuerpo puede desaparecer
for _ in 0..<n { _ = costoso(datos) }

// 2. Entrada constante: se evalua al compilar
for _ in 0..<n { usar(costoso([1, 2, 3])) }

// 3. Cuerpo invariante: se iza fuera del bucle y corre una vez
for i in 0..<n { total += costoso(datos) }

La defensa es un sumidero opaco: una función que el compilador no puede atravesar ni eliminar, a la que le entregas el resultado para que deje de ser código muerto. La construcción mínima combina la prohibición de inserción en línea con la extensión explícita de la vida del valor.

@inline(never)
public func sumidero<T>(_ x: T) {
    withExtendedLifetime(x) { }
}

Con eso resuelto queda el lado de la entrada, que se protege de forma simétrica: los datos deben construirse de manera que el compilador no pueda conocer su contenido, normalmente leyéndolos de una fuente externa o generándolos con una semilla que llega en tiempo de ejecución.

⚠️
Un cero sospechoso es casi siempre una prueba borrada

Si una medición devuelve tiempos del orden de un nanosegundo o menos por iteración para trabajo que evidentemente no es trivial, la hipótesis por defecto no es que tu código sea rapidísimo: es que el optimizador lo eliminó. La comprobación definitiva es emitir el SIL o el ensamblador y buscar si las instrucciones del cuerpo siguen ahí. Un benchmark que no se ha verificado a ese nivel al menos una vez no es evidencia de nada.

La anatomía de una medición

Con las defensas contra el optimizador en su sitio, el resto es disciplina experimental y conviene delegarla en una herramienta que ya la implemente. swift-benchmark proporciona el bucle de repetición, el calentamiento, la agregación estadística y un sumidero opaco propio, de modo que no tengas que reinventar ninguna de las tres cosas.

import Benchmark

let suite = BenchmarkSuite(name: "Despacho") { suite in
    let datos = cargarDesdeArchivo()          // no constante en tiempo de compilacion

    suite.benchmark("concreto") {
        var total = 0.0
        for f in datos.circulos { total += f.area }
        sumidero(total)
    }

    suite.benchmark("existencial") {
        var total = 0.0
        for f in datos.figuras { total += f.area }
        sumidero(total)
    }
}

Benchmark.main([suite])

Cuatro precauciones completan el banco de pruebas y ninguna es opcional. Calienta antes de medir, porque la primera iteración paga fallos de caché, fallos de página y a veces la resolución perezosa de símbolos, que no representan el estado estacionario. Compila con optimizaciones, porque una medición en depuración mide la ausencia del optimizador y no tu código. Fija la máquina en la medida de lo posible: sin otras cargas, con la alimentación conectada y aceptando que el escalado térmico de frecuencia introducirá deriva en tandas largas. Y separa las métricas deterministas de las ruidosas, porque el recuento de asignaciones y de instrucciones es reproducible y sirve como prueba de regresión automatizable, mientras que el tiempo solo sirve para comparaciones relativas dentro de la misma sesión y la misma máquina.

flowchart TB
N[Tengo un numero] --> Q1{Es sospechosamente bajo}
Q1 -->|Si| DCE[Verificar en el SIL que el cuerpo sigue existiendo]
Q1 -->|No| Q2{Fue medido con optimizaciones}
Q2 -->|No| INV[La medida no vale: mide la ausencia del optimizador]
Q2 -->|Si| Q3{Se solapan las distribuciones de las dos variantes}
Q3 -->|Si| RUIDO[Diferencia no concluyente: mas muestras o cambio mayor]
Q3 -->|No| Q4{Se reproduce en otra maquina y otro dia}
Q4 -->|No| ENT[Artefacto del entorno o de la tanda]
Q4 -->|Si| REAL[Diferencia real: registrar como linea base]
style REAL fill:#a6e3a1,color:#11111b
style INV fill:#f38ba8,color:#11111b
style DCE fill:#f9e2af,color:#11111b

Las trampas del propio banco de pruebas

Aun con sumideros correctos y la herramienta adecuada, quedan cuatro formas de medir algo distinto de lo que crees, y las cuatro son frecuentes.

La caché irreal es la primera. Un microbenchmark recorre el mismo conjunto de datos miles de veces, así que a partir de la segunda iteración todo está en caché de primer nivel. La aplicación real toca esos datos una vez cada mucho tiempo y paga fallos que la prueba nunca ve. Un benchmark de acceso a memoria que no varíe el conjunto de trabajo mide la caché, no el algoritmo.

El estado acumulado es la segunda. Si la operación bajo prueba modifica una estructura que persiste entre iteraciones, la iteración mil no hace el mismo trabajo que la primera. Insertar en un diccionario que crece, por ejemplo, mide una mezcla de inserciones baratas y redimensionados caros que no corresponde a ningún escenario real concreto.

La medición del andamiaje es la tercera. Si el cuerpo bajo prueba tarda menos que el propio bucle de repetición, la lectura del reloj o el sumidero, estás midiendo tu infraestructura. La defensa es agrupar trabajo suficiente por iteración para que el andamiaje sea despreciable, y comprobarlo midiendo una iteración vacía.

El orden de las variantes es la cuarta y la más traicionera, porque produce diferencias sistemáticas y reproducibles que no tienen nada que ver con el código: la primera variante paga el calentamiento del proceso y la última puede sufrir escalado térmico. La defensa es intercalar las variantes y repetir la tanda en orden inverso.

// Iteracion vacia para acotar el coste del andamiaje
suite.benchmark("linea base vacia") { sumidero(0) }
ℹ️
Compara siempre contra una línea base en la misma tanda

Los números absolutos de un benchmark no son transferibles entre máquinas, versiones del compilador ni siquiera entre días. Lo único que viaja es la relación entre variantes medidas juntas. Por eso toda tanda debería incluir una línea base conocida, y todo informe debería expresarse como proporción respecto a ella y no en nanosegundos sueltos.

Leer el resultado sin engañarse

Los tiempos de ejecución de un proceso no siguen una campana simétrica. Son una distribución con cola derecha larga, porque el trabajo tiene un mínimo físico y todo lo que puede ocurrir por encima —una interrupción, una migración de núcleo, un fallo de página, un recolector de otro proceso— solo añade tiempo. Esa asimetría tiene una consecuencia inmediata sobre qué estadístico usar.

La media es el peor resumen posible, porque unos pocos valores extremos la desplazan y acabas midiendo el planificador tanto como tu código. La mediana es robusta y describe bien la experiencia típica. El mínimo es el mejor estimador del coste intrínseco del código, porque es la ejecución que menos interferencia sufrió, y es el estadístico que quieres cuando comparas dos implementaciones. Los percentiles altos describen la cola y son lo que importa si mides latencia percibida en lugar de trabajo puro.

De ahí se sigue el criterio para declarar una diferencia. Si las distribuciones de las dos variantes se solapan, no hay diferencia demostrada por muchos que sean los porcentajes que separan sus medias. Una variación de unos pocos puntos porcentuales entre tandas es lo normal en una máquina de escritorio, así que una mejora de esa magnitud requiere muchas repeticiones y confirmación en otra sesión antes de creerla. Una mejora que cambia el orden de magnitud, en cambio, se ve a simple vista y no necesita estadística.

Y queda la advertencia que ordena todo lo demás. Un microbenchmark mide un fragmento en aislamiento, con sus datos en caché y sin competencia por memoria. El mismo fragmento dentro de una aplicación real compite por la caché con todo lo demás, y una optimización que gana en aislamiento puede perder en contexto si engorda el binario o desaloja datos ajenos. El microbenchmark responde a la pregunta de qué implementación es intrínsecamente más rápida; solo un perfil de la aplicación completa responde a la pregunta de si eso importa.

🕳️

Sumidero en el resultado y en la entrada

Sin sumidero el resultado es código muerto; con entrada constante el cálculo migra al tiempo de compilación. Hay que cerrar los dos lados.

📊

Mediana o mínimo, nunca media

La distribución tiene cola derecha larga. El mínimo estima el coste intrínseco; la mediana, la experiencia típica; la media, el planificador.

🧮

Métricas deterministas como regresión

Asignaciones e instrucciones son reproducibles y automatizables en integración continua. El tiempo solo vale como comparación relativa.

El observador dentro del sistema observado

Hay algo epistemológicamente peculiar en medir el rendimiento de un lenguaje compilado, y explicitarlo cambia la manera de escribir cualquier benchmark futuro. En casi todas las ciencias experimentales el aparato de medida es externo al fenómeno; aquí no lo es. El compilador que optimiza tu código bajo prueba es el mismo que optimiza el código que rodea la prueba, incluido el andamiaje que has escrito para medir, y no tiene forma de distinguir uno de otro porque no existe tal distinción en su modelo. Cuando el optimizador borra tu bucle no está fallando: está haciendo exactamente aquello por lo que lo valoras, eliminar trabajo cuyo resultado nadie observa. El problema es que tú observas, pero observas desde fuera del sistema de tipos, con un cronómetro que el compilador no puede ver. Todo el aparato de blackHole y sumideros opacos existe para trasladar esa observación al interior del modelo del compilador, para decirle en su propio lenguaje que este valor sí se usa. Un sumidero es, literalmente, una mentira útil sobre las dependencias del programa. De aquí se siguen dos consecuencias que separan a quien mide bien de quien produce números. La primera es que ningún benchmark es correcto por construcción: solo es correcto hasta que compruebas, en el ensamblador o en el SIL, que mide lo que crees. Esa comprobación no es paranoia, es la única validación disponible, y hay que hacerla al menos una vez por prueba. La segunda es más amplia y aplica a todo este nivel: la medición no es la fase final que confirma una optimización, es la única fuente de autoridad que tiene el proceso entero. Las cuatro lecciones anteriores describen mecanismos —despacho, especialización, exposición de cuerpos, asignaciones— y todos ellos son razonamientos sobre lo que el compilador debería poder hacer. Ninguno garantiza que lo haga en tu caso concreto, con tu versión del compilador, tus banderas y tus datos. La intuición sobre rendimiento en un lenguaje con un optimizador de esta sofisticación no es una habilidad que se afina con experiencia: es una hipótesis que se somete a prueba cada vez, y el mérito profesional no está en acertar sino en no creerse el número hasta haberlo verificado.

📝
Lo esencial de medir bien

El optimizador puede borrar tu prueba de tres formas: resultado sin usar, entrada constante y cuerpo invariante izado fuera del bucle. La defensa es un sumidero opaco en el resultado y datos que no se conozcan al compilar. Usa swift-benchmark para el calentamiento, la repetición y la agregación, mide siempre con optimizaciones y separa las métricas deterministas de las de tiempo. Resume con mediana o mínimo, nunca con la media, y no declares una diferencia si las distribuciones se solapan. Verifica al menos una vez en el SIL que el cuerpo sigue existiendo, y recuerda que un microbenchmark en aislamiento no prueba que la mejora importe en la aplicación completa.

⚔️ Construye un banco de pruebas que no te mienta
  1. Escribe un benchmark deliberadamente ingenuo sin sumidero, comprueba que devuelve un tiempo absurdamente bajo y localiza en el SIL la ausencia del cuerpo.
  2. Añade el sumidero opaco y datos cargados en tiempo de ejecución, y verifica en el ensamblador que las instrucciones del cálculo aparecen dentro del bucle.
  3. Mide la misma función cien veces y grafica la distribución; calcula media, mediana y mínimo y explica cuál usarías para comparar dos implementaciones y por qué.
  4. Compara las dos versiones del bucle de la primera lección, con tipo concreto y con existencial, y comprueba si la diferencia sobrevive a un cambio de máquina.
  5. Añade a tu integración continua una prueba que aserte sobre el recuento de asignaciones y no sobre el tiempo, y justifica esa elección con lo aprendido sobre la cola derecha.