11°
Portada del artículo: Bun se reescribió de Zig a Rust en once días. Medí las cinco cifras que publicaron
RustArquitectura

Bun se reescribió de Zig a Rust en once días. Medí las cinco cifras que publicaron

Bun portó casi un millón de líneas de Zig a Rust y publicó cinco números para probar que salió bien. La versión 1.3.14 fue la última en Zig y la 1.4.0 la primera en Rust, así que se descargan las dos y se mide lo mismo cambiando solo el binario. Dos cifras se quedan cortas, dos las superan con holgura, y una no aparece por ninguna parte.

Efrain Garay 27 de agosto de 2026

Un proyecto grande anunció que había reescrito su base de código entera en otro lenguaje, y publicó cinco números para demostrar que había salido bien. Los números eran buenos. Demasiado redondos, además: cinco veces menos CPU, la mitad de memoria, el doble de velocidad al arrancar.

La reescritura en sí me interesaba poco. Me quedé con otra cosa: las dos versiones se pueden descargar. La 1.3.14 fue la última construida en Zig y la 1.4.0 la primera construida en Rust, con el mismo servidor corriendo encima y el mismo sistema debajo. La única variable que cambia es el binario. Eso convierte un anuncio en algo comprobable, y comprobarlo cuesta una tarde.

Así que descargué las dos y medí las cinco cifras.

El banco corriendo de verdad: cuatro entornos de ejecución en contenedores con un núcleo y 512 MB cada uno, la carga alternando entre ellos, y las lecturas del cgroup en vivo. Narrado.Verlo en el visualizador de reels →

Qué es Bun y por qué esto es medible

Bun ejecuta JavaScript y TypeScript, el mismo papel de Node, dentro de un binario único que además trae gestor de paquetes, empaquetador y corredor de pruebas. Se vende como reemplazo directo de Node.

El proyecto describe la traducción como once días de trabajo intensivo sobre unas 535.000 líneas de Zig, y publica un desglose de cómo lo organizó. Ese proceso da para otra conversación entera. Lo que me interesa aquí es más estrecho y más comprobable: el binario resultante hace lo que dicen que hace.

Las cinco cifras publicadas son estas: arranque un 50% más rápido en Linux, cinco veces menos CPU en reposo, 48% menos memoria en servidores HTTP, entre 2% y 5% más peticiones por segundo, y cerca de un 20% menos de tamaño de binario.

El montaje, y por qué el primer intento no servía

Las dos versiones van a directorios separados, sin tocar ninguna instalación que ya funcione:

for V in 1.3.14 1.4.0; do
  curl -fsSL https://bun.sh/install | BUN_INSTALL=$PWD/i-$V bash -s "bun-v$V"
  cp $PWD/i-$V/bin/bun $PWD/bin/bun-$V
done

El primer dato sale de ahí, sin ejecutar nada: 92,75 MB el binario en Zig, 80,76 MB el binario en Rust. Un 12,9% menos. El proyecto habla de cerca de un 20%, y la diferencia probablemente esté en qué se pesa exactamente; el número de arriba es el binario que baja de su instalador, medido con ls.

El servidor de prueba es el mismo para los tres candidatos, sin dependencias ni framework encima, porque cualquier librería sería una variable que no controlo:

const handler = () => new Response("hola", {headers: {"content-type": "text/plain"}});

if (typeof Bun !== "undefined") {
  Bun.serve({port: puerto, fetch: handler});
} else {
  require("http").createServer((req, res) => {
    res.writeHead(200, {"content-type": "text/plain"});
    res.end("hola");
  }).listen(puerto);
}

Y aquí viene el primer tropiezo, que vale más que varias de las mediciones.

Las cinco cifras, medidas

Todo en la misma máquina, un Ryzen 7 7800X3D con 30 GB de RAM, en CPU. El servidor fijado a dos núcleos con taskset y el generador de carga a otros seis, para que no compitan por el mismo planificador: si compartieran núcleos, estaría midiendo cuál de los dos pierde la pelea.

anuncio de bun 1.4 · contra mi banco de pruebas
2/ 4se sostienen al medirlas
Arranque en frío, en Linuxse sostiene
dicen 50% más rápido

61,4% más rápidoDe 7,58 ms a 2,93 ms, mediana de las 60 corridas del gráfico de abajo.

CPU en reposose sostiene
dicen 5x menos

9,3x menosEn 300 segundos: 216,19 ms de CPU contra 23,18 ms. Repetido, el múltiplo se mueve entre 8,8 y 13,8.

Memoria de un servidor HTTPno llega
dicen 48% menos

38,2% menosDespués de atender tráfico. Recién levantado el proceso sí llega al 55,5%, así que el número depende de cuándo se mire.

Tamaño del binariono llega
dicen 20% menos

12,9% menosDe 92,75 MB a 80,76 MB, pesando el binario que baja del instalador oficial.

