wandres.dev
EL COMPILADOR · SIL y optimización

Whole-module optimization: ver el módulo entero

Qué cambia cuando el compilador optimiza todos los archivos como una sola unidad en lugar de archivo a archivo: inserción y especialización entre archivos, deducción de finalidad sobre tipos internos, eliminación de código muerto a escala de módulo. Y qué se paga a cambio en incrementalidad, paralelismo y tiempo de compilación, con el criterio para elegir modo según el contexto.

⏱ 19 min

La unidad de compilación no es un detalle de fontanería: define el límite de lo que el compilador puede demostrar. Compilando archivo a archivo, cada llamada a algo declarado en otro sitio es una caja negra, y toda la cascada de optimizaciones se detiene ahí. Con visión de módulo completo, esas fronteras internas desaparecen de golpe y el optimizador pasa a razonar sobre un único grafo con todos los cuerpos a la vista. WMO no es una bandera que hace las cosas más rápidas: es un cambio en la cantidad de verdad disponible, y su precio se paga íntegro en el tiempo de compilación.

🎯 Al terminar esta lección sabrás
  • Distinguir el modo por archivo primario, el modo por lotes y la optimización de módulo completo.
  • Enumerar las deducciones que solo son posibles cuando el compilador ve todos los archivos a la vez.
  • Explicar por qué WMO destruye la compilación incremental y qué paralelismo conserva.
  • Elegir el modo adecuado para desarrollo, para publicación y para una biblioteca distribuida.

Tres maneras de compilar el mismo módulo

Antes de hablar de qué gana WMO conviene fijar contra qué se compara, porque Swift admite tres regímenes distintos y la diferencia entre ellos no es de intensidad sino de naturaleza.

En el modo clásico, el compilador procesa un archivo primario por invocación y usa el resto solo como fuente de declaraciones: conoce las firmas, no los cuerpos. Es lo que permite recompilar únicamente lo que cambió, y también lo que convierte cada llamada entre archivos en una frontera opaca.

El modo por lotes agrupa varios archivos primarios en una misma invocación del frontend para amortizar el coste de arranque y de carga de módulos, pero conserva la semántica anterior: la granularidad de dependencias sigue siendo el archivo. Es el modo natural de las compilaciones de desarrollo.

La optimización de módulo completo elimina la distinción: todos los archivos son primarios, se construye un único módulo de SIL con todos los cuerpos y el pipeline de optimización corre sobre él. Es el modo por defecto de las configuraciones de publicación.

📄

Archivo primario

Un cuerpo visible por invocación. Máxima incrementalidad, mínima información. Toda llamada entre archivos es opaca.

📦

Por lotes

Varios primarios por proceso. Amortiza el arranque sin cambiar la granularidad de dependencias. Modo típico de desarrollo.

🌐

Módulo completo

Un solo módulo de SIL con todo dentro. Máxima información, cero incrementalidad a escala de archivo.

# Publicacion: modulo completo con optimizacion, el valor por defecto
swiftc -O -whole-module-optimization *.swift -o binario
swift build -c release

# Desarrollo: por lotes, incremental, sin optimizar
swiftc -Onone -enable-batch-mode -c *.swift

# Comprobar que modo esta usando realmente tu proyecto
swift build -c release -Xswiftc -driver-time-compilation 2>&1 | tail -20

Cuatro deducciones que exigen ver el módulo entero

La primera y más obvia es la inserción en línea entre archivos. Sin el cuerpo, no hay inserción; sin inserción, no hay propagación de constantes ni análisis de escape ni eliminación de conteo de referencias en el sitio de llamada. WMO recupera de golpe toda esa cascada para cada llamada interna del módulo.

La segunda es la especialización de genéricos entre archivos. Un genérico declarado en un archivo y usado con un tipo concreto en otro solo puede clonarse si ambas cosas están en la misma unidad. Sin WMO, todo genérico cuyo uso vive en otro archivo se queda en su forma indirecta con tablas de testigos. Como en la práctica casi nadie escribe los genéricos en el mismo archivo donde los usa, esta única diferencia decide si la biblioteca de abstracciones de tu proyecto cuesta cero o cuesta una indirección por operación.

La tercera es la más silenciosa y probablemente la más rentable: el análisis de jerarquía de clases. Con el módulo entero a la vista, el compilador puede probar que una clase de visibilidad interna no tiene subclases en ningún archivo, o que un método interno no se sobrescribe en ninguna parte, y tratarlos como finales aunque no lleven la palabra. Eso convierte despachos por tabla virtual en llamadas directas sin que el autor haya escrito nada. Archivo a archivo esa prueba es imposible, porque la subclase podría estar en cualquier otro sitio.

