Entendiendo el Compilador de Go: La Fase SSA

📚 Entendiendo el Compilador de Go (6 of 6)
La Fase SSA

En el artículo anterior , exploramos el IR—el formato de trabajo del compilador donde ocurren la desvirtualización, el inlining y el análisis de escape. El IR optimiza tu código a alto nivel, tomando decisiones inteligentes sobre qué funciones hacer inline y dónde deben vivir las variables, el heap o el stack.

Pero el IR todavía se parece mucho a tu código fuente. Tiene variables que pueden asignarse múltiples veces, flujo de control complejo con bucles y condicionales, y operaciones que se mapean directamente a la sintaxis de Go.

Ahora entramos en un mundo diferente. El compilador toma ese IR optimizado y lo transforma en la forma Static Single Assignment—SSA. Aquí es donde tu código se reestructura en un formato perfecto para optimización agresiva. La fase SSA es donde ocurren algunas de las transformaciones más sofisticadas del compilador.

Déjame mostrarte qué hace especial a SSA y por qué el compilador lo necesita.

¿Qué es SSA?

Static Single Assignment (SSA) es una representación intermedia usada por la mayoría de los compiladores modernos—GCC, LLVM, V8, HotSpot, y por supuesto, el compilador de Go. No es específico de Go; es una técnica fundamental en el diseño de compiladores que existe desde finales de los años 80.

La idea central: cada variable se asigna exactamente una vez. Una vez que escribes a una variable, eso es todo—sin reasignaciones, sin modificaciones.

Esta restricción puede sonar limitante, pero es increíblemente poderosa para la optimización. Déjame mostrarte por qué con un ejemplo simple:

x := 5
x = x + 10
x = x * 2
y := x

La variable x se asigna tres veces. Cuando el compilador lee y := x, tiene que preguntarse: “¿Qué x? ¿La primera? ¿La segunda? ¿La tercera?” Tiene que rastrear hacia atrás a través de todas las asignaciones para averiguar qué valor tiene realmente x en ese punto.

Ahora aquí está la misma lógica en forma SSA:

x1 := 5
x2 := x1 + 10
x3 := x2 * 2
y := x3

Cada asignación crea una nueva variable. Ahora cuando el compilador lee y := x3, no hay ambigüedad—x3 se define exactamente una vez, y sabemos exactamente qué valor tiene. El compilador no tiene que rastrear nada. Solo mira la definición.

Esto hace el análisis de optimización mucho más simple. ¿Quieres saber si una variable se usa? Solo comprueba si algo la referencia. ¿Quieres saber de dónde viene un valor? Mira su única definición. ¿Quieres eliminar código muerto? Si una variable nunca se usa, borra su definición—listo.

Pero SSA introduce un desafío con el código que bifurca.

El Problema del Nodo PHI

¿Qué pasa cuando tienes un if/else donde cada rama asigna un valor diferente a la misma variable? Mira esto:

var x int
if condition {
    x = 10  // Una posibilidad
} else {
    x = 20  // Otra posibilidad
}
// Ambas ramas vuelven a unirse aquí
y := x  // ¿Pero qué valor tiene x?

Cualquiera de las dos ramas podría haberse ejecutado. Después del if/else, la ejecución continúa, pero x podría ser 10 o 20 dependiendo de qué camino se tomó. En forma SSA, necesitamos representar esto explícitamente. Eso es lo que hacen los nodos PHI:

var x1 int
var x2 int
if condition {
    x1 = 10  // Rama verdadera
} else {
    x2 = 20  // Rama falsa
}
// Ambas ramas vuelven a unirse
x3 = φ(x1, x2)  // PHI: "x3 es x1 si tomamos la rama verdadera, x2 si tomamos la falsa"
y := x3

La función φ (PHI) crea una nueva variable x3 que representa “cualquier valor que se asignó”. Dice: “el valor de x3 depende de qué rama tomamos. Si la condición fue verdadera, x3 obtiene el valor de x1. Si la condición fue falsa, x3 obtiene el valor de x2.”

Los nodos PHI son cómo SSA maneja el código con bifurcaciones. Son el pegamento que mantiene todo unido cuando diferentes caminos de ejecución vuelven a unirse.

Ahora veamos cómo el compilador de Go genera realmente SSA desde el IR. Pero primero, necesitamos entender los bloques de construcción de los que está hecho SSA.

Los Bloques de Construcción: Values y Blocks

Antes de sumergirnos en cómo el compilador genera SSA, necesitamos entender cómo se ve realmente SSA en el compilador de Go. Está construido con dos estructuras fundamentales: Values y Blocks.

