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?
- El frontend solo necesita generar LLVM IR — no saber nada del hardware.
- Las optimizaciones se hacen una vez, todos los lenguajes se benefician.
- 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:
- Lenguajes formales y autómatas — los fundamentos teóricos: alfabetos, DFA/NFA, expresiones regulares, gramáticas CFG.
- Análisis léxico — el lexer: tokenización con PLY, manejo de errores léxicos.
- Análisis sintáctico — el parser: gramática BNF, parser descendente recursivo, construcción del AST.
- Análisis semántico — el verificador: tabla de símbolos, scoping, verificación de tipos con el patrón Visitor.
- 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
Solución
L1: ← inicio del bucle
t1 = contador > 0
ifFalse t1 goto L2 ← condición falsa: salir
t2 = total + precio
total = t2
t3 = contador - 1
contador = t3
goto L1 ← volver al inicio
L2: ← después del bucle
✏️ 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)
Solución
a. Constant folding → t1 = 13.0
b. Common subexpression elimination → reutilizar t1 para la segunda operación, eliminar t2 = x + y
c. Strength reduction → t1 = x << 1 (shift left 1 = multiplicar por 2)
d. Dead code elimination → eliminar las tres instrucciones; resultado = 42 puede simplificarse a asignación directa si se usa, o eliminarse si tampoco se usa
5.6 Para profundizar
- Dragon Book (Aho et al.) — el libro definitivo de compiladores.
- Crafting Interpreters (craftinginterpreters.com) — gratis, completo, excelente.
- LLVM Tutorial (llvm.org) — implementar un lenguaje simple con LLVM.
- CS 143 Stanford — curso de compiladores con videos en YouTube.
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):
- Lexer (cap. 2) — tokeniza la entrada con
reo un automata escrito a mano. - Parser (cap. 3) — descenso recursivo que produce un AST.
- Análisis semántico (cap. 4) — tabla de símbolos + chequeo de tipos básicos (int, string, bool).
- 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.
- 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.