La marca gris de cada barra es lo que promete el anuncio; la barra es lo que salió al medirlo. Falta una quinta fila, la de peticiones por segundo: la retiré al descubrir que el servidor y el generador compartían núcleos físicos. Se explica más abajo.

El arranque es real y es enorme

Es la diferencia más grande de todas, y la más fácil de comprobar: ejecutar un archivo con un console.log y cronometrar.

arrancar y ejecutar una línea · 60 corridas cada uno · ms

bun 1.4.0 · Rust2,93ms

La nube más apretada de las tres: 2,85 a 3,10 ms.

bun 1.3.14 · Zig7,58ms

node 22.23.215,81ms

Las 60 corridas de cada candidato, en ms
candidatomedianamínimomáximo
bun 1.4.0 · Rust2,932,853,10
bun 1.3.14 · Zig7,587,458,63
node 22.23.215,8114,7818,07

Las corridas caen en el orden real en que se ejecutaron, y la mediana se recalcula con cada una. Las tres nubes terminan sin tocarse: cuando la separación es así de limpia, la diferencia es del programa y no de una corrida afortunada.

Un 61,4% más rápido sobre este caso, contra el 50% que anuncian. Con una salvedad: esto es un console.log con el binario ya en la caché del sistema. No ejercita módulos, ni resolución de dependencias, ni transpilación, y esas son justo las etapas que un arranque real sí paga. Y el binario en Rust es además el más estable de los tres: sus sesenta corridas caben en 250 microsegundos, mientras que Zig tiene un pico suelto de 8,63 ms y Node uno de 18,07.

Esa estabilidad es la razón de publicar la mediana y no el promedio. Un solo pico del planificador arrastra un promedio entero, y en una máquina compartida siempre hay uno.

La CPU en reposo no se puede medir con las herramientas obvias

Aquí me equivoqué de instrumento antes de acertar. Un servidor levantado y sin nadie pidiendo nada debería consumir prácticamente cero, y eso es justo lo que hace difícil medirlo.

La primera pasada usó los contadores de /proc/<pid>/stat, que cuentan en tics de diez milisegundos. Sobre trescientos segundos dio ocho tics para Zig y un tic para Rust. Dividir eso arroja «ocho veces menos», que suena a resultado. No lo es: con un tic de resolución no se distingue «ocho veces menos» de «nada medible», y Node en esa misma pasada dio cero tics, o sea infinito.

La herramienta correcta es /proc/<pid>/schedstat, que expone el tiempo en CPU en nanosegundos, seis órdenes de magnitud mejor, y suma todos los hilos del proceso:

def ns_cpu(pid):
    """Nanosegundos de CPU del proceso, sumando todos sus hilos."""
    total = 0
    for tid in os.listdir(f"/proc/{pid}/task"):
        total += int(open(f"/proc/{pid}/task/{tid}/schedstat").read().split()[0])
    return total

Con esa resolución el número aparece limpio, y es el más favorable de los cinco.

schedstat · CPU acumulada de un servidor ocioso · 300 s
215,1bun 1.3.14 · Zig
22,2bun 1.4.0 · Rust
150 s
levantado5 min
bun 1.3.14 · Zig · 215,1 msgasta CPU en los 299 segundosbun 1.4.0 · Rust · 22,2 mssolo en 47 de 299

Nadie le pide nada al servidor en estos cinco minutos. La curva de Zig sube en todos y cada uno de los 299 segundos; la de Rust se queda plana y da 47 escalones sueltos. El total importa menos que la forma: uno se despierta sin parar y el otro duerme de verdad.

Nueve coma tres veces menos, contra las cinco que anuncian. Pero el total es lo menos interesante del gráfico.

Lo que se ve al reproducirlo es que las dos curvas tienen forma distinta. La de Zig es una rampa: sube en los 299 segundos, sin saltarse ninguno. La de Rust es una escalera casi plana, con 47 escalones en toda la ventana. Rust no abarata cada despertar. Se despierta seis veces menos.

Esta es la única cifra que no medí en la misma máquina que el resto. A media tarde la máquina de pruebas volvió a llenarse de trabajo ajeno, y el reposo es precisamente lo más sensible a eso: un proceso ocioso en una máquina saturada se despierta más veces por contención y acumula CPU que no es suya. Los números de aquí salen de un servidor de cuatro núcleos con carga 0,05, donde medir un proceso dormido no molesta a nadie.

Y hay que decir algo sobre la precisión del múltiplo. Repetí el experimento tres veces y dio 8,8, luego 13,8 y luego 9,3, en la misma máquina y con el mismo método. Cuando el numerador y el denominador son ambos diminutos, la razón entre ellos baila. El múltiplo exacto no es un dato firme; que supere las cinco veces anunciadas, sí, porque eso se sostuvo en todas las corridas.

Vale una aclaración honesta: los dos consumen una cantidad de CPU irrelevante en reposo. Doscientos dieciséis milisegundos en cinco minutos no le arruinan el día a nadie. Donde esto importa es al multiplicar por cientos de procesos, que es exactamente el caso que el proyecto menciona.

