Una réplica grande o muchas pequeñas: la decisión que define tu plataforma
TL;DR
Con 4 GPUs disponibles tienes dos opciones básicas: una instancia de vLLM usando las 4 (TP=4) o dos instancias independientes usando 2 cada una (TP=2 × 2 réplicas). La primera da menor latencia por request individual. La segunda da mayor throughput agregado a alta concurrencia, mejor fault tolerance y escala más fino. El punto de cruce —cuando la segunda supera a la primera— está típicamente entre 16 y 64 requests concurrentes para modelos 70B, mucho antes de lo que la mayoría asume. La métrica que lo decide: goodput, los tokens generados dentro del SLO de latencia dividido por el total.
La analogía
Dos formas de organizar un servicio de traducción: un traductor senior con acceso a cuatro diccionarios especializados simultáneamente (puede resolver cualquier consulta compleja en 30 segundos), o dos traductores junior cada uno con dos diccionarios (tardan 45 segundos por consulta compleja, pero pueden atender dos simultáneamente).
Para un cliente que llega solo y espera respuesta rápida: el senior gana. Para una cola de veinte clientes llegando a la vez: los dos juniors procesan el doble de consultas por hora aunque cada una tarde más. La pregunta no es quién es mejor, sino qué tipo de tráfico tienes.
Las dos arquitecturas en vLLM
Arquitectura A: TP=4, una réplica
# Una sola instancia usa las 4 GPUs vía tensor parallelism
vllm serve meta-llama/Meta-Llama-3.1-70B-Instruct \
--tensor-parallel-size 4 \
--gpu-memory-utilization 0.92 \
--port 8000
GPU-0 ─┐
GPU-1 ─┤─ vLLM instance 0 ──► puerto 8000
GPU-2 ─┤ (TP=4, el modelo se
GPU-3 ─┘ reparte entre 4 GPUs)
Cada operación de atención y FFN se divide entre 4 GPUs. Requieren comunicación all-reduce después de cada capa (en NVLink: ~50-200 µs; en PCIe: ~2-8 ms). El modelo completo está disponible en la VRAM agregada.
Arquitectura B: TP=2 × 2 réplicas
# Dos instancias independientes, cada una con 2 GPUs
# Instancia 0 en GPU 0-1
CUDA_VISIBLE_DEVICES=0,1 vllm serve meta-llama/Meta-Llama-3.1-70B-Instruct \
--tensor-parallel-size 2 --port 8000
# Instancia 1 en GPU 2-3
CUDA_VISIBLE_DEVICES=2,3 vllm serve meta-llama/Meta-Llama-3.1-70B-Instruct \
--tensor-parallel-size 2 --port 8001
GPU-0 ─┐ ┌─► puerto 8000
GPU-1 ─┘─ vLLM instance 0 ─┘
← load balancer
GPU-2 ─┐ ┌─► puerto 8001
GPU-3 ─┘─ vLLM instance 1 ─┘
Cada instancia tiene la mitad del modelo. Las requests se distribuyen entre instancias. Sin comunicación entre instancias (son completamente independientes).
Por qué TP=4 tiene mayor latencia individual que TP=2
El tensor parallelism divide cada capa del transformer. Después de calcular su fracción, cada GPU necesita sincronizarse con las otras vía all-reduce antes de pasar a la siguiente capa. El coste de esta sincronización:
$$\text{overhead_TP} = n_layers \times 2 \times \text{latencia_allreduce}$$
Para Llama 3 70B (80 capas) en 4×H100 NVLink:
$$\text{overhead_TP4} = 80 \times 2 \times 100,\mu s = 16,ms$$
En PCIe (sin NVLink directo entre GPUs):
$$\text{overhead_TP4_PCIe} = 80 \times 2 \times 3,ms = 480,ms$$
Ese overhead se suma a cada paso de decode. Con TP=2:
$$\text{overhead_TP2} = 80 \times 2 \times 60,\mu s = 9.6,ms \text{ (NVLink)}$$
La diferencia entre TP=2 y TP=4 en NVLink es ~6 ms por paso de decode —relevante para TPOT (inter-token latency) en aplicaciones de streaming.
En PCIe sin NVLink directo: TP=4 puede ser 400 ms más lento por paso que TP=2. Para un output de 200 tokens, eso son 80 segundos adicionales. En este escenario, TP=4 PCIe nunca debe usarse salvo que el modelo no quepa en 2 GPUs.
El punto de cruce: cuándo TP=2×2 supera a TP=4×1
Para un modelo 70B en 4×H100 SXM (NVLink), el throughput agregado en tokens/segundo:
Concurrencia | TP=4 × 1 instancia | TP=2 × 2 instancias | Ganador
──────────────┼──────────────────────┼───────────────────────┼────────
1 | 200 tok/s | 170 tok/s | TP=4 (latencia)
4 | 650 tok/s | 620 tok/s | TP=4 (ligero)
16 | 1.800 tok/s | 2.100 tok/s | TP=2×2
32 | 2.400 tok/s | 3.600 tok/s | TP=2×2 (+50%)
64 | 2.800 tok/s | 5.200 tok/s | TP=2×2 (+86%)
128 | 2.900 tok/s | 5.800 tok/s | TP=2×2 (+100%)
Por qué divergen a alta concurrencia: con TP=4, el scheduler de una sola instancia gestiona todas las requests pero el KV cache es compartido. Con TP=2×2, cada instancia tiene su propio scheduler y KV cache: menos contención, más paralelismo real.
El punto de cruce en NVLink está alrededor de 16-32 requests simultáneos para 70B. Para modelos más pequeños (14B, 7B), el cruce ocurre antes porque el overhead de comunicación TP pesa más relativamente.
Las tres implicaciones que nadie menciona
1. Fault tolerance
Con TP=4 × 1 réplica: si una GPU falla, la instancia entera cae. El servicio baja al 0% hasta que la GPU se recupera o el pod se reinicia en otro nodo.
Con TP=2 × 2 réplicas: si una GPU falla, cae una instancia. El servicio sigue al 50% de capacidad. Para ENS/NIS2 donde la disponibilidad es un requisito contractual, esta diferencia es determinante.
2. Granularidad de autoscaling
Con KEDA o HPA basado en vllm:num_waiting_seqs, el autoscaling debe provisionar en múltiplos de la unidad de deploy:
- TP=4 × 1: cada nuevo nodo requiere 4 GPUs. La granularidad mínima de escala es 4 GPUs.
- TP=2 × 2: cada nuevo pod requiere 2 GPUs. La granularidad mínima es 2 GPUs — más fino, más eficiente en coste.
3. Degradación de calidad bajo carga
TP=4 con muchos requests concurrentes empieza a tener preemptions cuando el KV cache se llena. TP=2×2 distribuye esa presión entre dos pools independientes de KV cache — la probabilidad de preemption es menor bajo la misma carga total.
Medir el punto de cruce con OTel
El goodput es la métrica correcta para comparar las dos arquitecturas. No el throughput bruto (que ignora el SLO), sino los tokens que se generan dentro del SLO de TPOT acordado:
# Goodput: tokens generados con TPOT dentro del SLO (ej: <50ms/token)
# Para TP=4:
rate(vllm:generation_tokens_total{instance="tp4"}[5m])
* (1 - histogram_quantile(0.95, rate(vllm:time_per_output_token_seconds_bucket{instance="tp4"}[5m])) > 0.050)
# Para TP=2×2 (suma de las dos instancias):
sum(rate(vllm:generation_tokens_total{instance=~"tp2-.*"}[5m]))
* (1 - histogram_quantile(0.95, sum(rate(vllm:time_per_output_token_seconds_bucket{instance=~"tp2-.*"}[5m]))) > 0.050)
La comparación directa en el mismo dashboard, con tráfico sintético a distintos niveles de concurrencia, determina el punto de cruce exacto para tu hardware y modelo.
La decisión por perfil de workload
| Perfil | Arquitectura recomendada | Razón |
|---|---|---|
| Chatbot usuario único / baja concurrencia (<10 simultáneos) | TP=4 × 1 | Latencia p50 más baja, experiencia de streaming mejor |
| API enterprise (20-100 concurrentes) | TP=2 × 2 | Goodput superior, fault tolerance, autoscaling más fino |
| Batch processing (throughput > latencia) | TP=2 × 2 (o más réplicas) | Throughput máximo siempre en réplicas |
| Modelo muy grande (>80B, no cabe en 2 GPUs) | TP=4 × 1 | Sin alternativa estructural |
| ENS/disponibilidad contractual | TP=2 × 2 mínimo | La caída de una GPU no es catastrófica |
Configuración en Kubernetes con ambas arquitecturas
# Deployments paralelos para A/B test o topologías distintas
# Instancias TP=2 (2 réplicas por deployment)
apiVersion: apps/v1
kind: Deployment
metadata:
name: vllm-tp2
spec:
replicas: 2
template:
spec:
containers:
- name: vllm
args: ["serve", "meta-llama/Meta-Llama-3.1-70B-Instruct",
"--tensor-parallel-size", "2",
"--gpu-memory-utilization", "0.92"]
resources:
limits:
nvidia.com/gpu: "2" # 2 GPUs por pod
---
# Service con load balancing entre las 2 réplicas
apiVersion: v1
kind: Service
metadata:
name: vllm-tp2
spec:
selector:
app: vllm-tp2
ports:
- port: 8000
sessionAffinity: ClientIP # para prefix cache awareness
sessionAffinityConfig:
clientIP:
timeoutSeconds: 10800 # 3 horas de afinidad por sesión
La sessionAffinity: ClientIP en el Service de Kubernetes es la forma más sencilla de implementar routing con afinidad por sesión — las requests del mismo cliente van siempre a la misma réplica, maximizando el hit rate del prefix cache del historial de conversación.
Ver también
- https://blog.lo0.es/posts/batch-sizing-vllm-grid-search/ — el grid search de max-num-seqs cambia con la arquitectura: una réplica grande tolera max-num-seqs más alto que dos pequeñas con el mismo KV cache total
- https://blog.lo0.es/posts/prefix-cache-hit-rate-engineering/ — el routing por sesión (
sessionAffinity) es la implementación K8s del prefix-aware routing: mismo cliente, misma réplica, mismo cache - https://blog.lo0.es/posts/autoscaling-llm-kubernetes-keda/ — KEDA escala en unidades de pod; TP=2×2 da granularidad de 2 GPUs vs 4 GPUs para TP=4×1, impactando el coste del autoscaling reactivo
- https://blog.lo0.es/posts/vllm-otel-instrumentacion-optimizaciones/ — goodput calculado sobre
generation_tokens_totalytime_per_output_token_secondsson las métricas que comparan las dos arquitecturas - https://blog.lo0.es/posts/disaggregated-serving-prefill-decode/ — el siguiente nivel de separación cuando ni TP=4×1 ni TP=2×2 son suficientes: separar el hardware de prefill del de decode