wandres.dev
EL COMPILADOR K2 · frontend, IR y backends

Compilar en la práctica: kotlinc, demonio e incremental

Entre saber cómo funciona el compilador y saber por qué tu build tarda cuatro minutos hay un salto que casi nadie da. Esta lección baja al terreno: las opciones de kotlinc que importan y las que solo parecen importar, qué es exactamente el demonio de compilación y por qué su memoria decide tu tiempo de espera, cómo funciona la compilación incremental con instantáneas de la clasificación de dependencias, y una metodología para diagnosticar un build lento con datos en lugar de con supersticiones.

⏱ 27 min

Casi nadie invoca kotlinc a mano, y sin embargo casi todo el mundo sufre sus consecuencias todos los días. El compilador que ejecuta tu sistema de construcción es el mismo binario, con las mismas opciones, ejecutándose dentro de un proceso que alguien decidió reutilizar o no, con una memoria que alguien configuró o no, y sobre un conjunto de ficheros que alguien calculó como afectados por tu último cambio. Cuando un build tarda de más, la causa está siempre en una de esas cuatro decisiones, y son cuatro decisiones observables. Esta lección va de dejar de adivinar.

🎯 Al terminar esta lección sabrás
  • Manejar las opciones de kotlinc que cambian el comportamiento del compilador y distinguirlas de las cosméticas.
  • Explicar qué hace el demonio de compilación y cómo elegir la estrategia de ejecución correcta.
  • Describir el mecanismo de la compilación incremental y qué hace que se degrade a compilación completa.
  • Diagnosticar un build lento con informes de construcción en lugar de con intuiciones.

kotlinc por dentro

El compilador de línea de órdenes acepta tres familias de opciones. Las estándar, sin prefijo, son estables y forman parte del contrato: destino de salida, versión de la JVM objetivo, nombre del módulo, ruta de clases. Las que empiezan por -X son avanzadas y pueden cambiar entre versiones. Las que empiezan por -XX son internas y no deberías depender de ellas fuera de una investigación.

# Lo minimo util: un jar autocontenido.
kotlinc Main.kt -include-runtime -d app.jar

# Lo que de verdad usa un proyecto serio.
kotlinc src/ \
  -d build/classes \
  -jvm-target 21 \
  -module-name dominio \
  -Werror \
  -progressive \
  -opt-in kotlin.time.ExperimentalTime

De ese bloque, dos opciones merecen comentario porque cambian la semántica y no solo la salida. -progressive activa el modo progresivo: las correcciones de comportamiento que normalmente llegan como aviso durante un ciclo de vida se aplican ya como error, lo cual acorta tu deuda futura a cambio de romperte antes. Y -Werror convierte todo aviso en error, que es una política excelente en integración continua y una tortura durante una migración; conviene activarla en el pipeline y no en la máquina de quien programa.

Hay una tercera que conviene entender bien porque la gente la confunde con las dos anteriores. -language-version y -api-version no aceleran ni endurecen nada: fijan, respectivamente, qué reglas del lenguaje se aplican y contra qué versión de la biblioteca estándar se permite compilar. La segunda es la que evita el fallo clásico de publicar una biblioteca que usa una función añadida ayer y que revienta en el proyecto de quien todavía no ha actualizado.

# Compilar con reglas de 2.2 aunque el compilador sea mas nuevo,
# y prohibir el uso de API de la biblioteca posterior a 2.2.
kotlinc -language-version 2.2 -api-version 2.2 src/ -d build/classes

En el terreno de las avanzadas, la que interesa a este nivel es -Xbackend-threads, que ejecuta las bajadas del backend en paralelo. Acepta un número de hilos, y el valor cero significa un hilo por núcleo. El intercambio es explícito: menos tiempo a cambio de más memoria dinámica, proporcional al número de hilos.

⚠️
Paralelizar dos veces suele ser peor que una

Si tu proyecto son cien módulos pequeños y tu sistema de construcción ya los compila en paralelo, añadir paralelismo dentro de cada compilación normalmente empeora el resultado por cambios de contexto y por presión de memoria. La opción brilla en el caso contrario: un módulo monolítico enorme que no se puede repartir de otra manera.

El demonio: el proceso que no quieres levantar

Arrancar una máquina virtual de Java e inicializar el compilador cuesta del orden de segundos. Hacerlo una vez por módulo y por build es inaceptable, así que el compilador vive en un proceso persistente —el demonio de Kotlin— al que el sistema de construcción le envía trabajos. El demonio conserva la máquina virtual caliente, las clases del compilador ya cargadas y, sobre todo, el código ya compilado por el compilador de la propia JVM, que es donde está la mayor parte del ahorro.

Existen tres estrategias de ejecución, y elegir la equivocada es una de las causas más frecuentes de builds lentos sin explicación aparente.

🏭

Demonio

