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:

  1. El scheduler mira las colas y produce una decisión.
  2. El model runner ejecuta un forward con ese batch en la GPU.
  3. El sampler saca un token nuevo por cada secuencia activa.
  4. 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).

Scheduler{req_id: n_tokens}Model runner1 forward (GPU)Sampler+1 token / seqActualizarKV, posicionesuna iteración = un step (orden de ms en H100)

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:

  1. 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.
  2. 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.

Bandeja: max_num_batched_tokens = 8192 tokens/step200 decode7992 para prefill (un prompt de 4000 cabe entero; uno de 30000 entra a trozos)Decode primero (clientes comiendo) · prefill rellena el resto (FCFS) · si no cabe, se trocea

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):

PresupuestoEfectoA costa de
Bajo (p. ej. 2048)más interleaving, ITL establemenos throughput pico
Alto (p. ej. 16384)máximo throughputpicos 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

#KnobQué controlaCoste si te pasas
1max_num_batched_tokenstamaño de la bandeja por stepITL alto si muy grande
2max_num_seqstope nominal de concurrenciapreemption si el KV no llega
3gpu_memory_utilizationbloques de KV disponiblesOOM si demasiado alto
4chunked prefill (umbral)tamaño del trozo de promptoverhead de troceo si muy fino
5política (fcfs/priority)a quién se sirve antesinanición de baja prioridad
6modo de preemptionRECOMPUTE vs SWAPtráfico PCIe / recómputo
7enable_prefix_cachingreutilizar KV de prefijospoco; casi siempre on
8max_model_lencontexto máximo por peticiónreserva KV pesimista
9tamaños de CUDA graphalinear batch con bucketspadding / captura (ver abajo)
10speculative tokenstokens extra por steptrabajo 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

Referencias