Generación y optimización de código

"Un compilador que genera código correcto pero lento es inútil en producción. Un compilador que genera código rápido pero incorrecto es peligroso. El arte está en ambos."

Qué vas a aprender en este capítulo

La última fase del compilador: tomar el AST verificado y producir código ejecutable. Para PupusaScript generaremos código de tres direcciones (un código intermedio sencillo), después lo optimizaremos con técnicas clásicas, y finalmente lo ejecutaremos en un intérprete de bytecode.


5.1 Código intermedio (IR)

💡 Intuición

En lugar de generar código máquina directamente (diferente para x86, ARM, RISC-V...), los compiladores modernos generan un código intermedio (IR) independiente de la arquitectura. Luego, un backend traduce el IR al código máquina específico.

LLVM es el ejemplo más famoso: Clang, Rust, Swift, Julia, y Kotlin lo usan como backend. Cada uno genera IR de LLVM y LLVM se encarga de optimizarlo y compilarlo para cualquier arquitectura.

📐 Fundamento

Código de tres direcciones (Three-Address Code, TAC):

Cada instrucción tiene como máximo tres operandos: resultado = op1 operador op2.

# Fuente PupusaScript:
total <- precio * cantidad + descuento

# TAC generado:
t1 = precio * cantidad     # temporales t1, t2...
t2 = t1 + descuento
total = t2

Instrucciones TAC:

# Asignación simple
x = y

# Operación binaria
x = y op z       (op: +, -, *, /)

# Operación unaria
x = -y

# Salto incondicional
goto L

# Salto condicional
if x goto L
ifFalse x goto L

# Comparación
x = y < z       (resultado booleano)

# Llamada a función
param x          # pasar parámetro
call f, n        # llamar función f con n parámetros
x = call f, n   # con valor de retorno

Generador de TAC — un Visitor más:

class GeneradorTAC(Visitor):
    def __init__(self):
        self.instrucciones = []
        self.temp_count = 0
        self.label_count = 0
    
    def nuevo_temp(self) -> str:
        self.temp_count += 1
        return f"t{self.temp_count}"
    
    def nueva_etiqueta(self) -> str:
        self.label_count += 1
        return f"L{self.label_count}"
    
    def emit(self, instruccion: str):
        self.instrucciones.append(instruccion)
    
    def visitar_programa(self, nodo: Programa):
        for stmt in nodo.cuerpo:
            stmt.accept(self)
    
    def visitar_asignacion(self, nodo: Asignacion):
        lugar = nodo.valor.accept(self)
        self.emit(f"{nodo.destino} = {lugar}")
    
    def visitar_binop(self, nodo: BinOp) -> str:
        lugar_izq = nodo.izq.accept(self)
        lugar_der = nodo.der.accept(self)
        resultado = self.nuevo_temp()
        self.emit(f"{resultado} = {lugar_izq} {nodo.op} {lugar_der}")
        return resultado
    
    def visitar_numero(self, nodo: Numero) -> str:
        return str(nodo.valor)
    
    def visitar_identificador(self, nodo: Identificador) -> str:
        return nodo.nombre
    
    def visitar_si_stmt(self, nodo: SiStmt):
        cond = nodo.condicion.accept(self)
        etq_sino = self.nueva_etiqueta()
        etq_fin  = self.nueva_etiqueta()
        
        self.emit(f"ifFalse {cond} goto {etq_sino}")
        
        for stmt in nodo.entonces:
            stmt.accept(self)
        
        if nodo.sino:
            self.emit(f"goto {etq_fin}")
            self.emit(f"{etq_sino}:")
            for stmt in nodo.sino:
                stmt.accept(self)
            self.emit(f"{etq_fin}:")
        else:
            self.emit(f"{etq_sino}:")
    
    def visitar_imprimir(self, nodo: ImprimirStmt):
        lugar = nodo.expresion.accept(self)
        self.emit(f"param {lugar}")
        self.emit(f"call imprimir, 1")

# Ejemplo de uso:
generador = GeneradorTAC()
ast.accept(generador)
print('\n'.join(generador.instrucciones))

Salida para total <- precio * 1.13:

t1 = precio * 1.13
total = t1

Salida para el bloque si:

t2 = descuento > 0
ifFalse t2 goto L1
t3 = precio * 0.1
descuento_calc = t3
goto L2
L1:
descuento_calc = 0
L2:

5.2 Optimizaciones clásicas

💡 Intuición

El código generado directamente del AST suele ser correcto pero ineficiente. Las optimizaciones hacen que el código final sea más rápido sin cambiar su comportamiento observable.

Principio: nunca optimices sin medir. La mayoría de optimizaciones valen la pena solo en los "hotspots" — el 20% del código que tarda el 80% del tiempo.

📐 Fundamento

1. Constant Folding (pliegue de constantes):

Evaluar expresiones constantes en tiempo de compilación.