Proceso separado y persistente. Es el valor por defecto y el único que aprovecha el calentamiento entre compilaciones. Tiene su propia memoria, independiente de la del sistema de construcción.

🧵

En proceso

El compilador se ejecuta dentro del proceso del sistema de construcción. Ahorra un proceso, pero no soporta compilación incremental y compite por la misma memoria.

🚪

Fuera de proceso

Un proceso nuevo por cada compilación. Es la opción más lenta y solo tiene sentido para aislar un fallo o reproducir un entorno limpio.

🧠

Memoria del demonio

Se configura aparte de la del resto del build. Un demonio corto de memoria pasa el tiempo recolectando basura o muere y se reinicia, y ambas cosas se ven como lentitud inexplicable.

# En gradle.properties
kotlin.compiler.execution.strategy=daemon
kotlin.daemon.jvmargs=-Xmx4g -XX:+UseParallelGC

Hay un detalle que causa mucha confusión: la memoria del demonio de Kotlin no es la memoria del demonio de Gradle. Si has subido una y sigues viendo pausas largas, lo más probable es que hayas subido la que no era. Y si el demonio muere y se relanza a mitad del build, el síntoma que verás no será un error sino una compilación que de pronto tarda el triple, porque acaba de perder todo su calentamiento.

Un segundo detalle: el demonio no es único por máquina, sino por conjunto de argumentos de arranque. Dos proyectos con configuraciones de memoria distintas levantan dos demonios, y cada uno consume su memoria completa. En una máquina con varios proyectos abiertos esto explica por qué el sistema empieza a intercambiar sin que ninguna herramienta parezca culpable. Si sospechas de esto, la comprobación es directa.

# Procesos de compilacion vivos en la maquina.
jps -l | grep -i kotlin

La regla razonable es fijar una configuración común y generosa para todos tus proyectos, en lugar de afinar cada uno por separado, precisamente para que compartan demonio. Y si una compilación falla de forma imposible de reproducir, matar el demonio antes de investigar ahorra horas: un demonio con estado corrupto produce síntomas que no corresponden a ningún error real de tu código.

Incremental: qué se recompila y por qué

La compilación incremental parte de una idea simple y tiene una implementación exigente. La idea: si solo has tocado un fichero, recompila ese fichero y aquellos cuyo resultado pueda cambiar por su culpa. La exigencia: decidir con exactitud cuáles son esos, porque equivocarse a la baja produce clases inconsistentes y equivocarse al alza produce recompilaciones inútiles.

Dentro del módulo, el compilador mantiene un registro de qué declara y qué usa cada fichero. Al cambiar uno, calcula el conjunto de declaraciones cuya firma se ha modificado y propaga la invalidación a los ficheros que dependen de ellas, repitiendo el proceso hasta que se estabiliza. Aquí está la clave práctica: si tu cambio no altera ninguna firma —tocas el cuerpo de una función y nada más— la propagación se detiene enseguida. Si tocas una constante pública, un supertipo o una función usada en todas partes, la propagación se lleva medio módulo por delante.

Entre módulos, el mecanismo se apoya en instantáneas de la interfaz binaria de las dependencias. Cuando un módulo del que dependes se recompila, tu módulo compara la instantánea nueva con la anterior: si solo cambiaron cuerpos, no hay nada que hacer; si cambió una firma pública, se invalida únicamente lo que la usaba. Ese refinamiento es lo que evitó que cualquier cambio en un módulo base recompilase todo lo que hubiera encima.

flowchart TD
A[Cambio en un fichero] --> B[Comparar firmas con la ejecucion anterior]
B -->|Solo cuerpos| C[Recompilar ese fichero]
B -->|Cambia una firma| D[Propagar a dependientes]
D --> E[Repetir hasta estabilizar]
C --> F[Salida]
E --> F
G[Cambio de version o de opciones] --> H[Compilacion completa]

De ahí sale una regla de diseño que paga todos los días: mantén estables las firmas y mueve con libertad los cuerpos. Un cambio que no altera ninguna firma pública es, literalmente, el cambio más barato que puedes hacerle a un proyecto grande, y un cambio en una firma muy usada es el más caro, aunque ambos ocupen una línea en el diff.

Conviene conocer las situaciones que degradan el incremental a completo, porque son pocas y muy reconocibles: cambiar la versión de Kotlin o del sistema de construcción, cambiar las opciones del compilador, añadir o quitar un plugin, tocar el fichero de construcción de forma que altere la ruta de clases, y perder los datos de la ejecución anterior. Esa última incluye el caso más autoinfligido de todos, que es limpiar el proyecto por costumbre. Cada limpieza tira exactamente la información que hace posible el incremental.

Diagnosticar en lugar de creer

Un build lento se investiga con datos. El sistema de construcción de Kotlin puede emitir informes con el tiempo desglosado por tarea y por fase interna del compilador, y ese desglose es lo que distingue las cuatro causas posibles.

