wandres.dev
RENDIMIENTO · dónde asigna Kotlin

Lo que la máquina hace por ti: compilación tardía, escape analysis y calentamiento

El código que se ejecuta no es el que escribió el compilador de Kotlin. Esta lección describe el segundo compilador que actúa en tiempo de ejecución con información que ninguna herramienta estática puede tener, explica cómo el `escape analysis` y la sustitución escalar hacen desaparecer objetos que el bytecode asignaba, detalla las fases de calentamiento por las que pasa un método antes de alcanzar su velocidad final, y deduce de todo ello por qué la medición ingenua produce números que no significan nada.

⏱ 24 min

Cualquier razonamiento sobre el rendimiento de Kotlin que se detenga en el bytecode está incompleto, porque el bytecode no es lo que ejecuta la máquina: es lo que la máquina lee antes de decidir qué ejecutar. Entre el artefacto compilado y las instrucciones que finalmente corren en el procesador hay un segundo compilador que trabaja mientras el programa está vivo, que observa qué ramas se toman de verdad, qué tipos aparecen de verdad en cada punto de llamada y qué objetos no sobreviven de verdad a la función que los creó, y que reescribe el código con esa información. Ese compilador puede borrar asignaciones que el bytecode declara, insertar llamadas que el código fuente separa y eliminar comprobaciones que el lenguaje exige. También puede cambiar de opinión y deshacerlo todo. Ignorar su existencia lleva a dos errores simétricos y ambos caros: optimizar a mano lo que la máquina ya resolvía, y medir en un estado del programa que no se parece en nada al que se quería medir.

🎯 Al terminar esta lección sabrás
  • Describir las fases por las que pasa un método desde la interpretación hasta la compilación optimizadora.
  • Explicar qué condiciones permiten que el escape analysis concluya que un objeto no escapa y qué hace la sustitución escalar con esa conclusión.
  • Reconocer los mecanismos especulativos que la máquina emplea y las circunstancias que provocan una desoptimización.
  • Deducir de todo lo anterior por qué una medición sin calentamiento produce cifras sistemáticamente falsas.

El compilador que llega después

Una máquina virtual moderna empieza interpretando el bytecode, que es lento pero arranca de inmediato, y va instrumentando cada método con contadores de invocación y de vueltas de bucle. Cuando esos contadores superan un umbral, el método se envía a compilar. La implementación más extendida usa dos compiladores en cascada: uno rápido que produce código razonable en poco tiempo y sigue recogiendo estadísticas, y otro lento que produce código muy optimizado usando esas estadísticas. Un método caliente pasa por los dos, y su velocidad puede variar en un factor de veinte entre la primera ejecución y la número cien mil.

flowchart TD
A[Primera ejecucion del metodo] --> B[Interprete con contadores]
B --> C{Supera el umbral de invocaciones}
C -- No --> B
C -- Si --> D[Compilador rapido con perfilado]
D --> E{Sigue caliente}
E -- Si --> F[Compilador optimizador con estadisticas]
F --> G[Codigo especializado por el perfil observado]
G --> H{El perfil deja de cumplirse}
H -- Si --> B
H -- No --> G

Lo decisivo es de qué información dispone ese segundo compilador y de cuál carece el primero. El compilador de Kotlin no sabe qué implementación concreta recibirá un punto de llamada polimórfico, así que emite una llamada virtual y se acabó. La máquina, en cambio, ha visto pasar cien mil invocaciones y sabe que en el noventa y nueve por ciento de ellas el receptor era de una única clase, de modo que puede insertar el cuerpo de esa clase directamente y dejar una comprobación barata que verifique la suposición. Eso se llama inserción especulativa y es la base de casi todo lo demás: sin insertar no hay contexto, y sin contexto no hay análisis.

ℹ️
Uno, dos o muchos

La máquina clasifica cada punto de llamada por cuántos tipos de receptor ha observado. Con uno solo lo trata como monomórfico y lo inserta sin dudar. Con dos puede seguir insertando ambos y elegir con una comprobación. A partir de ahí lo declara megamórfico y renuncia, dejando una llamada indirecta de verdad. Esa clasificación explica por qué una función perfectamente rápida en una prueba, donde siempre recibe la misma implementación, se degrada al integrarse en un sistema donde recibe cinco: el código no cambió, cambió el perfil.

La inserción tiene además un presupuesto, y ese presupuesto explica una de las pocas reglas de estilo con impacto medible sobre el rendimiento. La máquina se niega a insertar métodos cuyo tamaño en bytecode supera un umbral, y el umbral es sorprendentemente bajo para el código caliente. Un método largo no solo no se inserta: impide que se inserte todo lo que llama desde dentro, y con ello corta la cadena de la que dependen las optimizaciones posteriores. Partir una función grande en varias pequeñas no es una preferencia estética en una ruta caliente, es la condición para que exista contexto suficiente.

// Cuerpo largo: no cabe en el presupuesto y bloquea a sus llamadas internas
fun procesarTodo(p: Peticion): Respuesta { /* doscientas lineas */ }

