Inicio / Artículos / Por qué round-robin falla en flotas de LLM y cómo llm-d enruta según caché y cola

Por qué round-robin falla en flotas de LLM y cómo llm-d enruta según caché y cola

El balanceo round-robin desperdicia las cachés de prefijos e ignora el tamaño de las peticiones. Así puntúa llm-d las réplicas, separa prefill de decode y cuándo necesitas algo de esto.

2438 palabras

Servir un modelo de lenguaje grande en una sola máquina es un problema bastante resuelto. Servirlo a todo el mundo es donde empieza otra clase de problemas. Este artículo explica qué se rompe cuando muchos servidores con GPU están detrás de un balanceador de carga normal, y qué hace llm-d al respecto.

Los fundamentos, rápido

Unos pocos datos de las capas inferiores explican todo lo que viene después.

Empecemos por el modelo, que no es más que un archivo grande de pesos. Las GPU son la herramienta adecuada porque esa aritmética se paraleliza muy bien. La VRAM existe porque la RAM del sistema es unas 52 veces demasiado lenta para alimentar esos núcleos. Cada petición se divide en prefill, limitado por el cómputo y responsable de la pausa inicial (tiempo hasta el primer token), y decode, limitado por el ancho de banda y responsable del flujo (tiempo por token de salida). En decode, cada token cuesta una relectura completa de todo el modelo.

Sobre ese hardware hay una capa de software:

  • La caché KV guarda lo que calculó el prefill, para que decode nunca tenga que rehacerlo. Por eso la pausa inicial ocurre una sola vez.
  • Ocupa unos 128 KB por token en un modelo de 8B. Por tanto, lo que limita cuántos usuarios caben a la vez es la memoria para la caché, no el cómputo bruto. Unos 60 GB en una H100 alojan alrededor de 490.000 tokens.
  • PagedAttention trata ese pool igual que un sistema operativo trata la memoria virtual. La fragmentación desaparece, y un prefijo compartido por varias peticiones se guarda una sola vez.
  • El caché de prefijos permite que los bloques sobrevivan a la petición que los creó, indexados por un hash de su contenido. En el turno diez de un chat, el trabajo es más o menos el mismo que en el turno dos.
  • El batching continuo reparte el coste de leer los pesos entre todos los que esperan. La lectura de la KV no se puede compartir, porque pertenece a cada secuencia y se repite en cada paso. El throughput puede multiplicarse por diez o más, mientras la latencia por usuario crece con la longitud del contexto: unos 4,8 ms por token en solitario y unos 22 ms cuando el pool de caché está lleno.
  • El paralelismo de tensores, de pipeline y de expertos reparte un modelo entre muchas GPU cuando una sola tarjeta no puede contenerlo.

Todas esas optimizaciones comparten una trampa: cada una de ellas es estado de un servidor concreto.

P: ¿Qué falla en una flota?

Un servicio real no puede vivir en una sola máquina. Servir a todo el mundo significa muchos servidores, cada uno con sus propias GPU y una copia completa del modelo, con un balanceador de carga delante:

[ your message ] ──▶ [ load balancer ] ──▶ [ Server 2 ]
                       round-robin      ──▶ [ Server 5 ]
                                        ──▶ [ Server 9 ]

Es la pieza de infraestructura más estándar, aburrida y mejor entendida que existe, y lleva veinticinco años funcionando a la perfección para servicios web. Para servir LLM falla de dos maneras distintas.

Fallo 1: la caché se dispersa

Llega el turno 1 de una conversación y round-robin lo envía al servidor 2. Ese servidor hace el prefill del hilo, construye una caché KV, emite la respuesta y, gracias al caché de prefijos, conserva los bloques.

Luego llega el turno 2 y round-robin lo dirige al servidor 5. Esa máquina nunca ha visto esta conversación, así que todo el hilo vuelve a pasar por prefill desde cero. La caché útil del servidor 2 queda intacta, pagada y ociosa hasta que algo la desaloja.

