Por qué el batching estático desperdicia GPUs

¿Cómo sirves un LLM a muchos usuarios a la vez? La respuesta ingenua es el batching estático : recopilar $N$ solicitudes entrantes, rellenar cada secuencia hasta la longitud de la más larga, y empujar el lote completo a través del modelo junto. Esto es exactamente cómo funciona el entrenamiento (lotes de tamaño fijo, longitud de secuencia uniforme), así que es un primer intento natural para el servicio.

Pero la generación es fundamentalmente diferente del entrenamiento. Durante el entrenamiento, cada secuencia en un lote se procesa en un solo pase hacia adelante. Durante la generación, cada solicitud produce tokens de uno en uno en un bucle autorregresivo, y diferentes solicitudes terminan en momentos muy diferentes. Un usuario podría preguntar "¿Cuánto es 2+2?" (unos pocos tokens) mientras otro pide un ensayo de 500 palabras. Con batching estático, la solicitud corta termina en un puñado de pasos de decodificación, pero su espacio en el lote permanece ocupado (lleno con tokens de relleno) hasta que la solicitud más larga del lote se complete. La GPU está haciendo operaciones reales de multiplicar-y-acumular en esos tokens de relleno, pero sin producir nada útil.

¿Cuán grave es el desperdicio? Consideremos un lote de $N$ solicitudes donde la longitud máxima de salida es $L_{\text{max}}$ pero la longitud promedio de salida es $\bar{L}$. Cada solicitud se rellena hasta $L_{\text{max}}$, así que el cómputo total gastado en relleno es proporcional a $N \cdot (L_{\text{max}} - \bar{L})$, mientras que el cómputo útil es proporcional a $N \cdot \bar{L}$. La fracción de cómputo desperdiciado en relleno es:

$$\text{waste} = \frac{L_{\text{max}} - \bar{L}}{L_{\text{max}}} = 1 - \frac{\bar{L}}{L_{\text{max}}}$$

Verifiquemos los límites. Si cada solicitud produce exactamente el mismo número de tokens, $\bar{L} = L_{\text{max}}$ y desperdicio $= 0$ (no se necesita relleno, el caso ideal). Si $L_{\text{max}} = 2048$ pero la respuesta promedio es de 200 tokens ($\bar{L} = 200$), entonces desperdicio $= 1 - 200/2048 \approx 0.90$. El noventa por ciento de los FLOPs de la GPU se gastan calculando atención sobre tokens de relleno que no producen salida. En la práctica, las distribuciones de longitud de salida están fuertemente sesgadas a la derecha (la mayoría de las respuestas son cortas, unas pocas son muy largas), así que la solicitud más larga en cualquier lote de tamaño razonable tiende a ser mucho más larga que el promedio.

💡 La fórmula de desperdicio asume cómputo uniforme por token, lo cual es aproximadamente cierto para la fase de decodificación de la generación autorregresiva (cada paso procesa un token por solicitud). La fase de prefill (procesamiento del prompt completo de entrada en un solo pase) tiene un perfil de cómputo diferente, pero el problema de relleno aún aplica: los prompts más cortos se rellenan para igualar al más largo.

Hay un segundo problema, más sutil: el acoplamiento de latencia . El usuario que preguntó "¿Cuánto es 2+2?" tiene que esperar a que el ensayo termine, porque el lote no libera ninguna solicitud hasta que todas se completen. El rendimiento (tokens/segundo entre todas las solicitudes) está limitado por la solicitud más lenta, y la latencia por solicitud (tiempo-al-primer-token y tiempo-al-último-token) se infla para cada solicitud corta que tenga la mala suerte de compartir un lote con una larga.

En resumen: el batching estático trata al lote como una unidad atómica. Ninguna solicitud puede entrar o salir a mitad de la generación. Esto desperdicia cómputo en relleno y acopla la latencia de cada solicitud al peor caso. Necesitamos una manera de dejar que las solicitudes entren y salgan de forma independiente.

Batching continuo (In-Flight Batching)

La solución es sorprendentemente simple en concepto: en lugar de operar a nivel de solicitud (esperar a que el lote completo termine), operar a nivel de iteración (verificar después de cada paso de decodificación). Esta idea fue formalizada como programación a nivel de iteración en el artículo de Orca (Yu et al., 2022) , y la comunidad de servicio ahora lo llama batching continuo o in-flight batching .