Values

Piensa en un Value como una única computación u operación. Cuando escribes a + b en tu código, el compilador representa esa suma como un Value en SSA.

Cada Value obtiene un identificador único—los verás escritos como v1, v2, v3, y así sucesivamente. Ese ID es lo que hace funcionar la “asignación única”. El Value v3 se define exactamente una vez, y cada vez que ves v3 en cualquier parte de la función, sabes que se refiere a la misma computación.

Cada Value tiene una operación (como Add64 o Load), un tipo, y referencias a sus Values de entrada. Puedes ver la estructura completa en el código fuente del compilador (src/cmd/compile/internal/ssa/value.go:20 ).

Cuando ves SSA como esto:

v3 = Add64 <int> v1 v2

Estás viendo un Value con ID v3, operación Add64, tipo int, y dos argumentos: Values v1 y v2. Está diciendo “calcula la suma de 64 bits de v1 y v2, y asigna el resultado a v3.”

Blocks

Ahora, los Values no están sueltos—están organizados en Blocks. Un Block es una secuencia de Values que se ejecutan seguidos, uno tras otro, sin bifurcaciones en medio. Una vez que entras en un Block, ejecutas cada Value en él de arriba a abajo hasta llegar al final.

Piensa en los if, bucles y llamadas a funciones de tu código. Estos crean diferentes caminos de ejecución, ¿verdad? Los Blocks son cómo SSA representa esos caminos. Cada posible camino de ejecución a través de tu función se convierte en una secuencia de Blocks conectados entre sí.

Como los Values, los Blocks obtienen IDs únicos: b1, b2, b3, y así sucesivamente. Cada Block contiene una lista de Values a ejecutar, un tipo que determina qué pasa al final (pasar al siguiente Block, bifurcar, retornar), y conexiones a los Blocks sucesores y predecesores. Puedes ver la estructura completa en el código fuente del compilador (src/cmd/compile/internal/ssa/block.go:13 ).

Déjame mostrarte un ejemplo concreto. Toma este simple if/else:

if x > 10 {
    y = 20
} else {
    y = 30
}
use(y)

El compilador lo divide en múltiples Blocks:

b1:  // Block de entrada
  v5 = Arg <int> {x}           // Argumento de función x
  v7 = Const64 <int> [10]
  v8 = Less64 <bool> v7 v5     // Comprueba si 10 < x (es decir, x > 10)
  v9 = Const64 <int> [20]
  v10 = Const64 <int> [30]
  If v8 → b3, b4               // Bifurcar: b3 para verdadero, b4 para falso

b3:  // Rama verdadera (cuando x > 10)
  Plain → b2                   // Continuar al punto de unión

b4:  // Rama falsa (cuando x <= 10)
  Plain → b2                   // Continuar al punto de unión

b2:  // Ambos caminos vuelven a unirse
  v11 = Phi <int> v9 v10       // y es v9 o v10 dependiendo del camino
  v13 = Call {use} v11 // Llamada a use()
  Plain → b5                   // Continuar al block final

El Block b1 evalúa la condición (x > 10) y crea varios Values incluyendo la comparación. Luego bifurca—si la condición es verdadera, salta a b3. Si es falsa, salta a b4.

Los Blocks b3 y b4 son las dos ramas. Nota que no crean Values para 20 o 30—esas constantes (v9 y v10) ya se crearon en b1. Las ramas simplemente caen a b2.

El Block b2 es donde ambos caminos vuelven a unirse. Empieza con un Value PHI (v11) que representa “cualquier valor que se asignó a y”—ya sea v9 (20) de la rama verdadera o v10 (30) de la rama falsa. Luego llama a use(y) y continúa.

Ahora que entendemos cómo se ve SSA, veamos cómo el compilador realmente construye estos Values y Blocks desde el IR.

Generación de IR a SSA

La transformación de IR a SSA ocurre en la función buildssa (src/cmd/compile/internal/ssagen/ssa.go:312 )—esta es la Fase 1 del procesamiento de SSA.

La función buildssa funciona por función. Toma una única función IR (*ir.Func) como entrada y produce una única función SSA (*ssa.Func) como salida. El compilador llama a buildssa una vez por cada función en tu programa, convirtiéndolas una a la vez de IR a SSA.

Esto es lo que necesita ocurrir para cada función:

  1. Crear los Blocks iniciales para el grafo de flujo de control de la función
  2. Convertir nodos IR en Values SSA organizados en esos Blocks
  3. Insertar nodos PHI (Values especiales) en los puntos de unión del flujo de control
  4. Resolver todas las referencias a variables a sus Values que las definen

