En el artículo anterior, exploramos cómo el comprobador de tipos del compilador de Go analiza tu código. Vimos cómo resuelve identificadores, verifica la compatibilidad de tipos y asegura que tu programa sea semánticamente correcto.
Ahora que tenemos un AST completamente verificado en tipos, el siguiente paso lógico sería generar la Representación Intermedia (IR) del compilador—la forma que utiliza para optimización y generación de código. Pero aquí hay algo interesante: el compilador de Go no transforma inmediatamente el AST en IR. En su lugar, toma lo que podría parecer un desvío—serializa el AST verificado en tipos en un formato binario, luego lo deserializa de vuelta a nodos IR.
Espera, ¿qué? Eso suena ineficiente, ¿verdad? ¿Por qué no simplemente convertir nodos AST directamente a nodos IR?
Aquí es donde entra el formato Unified IR. Y a pesar del enfoque aparentemente indirecto, es en realidad un diseño brillante que resuelve varios problemas a la vez. Para entender por qué, primero necesitamos ver cómo Go organiza los paquetes compilados.
El Caché de Compilación y los Archivos Ar
Cuando compilas un programa Go, el compilador compila paquetes en orden de dependencias. Si el paquete B importa el paquete A, el compilador asegura que el paquete A se compile primero.
Al final de compilar cada paquete, el compilador genera un fichero ar (archive) y lo almacena en el caché de compilación. Estos archivos ar contienen dos archivos importantes:
__.PKGDEF - La representación Unified IR (lo que estamos cubriendo en este artículo): información de tipos, firmas de funciones, constantes, parámetros de tipos genéricos y cuerpos de funciones para funciones que se pueden hacer inline.
_go_.o - El código máquina compilado e información de depuración para el enlazador (lo cubriremos en un artículo futuro).
Cuando se compila un paquete que importa fmt, el compilador abre el archivo del caché de compilación y lee __.PKGDEF para obtener toda la información de tipos que necesita. Así es como el comprobador de tipos conoce sobre tipos de otros paquetes—esa información ya fue compilada y serializada cuando se construyó fmt. También es cómo el compilador puede hacer inline de funciones de paquetes importados—los cuerpos de las funciones que se pueden hacer inline están ahí mismo en el archivo __.PKGDEF.
La parte inteligente es que el compilador usa exactamente el mismo formato para ambos escenarios. Al compilar tu paquete local, serializa el AST verificado en tipos en este formato Unified IR, luego inmediatamente lo deserializa para construir el IR. Al leer paquetes importados, deserializa el Unified IR que fue escrito en __.PKGDEF cuando esos paquetes fueron compilados. Mismo formato, mismo código de deserialización—ya sea que el paquete acaba de ser compilado o vino del caché. Puedes ver esto orquestado en src/cmd/compile/internal/noder/unified.go, que coordina el pipeline de serializar-deserializar.
Ahora que entendemos dónde encaja el formato Unified IR en el proceso de compilación, profundicemos en los detalles del formato mismo.
¿Qué Se Serializa?
En su esencia, la serialización es una transformación de una representación a otra. Comienzas con un AST—una estructura de árbol en memoria que representa tu código. El compilador toma ese árbol completo, con toda su información de tipos y estructura, y lo convierte en un formato binario compacto que puede escribirse en disco y leerse de vuelta más tarde.
Ahora, hay dos contextos diferentes donde ocurre esta serialización, y serializan diferentes cantidades de datos.
Al compilar tu paquete local, el compilador serializa todo del AST completamente tipado: toda la información de tipos (cada expresión conoce su tipo), parámetros de tipos genéricos y restricciones, dependencias de importación, firmas de funciones, constantes y sus valores, y crucialmente—todos los cuerpos de funciones. Nada se pierde. Esta serialización completa es inmediatamente deserializada para construir el IR que el compilador usa para el resto del proceso de compilación.
Pero al escribir el archivo __.PKGDEF en el archivo ar, el compilador es más selectivo. La versión exportada contiene toda la información de tipos para símbolos exportados, firmas de funciones, constantes y sus valores—pero solo los cuerpos de funciones que pueden hacerse inline. Esta exportación selectiva es deliberada: proporciona a los paquetes importadores todo lo que necesitan para verificación de tipos y optimización entre paquetes, mientras mantiene los archivos ar compactos. Las funciones grandes que de todos modos no se harían inline no ocupan espacio en los datos de exportación. La lógica de serialización vive en src/cmd/compile/internal/noder/writer.go, que codifica el AST en el formato binario.
Así es como funcionan los paquetes importados—cuando compilas un paquete que importa fmt, el compilador lee el archivo __.PKGDEF de fmt y recrea la representación tipada que necesita, incluyendo esos cuerpos de funciones inline cuidadosamente seleccionados. El código de deserialización en src/cmd/compile/internal/noder/reader.go maneja la decodificación del formato binario de vuelta a nodos IR.
Ahora que sabemos qué se serializa, veamos cómo se organiza estos datos.
La Estructura del Formato Binario
El formato organiza todos estos datos en 10 secciones especializadas, cada una manejando un aspecto diferente del programa. Piensa en ello como un archivador bien organizado—todo tiene su lugar, y puedes encontrar lo que necesitas rápidamente. La implementación del formato binario vive en el paquete src/internal/pkgbits/, que proporciona las primitivas de codificación y decodificación.
En la parte superior, hay un Header con información de versión, flags e índices que te dicen dónde comienza y termina cada sección. En la parte inferior, hay una Fingerprint (un hash SHA-256 de 8 bytes) usado para caché de compilación—si esto no ha cambiado, los paquetes que importan este pueden omitir la recompilación.
En el medio, el Payload contiene 10 secciones que trabajan juntas para representar tu programa.
SectionString maneja la deduplicación—cada string en tu programa (nombres de paquetes, identificadores, rutas de importación) se almacena aquí una vez y se referencia en todas partes por índice. Si “main” aparece 500 veces en tu AST, se almacena una vez y esas 500 referencias simplemente usan el índice 1.
SectionPkg revela el grafo completo de dependencias. Incluso para nuestro simple hola mundo, esta sección lista los 59 paquetes que el compilador necesita—no solo fmt que importamos, sino errors que fmt importa, internal/reflectlite que errors importa, y así sucesivamente por la cadena.
SectionBody es donde vive el código real. Esto almacena cuerpos de funciones como árboles AST codificados, capturando la estructura de la lógica de tu programa. Como mencionamos antes, para paquetes locales que se están compilando, se incluyen todos los cuerpos de funciones. Pero al exportar a un archivo ar, solo entran los cuerpos de funciones inline—esto mantiene los archivos ar compactos mientras aún permite la optimización entre paquetes.
SectionMeta es el punto de entrada—donde el compilador comienza al leer un paquete. Contiene dos raíces: la raíz pública lista todos los símbolos exportados (la API del paquete que otros paquetes pueden usar), y la raíz privada contiene detalles de implementación internos como tareas de inicialización y cuerpos de funciones. Piensa en ella como el directorio al frente que apunta a todo lo demás en el formato.
Las secciones restantes manejan el sistema de tipos y metadatos. SectionType, SectionName y SectionObj trabajan juntas para almacenar definiciones de tipos, nombres calificados (como fmt.Println) y declaraciones de objetos (funciones, variables, constantes). SectionPosBase mapea cosas de vuelta a ubicaciones de archivos fuente para mensajes de error. SectionObjExt almacena metadatos de optimización como costos de inlining y resultados de análisis de escape. Y SectionObjDict contiene la información de instanciación necesaria para genéricos.
Si quieres una inmersión más profunda en la especificación del formato, consulta src/cmd/compile/internal/noder/doc.go—documenta el formato completo en detalle.
Ahora que hemos visto qué se serializa y cómo se organiza, veamos el formato en acción.
Pruébalo Tú Mismo
¿Quieres ver el formato Unified IR en acción? Compilemos nuestro programa hola mundo y exploremos el archivo ar.
Primero, crea el programa hola mundo:
// main.go
package main
import "fmt"
func main() {
fmt.Println("Hello world")
}
Para compilar esto en un archivo ar, necesitamos proporcionar una configuración de importación que le diga al compilador dónde encontrar el paquete fmt. Genérala con:
go list -export -json fmt | jq -r '"packagefile fmt=\(.Export)"' > importcfg
Esto crea un archivo importcfg que mapea el paquete fmt a su archivo ar en el caché de compilación.
Ahora compila el programa a un archivo ar:
go tool compile -p main -importcfg importcfg -o main.a main.go
Esto crea main.a—un archivo ar contiene tanto la representación Unified IR como el código objeto compilado para tu programa.
Echemos un vistazo dentro del archivo:
ar t main.a
Verás:
__.PKGDEF
_go_.o
El archivo __.PKGDEF contiene la representación Unified IR de tu paquete main. Para decodificarlo y explorarlo, podemos usar unified-ir-reader—una herramienta que creé específicamente para ayudar a visualizar y entender los conceptos que estamos explorando en este artículo. Instálala con:
go install github.com/jespino/unified-ir-reader@latest
Ahora decodifica el Unified IR directamente del archivo ar:
unified-ir-reader --limit 5 main.a
Verás una salida como esta:
╔═══════════════════════════════════════════════════════════════╗
║ Unified IR Binary Format ║
╚═══════════════════════════════════════════════════════════════╝
=== Format Metadata ===
Sync Markers: false
Total Elements: 159
Fingerprint: 4b413f76f05d40fa
=== Section Statistics ===
SectionString : 98 elements
SectionMeta : 2 elements
SectionPosBase : 0 elements
SectionPkg : 59 elements
SectionName : 0 elements
SectionType : 0 elements
SectionObj : 0 elements
SectionObjExt : 0 elements
SectionObjDict : 0 elements
SectionBody : 0 elements
=== SectionString (Deduplicated Strings) ===
Total strings: 98
(showing first 5)
[ 0] ""
[ 1] "main"
[ 2] "fmt"
[ 3] "errors"
[ 4] "unsafe"
... and 93 more
=== SectionPosBase (Source File Locations) ===
(none)
=== SectionPkg (Package References) ===
[0] <unlinkable> (name: main)
[1] fmt (name: fmt)
[2] errors (name: errors)
[3] (error reading package: unexpected decoding error: EOF)
[4] internal/reflectlite (name: reflectlite)
... and 54 more
=== SectionType (Type Definitions) ===
Total types: 0
=== SectionObj (Object Declarations) ===
(none)
=== SectionMeta - Private Root (Function Bodies & Internal Data) ===
Has .inittask: true
Function bodies: 0
La salida revela cómo se ve tu programa hola mundo en el formato Unified IR. Las estadísticas de sección muestran que la mayoría de los datos vive en SectionString (98 elementos) y SectionPkg (59 elementos), siendo el resto metadatos.
SectionString muestra los primeros 5 strings deduplicados—"main", "fmt", "errors", "unsafe". Cada uno aparece una vez y es referenciado en todas partes por índice.
SectionPkg revela algo sorprendente: ¡incluso un simple “hola mundo” referencia 59 paquetes! Este es el grafo completo de dependencias transitivas—fmt depende de errors, que depende de internal/reflectlite, y así sucesivamente por la cadena.
Nota que SectionName, SectionType y SectionObj muestran 0 elementos. Esto es porque el paquete main no exporta nada—la raíz pública está esencialmente vacía. Todo el código real vive en la raíz privada, que solo contiene el .inittask para inicialización.
¿Pero qué hay de un paquete que realmente exporta cosas? Exploremos uno de esos.
Explorando Paquetes del Caché
También puedes usar unified-ir-reader para explorar paquetes ya compilados de tu caché de compilación. ¿Recuerdas el archivo importcfg que creamos? Contiene la ruta al archivo del paquete fmt:
cat importcfg
Verás algo como packagefile fmt=/path/to/cache/go-build/14/14898fc9cf93ae046520d51da5f69ccf5f09b5d5d3faaf48d33ebf2088e4ded2-d, mostrando dónde vive fmt en tu caché de compilación. Puedes pasar esa ruta directamente a unified-ir-reader:
unified-ir-reader --limit 5 /path/to/cache/go-build/14/14898fc9cf93ae046520d51da5f69ccf5f09b5d5d3faaf48d33ebf2088e4ded2-d
Esto revela la representación Unified IR del paquete fmt—todos sus tipos exportados, funciones y los cuerpos de funciones inline que pone a disposición de los paquetes importadores. Verás mucho más contenido que nuestro simple paquete main: docenas de funciones exportadas como Println, Printf, Sprintf, junto con tipos como Stringer y Formatter. Esto es lo que el compilador lee cuando importas fmt—todo lo que necesita para verificar tipos de tu código y hacer inline de funciones pequeñas a través de los límites de paquetes.
Ahora que hemos explorado el formato tanto conceptualmente como de forma práctica, hablemos de por qué el equipo de Go lo construyó de esta manera.
¿Por Qué Unified IR?
Aquí está la cuestión: antes del unified IR, el compilador de Go tenía cuatro rutas de código separadas que todas hacían trabajos similares—copiar y transformar árboles IR. Había una para convertir el AST a IR (llamada “noding”), otra para manejar genéricos (stenciling), una tercera para hacer inline de funciones, y una cuarta para importar y exportar datos de paquetes. Cada una tenía su propia implementación, y cada una tenía que manejar todos los mismos casos extremos complicados en el sistema de tipos de Go.
El enfoque de unified IR colapsó las cuatro de estas en una sola ruta de código. Ahora hay una forma de serializar un árbol IR y una forma de deserializarlo, y ese mismo código maneja todo—compilación local, exportación de paquetes, importación, inlining y genéricos. Esto dramáticamente reduce el área superficial para bugs ya que solo hay una implementación para hacer bien, elimina todo ese código redundante, y simplifica todo el pipeline de compilación. En lugar de rastrear cómo interactúan cuatro procesos diferentes, entiendes un mecanismo central de serializar-deserializar. El compilador se vuelve más fácil de razonar y mantener.
La ganancia real se muestra en características del lenguaje. El inlining se volvió significativamente más poderoso porque usa la misma maquinaria que la importación/exportación de paquetes. Funciones que eran demasiado complejas para el antiguo inliner—cosas como literales de funciones y type switches—funcionan naturalmente ahora porque el formato ya las maneja. Similarmente, el soporte de genéricos es más completo ya que el enfoque unificado maneja sustituciones de parámetros de tipo e instanciaciones sin necesitar lógica de stenciling separada.
Es por eso que el enfoque de serializar-luego-deserializar que parecía ineficiente al principio es en realidad bastante elegante. Sí, los paquetes locales se serializan e inmediatamente se deserializan, lo que agrega un poco de sobrecarga. Pero esa sobrecarga te compra un compilador dramáticamente más simple que naturalmente soporta características de lenguaje más complejas. A veces la mejor solución no es la más directa.
Ahora que entendemos tanto el formato como por qué existe, recapitulemos lo que hemos cubierto.
Resumen
Hemos explorado el formato Unified IR—el formato de serialización binaria que se encuentra entre la verificación de tipos y la generación de IR en el compilador de Go. Vimos cómo vive en archivos __.PKGDEF dentro de archivos ar en el caché de compilación, y cómo el mismo formato maneja tanto paquetes locales (serializar todo, inmediatamente deserializar) como paquetes importados (deserializar lo que fue escrito durante su compilación).
Recorrimos la estructura del formato: 10 secciones organizando todo desde strings deduplicados hasta cuerpos de funciones, con exportación selectiva que mantiene los archivos ar compactos al incluir solo funciones que se pueden hacer inline. Obtuvimos experiencia práctica compilando un programa hola mundo y explorando su representación serializada con la herramienta unified-ir-reader.
También exploramos por qué el equipo de Go eligió este enfoque—cómo unificar cuatro rutas de código separadas en un mecanismo de serializar-deserializar reduce bugs, elimina código redundante, simplifica el pipeline de compilación y permite soporte más poderoso de inlining y genéricos. El enfoque aparentemente indirecto de serializar-luego-deserializar resulta ser una solución elegante que hace que el compilador sea más simple y más capaz.
En el próximo artículo, exploraremos el IR mismo y los pases de optimización que lo transforman antes de la generación de código.
