Parte 16 de la serie Fitz. Fitz es un lenguaje compilado donde HTTP, Postgres, JWT y WebSockets son parte de la sintaxis. Encadena con la Parte 9 (ORM tipado + migraciones). Hoy: dejo de agitar las manos sobre performance y lo mido de verdad.
La promesa que nadie debería creerse
Todo lenguaje que compila a binario nativo ama decir "cero overhead". Es lo más fácil de escribir en un README y lo más difícil de respaldar. Todo el pitch de Fitz es que HTTP + Postgres viven en el core del lenguaje — un driver Postgres en Rust puro compilado al binario, sin libpq, sin libpython, sin GIL. Eso debería hacerlo rápido. Pero "debería" no es un número.
Por eso el repo trae un benchmark reproducible. No un micro-loop sintético — dos boilerplates reales que cualquiera puede git clone y docker compose up:
| Impl | Boilerplate | Stack |
|---|---|---|
| Fitz ORM | api-postgres-fitz |
Driver Postgres wire puro + ORM nativo |
| Python | api-postgres-python |
Fitz + from python import + SQLAlchemy 2.x + psycopg2 |
Mismo Postgres 16, misma red Docker, mismo host, los mismos tres endpoints con el mismo shape de JSON:
-
GET /users— lista de 50 filas -
GET /users/{id}— un read por PK -
POST /users— insert
Solo cambia el ORM/driver detrás. Si Fitz es más rápido, es el driver — nada más se movió.
Los números (v0.37.12, mediana de 3 corridas)
Hardware: Intel Core Ultra 7 155H (16 cores), 64 GB RAM, Windows 11 + Docker 29.2.1 (WSL2). 30s sostenidos a concurrencia 10, medido con oha. Mediana de 3 corridas (las corridas locales varían ±10% por thermals del CPU y estado del cache).
Cold start, imagen, memoria
| Métrica | Fitz ORM | Python + SQLAlchemy | Ratio |
|---|---|---|---|
| Cold start (s) | 0.34 | 0.31 | ~empate |
| Tamaño de imagen | 134 MB | 272 MB | 2× más liviano |
| Memory peak (MB) | 9.2 | 52.4 | 5.7× más eficiente |
El número de memoria es el que hace mirar dos veces: 9.2 MB vs 52.4 MB bajo carga sostenida. No es un snapshot idle-en-frío — es el peak sirviendo miles de requests por segundo. Fitz carga un runtime tokio + axum + el driver, y nada más. SQLAlchemy arrastra un intérprete Python, la maquinaria de instancias por fila del ORM, y un connection pool custodiado por un threading.Lock.
Cold start es un empate hoy — y hay que ser honesto. Fitz booteaba en 0.14s, pero desde que empezó a linkear OpenTelemetry + tracing + metrics al binario HTTP, el cold start subió a ~0.34s, al lado de Python. (Es opt-out con @server(observability=false) si querés recuperar los 0.14s.)
GET /users — lista de 50 filas, 30s sostenidos, c=10
| Métrica | Fitz ORM | Python + SQLAlchemy | Speedup |
|---|---|---|---|
| p50 latency (ms) | 3.57 | 31.24 | 8.75× |
| p95 latency (ms) | 5.76 | 56.75 | 9.85× |
| p99 latency (ms) | 8.22 | 72.39 | 8.81× |
| Throughput (RPS) | 2618 | 297 | 8.81× |
GET /users/{id} — un read por PK, 30s sostenidos, c=10 ⭐
| Métrica | Fitz ORM | Python + SQLAlchemy | Speedup |
|---|---|---|---|
| p50 latency (ms) | 2.74 | 21.52 | 7.85× |
| p95 latency (ms) | 4.51 | 44.07 | 9.77× |
| p99 latency (ms) | 6.44 | 61.50 | 9.55× |
| Throughput (RPS) | 3377 | 411 | 8.22× |
Los read workloads — la forma típica de una API REST — quedan alrededor de ~8× el throughput con ~8× menos latencia, y la cola (p95/p99) ensancha la diferencia en vez de cerrarla. La cola de Fitz es apretada porque no hay pausa de GC ni contención de GIL serializando el manejo de requests.
Por qué Fitz gana en reads
El driver Postgres en Rust puro está compilado directo al binario nativo. Un request hace: parsear el frame HTTP (axum) → armar SQL parametrizado (sin malabares de strings con muchas allocations) → un round trip a Postgres → deserializar filas a structs tipados → serializar JSON. Ese es todo el hot path, y su overhead de runtime es casi nada.
SQLAlchemy suma capas en cada request: compilación de SQL del lado de Python, un connection pool coordinado con un threading.Lock, y convertir cada fila del resultado en una instancia del ORM con __init__ por fila — todo serializado por el GIL. Nada de eso es lento aislado; es simplemente trabajo que Fitz no hace.
Por qué Python no es ridículamente lento (la parte honesta)
SQLAlchemy 2.x está genuinamente bien optimizado, y el GIL solo bloquea Python puro — no la ejecución de SQL, no el I/O. Para un workload DB-bound, el verdadero cuello de botella suele ser Postgres, no el lenguaje encima. Por eso exactamente los writes empatan:
POST /users — 100 sequential, body único por request
| Métrica | Fitz ORM | Python + SQLAlchemy | Speedup |
|---|---|---|---|
| p50 latency (ms) | 174.69 | 190.23 | 1.09× |
| p95 latency (ms) | 243.97 | 271.75 | 1.11× |
| Throughput (RPS) | 3.28 | 2.98 | 1.10× |
Eso es un empate técnico — y tengo que ser directo con el por qué. Esto no mide el throughput de escritura del server; mide el cliente. El bench hace un loop curl sequential con un email único por request, y en Git Bash sobre Windows cada subshell arrastra ~1s de overhead. Que la latencia per-request sea ~igual te dice que el cuello de botella es el write durable de Postgres, no el server. Medir throughput de POST honesto necesita k6 o wrk+lua con randomización de bodies — eso es una extensión futura, no un número que voy a maquillar.
El bug que alguna vez hizo a Fitz 30% MÁS LENTO que Python
La parte más instructiva de toda esta historia: Fitz alguna vez perdió.
En una versión anterior, GET /users/{id} tenía un p50 de 43.70 ms — como 30% más lento que SQLAlchemy sobre exactamente la misma query. Para un "binario nativo con driver puro", eso fue humillante y, honestamente, un gran empujón para profilear de verdad en vez de asumir.
El culpable era el algoritmo de Nagle. El driver mandaba los cinco mensajes del Extended Query Protocol de Postgres (Parse / Bind / Describe / Execute / Sync) como cinco write().await separados. Nagle coalescía los paquetes chicos y esperaba un delayed-ACK — sumando ~40 ms a cada query parametrizada. El fix fueron dos líneas en el driver:
-
set_nodelay(true)en elTcpStream(deshabilita Nagle entre cliente y server). - Batchear los cinco mensajes del protocolo en un solo
write_all.
Resultado: GET /users/{id} pasó de 43.70 ms → ~2.7 ms p50 — una mejora de ~16×, dando vuelta la historia de "Fitz pierde" a "Fitz gana ~8×". Estable ahí desde entonces.
La lección es exactamente para lo que sirven los benchmarks: no existen para que un gráfico se vea lindo, existen para cazarte siendo lento. Sin un bench reproducible, esos 40 ms seguirían ahí.
Reproducilo vos mismo
cd benchmarks/orm-vs-sqlalchemy
bash run.sh
El script levanta cada boilerplate con docker compose up -d --build, espera el primer 200, seedea users, bencha cada endpoint con oha, muestrea memoria via docker stats, y escribe un summary.md. Para números publicables, corrélo tres veces y tomá la mediana — los ratios headline (5.7× memoria, ~8× reads) son estables entre corridas; solo se mueven los decimales.
Lo que no estoy afirmando
- Reads, no writes. La ventaja es en workloads read-heavy. El throughput de POST es un artefacto client-side de este bench, no una medición del server.
- Mediana, no best-case. Los números absolutos se mueven con la carga de la máquina. Los ratios son lo que se sostiene.
- Todavía sin queries con JOINs pesados. Eager loading, agregaciones y window functions tienen perfiles distintos. Hay un bench mixed-workload separado (Fitz vs Python vs Node) para el panorama más completo.
Si querés las tripas del driver — wire protocol v3.0, SCRAM-SHA-256, el connection pool — están todas en src/db.rs, un solo archivo, sin libpq. Ese es todo el punto: lo que lo hace rápido es algo que podés leer.
Próximo en la serie: más del stack que está incorporado en el lenguaje, y los modos de falla honestos que sigo encontrando construyendo productos reales con él.
Top comments (0)