En una flota balanceada al azar, la tasa de aciertos del caché de prefijos se acerca a cero. La optimización más valiosa del stack se tira a la basura en la capa de enrutamiento.

Fallo 2: todas las peticiones parecen igual de caras

Round-robin supone que cada petición cuesta más o menos lo mismo, en tiempo y en memoria. Esa suposición viene del servicio web corriente, donde está lo bastante cerca de la verdad. Cargar una página de perfil, renderizar una plantilla y ejecutar una consulta indexada terminan en decenas de milisegundos y ocupan mientras tanto unos pocos kilobytes. Cuando todas las peticiones son así de pequeñas y uniformes, apenas importa qué servidor recibe cada una, y contar conexiones es una buena forma de repartir la carga.

Un LLM rompe las dos mitades de esa suposición a la vez:

  • un usuario escribe una pregunta corta de unos 40 tokens
  • otro usuario sube un PDF de 60 páginas que suma unos 50.000 tokens

Son tres órdenes de magnitud de diferencia en cómputo. Como la caché KV escala con los tokens, la memoria difiere más o menos en el mismo factor: una petición ocupa un trozo de memoria de GPU unas 1.250 veces mayor que la otra, y durante mucho más tiempo.

Round-robin le da a cada una un único hueco. Peor aún, las peticiones hacen cola detrás de la que llegó primero. Una máquina puede quedar enterrada bajo tres prefills gigantes mientras su vecina está ociosa, y el balanceador, que rota a ciegas o, como mucho, cuenta conexiones abiertas, ve un reparto perfectamente equilibrado.

La documentación de llm-d dice que los balanceadores genéricos ignoran la estructura del prompt, el número de tokens, la profundidad de la cola y la carga de la GPU. A escala de flota no es una pequeña ineficiencia. Eclipsa todo lo demás que se haya ajustado.

P: ¿Qué haría un router que entienda los LLM?

Dejaría de preguntar a quién le toca y empezaría a preguntar qué réplica le conviene a esta petición en concreto ahora mismo.

Ese es el problema que aborda llm-d. Es un stack de inferencia distribuida creado de forma nativa para Kubernetes, alojado como proyecto CNCF Sandbox e iniciado por Red Hat, Google Cloud, IBM Research, CoreWeave y NVIDIA. vLLM hace de motor, y llm-d coordina muchos de ellos.

Las peticiones entran por un Inference Gateway, formado por un proxy y un Endpoint Picker (EPP), la parte que decide adónde va cada petición. El EPP puntúa cada réplica candidata con señales de las que carece un balanceador normal:

  • Coincidencia con el caché de prefijos: qué parte de este prompt tiene ya cacheada la réplica
  • Profundidad de cola: cuánto trabajo espera ya en ella
  • Uso de la caché KV: si está casi llena y a punto de empezar a desalojar
  • Latencia prevista: un modelo XGBoost opcional en llm-d que estima cuánto tardaría allí esta petición

Un ejemplo con tres réplicas muestra los compromisos. La réplica B está totalmente ociosa pero no guarda nada relevante, así que elegirla obliga a un prefill completo en frío. La réplica C coincide en el 88% del prefijo, pero tiene 31 peticiones por delante en la cola, lo que significa perder más tiempo esperando del que se ahorra. La réplica A coincide en el 92% con solo 2 en cola, y gana. Un balanceador round-robin no puede llegar a ese veredicto, porque depende de lo que contiene cada servidor.

P: ¿Cómo sabe el router qué está en caché?

Hay dos diseños, y cambian precisión por simplicidad.

Aproximado. El router trocea cada prompt en bloques de tamaño fijo y encadena sobre ellos hashes móviles. En un índice LRU en memoria recuerda qué hashes de prefijo ha enviado a qué pod. Este diseño es muy ligero y no necesita tokenizador ni servicio adicional. La desventaja es que adivina el estado del servidor a partir de su propio registro, y la estimación se desvía cada vez que una réplica desaloja bloques sin avisar al router.

