Índice

LLVM y rendimiento

¿Qué pasa cuando compilas?

En las Partes 1 y 2, escribiste código Nyx, lo compilaste y lo ejecutaste. Pero ¿qué pasa realmente entre tu archivo .nx y el programa en ejecución? Entender este pipeline te ayuda a escribir código más rápido y depurar problemas de rendimiento.

El pipeline de compilación

Cuando ejecutas make run FILE=programa.nx, ocurren cuatro cosas:

programa.nx  →  Compilador Nyx  →  programa.ll  →  Clang/LLVM  →  programa (binario)
   (Nyx)          (parser +          (LLVM IR)       (optimizador +     (código máquina
                   codegen)                            ensamblador)       nativo)
  1. El Compilador Nyx lee tu archivo .nx, lo parsea en un AST (Árbol de Sintaxis Abstracta), verifica tipos y genera LLVM IR — una representación intermedia.
  2. LLVM toma el IR, lo optimiza agresivamente y genera código máquina nativo para tu CPU.

El resultado es un binario independiente — sin intérprete, sin VM, sin overhead de runtime. Solo instrucciones de máquina.

¿Qué es LLVM IR?

LLVM IR es un lenguaje de bajo nivel que se ubica entre Nyx y el código máquina. Se parece a assembly pero es portable entre arquitecturas de CPU. Así se ve una función simple:

fn sumar(a: int, b: int) -> int {
    return a + b
}

Se convierte en este LLVM IR:

define i64 @sumar(i64 %a, i64 %b) {
entry:
    %result = add i64 %a, %b
    ret i64 %result
}

No necesitas escribir IR — el compilador de Nyx lo genera. Pero verlo te ayuda a entender en qué se convierte tu código.

Ver el IR

Para ver el IR de cualquier programa Nyx:

make compile FILE=programa.nx
cat programa.ll

Esto es útil para entender el rendimiento. Si una función genera cientos de instrucciones IR, podría valer la pena simplificarla.

Cómo LLVM optimiza tu código

LLVM aplica docenas de pasadas de optimización. Estas son las de mayor impacto:

Eliminación de código muerto

fn main() {
    let x: int = 42    // se computa pero nunca se usa
    let y: int = 10
    print(y)            // solo y importa
}

LLVM elimina x completamente. El programa compilado nunca computa 42.

Plegado de constantes

fn main() {
    let resultado: int = 3 * 4 + 5
    print(resultado)    // 17
}

LLVM computa 17 en tiempo de compilación. El binario solo carga la constante.

Optimización de bucles

fn sumar_hasta(n: int) -> int {
    var total: int = 0
    var i: int = 0
    while i < n {
        total += i
        i += 1
    }
    return total
}

LLVM puede desenrollar bucles pequeños, vectorizar operaciones y a veces reemplazar el bucle entero con una fórmula cerrada.

Inlining

Las funciones pequeñas se incrustan — su código se copia directamente en el llamador, eliminando el overhead de la llamada:

fn cuadrado(x: int) -> int { return x * x }

fn main() {
    let n: int = cuadrado(5)    // se convierte en: let n: int = 5 * 5
}

Características de rendimiento de Nyx

Como Nyx compila a código nativo via LLVM, logra rendimiento comparable a C:

Benchmark Nyx C Ratio
fibonacci(40) 166ms 190ms 0.87x (Nyx gana)
primes(100K) 3.6ms 3.6ms 1.0x
loop(100M) 0μs 0μs ambos optimizados
map(100K ops) 24.6ms 23ms 1.07x
HTTP req/s 73,863 competitivo con Go

Escribir código Nyx rápido

Preferir enteros sobre strings

Las operaciones con enteros son una sola instrucción de CPU. Las operaciones con strings asignan memoria y copian bytes:

// Rápido — comparación de enteros
if estado == 200 { ... }

// Más lento — comparación de strings (compara byte por byte)
if texto_estado == "OK" { ... }

Minimizar asignaciones en bucles calientes

Cada vez que creas un string, array o struct, el recolector de basura debe rastrearlo. En un bucle ajustado, esto se acumula:

// Lento — crea un nuevo string cada iteración
var i: int = 0
while i < 1000000 {
    let s: String = "hola" + int_to_string(i)    // ¡asignación!
    i += 1
}

// Rápido — evitar asignaciones innecesarias
var i: int = 0
var total: int = 0
while i < 1000000 {
    total += i    // sin asignación
    i += 1
}

Usar StringBuilder para construir strings

Cuando construyas strings en un bucle, usa StringBuilder en lugar de concatenación:

// Lento — O(n²) porque cada + crea un nuevo string
var resultado: String = ""
var i: int = 0
while i < 10000 {
    resultado = resultado + "x"
    i += 1
}

// Rápido — O(n) con StringBuilder
var sb: StringBuilder = sb_new()
var i: int = 0
while i < 10000 {
    sb_append(sb, "x")
    i += 1
}
let resultado: String = sb_to_string(sb)

Cachear computaciones repetidas

// Lento — llama length() cada iteración
while i < arr.length() {
    // ...
    i += 1
}

// Rápido — cachear la longitud
let len: int = arr.length()
while i < len {
    // ...
    i += 1
}

El recolector de basura

Nyx usa el Boehm GC — un recolector de basura conservativo. Libera automáticamente la memoria que ya no usas. Nunca necesitas llamar free() (a menos que optes por gestión manual de memoria unsafe).

El GC se ejecuta periódicamente. Escanea la memoria para encontrar objetos que ya no están referenciados y los reclama. Esto introduce pequeñas pausas, pero Nyx ajusta el GC para baja latencia:

Para la gran mayoría de programas, el GC es invisible. Para necesidades extremas de rendimiento (sistemas en tiempo real, motores de juegos), Nyx ofrece modo sin GC:

make run-no-gc FILE=programa.nx

En modo sin GC, debes gestionar la memoria manualmente con alloc() y free().

Self-hosting: el benchmark definitivo

El compilador de Nyx está escrito en Nyx y se compila a sí mismo. Esto se llama self-hosting — y es la prueba definitiva de rendimiento. Si el compilador fuera lento, compilarse a sí mismo sería dolorosamente lento.

El pipeline de compilación logra un punto fijo: compilar el compilador dos veces produce salida idéntica. Esto prueba tanto corrección como consistencia.

Ejercicios

  1. Escribe un programa que sume números del 1 al 10,000,000. Compílalo con make compile y mira el archivo .ll generado. ¿Puedes encontrar el bucle en el IR?
  1. Escribe dos versiones de construcción de strings: una con concatenación + en un bucle, otra con StringBuilder. Mide el tiempo de ambas con un gran número de iteraciones.
  1. Escribe una función que compute fibonacci(40). Compara el tiempo con el mismo algoritmo en Python u otro lenguaje interpretado.
  1. Mira el IR de una función simple con un if. ¿Puedes identificar las instrucciones de salto?
  1. Escribe un programa que cree 1 millón de structs en un bucle. Luego escribe uno que cree 1 millón de enteros. Compara la diferencia de rendimiento.

Resumen

Siguiente capítulo: FFI — Llamar código C →

← Anterior: Tu segundo proyecto — Un servidor web Siguiente: FFI — Llamar código C →