Optimizando el decode en vLLM: exprimir cada token en hardware pequeño
TL;DR
El decode es la fase en la que vLLM genera tokens de salida uno a uno. Es memory-bound, no compute-bound: la GPU pasa más tiempo esperando que lleguen los pesos desde VRAM que haciendo cálculos. En hardware pequeño —RTX 4090 (24 GB) o L40 (48 GB)— el decode mal configurado desaprovecha la mitad de la capacidad de la tarjeta. Cinco parámetros de vLLM cambian la ecuación: gpu-memory-utilization, max-num-seqs, speculative decoding, KV cache en FP8 y un swap-space correctamente en cero. Bien calibrados, la diferencia es real: de 15 tokens/s a 35–50 tokens/s en el mismo hardware.
La analogía
Un obrero de cadena de montaje que ensambla coches. Cada coche requiere exactamente el mismo proceso: va a buscar la pieza al almacén, vuelve, la atornilla, repite. El tiempo de transporte al almacén —la latencia de VRAM— es fijo y no se puede eliminar. Pero hay formas de hacerlo menos doloroso:
- Tener varios coches en paralelo en la cadena (más concurrencia, mismo tiempo de transporte amortizado).
- Que un ayudante prefabrique piezas comunes (speculative decoding: el draft model propone, el verifier confirma).
- Almacenar en el taller sólo las piezas más usadas (KV cache cuantizado: caben más contextos en el mismo espacio).
Las tres estrategias son exactamente los tres ejes de optimización del decode en vLLM.
Por qué el decode es memory-bound
Durante el prefill, la GPU procesa N tokens en paralelo: la operación de atención es un matmul grande y las unidades de cómputo están ocupadas. Durante el decode, procesa 1 token por paso: el matmul se convierte en un vector-matrix product, operación que infrautiliza los tensor cores.
El ratio de utilización de cómputo durante decode típico en una RTX 4090:
$$\text{MFU}_{decode} \approx 5–15% \quad \text{(vs 40–60% en prefill)}$$
El cuello no es la potencia de cálculo, sino el ancho de banda. Para generar cada token, el modelo tiene que leer sus pesos completos desde VRAM:
$$\text{tiempo_por_token} \approx \frac{\text{tamaño_pesos_bytes}}{\text{ancho_banda_VRAM}}$$
Para Qwen2.5-7B en BF16 (14 GB de pesos) en una RTX 4090 (1.008 GB/s):
$$t \approx \frac{14 \times 10^9}{1.008 \times 10^{12}} \approx 13.9 \text{ ms/token} \approx 72 \text{ tokens/s teórico máximo}$$
El valor real es menor (~30–50 tok/s) por overhead de scheduler, atención sobre el KV cache creciente y otras latencias. Pero el límite teórico marca el techo.
Con Q4_K_M (pesos ~4 GB):
$$t \approx \frac{4 \times 10^9}{1.008 \times 10^{12}} \approx 3.97 \text{ ms/token} \approx 252 \text{ tokens/s teórico}$$
Cuantizar el modelo es la forma más directa de mejorar el throughput de decode en hardware memory-bound. Todo lo demás optimiza sobre ese techo.
Las cinco palancas
1. Darle a vLLM toda la VRAM que puedas — --gpu-memory-utilization
--gpu-memory-utilization (abreviado --gpu-mem-util) define la fracción de VRAM disponible que vLLM puede usar para el KV cache, una vez cargados los pesos del modelo. El resto lo reserva para activaciones durante el forward pass y el contexto CUDA.
vllm serve mi-modelo \
--gpu-memory-utilization 0.92
El valor por defecto es 0.90. En bare metal donde ningún otro proceso usa la GPU, 0.92–0.95 es seguro. No subas de 0.95: vLLM necesita margen para activaciones durante picos de batch, y quedarse sin VRAM en medio de una inferencia resulta en un crash del proceso, no en un error limpio.
Por qué importa: más KV cache disponible = más requests simultáneos en vuelo = mejor utilización de la GPU durante decode. PagedAttention asigna el KV cache en bloques de tamaño fijo (16 tokens/bloque por defecto), y vLLM los gestiona como páginas de memoria virtual. A más bloques disponibles, más requests puede servir sin que ninguna se quede esperando por espacio.
RTX 4090, Qwen2.5-7B-BF16 (14 GB pesos):
VRAM total: 24 GB
Pesos: 14 GB
Disponible para KV cache: 10 GB
gpu-memory-utilization 0.90 → 0.90 × 10 GB = 9 GB para KV cache
gpu-memory-utilization 0.94 → 0.94 × 10 GB = 9.4 GB → ~4% más de tokens en vuelo
El impacto es modesto con modelos que caben cómodos, pero se amplifica con modelos que apuran la VRAM.
2. Concurrencia real — --max-num-seqs
--max-num-seqs es el número máximo de requests que vLLM puede tener en proceso simultáneamente (sumando prefill y decode). Es el parámetro que controla la concurrencia efectiva del sistema.
vllm serve mi-modelo \
--max-num-seqs 128
El efecto es directo: más requests en decode simultáneo = mejor amortización del coste fijo de leer pesos. Cuando el batch de decode crece de 1 a 8, el tiempo de generar 8 tokens es casi el mismo que generar 1 (los pesos se leen una sola vez para todos). El throughput agregado escala casi linealmente hasta que el KV cache o la VRAM de activaciones se convierten en el cuello.
$$\text{throughput_agregado}(B) \approx B \times \text{throughput}(1) \quad \text{para } B \ll B_{max}$$
Error común: subir --max-num-seqs sin asegurarse de que hay suficiente KV cache en VRAM para todas las requests. Si vLLM no puede alojar los KV cache de 128 requests simultáneas, hace preemption (pausa alguna request y libera su KV cache) con coste de latencia. Monitoriza vllm:num_preemptions_total.
Interacción con --max-num-batched-tokens: el scheduler de vLLM procesa hasta max-num-batched-tokens tokens por paso. Si tienes 128 requests en decode generando 1 token cada una, eso son 128 tokens de decode. El presupuesto de decode consume 128 tokens del presupuesto total; el resto lo dedica a prefill en chunks. Ajusta ambos valores conjuntamente.
# Para RTX 4090 sirviendo ~50 usuarios concurrentes con respuestas de hasta 512 tokens
vllm serve mi-modelo \
--max-num-seqs 64 \
--max-num-batched-tokens 8192
# 64 tokens de decode por paso + hasta 8128 tokens de prefill chunked
3. Speculative decoding — --speculative-model + --num-speculative-tokens
Speculative decoding es el cambio más impactante para decode en hardware pequeño. La idea es simple: un modelo draft pequeño propone varios tokens a la vez, y el modelo verifier los valida o rechaza en un solo forward pass.
vllm serve Qwen/Qwen2.5-7B-Instruct \
--speculative-model Qwen/Qwen2.5-0.5B-Instruct \
--num-speculative-tokens 5 \
--speculative-draft-tensor-parallel-size 1
Por qué funciona: el verifier de 7B tiene que leer 14 GB de pesos por paso. Con 5 tokens propuestos, si el acceptance rate es del 80%, se generan de media 4–5 tokens por paso de verifier en vez de 1. El throughput efectivo sube sin que la GPU trabaje más.
El acceptance rate (α) depende de qué tan bien el draft predice la distribución del verifier. Para el mismo dominio, modelos de la misma familia suelen tener α > 0.75:
$$\text{speedup} \approx \frac{1 + \alpha \cdot k}{1 + \alpha \cdot k / \text{cost_ratio}}$$
Donde $k$ es el número de tokens propuestos y cost_ratio es el ratio de coste draft/verifier. Para un 0.5B draft y 7B verifier (ratio ~14×):
$$\text{speedup} \approx 1 + 0.8 \times 5 \approx 5 \text{ (teórico máximo, no alcanzable)}$$
En práctica, con α = 0.75 y k = 5 en hardware sin NVLink: 1.8–2.5× más tokens/s comparado con decode solo.
EAGLE-3 en 2026: los mejores drafters actuales no son versiones small del mismo modelo, sino redes especializadas en predecir la distribución del verifier. EAGLE-3 reporta 3–6.5× speedup sobre decode vanilla en benchmarks públicos. En producción con batches mixtos el speedup real es más conservador (1.5–3×). vLLM soporta EAGLE/EAGLE-2 via --speculative-model:
# Con un drafter EAGLE (requiere drafter entrenado específicamente para el base model)
vllm serve meta-llama/Llama-3.1-8B-Instruct \
--speculative-model yuhuili/EAGLE3-LLaMA3.1-Instruct-8B \
--num-speculative-tokens 6
Cuándo el speculative decoding NO ayuda:
- Batches muy grandes (>32 requests): el acceptance rate varía entre requests y el batch pasa más tiempo en re-draft que en aceptar.
- Tareas de alta entropía (brainstorming, código muy creativo): el draft predice peor, α cae por debajo de 0.5 y el overhead del draft pesa más que la ganancia.
- Si el modelo draft no cabe en la VRAM disponible junto al verifier.
En una RTX 4090 con un 7B verifier y un 0.5B draft (BF16): 14 + 1 GB = 15 GB. Quedan 9 GB para KV cache. Funciona.
4. KV cache cuantizado — --kv-cache-dtype fp8
Ya se cubrió en el artículo de prefill para su efecto en capacidad de contexto. Desde el punto de vista del decode, el beneficio es diferente: más tokens caben en el KV cache → más requests simultáneas sin preemption → mejor throughput agregado.
vllm serve mi-modelo \
--kv-cache-dtype fp8 \
--calculate-kv-scales
Advertencia de precisión en decode: el KV cache se lee en cada paso de atención del decode. La cuantización introduce ruido en las activaciones de atención. Para textos largos (>4K tokens de contexto) puede acumularse. En benchmarks de calidad (MMLU, HellaSwag) la degradación con FP8 KV y --calculate-kv-scales es <0.5% en modelos modernos. Sin --calculate-kv-scales, la degradación puede ser mayor porque las escalas se fijan estáticamente.
Combinación óptima para RTX 4090:
vllm serve Qwen/Qwen2.5-7B-Instruct-AWQ \
--quantization awq \ # pesos en INT4: 4 GB modelo
--kv-cache-dtype fp8 \ # KV cache a mitad de tamaño
--calculate-kv-scales \
--gpu-memory-utilization 0.94
# VRAM disponible: 24 - 4 = 20 GB para KV cache
# Con FP8: ~40 KB/token (vs 80 KB BF16) → 20 GB / 40 KB = 500.000 tokens de contexto total
# Con max-num-seqs 64 y ctx de 4K: 64 × 4096 × 40KB = 10 GB → cabe con margen
5. Eliminar el swap — --swap-space 0
--swap-space define cuánta RAM de sistema (no VRAM) puede usar vLLM para hacer preemption de KV caches. Cuando vLLM tiene más requests activas de las que caben en VRAM, puede “pausar” algunas moviendo su KV cache a RAM y reactivarlas más tarde.
El problema: mover un KV cache de 4K tokens de VRAM a RAM y de vuelta tiene una latencia de decenas de milisegundos vía PCIe. Para un sistema donde quieres latencia predecible, el swap introduce jitter inaceptable.
vllm serve mi-modelo \
--swap-space 0
Con --swap-space 0, cuando vLLM no puede alojar más requests en VRAM, directamente las encola en vez de hacer preemption. La cola añade latencia de espera, pero es predecible y no interrumpe las requests ya en vuelo.
¿Cuándo sí tener swap? Si tu workload tiene picos de demanda cortos y puedes tolerar jitter ocasional a cambio de no rechazar requests, un swap de 4–8 GB puede ser útil. En despliegues ENS donde la latencia es un SLA contrato, --swap-space 0 es la opción correcta.
La configuración de referencia por hardware
RTX 4090 (24 GB) — modelo 7B, uso interno
vllm serve Qwen/Qwen2.5-7B-Instruct \
--gpu-memory-utilization 0.92 \
--max-model-len 8192 \
--max-num-seqs 64 \
--max-num-batched-tokens 8192 \
--enable-chunked-prefill \
--enable-prefix-caching \
--kv-cache-dtype fp8 \
--calculate-kv-scales \
--swap-space 0 \
--speculative-model Qwen/Qwen2.5-0.5B-Instruct \
--num-speculative-tokens 5 \
--speculative-draft-tensor-parallel-size 1 \
--dtype bfloat16
Throughput esperado: 35–55 tokens/s por usuario, hasta 64 simultáneos, TTFT <500ms para prompts <1K tokens.
L40 (48 GB) — modelo 14B, multi-usuario
vllm serve Qwen/Qwen2.5-14B-Instruct \
--gpu-memory-utilization 0.90 \
--max-model-len 16384 \
--max-num-seqs 128 \
--max-num-batched-tokens 16384 \
--enable-chunked-prefill \
--enable-prefix-caching \
--kv-cache-dtype fp8 \
--calculate-kv-scales \
--swap-space 0 \
--speculative-model Qwen/Qwen2.5-1.5B-Instruct \
--num-speculative-tokens 5 \
--dtype bfloat16
Throughput esperado: 25–40 tokens/s por usuario, hasta 128 simultáneos con speculative decoding activo, TTFT <800ms para prompts <2K tokens.
Cómo medir que el decode está optimizado
# Métricas clave en vllm:8000/metrics
vllm:generation_tokens_total # tokens generados en total → tendencia
vllm:e2e_request_latency_seconds_* # latencia end-to-end por percentil
vllm:time_per_output_token_seconds_* # ITL (inter-token latency)
vllm:num_preemptions_total # si sube, KV cache se está llenando
vllm:spec_decode_draft_acceptance_rate # hit rate del speculative decoding
Si spec_decode_draft_acceptance_rate < 0.6, el drafter no está ayudando: desactiva speculative decoding o busca un drafter mejor entrenado para tu modelo/dominio.
Si num_preemptions_total crece, tienes demasiadas requests simultáneas para el KV cache disponible. Opciones: bajar max-num-seqs, activar FP8 KV cache, bajar max-model-len, o cuantizar más el modelo.
Implicaciones para inferencia on-premise
En un despliegue soberano con hardware fijo, no puedes comprar más GPUs a voluntad. Cada décima de gpu-memory-utilization bien calibrada, cada punto de acceptance rate del speculative decoding y cada MB de KV cache liberado por FP8 son capacidad real que no tienes que provisionar con otro nodo.
La combinación de pesos cuantizados (AWQ/GPTQ), KV cache FP8 y speculative decoding permite que un 14B sirva en una L40 lo que sin optimizaciones requeriría dos L40 en tensor parallel. Eso es el argumento económico para invertir tiempo en estos parámetros.
El decode no se puede acelerar infinitamente en hardware memory-bound: el límite teórico lo pone el ancho de banda de VRAM. Pero la diferencia entre el mínimo y el máximo alcanzable en ese hardware puede ser 3–4× con las palancas correctas.
Ver también
- https://blog.lo0.es/posts/prefill-optimizaciones-vllm/ — las optimizaciones de la fase de prefill: chunked prefill, prefix caching y FP8 KV
- https://blog.lo0.es/posts/speculative-decoding-fundamentos/ — cómo funciona el speculative decoding por dentro
- https://blog.lo0.es/posts/kv-cache-fundamentos/ — la estructura que el decode consulta en cada token
- https://blog.lo0.es/posts/quantization-fundamentos-inferencia/ — por qué cuantizar pesos cambia el techo de velocidad del decode
- https://blog.lo0.es/posts/continuous-batching-fundamentos/ — la base de la gestión de concurrencia en vLLM
- https://blog.lo0.es/posts/vllm-otel-instrumentacion-optimizaciones/ — cómo medir con Prometheus si speculative decoding, gpu-memory-utilization y max-num-seqs están funcionando:
spec_decode_draft_acceptance_rate,num_preemptions_totaly la matriz de diagnóstico
Referencias
- Efficient Memory Management for Large Language Model Serving with PagedAttention — Kwon et al., 2023
- EAGLE-2: Faster Inference of Language Models with Dynamic Draft Trees — Li et al., 2024
- Fast Inference from Transformers via Speculative Decoding — Leviathan et al., 2022
- vLLM Optimization and Tuning — documentación oficial
- vLLM Speculative Decoding
- FP8 KV Cache en vLLM