wandres.dev
EL COMPILADOR · SIL y optimización

Diagnosticar compilaciones lentas

Método para convertir una compilación lenta en un problema medible: banderas del frontend que avisan de funciones y expresiones caras, volcado de estadísticas y cronometraje del conductor. Por qué la comprobación de tipos domina el coste, qué construcciones disparan la búsqueda combinatoria y cuáles son las demás causas frecuentes, con las correcciones concretas para cada una.

⏱ 20 min

Una compilación lenta se vive como un fenómeno atmosférico: llega, molesta y nadie sabe de dónde viene. Es una ilusión producida por la falta de instrumentación, porque el compilador de Swift sabe con precisión de microsegundos cuánto le costó cada cuerpo de función y cada expresión, y lo dirá en cuanto se lo pidas. En la inmensa mayoría de los proyectos el culpable no está repartido: unas pocas expresiones concentran una fracción desproporcionada del tiempo, y casi siempre por la misma razón estructural. Diagnosticar consiste en dejar de sospechar y empezar a ordenar por coste.

🎯 Al terminar esta lección sabrás
  • Instrumentar una compilación para obtener el coste por función y por expresión en lugar de un total agregado.
  • Explicar por qué la resolución de restricciones puede volverse combinatoria y qué construcciones la disparan.
  • Reconocer las causas frecuentes que no son del comprobador de tipos y distinguirlas por su firma en las medidas.
  • Aplicar un protocolo reproducible de medición, corrección y verificación sobre un proyecto real.

Medir antes de tocar nada

El primer error es optimizar por intuición. Las banderas útiles son pocas y se pasan al frontend a través del conductor; con umbrales en milisegundos, cada cuerpo o expresión que los supere genera un aviso con su ubicación exacta.

# Avisos por umbral: funciones y expresiones que tardan de mas
swift build -Xswiftc -Xfrontend -Xswiftc -warn-long-function-bodies=100 \
            -Xswiftc -Xfrontend -Xswiftc -warn-long-expression-type-checking=100

# Coste de cada cuerpo, ordenado de mayor a menor
swiftc -Onone -Xfrontend -debug-time-function-bodies Fuente.swift 2>&1 \
  | grep -oE "^[0-9.]+ms.*" | sort -rn | head -20

# Estadisticas del frontend por fase, en formato legible por herramientas
swiftc -stats-output-dir ./stats -Onone -c *.swift

# Reparto del tiempo entre las invocaciones del conductor
swift build -Xswiftc -driver-time-compilation
⏱️

Umbrales de aviso

Marcan cuerpos y expresiones que superan un tiempo dado. Son la herramienta de primera línea porque señalan ubicación exacta y se pueden dejar activadas de forma permanente.

📊

Estadísticas del frontend

Vuelcan contadores por fase en un formato procesable: cuántas restricciones se resolvieron, cuántas funciones se especializaron, cuánto duró cada etapa.

🧵

Cronometraje del conductor

Reparte el tiempo entre invocaciones y revela si el problema es una invocación monstruosa o miles de invocaciones pequeñas mal paralelizadas.

La lectura correcta de esa salida es una distribución, no una lista. Ordena por coste descendente y observa la forma: si las diez primeras entradas concentran la mayor parte del tiempo, tienes un problema local y muy corregible; si el tiempo está repartido de forma plana entre cientos de funciones, el problema no es ninguna expresión concreta sino la estructura de la compilación, y hay que mirar hacia los modos, las dependencias y la invalidación incremental.

💡
Mide siempre en el modo en que duele

Una compilación de desarrollo sin optimizar y una de publicación con módulo completo tienen perfiles de coste distintos y causas distintas. La primera suele estar dominada por la comprobación de tipos; la segunda, por la especialización y la bajada a bajo nivel. Diagnosticar una con las herramientas de la otra produce conclusiones inútiles.

El comprobador de tipos y la explosión combinatoria

La causa dominante en desarrollo es casi siempre la misma. Swift resuelve tipos generando restricciones sobre la expresión completa y buscando una solución consistente; cuando hay varias sobrecargas posibles para un operador, el sistema plantea una disyunción y explora alternativas con retroceso. El espacio de búsqueda crece de forma combinatoria con el número de disyunciones simultáneas, y una sola expresión puede contener docenas.