# Antes:
t1 = 1.0 * 0.13    # siempre 0.13 — calculable en compilación

# Después:
t1 = 0.13
def optimizar_constant_folding(instrucciones):
    resultado = []
    for inst in instrucciones:
        # Detectar patrón "t = constante op constante"
        m = re.match(r'(\w+) = ([\d.]+) ([+\-*/]) ([\d.]+)', inst)
        if m:
            dest, a, op, b = m.groups()
            a, b = float(a), float(b)
            valor = {'+': a+b, '-': a-b, '*': a*b, '/': a/b}[op]
            resultado.append(f"{dest} = {valor}")
        else:
            resultado.append(inst)
    return resultado

2. Dead Code Elimination (eliminación de código muerto):

# Antes (t1 nunca se usa):
t1 = x * 2
t2 = y + 3
resultado = t2

# Después:
t2 = y + 3
resultado = t2

3. Common Subexpression Elimination (CSE):

# Antes (precio * 1.13 calculado dos veces):
t1 = precio * 1.13
t2 = precio * 1.13    ← mismo cálculo
total = t1 + t2

# Después:
t1 = precio * 1.13
total = t1 + t1       ← reutilizar t1

4. Loop Invariant Code Motion:

Mover cálculos que no cambian dentro de un bucle, afuera del bucle.

# Antes:
while i < n:
    t1 = precio * iva    ← no depende de i, se calcula N veces
    total[i] = base + t1
    i = i + 1

# Después:
t1 = precio * iva        ← calculado una sola vez
while i < n:
    total[i] = base + t1
    i = i + 1

5. Strength Reduction:

Reemplazar operaciones costosas por equivalentes más baratas.

# Antes (multiplicación en cada iteración):
i = 0
while i < n:
    t1 = i * 4       ← multiplicación
    ...
    i = i + 1

# Después (solo suma):
i = 0; t1 = 0
while i < n:
    ...
    i = i + 1
    t1 = t1 + 4      ← suma (más barata que multiplicación)

Optimizaciones que hace LLVM:

Nivel Flag Optimizaciones
O0 Sin opt. Solo código correcto
O1 Básico Constant folding, dead code elimination
O2 Moderado + CSE, loop invariant, inlining
O3 Agresivo + vectorización, loop unrolling
Os Tamaño Minimizar tamaño del ejecutable

5.3 Intérprete de bytecode para PupusaScript

📐 Fundamento

En lugar de generar código máquina nativo, podemos generar bytecode para una máquina virtual (VM). Python, Java, C#, y Lua lo hacen así.

from enum import Enum, auto

class Op(Enum):
    LOAD_CONST  = auto()  # push constante al stack
    LOAD_VAR    = auto()  # push variable al stack
    STORE_VAR   = auto()  # pop stack → variable
    BINARY_ADD  = auto()
    BINARY_SUB  = auto()
    BINARY_MUL  = auto()
    BINARY_DIV  = auto()
    COMPARE_LT  = auto()
    COMPARE_GT  = auto()
    JUMP_IF_FALSE = auto() # saltar si TOS es falso
    JUMP         = auto()
    PRINT        = auto()
    HALT         = auto()

class Instruccion:
    def __init__(self, op: Op, arg=None):
        self.op = op
        self.arg = arg

class VirtualMachine:
    """Stack-based virtual machine para PupusaScript."""
    
    def __init__(self, codigo: list[Instruccion]):
        self.codigo = codigo
        self.stack = []
        self.variables = {}
        self.pc = 0          # program counter
    
    def push(self, val): self.stack.append(val)
    def pop(self):       return self.stack.pop()
    
    def ejecutar(self):
        while self.pc < len(self.codigo):
            inst = self.codigo[self.pc]
            self.pc += 1
            
            match inst.op:
                case Op.LOAD_CONST:
                    self.push(inst.arg)
                
                case Op.LOAD_VAR:
                    if inst.arg not in self.variables:
                        raise RuntimeError(f"Variable '{inst.arg}' no inicializada")
                    self.push(self.variables[inst.arg])
                
                case Op.STORE_VAR:
                    self.variables[inst.arg] = self.pop()
                
                case Op.BINARY_ADD:
                    b, a = self.pop(), self.pop()
                    self.push(a + b)
                
                case Op.BINARY_MUL:
                    b, a = self.pop(), self.pop()
                    self.push(a * b)
                
                case Op.COMPARE_GT:
                    b, a = self.pop(), self.pop()
                    self.push(a > b)
                
                case Op.JUMP_IF_FALSE:
                    cond = self.pop()
                    if not cond:
                        self.pc = inst.arg
                
                case Op.JUMP:
                    self.pc = inst.arg
                
                case Op.PRINT:
                    print(self.pop())
                
                case Op.HALT:
                    break

