DEV Community

Martin Palopoli
Martin Palopoli

Posted on

Benchmarkée el ORM Postgres nativo de mi lenguaje contra SQLAlchemy: ~8 más rápido en reads, 5.7 menos memoria — y dónde empata

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:

  1. set_nodelay(true) en el TcpStream (deshabilita Nagle entre cliente y server).
  2. 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
Enter fullscreen mode Exit fullscreen mode

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)