Así es como funciona. El sistema mantiene un lote activo de hasta $B$ solicitudes. Después de cada paso de decodificación (un token generado por solicitud activa), el planificador verifica: ¿alguna solicitud ha emitido un token de fin de secuencia o ha alcanzado su longitud máxima? Si es así, desaloja esa solicitud del lote e inmediatamente inserta una nueva solicitud de la cola de espera en el espacio liberado. El lote se mantiene lleno (o tan lleno como la cola permita) en cada iteración, y ninguna solicitud espera a que otra termine.

Una analogía lo hace concreto. El batching estático es como un autobús turístico: todos abordan al mismo tiempo, el autobús recorre la ruta completa, y nadie baja hasta la última parada. Los pasajeros con destinos cercanos se quedan sentados sin hacer nada durante todo el viaje. El batching continuo es como un metro: los pasajeros suben y bajan en cada estación. Tan pronto como alguien baja, su asiento está disponible para la siguiente persona esperando en el andén. El tren siempre está lleno mientras haya pasajeros esperando.

La ganancia de rendimiento viene de dos fuentes. Primero, no se desperdicia cómputo en relleno, porque cada espacio activo contiene una solicitud real en progreso generando tokens reales. Segundo, el lote se mantiene lleno: tan pronto como una solicitud corta termina, una nueva solicitud de la cola toma su lugar, así que la GPU nunca queda inactiva esperando a la solicitud más lenta. Si la cola es suficientemente profunda (más solicitudes entrantes que espacios en el lote), cada paso de decodificación procesa exactamente $B$ tokens reales.

Podemos cuantificar la mejora. En batching estático, el rendimiento sobre un "ciclo de lote" es $\sum_{i=1}^{N} L_i$ tokens útiles generados en $L_{\text{max}}$ pasos de decodificación (porque cada paso procesa $N$ secuencias pero solo $\sum_i \mathbf{1}[t \leq L_i]$ están haciendo trabajo útil en el paso $t$). En batching continuo con una cola profunda, el rendimiento sobre $L_{\text{max}}$ pasos de decodificación es $B \cdot L_{\text{max}}$ tokens útiles, porque cada espacio está ocupado en cada paso. La relación es:

$$\frac{\text{throughput}_{\text{continuous}}}{\text{throughput}_{\text{static}}} = \frac{B \cdot L_{\text{max}}}{\sum_{i=1}^{N} L_i} = \frac{L_{\text{max}}}{\bar{L}}$$

Aquí asumimos $B = N$ (mismo tamaño de lote). Cuando $\bar{L} = L_{\text{max}}$ (todas las solicitudes tienen la misma longitud), la relación es 1 — el batching continuo no ofrece ventaja, porque no hay desperdicio que eliminar. Cuando $\bar{L} = 200$ y $L_{\text{max}} = 2048$, la relación es $2048/200 \approx 10\times$. Esa mejora de rendimiento de diez veces no viene de hardware más rápido o un modelo más pequeño. Es puramente por eliminar cómputo inactivo. En cargas de trabajo de producción con alta varianza de longitud, el batching continuo comúnmente entrega ganancias de rendimiento de 2-10 veces sobre el batching estático.

💡 La relación de rendimiento $L_{\text{max}} / \bar{L}$ asume una cola saturada (siempre más solicitudes esperando que espacios en el lote). En escenarios de bajo tráfico donde la cola frecuentemente está vacía, el lote puede no mantenerse lleno, y la ganancia real es menor. Pero los sistemas de servicio están diseñados para alta carga, así que la suposición de cola saturada es la que importa en la práctica.

La latencia también mejora. Una solicitud corta ya no espera a la solicitud más larga en su lote. Entra al lote, genera sus tokens, y sale, con su latencia total determinada solo por su propia longitud más cualquier tiempo en la cola de espera. El planificador también puede aplicar políticas de prioridad (por ejemplo, priorizar chat interactivo sobre resumen por lotes) que eran imposibles con el modelo de todo-o-nada del batching estático.