// Sin la palabra final. Con vision de modulo, el compilador prueba
// que nadie hereda de Cache dentro del modulo y despacha directo.
// Archivo a archivo, la misma llamada pasa por la tabla virtual.
internal class Cache {
    private var datos: [String: Int] = [:]
    func leer(_ clave: String) -> Int? { datos[clave] }
}

Este tercer punto tiene una implicación de estilo que merece decirse en voz alta: escribir la palabra final en todas partes es, dentro de un módulo compilado con visión completa, redundante en la mayoría de los casos, porque el compilador ya lo dedujo. Sigue siendo valioso como documentación de intención y como garantía en tipos públicos, donde la deducción es imposible por definición, pero no es la palanca de rendimiento que la tradición sugiere.

La cuarta es la eliminación de código muerto a escala de módulo: funciones internas nunca llamadas, conformidades nunca usadas, ramas que dependían de una constante global cuya única asignación ahora es visible. Reduce el binario y, más importante, simplifica el grafo sobre el que corren los demás pases.

flowchart TB
subgraph SIN[Sin vision de modulo]
  A1[Archivo A: cuerpo visible] --> B1[Archivo B: solo firmas]
  B1 --> C1[Llamada opaca: sin inline ni especializacion]
end
subgraph CON[Con vision de modulo]
  A2[Todos los archivos en un modulo de SIL] --> B2[Inline y especializacion entre archivos]
  A2 --> C2[Analisis de jerarquia: finalidad deducida]
  A2 --> D2[Eliminacion de codigo muerto global]
  B2 --> E2[Cascada completa de optimizacion]
  C2 --> E2
  D2 --> E2
end
style C1 fill:#f38ba8,color:#11111b
style E2 fill:#a6e3a1,color:#11111b

El precio: incrementalidad y tiempo

Lo que se gana en información se paga en granularidad. Con WMO, cambiar una línea de un archivo obliga a recompilar el módulo entero, porque la unidad de dependencia ya no es el archivo. En un módulo de cientos de archivos eso convierte una edición trivial en una compilación completa, y es la razón por la que nadie desarrolla en ese modo.

Esa es la razón por la que la configuración de desarrollo y la de publicación no son dos ajustes del mismo interruptor sino dos regímenes distintos con objetivos opuestos: una optimiza el tiempo hasta ver el resultado de un cambio, la otra optimiza el tiempo de ejecución del producto final. Medir una con los criterios de la otra lleva sistemáticamente a conclusiones equivocadas.

El paralelismo tampoco desaparece pero se desplaza. El pipeline de optimización de SIL opera sobre un único módulo y es esencialmente secuencial; lo que sí puede repartirse entre hilos es la fase posterior, la bajada a la representación de bajo nivel y su compilación, dividiendo la salida en varios archivos objeto. Por eso el número de hilos ayuda al final del proceso pero no al principio.

Hay además un efecto de segundo orden que sorprende a mucha gente: WMO puede aumentar el tiempo total mucho más de lo que sugiere la suma de las partes, porque la especialización agresiva multiplica la cantidad de código que los pases posteriores deben procesar. Un módulo con genéricos muy reutilizados puede generar decenas de clones y hacer que la fase de bajada se dispare.

La consecuencia arquitectónica es que el tamaño del módulo pasa a ser una decisión de rendimiento de compilación. Un módulo gigante maximiza la información disponible y maximiza también el coste de cada recompilación; partirlo en varios recupera incrementalidad a cambio de erigir fronteras opacas justo donde antes no las había. No existe una respuesta universal: existe una medición por proyecto.

⚠️
La frontera de módulo sigue siendo opaca

WMO derriba las fronteras internas, no las externas. Una llamada a otro módulo sigue sin ver el cuerpo salvo que este se haya serializado explícitamente, y si el módulo se compiló con evolución de biblioteca activada la opacidad es aún mayor: ni siquiera la disposición en memoria de sus tipos es conocida, porque el contrato promete poder cambiarla sin recompilar a los consumidores. Rendimiento y resiliencia son objetivos directamente opuestos, y el sistema te obliga a elegir declarándolo.

Más allá del módulo: serializar, congelar, agrupar

Una vez agotado lo que WMO ofrece dentro del módulo, la única forma de seguir es exportar información hacia fuera, y cada mecanismo tiene un precio contractual distinto.

