De una sola GPU a producción
Todo lo que hemos cubierto hasta ahora en este track ha sido sobre hacer rápida una sola solicitud en una sola GPU. La KV cache evita cómputo de atención redundante. La cuantización reduce los pesos del modelo para que se transfieran más rápido desde la memoria. El batching continuo mantiene la GPU llena permitiendo que las solicitudes entren y salgan del lote independientemente. FlashAttention hace el kernel de atención en sí más rápido fusionando operaciones y permaneciendo en SRAM. Estas son todas optimizaciones de una sola máquina. Pero el servicio de LLM en producción no se parece en nada a un usuario escribiendo en un notebook.
En producción, miles de usuarios concurrentes envían solicitudes con longitudes de prompt y requisitos de salida muy diferentes. Hay SLAs de latencia — un chatbot podría requerir tiempo-al-primer-token menor a 500ms y latencia entre tokens menor a 50ms, mientras que un trabajo de resumen por lotes solo se preocupa por el rendimiento total. El modelo en sí puede no caber en una sola GPU: un Llama-3-70B en float16 requiere aproximadamente 140 GB solo para pesos, mientras que la GPU de consumo más grande (una H100 SXM) tiene 80 GB. E incluso si cabe, una GPU no puede servir el volumen de solicitudes que un producto popular demanda.
Este artículo cubre las decisiones a nivel de sistemas que cierran esa brecha: cómo dividir un modelo entre múltiples GPUs cuando no cabe en una, cómo separar las fases de prefill y decodificación para que no interfieran entre sí, la compensación fundamental entre rendimiento y latencia, y cómo se comparan los principales frameworks de servicio de código abierto. Estas son las decisiones que convierten un prototipo funcional en un servicio que maneja 10,000 solicitudes por segundo.
Paralelismo tensorial vs paralelismo de pipeline
Cuando un modelo no cabe en una sola GPU, tenemos que dividirlo entre múltiples GPUs. Pero ¿cómo? Hay dos estrategias fundamentales, y hacen compensaciones muy diferentes entre latencia y costo de comunicación.
Paralelismo Tensorial (TP) divide capas individuales entre GPUs. Cada GPU tiene una porción de cada matriz de pesos. Cuando un token pasa por una capa, cada GPU calcula su porción de la multiplicación de matrices simultáneamente, luego todas las GPUs intercambian resultados parciales mediante una operación de all-reduce para producir la salida completa. La idea clave es que cada GPU trabaja en cada token al mismo tiempo — el paralelismo ocurre dentro de cada capa, no entre capas.
¿Qué nos ofrece esto? La latencia se mantiene aproximadamente igual que una sola GPU (todas las GPUs calculan en paralelo), y la memoria se divide equitativamente (cada GPU almacena $1/t$ de los pesos, donde $t$ es el grado de TP). Pero hay un alto costo de comunicación: una operación all-reduce después de cada capa en cada pase hacia adelante. Para un modelo de 32 capas con TP=8, eso son 32 operaciones all-reduce por token, cada una intercambiando datos entre 8 GPUs. Por eso TP se usa casi siempre dentro de un solo nodo, donde las GPUs están conectadas por NVLink (900 GB/s bidireccional en H100) en lugar de entre nodos, donde InfiniBand alcanza un máximo de alrededor de 50-100 GB/s. El ancho de banda de la interconexión es el cuello de botella.
¿Cuántos datos mueve realmente cada all-reduce? Para una sola capa del transformer procesando un token, cada GPU debe enviar y recibir un volumen proporcional a la dimensión oculta. El volumen total de all-reduce por capa por token es:
Aquí $t$ es el grado de TP (número de GPUs), $d_{\text{hidden}}$ es la dimensión oculta, y $b_{\text{precision}}$ son bytes por elemento (2 para float16, 1 para int8). El factor $2(t-1)/t$ viene del algoritmo ring all-reduce: cada GPU envía y recibe $(t-1)/t$ de los datos, y el factor de 2 cuenta las fases de reduce-scatter y all-gather. Verifiquemos los límites. Cuando $t = 1$ (una sola GPU), el numerador es $2(1-1)/1 = 0$ — no se necesita comunicación, lo cual es correcto: no hay nada que sincronizar. Cuando $t = 2$, obtenemos $2(1)/2 = 1$, significando que cada GPU transfiere una copia completa de $d_{\text{hidden}} \times b_{\text{precision}}$ bytes. Cuando $t = 8$, obtenemos $2(7)/8 = 1.75$, significando que el tráfico total es 1.75 veces el tamaño de los datos. A medida que $t \to \infty$, el factor se acerca a 2, así que el costo de comunicación tiene una asíntota — nunca pagas más de dos veces el tamaño de los datos sin importar cuántas GPUs uses.
Para un ejemplo concreto, consideremos Llama-3-70B ($d_{\text{hidden}} = 8192$) con TP=8 y float16. Por capa por token, el all-reduce transfiere $1.75 \times 8192 \times 2 \approx 28{,}672$ bytes, aproximadamente 28 KB. A través de 80 capas, eso es aproximadamente 2.2 MB por token. En NVLink a 900 GB/s, esto toma aproximadamente 2.5 microsegundos — despreciable. Pero a través de InfiniBand a 50 GB/s, la misma transferencia toma aproximadamente 45 microsegundos, y a través de 80 capas eso son 3.6 milisegundos por token. A 30 tokens por segundo de velocidad de decodificación, 3.6ms por token es más del 10% del presupuesto total de tiempo por token (33ms). Por eso el TP entre nodos es impracticable para servicio sensible a la latencia.
Paralelismo de Pipeline (PP) toma el enfoque opuesto: en lugar de dividir cada capa entre GPUs, asigna capas enteras a diferentes GPUs. La GPU 0 ejecuta las capas 1-20, la GPU 1 ejecuta las capas 21-40, la GPU 2 ejecuta las capas 41-60, y la GPU 3 ejecuta las capas 61-80. Las activaciones de un token fluyen secuencialmente a través del pipeline: la GPU 0 calcula sus capas, envía las activaciones de salida a la GPU 1, que calcula sus capas, envía a la GPU 2, y así sucesivamente.
El costo de comunicación es mucho menor que TP. En lugar de un all-reduce después de cada capa, PP solo transfiere tensores de activación entre etapas del pipeline — una vez por límite de etapa, no una vez por capa. Para un modelo con dimensión oculta $d_{\text{hidden}}$ y un lote de $B$ tokens, cada transferencia entre etapas es solo $B \times d_{\text{hidden}} \times b_{\text{precision}}$ bytes. Con $d_{\text{hidden}} = 8192$, $B = 1$, y float16, eso es aproximadamente 16 KB por límite de etapa — pequeño comparado con all-reduce a través de 8 GPUs.
Pero PP tiene un problema fundamental de latencia: el pipeline es secuencial. Un token debe pasar por cada etapa en orden, y mientras la GPU 0 está trabajando, las GPUs 1-3 están inactivas (y viceversa). Esto crea una burbuja de pipeline — tiempo inactivo donde las GPUs esperan a que sus predecesoras terminen. Durante el entrenamiento, trucos de micro-batching pueden llenar parcialmente la burbuja, pero durante la decodificación (generando un token a la vez), la burbuja es inevitable. Con 4 etapas de pipeline, cada paso de decodificación tiene aproximadamente 75% de tiempo inactivo entre GPUs, porque solo una etapa está activa a la vez.
Para servicio, la elección es clara: TP se prefiere porque minimiza la latencia por solicitud . Todas las GPUs trabajan simultáneamente en cada token, así que la latencia por token es cercana a lo que una sola GPU (imposiblemente grande) lograría. PP se usa cuando te quedas sin GPUs conectadas por NVLink . Un nodo típico de H100 tiene 8 GPUs conectadas por NVLink, dando TP hasta 8. Si necesitas más de 8 GPUs (digamos, para un modelo de 405B), usas TP=8 dentro del nodo y PP entre nodos, combinando ambas estrategias. TP maneja el paralelismo intra-capa crítico para la latencia sobre NVLink rápido, mientras que PP maneja la comunicación entre nodos sobre InfiniBand más lento donde el menor ancho de banda es aceptable porque se mueven menos datos.
Con grado de TP $t$, la memoria por GPU para los pesos del modelo es aproximadamente:
La KV cache no se divide por $t$ en el caso general — depende de la arquitectura de atención. Con atención multi-cabeza estándar, cada GPU almacena $1/t$ de las cabezas KV, así que la caché también se divide. Pero con grouped-query attention (GQA) , que usa menos cabezas KV que cabezas de consulta, la KV cache ya es pequeña y puede replicarse entre GPUs por simplicidad. La conclusión clave es que TP divide la memoria de pesos limpiamente, pero el comportamiento de la KV cache depende del diseño de atención.
import json, js
# Compare TP and PP characteristics
rows = [
["What is split", "Each layer's weight matrices (columns/rows)", "Entire layers assigned to different GPUs"],
["Communication pattern", "All-reduce after every layer", "Activation transfer between stages only"],
["Communication volume", "2(t-1)/t * d_hidden * b per layer per token", "B * d_hidden * b per stage boundary"],
["Latency impact", "Low (all GPUs work simultaneously)", "High (sequential pipeline, bubble overhead)"],
["Interconnect requirement", "Fast (NVLink: 900 GB/s)", "Moderate (InfiniBand: 50-100 GB/s OK)"],
["Typical deployment", "Within a node (2-8 GPUs)", "Across nodes"],
["GPU utilisation during decode", "High (all GPUs active per step)", "Low (only 1 stage active at a time)"],
["Best for serving?", "Yes (latency-optimised)", "Fallback when TP alone is insufficient"],
]
js.window.py_table_data = json.dumps({
"headers": ["Property", "Tensor Parallelism (TP)", "Pipeline Parallelism (PP)"],
"rows": rows
})
print("TP splits within layers (horizontal cut), PP splits across layers (vertical cut).")
print("For serving: always prefer TP within the node, add PP only when you need more GPUs than NVLink connects.")
Desagregación prefill-decodificación
Establecimos en el artículo 1 que el prefill (procesamiento del prompt de entrada) está limitado por el cómputo mientras que la decodificación (generación de tokens uno a uno) está limitada por el ancho de banda de memoria . Estresan diferentes recursos de hardware, toman cantidades de tiempo muy diferentes, y — críticamente — cuando se ejecutan en la misma GPU, interfieren entre sí. Esta interferencia es el problema central que resuelve la desagregación prefill-decodificación.
Aquí está el problema en términos concretos. Supongamos que una GPU está en medio de la decodificación para 32 solicitudes activas — cada paso de decodificación toma aproximadamente 30ms, dando a los usuarios un flujo suave de 33 tokens/segundo. Ahora llega una nueva solicitud con un prompt de 4,000 tokens. El sistema de servicio debe hacer el prefill de esos 4,000 tokens antes de que la nueva solicitud pueda unirse al lote de decodificación. Ese prefill podría tomar 200-500ms, dependiendo del modelo y la GPU. Durante todo ese tiempo, las 32 solicitudes de decodificación existentes están detenidas — no se generan nuevos tokens para ninguna de ellas. Desde la perspectiva del usuario, el flujo de tokens se congela por medio segundo, luego se reanuda. Esto se llama un bloqueo de prefill o tartamudeo de generación , y viola directamente los SLAs de latencia.
La solución, formalizada en el artículo de DistServe (Zhong et al., 2024) , es separar físicamente las dos fases en diferentes GPUs. Las GPUs de prefill solo manejan el procesamiento de prompts: reciben la entrada, calculan la atención sobre todos los tokens del prompt, producen la KV cache, y la pasan a una GPU de decodificación. Las GPUs de decodificación solo manejan la generación autorregresiva: reciben la KV cache de una GPU de prefill y generan tokens uno a uno hasta que la solicitud se completa. Ninguna GPU de decodificación ejecuta un prefill, y ninguna GPU de prefill ejecuta un bucle de decodificación.
¿Por qué esto ayuda? Tres razones:
- Sin bloqueos de prefill durante la decodificación. Dado que las GPUs de decodificación nunca ejecutan operaciones de prefill, el flujo de tokens para solicitudes activas nunca se interrumpe. La latencia entre tokens se vuelve predecible, que es exactamente lo que los SLAs requieren.
- Programación apropiada al hardware. El prefill está limitado por el cómputo, así que las GPUs de prefill se benefician de altos FLOPS (quieren procesar 4,000 tokens de multiplicaciones de matrices lo más rápido posible). La decodificación está limitada por el ancho de banda de memoria, así que las GPUs de decodificación se benefician de alto ancho de banda de memoria. En principio, podrías usar diferentes tipos de GPU para cada fase, aunque en la práctica la mayoría de los despliegues usan el mismo hardware y simplemente dedican diferentes pools a cada rol.
- Escalamiento independiente. Si la demanda de prefill aumenta (muchos prompts largos llegando a la vez), puedes escalar el pool de prefill sin afectar el pool de decodificación, y viceversa. Este desacoplamiento hace la planificación de capacidad más simple.
El costo principal de la desagregación es la transferencia de la KV cache . Después de que el prefill se completa, la KV cache completa para esa solicitud debe enviarse desde la GPU de prefill a una GPU de decodificación. Para un modelo con $L$ capas, $n_{\text{kv}}$ cabezas KV, dimensión de cabeza $d_h$, y un prompt de $s$ tokens en float16, el tamaño de la transferencia es:
Para Llama-3-70B ($L = 80$, $n_{\text{kv}} = 8$ con GQA, $d_h = 128$) con un prompt de 4,000 tokens en float16: $2 \times 80 \times 8 \times 128 \times 4000 \times 2 \approx 1.31$ GB. Sobre InfiniBand a 50 GB/s, esa transferencia toma aproximadamente 26ms — añadidos directamente al tiempo-al-primer-token. Sobre NVLink dentro de un nodo, es menos de 2ms. Por eso los sistemas desagregados prefieren mantener las GPUs de prefill y decodificación cercanas (idealmente en el mismo rack o conectadas por fabric de alta velocidad).
Verifiquemos la fórmula en los límites. Con $s = 1$ (un prompt de un solo token), la transferencia es despreciable — apenas hay KV cache que mover, y la desagregación ofrece poco beneficio ya que el prefill es casi instantáneo de todas formas. Con $s = 128{,}000$ (un contexto muy largo), la transferencia crece a aproximadamente 42 GB, que a 50 GB/s toma casi un segundo. Para contextos extremadamente largos, el costo de transferencia se vuelve significativo, y los diseñadores de sistemas deben sopesarlo contra el beneficio de evitar bloqueos de prefill.
Rendimiento vs latencia: La compensación del servicio
Al evaluar un sistema de servicio de LLM, tres métricas dominan:
- Tiempo al Primer Token (TTFT): cuánto tiempo desde que el usuario envía una solicitud hasta que aparece el primer token de salida. Está dominado por el tiempo de prefill (procesamiento del prompt completo de entrada), más cualquier retraso de cola.
- Tiempo Por Token de Salida (TPOT): la latencia entre tokens durante la decodificación — cuánto tiempo entre tokens de salida sucesivos. Esto es lo que determina la "velocidad de streaming" que el usuario percibe. Un TPOT de 30ms significa aproximadamente 33 tokens por segundo, lo que se siente rápido y fluido. Un TPOT de 100ms (10 tokens/segundo) se siente lento.
- Rendimiento: tokens totales generados por segundo entre todas las solicitudes. Esta es la métrica que determina cuántos usuarios puede servir el sistema simultáneamente y, en última instancia, el costo por token.
Estas tres métricas están en tensión fundamental. Cada decisión de servicio que mejora una tiende a perjudicar otra. El ejemplo más claro es el tamaño del lote. Con un lote más grande, más solicitudes comparten cada operación de carga de pesos, así que el rendimiento total sube (más tokens por segundo entre todos los usuarios). Pero cada paso individual de decodificación toma más tiempo, porque la GPU debe leer más entradas de KV cache y hacer más trabajo por paso, así que el TPOT sube (cada usuario ve un streaming de tokens más lento).
Podemos expresar esto más precisamente. Con tamaño de lote $B$, el rendimiento del sistema es:
donde $t_{\text{token}}(B)$ es el tiempo por paso de decodificación cuando el lote contiene $B$ solicitudes activas. Esta función no es lineal en $B$. Con tamaños de lote pequeños, la carga de trabajo está limitada por el ancho de banda de memoria: cada paso de decodificación lee todos los pesos del modelo independientemente del tamaño del lote, así que $t_{\text{token}}(B)$ apenas aumenta al añadir más solicitudes al lote (el costo de lectura de pesos domina y se amortiza). El rendimiento crece casi linealmente con $B$ en este régimen — la GPU estaba inactiva de todas formas, y ahora está haciendo trabajo útil. Con tamaños de lote grandes, la carga de trabajo se vuelve limitada por el cómputo: las multiplicaciones de matrices para $B$ tokens realmente saturan las unidades aritméticas de la GPU, y $t_{\text{token}}(B)$ comienza a crecer proporcionalmente con $B$. El rendimiento se aplana — añadir más solicitudes solo hace todo más lento sin aumentar la salida total.
Verifiquemos los límites. Cuando $B = 1$, tenemos rendimiento mínimo pero también TPOT mínimo — un solo usuario recibe la atención completa de la GPU. Cuando $B \to \infty$, el rendimiento satura en los FLOPS pico de la GPU dividido por los FLOPs por token, y el TPOT crece sin límite (los tokens de cada usuario toman más y más tiempo porque comparten la GPU con una multitud siempre creciente). El punto de operación óptimo está en algún punto intermedio: el tamaño de lote donde el rendimiento es alto pero el TPOT aún cumple el SLA. Encontrar este punto de operación es una de las decisiones de ajuste más importantes en el servicio en producción.
La otra compensación importante involucra la longitud del contexto. Prompts de entrada más largos significan mayor tiempo de prefill, lo que aumenta el TTFT. También significan KV caches más grandes por solicitud, lo que significa que menos solicitudes caben en el lote (menos memoria de GPU disponible para KV caches concurrentes), lo que reduce el rendimiento. Y la cuantización se sitúa en otro eje más: pesos de menor precisión se transfieren más rápido desde la memoria (mejorando el TPOT) y usan menos memoria (permitiendo lotes más grandes, mejorando el rendimiento), pero pueden reducir la calidad de la salida.
Diferentes aplicaciones aterrizan en puntos muy diferentes de esta superficie de compensación:
import json, js
rows = [
["Interactive chatbot", "< 500ms", "< 50ms (20+ tok/s)", "Moderate", "Optimise for latency (small batch, fast prefill, disaggregation)"],
["Code completion", "< 200ms", "< 30ms (33+ tok/s)", "Moderate", "Ultra-low latency (speculative decoding, small models)"],
["Batch summarisation", "Don't care", "Don't care", "Maximum", "Optimise for throughput (large batch, high utilisation)"],
["Document analysis (long ctx)", "< 2s", "< 60ms", "Moderate", "Balance prefill cost vs decode speed"],
["Agentic workflows", "< 1s", "< 40ms", "High", "Many short requests; optimise for both TTFT and throughput"],
]
js.window.py_table_data = json.dumps({
"headers": ["Use Case", "TTFT Target", "TPOT Target", "Throughput Priority", "Serving Strategy"],
"rows": rows
})
print("Each use case demands a different balance of TTFT, TPOT, and throughput.")
print("There is no single 'best' configuration — the SLA determines the operating point.")
El panorama de frameworks de servicio
No tienes que construir nada de esto desde cero. Varios frameworks de código abierto empaquetan batching continuo, paralelismo tensorial, cuantización y kernels de atención optimizados en sistemas de servicio listos para desplegar. Cada uno hace diferentes compensaciones, y entender esas compensaciones es esencial para elegir la herramienta correcta.
vLLM (Kwon et al., 2023) es el motor de servicio de LLM de código abierto más ampliamente adoptado. Su innovación emblemática es PagedAttention (cubierto en el artículo 4 ), que gestiona la memoria de la KV cache usando paginación estilo memoria virtual para eliminar la fragmentación. vLLM soporta batching continuo, paralelismo tensorial (hasta 8 vías y más), una amplia gama de formatos de cuantización (GPTQ, AWQ, FP8, GGUF), y caching de prefijos para prompts de sistema compartidos. Es la opción por defecto para la mayoría de los equipos: funciona de inmediato, maneja la mayor variedad de modelos, y tiene la comunidad más grande. Su principal debilidad es la latencia bruta por solicitud — el planificador con mucho Python puede añadir sobrecarga comparado con soluciones más compiladas.
TGI (Text Generation Inference) (HuggingFace, 2023) es la solución de servicio de HuggingFace. Escrita en Rust con una capa de modelo en Python, se integra estrechamente con el hub de modelos de HuggingFace: apúntalo a un ID de modelo y maneja la descarga, fragmentación y servicio. TGI soporta batching continuo, paralelismo tensorial, cuantización (GPTQ, AWQ, EETQ, bitsandbytes), y Flash Attention. Su fortaleza es la facilidad de despliegue, especialmente para equipos que ya están en el ecosistema de HuggingFace. Su rendimiento es competitivo con vLLM para la mayoría de las cargas de trabajo, aunque tiene menos opciones avanzadas de programación.
TensorRT-LLM (NVIDIA, 2023) es la solución de NVIDIA. Compila modelos en motores TensorRT optimizados con kernels CUDA personalizados, logrando la menor latencia por solicitud de cualquier framework en hardware NVIDIA. El paso de compilación fusiona operaciones, optimiza diseños de memoria, y aplica optimizaciones específicas del hardware que los frameworks de propósito general no pueden igualar. La compensación es la complejidad: los modelos deben convertirse explícitamente (no todas las arquitecturas están soportadas de inmediato), el proceso de compilación es lento, y la depuración es más difícil. TensorRT-LLM es la elección correcta cuando necesitas rendimiento máximo en GPUs NVIDIA y puedes invertir el tiempo de ingeniería.
SGLang (Zheng et al., 2024) toma un enfoque de co-diseño: en lugar de tratar el frontend (cómo escribes prompts y estructuras llamadas al LLM) y el backend (cómo se agrupan y ejecutan las solicitudes) como preocupaciones separadas, los optimiza juntos. Su innovación clave es RadixAttention , que automáticamente comparte KV cache entre solicitudes que tienen prefijos comunes usando una estructura de datos radix tree. Si 100 solicitudes comparten el mismo prompt de sistema, la KV cache del prompt de sistema se calcula una vez y se comparte entre las 100. SGLang es particularmente fuerte para tareas de generación estructurada (donde muchas solicitudes comparten prefijos largos o siguen patrones de ramificación tipo árbol) y para cargas de trabajo agénticas donde el mismo modelo se llama repetidamente con contextos superpuestos.
Ollama / llama.cpp ocupan un nicho completamente diferente. llama.cpp es un motor de inferencia en C/C++ optimizado para ejecutar modelos cuantizados en CPUs (y GPUs de Apple Silicon), usando el formato de cuantización GGUF. Ollama envuelve llama.cpp en una CLI y API amigable. Ninguno está diseñado para servicio a escala de producción con miles de usuarios concurrentes — carecen de batching continuo, paralelismo tensorial a través de múltiples GPUs, y la sofisticación de programación de los frameworks anteriores. Pero para desarrollo local, despliegue en el borde, e inferencia de un solo usuario en hardware de consumo, son inigualables en simplicidad.
import json, js
rows = [
["vLLM", "High-throughput serving", "Yes (multi-node)", "GPTQ, AWQ, FP8, GGUF", "Continuous + PagedAttention", "Largest community, widest model support"],
["TGI", "HuggingFace ecosystem", "Yes", "GPTQ, AWQ, EETQ, bnb", "Continuous", "Easy HF Hub integration, Rust scheduler"],
["TensorRT-LLM", "Max per-request speed", "Yes (multi-node)", "FP8, INT8, INT4 (via TRT)", "Continuous (in-flight)", "Compiled kernels, lowest latency on NVIDIA"],
["SGLang", "Structured / agentic", "Yes", "GPTQ, AWQ, FP8", "Continuous + RadixAttention", "KV cache sharing across common prefixes"],
["Ollama / llama.cpp", "Local / edge / dev", "No (single GPU/CPU)", "GGUF (Q4, Q5, Q8, etc.)", "Basic / static", "Runs on CPU, Apple Silicon; simple CLI"],
]
js.window.py_table_data = json.dumps({
"headers": ["Framework", "Best For", "Tensor Parallelism", "Quantisation Support", "Batching Strategy", "Distinguishing Feature"],
"rows": rows
})
print("No single framework wins on every axis.")
print("vLLM is the safe default. TensorRT-LLM if you need lowest latency.")
print("SGLang if you have heavy prefix sharing. Ollama for local development.")
Todo se compone
A lo largo de este track, hemos examinado cada optimización de forma aislada. Pero el poder de estas técnicas es que se componen . Un sistema de servicio de LLM en producción no elige entre cuantización y batching continuo — usa ambos, más FlashAttention, más paralelismo tensorial, más desagregación prefill-decodificación. Cada técnica apunta a un cuello de botella diferente, y porque los cuellos de botella son en gran parte independientes, las aceleraciones se apilan multiplicativamente.
Aquí está el stack de optimización completo para un sistema de servicio en producción de última generación, y el cuello de botella que cada capa aborda:
import json, js
rows = [
["1. Model architecture", "GQA (Grouped-Query Attention)", "KV cache size", "Trained-in: fewer KV heads = smaller cache per token"],
["2. Weight compression", "AWQ / GPTQ INT4 quantisation", "Weight memory + bandwidth", "4x less memory, 2-4x faster weight loading"],
["3. Attention kernel", "FlashAttention + FlashDecoding", "Attention compute + memory", "Fused kernel avoids materialising attention matrix"],
["4. KV cache management", "PagedAttention + INT8 cache quantisation", "KV cache memory", "No fragmentation + 2x cache compression"],
["5. Batch scheduling", "Continuous batching", "GPU utilisation", "No idle slots; requests enter/leave per iteration"],
["6. Model parallelism", "Tensor parallelism (within node)", "Single-GPU memory limit", "Split weights across GPUs; near-linear memory scaling"],
["7. Phase scheduling", "Prefill-decode disaggregation", "Prefill-decode interference", "Predictable decode latency; no prefill stalls"],
["8. Generation strategy", "Speculative decoding (optional)", "Autoregressive bottleneck", "Draft model proposes multiple tokens; verified in parallel"],
]
js.window.py_table_data = json.dumps({
"headers": ["Layer", "Technique", "Bottleneck Addressed", "Effect"],
"rows": rows
})
print("All 8 layers are active simultaneously in a production system.")
print("They compose because each targets a different bottleneck:")
print(" - Layers 1-2 reduce what needs to be stored and transferred")
print(" - Layers 3-4 make attention and caching efficient")
print(" - Layers 5-7 scale across requests, GPUs, and phases")
print(" - Layer 8 attacks the sequential token-by-token generation itself")
Para apreciar cómo se multiplican, consideremos un ejemplo concreto (simplificado). Comencemos con una línea base ingenua: un modelo de 70B en float16 ejecutándose en una sola GPU con batching estático, atención estándar, y sin cuantización — la llamada
model.generate()
que escribirías en un notebook. Supongamos que esto logra 5 tokens/segundo para un solo usuario, y la GPU puede manejar un lote de 4 antes de quedarse sin memoria.
- Cuantización INT4 reduce el tamaño de los pesos por 4 veces, acelerando la fase de decodificación limitada por memoria aproximadamente 2-3 veces (no 4 veces, debido a la sobrecarga de cuantización/descuantización). Velocidad de un solo usuario: ~12 tokens/segundo. La memoria liberada permite un lote más grande.
- Batching continuo + PagedAttention elimina el desperdicio de relleno y la fragmentación de memoria, permitiendo un lote de 32 en lugar de 4. Rendimiento total: 32 solicitudes a ~10 tokens/segundo cada una = ~320 tokens/segundo en total (vs los ~20 de la línea base).
- Paralelismo tensorial a través de 8 GPUs divide la memoria de pesos por 8, liberando espacio para lotes aún más grandes (digamos, 128 solicitudes concurrentes) y reduciendo la latencia por paso. El rendimiento total se acerca a ~1,000+ tokens/segundo.
- Desagregación prefill-decodificación elimina los picos de latencia, asegurando que el TPOT del percentil 50 sea cercano al del percentil 99 — velocidad de streaming consistente incluso bajo carga pesada.
La brecha entre "model.generate() en una sola GPU" y "servicio en producción a 10,000 solicitudes por segundo" no se trata de un modelo diferente o una arquitectura diferente. Los pesos del modelo son idénticos. Es completamente sobre el sistema de servicio: cómo se gestiona la memoria, cómo se programan las solicitudes, cómo se distribuye el modelo a través del hardware, y cómo se orquestan las dos fases fundamentalmente diferentes de la inferencia. Cada técnica en este track contribuye una pieza de ese rompecabezas.
Entender estas capas no solo te ayuda a desplegar modelos — te ayuda a entender por qué los modelos desplegados se comportan como lo hacen. ¿Por qué el tiempo-al-primer-token aumenta con la longitud del prompt? El prefill está limitado por el cómputo y escala con el tamaño de la entrada. ¿Por qué el streaming se ralentiza cuando el servicio está bajo carga pesada? El tamaño del lote creció, aumentando el tiempo de decodificación por paso. ¿Por qué un modelo de 70B responde más rápido de lo esperado? Probablemente está cuantizado a INT4 y ejecutándose en 8 GPUs con paralelismo tensorial. El stack de servicio no es una caja negra — es un conjunto de decisiones de ingeniería bien entendidas, cada una motivada por un análisis específico de cuellos de botella.
Quiz
Pon a prueba tu comprensión del servicio de LLM en producción a escala.
¿Por qué se prefiere el paralelismo tensorial (TP) sobre el paralelismo de pipeline (PP) para servicio de LLM sensible a la latencia?
¿Cuál es el problema principal que resuelve la desagregación prefill-decodificación?
A medida que el tamaño del lote $B$ aumenta durante la decodificación, ¿qué sucede con el TPOT por usuario y el rendimiento total?
¿Cuál framework de servicio tiene como innovación clave RadixAttention para compartir KV cache entre solicitudes con prefijos comunes?