Recorramos cada paso.

Paso 1: Configurar la Función SSA

El primer paso es simple: buildssa (src/cmd/compile/internal/ssagen/ssa.go:312 ) crea una estructura de función SSA vacía para contener todos los Blocks y Values que estamos a punto de generar.

Piénsalo como preparar un lienzo en blanco. El compilador crea el objeto de función SSA, luego crea el primer Block—el Block de entrada. Aquí es donde empieza la ejecución cuando tu función se llama. Cada función necesita uno.

Con la estructura en su sitio, el compilador está listo para empezar a convertir tu código IR en SSA.

Paso 2: Convertir IR a SSA

Ahora el compilador recorre el código IR de tu función y lo convierte en SSA—generando Values y Blocks a medida que avanza. Diferentes tipos de sentencias se convierten en diferentes estructuras SSA.

A medida que el compilador construye SSA, hace seguimiento de las variables. Para cada variable, recuerda qué Value la define actualmente. Así cuando tu código usa la variable x, el compilador sabe a qué Value se refiere x.

Pero hay un problema: ¿qué pasa cuando x podría venir de diferentes caminos? En los puntos de unión del flujo de control, x podría tener diferentes Values dependiendo de qué rama se tomó. El compilador maneja esto creando un FwdRef (referencia hacia adelante)—un marcador de posición que dice “lo averiguaremos después”. Estos marcadores se resuelven a nodos PHI en el Paso 3.

Veamos cómo funciona esto con algunos ejemplos.

Asignaciones

Empecemos simple. Cuando escribes:

y := 5
x := y + 10

El compilador lo descompone en operaciones individuales. La constante 5 se convierte en un Value. La constante 10 se convierte en otro Value. Luego la suma y + 10 se convierte en un tercer Value que usa los dos primeros como entradas.

Aquí está el SSA resultante:

v6 = Const64 <int> [5]      // y := 5
v7 = Const64 <int> [10]     // constante 10
v8 = Add64 <int> v6 v7      // x := y + 10

El compilador hace seguimiento de que la variable x ahora está definida por el Value v8. Cuando uses x más tarde en la función, el compilador sabe exactamente a qué Value se refiere—sin ambigüedad.

Flujo de Control

Ahora veamos algo más interesante—bifurcación. ¿Recuerdas el ejemplo de if/else que vimos al introducir los Blocks? Veamos cómo se genera realmente durante la conversión de IR a SSA:

if x > 10 {
    y = 20
} else {
    y = 30
}
use(y)

Aquí está el SSA resultante (antes de la inserción de PHI):

b1:  // Block de entrada
  v5 = Arg <int> {x}           // Argumento de función x
  v7 = Const64 <int> [10]
  v8 = Less64 <bool> v7 v5     // Comprueba si 10 < x (es decir, x > 10)
  v9 = Const64 <int> [20]
  v10 = Const64 <int> [30]
  If v8 → b3, b4               // Bifurcar: b3 para verdadero, b4 para falso

b3:  // Rama verdadera (cuando x > 10)
  Plain → b2                   // Continuar al punto de unión

b4:  // Rama falsa (cuando x <= 10)
  Plain → b2                   // Continuar al punto de unión

b2:  // Ambos caminos vuelven a unirse
  v11 = FwdRef <int> {{[] y}}  // Marcador: "necesitamos y, averiguarlo después"
  v13 = Call {use} v11 // Llamada a use()
  Plain → b5                   // Continuar al block final

El Block b1 evalúa la condición y crea las constantes para ambas ramas (v9 para 20, v10 para 30), luego bifurca basándose en la comparación. El Block b3 maneja el caso verdadero (cuando x > 10), el Block b4 maneja el caso falso (cuando x <= 10). Ambas ramas luego continúan al Block b2 donde los caminos se unen.

Nota que y se asigna a diferentes Values dependiendo de qué camino tomamos—v9 (20) si es verdadero, v10 (30) si es falso. Cuando el compilador procesa el Block b2 (el punto de unión), todavía no sabe qué Value tiene y, así que crea un marcador FwdRef (v11).

Esto nos lleva al paso final: resolver estos marcadores FwdRef.

Paso 3: Inserción de Nodos PHI

¿Recuerdas esos marcadores FwdRef que creamos en los puntos de unión? Ahora es momento de averiguar qué deberían ser realmente. El compilador usa la función insertPhis (src/cmd/compile/internal/ssagen/phi.go:42 ) para recorrer cada FwdRef y resolverlo.

