Skip to content

English · Español

02 — Static vs continuous batching: el núcleo del servicio moderno de LLM

Static batching es "junta N peticiones, ejecuta el batch entero". Continuous batching es "ejecuta un paso del decode para todas las que están vivas; cuando una emite EOS, sale; nuevas peticiones entran entre pasos". El cambio es de un eje (request) a otro (token-step).

El forward pass del modelo amortiza coste fijo

Un único forward pass del Mini-GPT sobre una secuencia de longitud \(T\) hace \(O(T \cdot d^2)\) trabajo de matmul. Con dimensión de batch \(B\) — es decir, \(B\) secuencias procesadas en paralelo — el trabajo es \(O(B \cdot T \cdot d^2)\), pero el overhead Python (la orquestación, el setting de slots, los lookups de cache) es esencialmente constante en \(B\).

Entonces: a \(B\) bajo, estás Python-bound; a \(B\) alto, estás matmul-bound; la curva throughput-vs-\(B\) satura en algún punto intermedio.

Esta es toda la motivación del batching. Un forward pass sobre \(B\) requests es mucho más barato que \(B\) forward passes sobre 1 request cada uno.

Static batching

El enfoque ingenuo.

def static_batch_loop():
    while True:
        batch = []
        deadline = time.time() + MAX_WAIT_MS / 1000
        while len(batch) < MAX_BATCH and time.time() < deadline:
            batch.append(queue.get(timeout=...))
        if batch:
            run_full_inference(batch)   # genera hasta que TODAS las requests del batch terminen

El scheduler recolecta hasta MAX_BATCH requests (o hasta que transcurran MAX_WAIT_MS), luego ejecuta el bucle de generación entero sobre ese batch.

El problema: las requests del mismo batch tienen longitudes de generación distintas. Si la request A quiere 3 tokens y la B quiere 30, el batch corre durante 30 pasos. La A termina sus 3 tokens pero tiene que esperar a que B acabe sus 30 — y la respuesta del batch se envía al final.

Este es el desastre de latencia (latency) de cola del static batching. Las requests rápidas pagan por las lentas.

Ilustración:

Request A (3 tokens): [t0][t1][t2][wait][wait][wait]...[wait]   end-time: 30 pasos
Request B (30 tokens):[t0][t1][t2][t3 ][t4 ][t5 ]...[t29 ]      end-time: 30 pasos

p95 latency = la longitud de generación del percentil 95 × tiempo por paso. No es bueno.

Continuous batching

El cambio: schedule a nivel de paso de token, no a nivel de request.

def continuous_batch_loop():
    in_flight: list[Request] = []
    while True:
        # Admite nuevas requests hasta capacidad
        while len(in_flight) < MAX_INFLIGHT and not queue.empty():
            in_flight.append(queue.get_nowait())

        if not in_flight:
            time.sleep(IDLE_MS / 1000)
            continue

        # Ejecuta UN paso de decode para todas las in-flight
        step_outputs = model.forward_one_step(in_flight)   # batched

        for req, out_token in zip(in_flight, step_outputs):
            req.append(out_token)
            if out_token == EOS or req.length >= req.max_tokens:
                req.send_response()
        in_flight = [r for r in in_flight if not r.done]

Ahora:

Request A (3 tokens): [t0][t1][t2][DONE]                         end-time: 3 pasos
Request B (30 tokens):[t0][t1][t2][t3 ][t4 ][t5 ]...[t29 ][DONE] end-time: 30 pasos

La respuesta de la request A se envía en el paso 3, no en el 30. La latencia de cola cae drásticamente para requests cortas.

Esta es la técnica que vLLM, TGI, Triton y cualquier servidor de inferencia moderno usa. El nombre "continuous batching" viene del hecho de que el batch cambia continuamente — las requests entran y salen en cada paso.

¿Qué hace al continuous batching delicado?

  1. KV cache por request. Cada request tiene su propia KV cache. El forward pass debe leer de \(B\) caches distintas y escribir en \(B\) caches distintas. Esto es lo que PagedAttention (Fase 27) aborda a gran escala — gestionar muchas KV caches de longitud variable sin un desperdicio masivo de memoria.

  2. Longitudes de secuencia variables en un batch. Cuando las requests A (longitud actual 5) y B (longitud actual 12) están en el mismo paso del batch, la atención tiene que manejar longitudes de secuencia distintas. Hay padding o masking de por medio. (En el lab 03 lo evitamos haciendo que solo la query del nuevo token atienda a los K/V cacheados, lo cual es naturalmente por-request.)

  3. Control de admisión. ¿Cuándo admitir una nueva request? Si tu conjunto in-flight está al máximo, la nueva request espera. Si admites demasiado, la latencia se hunde para todos.

  4. Prefill vs decode. El primer forward de una request (el prefill sobre el prompt) es mucho más caro que cada paso de decode posterior. Mezclar prefill en un batch de decode es difícil. Los sistemas de producción separan los batches de prefill y de decode; lo mencionaremos en el survey del lab 04.

Para la Fase 33 manejaremos los puntos 1 y 2 trivialmente (Mini-GPT pequeño, cache por request como dict), y el punto 3 mínimamente (admisión FIFO hasta un tope).

La ganancia esperada

Para una carga con longitudes de generación mixtas (algunas correcciones cortas, otras con explicaciones más largas), la p95 del continuous batching debería bajar ~30-70 % vs el static batching con el mismo throughput medio. Esa es la cifra que medirá el lab.

Para una carga donde todas las requests tienen la misma longitud, continuous batching y static batching son equivalentes — no hay requests rápidas esperando a las lentas.

Una observación contraintuitiva

El continuous batching no necesariamente incrementa el throughput. El trabajo total (suma de todos los FLOPs) es el mismo. Lo que cambia es la distribución de latencias — la cola larga se encoge.

Si solo mides requests/s (throughput), puede que no veas mejora. Si mides p95 latency, verás una ganancia clara. Por eso el DoD especifica p95.

Lo que este archivo NO cubre

  • PagedAttention. Fase 27.
  • Speculative decoding dentro del scheduler. Fase 36.
  • Prefill/decode desagregados. Fuera de alcance (técnica avanzada de producción).

Siguiente: 03-littles-law-and-capacity.md