La memoria mejora, pero no tanto como dicen

RuntimeRecién levantadoTras atender tráfico
bun 1.3.14 · Zig36,4 MB44,5 MB
bun 1.4.0 · Rust16,2 MB27,5 MB
node 22.23.248,9 MB62,0 MB

Aquí está el matiz que explica la brecha. Recién levantado el proceso, la mejora es del 55,5%, por encima del 48% anunciado. Después de atender tráfico real baja al 38,2%, por debajo.

No creo que nadie haya hecho trampa. El número depende de cuándo se mire, y un anuncio rara vez aclara ese detalle. Si te importa lo que va a ocupar el proceso en producción, la cifra útil es la de después del tráfico, y esa es 38,2%.

Dicho eso, la mejora es grande de todos modos, y ambas versiones de Bun quedan muy por debajo de Node.

Las peticiones por segundo: retiro esta medición

Aquí publiqué un empate, y estaba mal medido. Lo dejo escrito con el error a la vista porque la causa es la clase de trampa que se repite.

Fijé el servidor a los núcleos 10 y 11, y el generador de carga a los núcleos 0 al 5. Por los números parecen conjuntos separados. No lo son:

$ cat /sys/devices/system/cpu/cpu10/topology/core_cpus_list
2,10
$ cat /sys/devices/system/cpu/cpu11/topology/core_cpus_list
3,11

La CPU 10 es el hermano lógico de la 2, y la 11 lo es de la 3. En un procesador con SMT, dos «CPUs» con números lejanos pueden ser el mismo núcleo físico. Durante toda esa medición el servidor y el generador pelearon por dos núcleos físicos, así que lo que medí fue esa pelea.

El número que publiqué era 84.162 contra 84.508. Lo retiro: no puedo afirmar ni empate ni mejora a partir de ahí. Más abajo está la medición nueva, en contenedores con límites y con los núcleos verificados por topología en vez de por número.

Y la lección, que vale más que la cifra: taskset no avisa. Acepta cualquier lista de números y el trabajo parece aislado. La única forma de saberlo es preguntar por la topología.

La distancia con Node sí sobrevive al error, porque es de otro orden de magnitud: casi el doble de peticiones servidas con el mismo código. Dos runtimes peleando por un núcleo compartido no se vuelven el doble de rápidos entre sí por eso.

Instalar dependencias: sin cambios

El uso más frecuente de Bun en el día a día no es servir HTTP, es instalar paquetes. Con ocho dependencias comunes y la caché borrada entre corridas:

RuntimeInstalación en frío
bun 1.3.14 · Zig2,43 s
bun 1.4.0 · Rust2,34 s

Empate otra vez, dentro del margen. El gestor de paquetes está limitado por la red y el disco, así que tiene sentido que un cambio de lenguaje no lo mueva.

Qué aprendí midiendo esto

Que un proyecto publique cifras favorables sobre sí mismo no significa que estén infladas. Aquí ninguna lo estaba: dos se quedan cortas en mi banco, dos las superan con holgura, y todas apuntan en la dirección correcta.

Lo que sí cambia al medirlo uno mismo es el matiz, y el matiz es lo que sirve para decidir:

Cuándo lo usaría y cuándo no

Actualizaría a la 1.4 sin dudarlo si el arranque importa: scripts, ganchos, tareas programadas, contenedores que arrancan y mueren. Ahí el 59% es real y se siente.

Actualizaría también por la memoria, con expectativas ajustadas: cuenta con un tercio menos, no con la mitad.

No actualizaría esperando más peticiones por segundo, porque en mi medición no las hay. Y no cambiaría de Node a Bun solo por el consumo en reposo: los dos gastan tan poco que la diferencia solo importa multiplicada por cientos de procesos.

Con una advertencia que pone la aritmética por mí: esta es la primera versión estable sobre una base de código nueva, traducida entera en once días. Los números están bien. La cantidad de superficie recién estrenada es enorme, y eso no lo mide un banco de pruebas.

Cómo reproducirlo

Todo lo que hay arriba sale de cuatro guiones cortos: uno para el arranque, uno para memoria y CPU, uno para el reposo en nanosegundos y uno que envuelve a oha. El servidor de prueba son las doce líneas de más arriba.

Las condiciones importan más que los guiones. A media medición la máquina tenía otro trabajo encima y los tiempos de arranque se duplicaron: 13,77 ms para Zig donde con la máquina libre daba 7,58. Las proporciones aguantaron, los absolutos no. Todo lo que se publica aquí está medido con la máquina en reposo, verificado con uptime antes de cada corrida, y con los procesos fijados a núcleos concretos.

Fuentes

Comentarios

Todavía no hay comentarios. El primero es tuyo.

Se revisa antes de publicarse. El correo no se guarda ni aparece en ninguna parte.