Cuatro ingredientes multiplican el problema, y suelen aparecer juntos:

  • Literales sin tipo fijado. Un literal numérico admite cualquier tipo que sea expresable por ese literal; hasta que la solución completa no se cierra, cada uno es una variable libre más.
  • Operadores muy sobrecargados. La suma, la concatenación y la coalescencia de opcionales tienen muchas sobrecargas, y cada aparición añade una disyunción.
  • Mezcla de tipos numéricos. Combinar tipos de coma flotante distintos en la misma expresión introduce conversiones candidatas que amplían la búsqueda en lugar de restringirla.
  • Cierres sin firma explícita. Si el tipo de los parámetros y del retorno debe inferirse desde fuera, el cuerpo entra en el mismo problema de restricciones en lugar de resolverse aparte.
// Cara: literales libres, operadores sobrecargados y mezcla de tipos
let valor = (a + b) * 2 + c / 3.0 - (d ?? 0) + Double(e) * 1.5

// Barata: cada paso fija tipos y cierra el subproblema
let base: Double = Double(a + b) * 2
let ajuste: Double = Double(c) / 3.0
let extra: Double = Double(d ?? 0) + Double(e) * 1.5
let valor: Double = base + ajuste + extra

Hay un caso particular que merece mención propia porque domina las quejas de compilación lenta en interfaces declarativas: un cuerpo de vista construido con un constructor de resultados y encadenado con decenas de modificadores es, para el solucionador, una única expresión gigante. Cada modificador es una llamada genérica que devuelve un tipo distinto, cada rama condicional multiplica las combinaciones y el conjunto se resuelve de una sola vez. Extraer subvistas no es una preferencia estética: parte una expresión intratable en varias tratables.

// Cada extraccion a una subvista o a una variable calculada con tipo
// explicito cierra un subproblema y lo saca del sistema de restricciones.
private var cabecera: some View { ... }
private var cuerpo: some View { ... }

La corrección es siempre la misma idea: dar información para podar la búsqueda. Anotar el tipo de la variable de destino, partir la expresión en enlaces intermedios con tipo explícito, escribir la firma completa de los cierres, evitar colecciones literales enormes con elementos heterogéneos y construirlas programáticamente si son grandes. Una expresión de doce segundos suele bajar a milisegundos con dos anotaciones bien puestas.

flowchart TB
A[Compilacion lenta] --> B[Instrumentar con umbrales y estadisticas]
B --> C{Como se reparte el tiempo}
C -->|Concentrado en pocas expresiones| D[Comprobador de tipos]
C -->|Plano en todo el modulo| E[Estructura de la compilacion]
D --> F[Anotar tipos y partir expresiones]
D --> G[Firmas explicitas en cierres]
E --> H[Revisar modo de compilacion e incrementalidad]
E --> I[Dependencias, interoperabilidad y macros]
F --> J[Volver a medir y comparar]
G --> J
H --> J
I --> J
style D fill:#f9e2af,color:#11111b
style E fill:#89b4fa,color:#11111b
style J fill:#a6e3a1,color:#11111b

Las demás causas y su firma

Cuando el tiempo está repartido, el culpable es estructural y cada causa deja una huella reconocible en las medidas.

Recompilación total en cada edición. Si el módulo se compila con visión completa, cualquier cambio invalida todo. También ocurre sin esa bandera cuando la edición toca una declaración de la que dependen muchos archivos, o cuando la información incremental se invalida por un cambio en las opciones. Firma: el tiempo no depende de qué archivo tocaste.

Expansión de macros. Cada macro se resuelve en un proceso plugin externo al que hay que enviar el árbol y del que hay que recibir código que se vuelve a parsear y comprobar. Firma: el coste escala con el número de sitios de uso y aparece antes de cualquier optimización.

Síntesis del compilador. Las conformidades generadas automáticamente para tipos grandes, y los constructores de resultados con muchas ramas condicionales, generan código que después hay que comprobar entero. Firma: funciones concretas y muy caras cuyo cuerpo apenas tiene líneas escritas a mano.

Interoperabilidad y cabeceras. Importar interfaces extensas obliga a procesarlas y a mantener el mapeo de tipos. Firma: coste alto y constante en archivos que casi no tienen lógica propia.