Preciso. Aquí el router obtiene la tokenización exacta del endpoint de render de vLLM, mientras vLLM publica eventos de caché KV por ZMQ cada vez que se crea o desaloja un bloque. Así el router mantiene un mapa globalmente correcto de los bloques de tokens y su ubicación. La precisión es exacta, y los patrones de desalojo complicados y los despliegues desagregados se gestionan bien, aunque intervienen más componentes.

La ganancia reportada por hacerlo bien es de alrededor de 3 veces más throughput de salida y un tiempo hasta el primer token 2 veces más rápido. No viene de hardware nuevo. Viene de mandar las peticiones a la máquina que ya hizo el trabajo.

P: ¿Es un problema que prefill y decode compartan GPU?

Sí, y serio; se deduce de los fundamentos de arriba. El prefill ansía cómputo y llega a ráfagas, mientras que decode ansía ancho de banda y dura mucho. En la misma GPU, compiten.

Los usuarios notan el fallo directamente. Imagina a cincuenta personas a mitad de sus respuestas, todas recibiendo tokens con fluidez. Entonces llega para prefill un prompt de cincuenta mil tokens que acapara la GPU durante cientos de milisegundos, y los cincuenta flujos se detienen. Una sola petición pesada perjudica a todos, y el planificador de batching no puede ayudar, porque ambas tareas necesitan de verdad el mismo silicio.

También hay un desajuste en la forma de los recursos. Las GPU dimensionadas para prefill tienen ancho de banda de sobra, y las dimensionadas para decode se quedan cortas de FLOPs. Un único pool no puede servir bien a ambos.

La solución: dos pools para dos tareas opuestas

La desagregación divide la flota en dos:

  • El pool de prefill está dimensionado para cómputo. Lee los prompts, construye las cachés KV y las pasa.
  • El pool de decode está dimensionado para ancho de banda de memoria. Recibe cachés y emite tokens en grandes batches, sin interrupciones de prefill.

Los dos pools tienen que intercambiarse la caché KV, es decir, copiarla de la memoria de una GPU a la de otra. De eso se encarga NIXL, la NVIDIA Inference Xfer Library, que mueve tensores de caché por RDMA o InfiniBand, NVLink, NVMe o TCP a la velocidad completa del enlace. La viabilidad depende de esta capa de transporte. Una transferencia más lenta costaría más que recalcular la caché.

La recompensa es que los dos pools escalan de forma independiente:

Carga de trabajo Ejemplos típicos Añadir capacidad en
Prompts largos, respuestas cortas Resúmenes, RAG, revisión de código GPU de prefill
Prompts cortos, respuestas largas Chat, agentes GPU de decode

llm-d reporta hasta un 70% más de tokens por segundo solo con esta división, y la desagregación suele describirse como una ganancia inmediata de ~25% en despliegues típicos.

P: ¿Qué más vive en la capa de flota?

Con un router consciente de la inferencia y pools especializados, se abren varias posibilidades más.

Descarga de KV por niveles. La VRAM es el recurso más escaso, así que los bloques de caché que no se usan en ese momento pueden bajar a la RAM de la CPU y luego a un SSD local, y volver cuando aparece una petición que encaja. Ese nivel es mucho más lento que la VRAM, pero aun así supera con creces a repetir el prefill. Para cargas con mucho uso de caché, llm-d reporta hasta 13,9 veces más throughput con la descarga jerárquica.

Autoescalado consciente de los SLO. Por defecto, Kubernetes escala según el uso de GPU, que aquí casi no significa nada, porque una GPU puede estar ocupada al 100% haciendo recálculos inútiles. llm-d escala según lo que se prometió de verdad: objetivos de tiempo hasta el primer token y de latencia entre tokens. También puede escalar por separado los pools de prefill y de decode, algo posible solo porque están desagregados.

Paralelismo amplio de expertos. En los modelos Mixture-of-Experts, los expertos se reparten entre muchas GPU para que solo trabajen aquellos a los que se enruta un token. Eso es lo que hace servibles los modelos de 671B parámetros, y llm-d cita unos 50.000 tokens por segundo de throughput de clúster en esta ruta.

