SIL: leer la representación intermedia de Swift
La forma de asignación única estática con tipos de Swift y propiedad explícita: bloques con argumentos, valores de dirección frente a valores de objeto, convenciones de llamada, instrucciones de despacho y de gestión de memoria. Cómo volcar el SIL de una función real y qué revela sobre asignaciones, indirecciones y trampas que el código fuente esconde por completo.
El código fuente de Swift está diseñado para que no veas el trabajo: una asignación oculta una copia, una llamada a método oculta una consulta de tabla, un cierre oculta una asignación en el montón y una suma oculta una comprobación de desbordamiento. SIL es la representación donde todo eso está escrito, instrucción por instrucción, en una forma que el compilador puede verificar. Aprender a leerlo no es un ejercicio de arqueología: es la única manera de sustituir la creencia sobre lo que cuesta tu código por la observación directa de lo que el compilador realmente emitió.
- Describir la estructura de una función en
SIL: firma, bloques básicos, argumentos de bloque y valores en forma de asignación única. - Distinguir valores de objeto de valores de dirección y las convenciones con las que viajan los parámetros.
- Identificar en un volcado las instrucciones de despacho, de asignación de memoria, de propiedad y de trampa.
- Volcar el
SILde una función concreta, desdecodificar sus nombres y extraer una conclusión de rendimiento.
Por qué SIL no se parece al fuente
SIL está en forma de asignación única estática: cada valor se define exactamente una vez y nunca se reasigna. Lo que en el fuente era una variable mutada en un bucle aquí es una cadena de valores distintos. En lugar de las funciones de confluencia clásicas de otras representaciones, Swift usa argumentos de bloque: un bloque básico declara parámetros y cada salto le pasa los valores correspondientes, lo cual hace el grafo más simple de transformar.
La segunda diferencia es que SIL conserva los tipos de Swift, no los de la máquina, y añade una distinción esencial. Un tipo escrito como valor directo indica un valor de objeto, cargado y manipulado como tal; el mismo tipo precedido de asterisco indica una dirección, un lugar en memoria sobre el que se opera con carga, almacenamiento y destrucción. Esa distinción es lo que permite manejar tipos cuyo tamaño no se conoce al compilar.
La tercera es la propiedad explícita. En la forma con propiedad, cada valor tiene un dueño conocido y su ciclo de vida está delimitado por instrucciones concretas: se copia con una instrucción, se destruye con otra, se presta con un par de marcas de inicio y fin. El verificador rechaza cualquier función donde un valor se use tras destruirse o se abandone sin destruir. La gestión de memoria deja de ser una convención y pasa a ser una propiedad demostrable.
Bloques con argumentos
Cada bloque declara sus parámetros y cada salto los suministra. Sustituye a las funciones de confluencia y simplifica el análisis de flujo.
Objeto frente a dirección
Un valor cargado vive en registros virtuales; una dirección se opera en memoria. La distinción sostiene los genéricos no especializados.
Propiedad verificada
Copias, destrucciones y préstamos explícitos. El verificador prueba que ningún valor se usa después de morir ni se filtra.
Anatomía de una función
Una función en SIL empieza por una declaración con su visibilidad, sus atributos, su nombre decorado y su tipo, que incluye la convención de llamada de cada parámetro: si llega prestado, si llega en propiedad, si llega por dirección de entrada, de salida o de entrada y salida. Después vienen los bloques.
// Fuente
func doble(_ n: Int) -> Int { n + n }
// SIL canonico, simplificado y anotado
// sil hidden [ossa] @$s4Demo5dobleyS2iF : $@convention(thin) (Int) -> Int {
// bb0(%0 : $Int): // argumento de bloque, valor de objeto
// %1 = struct_extract %0 : $Int, #Int._value // saca el entero primitivo del struct
// %2 = builtin "sadd_with_overflow_Int64"(%1, %1, ...) // suma con bandera
// %3 = tuple_extract %2, 0 // el resultado
// %4 = tuple_extract %2, 1 // hubo desbordamiento
// cond_fail %4, "arithmetic overflow" // la trampa que el fuente no muestra
// %5 = struct $Int (%3) // vuelve a envolverse en Int
// return %5 : $Int
// }
Dos lecturas inmediatas. La primera: Int no es un entero primitivo sino un struct de un campo, y el compilador lo desenvuelve y lo vuelve a envolver sin coste alguno. La segunda: la suma de Swift incluye una comprobación de desbordamiento con una instrucción de trampa asociada, que es exactamente la diferencia con la aritmética envolvente y el motivo por el que existe una familia de operadores alternativos.
Qué buscar en un volcado
Un volcado real es largo, y leerlo entero es una pérdida de tiempo. Lo productivo es buscar familias concretas de instrucciones, porque cada familia responde a una pregunta de rendimiento distinta.
- Despacho. Una referencia a función seguida de una aplicación es una llamada directa, candidata a insertarse en línea. Una consulta de método de clase indica despacho por tabla virtual; una consulta de método de testigo indica despacho por protocolo. Ver una de estas dos en un bucle caliente es la señal de que la desvirtualización no ocurrió.
- Memoria. Las instrucciones de reserva de caja o de referencia son asignaciones en el montón. Una caja en una función que no debería asignar suele delatar una captura que escapa o un existencial que no cupo en línea.
- Propiedad. Las copias y destrucciones de valor, o las retenciones y liberaciones en la forma sin propiedad, son el tráfico de conteo de referencias. Contarlas en el cuerpo de un bucle es la medida más directa del coste que impone la gestión automática de memoria.
- Existenciales y genéricos. Las instrucciones de inicialización de existencial revelan un borrado de tipo; los nombres decorados que anuncian una versión especializada revelan que el optimizador clonó el genérico para un tipo concreto.
- Trampas. Las instrucciones de fallo condicional son comprobaciones de límites, de desbordamiento o de conversión. Su ausencia tras optimizar demuestra que el compilador probó que eran innecesarias.
flowchart TB
A[Volcado de SIL de una funcion] --> B{Que pregunta quieres responder}
B -->|Coste de llamada| C[Buscar function ref y apply frente a class method o witness method]
B -->|Coste de memoria| D[Buscar alloc box y alloc ref]
B -->|Coste de ARC| E[Buscar copy value y destroy value]
B -->|Abstraccion borrada| F[Buscar init existential y nombres con specialized]
B -->|Comprobaciones| G[Buscar cond fail]
C --> H[Conclusion sobre despacho e insercion en linea]
D --> H
E --> H
F --> H
G --> H
style H fill:#a6e3a1,color:#11111b
style B fill:#89b4fa,color:#11111b# SIL en bruto: lo mas cercano al fuente, util para entender la bajada
swiftc -emit-silgen Fuente.swift | swift demangle > bruto.sil
# SIL canonico sin optimizar y SIL tras el pipeline de optimizacion
swiftc -Onone -emit-sil Fuente.swift | swift demangle > canonico.sil
swiftc -O -emit-sil Fuente.swift | swift demangle > optimizado.sil
# Aislar una sola funcion en lugar de leer el modulo entero
swiftc -O -emit-sil -Xllvm -sil-print-function=doble Fuente.swift
# Contar el trafico de conteo de referencias de un cuerpo concreto
grep -cE "copy_value|destroy_value|strong_retain|strong_release" optimizado.sil
Un volcado aislado dice poco porque no tienes con qué contrastarlo. La técnica que produce respuestas es diferenciar el SIL sin optimizar contra el optimizado para ver qué se llevó el pipeline, o diferenciar dos versiones de tu propio código para ver si el cambio que hiciste tuvo el efecto que suponías. La diferencia es información; el volcado suelto es ruido.
Un caso completo: el existencial frente al genérico
La utilidad de leer SIL se aprecia mejor sobre dos funciones que hacen exactamente lo mismo y que el fuente presenta como casi idénticas.
protocol Figura { var area: Double { get } }
struct Circulo: Figura { let r: Double; var area: Double { 3.14159 * r * r } }
func sumarCaja(_ xs: [any Figura]) -> Double {
xs.reduce(0) { $0 + $1.area }
}
func sumarGen<F: Figura>(_ xs: [F]) -> Double {
xs.reduce(0) { $0 + $1.area }
}
En el volcado optimizado del primero encuentras, dentro del bucle, una consulta de método de testigo por elemento, una apertura de existencial para acceder al valor guardado y tráfico de conteo de referencias asociado a la caja del montón cuando el valor no cabía en línea. Ninguna de esas instrucciones puede eliminarse, porque el tipo dinámico se decide en ejecución y no hay nada sobre lo que razonar.
En el volcado del segundo encuentras una función clonada cuyo nombre decorado menciona el tipo concreto, la llamada al cálculo del área insertada en línea, la aritmética expuesta como operaciones primitivas y ni una sola instrucción de propiedad. El bucle es candidato a desenrollado y a vectorización, y en muchos casos el compilador ya lo hizo.
# La comparacion que cierra la discusion en treinta segundos
swiftc -O -emit-sil Figuras.swift | swift demangle > opt.sil
grep -c "witness_method" opt.sil # despacho dinamico superviviente
grep -c "open_existential" opt.sil # aperturas de caja
grep -c "specialized" opt.sil # clones generados
Eso es lo que significa leer SIL con propósito: no admirar la representación intermedia, sino convertir una discusión de opiniones sobre diseño en tres números que cualquiera puede reproducir.
Hay una lectura ingenua de SIL según la cual existe para optimizar mejor, y es cierta pero secundaria. Su función más profunda es hacer verificable lo que el lenguaje promete. Swift afirma que los tipos de valor tienen semántica de valor, que la memoria se gestiona sin fugas, que dos accesos exclusivos no se solapan, que ninguna variable se lee antes de inicializarse. Ninguna de esas afirmaciones puede comprobarse sobre un árbol sintáctico, porque todas son propiedades del flujo de datos, y ninguna puede comprobarse sobre LLVM IR, porque para entonces ya se ha perdido la noción de qué era una copia y qué era una retención. SIL existe en el único punto del pipeline donde ambas cosas coexisten: la semántica de Swift intacta y la estructura de un grafo de flujo en forma de asignación única. La forma con propiedad lleva esa idea hasta su conclusión: convierte el ciclo de vida de cada valor en una obligación sintáctica que el verificador puede probar función a función, sin análisis global y sin confiar en el buen juicio de nadie. Y ahí aparece la consecuencia que reordena todo lo demás: el conteo automático de referencias de Swift no es una biblioteca ni una convención de llamada, es un invariante del compilador, y las optimizaciones que eliminan retenciones no son heurísticas atrevidas sino reescrituras que preservan un invariante demostrado. Cuando lees SIL no estás mirando un paso intermedio hacia el código máquina; estás mirando el lugar donde el lenguaje deja de ser una promesa y pasa a ser un teorema, con la lista completa de lo que esa promesa cuesta escrita al lado.
- Vuelca el
SILde una suma de enteros y localiza la comprobación de desbordamiento; reescríbela con el operador que la evita y comprueba que la instrucción de trampa desaparece. - Escribe una función que reciba un parámetro genérico restringido y otra que reciba el existencial equivalente; compara ambos volcados y señala la instrucción exacta que marca la diferencia de despacho.
- Crea un cierre que capture una variable local y otro que la capture escapando; identifica en cuál aparece una reserva de caja y explica por qué.
- Recorre un array dentro de un bucle, cuenta las instrucciones de fallo condicional sin optimizar y vuelve a contarlas con optimización.
- Toma la función más caliente de un proyecto tuyo, cuenta su tráfico de conteo de referencias antes y después de un cambio de diseño y justifica el resultado.