Pero el batching continuo introduce un nuevo problema. Las solicitudes ahora tienen diferentes longitudes de contexto y diferentes tiempos de vida. La KV cache (los tensores de clave-valor almacenados de tokens anteriores para no recalcular la atención desde cero en cada paso) debe gestionarse dinámicamente: asignarse cuando una solicitud entra, crecer a medida que genera tokens, y liberarse cuando sale. ¿Cómo gestionamos esta memoria eficientemente?

El problema de fragmentación de la KV cache

Durante la generación autorregresiva, cada capa del transformer almacena en caché las proyecciones de clave y valor de cada token generado hasta ahora. Esta KV cache es lo que hace que la generación sea $O(n)$ por paso en lugar de $O(n^2)$: en el paso $t$, solo calculamos la consulta para el nuevo token y atendemos sobre las claves y valores en caché de los pasos $1$ hasta $t-1$, en lugar de reprocesar la secuencia completa. Para un modelo con $L$ capas, $h$ cabezas de atención, y dimensión de cabeza $d_h$, la KV cache para una sola solicitud en la posición de secuencia $t$ ocupa:

$$\text{KV size}(t) = 2 \cdot L \cdot h \cdot d_h \cdot t \cdot \text{sizeof(dtype)}$$

El factor de 2 cuenta tanto claves como valores. Para un modelo de 7B parámetros (por ejemplo, Llama-2-7B con $L=32$, $h=32$, $d_h=128$) usando float16 (2 bytes por elemento), una sola solicitud a longitud de contexto máximo $t = 4096$ requiere $2 \times 32 \times 32 \times 128 \times 4096 \times 2 \approx 2.1$ GB. Para un lote de 32 solicitudes todas a longitud máxima, eso son 67 GB de KV cache sola, que no cabría en la mayoría de las GPUs. Y esto es un modelo de 7B — modelos más grandes necesitan proporcionalmente más.

El enfoque ingenuo para gestionar esta memoria es pre-asignar $L_{\text{max}} \times 2 \times L \times h \times d_h$ bytes para cada solicitud cuando entra al lote, reservando espacio para la longitud máxima posible de secuencia. Esto es lo que hace la función generate() por defecto de HuggingFace. Es simple, pero catastróficamente despilfarrador cuando se combina con batching continuo, porque la mayoría de las solicitudes no usarán ni de cerca $L_{\text{max}}$ tokens.

Esto crea dos tipos de fragmentación, directamente análogos a los problemas clásicos de memoria de sistemas operativos:

  • Fragmentación interna: cada solicitud tiene un bloque contiguo de $L_{\text{max}}$ espacios reservados, pero solo usa $L_i \ll L_{\text{max}}$ de ellos. Los espacios no usados dentro de cada asignación se desperdician. Si $L_{\text{max}} = 4096$ y una solicitud solo genera 150 tokens, el $96\%$ de su memoria reservada nunca se toca.
  • Fragmentación externa: cuando las solicitudes terminan y su memoria se libera, los bloques liberados pueden no ser contiguos entre sí. Una nueva solicitud que necesita una asignación contigua grande podría fallar aunque la memoria libre total sea suficiente, porque está dispersa en huecos no adyacentes entre solicitudes aún activas.

¿Cuánta memoria se desperdicia realmente? Con pre-asignación, la memoria reservada para un lote de $N$ solicitudes es $N \cdot L_{\text{max}} \cdot c$ (donde $c$ es el tamaño de KV cache por token), pero la memoria realmente usada es $\sum_{i=1}^{N} L_i \cdot c = N \cdot \bar{L} \cdot c$. La tasa de utilización es:

$$\text{utilisation} = \frac{\bar{L}}{L_{\text{max}}}$$

Esta es la misma relación que vimos para el desperdicio de cómputo, y los mismos límites aplican: $100\%$ de utilización solo cuando todas las solicitudes usan la longitud de contexto completa, y tan bajo como $5\text{--}10\%$ en cargas de trabajo realistas. Dado que la memoria de la GPU limita directamente cuántas solicitudes pueden estar en el lote simultáneamente, desperdiciar el $90\%$ de la memoria de la KV cache significa que el tamaño de lote efectivo es aproximadamente $10\times$ menor de lo que el hardware podría soportar si la memoria se gestionara eficientemente. Lotes más pequeños significan menor rendimiento y mayor latencia de cola.

