Medir de verdad: el arnés, las trampas y la distancia hasta producción
Si el rendimiento de un programa gestionado depende de su historia de ejecución, medirlo exige construir esa historia con método. Esta lección explica qué hace exactamente un arnés como `JMH` para neutralizar la eliminación de código muerto, el plegado de constantes y el calentamiento insuficiente; muestra cómo se escribe un microbenchmark que mida lo que dice medir; presenta el perfilado de asignaciones como una alternativa más estable que el cronómetro; y delimita hasta dónde puede extrapolarse un número obtenido en el banco de pruebas.
Casi todo el mundo que ha intentado medir el rendimiento de dos formas de escribir lo mismo ha obtenido, en algún momento, un resultado imposible: la versión que hace más trabajo sale más rápida, o una de las dos tarda cero. No es que la máquina sea caprichosa; es que el experimento estaba mal montado y el optimizador hizo exactamente lo que debía. Un cálculo cuyo resultado nadie usa se borra entero. Un cálculo sobre constantes se resuelve una vez. Un método medido en frío mide al intérprete. Un método medido después de otro hereda su perfil. Cada una de estas trampas tiene un remedio conocido, y el conjunto de esos remedios es lo que constituye un arnés de medición serio. Aprender a usarlo no es aprender una herramienta más: es la diferencia entre tener un número y tener una superstición con decimales.
- Enumerar las trampas concretas de la medición y el mecanismo con el que un arnés neutraliza cada una.
- Escribir un microbenchmark cuyo estado, consumo de resultados y calentamiento estén bajo control explícito.
- Interpretar el perfilado de asignaciones y justificar por qué es más estable que la medición de tiempo.
- Delimitar qué conclusiones de un microbenchmark se pueden trasladar a producción y cuáles no.
Qué resuelve un arnés y por qué no basta un cronómetro
Un arnés de microbenchmarking no es un cronómetro con estadística: es un generador de código que envuelve el fragmento a medir en un armazón diseñado para impedir cada una de las optimizaciones que falsearían el resultado. Genera un bucle externo que el compilador no puede desenrollar hasta hacer desaparecer el cuerpo. Introduce los datos a través de un objeto de estado cuyos campos la máquina no puede tratar como constantes. Consume el resultado mediante un sumidero que el optimizador no consigue demostrar inútil. Y separa el calentamiento de la medición, ejecutando el fragmento hasta que el código compilado se estabiliza antes de empezar a contar.
flowchart TD
A[Fragmento que se quiere medir] --> B[Estado externo no constante]
B --> C[Iteraciones de calentamiento]
C --> D{El tiempo se ha estabilizado}
D -- No --> C
D -- Si --> E[Iteraciones de medicion]
E --> F[Resultado enviado al sumidero]
F --> G[Estadistica sobre varias bifurcaciones del proceso]El último eslabón es el que más gente omite y el que más importa cuando se comparan dos alternativas. Una única ejecución del proceso puede quedar atrapada en un estado de compilación desafortunado, con un perfil contaminado o una disposición de memoria adversa que persiste durante toda su vida. Por eso un arnés serio repite el experimento en varios procesos independientes y presenta la dispersión entre ellos, no solo la dispersión dentro de uno. Cuando la diferencia entre dos alternativas es menor que esa dispersión, la conclusión correcta no es cuál gana sino que el experimento no distingue.
La eliminación de código muerto se neutraliza devolviendo el resultado del método de medición o entregándolo a un sumidero explícito. El plegado de constantes se neutraliza leyendo las entradas de campos de un objeto de estado en lugar de escribirlas como literales. El calentamiento insuficiente se neutraliza con iteraciones previas que no cuentan. La contaminación del perfil se neutraliza aislando cada medición en su propio proceso. Un experimento que no aplica las cuatro produce números, pero no produce información.
Queda una decisión previa que condiciona todo lo demás y que conviene tomar de forma consciente: qué se está midiendo exactamente. Medir el tiempo medio por operación sirve para comparar alternativas en un cálculo puro. Medir el rendimiento sostenido sirve para dimensionar un componente que procesa un flujo continuo. Medir el tiempo de una única invocación con la máquina en reposo sirve para caracterizar latencias de cola, y exige un modo de muestreo distinto porque la media oculta precisamente lo que se busca. Elegir el modo equivocado produce un número correcto que responde a otra pregunta.
Escribir un microbenchmark que mida lo que dice
La estructura mínima consiste en tres piezas: una clase de estado que contiene los datos y se construye una sola vez, un método anotado como medible que ejecuta el fragmento, y una devolución del resultado para que no pueda descartarse. El estado debe generarse con datos representativos y con tamaño configurable, porque casi todas las comparaciones interesantes cambian de signo según el volumen de entrada.
@State(Scope.Benchmark)
open class Datos {
@Param("10", "1000", "100000")
var tam: Int = 0
lateinit var lista: List<Int>
@Setup(Level.Trial)
fun preparar() {
lista = List(tam) { it }
}
}
@Fork(3)
@Warmup(iterations = 5)
@Measurement(iterations = 10)
open class Comparativa {
@Benchmark
fun ansiosa(d: Datos): Int =
d.lista.map { it * 2 }.filter { it % 3 == 0 }.sum()
@Benchmark
fun perezosa(d: Datos): Int =
d.lista.asSequence().map { it * 2 }.filter { it % 3 == 0 }.sum()
}
Tres detalles de ese esqueleto merecen comentario. El parámetro de tamaño convierte una comparación binaria en una curva, que es lo único que permite responder a la pregunta real, que nunca es cuál es más rápida sino a partir de qué volumen cambia el orden. Las bifurcaciones del proceso protegen contra el perfil heredado entre los dos métodos, que sin ellas se medirían en el mismo proceso y compartirían las decisiones del optimizador sobre map y filter. Y la devolución del entero final es lo que impide que toda la cadena se borre por inútil; cuando el resultado no es un valor único hay que entregarlo a un sumidero, que es un objeto cuyo consumo la máquina no puede demostrar innecesario.
@Benchmark
fun conSumidero(d: Datos, sumidero: Blackhole) {
for (v in d.lista) sumidero.consume(v * 2)
}
Un detalle propio de Kotlin merece mención aparte, porque produce errores difíciles de detectar. Las clases del arnés deben poder heredarse y sus miembros deben ser accesibles desde el código generado, lo que en un lenguaje donde todo es cerrado y final por defecto obliga a marcar explícitamente cada declaración. Olvidarlo produce fallos en la generación que rara vez explican su causa real, y hace perder más tiempo que cualquier otra parte del montaje.
// Sin la marca de apertura, el arnes no puede generar la subclase
@State(Scope.Benchmark)
open class Estado {
var entrada: String = ""
}
Perfilar asignaciones en lugar de cronometrar
Hay una razón profunda para preferir, siempre que se pueda, una métrica de asignación sobre una métrica de tiempo: el tiempo depende de la máquina, del sistema operativo, de la frecuencia del procesador, de qué más se estaba ejecutando y del estado del recolector, mientras que los bytes asignados por operación son casi deterministas. Dos ejecuciones del mismo experimento en máquinas distintas dan tiempos distintos y asignaciones idénticas. Eso convierte la métrica de asignación en la única que se puede fijar como umbral en una prueba automática sin condenarse a falsos positivos permanentes.
Bytes por operación
El perfilador de asignaciones del arnés informa de cuánta memoria deja cada invocación. Es reproducible, comparable entre máquinas y directamente atribuible a una construcción del lenguaje.
Muestreo en producción
Un perfilador de muestreo con eventos de asignación señala qué pilas de llamada crean más objetos en un sistema real, con un impacto lo bastante bajo para dejarlo activado.
Registro de vuelo
El registrador de eventos de la máquina virtual guarda muestras de asignación, pausas del recolector y compilaciones, y permite reconstruir a posteriori qué pasó durante un incidente.
El resultado de activar ese perfilador es una columna adicional junto al tiempo, expresada en bytes por operación y en tasa por segundo, y su interpretación es directa. Un valor de cero significa que la máquina consiguió eliminar todas las asignaciones del fragmento, lo que sucede más a menudo de lo que se supone. Un valor idéntico entre dos ejecuciones confirma que la construcción medida es determinista en su huella. Y un valor que cambia con el tamaño de la entrada revela que la asignación es proporcional y no constante, que es exactamente la distinción que decide si merece la pena actuar.
// Salida tipica de una comparativa con perfilado de asignaciones
// Benchmark (tam) Score Units gc.alloc.rate.norm
// ansiosa 100000 412.7 us/op 4800120 B/op
// perezosa 100000 388.1 us/op 0 B/op
// ansiosa 10 0.09 us/op 520 B/op
// perezosa 10 0.14 us/op 320 B/op
La lectura de un perfil de asignaciones tiene una particularidad que conviene anticipar. Lo que aparece arriba no suele ser el objeto que el programador tenía en mente, sino un array interno, una entrada de mapa o una instancia de una clase generada cuyo nombre incluye el de una función anónima. Ese nombre generado es precisamente la pista: identifica la lambda concreta que se está instanciando y, por tanto, la función de orden superior que no se insertó. Del mismo modo, una acumulación de instancias del envoltorio de un entero apunta a una frontera genérica que se cruza en un bucle, y un exceso de arrays de caracteres apunta a construcción de texto.
La distancia hasta producción
Un microbenchmark mide una función aislada, con datos que caben en la caché, sin competencia por la memoria, sin otros hilos, con un perfil monomórfico artificialmente limpio y con el recolector recién estrenado. Producción es lo contrario en las seis dimensiones. Por eso el resultado de un microbenchmark no es una predicción de rendimiento sino una acotación de un mecanismo: sirve para afirmar que una construcción asigna tanto y otra tanto, no para afirmar que sustituirla mejorará el sistema.
// Lo que el microbenchmark demuestra:
// la version perezosa asigna 0 bytes por operacion frente a 4800 de la ansiosa
// Lo que NO demuestra:
// que cambiarla mejore la latencia del servicio, porque quiza esa ruta
// representa el 0.2 por ciento del tiempo total y esta dominada por la red
La discrepancia más frecuente entre banco y producción tiene un nombre concreto y ya apareció en la lección anterior: el perfil de los puntos de llamada. En un microbenchmark, una interfaz recibe siempre la misma implementación porque el experimento solo instancia una, de modo que la máquina la inserta y mide un código que en producción no existe. En el sistema real esa misma interfaz recibe cinco implementaciones, el punto de llamada se declara megamórfico y todo el análisis posterior se desmorona. Un banco de pruebas que quiera parecerse a producción tiene que incluir esa diversidad de forma deliberada.
La segunda discrepancia es de memoria. El microbenchmark trabaja sobre un conjunto de datos que cabe entero en la caché del procesador, mientras que el sistema real compite por esa caché con todo lo demás que hay en el proceso. Una estructura que en el banco se recorre a velocidad de registro puede convertirse en una cadena de fallos de caché cuando comparte máquina con un montículo de varios gigabytes lleno de objetos vivos. Esa diferencia no se puede simular con facilidad, y es la razón principal por la que las mejoras medidas en aislamiento se encogen al integrarse.
La única forma honesta de cerrar la distancia es medir el sistema completo antes y después, con la métrica que le importa a alguien: latencia en un percentil alto, rendimiento sostenido, tiempo total de pausa del recolector. Un microbenchmark que mejora un factor de diez y un servicio cuya latencia no se mueve son una situación completamente normal, y no significa que la medición fuese errónea: significa que el mecanismo medido no estaba en el camino crítico. Reconocer eso rápido es lo que evita gastar semanas en optimizaciones ciertas e irrelevantes.
Hay una asimetría curiosa en cómo se trata la medición de rendimiento frente a cómo se trata cualquier otra afirmación técnica. Nadie acepta un cambio de comportamiento sin una prueba que falle antes y pase después, pero se aceptan sin resistencia afirmaciones de rendimiento apoyadas en una ejecución única, en una máquina, sin calentamiento y sin dispersión. La raíz del problema es que un cronómetro siempre devuelve un número, y un número tiene un aire de objetividad que una opinión no tiene, aunque en este dominio la mayoría de los números sean opiniones con unidades. Salir de ahí exige adoptar la actitud de quien diseña un experimento en lugar de la de quien toma una lectura. Un experimento bien diseñado se caracteriza por tener una hipótesis enunciada antes de ejecutarlo, por especificar qué resultado la refutaría, y por incluir controles que detecten si el aparato está midiendo lo que se cree. En este dominio esos controles existen y son concretos: una alternativa que sabemos peor debe salir peor, la curva debe cambiar con el tamaño de la entrada en la dirección que la teoría predice, y la dispersión entre bifurcaciones del proceso debe ser menor que la diferencia que se quiere afirmar. Si el experimento no incluye ninguna forma de salir mal, no es una medición: es una ceremonia para confirmar lo que ya se había decidido. Y aquí está la consecuencia que más cambia el trabajo diario: la mayoría de las mediciones bien diseñadas terminan refutando la hipótesis de partida. Se optimiza lo que parecía caro y el sistema no se mueve, porque el tiempo estaba en otro sitio. Ese resultado, que se siente como un fracaso, es en realidad el producto más valioso del proceso, porque evita un cambio que habría añadido complejidad permanente a cambio de nada. Quien mide para confirmar acumula optimizaciones. Quien mide para refutar acumula código simple.
- Escribe un microbenchmark ingenuo sin sumidero ni calentamiento, observa el número absurdo que produce y explica qué optimización lo causó.
- Convierte una comparación binaria en una curva parametrizada por el tamaño de la entrada y localiza el punto donde cambia el ganador.
- Ejecuta el mismo experimento con perfilado de asignaciones y compara la estabilidad de esa métrica con la del tiempo entre dos ejecuciones.
- Interpreta un perfil donde el objeto más asignado tenga un nombre generado y deduce qué función de orden superior lo produce.
- Enuncia por escrito, antes de medir, qué resultado te haría abandonar la optimización que tenías pensada.