Serializar el cuerpo de una función pública lo copia dentro del módulo compilado, de modo que el consumidor pueda insertarlo en línea y especializarlo. A cambio, ese cuerpo pasa a formar parte del contrato binario: cambiarlo no afecta a quien recompile, pero sí a quien ya se compiló contra la versión anterior. Todo lo que ese cuerpo use debe además ser visible desde fuera, aunque no sea parte de la interfaz pública.

Congelar un tipo promete que su disposición en memoria y su conjunto de casos no cambiarán, lo que devuelve al consumidor la capacidad de asignarlo en la pila y de tratar sus condicionales como exhaustivos. Es la renuncia más fuerte y la más rentable en tipos pequeños y estables del camino caliente.

@frozen
public struct Punto {          // disposicion congelada: el consumidor la conoce
    public var x: Double
    public var y: Double
    @inlinable public init(x: Double, y: Double) { self.x = x; self.y = y }
}

@inlinable
public func distancia(_ a: Punto, _ b: Punto) -> Double {
    ((a.x - b.x) * (a.x - b.x) + (a.y - b.y) * (a.y - b.y)).squareRoot()
}

Existe finalmente un nivel intermedio pensado justo para el caso más común: varios módulos que se distribuyen y se compilan siempre juntos como una unidad de distribución. Un ámbito de visibilidad situado entre lo interno y lo público permite compartir declaraciones dentro de ese grupo sin publicarlas, y habilita una optimización que atraviesa los módulos del grupo con la misma lógica que WMO aplica dentro de uno. Es la manera de partir un proyecto grande en módulos por higiene de arquitectura sin pagar por ello una barrera de optimización en cada frontera.

💡
Criterio operativo

Desarrollo: sin optimizar y por lotes, con dependencias incrementales sanas. Publicación de una aplicación: módulo completo con optimización, midiendo el tiempo de compilación como un coste real de integración continua. Biblioteca distribuida en binario: módulo completo dentro, más una decisión consciente sobre qué cuerpos serializar hacia fuera y qué tipos congelar, sabiendo que cada uno de esos gestos es un compromiso permanente de estabilidad.

La unidad de compilación es una decisión epistemológica, no de ingeniería de compilación

Detrás de la elección entre archivo y módulo hay una pregunta que no tiene nada de técnica: qué está permitido saber. Un compilador solo puede optimizar lo que puede demostrar, y solo puede demostrar sobre aquello que tiene delante; ampliar la unidad de compilación es literalmente ampliar el universo del discurso. Visto así, WMO no es una bandera de velocidad sino una redefinición de la frontera entre lo conocido y lo desconocido, y todas sus consecuencias —buenas y malas— se derivan de esa única redefinición. La deducción de finalidad lo muestra con una claridad casi matemática: la afirmación este método no se sobrescribe nunca es indemostrable examinando un archivo y trivial examinando el módulo, y no porque el análisis sea más listo, sino porque en el primer caso el enunciado ni siquiera está bien formado. Y ahí aparece la tensión que estructura todo el ecosistema de Swift. Cada frontera que existe para permitir el cambio independiente —el módulo, la evolución de biblioteca, la estabilidad de la interfaz binaria— es por construcción una frontera de ignorancia para el optimizador. No es un defecto de implementación que un día se arreglará: es el mismo hecho visto desde dos lados. Prometer a un consumidor que puedes cambiar la disposición de un tipo sin que recompile es exactamente prometerle al optimizador que nunca sabrá esa disposición. Por eso las anotaciones que serializan cuerpos o congelan estructuras no son atajos de rendimiento sino renuncias contractuales explícitas, y por eso la compilación incremental y la optimización global son irreconciliables en el fondo: la primera vive de no mirar lo que no cambió, y la segunda vive de mirarlo todo cada vez.

⚔️ Mide las dos caras del intercambio
  1. Compila un módulo pequeño en modo por lotes y en módulo completo y compara los tiempos y el tamaño del binario.
  2. Declara una clase interna sin la palabra final, con un método llamado en un bucle de otro archivo, y comprueba en el SIL si el despacho es directo en cada modo.
  3. Cambia una sola línea de un archivo y mide el tiempo de recompilación en ambos modos.
  4. Busca en el binario de publicación los clones especializados de un genérico usado desde varios archivos y cuenta cuántos se generaron.
  5. Activa la evolución de biblioteca en un módulo propio y enumera, leyendo el SIL del consumidor, qué optimizaciones dejaron de aplicarse.