// Cuerpo corto: cabe, se inserta y deja ver el interior de las tres llamadas
fun procesarMejor(p: Peticion): Respuesta {
    val v = validar(p)
    val r = calcular(v)
    return formatear(r)
}

Escape analysis y sustitución escalar

Una vez insertado el cuerpo de las funciones llamadas, la máquina puede ver el ciclo de vida completo de los objetos creados dentro de esa región ampliada. El escape analysis responde a una pregunta concreta sobre cada asignación: la referencia a este objeto, alcanza algún punto donde otro hilo u otro marco de pila pueda observarla. Si la respuesta es que no escapa en absoluto, la máquina puede aplicar la sustitución escalar, que consiste en no crear el objeto y sustituirlo por sus campos como si fueran variables locales sueltas. El objeto desaparece del montículo, y con él desaparece la asignación, la cabecera, la posterior recolección y cualquier bloqueo asociado.

data class Punto(val x: Double, val y: Double)

fun distancia(ax: Double, ay: Double, bx: Double, by: Double): Double {
    val a = Punto(ax, ay)      // candidato a desaparecer por completo
    val b = Punto(bx, by)
    val dx = a.x - b.x
    val dy = a.y - b.y
    return kotlin.math.sqrt(dx * dx + dy * dy)
}

En ese fragmento, un bucle que llame a la función un número suficiente de veces terminará ejecutando aritmética pura sobre registros, sin ningún objeto. Es el mismo mecanismo que hace que muchas lambdas no insertadas resulten gratuitas en la práctica: si la instancia se crea, se invoca y se abandona dentro de una región que la máquina consiguió insertar entera, el objeto nunca llega a existir. De ahí que la marca inline de Kotlin no sea redundante pero sí menos crítica de lo que sugiere el bytecode: garantiza en tiempo de compilación algo que la máquina consigue muchas veces por su cuenta, pero no siempre.

El análisis falla en condiciones concretas y conviene conocerlas, porque son las que separan el caso donde la abstracción es gratis del caso donde no lo es. Falla si la referencia se guarda en un campo, se devuelve, se pasa a un método que no se pudo insertar, se almacena en una colección o se publica en cualquier estructura compartida. Falla también si la región resultante de insertar se vuelve demasiado grande y la máquina abandona la inserción, lo que ocurre con métodos largos. Y falla, de forma menos obvia, si el punto de llamada es megamórfico, porque entonces no hay cuerpo que insertar y el objeto cruza una frontera opaca.

🌫️

No escapa

El objeto nace y muere dentro de la región compilada. Se descompone en campos y jamás toca el montículo.

🧵

Escapa por argumento

La referencia se pasa a un método no insertado. La máquina debe materializar el objeto porque otro marco podría verlo.

🌍

Escapa globalmente

La referencia se guarda en un campo, en una colección o se devuelve. Hay asignación real y recolección posterior garantizadas.

Hay además un efecto secundario del análisis que rara vez se menciona y que importa en código concurrente: cuando la máquina demuestra que un objeto no escapa, también demuestra que ningún otro hilo puede observarlo, y con esa demostración puede eliminar por completo los bloqueos que se tomen sobre él. Ese es el motivo por el que un constructor de texto sincronizado dentro de un método local puede acabar sin ninguna sincronización real, y también el motivo por el que la diferencia entre una estructura sincronizada y otra que no lo está desaparece en algunos microbenchmarks y reaparece en cuanto la referencia se guarda en un campo.

fun etiquetar(n: Int): String {
    val sb = StringBuffer()      // sincronizado por contrato
    sb.append("id-").append(n)
    return sb.toString()         // no escapa: los bloqueos se eliminan
}

Especulación y desoptimización

Casi todo lo que hace el compilador optimizador es una apuesta apoyada en lo observado hasta ese momento. Apuesta a que una rama nunca se toma y elimina el código de la otra. Apuesta a que un receptor siempre es de la misma clase y elimina la llamada virtual. Apuesta a que una excepción nunca se lanza y elimina el manejo. Apuesta a que un campo nunca se modifica y propaga su valor como constante. Cada apuesta se acompaña de una comprobación barata, y cuando esa comprobación falla el código compilado se descarta y la ejecución vuelve al intérprete en el punto exacto donde estaba, para volver a compilar más tarde con el perfil corregido.

interface Formato { fun render(v: Int): String }
class Decimal : Formato { override fun render(v: Int) = v.toString() }
class Hexadecimal : Formato { override fun render(v: Int) = v.toString(16) }

fun procesar(fs: List<Formato>, n: Int): Int {
    var total = 0
    for (f in fs) total += f.render(n).length
    return total
}

Si la lista contiene siempre instancias de una sola clase, el bucle acaba compilándose sin ninguna llamada real. El día que aparece la segunda implementación, la comprobación falla, el código se descarta, el método vuelve a interpretarse y se recompila en una versión más conservadora. Ese episodio es invisible desde fuera salvo por un dato: el rendimiento cae y no vuelve a recuperarse del todo. Es una de las razones por las que un sistema puede degradarse tras un despliegue que no tocó ninguna ruta caliente.

