La pregunta que dejamos abierta
Una GPU con un solo modelo cargado atiende a decenas de usuarios a la vez, cada uno con su prompt y su respuesta en marcha. ¿Cómo? La respuesta fácil —“pues los pone en una cola y va uno por uno”— es justo la que hunde el rendimiento. Servir bien un LLM no va de despachar peticiones en fila: va de mantener la GPU ocupada. Y esas dos cosas se pelean.
La pieza que las reconcilia se llama batching continuo (o in-flight batching). Es la diferencia entre un motor de inferencia que rinde y uno que desperdicia la mitad del silicio. Y, como casi todo en la capa de inferencia, se entiende mejor si recuerdas que generar texto son dos fases con costes muy distintos.
Por qué una petición sola desaprovecha la GPU
Una GPU es una máquina de hacer multiplicaciones de matrices en masa. Su gracia está en el paralelismo: procesar cientos de cosas a la vez sale casi al mismo precio que procesar una. Cuando generas un token para una única petición, mueves los pesos del modelo enteros desde la memoria solo para multiplicar un vector diminuto. El cálculo es ridículo comparado con lo que has tenido que leer. La GPU está limitada por memoria, no por cómputo: pasa más tiempo trayendo pesos que usándolos.
La solución obvia es agrupar. Si en vez de una petición procesas 32 a la vez, lees los pesos una sola vez y los reutilizas para las 32. El coste por token se desploma. A eso se le llama batching, y es la razón de que servir en lote sea órdenes de magnitud más eficiente que servir de una en una.
El problema es cómo se forma ese lote.
El batching estático y su desperdicio
La forma ingenua es el batching estático: juntas N peticiones, las arrancas juntas y esperas a que todas terminen antes de admitir el siguiente lote. Suena razonable hasta que miras las longitudes reales.
Las respuestas de un LLM no duran lo mismo. Una petición pide un “sí” y termina en tres tokens; la de al lado pide un informe y genera 800. En un lote estático, la petición corta acaba enseguida… y su hueco en el lote se queda vacío, ocupando memoria y sin hacer nada, hasta que la petición larga termine. Has reservado 32 asientos y, a mitad de trayecto, van medio vacíos.
Peor todavía: mientras ese lote no se vacía del todo, las peticiones nuevas esperan en la puerta aunque haya sitio de sobra. Es el clásico head-of-line blocking: la petición más lenta marca el ritmo de todas. El resultado es una GPU con utilización baja y unos usuarios con latencia alta a la vez. Lo peor de los dos mundos.
El truco: agrupar por iteración, no por petición
El batching continuo cambia la unidad de agrupación. En lugar de formar un lote de peticiones y esperar a que acabe, forma un lote en cada paso de generación.
La clave, otra vez, es que el decode produce un token por iteración. Así que el planificador del motor trabaja token a token: en cada paso mira qué peticiones siguen vivas, mete sus siguientes tokens en el lote y multiplica. En cuanto una petición emite su token de fin, sale del lote esa misma iteración y libera su sitio. Y ese sitio no se queda frío: entra una petición que estaba esperando. El lote respira, se llena y se vacía continuamente, sin esperar a nadie.
# Planificador de batching continuo, simplificado.
# La idea: el lote se recompone en CADA iteración, no una vez por grupo.
activas = [] # peticiones en pleno decode
espera = cola() # peticiones admitidas que aún no han arrancado
while activas or espera:
# 1. Rellenar huecos libres con peticiones nuevas (su prefill entra aquí)
while len(activas) < TAM_LOTE and espera:
activas.append(espera.pop())
# 2. Un paso de decode para TODAS las activas, en un solo pase por la GPU
tokens = modelo.decode_lote(activas) # los pesos se leen una vez para todas
# 3. Retirar las que acaban de terminar y devolver su respuesta ya
for pet, tok in zip(activas, tokens):
pet.emitir(tok)
if tok == FIN or pet.limite_alcanzado():
activas.remove(pet) # libera su asiento ESTA iteración
Nadie espera a que el vecino termine su párrafo. Una petición que acaba pronto devuelve su respuesta y se va; una que dura sigue en el lote sin bloquear a las demás. La GPU nunca deja asientos vacíos si hay cola.
Estático frente a continuo, de un vistazo
| Batching estático | Batching continuo | |
|---|---|---|
| Unidad de agrupación | La petición | La iteración (token) |
| Cuándo entra una nueva | Al vaciarse todo el lote | En cuanto se libera un hueco |
| Peticiones de distinta longitud | Las cortas dejan huecos muertos | Se rellenan al instante |
| Head-of-line blocking | Sí, la más lenta manda | No |
| Utilización de la GPU | Irregular, con valles | Alta y sostenida |
| Throughput (tokens/s agregados) | Bajo bajo carga mixta | Varias veces mayor |
Los saltos de rendimiento que anuncian motores como vLLM o TensorRT-LLM salen en buena parte de aquí: no de un modelo más rápido, sino de dejar de desperdiciar iteraciones.
Lo que sigue costando: la memoria del cache
El batching continuo no es magia gratis, y el peaje es el de siempre: el KV cache. Cada petición activa mantiene su tabla de keys y values en la VRAM mientras genera. Cuantas más peticiones metas en el lote, más caches simultáneos, y la memoria de la GPU es finita.
Ese es el techo real de tu concurrencia. El límite de “cuántas peticiones en paralelo” casi nunca lo ponen los pesos del modelo —esos se cargan una vez— sino la suma de los KV caches vivos. Por eso los motores modernos gestionan ese cache con cuidado (paged attention reparte la memoria en bloques para no desperdiciarla), y por eso, cuando la VRAM se llena, el planificador tiene que hacer cola o incluso expulsar peticiones y recalcularlas más tarde. Throughput y memoria son la misma moneda por las dos caras.
Cómo jugarlo a tu favor
Cuatro decisiones prácticas que salen directas de entender esto:
- No reinventes el planificador. Usa un motor que traiga batching continuo de serie (vLLM, TensorRT-LLM, TGI). Escribir tu propio bucle de decode con una cola ingenua es la forma más rápida de tirar la mitad de tu GPU.
- Mide throughput y latencia por separado, y bajo carga mixta. Un benchmark con peticiones todas iguales esconde justo el caso donde el batching continuo brilla. Prueba con longitudes de respuesta realistas y concurrencia real.
- Vigila la profundidad de la cola, no solo la GPU. Si las peticiones esperan mucho antes de entrar al lote, no te faltan tokens por segundo: te falta VRAM para más caches concurrentes. Ahí la palanca es cuantizar, acortar contextos o escalar.
- Acota los tokens de salida. Una respuesta sin límite ocupa un asiento y su
cache durante todo el rato. Un
max_tokenssensato libera capacidad para todos.
En resumen
Una GPU sirve a cien usuarios a la vez no porque vaya rápido en fila, sino porque no espera a nadie: recompone el lote en cada iteración, retira lo que termina y llena el hueco al momento. Eso es el batching continuo, y explica por qué cambiar de motor puede multiplicar tu throughput sin tocar el modelo. El límite, como siempre, es la memoria del KV cache. Servir un LLM barato no va de comprar más GPU: va de no dejar ni una iteración vacía.