Optimizaciones clave: especialización, inlining y ARC
Las tres transformaciones que definen el rendimiento de Swift y cómo se habilitan entre sí: clonado de genéricos para tipos concretos, inserción en línea guiada por heurísticas de coste y eliminación de retenciones y liberaciones redundantes. Qué condiciones necesita cada una, qué las bloquea y cómo verificar en el SIL que realmente ocurrieron.
El rendimiento de Swift no sale de un truco sino de una cascada. La especialización de genéricos convierte código abstracto en código concreto; la inserción en línea borra fronteras de llamada y con ellas las barreras que impedían razonar; la optimización de conteo de referencias recoge el trabajo redundante que las dos anteriores dejaron a la vista. Cada una alimenta a la siguiente, y ninguna funciona sin información. Por eso la pregunta útil nunca es si el compilador optimizó, sino qué sabía en ese punto y quién le quitó ese conocimiento.
- Explicar qué condiciones exactas habilitan la especialización de un genérico y cuáles la impiden.
- Describir cómo decide el compilador insertar en línea y por qué esa decisión desbloquea otras optimizaciones.
- Identificar los patrones que permiten eliminar retenciones y liberaciones y los que los bloquean.
- Verificar en un volcado de
SILque cada una de las tres transformaciones ocurrió.
Especialización: de una función a muchas
La especialización es la primera transformación de la cascada porque es la que introduce el hecho más valioso de todos: la identidad del tipo concreto. Sin ella, casi ninguna de las demás tiene sobre qué apoyarse.
Un genérico sin especializar se compila una sola vez y opera por indirección: el valor se maneja por dirección, con tamaño y alineación desconocidos, y cada operación del contrato pasa por una tabla de testigos. La especialización clona el cuerpo sustituyendo el parámetro de tipo por un tipo concreto, y el resultado es indistinguible de haber escrito esa función a mano.
La condición es doble y ambas partes deben cumplirse dentro de la misma unidad de optimización: el optimizador tiene que ver el cuerpo de la función genérica y tiene que conocer el tipo concreto en el punto de llamada. Dentro de un módulo compilado con visión completa esto es lo habitual. Cruzando una frontera de módulo, el cuerpo no viaja con la interfaz, y hay que serializarlo explícitamente aceptando que su implementación pasa a formar parte del contrato público.
@inlinable
public func maximo<T: Comparable>(_ xs: [T]) -> T? {
var mejor: T? = nil
for x in xs where mejor == nil || x > mejor! { mejor = x }
return mejor
}
// Pedir por adelantado una version concreta, util cuando conoces
// de antemano los tipos que dominaran en los consumidores.
@_specialize(where T == Int)
public func ordenar<T: Comparable>(_ xs: inout [T]) { xs.sort() }
Conviene entender también qué se paga. Cada clon es código nuevo en el binario, y un genérico instanciado con veinte tipos concretos multiplica por veinte su contribución al ejecutable, con la presión sobre la caché de instrucciones que eso implica. El compilador aplica por tanto heurísticas en lugar de clonarlo todo, y una parte de las sorpresas de rendimiento consiste en descubrir que decidió no hacerlo.
Hay un tercer factor menos conocido: la forma del genérico. El clonador aplica heurísticas de coste y se rinde ante cuerpos enormes o cadenas profundas de llamadas genéricas. Partir un genérico monolítico en varias funciones pequeñas suele habilitar la especialización que el monolito bloqueaba, sin cambiar una línea de la lógica.
Inserción en línea: la que habilita a las demás
Swift ejecuta dos inserciones distintas. La obligatoria corre siempre, incluso sin optimizar, y afecta a las funciones marcadas como transparentes: operadores de la biblioteca estándar y envoltorios triviales que deben desaparecer para que los diagnósticos y la semántica funcionen. La de rendimiento corre solo con optimización y decide caso por caso con un modelo de coste que pondera el tamaño del cuerpo, el número de llamadas, si el sitio de llamada está en un bucle y si insertar habilitará simplificaciones posteriores.
Lo importante no es el ahorro del salto, que en una máquina moderna es casi ruido. Lo importante es que insertar convierte una frontera opaca en código visible, y todo lo que estaba bloqueado por esa opacidad se vuelve posible: propagar constantes a través de la llamada, probar que un objeto no escapa y por tanto no necesita conteo de referencias, desvirtualizar la llamada siguiente porque ahora se conoce el tipo dinámico, eliminar comprobaciones de límites porque el índice resultó ser demostrablemente válido.
@inline(__always) func clamp(_ v: Int, _ lo: Int, _ hi: Int) -> Int {
min(max(v, lo), hi)
}
@inline(never) func fronteraDeMedicion(_ x: Int) -> Int { x &* 3 }
Las anotaciones existen y a veces son necesarias, pero son afirmaciones sobre el modelo de coste del compilador que sustituyen su juicio por el tuyo. Forzar la inserción de un cuerpo grande usado en muchos sitios infla el binario y presiona la caché de instrucciones, y el resultado neto puede ser más lento. La prohibición explícita, en cambio, es una herramienta legítima de medición: mantiene una frontera visible en el perfil.
flowchart LR A[Generico sin especializar] --> B[Especializacion por tipo concreto] B --> C[Insercion en linea del cuerpo] C --> D[Propagacion de constantes] C --> E[Analisis de escape] C --> F[Desvirtualizacion de llamadas siguientes] E --> G[Eliminacion de retenciones y liberaciones] D --> H[Eliminacion de comprobaciones de limites] F --> C style B fill:#89b4fa,color:#11111b style C fill:#f9e2af,color:#11111b style G fill:#a6e3a1,color:#11111b style H fill:#a6e3a1,color:#11111b
ARC: borrar el trabajo que nadie observa
El generador de SIL es deliberadamente pesimista: inserta una retención en cada punto donde un valor de referencia podría necesitar mantenerse vivo y una liberación en cada punto donde podría dejar de necesitarse. El resultado en bruto es una cantidad absurda de tráfico atómico. Los pases de conteo de referencias existen para demostrar cuál de ese tráfico es innecesario.
Las técnicas principales son tres. Emparejamiento y cancelación: si entre una retención y su liberación correspondiente no ocurre nada que pueda observar el conteo ni liberar el objeto, el par entero se borra. Movimiento: hundir liberaciones hacia el final y elevar retenciones hacia el principio acerca los pares y crea oportunidades de cancelación que estaban ocultas. Convención garantizada: cuando se puede probar que quien llama mantiene vivo el argumento durante toda la llamada, este viaja prestado en lugar de en propiedad, y la función deja de retener y liberar en cada entrada y salida.
final class Nodo { var valor: Int = 0 }
// Sin optimizar, cada acceso a n dentro del bucle puede generar
// una retencion y una liberacion. Con el objeto probadamente vivo
// durante todo el ambito, el optimizador borra el par entero.
func sumar(_ n: Nodo, _ veces: Int) -> Int {
var acc = 0
for _ in 0..<veces { acc &+= n.valor }
return acc
}
Lo que bloquea todo esto es la opacidad. Una llamada cuyo cuerpo el compilador no ve debe tratarse como capaz de hacer cualquier cosa, incluida liberar el último referente del objeto, y por tanto ninguna retención puede cruzarla. De ahí una regla de diseño concreta: las llamadas a funciones no inlinables, a métodos con despacho dinámico y a interfaces de otros módulos actúan como barreras que fijan el tráfico de conteo de referencias a su alrededor. Marcar una clase como final, mantener las rutas calientes dentro del módulo y evitar existenciales en bucles no son supersticiones: son formas de retirar barreras.
Una llamada a método por tabla virtual o por tabla de testigos impide inserción en línea, y sin inserción no hay propagación de constantes, ni análisis de escape, ni eliminación de conteo de referencias. Convertirla en llamada directa —con una clase final, un método privado, un genérico en lugar de un existencial o simplemente compilando el módulo entero de una vez— no gana un salto: desbloquea la cascada completa.
Alrededor de las tres transformaciones centrales orbitan otras que rara vez se nombran y que explican buena parte de la distancia entre el SIL en bruto y el final.
Especialización de cierres
Cuando una función recibe un cierre cuyo cuerpo es conocido en el sitio de llamada, el compilador clona la función con ese cierre incrustado. Es lo que hace que las operaciones de orden superior de la biblioteca estándar cuesten lo mismo que un bucle escrito a mano.
Optimización de firma
Elimina parámetros muertos, convierte argumentos en propiedad a argumentos prestados y explota structs pequeños en sus campos para que viajen en registros en lugar de por memoria.
Semántica declarada
La biblioteca estándar anota el significado de ciertas operaciones para que el optimizador razone sobre ellas sin analizar su implementación. Es lo que permite borrar comprobaciones de límites y de unicidad redundantes dentro de un bucle sobre un array.
Esa última categoría encierra una lección incómoda: parte del rendimiento de Swift no proviene de análisis generales sino de conocimiento privilegiado que el compilador tiene sobre unos pocos tipos fundamentales. Un tipo propio con semántica de copia al escribir no recibe ese trato automáticamente; solo se acerca a él en la medida en que reproduzcas las condiciones que el optimizador sabe reconocer.
Verificar en lugar de creer
Las tres transformaciones dejan huellas inequívocas en el SIL optimizado, y comprobarlas cuesta menos que discutir sobre ellas. La regla es no aceptar ninguna afirmación sobre optimización, propia o ajena, que no venga acompañada de la orden que la reproduce.
# La especializacion aparece como una funcion clonada con el tipo en el nombre
swiftc -O -emit-sil Fuente.swift | swift demangle | grep -i "specialized"
# El trafico de conteo de referencias, antes y despues del pipeline
swiftc -Onone -emit-sil Fuente.swift | grep -cE "strong_retain|strong_release|copy_value|destroy_value"
swiftc -O -emit-sil Fuente.swift | grep -cE "strong_retain|strong_release|copy_value|destroy_value"
# Despacho dinamico superviviente en la funcion caliente
swiftc -O -emit-sil -Xllvm -sil-print-function=rutaCaliente Fuente.swift | grep -E "class_method|witness_method"
# Que pase concreto realizo cada cambio
swiftc -O -emit-sil -Xllvm -sil-print-pass-name Fuente.swift 2>&1 | head -60
La forma habitual de contar esto —una lista de optimizaciones, cada una con su nombre y su efecto— es didácticamente cómoda y conceptualmente engañosa. El pipeline de SIL no es una secuencia de mejoras independientes; es un procedimiento que itera sobre un conjunto de hechos demostrados hasta que deja de crecer. La especialización no es valiosa porque elimine una tabla de testigos, sino porque añade un hecho: ahora se conoce el tipo. La inserción en línea no es valiosa porque ahorre un salto, sino porque añade otro: ahora se conoce el cuerpo. Y con esos dos hechos aparecen terceros que ninguno de los dos podía producir por separado —este objeto no escapa, este índice está en rango, esta rama es inalcanzable— que a su vez habilitan una nueva ronda. Por eso los pases corren repetidamente y en un orden cuidadosamente elegido, y por eso una única barrera bien colocada puede costar un orden de magnitud: no cuesta lo que ella misma impide, cuesta la clausura entera de deducciones que dependían de atravesarla. Esto reordena por completo lo que significa escribir Swift rápido. No consiste en evitar abstracciones, porque una abstracción sobre la que el compilador puede razonar es literalmente gratuita tras la cascada. Consiste en no destruir información: no borrar tipos donde no hace falta, no imponer despacho dinámico donde no aporta, no cruzar fronteras de módulo en el camino caliente sin exponer el cuerpo. El coste en Swift no es una propiedad de la sintaxis que escribes, es una propiedad de lo que el compilador todavía sabe cuando llega a ella. Y esa es la razón última por la que existe SIL: es el sitio donde ese conocimiento puede acumularse mientras todavía significa algo.
- Escribe una función genérica que sume una colección, compílala con optimización y localiza en el
SILel clon especializado para enteros. - Repite con la función en un módulo aparte, sin la anotación de inlinable, y comprueba que el clon desaparece.
- Mide el tráfico de conteo de referencias de un bucle sobre una clase no final; márcala como final, vuelve a medir y explica la diferencia con la desvirtualización.
- Sustituye un parámetro genérico restringido por su existencial equivalente y enumera, leyendo el
SIL, todas las optimizaciones que se perdieron. - Fuerza la inserción en línea de un cuerpo grande usado en veinte sitios y compara tamaño de binario y tiempo de ejecución antes de decidir si compensa.