El pase: el jefe de sala que arma cada ronda — el scheduler step de vLLM
Sigue la serie por debajo del motor. Los posts anteriores miraron el silicio que ejecuta los kernels (SMs y CUDA graphs) y la carga de los pesos (del disco a la HBM). Este sube un piso: quién decide qué corre en cada forward. Antes de que la GPU lance un solo kernel, alguien ha tenido que armar la comanda de esta ronda. Ese alguien es el scheduler, y es el corazón del continuous batching.
TL;DR
Un servidor de LLM no atiende una petición entera y luego la siguiente: avanza todas las peticiones vivas a la vez, un poquito en cada iteración del motor. La pieza que decide cuánto avanza cada una en cada paso es el scheduler, y su salida es engañosamente simple: un diccionario {req_id: nº de tokens} que el model runner convierte en un solo forward sobre la GPU. La decisión más importante de vLLM V1 fue borrar la distinción entre prefill y decode: para el scheduler, un token de prompt y un token recién generado son la misma cosa —tokens que hay que procesar—, y por eso puede meter en el mismo batch un prompt de 4000 tokens junto a 200 decodes de 1 token. Las piezas son cuatro: el presupuesto de tokens (max_num_batched_tokens), que es una bandeja de tamaño fijo que se llena cada ronda; el chunked prefill, que parte un prompt enorme en trozos para que no acapare la bandeja y dispare la latencia del resto; las dos colas (waiting, en orden de llegada, y running); y la preemption —cuando se acaba el KV cache, alguien tiene que bajarse del tren—. Este post explica el bucle, las matemáticas del presupuesto y de la concurrencia, los 10 knobs y la trampa estrella: subir el presupuesto mejora el throughput pero empeora el ITL (tiempo entre tokens), y casi nadie mide las dos cosas a la vez. Sobre el cluster genérico 4×H100 SXM.
Dónde estás: el pase, antes de que la cocina arranque
Imagina el pase de un restaurante con mucha sala. No hay un cocinero por cliente; hay una cocina compartida y un jefe de sala que, cada pocos segundos, mira todas las comandas abiertas y arma una bandeja para mandar a fogones. En esa bandeja caben, pongamos, 8000 “unidades de trabajo”. El jefe de sala decide qué entra: a las mesas que ya están comiendo (peticiones en decode) les manda un plato más a cada una; a las mesas nuevas que acaban de pedir (peticiones en prefill, con su prompt entero por procesar) les manda tanta comanda como quepa en lo que sobra de bandeja. Manda la bandeja, la cocina la ejecuta de golpe, y vuelve a empezar. Cientos de veces por segundo.
Ese jefe de sala es el scheduler. La cocina es la GPU ejecutando un forward pass. Y la regla de oro del sitio es que la cocina no para nunca a esperar a una sola mesa: si una comanda nueva es gigantesca (un prompt de 30.000 tokens), no se manda entera de una vez bloqueando a todos los demás, se manda a trozos. Esa es, en una frase, toda la mecánica del scheduler de vLLM.
El bucle del motor: un diccionario por iteración
El motor de inferencia es un bucle muy corto. En cada vuelta:
- El scheduler mira las colas y produce una decisión.
- El model runner ejecuta un forward con ese batch en la GPU.
- El sampler saca un token nuevo por cada secuencia activa.
- Se actualiza el estado (KV cache, posiciones, peticiones terminadas) y se vuelve a 1.
Lo sorprendente es la forma de la decisión del paso 1. En vLLM V1 no es una estructura compleja con fases: es literalmente un diccionario
$$\text{schedule} = {, \text{req_id} \rightarrow n_\text{tokens} ,}$$
que dice, para cada petición que entra en esta ronda, cuántos tokens se procesan de ella. Para una petición que está generando texto, n_tokens = 1 (un paso autoregresivo). Para una petición nueva, n_tokens puede ser hasta la longitud entera de su prompt. Y puede ser cualquier valor intermedio —un trozo de prompt— en caso de chunked prefill, prefix caching o speculative decoding (docs vLLM V1).
Que la decisión quepa en un diccionario {id: número} no es un detalle de implementación bonito: es el motivo de que continuous batching funcione. Como el scheduler no piensa en “fases” sino en “cuántos tokens a cada uno”, puede mezclar en el mismo forward peticiones en cualquier punto de su vida. La GPU recibe un único tensor de tokens heterogéneo y lo procesa de una vez.
La muerte de la distinción prefill/decode
Esta es la idea que más cuesta y la que más importa. En las primeras arquitecturas de servidores de LLM, una petición vivía en dos fases separadas: primero prefill (procesar todo el prompt y llenar el KV cache), luego decode (generar token a token). El scheduler tenía que coreografiar el paso de una fase a otra, y mezclarlas era difícil.
vLLM V1 eliminó la distinción (diseño V1). El scheduler trata los tokens de prompt y los tokens generados de forma uniforme: todos son tokens que el modelo tiene que procesar en un forward. La consecuencia práctica es enorme. Un prompt de 4000 tokens y una secuencia que lleva 800 tokens generando y necesita uno más son, para el scheduler, “4000 tokens de la petición A” y “1 token de la petición B”. Caben juntos en la misma bandeja. No hay coreografía de fases, solo un presupuesto que repartir.
Esto desbloquea el patrón que de verdad rinde: mezclar prefill y decode en cada step. El prefill es trabajo compute-bound (mucha matriz que multiplicar); el decode es memory-bound (poco cómputo, mucho mover KV). Mezclarlos en el mismo batch llena los huecos: mientras la GPU está ocupada con el prefill pesado, “de gratis” hace avanzar los decodes ligeros. Es el mismo principio de eficiencia que el continuous batching llevado a su forma más limpia.
El presupuesto de tokens: la bandeja de tamaño fijo
La bandeja tiene un tamaño: max_num_batched_tokens. Es el número máximo de tokens que el scheduler puede meter en un solo step (optimización vLLM). La política, con chunked prefill activo (lo está siempre en V1), es clara:
- Primero los decodes. Se reserva sitio para un token por cada petición en la cola running. Son baratos y son clientes que ya están comiendo: no se les deja esperar.
- Lo que sobra, para prefills. Con el presupuesto restante, se meten tokens de prompt de las peticiones waiting, en orden de llegada (FCFS), partiéndolos en trozos si hace falta.
Un ejemplo con números. Presupuesto max_num_batched_tokens = 8192, y en este instante hay 200 peticiones generando:
$$\text{decode} = 200 \times 1 = 200 \text{ tokens}$$ $$\text{presupuesto libre} = 8192 - 200 = 7992 \text{ tokens para prefill}$$
Si llega una petición nueva con un prompt de 4000 tokens, cabe entera en este step (4000 < 7992) y sobran 3992 para otra. Si llega una con 30.000 tokens, no cabe: el scheduler le manda un trozo de 7992 este step, y los 22.008 restantes en steps siguientes. Eso es el chunked prefill.
El presupuesto importa porque fija cuántos forwards hacen falta para tragar un prompt. Un prompt de 30.000 tokens con bandeja de 8192 tarda 4 steps solo en prefill antes de soltar su primer token. Con bandeja de 2048, tarda 15 steps —pero cada uno de esos steps deja más sitio para decodes ajenos, así que los demás clientes notan menos el atasco.
Las dos colas y la preemption: cuando alguien se baja del tren
El scheduler maneja dos colas. La waiting son peticiones que aún no han empezado (su prompt no se ha procesado), y se sirven en orden de llegada —FCFS por defecto, aunque hay política priority—. La running son las que ya están vivas y generando (scheduling vLLM).
Hay un segundo presupuesto, más duro que el de tokens: el KV cache. Cada token vivo ocupa bloques de KV en la HBM, y son finitos (los fija gpu_memory_utilization). Cuando el scheduler quiere avanzar las peticiones running pero no hay bloques libres para el KV del token siguiente, alguien tiene que bajarse: eso es preemption.
vLLM V1 preempta por RECOMPUTE por defecto, no por SWAP (V1 guide). La diferencia:
- SWAP: copiar el KV de la víctima a RAM de host y traerlo de vuelta luego. Mueve gigabytes por el PCIe (ver el post de PCIe y P2P).
- RECOMPUTE: tirar el KV de la víctima y, cuando vuelva a tener sitio, re-hacer su prefill desde cero. Suena caro, pero en la arquitectura V1 sale más barato que el swap porque el prefill es trabajo que la GPU hace muy rápido, y te ahorras el viaje de ida y vuelta por el bus.
La víctima suele ser la petición más nueva de la cola running (para no penalizar a quien lleva más tiempo esperando su respuesta). El peligro es el thrashing: si admites demasiadas peticiones a la vez, el sistema entra en un ciclo de preemptar-recomputar-preemptar que tira el throughput al suelo. Por eso existe el segundo tope.
Las matemáticas que importan: concurrencia y el trade-off del presupuesto
Cuántas peticiones caben a la vez. El límite real de concurrencia no es max_num_seqs (el tope nominal de secuencias simultáneas); suele ser el KV cache. Si un nodo tiene $B$ bloques de KV libres, cada bloque guarda $b$ tokens (16 por defecto), y cada petición ocupa de media $L$ tokens de contexto, la concurrencia máxima sostenible es:
$$N_\text{max} \approx \frac{B \cdot b}{L}$$
Pongamos un modelo de 70B en FP16 sobre 4×H100 SXM (320 GB), con el grueso de la HBM en pesos y, digamos, ~120 GB libres para KV. Con un KV de ~0,3 MB/token (cifra de orden, depende de capas y cabezas), eso son ~400.000 tokens de KV. Con contextos medios de 4000 tokens:
$$N_\text{max} \approx \frac{400000}{4000} = 100 \text{ peticiones concurrentes}$$
Subir max_num_seqs a 400 no te da 400 concurrentes: te da preemption y thrashing en cuanto los contextos crezcan. El KV manda.
El trade-off del presupuesto. Subir max_num_batched_tokens mete más trabajo por forward, así que menos forwards para el mismo trabajo total → más throughput. Pero un presupuesto grande deja que un prefill enorme ocupe casi toda la bandeja en un step, y ese step tarda más → los decodes de todos los demás esperan ese step entero → sube el ITL (inter-token latency) de todo el mundo. La regla práctica (optimización vLLM):
| Presupuesto | Efecto | A costa de |
|---|---|---|
| Bajo (p. ej. 2048) | más interleaving, ITL estable | menos throughput pico |
| Alto (p. ej. 16384) | máximo throughput | picos de ITL cuando entra un prefill grande |
No hay valor “correcto”: hay un punto en tu carga. Y solo lo encuentras midiendo throughput e ITL a la vez, que es justo lo que casi nadie hace.
Los 10 knobs del scheduler
| # | Knob | Qué controla | Coste si te pasas |
|---|---|---|---|
| 1 | max_num_batched_tokens | tamaño de la bandeja por step | ITL alto si muy grande |
| 2 | max_num_seqs | tope nominal de concurrencia | preemption si el KV no llega |
| 3 | gpu_memory_utilization | bloques de KV disponibles | OOM si demasiado alto |
| 4 | chunked prefill (umbral) | tamaño del trozo de prompt | overhead de troceo si muy fino |
| 5 | política (fcfs/priority) | a quién se sirve antes | inanición de baja prioridad |
| 6 | modo de preemption | RECOMPUTE vs SWAP | tráfico PCIe / recómputo |
| 7 | enable_prefix_caching | reutilizar KV de prefijos | poco; casi siempre on |
| 8 | max_model_len | contexto máximo por petición | reserva KV pesimista |
| 9 | tamaños de CUDA graph | alinear batch con buckets | padding / captura (ver abajo) |
| 10 | speculative tokens | tokens extra por step | trabajo desperdiciado si baja aceptación |
Cómo se conecta con el resto del stack
Con el continuous batching. El scheduler es el continuous batching hecho código. El post de batching explica el qué (avanzar todas las peticiones a la vez); este explica el cómo (el diccionario de tokens por step).
Con el KV cache y el block manager. El segundo presupuesto —los bloques— lo gestiona el block manager de PagedAttention. El scheduler pide bloques; si no hay, preempta. Las dos piezas están acopladas por la memoria.
Con los CUDA graphs. Los CUDA graphs se capturan para tamaños de batch concretos (buckets). El scheduler debería producir batches cuyo tamaño caiga en esos buckets para evitar padding; si no, se pierde parte del beneficio del graph.
Con el chunked prefill y el prefix cache. Trocear un prompt interactúa con el prefix caching: los trozos que coinciden con un prefijo ya cacheado se saltan el cómputo, y el scheduler lo refleja bajando los n_tokens de esa petición.
Con el speculative decoding. El speculative decoding hace que un step verifique varios tokens de golpe; el scheduler lo modela como n_tokens > 1 para una petición en decode.
Con el disaggregated serving. En prefill/decode desagregado hay dos schedulers, uno por pool, cada uno con su presupuesto; la distinción de fases que V1 borró dentro de un motor vuelve a aparecer entre motores.
Con el autoscaling. Las métricas que dispara el autoscaling con KEDA —longitud de la cola waiting, peticiones preemptadas— salen directamente del estado del scheduler.
Trampas y cosas que no son lo que parecen
“Subir max_num_batched_tokens siempre mejora.” Mejora el throughput y empeora el ITL. Si solo miras tokens/s en un benchmark de batch grande, “confirmas” que más es mejor; en producción interactiva, tus usuarios notan los tirones. Mide las dos métricas o no estás midiendo.
“El motor hace primero todos los prefills y luego los decodes.” Es la intuición de la arquitectura vieja. En V1 no hay fases: cada step mezcla prefill y decode según el presupuesto. Razonar con el modelo de fases lleva a conclusiones equivocadas sobre por qué sube la latencia.
“Más max_num_seqs = más throughput.” Solo hasta que el KV cache se agota. A partir de ahí, más concurrencia nominal produce preemption, y la preemption en cascada (thrashing) baja el throughput. El techo real es el KV, no el parámetro.
“RECOMPUTE es un desperdicio, mejor SWAP.” En V1, RECOMPUTE suele ganar: el prefill es rapidísimo en GPU y el SWAP mete un viaje de gigabytes por el PCIe en el camino crítico. Cambiar a SWAP “para no recomputar” puede empeorar la latencia.
“El scheduler es el cuello de botella.” Casi nunca. La decisión es un diccionario que se arma en microsegundos; el coste de la ronda es el forward en la GPU, que está tres órdenes de magnitud por encima. Si tu CPU de scheduling aparece en el profiler, el problema suele ser jitter del hilo de host (ver NUMA y aislamiento de CPU), no la lógica del scheduler.
Chunked prefill demasiado fino. Trozos minúsculos hacen que un prompt grande tarde muchos steps y añaden overhead fijo por step. El troceo es para acotar el impacto en el ITL, no para pulverizar el prompt.
Conclusión
Toda la potencia de un servidor de LLM moderno —tragar cientos de peticiones a la vez sin que ninguna bloquee a las demás— descansa sobre una decisión que cabe en un diccionario {petición: cuántos tokens}, tomada cientos de veces por segundo. La idea que lo hizo posible no fue un kernel más rápido ni una GPU más grande: fue dejar de pensar en fases. Cuando un token de prompt y un token generado son la misma cosa, el scheduler puede llenar cada bandeja mezclando lo pesado y lo ligero, y la cocina no para nunca. El resto son dos presupuestos —tokens y bloques de KV— y una regla para cuando el segundo se agota. El jefe de sala no cocina; solo decide qué entra a fogones en cada ronda. Pero esa decisión, repetida sin descanso, es lo que separa una GPU ociosa esperando comandas de una cocina que va a pleno gas. Y la lección incómoda para quien tunea: el throughput y la latencia se tocan en el presupuesto, y optimizar uno a ciegas es empeorar el otro sin enterarte.
Ver también
- Continuous batching: por qué no esperamos a terminar una petición — el qué; este post es el cómo que lo implementa.
- PagedAttention y el block manager — el segundo presupuesto del scheduler, los bloques de KV; cuando se agotan, preemption.
- KV cache: la memoria de trabajo — qué ocupa cada token vivo y por qué la concurrencia la limita la memoria, no el parámetro.
- SM, CUDA streams y CUDA graphs — los buckets de captura que el scheduler debería respetar para no pagar padding.
- Prefix cache hit rate engineering — cómo los trozos cacheados bajan los
n_tokensque el scheduler asigna. - Speculative decoding — el caso
n_tokens > 1en decode. - Disaggregated serving: prefill y decode separados — dos schedulers, la distinción de fases que vuelve entre motores.
- Autoscaling de LLM con KEDA — las métricas del scheduler (cola waiting, preemptados) como señal de escalado.
Referencias
- vLLM, vLLM V1: A Major Upgrade to vLLM’s Core Architecture: https://openlm.ai/vllm-v1/.
- vLLM, vLLM V1 User Guide (chunked prefill por defecto, preemption RECOMPUTE): https://docs.vllm.ai/en/v0.9.2/usage/v1_guide.html.
- vLLM, Optimization and Tuning (
max_num_batched_tokens, presupuesto y trade-off): https://docs.vllm.ai/en/stable/configuration/optimization/. - A. Wong, Understanding vLLM Scheduling: Token Budgets, Chunked Prefill, and Policies: https://audreywongkg.medium.com/understanding-vllm-scheduling-token-budgets-chunked-prefill-and-policies-2c879e3980e3.
- W. Kwon et al., Efficient Memory Management for Large Language Model Serving with PagedAttention (SOSP 2023): https://arxiv.org/pdf/2309.06180.