💡 El problema de fragmentación es particularmente severo para modelos de contexto largo ($L_{\text{max}} = 32{,}768$ o $131{,}072$). Pre-asignar la ventana de contexto completa para cada solicitud en el lote es simplemente inviable a estas longitudes, razón por la cual la gestión eficiente de la KV cache se convirtió en un prerrequisito para el servicio con contextos largos.

Para resumir la situación: el batching continuo resolvió el problema de desperdicio de cómputo (no más relleno), pero expuso un problema de desperdicio de memoria (las KV caches para solicitudes de longitud variable son difíciles de gestionar sin fragmentación). Necesitamos un esquema de gestión de memoria que asigne exactamente lo que cada solicitud necesita, nada más, y que pueda reclamar y reutilizar memoria sin restricciones de contigüidad. Los sistemas operativos resolvieron este mismo problema hace décadas con la memoria virtual y la paginación. ¿Podemos aplicar la misma idea a las KV caches?

PagedAttention: Memoria virtual para la KV cache

La respuesta es sí, y la técnica se llama PagedAttention , introducida en el artículo de vLLM (Kwon et al., 2023) . La idea central es un trasplante directo del diseño de sistemas operativos: tratar la memoria de la KV cache como un SO trata la RAM, usando paginación . En lugar de asignar un bloque contiguo por solicitud, dividir toda la memoria de la KV cache en bloques (páginas) de tamaño fijo, y asignarlos bajo demanda a medida que cada solicitud crece.

Aquí está el mecanismo en detalle. La memoria física de la KV cache en la GPU se divide en bloques, cada uno conteniendo las claves y valores para un número fijo de tokens (típicamente $P = 16$ tokens por bloque). Un solo bloque para una capa y una cabeza almacena $2 \times P \times d_h$ elementos (claves y valores, $P$ posiciones de token, dimensión de cabeza $d_h$). A través de todas las capas y cabezas, un "bloque" lógico para una solicitud almacena:

$$\text{block size} = 2 \cdot L \cdot h \cdot d_h \cdot P \cdot \text{sizeof(dtype)}$$

Cada solicitud activa mantiene una tabla de páginas : un mapeo de índices de bloques lógicos (el bloque 0 contiene tokens 0-15, el bloque 1 contiene tokens 16-31, etc.) a direcciones de bloques físicos en memoria de GPU. Cuando una solicitud se programa por primera vez, recibe un bloque. A medida que genera tokens y llena ese bloque, se asigna un nuevo bloque del pool libre y se añade a su tabla de páginas. Cuando una solicitud termina, todos sus bloques se devuelven al pool libre inmediatamente.

¿Por qué esto resuelve ambos problemas de fragmentación? La fragmentación interna está acotada: el único espacio desperdiciado está en el último bloque de cada solicitud, que en promedio desperdicia $P/2$ espacios de tokens. Si $P = 16$, eso es como máximo 16 espacios de token por solicitud — compárese con desperdiciar $L_{\text{max}} - L_i$ espacios (potencialmente miles) con pre-asignación. Para una solicitud que genera $L_i$ tokens, la fracción de desperdicio por solicitud es:

$$\text{waste}_{\text{paged}} \leq \frac{P}{L_i}$$

Cuando $L_i = 200$ y $P = 16$, el desperdicio es como máximo $16/200 = 8\%$. Cuando $L_i = 2000$, el desperdicio baja a $0.8\%$. Compárese con el desperdicio de pre-asignación de $1 - L_i / L_{\text{max}}$, que para $L_i = 200$ y $L_{\text{max}} = 4096$ es $95\%$. La mejora es dramática. La fragmentación externa desaparece completamente, porque los bloques son todos del mismo tamaño y cualquier bloque libre puede asignarse a cualquier solicitud independientemente de dónde se encuentre en la memoria física. No hay necesidad de asignación contigua.