Especialización desbocada en publicación. Un genérico muy reutilizado puede producir decenas de clones que después hay que bajar y optimizar uno a uno. Firma: la compilación de desarrollo es sana y la de publicación se dispara en la fase final.

⚠️
No confundas lento con caro

Un cuerpo que aparece en la lista de los más caros puede ser sencillamente grande y estar bien. Lo que delata un problema real no es el valor absoluto sino la desproporción: una expresión de tres líneas que cuesta lo mismo que un archivo entero es una anomalía; una función de trescientas líneas que cuesta mucho es aritmética. Ordena por coste por línea escrita, no solo por coste.

Un protocolo reproducible

  1. Fija una línea base. Compila desde limpio tres veces, anota la mediana y separa el tiempo de desarrollo del de publicación. Sin línea base no hay mejora demostrable.
  2. Instrumenta con umbrales generosos y baja. Empieza en un umbral alto para ver solo lo escandaloso, corrígelo y vuelve a bajar el umbral. Perseguir avisos de umbral bajo desde el principio produce ruido.
  3. Corrige una causa por vez y vuelve a medir. Dos cambios simultáneos hacen imposible atribuir la mejora, y en compilación es habitual que un cambio plausible empeore el resultado.
  4. Distingue el arranque en frío de la iteración. Una compilación desde limpio y una recompilación tras editar una línea miden cosas distintas y se corrigen con acciones distintas. La segunda es la que realmente vives, y por tanto la que debe gobernar tus decisiones de arquitectura de módulos.
  5. Escribe el resultado en el repositorio. Un umbral de aviso configurado en la compilación es una prueba de regresión: convierte la lentitud en un fallo visible el día que se introduce, no seis meses después.
ℹ️
El umbral correcto es el que casi no dispara

Un valor demasiado bajo llena la salida de avisos, el equipo aprende a ignorarlos y el mecanismo deja de existir. Un valor demasiado alto no captura nada. La calibración práctica consiste en empezar por encima de la peor expresión actual del proyecto y bajarlo escalonadamente a medida que corriges, hasta el punto en que dispara unas pocas veces al mes y cada disparo señala algo real.

La lentitud del compilador es el precio contable de la expresividad, y por eso se paga anotando

Merece la pena mirar de frente lo que ocurre cuando una expresión tarda diez segundos en comprobarse, porque no es un error del compilador: es el comportamiento correcto de un solucionador al que se le ha planteado un problema mal condicionado. Swift eligió un sistema de tipos con inferencia bidireccional, sobrecarga por tipo de retorno y literales polimórficos, y esa combinación es precisamente la que hace posible escribir código que parece no tener anotaciones y aun así sea totalmente estático. El precio de ese regalo es que el compilador debe buscar, y toda búsqueda con disyunciones tiene un peor caso exponencial. La consecuencia práctica es una de las asimetrías más útiles de todo el ecosistema: para ti, escribir una anotación de tipo cuesta cinco segundos y no cambia nada de la semántica del programa; para el solucionador, esa misma anotación puede colapsar un espacio de búsqueda de millones de candidatos a uno solo. Es la relación de intercambio más favorable que vas a encontrar en el desarrollo de software, y la mayoría de los equipos la ignoran durante años a cambio de una estética de brevedad. Hay además una lección de segundo orden que trasciende a Swift. Un compilador es la única herramienta de tu cadena que ejecutas cientos de veces al día, y por tanto su latencia no es un coste de máquina sino un coste cognitivo: determina si iteras dentro del ciclo de atención o fuera de él. Tratar el tiempo de compilación como una métrica de producto —medida, con línea base, con umbrales que fallan la integración cuando se cruzan— no es pulcritud de ingeniería. Es la decisión que separa un proyecto donde probar una idea cuesta segundos de uno donde cuesta café.

⚔️ Instrumenta un proyecto real
  1. Establece una línea base con tres compilaciones limpias y anota la mediana en desarrollo y en publicación.
  2. Activa los avisos por umbral y produce la lista ordenada de las veinte funciones y expresiones más caras.
  3. Toma la peor expresión, pártela en enlaces con tipo explícito y vuelve a medir su coste concreto.
  4. Cambia una sola línea de un archivo periférico y de un archivo central, y compara los tiempos para diagnosticar tu incrementalidad.
  5. Fija en la configuración del proyecto un umbral de aviso permanente y justifica el número que elegiste con los datos que acabas de reunir.