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:

  1. 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.
  2. VRAM disponible para KV cache — determinada por el modelo, la cuantización y --gpu-memory-utilization.
  3. 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 normal
  • num_preemptions_total estable (no crece)
  • time_per_output_token unimodal

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):

PerfilPrompt p50Output p50max-num-seqsmax-num-batched-tokens
Chat conversacional150 tok300 tok25616384
RAG enterprise1500 tok200 tok6432768
Coding (completion)800 tok500 tok12832768
Summarización2500 tok400 tok3265536
Batch procesamiento4000 tok800 tok1665536

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-tokens es 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-seqs interactúa directamente con gpu-memory-utilization y la capacidad de KV cache para decode
  • https://blog.lo0.es/posts/vllm-otel-instrumentacion-optimizaciones/ — las métricas num_waiting_seqs, num_preemptions_total y time_per_output_token configuradas 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-seqs para 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

Referencias