Así es como funciona: para cada FwdRef, el compilador mira todos los caminos que podrían llegar a ese punto. Comprueba el mapa de seguimiento de variables del Paso 2 para ver qué Value define la variable en cada Block entrante.

A veces todos los caminos tienen el mismo Value. Fácil—el FwdRef simplemente se convierte en una copia de ese Value.

Pero cuando diferentes caminos tienen diferentes Values, el compilador crea un nodo PHI. El nodo PHI dice “esta variable podría ser Value A o Value B, dependiendo de qué camino tomamos”. Lista todos los Values posibles de los caminos entrantes.

Déjame mostrarte cómo se ve esto con nuestro ejemplo de Flujo de Control del Paso 2. Después de la conversión inicial, el Block b2 (el punto de unión) tenía este FwdRef:

b2:  // Ambos caminos vuelven a unirse
  v11 = FwdRef <int> {{[] y}}  // Marcador: "necesitamos y, averiguarlo después"
  v13 = Call {use} v11 // Llamada a use()

La función insertPhis mira este FwdRef y comprueba el mapa de seguimiento de variables para ver de dónde podría venir y. El Block b2 tiene dos predecesores: b3 y b4. Mirando la información de seguimiento:

  • En el Block b3, la variable y está definida por v9 (20)
  • En el Block b4, la variable y está definida por v10 (30)

¡Values diferentes! Así que el FwdRef se reemplaza con un nodo PHI que une ambos:

b2:  // Ambos caminos vuelven a unirse
  v11 = Phi <int> v9 v10       // Resuelto: y es v9 o v10 dependiendo del camino
  v13 = Call {use} v11 // Llamada a use()

Perfecto. Ahora use(y) tiene un Value claro con el que trabajar: v11, que representa “cualquier valor que obtuvo y” de las ramas.

¡Y eso es todo! Con todos los FwdRefs resueltos y los nodos PHI insertados donde se necesitan, tenemos SSA válido. El compilador ahora puede pasar a la optimización.

El Pipeline de Optimización SSA

Una vez que tenemos SSA válido, ocurre la verdadera magia. El compilador pasa tu código a través de una serie de transformaciones (src/cmd/compile/internal/ssa/compile.go:30 )—más de 30 pases de optimización diferentes que cada uno mejora el código de alguna manera.

El objetivo es transformar el SSA hasta que cada Value se mapee a una instrucción de máquina real, y los Blocks estén en el orden correcto para la generación de código. Veamos un ejemplo de optimización para entender cómo funciona esto.

Eliminar Computaciones Duplicadas

Una de las primeras cosas que hace el compilador es buscar trabajo duplicado. Si calculas lo mismo dos veces, ¿por qué mantener ambos? Esto se llama Common Subexpression Elimination (CSE).

Digamos que tienes este SSA:

v1 = Const64 <int> [5]
v2 = Const64 <int> [10]
v3 = Add64 v1 v2       // 5 + 10
v4 = Add64 v1 v2       // ¡La misma computación!
v5 = Mul64 v3 v4

CSE nota que v4 está calculando exactamente lo mismo que v3—sumando v1 y v2. Así que mantiene el primero y lo reutiliza:

v1 = Const64 <int> [5]
v2 = Const64 <int> [10]
v3 = Add64 v1 v2
v5 = Mul64 v3 v3       // Simplemente usa v3 dos veces

Ahora v4 desaparece por completo. Menos trabajo, mismo resultado.

CSE es solo una de muchas optimizaciones que se ejecutan en el SSA genérico. Hay pases que eliminan código muerto, simplifican el flujo de control, eliminan comprobaciones redundantes, y mucho más. Pero eventualmente, todo este SSA genérico necesita convertirse en código máquina real.

Lowering a Operaciones de Máquina

Aquí hay una transformación crítica en el pipeline: aquí es donde tu programa se vuelve específico de arquitectura.

Hasta este punto—a través del parsing, comprobación de tipos, generación de IR, conversión a SSA, y todas las optimizaciones que hemos visto—todo ha sido completamente independiente de la arquitectura. El mismo código SSA podría compilarse para Intel, ARM, RISC-V, o cualquier otra plataforma que Go soporte.

El pase lower es donde el compilador se compromete con una arquitectura específica y transforma las operaciones SSA genéricas en instrucciones de máquina específicas de la arquitectura. Es solo otro pase de transformación en el pipeline SSA, pero es crucial. Después del lowering, se ejecutan muchos más pases—más optimizaciones, asignación de registros, planificación de instrucciones, y otros—todos trabajando en el SSA ahora específico de la arquitectura.