⚠️
La máquina no puede deshacer una asignación que sí escapa

Es tentador concluir que la máquina lo arregla todo y que no merece la pena preocuparse por las asignaciones. La conclusión es falsa por un motivo estructural: el análisis solo puede eliminar objetos cuya referencia no sale de la región compilada. Una lista intermedia que se devuelve, un objeto que se guarda en una caché, un valor empaquetado que entra en una colección o cualquier cosa que atraviese una frontera de hilo escapan por definición y no hay optimización posible. Lo que la máquina regala son los objetos efímeros y locales; lo que sigue costando es exactamente lo que el mapa de asignaciones señalaba como estructural.

Por qué medir mal es la norma

De todo lo anterior se deduce una consecuencia práctica que invalida la mayoría de las mediciones caseras. Un programa que acaba de arrancar ejecuta código interpretado; un programa que lleva un rato ejecuta código compilado por el compilador rápido; un programa que lleva mucho ejecuta código especializado según un perfil que depende de qué datos ha visto. Cronometrar un bucle nada más arrancar no mide el rendimiento del algoritmo: mide la velocidad del intérprete más el tiempo que tardó la máquina en decidir compilar.

fun main() {
    val inicio = System.nanoTime()
    val r = calculo()                      // se ejecuta interpretado casi entero
    println(System.nanoTime() - inicio)    // cifra sin significado
    println(r)
}

El problema no se arregla ejecutando el bucle muchas veces dentro del mismo cronómetro, que es la corrección intuitiva y la que casi todo el mundo intenta primero. Repetir dentro de la medición mezcla las fases: los primeros millones de vueltas se ejecutan interpretadas y los últimos compilados, de modo que la media resultante no corresponde a ningún régimen real y depende del número de repeticiones elegido. La corrección verdadera consiste en separar un tramo de calentamiento cuyos tiempos se descartan de un tramo de medición cuyos tiempos se conservan, y en comprobar que el segundo tramo es estable.

A eso se suman al menos cuatro fuentes de error independientes. La eliminación de código muerto hace que la máquina borre por completo un cálculo cuyo resultado no se usa, produciendo tiempos absurdamente bajos. El plegado de constantes hace que un cálculo sobre valores conocidos en tiempo de compilación se resuelva una vez y se reutilice. El perfil contaminado hace que un método medido después de otro que usaba las mismas funciones herede decisiones tomadas para el primero. Y la variabilidad del recolector introduce pausas que aparecen o no según el momento exacto en que se tomó la muestra.

El rendimiento de un programa gestionado no es una propiedad del código sino del encuentro entre el código y su historia

Hay un cambio de modelo mental que este nivel exige y que conviene enunciar sin rodeos, porque casi todos los errores de razonamiento sobre rendimiento en la plataforma Java se derivan de no haberlo hecho. En un lenguaje compilado por adelantado, la velocidad de una función es aproximadamente una propiedad de la función: se compila una vez, produce unas instrucciones y esas instrucciones son las mismas la primera vez y la millonésima. Aquí no. Aquí la velocidad de una función es una propiedad de la pareja formada por la función y la historia de ejecución que la precedió. La misma función, con el mismo bytecode, sobre los mismos datos, puede ser veinte veces más lenta si se la llama poco, cinco veces más lenta si el punto de llamada anterior vio tres implementaciones distintas, y sustancialmente más rápida si otro método que ya se compiló la insertó dentro de una región donde sus objetos dejaron de escapar. Nada de eso está en el código fuente y nada de eso es estable. La primera consecuencia es epistemológica: cualquier afirmación de la forma esta construcción de Kotlin es más lenta que aquella, dicha sin especificar en qué estado del sistema, es literalmente incompleta, y la abundancia de afirmaciones así en foros y artículos explica la cantidad de optimizaciones supersticiosas que circulan. La segunda consecuencia es metodológica y es la que ocupa la lección siguiente: si el objeto de estudio no es el código sino su encuentro con una historia, entonces medir consiste en construir esa historia deliberadamente, repetirla, y demostrar que el número obtenido es reproducible. La tercera es de diseño, y es la más útil de las tres: en lugar de pelear con el optimizador conviene escribir código que le facilite el trabajo, con métodos cortos que quepan en el presupuesto de inserción, con puntos de llamada que no vean cinco implementaciones y con objetos cuya referencia no escape sin necesidad. Esas tres cosas valen más que cualquier micro truco, porque no compiten con la máquina: la alimentan.

⚔️ Observa a la máquina trabajando
  1. Escribe un bucle que cree un objeto pequeño en cada vuelta, ejecútalo con y sin el análisis de escape activado, y explica la diferencia de asignaciones.
  2. Mide un método antes y después de cien mil invocaciones y razona qué fase de compilación explica cada cifra.
  3. Construye un punto de llamada que reciba primero una sola implementación y luego tres, y describe qué pierde la máquina en el segundo caso.
  4. Cronometra un cálculo cuyo resultado no se use y explica por qué el tiempo obtenido es casi cero.
  5. Enumera tres formas de hacer que una referencia escape y decide, para cada una, si tu código las provoca sin necesidad.