# En gradle.properties
kotlin.build.report.output=file
kotlin.build.report.file.output_dir=build/reports/kotlin

Con el informe delante, la lectura sigue un orden fijo. Primero mira si tus compilaciones fueron incrementales o completas: si son completas sin que hayas cambiado nada estructural, el problema es de invalidación y no de velocidad, y ninguna opción del compilador te va a salvar. Segundo, mira el reparto entre frontend y backend: un frontend dominante suele señalar código con inferencia pesada, jerarquías profundas o mucha generación de fuentes; un backend dominante señala módulos monolíticos, y es el único caso donde -Xbackend-threads ayuda de verdad. Tercero, mira si el tiempo está fuera del compilador: procesado de anotaciones, generación de código, tareas de recursos. Es sorprendentemente habitual descubrir que el compilador de Kotlin no era el problema.

# Medida directa por fases desde la linea de ordenes.
kotlinc -Xreport-perf src/ -d build/classes

Una advertencia sobre cómo medir: la primera compilación después de arrancar es siempre la más lenta, porque el demonio aún no está caliente y las cachés están frías. Comparar una configuración con otra usando la primera ejecución de cada una es la forma más común de llegar a conclusiones falsas. Mide siempre después de dos o tres pasadas de calentamiento, y haz el cambio que quieras evaluar sobre el mismo fichero cada vez.

Y hay un cuarto sospechoso que no aparece en ningún informe: la estructura del proyecto. Un módulo del que todo depende y que cambia a diario es un cuello de botella que ninguna configuración arregla, porque cada uno de sus cambios invalida el grafo entero. Cuando los tres primeros diagnósticos salen limpios y el build sigue siendo lento, la respuesta ya no es una opción del compilador sino un corte en el grafo de módulos.

Optimizar un build no consiste en hacer que el compilador vaya más rápido sino en conseguir que trabaje menos, y esas dos frases apuntan a decisiones arquitectónicas opuestas

Hay una asimetría brutal en la economía de una compilación, y casi todo el tiempo que la gente pierde optimizando builds se pierde por ignorarla. Las opciones del compilador operan sobre el coste de compilar una unidad de trabajo, y ese coste es lineal en el tamaño y ya está muy optimizado: puedes aspirar a mejoras de decenas por ciento, y las consigues a cambio de memoria o de riesgo. La estructura del proyecto opera sobre cuántas unidades de trabajo existen tras un cambio, y ese número no es lineal en nada: depende del grafo de dependencias y de la estabilidad de las interfaces, y puede variar en dos órdenes de magnitud entre dos proyectos con la misma cantidad de código. Cambiar el cuerpo de una función en un módulo hoja recompila un fichero; cambiar una constante pública en un módulo del que cuelga todo recompila cien mil líneas. El compilador es igual de rápido en ambos casos y el reloj marca segundos en uno y minutos en el otro. De ahí sale una regla que conviene interiorizar antes de tocar una sola opción: el tiempo de tu build lo determina la forma de tu grafo de módulos y la estabilidad de tus interfaces públicas, no la configuración de la herramienta. Y de ahí sale también una manera distinta de leer decisiones de diseño que parecían puramente estéticas. Separar la interfaz de la implementación en módulos distintos no es purismo: hace que los cambios de comportamiento no toquen ninguna firma y mueran donde nacen. Evitar constantes públicas propagadas es lo mismo desde otro ángulo. Restringir la visibilidad con internal no es solo higiene: reduce literalmente la superficie que puede invalidar a otros. Mantener módulos pequeños y con pocas dependencias entrantes es lo que convierte un cambio en trabajo local. Todo eso ya lo recomendaba el buen diseño por razones de acoplamiento, y resulta que la máquina cobra por el acoplamiento un peaje medible en segundos, todos los días, a cada persona del equipo. Cuando alguien pregunta cómo acelerar el build, la respuesta honesta casi nunca es una bandera: es que el grafo está mal cortado y llevas meses pagándolo.

⚔️ Mide tu propio build
  1. Activa los informes de construcción y guarda el desglose de una compilación completa y de una incremental. Compara el reparto entre frontend y backend.
  2. Cambia solo el cuerpo de una función y anota cuántos ficheros se recompilan. Repite cambiando su firma y compara los números.
  3. Sube deliberadamente la memoria del demonio de Kotlin y vuelve a medir. Si no cambia nada, confirma con el informe que el demonio no estaba reiniciándose.
  4. Prueba -Xbackend-threads en tu módulo más grande y en uno pequeño. Documenta en cuál ayuda y explica por qué usando lo aprendido.
  5. Dibuja el grafo de dependencias de tus módulos y señala el que más veces se toca con más dependientes. Propón un corte concreto y estima su efecto.