Veamos cómo se ve una de estas transformaciones de lowering. Una suma genérica:

v1 = Add64 <int> v2 v3

Se convierte en una instrucción específica de AMD64:

v1 = AMD64ADDQ <int> v2 v3

Ahora cada Value se mapea directamente a una instrucción de máquina real que tu CPU sabe cómo ejecutar. AMD64ADDQ es la instrucción de suma de 64 bits en procesadores Intel/AMD.

Si estuvieras compilando para ARM en su lugar, el mismo Add64 genérico se convertiría en una instrucción ARM completamente diferente. Este es el poder de SSA—el compilador puede mantener tu código en una forma genérica tanto tiempo como sea posible, aplicando optimizaciones que funcionan en cualquier arquitectura, y luego especializarlo al final.

Poniéndolo Todo Junto

El compilador ejecuta más de 30 pases de optimización a lo largo del pipeline SSA. Algunos se ejecutan antes del lowering—como CSE, eliminación de código muerto, y simplificación del flujo de control—trabajando en SSA genérico, independiente de la arquitectura. Luego viene el pase de lowering, convirtiendo a operaciones específicas de la arquitectura. Después de eso, se ejecutan más pases—asignación de registros, planificación de instrucciones, y optimizaciones específicas de la arquitectura—trabajando en el SSA ahora especializado.

Cada pase hace el código un poco más simple, un poco más eficiente. Y aquí está la clave: estos pases crean oportunidades unos para otros. Cuando CSE elimina una computación duplicada, podría hacer que algunos Values queden sin usar. Otros pases pueden entonces eliminar ese código muerto, lo que simplifica el flujo de control, lo que abre nuevas oportunidades de optimización. Por eso el compilador ejecuta múltiples pases—cada ronda de optimización hace la siguiente ronda más efectiva.

Para cuando todos estos pases se completan, tu SSA se ha transformado en código compacto, eficiente, específico de la arquitectura, listo para la generación de ensamblador.

Pruébalo Tú Mismo

¿Quieres ver SSA para tu propio código? El compilador de Go puede volcar SSA en varias etapas con la variable de entorno GOSSAFUNC:

GOSSAFUNC=max go build max.go

Esto crea un archivo HTML mostrando el SSA en cada pase. Puedes ver tu función transformarse paso a paso, viendo exactamente qué hace cada optimización.

Pruébalo con diferentes funciones—bucles, condicionales anidados, llamadas a métodos. Observa cómo los Values PHI aparecen en los puntos de unión de Blocks, cómo CSE elimina Values duplicados, cómo desaparecen las comprobaciones de límites.

Si quieres trabajar con SSA programáticamente en tus propias herramientas, echa un vistazo al paquete golang.org/x/tools/go/ssa —proporciona una representación SSA en espacio de usuario que puedes usar directamente en tus programas Go para análisis y herramientas.

Ahora que hemos explorado la fase SSA desde los bloques de construcción hasta el pipeline de optimización, recapitulemos lo que hemos aprendido.

Resumen

La fase SSA transforma tu código en una forma perfecta para la optimización. Así es como funciona:

El compilador convierte IR en SSA—Blocks (caminos de ejecución) conteniendo Values (operaciones con IDs únicos)—en tres pasos: configurar la estructura de la función, convertir sentencias en Values y Blocks (usando marcadores FwdRef en puntos de unión), y resolver esos marcadores en nodos PHI.

Luego ejecuta más de 30 pases de optimización. Algunos trabajan en SSA genérico antes del lowering (como CSE). El lowering convierte todo a instrucciones específicas de la arquitectura. Luego más pases optimizan el código especializado (asignación de registros, planificación de instrucciones). Cada pase crea oportunidades para el siguiente.

Al final, tu código se ha transformado en SSA compacto, eficiente, específico de la arquitectura, listo para la generación de ensamblador.

Si quieres profundizar más, explora el código SSA real en src/cmd/compile/internal/ssa/ . Las reglas de reescritura (usadas por el lowering y los pases de optimización) son particularmente interesantes—están escritas en un lenguaje de dominio específico en src/cmd/compile/internal/ssa/_gen/ y automáticamente compiladas a código Go.

En el próximo artículo, cubriré la generación de ensamblador y la codificación de código máquina—cómo el compilador toma este SSA optimizado y lo convierte en los bytes reales que se ejecutan en tu CPU.