3,5 GB de prompts muertos: lo que cuesta de verdad un gateway de LLM

Monté un gateway de LLM sobre Pingora y lo medí frente a nginx y OpenResty haciendo el mismo trabajo. La CPU iba bien. La memoria guardaba cada prompt hasta el último token, y uno de los arreglos acabó siendo un parche para Pingora.
Esta semana monté un gateway para inferencia de LLM: una sola puerta delante de varios backends compatibles con la API de OpenAI (vLLM, en mi caso), corriendo como job de Nomad. Comprueba las API keys, aplica límites por key, encola las peticiones cuando todos los huecos de los backends están ocupados, enruta por model, mantiene a cada usuario en la misma réplica para que la caché de prefijo siga caliente, reintenta antes del primer byte y escribe una línea de uso por petición.
Antes de fiarme de él con tráfico real quería dos cifras: cuánto cuesta por petición y cuánto cuesta por stream abierto. Y las quería al lado de la alternativa obvia, porque lo primero que pregunta cualquiera ante un proxy hecho a mano es «¿y por qué no nginx?».
La respuesta corta: por petición es tan barato como las alternativas, y con prompts grandes, siete veces más barato. Por stream abierto guardaba 3,5 GB de prompts que no necesitaba nadie con 10.000 streams, y bajarlo a 1,67 GB costó tres arreglos, uno de ellos en el propio Pingora.
Por qué no nginx sin más
El tráfico de un LLM no se parece al tráfico web:
- Las peticiones viven segundos o minutos. Una respuesta en streaming mantiene la conexión abierta mientras el modelo genera, token a token.
- Los cuerpos son grandes. Un prompt con un documento dentro pasa de 100 KB sin que nadie se dé cuenta.
- Para enrutar hay que leer el cuerpo. El campo
modelva dentro del JSON, así que el proxy tiene que leerlo antes de elegir backend. - La lógica es muy concreta. Encolar por huecos libres, la afinidad por caché de prefijo o rotar credenciales de proveedores no existen en un proxy inverso estándar.
nginx puede reenviar el tráfico, pero toda esa lógica acaba en Lua, es decir, en OpenResty. También probé Bifrost. Pingora, el framework en Rust con el que Cloudflare sustituyó a nginx en su edge, fue la única opción que me dejó escribir esa lógica como código normal, sin pelearme con la herramienta. Así que la comparación de verdad no es «Pingora contra nginx». Es «Pingora contra OpenResty haciendo el mismo trabajo», con nginx a pelo como suelo.
Cómo lo medí
Todo en una sola máquina (un i7-12700K), con el proxy fijado a un único núcleo y el generador de carga (oha) y los backends falsos en otros. Con todos los núcleos disponibles, las diferencias se diluyen. Son cifras para comparar proxies, no un plan de capacidad.
Dos backends falsos: uno contesta al instante con un JSON (todo lo que mides es el proxy) y otro hace streaming como vLLM, un evento SSE de unos 330 bytes por token a ritmo fijo, con el uso al final. La CPU es el tiempo de usuario más sistema del proceso del proxy, sacado de /proc/<pid>/stat y dividido entre peticiones o trozos. La memoria, su RSS.
Tres proxies, un worker cada uno, en el mismo núcleo:
| Qué hace por petición | |
|---|---|
| Mi gateway (Pingora) | Comprueba la key con SHA-256, límites por key, lee el cuerpo para sacar model, la afinidad y el tamaño del prompt, mete stream_options, quita las cabeceras del cliente, reintenta antes del primer byte, mide TTFT y uso, y escribe una línea de uso de 32 campos. |
| OpenResty | Lo mismo en Lua: resty.sha256, contadores compartidos, cjson para el cuerpo, un filtro de cuerpo para TTFT y uso, y la misma línea de log. |
| nginx a pelo | Solo reenvía. Key por coincidencia exacta, no lee el cuerpo, log corto. Es el suelo, no una alternativa. |
Por petición: empate, hasta que el prompt crece
| CPU por petición | req/s (1 núcleo) | p99 | |
|---|---|---|---|
| Cuerpo pequeño | |||
| Mi gateway | 18,4 µs | 48.129 | 1,93 ms |
| OpenResty (mismo trabajo) | 19,5 µs | 46.689 | 1,53 ms |
| nginx a pelo | 5,7 µs | 136.597 | 0,52 ms |
| Cuerpo de 100 KB, streaming | |||
| Mi gateway | 64,2 µs | 14.457 | 7,28 ms |
| OpenResty (mismo trabajo) | 457,1 µs | 2.196 | 60,68 ms |
| nginx a pelo | 24,8 µs | 35.085 | 3,26 ms |
Con cuerpos pequeños, el gateway y OpenResty cuestan lo mismo, y nginx a pelo es tres veces más barato porque no hace nada de ese trabajo. Con prompts de 100 KB, OpenResty cuesta siete veces más que el gateway: cjson construye el documento entero y lo vuelve a serializar solo para añadir stream_options. El gateway lee el cuerpo sin construirlo y mete el campo directamente en los bytes.
Con una sola petición y nada más en marcha, la mediana pasa de 7 µs yendo directo al backend a 24 µs a través del gateway: 17 µs añadidos, frente a 21 µs de OpenResty y 12 µs de nginx a pelo. Al lado de un modelo que tarda cientos de milisegundos en dar su primer token, eso es ruido.
No empezó ahí. La primera versión costaba 27,9 µs por petición pequeña y 158 µs por una de 100 KB. Fue el perfilador, no la intuición, el que encontró dónde se iba:
- La línea de uso se construía con
json!y se clonaba dos veces: el 30 % de una petición pequeña. - La clave de afinidad era un SHA-256 del primer mensaje: el 55 % de una petición de 100 KB. No es un uso de seguridad, así que ahora es XXH3.
- Dos parseos a
serde_json::Value, más volver a serializar el prompt solo para medirlo. - El SHA-256 de la API key, lo primero de lo que sospecha todo el mundo, era el 0,7 %.
Lo que queda es sobre todo el propio Pingora y una pasada de serde por el texto (unos 23 µs por cada 100 KB), que hace falta para saber dónde acaba cada mensaje.
Streams abiertos: a dónde se fueron 3,5 GB
Este es el escenario que importa con LLMs. 2.000 streams abiertos a la vez, de 100 tokens cada uno a 50 tokens/s, con prompts de 100 KB: 1.000 peticiones nuevas y 100.000 trozos por segundo. Un stream debería durar 2 segundos; lo que pase de ahí lo pone el proxy.
| CPU (1 núcleo) | CPU por trozo | Stream p50 (ideal 2 s) | |
|---|---|---|---|
| Mi gateway | 72 % | 7,4 µs | 2,046 s |
| OpenResty (mismo trabajo) | 92 % | 11,1 µs | 2,418 s |
| nginx a pelo | 35 % | 3,5 µs | 1,995 s |
Un núcleo lo aguanta con margen, mientras OpenResty ya alarga cada stream un 22 %. Apretando más a un solo hilo del gateway:
Unos 4.000 streams simultáneos por hilo es el techo cómodo; con 10.000, el hilo se satura y cada stream tarda un 45 % más. Aquí Pingora corre con un único hilo de trabajo, así que el siguiente paso es tan simple como darle más.
La CPU iba bien. La memoria no: 369 KiB por stream abierto, o 3,5 GB con 10.000 streams. Nada de eso era una fuga. Era memoria retenida más tiempo del que servía, en tres sitios.
1. El búfer de reintento sobrevivía al reintento
Pingora guarda el cuerpo de la petición por si tiene que repetirla contra otro backend, y una petición solo se puede reintentar mientras al cliente no le ha llegado ni un byte. En una respuesta en streaming las cabeceras salen casi al momento, así que a partir de ahí el prompt es peso muerto. Y seguía en memoria hasta el último token.
Ahora se suelta en cuanto la respuesta está saliendo hacia el cliente, y ni un momento antes. Soltarlo cuando el upstream aceptaba la petición dejaba una ventana en la que un fallo reintentaba con el cuerpo vacío. Hay un test que falla si alguien lo vuelve a adelantar. Sin streaming, el cuerpo se guarda como antes, que es lo correcto: hasta que llegan las cabeceras, un 500 todavía puede pedir un reintento.
2. glibc se quedaba lo que liberaba
Después de liberar un bloque grande, glibc sube su umbral de mmap. A partir de ahí, los prompts caen en sus arenas, se liberan en momentos distintos entre reservas pequeñas que siguen vivas y la arena no se puede recortar: con prompts de 400 KB, 1.350 MiB de RSS para 665 MiB realmente vivos. Poner MALLOC_MMAP_THRESHOLD_=65536 en el job manda los búferes grandes a mmap, que se devuelve al kernel al liberarlos.
Los dos arreglos juntos aplanan la curva: la memoria por stream deja de crecer con el prompt. El umbral cuesta unos 60 µs en una petición grande, algo invisible en un stream que dura segundos (72 % de CPU frente a 68 % sin él).
3. 64 KiB para una respuesta de 200 bytes
Lo que quedaba era de Pingora. El lector del cuerpo de la petición mantiene su búfer (hasta 64 KiB) hasta que termina la petición, y cada respuesta del upstream se lee con un búfer de 64 KiB, aunque traiga 200 bytes. Un cambio de 19 líneas suelta el búfer del lector cuando el cuerpo está completo y lo ajusta al Content-Length.
Solo el parche supone un 25 % menos por stream (de 219 a 165 KiB), y las peticiones pequeñas también se abarataron (de 21,1 a 18,7 µs), porque ese búfer de 64 KiB de cada respuesta caía justo en el umbral de mmap y se mapeaba en cada petición. Pasa los tests del propio Pingora y tres tests nuevos que fallan sin el cambio, y comprobé las dos cosas que podía romper: una petición en pipelining que llega en la misma lectura sobrevive, y un upstream que manda más de su Content-Length se sigue detectando, así que su conexión sigue sin reutilizarse. He abierto cloudflare/pingora#1020 para proponerlo upstream.
Lo que no sirvió
Medido y descartado, para que nadie tenga que volver a probarlo:
- mimalloc: un 10 % menos de CPU con cuerpos pequeños, 14 MiB más de RSS en reposo. Aquí no compensa.
- Un umbral de
mmapde 68 KiB en vez de 64: peticiones instantáneas más baratas, pero 226 KiB por stream en vez de 165. - Evitar la copia que mete
stream_options: con el campo ya puesto en el cuerpo, las cifras salían iguales. - La copia del búfer de reintento de Pingora: con el cuerpo leído por adelantado se activa después y no copia nada.
Bugs que solo encuentra el tráfico de un LLM
Los benchmarks llegaron al final de la semana. Antes hubo dos días apuntando el gateway contra backends reales de vLLM, y casi todo lo que se rompió es propio de los LLMs.
Cuerpos de unos 64 KiB. Si el proxy lee el cuerpo para enrutar, Pingora 0.9 solo puede reenviarlo desde su búfer de reintento de 64 KiB. Un prompt de 95 KB llegaba cortado al backend, con el Content-Length completo, y se quedaba colgado hasta el timeout: un 504 a los 60 segundos. El arreglo es el early body buffer de cloudflare/pingora#816, y hay un test de integración con cuerpos de 30 KiB, 1 MiB y 10 MiB.
Un timeout por silencio que repetía generaciones. Un límite de silencio solo tiene sentido en streaming. Sin streaming, vLLM calla hasta terminar. Un límite de 60 segundos de silencio convertía una respuesta de 2.500 tokens (96 segundos) en un 504, marcaba como caída una réplica sana y repetía la generación entera. Ahora: el silencio solo cuenta en streaming, el límite total es de 10 minutos, y un timeout ni marca caído un backend ni reintenta.
Un puerto abierto no es salud. vLLM escucha en su puerto mientras todavía está cargando el modelo. La comprobación tiene que ser GET /health con un 2xx.
El uso que desaparecía. Sin streaming, el gateway guarda los últimos 8 KiB de la respuesta para encontrar el bloque usage. Una respuesta de más de 8 KiB, unas 2.000 palabras, llegaba cortada por delante, no era JSON válido y su recuento de tokens se perdía: justo la cifra por la que se factura. Ahora lee el valor que sigue al último "usage".
Contadores que sobrevivían a sus rutas. Los contadores de peticiones en vuelo y en cola tienen que compartirse con la ruta que los sustituye en una recarga en caliente. Copiar el valor los dejaba pegados: 329 peticiones «en vuelo» con todo el clúster parado.
Más de un gateway. Con varias réplicas, la afinidad usa hash consistente para que todos los gateways elijan el mismo backend, y la carga se lee del propio backend (vllm:num_requests_running más las que esperan) en vez del contador de cada gateway. Con 16 peticiones enviadas directamente a vLLM, un gateway sin ninguna propia espera en su cola en vez de amontonar más.
Lo que pide de verdad 1.000 req/s
Con prompts de 100 KB y respuestas en streaming:
- CPU: unos 4.000 streams de 4 segundos caben en un hilo. Con respuestas de 10 segundos (10.000 streams abiertos) hacen falta dos núcleos.
- La memoria crece con los streams abiertos, no con las peticiones por segundo. Los streams abiertos son req/s × duración: unos 0,9 GB con respuestas de 4 segundos y unos 2,3 GB con respuestas de 10.
- Logs: 560 bytes por petición, de media sobre 2.993 líneas reales, son 0,56 MB/s, o 48 GB al día. Presupuéstalo.
Para ponerlo en perspectiva: la máquina de inferencia que hay detrás de este gateway saca unas 2 peticiones por segundo con carga. El gateway no será el cuello de botella hasta que tenga cientos de backends detrás. El objetivo de la semana no era hacerlo rápido. Era saber exactamente cuánto cuesta antes de fiarme de él, y la lección que se cumplió cada vez fue la misma: perfila antes de optimizar. El sospechoso obvio era el 0,7 %.
Lo siguiente: esa máquina de inferencia. Cómo hacer que vLLM funcione en la iGPU de un AMD Strix Halo, y bajar su arranque de 9 minutos a 97 segundos.