El número total de bloques que caben en la GPU determina la capacidad del sistema. Si la GPU tiene $M$ bytes de memoria disponible para la KV cache y cada bloque es de $b$ bytes, entonces el sistema tiene $\lfloor M / b \rfloor$ bloques. Con pre-asignación, cada solicitud reserva $\lceil L_{\text{max}} / P \rceil$ bloques independientemente del uso real. Con asignación paginada, cada solicitud usa solo $\lceil L_i / P \rceil$ bloques. El tamaño máximo de lote con paginación es:

$$B_{\text{paged}} = \left\lfloor \frac{M / b}{\lceil \bar{L} / P \rceil} \right\rfloor \quad \text{vs} \quad B_{\text{pre-alloc}} = \left\lfloor \frac{M / b}{\lceil L_{\text{max}} / P \rceil} \right\rfloor$$

La relación $B_{\text{paged}} / B_{\text{pre-alloc}} \approx L_{\text{max}} / \bar{L}$, que puede ser $10\text{--}20\times$ para cargas de trabajo típicas. Más solicitudes en el lote significa mayor rendimiento, y esto viene sin costo — solo estamos usando memoria que antes se desperdiciaba. El artículo de vLLM reporta que PagedAttention logra una utilización de memoria casi óptima: típicamente $> 95\%$ de la memoria de KV cache asignada almacena activamente pares clave-valor útiles, comparado con $20\text{--}60\%$ con pre-asignación ingenua.

PagedAttention habilita una optimización más importante: compartición copy-on-write (CoW) . En muchos escenarios de servicio, múltiples solicitudes comparten el mismo prefijo — por ejemplo, un prompt de sistema que se antepone a cada mensaje de usuario, o múltiples respuestas candidatas generadas a partir de la misma entrada (beam search, muestreo paralelo). Con paginación, estas solicitudes pueden compartir los mismos bloques físicos para su prefijo común. La tabla de páginas de cada solicitud apunta a los mismos bloques físicos para los tokens compartidos, y solo cuando una solicitud diverge (genera un token diferente) obtiene su propia copia del bloque divergente. Esto es exactamente cómo el fork() de Unix funciona con la memoria de procesos.

Para un prompt de sistema de $S$ tokens compartido por $N$ solicitudes concurrentes, copy-on-write ahorra $N \cdot S \cdot c - S \cdot c = (N-1) \cdot S \cdot c$ bytes (donde $c$ es el tamaño de caché por token). Si $S = 500$, $N = 32$, y $c = 0.5$ KB por token (típico para un modelo de 7B), el ahorro es $31 \times 500 \times 0.5$ KB $\approx 7.6$ MB. Para modelos más grandes y prompts de sistema más largos, los ahorros escalan proporcionalmente y pueden liberar suficiente memoria para añadir varias solicitudes más al lote.

💡 PagedAttention requiere un kernel CUDA personalizado para el cálculo de atención, porque las claves y valores de una sola solicitud ya no están almacenados contiguamente en memoria. El kernel debe recopilar datos de clave-valor de los bloques físicos listados en la tabla de páginas de la solicitud. El equipo de vLLM implementó kernels altamente optimizados para esto, y la sobrecarga de la operación de recopilación es despreciable comparada con los ahorros de memoria que habilita.

Juntándolo todo: Arquitectura de vLLM

vLLM (Kwon et al., 2023) combina batching continuo y PagedAttention con kernels CUDA optimizados en un sistema de servicio completo. Entender el ciclo de vida completo de una solicitud a través de vLLM une todo lo que hemos discutido.

Una solicitud llega a través de la API y entra a una cola de espera . El planificador se ejecuta en cada iteración y decide qué solicitudes promover de la cola al lote activo. Verifica: ¿hay suficientes bloques libres de KV cache para admitir una nueva solicitud? Si es así, asigna un bloque inicial del pool libre, ejecuta la fase de prefill (procesando el prompt completo de entrada en un solo pase hacia adelante para poblar la KV cache), y la solicitud comienza a decodificar. En cada paso de decodificación subsiguiente, la solicitud genera un token. Si su bloque actual está lleno, el planificador asigna un nuevo bloque. Cuando la solicitud emite un token de fin de secuencia, todos sus bloques se devuelven al pool libre y el espacio se abre para la siguiente solicitud en cola.