Control de flujo. El servicio multiinquilino necesita equidad para que un trabajo por lotes de un cliente no deje sin recursos el tráfico interactivo de otro. Las prioridades deben aplicarse en el gateway, porque a una petición que ya ha llegado a una GPU ya no se la puede ayudar.

Cuidado con las cifras. Todos estos datos proceden de los benchmarks propios del proyecto en v0.7, ejecutados en «well-lit paths» («caminos bien iluminados»), que son configuraciones de referencia probadas. Tómalos como límites superiores en buenas condiciones y bien ajustadas, no como promesas. Los resultados reales siguen la forma del tráfico. Si los prompts no comparten prefijos, el enrutamiento consciente de la caché no aporta nada, así que conviene medir primero una carga real.

P: ¿Hace falta todo esto?

Casi seguro que no al principio. Hay un orden sensato, y adelantarse cuesta más de lo que ahorra. El propio llm-d describe sus funciones como componibles, pensadas para adoptarse una a una a medida que aparecen cuellos de botella concretos y no para instalarse en bloque.

Esto vale en general. Cada capa tratada aquí se creó para resolver un problema medido en la capa de debajo. Adoptar una capa sin tener su problema solo añade carga operativa.

Mide primero, porque el cuello de botella rara vez está donde se supone.

Todo el stack como cadena de consecuencias

  1. La GPU está ahí porque una pasada hacia delante son miles de millones de multiplicaciones independientes, y una CPU las procesa en serie.
  2. La VRAM está ahí porque la RAM del sistema es unas 52 veces demasiado lenta para abastecer a miles de núcleos.
  3. La VRAM además es pequeña, lo que convierte la memoria en el recurso escaso que limita todas las capas superiores.
  4. vLLM está ahí porque un script de PyTorch por sí solo no es un servicio.
  5. La caché KV está ahí porque rehacer el prefill para cada token provocaría un parón antes de cada palabra.
  6. PagedAttention está ahí porque la asignación ingenua de KV desperdicia la mayor parte de la memoria.
  7. El caché de prefijos está ahí porque un chat reenvía el hilo entero en cada turno.
  8. El batching continuo está ahí porque una sola lectura de pesos debería servir a todos los que esperan, no a una persona.
  9. El paralelismo está ahí porque el modelo se quedó grande para la tarjeta.
  10. llm-d está ahí porque el balanceo round-robin tira por la borda el punto 7 y socava el punto 8 en cuanto se suma un segundo servidor.

Qué pasa cuando pulsas Enter en una app de chat

El mensaje se añade al hilo y se envía a un gateway de inferencia. Un router calcula el hash del prefijo, busca qué réplicas tienen ya esa conversación, compara sus profundidades de cola y la presión sobre la caché, y elige una.

La réplica elegida encuentra en caché la mayor parte del hilo del turno anterior, así que solo el mensaje más reciente necesita prefill. Si la flota está dividida, ese trabajo cae en un pool de GPU reservado para prefill, y la caché KV resultante viaja después por RDMA hasta un pool de decode.

En el pool de decode, la petición se une a un batch con decenas de desconocidos. Cada pocos milisegundos se lee el modelo entero de la VRAM exactamente una vez, y esa única lectura produce el siguiente token para todos a la vez. La caché crece 128 KB, el batch se reconstruye y el ciclo se repite.

Unos cientos de veces después, aparecen palabras en la pantalla.

Detrás de esa respuesta hay una solución desmesuradamente difícil de construir.

Dónde leer más

  • el repositorio de llm-d, con las rutas de referencia y los resultados de los benchmarks de v0.7
  • el sitio del proyecto llm-d, con la documentación de arquitectura
  • la documentación de vLLM, con las notas de diseño de su caché de prefijos
  • el repositorio de NIXL, que alberga la biblioteca de transferencia de KV