Benchmark, ronda 2: Rust, Go y los frameworks de Rust, perfilados
Go y siete aplicaciones Rust (axum, actix-web, Rocket, Loco, Cot y Rustango sobre axum y actix) con las mismas reglas, con solo la aplicación bajo prueba en marcha, y los perfiles de CPU, syscalls y red que muestran en qué gasta su tiempo cada una.
Esta es la ronda 2 del benchmark justo. La ronda 2 cubre solo las aplicaciones compiladas: Go y siete aplicaciones Rust. Añade tres frameworks de Rust (Rocket, Loco, Cot) y perfiles de CPU, syscalls y red que muestran en qué gasta su tiempo cada aplicación.
Medido el 14 de octubre de 2026 en un Apple M3 Pro (VM de Docker con 12 CPU y 16 GB).
- 441/s recolecciones de basura de Go en el índice en caché
- 3 vs 9 viajes de ida y vuelta a Postgres por página de detalle: Go vs sqlx con el ping al adquirir
- +45% Rustango en actix, página de índice, con una porción del pool por worker
- 6.3–22.8 MiB memoria bajo carga, las ocho aplicaciones
- 0 errores o respuestas no 200 en 144 ejecuciones
La configuración
El mismo blog que en la ronda 1: 10 autores, 30 etiquetas, 1 000 artículos, 2 600 enlaces artículo-etiqueta y 10 000 comentarios. Seis rutas: el índice (los últimos 20 artículos), una página de detalle de artículo con todos sus comentarios, una página de etiqueta, el índice y la página de detalle desde una caché de páginas en Redis, y /compute (suma de los primos menores que 20 000, sin base de datos).
Ocho aplicaciones:
- Go:
net/httpde la stdlib, pgx v5, go-redis - axum + sqlx y actix + sqlx: sqlx puro con el texto SQL de Go, plantillas Tera
- Rocket:
rocket_db_pools(sqlx) yrocket_dyn_templates(Tera) - Rustango sobre axum y sobre actix-web: el ORM de Rustango y su caché de páginas
- Loco: SeaORM, Tera
- Cot: el ORM de Cot, plantillas askama
Las reglas:
- La aplicación recibe 4 CPU (fijadas a los núcleos 0–3) y 2 GB de RAM, con 4 workers. PostgreSQL 16 y Redis 7 corren en los núcleos 4–7, el generador de carga oha en los núcleos 8–11.
- Solo corre la aplicación bajo prueba; las demás están detenidas. Entre aplicaciones, el número de conexiones de Postgres vuelve a su valor base.
- Pools: 16 conexiones máx., 4 mín.
- Antes de cualquier carga, cada página de cada aplicación se compara con el HTML de Go. Una discrepancia detiene la ejecución.
- Sin compresión. 5 s de calentamiento y luego tres ejecuciones de 20 s por endpoint con 50 conexiones; cuenta la mediana.
El banco de pruebas
El mismo banco de pruebas que en la ronda 1. La ronda 2 usa solo PostgreSQL y Redis (sin MySQL).
Versiones
- Go 1.25.14, pgx v5.7.2, go-redis v9.7.0
- rustc 1.99.0; axum 0.8.9, actix-web 4.16.0, tokio 1.53, sqlx 0.8.6, Tera 1.20.1
- Rocket 0.5.1 (sqlx 0.7.4, hyper 0.14)
- Loco 1.2.0 (SeaORM 2.0.4, sqlx 0.9.0)
- Cot 0.7.0 (sqlx 0.9.0, askama 0.16.1)
- Rustango sin ping al adquirir, con una caché de páginas que guarda bytes sin procesar y una conexión del pool por petición (hallazgo 5)
Ajustes que no se pudieron igualar
| Ajuste | Aplicaciones | Por qué |
|---|---|---|
El pool hace ping a Postgres en cada préstamo (sqlx test_before_acquire) | Rocket, Loco, Cot | No hay ajuste para desactivarlo. Está desactivado en axum + sqlx, actix + sqlx y Rustango. |
| 10 conexiones de pool en lugar de 16 | Cot | Cot no tiene ajustes de pool. |
| Una porción del pool por worker (4 de las 16 conexiones) y una conexión de Redis por worker | actix + sqlx, Rustango · actix | Un pool compartido es más lento en actix-web (ver hallazgo 6). |
| sqlx 0.7.4 y hyper 0.14 | Rocket | Fijados por Rocket 0.5.1. |
| 5, 5 y 7 consultas por página de índice, detalle y etiqueta en lugar de 3, 3 y 4 | Cot | Su API de consultas no puede expresar las consultas de Go (ver hallazgo 9). |
| askama en lugar de Tera | Cot | El motor de plantillas de Cot. |
Resultados
Peticiones por segundo, más es mejor.
Páginas sin caché
Mismo HTML, 50 conexiones, PostgreSQL en cada petición
req/s · higher is better- Go
- Rust
- Rustango
- Loco
- Cot
Mediana de tres ejecuciones de 20 s con 50 conexiones. Solo corría la aplicación bajo prueba. 0 respuestas no 200, 0 errores.
Páginas en caché
Servidas desde una caché de páginas en Redis, sin SQL
req/s · higher is better- Go
- Rust
- Rustango
- Loco
- Cot
Mediana de tres ejecuciones de 20 s con 50 conexiones. Solo corría la aplicación bajo prueba. 0 respuestas no 200, 0 errores.
Cómputo
Primos menores que 20 000, sin base de datos
req/s · higher is better- Go
- Rust
- Rustango
- Loco
- Cot
Mediana de tres ejecuciones de 20 s con 50 conexiones. Solo corría la aplicación bajo prueba. 0 respuestas no 200, 0 errores.
Latencia, p50 / p99 en ms:
| Aplicación | Índice | Detalle | Etiqueta | Índice, en caché | Detalle, en caché | Cómputo |
|---|---|---|---|---|---|---|
| Go | 5.50 / 9.96 | 1.91 / 5.02 | 7.61 / 11.72 | 0.35 / 2.73 | 0.24 / 2.05 | 2.37 / 10.75 |
| axum + sqlx | 3.49 / 4.71 | 2.14 / 3.60 | 7.42 / 13.89 | 0.24 / 0.53 | 0.22 / 0.51 | 3.35 / 6.07 |
| actix + sqlx | 3.32 / 6.38 | 2.21 / 4.30 | 7.23 / 13.23 | 0.20 / 0.50 | 0.19 / 0.45 | 3.41 / 4.97 |
| Rocket | 3.66 / 5.83 | 1.97 / 3.58 | 7.14 / 11.55 | 0.34 / 0.70 | 0.30 / 0.67 | 3.40 / 6.14 |
| Rustango · axum | 3.28 / 5.65 | 1.83 / 3.55 | 6.90 / 11.18 | 0.27 / 0.59 | 0.25 / 0.52 | 3.38 / 6.09 |
| Rustango · actix | 2.80 / 4.51 | 1.77 / 3.37 | 6.91 / 11.53 | 0.20 / 0.48 | 0.19 / 0.45 | 3.41 / 3.96 |
| Loco | 4.62 / 10.84 | 2.99 / 6.34 | 8.67 / 14.52 | 0.30 / 0.79 | 0.28 / 0.62 | 3.48 / 6.90 |
| Cot | 23.35 / 50.36 | 5.28 / 8.02 | 15.57 / 27.06 | 0.25 / 0.50 | 0.23 / 0.52 | 3.27 / 5.85 |
Memoria
Memoria bajo carga
Memoria residente del contenedor de la aplicación en el índice sin caché
MiB · lower is better- Go
- Rust
- Rustango
- Loco
- Cot
docker stats, la muestra con mayor CPU de seis tomadas durante la primera ejecución del índice.
Go y Cot son los que más usan. El índice de Cot carga los 1 000 artículos con su contenido en cada petición (hallazgo 9).
Hallazgos
Cada hallazgo indica qué se midió, por qué ocurre y cómo se demostró. Los perfiles son ejecuciones únicas de 25 s en el mismo banco de pruebas, una aplicación a la vez, así que sus req/s difieren un poco de la ejecución justa de arriba. Cuando una causa no está probada, se indica.
1. Cómputo: Go es un 8–17% más rápido
Qué. Go sirve 16 580 req/s en /compute; las aplicaciones Rust sirven 14 184–15 330.
Por qué. Ambos bucles compilados tienen 8 instrucciones, un sdiv de 64 bits y una comprobación de división por cero. La diferencia está en el orden de las instrucciones que elige LLVM. Qué detalle microarquitectónico de la CPU hace más lento ese orden está sin probar: la VM de Docker no tiene contadores de hardware.
Cómo se demostró. El bucle solo, en un núcleo, fuera de HTTP:
Bucle de primos, una llamada
Un solo núcleo, sin HTTP
microsegundos · lower is better- Go
- Rust
Rust con exactamente el orden de instrucciones de Go tarda lo mismo que Go. El resto de cada petición es más barato en Rust: 8 µs frente a los 14 µs de Go.
Latencia. Go tiene el p50 más bajo pero el p99 más alto. La latencia media es 50 / req/s para cada aplicación, así que las medias siguen al rendimiento; la diferencia está en la dispersión. Go atiende las peticiones fuera de orden: p10 0.56 ms, p99 10.75 ms. Las aplicaciones Rust atienden casi en orden de llegada: axum + sqlx p10 1.85 ms, p99 6.07 ms. Por qué el p99 de Go ronda los 10 ms está sin probar.
2. Páginas en caché: el recolector de basura de Go
Qué. En el índice en caché Go sirve 91 839 req/s; axum + sqlx sirve 198 669.
Por qué. Go ejecuta 441 recolecciones de basura por segundo en esta ruta, y las pausas stop-the-world suman cerca del 10% del tiempo. El resto es CPU por petición: 27.2 µs en Go frente a 14.6 µs en axum + sqlx.
Cómo se demostró. Subir GOGC elimina la mayor parte de la diferencia:
Go, índice en caché, GC ajustado
Antes: GOGC=100 por defecto. Ejecuciones de perfilado.
A dónde va la CPU restante, por petición en el índice en caché:
| Go | axum + sqlx | |
|---|---|---|
| CPU de la aplicación | 27.2 µs | 14.6 µs |
| Ruta de escritura del kernel | 10.6 µs | 6.9 µs |
| Syscalls | 10.7 (6.6 read, 2.9 write) | 4.8 (2.0 recvfrom, 1.4 writev) |
| CPU de Redis | 6.8 µs | 2.5 µs |
| Tráfico de Redis | go-redis: un GET por petición | una conexión, GET de muchas peticiones agrupados en un solo pipeline |
Go: CPU por petición en caché
27.2 µs en total
- 39%Ruta de escritura del kernel, 10.6 µs
- 61%Todo lo demás, 16.6 µs
axum + sqlx: CPU por petición en caché
14.6 µs en total
- 47%Ruta de escritura del kernel, 6.9 µs
- 53%Todo lo demás, 7.7 µs
3. Página de detalle: viajes de ida y vuelta a Postgres
Qué. Página de detalle: Go 22 607, axum + sqlx 22 842, Rocket 24 561, Rustango · axum 25 788 req/s. Todas envían las mismas tres consultas.
Por qué. El número de viajes de ida y vuelta a Postgres es distinto. pgx envía uno por consulta. sqlx añade uno cuando se toma una conexión del pool (el ping al adquirir) y otro cuando vuelve. axum + sqlx toma una conexión por consulta, así que paga en cada consulta; Rocket y Rustango toman una conexión por página, así que pagan una sola vez. Cada viaje extra cuesta unos 10 µs de CPU de Postgres.
Cómo se demostró. Captura del protocolo de Postgres durante la carga, contando los mensajes ReadyForQuery por página:
Viajes de ida y vuelta a Postgres por página de detalle
Captura de red, las mismas tres consultas; ping = ping al adquirir de sqlx
viajes de ida y vuelta · lower is better- Go
- Rustango
- Rust
La página de índice muestra las mismas cifras.
axum + sqlx: ping al adquirir desactivado
Ejecuciones de perfilado
Tres viajes menos ahorran 29–32 µs de CPU de Postgres por página: unos 10 µs cada uno.
Go hace menos viajes de ida y vuelta pero aquí no es el más rápido: su recolector de basura lo detiene cerca del 9% del tiempo en esta ruta.
4. Página de índice: GC y renderizado de plantillas
Qué. Página de índice: Go 8 741 req/s, axum + sqlx 14 140.
Por qué. Dos causas medidas. Otra vez el recolector de basura de Go (las pausas suman cerca del 8% del tiempo en esta ruta), y el renderizado de plantillas: html/template de Go usa reflexión y cuesta 167 µs por petición; serde + Tera en Rust cuestan 72 µs.
Cómo se demostró.
Go, índice sin caché, GC ajustado
Antes: GOGC=100 por defecto. Ejecuciones de perfilado.
Renderizado de plantillas, página de índice
CPU por petición, según el perfil de CPU
microsegundos · lower is better- Go
- Rust
En qué gasta CPU axum + sqlx en el índice, según su perfil (las partes se solapan: las asignaciones ocurren dentro de sqlx y Tera):
- sqlx: unos 112 µs por petición, de los cuales unos 25 µs son el ping cuando una conexión vuelve al pool
mallocde glibc: unos 56 µssendtodel kernel: 38 µs
5. Rustango: qué cambiaron las correcciones
Qué. Los perfiles de Rustango antes de estas correcciones mostraban cuatro costes. Tres están corregidos:
| Coste antes | Parte de CPU | Corrección |
|---|---|---|
| Un préstamo del pool por cada consulta | 19–35% | Una conexión del pool por petición: detalle +26% |
| Ping a Postgres en cada préstamo | 8–16% | Ping desactivado por defecto |
CachePageLayer guarda las páginas como JSON + base64 | ~12–20% por acierto de caché | Páginas guardadas como bytes sin procesar |
| Querysets reconstruidos y compilados en cada petición | ~2% | Aún sin corregir |
Rustango · axum, antes → después de las correcciones
Ejecuciones de perfilado, una aplicación a la vez
En la ejecución justa, Rustango sobre actix-web y sobre axum son las dos aplicaciones más rápidas en las tres páginas sin caché.
Pendiente. En el índice en caché Rustango · axum sirve 174 501 req/s frente a los 198 669 de axum + sqlx. Esta diferencia aún no se ha perfilado.
6. actix-web: un pool compartido es lento
Qué. En la ronda 1 las aplicaciones actix-web quedaban por detrás de axum en las páginas sin caché y usaban menos del 300% de su presupuesto de CPU del 400%.
Por qué. Con un único pool compartido, cada consulta despierta un hilo de otro runtime. Dar a cada worker de actix su propia porción del pool (4 de las 16 conexiones) y su propia conexión de Redis lo soluciona. El mecanismo, que un socket lo gestiona el runtime que lo abrió, es una inferencia; el efecto está medido.
Cómo se demostró. Mismas aplicaciones, pool compartido frente a porciones por worker:
Pool compartido → una porción del pool por worker
Ejecuciones de perfilado
| Cambios de contexto por petición | Pool compartido | Pool por worker |
|---|---|---|
| actix + sqlx, índice | 4.04 | 1.45 |
| actix + sqlx, detalle | 3.57 | 1.64 |
| Rustango · actix, índice | 2.70 | 0.81 |
| Rustango · actix, detalle | 2.34 | 1.03 |
Unas 2.5× menos cambios de contexto por petición. Más workers no ayudan: 8 workers con un pool compartido alcanzan 11 904 req/s en el índice de actix + sqlx (4 workers, compartido: 11 094).
7. Rocket: fuerte en páginas con base de datos, el más lento en caché
Qué. Rocket: detalle 24 561 req/s, por delante de Go y de las aplicaciones con sqlx puro; índice en caché 138 224, el resultado de Rust más lento.
Por qué. En las páginas con base de datos Rocket toma una conexión por página y hace 5 viajes de ida y vuelta (hallazgo 3). En las páginas en caché gasta 24.1 µs de CPU por petición frente a los 14.6 µs de axum + sqlx, con 8.1 syscalls por petición frente a 4.8. El perfil muestra un writev extra (2.24 por petición frente a 1.36) y contención en malloc; las llamadas a futex son 2.88 por petición frente a 0.91.
Cómo se demostró. Contabilidad de CPU, recuento de syscalls y perfiles de CPU en el índice en caché.
8. Loco
Qué. Loco: índice 10 290, detalle 15 635, índice en caché 153 243 req/s.
Por qué. SeaORM envía el mismo número de consultas que Go. En las páginas en caché Loco gasta 21.0 µs por petición: 2.4 µs más en malloc y 1.2 µs más en middleware que axum + sqlx. En las páginas con base de datos el ping al adquirir no se puede desactivar (el hallazgo 3 muestra su coste en sqlx).
Cómo se demostró. Contabilidad de CPU y perfiles de CPU en el índice en caché. Las páginas con base de datos de Loco no se perfilaron, así que cuánto de esa diferencia explica el ping está sin probar.
9. Cot: la API de consultas marca el límite
Qué. Cot: índice 2 018, detalle 9 282, etiqueta 3 065 req/s, pero índice en caché 190 245.
Por qué. La API de consultas de Cot solo tiene filter, limit y offset: no hay ORDER BY, JOIN, IN ni COUNT. Para mostrar los últimos 20 artículos, el índice carga los 1 000 artículos con su contenido. Las páginas necesitan 5, 5 y 7 consultas en lugar de 3, 3 y 4. La memoria bajo carga es de 22.6 MiB. Las páginas en caché no tocan el ORM y son rápidas: 16.4 µs de CPU por petición.
Cómo se demostró. Recuento de consultas a partir del código, memoria de la ejecución justa, contabilidad de CPU en el índice en caché.
10. Memoria
Ver el gráfico de memoria de arriba. Seis de las aplicaciones Rust usan 6.3–12.0 MiB bajo carga. Go usa 22.8 MiB. Cot usa 22.6 MiB; su índice carga todos los artículos en cada petición (hallazgo 9).
11. Costes compartidos
Dos costes aparecen en todas las aplicaciones Rust y no pertenecen a ningún framework:
- Renderizado con Tera: cerca del 18–20% de la CPU en las páginas sin caché
mallocde glibc: cerca del 12%
Un asignador más rápido como mimalloc o jemalloc podría reducir el segundo. Esto está sin probar.
Limitaciones
- Un solo equipo: un Apple M3 Pro. Otras CPU pueden ordenar los resultados de otra manera, sobre todo en cómputo (hallazgo 1).
- Una carga de solo lectura: sin escrituras ni transacciones.
- Las aplicaciones corren en la VM de Docker en macOS, no en Linux bare-metal. La VM no tiene contadores de rendimiento por hardware.
Conclusión: por qué Rustango en actix-web se volvió mucho más rápido
Rustango · actix, página de índice
Tres ejecuciones: la ronda 1, una ejecución de perfilado y la ejecución justa de esta ronda
req/s · higher is betterDos cambios, en dos sitios.
1. Correcciones en Rustango. Ayudan tanto en axum como en actix-web:
- Cada columna se decodifica una sola vez, según su tipo. Antes, la decodificación ocupaba el 46% de la CPU.
- Sin ping antes de cada préstamo del pool: un viaje de ida y vuelta a Postgres menos por consulta.
- Una conexión del pool por petición: las consultas de una página comparten una conexión, en lugar de un préstamo y un ping de devolución cada una.
2. La configuración de actix-web. axum ejecuta un único runtime de Tokio compartido y multihilo. actix-web ejecuta un runtime de un solo hilo por worker. Con un pool compartido por todos los workers, el socket de una conexión sigue gestionado por el runtime que lo abrió, así que cuando otro worker usa esa conexión, cada lectura y escritura despierta al otro hilo. La aplicación hace entonces 2.5× más cambios de contexto y usa cerca del 280% de sus 4 CPU. Una porción del pool (4 de las 16 conexiones) y una conexión de Redis por worker eliminan estos saltos entre hilos:
- Rustango · actix: índice 12 052 → 17 454, detalle 23 032 → 28 693, índice en caché 190 889 → 237 962 req/s
- actix + sqlx: índice 11 094 → 15 108 req/s
- 8 workers con un pool compartido no ayudaron: 11 904 req/s en el índice de actix + sqlx
La mejora de velocidad y la caída de los cambios de contexto están medidas. La propiedad de los sockets es la mejor explicación, pero no está probada directamente. axum no tiene este problema, porque todas sus tareas corren en un único runtime compartido.
En la práctica: en actix-web, crea el pool de base de datos dentro de cada worker, no una sola vez para todos los workers.