El planificador también maneja la preemción por presión de memoria. Si el pool libre se queda sin bloques (toda la memoria está en uso por solicitudes activas), el planificador puede intercambiar una solicitud de baja prioridad: copiar sus bloques de KV cache a la memoria de CPU, liberar los bloques de GPU, y admitir una solicitud de mayor prioridad. Cuando la memoria de GPU vuelve a estar disponible, los bloques de la solicitud intercambiada se copian de vuelta y se reanuda desde donde quedó. Esto es exactamente análogo al intercambio de páginas del SO entre RAM y disco.

Así es como configurar un servidor vLLM y consultarlo:

# ── Terminal 1: Start the vLLM server ──────────────────────────────────
# pip install vllm
# This launches an OpenAI-compatible API server with continuous batching
# and PagedAttention enabled by default.
# vllm serve meta-llama/Llama-2-7b-chat-hf \
#     --max-model-len 4096 \
#     --gpu-memory-utilization 0.90

# ── Terminal 2: Send requests ──────────────────────────────────────────
from openai import OpenAI

client = OpenAI(
    base_url="http://localhost:8000/v1",
    api_key="unused",  # vLLM doesn't require a real key
)

# These requests are batched continuously by the server —
# short ones finish and free their KV cache blocks immediately,
# while long ones keep generating without blocking anyone.
response = client.chat.completions.create(
    model="meta-llama/Llama-2-7b-chat-hf",
    messages=[
        {"role": "system", "content": "You are a helpful assistant."},
        {"role": "user", "content": "Explain PagedAttention in two sentences."},
    ],
    max_tokens=256,
    temperature=0.7,
)
print(response.choices[0].message.content)

También puedes usar la API offline (sin servidor) de vLLM directamente para inferencia por lotes:

from vllm import LLM, SamplingParams

# Load the model — PagedAttention and continuous batching
# are handled internally.
llm = LLM(
    model="meta-llama/Llama-2-7b-chat-hf",
    max_model_len=4096,
    gpu_memory_utilization=0.90,
)

prompts = [
    "What is 2 + 2?",                          # very short response
    "Write a 500-word essay on climate change.", # long response
    "Translate 'hello' to French.",             # short response
    "Explain quantum computing in detail.",     # medium response
]

params = SamplingParams(temperature=0.7, max_tokens=1024)

# vLLM handles scheduling, KV cache paging, and eviction
# internally. Short requests finish first and free memory
# for new work — no padding, no waiting.
outputs = llm.generate(prompts, params)

for output in outputs:
    prompt = output.prompt
    generated = output.outputs[0].text
    print(f"Prompt:   {prompt[:60]}...")
    print(f"Response: {generated[:100]}...")
    print(f"Tokens:   {len(output.outputs[0].token_ids)}")
    print()

Los números de rendimiento son impresionantes. El artículo de vLLM compara contra la función generate() de HuggingFace Transformers (batching estático, KV cache pre-asignada) y reporta mejora de rendimiento de 2-4 veces en cargas de trabajo con salida corta y hasta 24 veces en cargas de trabajo con salida larga y alta varianza . Las ganancias son mayores cuando las longitudes de salida son altamente variables (maximizando el desperdicio que incurre el batching estático) y cuando el servidor está bajo carga pesada (manteniendo el lote continuo lleno).

Para construir intuición sobre de dónde vienen las ganancias, la simulación a continuación modela batching estático vs continuo para un conjunto de solicitudes con longitudes de salida variables. Rastrea la utilización de espacios de GPU (fracción de espacios del lote haciendo trabajo útil) en cada paso de decodificación:

import json, random, js

random.seed(42)
BATCH_SIZE = 8
NUM_REQUESTS = 24

# Generate requests with variable output lengths (right-skewed)
request_lengths = [random.randint(10, 40) if random.random() < 0.7
                   else random.randint(80, 150) for _ in range(NUM_REQUESTS)]

# ── Static batching simulation ─────────────────────────────────────
static_util = []
idx = 0
while idx < NUM_REQUESTS:
    batch = request_lengths[idx:idx + BATCH_SIZE]
    max_len = max(batch)
    for step in range(max_len):
        active = sum(1 for l in batch if step < l)
        static_util.append(active / BATCH_SIZE)
    idx += BATCH_SIZE

