Tuning de vLLM: cerrar el gap conscientemente
conocer las palancas reales de vLLM, qué hace cada una al roofline/VRAM, y ajustarlas con una hipótesis (no por superstición). Cada flag aquí se justifica con la teoría de L1.
2.1Las palancas y qué tocan
| Flag | Qué controla | Efecto |
|---|---|---|
--gpu-memory-utilization | fracción de VRAM para pesos+KV+activación | sube KV-cache disponible → más concurrencia; no bajar de 0.7 (prefix cache necesita ~30%) |
--max-model-len | longitud máxima de contexto | reduce reserva de KV → primera palanca contra OOM |
--max-num-seqs | nº máximo de secuencias concurrentes | techo del batch → throughput vs latencia |
--max-num-batched-tokens | tokens por iteración (prefill+decode) | tamaño del trabajo por paso |
--enable-chunked-prefill | trocea prefills largos | mejora TPOT cuando hay prefills grandes mezclados con decodes |
--enable-prefix-caching | reutiliza KV de prefijos | enorme en workloads con prompts repetidos |
--kv-cache-dtype fp8 | precisión del KV-cache | ~2× contexto/concurrencia, leve coste de calidad |
2.2Memoria y concurrencia (el equilibrio base)
--gpu-memory-utilization reparte tu VRAM. Con 0.90 en una 5090, ~28.8 GB quedan para pesos + KV-cache. Si el modelo pesa 16 GB (8B BF16), te quedan ~12.8 GB de KV-cache → muchas peticiones concurrentes. Si subes el modelo a 14B (28 GB), casi no queda KV → OOM o concurrencia ínfima.
Orden correcto para resolver OOM (apréndetelo):
- Baja
--max-model-len(la mayoría de cargas no necesitan 128K). - Cuantiza el modelo (L4) para que los pesos ocupen menos y quede más KV.
- Solo en último caso toca
--gpu-memory-utilization(y nunca <0.7).
2.3Chunked prefill: arreglar el TTFT con prefills largos
Problema: un prefill largo (un prompt de 30K tokens) monopoliza una iteración entera → todas las peticiones en decode se quedan esperando → su TPOT se dispara (jitter).
--enable-chunked-prefill parte ese prefill en trozos e intercala trozos de prefill con pasos de decode de otras peticiones. Resultado: TPOT más estable a costa de un TTFT ligeramente mayor para la petición de prompt largo. Imprescindible si mezclas prompts largos y cortos.
2.4Prefix caching determinista
Si muchas peticiones comparten prefijo (un system prompt, un preámbulo de herramientas, un few-shot), vLLM puede cachear el KV de ese prefijo y reutilizarlo entre peticiones (gracias a PagedAttention, L1.2). El prefijo solo se computa una vez.
1vllm serve Qwen/Qwen3-8B --enable-prefix-caching \
2 --prefix-caching-hash-algo sha256_cbor # hashing determinista y reproducible entre procesosPor qué sha256_cbor y no el default: el hashing por defecto puede variar entre ejecuciones/entornos; sha256_cbor es estable y serializable, lo que hace el comportamiento del cache reproducible (clave para medir hit-rate de forma fiable y para entornos multi-proceso). Lo notarás cuando midas el caso prefix-heavy en L5/L6.
2.5KV-cache FP8: duplicar el contexto casi gratis
1vllm serve Qwen/Qwen3-8B --kv-cache-dtype fp8El KV-cache pasa de 2 bytes/elemento (FP16) a 1 (FP8) → cabe ~2× más contexto o ~2× más peticiones concurrentes en la misma VRAM. El coste de calidad suele ser despreciable para chat/tool-calling; mídelo tú (L4 te enseña a evaluar degradación).
2.6Laboratorio L2.1 — Barrido de configuración con prefijo compartido
Diseña una workload que comparta prefijo (para que prefix caching brille) y compara configuraciones.
1# lab_n1l2_prefix.py — mide el efecto de prefix caching con un system prompt compartido
2import asyncio, time, httpx
3
4URL = "http://localhost:8000/v1/chat/completions"
5MODEL = "Qwen/Qwen3-8B"
6# System prompt largo y COMPARTIDO por todas las peticiones (caso típico de agentes/RAG)
7SYSTEM = "Eres un asistente experto. " * 400 # ~prefijo grande y repetido
8QUERIES = [f"Pregunta {i}: resume el concepto número {i}." for i in range(64)]
9
10async def ask(client, q):
11 t0 = time.perf_counter()
12 r = await client.post(URL, json={
13 "model": MODEL,
14 "messages": [{"role": "system", "content": SYSTEM},
15 {"role": "user", "content": q}],
16 "max_tokens": 128, "temperature": 0.0,
17 }, timeout=120)
18 return time.perf_counter() - t0
19
20async def main():
21 async with httpx.AsyncClient() as client:
22 # primera ronda: llena la prefix cache
23 await asyncio.gather(*[ask(client, q) for q in QUERIES])
24 # segunda ronda: debería beneficiarse del cache (mismo SYSTEM)
25 t0 = time.perf_counter()
26 lats = await asyncio.gather(*[ask(client, q) for q in QUERIES])
27 wall = time.perf_counter() - t0
28 print(f"2ª ronda: wall={wall:.2f}s TTFT_medio≈{sum(lats)/len(lats):.3f}s")
29
30asyncio.run(main())Corre el script dos veces: una con el servidor lanzado sin --enable-prefix-caching y otra con él (+ sha256_cbor). Compara el wall-time de la 2ª ronda. El servidor con prefix caching debería ser claramente más rápido porque no recomputa el SYSTEM de 400 repeticiones cada vez.
Líneas no triviales:
SYSTEMenorme y repetido: maximiza el ahorro del cache; en producción es tu system prompt + definiciones de tools.- Dos rondas: la primera llena el cache, la segunda mide el hit. Comparar entre servidores (con/sin flag) aísla el efecto.
- vLLM expone métricas Prometheus (
/metrics) congpu_prefix_cache_hit_rate; consúltalas para el número exacto de hit-rate.
2.7Ejercicios
E1. Mide el hit-rate de prefix cache real vía /metrics (curl localhost:8000/metrics | grep prefix). Relaciónalo con el speedup de la 2ª ronda.
E2. Sube --kv-cache-dtype fp8 y mide cuánto más contexto (--max-model-len) puedes pedir sin OOM. ¿Coincide con el ~2× teórico?
E3. Con --enable-chunked-prefill, mezcla 1 petición de prompt de 20K tokens con 16 peticiones cortas. Mide el TPOT de las cortas con y sin chunked prefill. Documenta el jitter.
2.8Trampas comunes
- Bajar
--gpu-memory-utilizationpor debajo de 0.7 para "arreglar" OOM → mata el prefix cache; baja--max-model-lenprimero. - Medir prefix caching con una sola ronda (no hay hit que medir).
- Asumir que KV FP8 degrada mucho sin medirlo.
2.9Referencias
- Docs vLLM: "Optimization and Tuning", "Automatic Prefix Caching", "Chunked Prefill".