Optimizando el prefill en vLLM: los knobs que tu TTFT no perdona
TL;DR
El prefill es la fase en la que vLLM procesa tu prompt de entrada y produce el KV cache inicial. Es compute-bound (a diferencia del decode, que es memory-bound), tarda más cuanto más largo es el prompt, y bloquea el decode de todas las demás requests en cola. Hay cuatro palancas en vLLM que cambian radicalmente su comportamiento: chunked prefill, prefix caching, FP8 KV cache y el presupuesto de tokens por batch. Con hardware modesto —una RTX 4090 de 24 GB o una L40 de 48 GB— la diferencia entre ignorarlas y usarlas bien puede ser un TTFT 3× menor y un 40% más de throughput agregado.
La analogía
Imagina una imprenta de principios del siglo XX. Componer los tipos de plomo (preparar el molde) es lento y bloquea la prensa. Imprimir las páginas ya compuestas es rápido, pero necesita el molde listo antes de empezar.
El prefill es componer los tipos. El decode es imprimir. Una prensa que sólo puede hacer una cosa a la vez —o compone o imprime— deja la maquinaria parada la mitad del tiempo. La solución histórica fue tener un obrero componiendo la siguiente plana mientras la anterior ya estaba en prensa. Eso es, exactamente, chunked prefill.
Qué es el prefill realmente
Cuando llega un request a vLLM, el motor tiene que procesar todos los tokens del prompt de una vez antes de poder emitir el primer token de respuesta. Durante ese procesamiento calcula, para cada token del prompt, sus vectores Key y Value de la atención. El resultado —el KV cache inicial— se almacena en VRAM y se usa durante todo el decode posterior.
A diferencia del decode, donde el modelo procesa un token nuevo por paso, en el prefill procesa N tokens de golpe. Eso lo hace mucho más eficiente en FLOPs/token (las GPUs son buenas en matmuls grandes), pero tiene un coste cuadrático en atención:
$$\text{FLOPs_atención_prefill} \approx 4 \cdot N^2 \cdot d_{model}$$
Con un prompt de 1.000 tokens y $d_{model} = 4096$ (Qwen2.5-7B): $4 \cdot 10^6 \cdot 4096 \approx 16 \times 10^9$ FLOPs sólo en atención. Con 4.000 tokens, 256× más por la naturaleza cuadrática.
Prefill (compute-bound):
prompt tokens → [attention O(N²)] → [FFN] → KV cache inicial
Decode (memory-bound):
1 token nuevo → [cross-attention sobre KV cache] → siguiente token
Por qué el prefill es un problema en hardware pequeño
En una H100 con 3,35 TB/s de ancho de banda, un prefill largo se amortiza rápido. En una RTX 4090 (1,008 TB/s) o una L40 (864 GB/s), el cuello de botella aparece antes y tiene consecuencias concretas:
El problema del head-of-line blocking. Por defecto, vLLM procesa un prefill completo antes de hacer cualquier decode. Si tienes 10 requests en cola —9 en decode, 1 con un prompt de 8.000 tokens— esas 9 requests se detienen mientras la GPU mastica el prefill largo. Sus usuarios ven que el streaming se congela. Esto se llama head-of-line blocking y es el enemigo número uno del TTFT en producción.
Las cuatro palancas
1. Chunked prefill — --enable-chunked-prefill + --max-num-batched-tokens
Chunked prefill parte el prefill largo en trozos (chunks) y los intercala con pasos de decode en el mismo batch. En vLLM V1 (≥ 0.6) está activo por defecto.
vllm serve mi-modelo \
--enable-chunked-prefill \
--max-num-batched-tokens 4096
--max-num-batched-tokens es el presupuesto total de tokens que vLLM puede procesar en un único paso del motor, sumando prefill y decode. Es el parámetro más importante para controlar el trade-off:
max-num-batched-tokens | Efecto |
|---|---|
| Bajo (512–2048) | Más pasos de decode por ciclo → mejor ITL, peor TTFT |
| Alto (8192–32768) | Chunks de prefill grandes → mejor TTFT y throughput, peor ITL |
Para una RTX 4090 sirviendo modelos 7B–13B con contextos mixtos (256–4096 tokens):
--max-num-batched-tokens 8192 # punto de equilibrio razonable
Para una L40 (48 GB) con modelos más grandes y prompts más largos:
--max-num-batched-tokens 16384
Cómo funciona internamente: con un presupuesto de 4.096 tokens y un prefill de 10.000, vLLM lo parte en 3 chunks (4.096 + 4.096 + 1.808). Entre chunks, procesa los pasos de decode pendientes. Las requests en decode siguen avanzando; el prefill largo tarda más en terminar, pero no congela nada.
Sin chunked prefill:
t=0 [prefill 10k tokens]─────────────────────────────┐
t=1 └─[decode r1,r2...r9]
Con chunked prefill (budget 4096):
t=0 [prefill chunk 4096][decode r1..r9]
t=1 [prefill chunk 4096][decode r1..r9]
t=2 [prefill chunk 1808][decode r1..r9]
t=3 [decode todos, incluido el nuevo]
El TTFT del request largo aumenta ligeramente (3 pasos en vez de 1), pero el ITL de las otras 9 requests no se interrumpe.
2. Prefix caching — --enable-prefix-caching
Si múltiples requests comparten el mismo prefijo —un system prompt, un few-shot, un documento de contexto— vLLM puede calcular el KV cache de ese prefijo una sola vez y reutilizarlo.
vllm serve mi-modelo \
--enable-prefix-caching
Esto se llama Automatic Prefix Caching (APC). Internamente, vLLM divide el KV cache en bloques de tamaño fijo (por defecto 16 tokens/bloque) y les asigna un hash SHA basado en el contenido. Cuando llega un request nuevo, comprueba si algún bloque inicial ya está en la caché. Si hay hit, se salta ese prefill.
El impacto numérico: supón un system prompt de 512 tokens que aparece en el 80% de tus requests, y una tasa de 100 req/min:
- Sin APC: 80 req/min × 512 tokens × costo_prefill = 41.000 tokens/min de prefill redundante
- Con APC (hit rate 80%): 20 req/min × 512 = 10.240 tokens/min de prefill → reducción del 75%
El TTFT de esos 80 requests cae a lo que cuesta procesar sólo el sufijo nuevo.
Limitación con chunked prefill: cuando chunked prefill está activo, sólo el primer chunk del prefill se beneficia de APC en la implementación actual de vLLM. Para workloads donde el hit de caché es muy alto y los sufijos son cortos, considera reducir --max-num-batched-tokens para que el primer chunk cubra más del prefijo compartido.
# Configuración optimizada para alto hit rate de prefix cache
vllm serve mi-modelo \
--enable-prefix-caching \
--enable-chunked-prefill \
--max-num-batched-tokens 4096 # chunks más pequeños = prefijo cabe en chunk 1
3. FP8 KV cache — --kv-cache-dtype fp8
El KV cache ocupa VRAM. Cuanta más VRAM consume, menos requests concurrentes puedes mantener en vuelo. En una RTX 4090 de 24 GB, el modelo Qwen2.5-14B en BF16 ya ocupa ~28 GB —no cabe. En Q4 ocupa ~9 GB, dejando ~14 GB para KV cache.
¿Cuántos tokens de contexto caben en 14 GB de KV cache BF16 para un 14B con GQA?
$$\text{KV_size_por_token} = 2 \cdot n_{kv_heads} \cdot d_{head} \cdot n_{layers} \cdot 2 \text{ bytes}$$
Para Qwen2.5-14B: $n_{kv_heads}=8$, $d_{head}=128$, $n_{layers}=40$, BF16 → $2 \cdot 8 \cdot 128 \cdot 40 \cdot 2 = 163.840$ bytes ≈ 160 KB/token.
14 GB / 160 KB ≈ 87.500 tokens de contexto total. Con 8 usuarios en paralelo y 4.096 tokens de contexto cada uno: 32.768 tokens ocupados de 87.500. Hay margen, pero es finito.
Pasando a FP8 (1 byte en vez de 2):
$$\text{KV_FP8} = 80 \text{ KB/token} \implies 14 \text{ GB} / 80 \text{ KB} = 175.000 \text{ tokens}$$
El doble de capacidad de contexto con la misma VRAM. Eso permite o bien más concurrencia, o bien contextos más largos.
vllm serve mi-modelo \
--kv-cache-dtype fp8 \
--calculate-kv-scales # calibra las escalas dinámicamente; sin esto hay degradación
Advertencia para RTX 4090 y L40: Ada Lovelace tiene instrucciones FP8 a nivel CUDA pero sin el hardware de scaling dedicado de Hopper (H100). La reducción de memoria es real; la aceleración de cómputo es menor que en H100. No esperes el mismo speedup que en un datacenter Hopper. En L40S (la variante con tensor cores FP8 optimizados) el beneficio es mayor que en RTX 4090.
4. Presupuesto de contexto — --max-model-len
--max-model-len define el máximo de tokens que vLLM puede manejar en un único request (prompt + generación). Es el límite duro que determina cuánta VRAM se reserva para el KV cache en el peor caso.
En hardware pequeño, reducirlo libera VRAM para más concurrencia:
# Modelo 7B en RTX 4090, contexto típico de 4K pero el modelo soporta 128K
vllm serve mi-modelo \
--max-model-len 8192 \ # en vez de 131072
--gpu-memory-utilization 0.92
Con contexto recortado a 8.192 tokens, vLLM no reserva KV cache para 131.072 tokens potenciales y puede meter más requests simultáneas. El riesgo es obvio: requests que superen 8.192 tokens fallan con un error. Ajústalo al P99 de tu distribución real de longitudes.
Interacción entre parámetros
Los cuatro parámetros no son independientes. Un error común es activar prefix caching sin ajustar el tamaño de bloque, o subir max-num-batched-tokens sin revisar que max-num-seqs permita llenarlo:
max-num-batched-tokens = 8192
max-num-seqs = 4
prompt medio = 512 tokens → 4 × 512 = 2048 tokens de prefill < 8192
El presupuesto de 8192 nunca se llena porque max-num-seqs limita antes.
Solución: subir max-num-seqs o bajar max-num-batched-tokens.
Configuración equilibrada para RTX 4090 + modelo 7B:
vllm serve Qwen/Qwen2.5-7B-Instruct \
--gpu-memory-utilization 0.92 \
--max-model-len 8192 \
--enable-chunked-prefill \
--max-num-batched-tokens 8192 \
--max-num-seqs 64 \
--enable-prefix-caching \
--kv-cache-dtype fp8 \
--calculate-kv-scales
Configuración para L40 (48 GB) + modelo 14B:
vllm serve Qwen/Qwen2.5-14B-Instruct-AWQ \
--gpu-memory-utilization 0.90 \
--max-model-len 16384 \
--enable-chunked-prefill \
--max-num-batched-tokens 16384 \
--max-num-seqs 128 \
--enable-prefix-caching \
--kv-cache-dtype fp8 \
--calculate-kv-scales
Cómo medir que está funcionando
Las métricas que confirman que el prefill está optimizado:
# En las métricas Prometheus de vLLM (puerto 8000/metrics):
vllm:time_to_first_token_seconds_bucket → distribución de TTFT
vllm:gpu_cache_usage_perc → utilización de KV cache
vllm:prefix_cache_hit_rate → hit rate de APC (si está activo)
vllm:num_running_seqs → requests en vuelo simultáneos
Un prefix_cache_hit_rate por debajo del 30% en workloads con system prompt fijo indica que algo en el hash no está funcionando (system prompt que varía por timestamp, formato de fecha en el prompt, etc.).
Implicaciones para inferencia on-premise
Chunked prefill y prefix caching son cero coste: se activan con flags, no requieren hardware adicional. FP8 KV cache requiere que el modelo sea compatible (casi todos los transformers modernos lo son) y que estés en Ada Lovelace o superior.
Para despliegues soberanos ENS donde el hardware es fijo y no puedes escalar horizontalmente a demanda, el prefill bien configurado es la diferencia entre necesitar 4 nodos y necesitar 2 para la misma carga.
El segundo artículo de esta serie cubre las optimizaciones del decode: speculative decoding, tuning del KV cache para maximizar concurrencia y cómo configurar gpu-memory-utilization sin que vLLM se quede sin VRAM a medianoche.
Ver también
- https://blog.lo0.es/posts/kv-cache-fundamentos/ — cómo funciona el KV cache por dentro
- https://blog.lo0.es/posts/flashattention-fundamentos/ — por qué FlashAttention cambia el consumo de memoria en prefill
- https://blog.lo0.es/posts/continuous-batching-fundamentos/ — la base sobre la que chunked prefill construye
- https://blog.lo0.es/posts/disaggregated-serving-prefill-decode/ — cuando escalar un solo nodo ya no basta
- https://blog.lo0.es/posts/vllm-otel-instrumentacion-optimizaciones/ — cómo medir con Prometheus y OTel si chunked prefill y prefix caching están funcionando:
ttft p99,gpu_prefix_cache_hit_ratey las alertas concretas