Batch sizing en vLLM: el grid search de dos horas que vale semanas de hardware
TL;DR
max-num-seqs y max-num-batched-tokens son los dos diales que controlan cuánto trabajo procesa vLLM en cada iteración del scheduler. Sus valores por defecto están calibrados para ser seguros en cualquier hardware, no para maximizar throughput en el tuyo. Un grid search sistemático de 25 configuraciones —ejecutable en dos horas— identifica la combinación que, para tu workload y hardware específico, puede doblar el throughput sin cambiar ninguna línea de modelo ni añadir una GPU. Las métricas OTel que confirman que encontraste el óptimo son vllm:num_waiting_seqs, vllm:num_preemptions_total y vllm:time_per_output_token_seconds.
La analogía
Una cocina industrial con un chef y diez fogones. Si el maitre sólo envía un pedido a la vez, el chef trabaja al 10% de capacidad. Si envía cien pedidos simultáneos pero sólo hay ingredientes para veinte, el chef pasa la mitad del tiempo esperando reposición. El óptimo está en el punto donde todos los fogones están encendidos y el reabastecimiento nunca se agota.
max-num-seqs es cuántos pedidos puede tener el chef en preparación simultánea. max-num-batched-tokens es cuántos ingredientes puede procesar en un solo movimiento de wok. Equivocarse en cualquiera de los dos deja fogones vacíos.
El problema: los defaults no son para tu hardware
En vLLM V1 (≥ 0.6), los defaults son:
max-num-seqs = 1024 (V1) / 256 (V0)
max-num-batched-tokens = 8192
Estos valores garantizan que vLLM arranca en cualquier GPU sin OOM. No garantizan throughput óptimo. La razón: el punto óptimo depende de tres variables que vLLM no conoce al arrancar:
- Distribución de longitudes de tu workload real — un sistema de RAG con prompts de 2K tokens necesita un presupuesto distinto al de un chat con mensajes de 50 tokens.
- VRAM disponible para KV cache — determinada por el modelo, la cuantización y
--gpu-memory-utilization. - Concurrencia real esperada — cuántos usuarios simultáneos llegan en el percentil 95.
La interacción entre estos tres factores hace imposible que un default universal sea óptimo para casos concretos.
Las matemáticas que importan
El scheduler de vLLM en cada iteración decide qué tokens procesar. El presupuesto total disponible por paso es max-num-batched-tokens. Ese presupuesto se reparte entre:
- Tokens de decode: 1 por cada request activo en fase de generación. Con 64 requests en decode, se consumen 64 tokens de presupuesto.
- Tokens de prefill (en chunks): el resto del presupuesto va al procesamiento de prompts nuevos.
$$\text{tokens_prefill_por_paso} = \text{max_num_batched_tokens} - \text{num_requests_decode}$$
Si max-num-batched-tokens = 8192 y tienes 512 requests en decode, cada paso sólo puede procesar 8192 - 512 = 7680 tokens de prefill. Con prompts de 2000 tokens, eso son ~3.8 prompts nuevos por iteración.
El problema aparece cuando max-num-seqs es muy alto en relación al KV cache disponible. Cada request activo en decode ocupa bloques de KV cache. Si se agotan los bloques, vLLM hace preemption: pausa una request, libera su KV cache y la vuelve a encolar. Cada preemption cuesta latencia adicional al request pausado y complejidad al scheduler.
$$\text{KV_budget} = \frac{\text{VRAM libre} \times \text{gpu_memory_utilization}}{\text{bytes_por_token} \times \text{max_model_len}}$$
Para un Qwen2.5-14B en RTX 4090 con Q4_K_M (9 GB de modelo, 15 GB libres):
$$\text{KV_budget} = \frac{15 \times 0.92 \times 10^9}{40,000} \approx 345,000 \text{ tokens}$$
Con max-model-len = 8192, el número máximo de requests simultáneos con contexto completo es:
$$\text{max_seqs_real} = \frac{345,000}{8192} \approx 42 \text{ requests}$$
Configurar max-num-seqs = 1024 con esos números garantiza preemptions constantes. El óptimo está en 40-50.
El grid search: metodología
Paso 1: medir el workload real
Antes de buscar el óptimo, hay que conocer los percentiles de tu tráfico. Desde Langfuse o los logs de vLLM:
# Extraer distribución de longitudes desde Langfuse
import langfuse
client = langfuse.Langfuse()
traces = client.fetch_traces(limit=1000).data
prompt_lens = [t.input_tokens for t in traces if t.input_tokens]
output_lens = [t.output_tokens for t in traces if t.output_tokens]
import numpy as np
print(f"Prompt p50={np.percentile(prompt_lens,50):.0f} p95={np.percentile(prompt_lens,95):.0f} p99={np.percentile(prompt_lens,99):.0f}")
print(f"Output p50={np.percentile(output_lens,50):.0f} p95={np.percentile(output_lens,95):.0f} p99={np.percentile(output_lens,99):.0f}")
Paso 2: calcular el KV budget
Ejecutar una vez con --dry-run o leer el log de arranque de vLLM:
INFO: # GPU blocks: 4521, # CPU blocks: 512
Cada bloque son 16 tokens. 4521 × 16 = 72.336 tokens de KV budget total.
Paso 3: el grid
Con el KV budget conocido y el p95 de longitud de prompt/output:
# grid_search_batch.py
import subprocess, json, time
MODEL = "Qwen/Qwen2.5-14B-Instruct-AWQ"
PROMPT_LEN = 512 # p50 de tu workload
OUTPUT_LEN = 256
CONCURRENCY = 32 # usuarios simultáneos esperados en pico
seqs_values = [32, 64, 128, 256, 512]
tokens_values = [4096, 8192, 16384, 32768, 65536]
results = []
for seqs in seqs_values:
for tokens in tokens_values:
cmd = [
"python", "-m", "vllm.entrypoints.benchmark_throughput",
"--model", MODEL,
"--max-num-seqs", str(seqs),
"--max-num-batched-tokens", str(tokens),
"--num-prompts", "200",
"--input-len", str(PROMPT_LEN),
"--output-len", str(OUTPUT_LEN),
]
out = subprocess.run(cmd, capture_output=True, text=True, timeout=300)
# Parsear throughput de la salida
for line in out.stdout.splitlines():
if "Throughput" in line:
tps = float(line.split(":")[1].strip().split()[0])
results.append({"seqs": seqs, "tokens": tokens, "tps": tps})
print(f"seqs={seqs} tokens={tokens} → {tps:.1f} tok/s")
# Guardar para análisis
with open("grid_results.json", "w") as f:
json.dump(results, f, indent=2)
25 configuraciones × ~5 min = ~2 horas. Tiempo real de ejecución, no de espera.
Paso 4: interpretar la superficie
El resultado es una matriz 5×5 de throughput. La forma típica:
max-num-batched-tokens → 4K 8K 16K 32K 64K
max-num-seqs ↓
32 180 310 380 390 385 ← max-num-seqs demasiado bajo
64 185 350 480 510 508 ← punto óptimo para este workload
128 182 340 450 480 475
256 178 320 400 410 402 ← KV cache se agota, preemptions
512 170 290 360 370 368 ← preemptions altas
El óptimo en este ejemplo: max-num-seqs=64, max-num-batched-tokens=32768. Por encima, las preemptions cancean la ganancia de concurrencia.
Confirmación con OTel
Una vez desplegada la configuración óptima, tres métricas de Prometheus confirman que está bien calibrada:
# 1. Requests en cola — debe mantenerse cerca de 0
# Si crece sostenido: max-num-seqs demasiado bajo o max-num-batched-tokens insuficiente
vllm:num_waiting_seqs
# 2. Preemptions — debe ser 0 o muy ocasional (<1/min)
# Si crece: max-num-seqs demasiado alto para el KV cache disponible
rate(vllm:num_preemptions_total[5m]) * 60
# 3. ITL (inter-token latency) — debe ser estable, sin picos
# Bimodalidad = batch size mal calibrado (algunos requests fuera del CUDA graph bucket)
histogram_quantile(0.99, rate(vllm:time_per_output_token_seconds_bucket[5m]))
La configuración óptima produce:
num_waiting_seqs≈ 0 en régimen normalnum_preemptions_totalestable (no crece)time_per_output_tokenunimodal
Si num_waiting_seqs es alto con gpu_cache_usage_perc bajo: aumentar max-num-batched-tokens para procesar prefills más rápido. Si num_preemptions_total crece: bajar max-num-seqs o activar FP8 KV cache para liberar bloques.
Configuraciones de referencia por perfil
Basadas en el grid search para hardware mediano (4×H100 genérico, modelo 14B-70B):
| Perfil | Prompt p50 | Output p50 | max-num-seqs | max-num-batched-tokens |
|---|---|---|---|---|
| Chat conversacional | 150 tok | 300 tok | 256 | 16384 |
| RAG enterprise | 1500 tok | 200 tok | 64 | 32768 |
| Coding (completion) | 800 tok | 500 tok | 128 | 32768 |
| Summarización | 2500 tok | 400 tok | 32 | 65536 |
| Batch procesamiento | 4000 tok | 800 tok | 16 | 65536 |
Ninguna de estas es universal. Son puntos de partida para el grid search en tu hardware y workload real.
Cuándo no tocar los defaults
Si tu sistema está por debajo del 50% de utilización de KV cache (vllm:gpu_cache_usage_perc < 0.50) con demanda real y sin num_waiting_seqs, los defaults son suficientes para tu carga actual. El grid search aporta más cuando estás cerca de la capacidad máxima o cuando quieres extraer el rendimiento completo de un hardware fijo.
Ver también
- https://blog.lo0.es/posts/prefill-optimizaciones-vllm/ —
max-num-batched-tokenses el presupuesto que chunked prefill usa para intercalar decode; este artículo cubre el tuning de ese parámetro - https://blog.lo0.es/posts/decode-optimizaciones-vllm/ —
max-num-seqsinteractúa directamente congpu-memory-utilizationy la capacidad de KV cache para decode - https://blog.lo0.es/posts/vllm-otel-instrumentacion-optimizaciones/ — las métricas
num_waiting_seqs,num_preemptions_totalytime_per_output_tokenconfiguradas en el pipeline OTel completo - https://blog.lo0.es/posts/kv-cache-fundamentos/ — la fórmula del KV budget que determina el máximo real de
max-num-seqspara tu hardware - https://blog.lo0.es/posts/capacity-planning-inferencia-llm-on-premise/ — el sizing de hardware parte del throughput óptimo que este grid search determina