# ── Continuous batching simulation ─────────────────────────────────
cont_util = []
active_requests = []
queue = list(request_lengths)
step_count = 0
max_steps = len(static_util)

while step_count < max_steps and (active_requests or queue):
    # Fill empty slots from queue
    while len(active_requests) < BATCH_SIZE and queue:
        active_requests.append(queue.pop(0))
    if not active_requests:
        break
    # Record utilisation
    cont_util.append(len(active_requests) / BATCH_SIZE)
    # Decrement remaining lengths, remove finished
    active_requests = [r - 1 for r in active_requests]
    active_requests = [r for r in active_requests if r > 0]
    step_count += 1

# Pad to same length for plotting
max_plot = max(len(static_util), len(cont_util))
static_util += [0] * (max_plot - len(static_util))
cont_util += [0] * (max_plot - len(cont_util))

# Sample every few steps for readability
step = max(1, max_plot // 60)
x_labels = [str(i) for i in range(0, max_plot, step)]
static_sampled = [round(static_util[i], 2) for i in range(0, max_plot, step)]
cont_sampled = [round(cont_util[i], 2) for i in range(0, max_plot, step)]

avg_static = sum(static_util) / len(static_util)
avg_cont = sum(cont_util) / len(cont_util)

plot_data = [
    {
        "title": "GPU Slot Utilisation: Static vs Continuous Batching",
        "x_label": "Decode Step",
        "y_label": "Utilisation (fraction of slots active)",
        "x_data": x_labels,
        "lines": [
            {"label": f"Static (avg {avg_static:.0%})", "data": static_sampled, "color": "#ef4444"},
            {"label": f"Continuous (avg {avg_cont:.0%})", "data": cont_sampled, "color": "#22c55e"},
        ]
    }
]
js.window.py_plot_data = json.dumps(plot_data)

total_tokens = sum(request_lengths)
static_steps = sum(max(request_lengths[i:i+BATCH_SIZE])
                    for i in range(0, NUM_REQUESTS, BATCH_SIZE))
print(f"Requests: {NUM_REQUESTS}, Batch size: {BATCH_SIZE}")
print(f"Output lengths: min={min(request_lengths)}, max={max(request_lengths)}, "
      f"avg={sum(request_lengths)/len(request_lengths):.0f}")
print(f"Static batching:     {static_steps} total decode steps, "
      f"avg utilisation {avg_static:.1%}")
print(f"Continuous batching: {len([u for u in cont_util if u > 0])} total decode steps, "
      f"avg utilisation {avg_cont:.1%}")
print(f"Throughput ratio:    {avg_cont/avg_static:.2f}x")

El gráfico muestra la diferencia fundamental: el batching estático tiene caídas periódicas en la utilización a medida que las solicitudes cortas terminan pero sus espacios permanecen bloqueados hasta que el lote se completa (el patrón de dientes de sierra), mientras que el batching continuo mantiene una utilización cercana al 100% rellenando inmediatamente los espacios completados desde la cola.

Hoy, virtualmente todos los frameworks de servicio de LLM en producción usan batching continuo con alguna forma de gestión paginada de KV cache. vLLM popularizó la idea, pero los mismos principios aparecen en TensorRT-LLM (NVIDIA), TGI (HuggingFace), y DeepSpeed-FastGen (Microsoft). Los detalles difieren (tamaño de bloque, política de programación, implementación del kernel), pero la arquitectura es la misma: programación a nivel de iteración, asignación dinámica de memoria vía paginación, y compartición copy-on-write para prefijos comunes.

Quiz

Pon a prueba tu comprensión del batching continuo y PagedAttention.

En batching estático con $L_{\text{max}} = 2048$ y una longitud promedio de salida de $\bar{L} = 256$, ¿qué fracción del cómputo de la GPU se desperdicia en relleno?

¿Cuál es la diferencia clave de programación entre el batching estático y el batching continuo?

¿Por qué PagedAttention elimina virtualmente la fragmentación externa en la memoria de la KV cache?

¿Cómo ahorra memoria el copy-on-write (CoW) en PagedAttention cuando múltiples solicitudes comparten un prompt de sistema?