# Bytecode para: precio <- 8.50 * 1.13; imprimir(precio)
programa = [
    Instruccion(Op.LOAD_CONST, 8.50),
    Instruccion(Op.LOAD_CONST, 1.13),
    Instruccion(Op.BINARY_MUL),
    Instruccion(Op.STORE_VAR, 'precio'),
    Instruccion(Op.LOAD_VAR, 'precio'),
    Instruccion(Op.PRINT),
    Instruccion(Op.HALT),
]

vm = VirtualMachine(programa)
vm.ejecutar()
# → 9.605

🛠️ En la práctica

Compiladores en la industria: LLVM

La arquitectura de los compiladores modernos (Clang para C/C++, Rust, Swift, Julia):

Código fuente
     │
     ▼
  Frontend (específico del lenguaje)
  - Lexer, parser, verificación de tipos
  - Genera LLVM IR
     │
     ▼
  Middle-end (LLVM — agnóstico de lenguaje y arquitectura)
  - Optimizaciones: O1, O2, O3
  - Constant folding, dead code elimination, loop optimization
     │
     ▼
  Backend (específico de la arquitectura)
  - Genera código nativo para x86-64, ARM, RISC-V, WebAssembly
     │
     ▼
  Ejecutable / bytecode

¿Por qué LLVM es tan exitoso?

  1. El frontend solo necesita generar LLVM IR — no saber nada del hardware.
  2. Las optimizaciones se hacen una vez, todos los lenguajes se benefician.
  3. Si LLVM agrega soporte para una nueva arquitectura (ej: RISC-V), todos los lenguajes basados en LLVM automáticamente compilan a esa arquitectura.

PupusaScript podría usar LLVM: Con la biblioteca llvmlite (Python), el generador de TAC podría emitir LLVM IR en lugar de código interpretado, obteniendo rendimiento nativo.


5.4 Cierre del libro

Este libro construyó un compilador completo para PupusaScript:

  1. Lenguajes formales y autómatas — los fundamentos teóricos: alfabetos, DFA/NFA, expresiones regulares, gramáticas CFG.
  2. Análisis léxico — el lexer: tokenización con PLY, manejo de errores léxicos.
  3. Análisis sintáctico — el parser: gramática BNF, parser descendente recursivo, construcción del AST.
  4. Análisis semántico — el verificador: tabla de símbolos, scoping, verificación de tipos con el patrón Visitor.
  5. Generación y optimización — el backend: TAC, optimizaciones clásicas, máquina virtual de bytecode.

Las mismas cinco fases que usa GCC, Clang, javac, y el compilador de Python — solo más simples.


5.5 Ejercicios

✏️ Ejercicio 5.1 — Generar TAC manualmente

Generá el código TAC para el siguiente fragmento PupusaScript:

mientras contador > 0 hacer
    total <- total + precio
    contador <- contador - 1
fin_mientras

✏️ Ejercicio 5.2 — Identificar optimizaciones

Para cada fragmento de TAC, identificá qué optimización se puede aplicar:

a. t1 = 100 * 0.13

b. t1 = x + y → (luego, en el mismo bloque básico) t2 = x + y

c. t1 = x * 2 donde x es un entero (en arquitecturas donde el shift left es más rápido que la multiplicación)

d. t1 = 5; t2 = t1 * 3; resultado = 42 (t1 y t2 nunca se usan después)


5.6 Para profundizar

5.X Mini-proyecto integrador

🏗️ Proyecto final — Tu propio mini-lenguaje

Alcance: construir un compilador / intérprete completo para un lenguaje pequeño llamado PupuLang, integrando todas las fases del libro.

Especificación de PupuLang:

let x = 5;
let y = 3;
let z = x + y * 2;
print(z);             // 11

if (z > 10) {
  print("grande");
} else {
  print("chico");
}

while (x > 0) {
  print(x);
  x = x - 1;
}

Entregables (en Python o Rust):

  1. Lexer (cap. 2) — tokeniza la entrada con re o un automata escrito a mano.
  2. Parser (cap. 3) — descenso recursivo que produce un AST.
  3. Análisis semántico (cap. 4) — tabla de símbolos + chequeo de tipos básicos (int, string, bool).
  4. Generación (cap. 5) — uno de: (a) intérprete sobre el AST, (b) bytecode para una stack machine simple, (c) traducción a Python o C.
  5. Tests — al menos 10 programas que pasen + 5 con errores que se reporten claramente.

Criterio de éxito: un programa de 30 líneas en PupuLang compila y produce la salida esperada. Los errores tienen línea y columna.

Tiempo estimado: 4-6 semanas. Es el proyecto más exigente del corpus; vale 2 sprints.


Definiciones nuevas: representación intermedia (IR), código de tres direcciones (TAC), constant folding, dead code elimination, CSE, loop invariant code motion, strength reduction, bytecode, máquina virtual (VM), LLVM, frontend/middle-end/backend, program counter, stack machine.