El jefe que canta cada comanda: SMs, CUDA streams y CUDA graphs, o por qué la GPU se aburre generando tokens
Cierra el par “fuera de la API”. El post anterior subió los pesos del disco a la HBM; aquí miramos qué pasa una vez están dentro, en el silicio que los ejecuta. Es el piso por debajo del kernel launch que el post de NUMA mencionaba sin abrir: quién lanza esos kernels, cómo, y por qué en decode la GPU pasa más tiempo esperando órdenes que computando.
TL;DR
Una H100 tiene ~132 streaming multiprocessors (SMs) —los “fogones” que ejecutan el cómputo— y la ocupación mide cuántos warps (grupos de 32 hilos) tiene activos para esconder latencia. Pero el cuello del decode raramente es la potencia de esos SMs. Cada paso de decode lanza cientos de kernels diminutos (varias proyecciones por capa × ~80 capas), y cada kernel launch cuesta 5-10 µs de CPU en serie. Como en decode los kernels son pequeños (batch pequeño, un solo token), la GPU los termina antes de que la CPU cante el siguiente: aparecen burbujas y la GPU se aburre esperando órdenes. Ese régimen se llama launch-bound, y es la razón profunda —no la potencia, no la memoria— por la que --enforce-eager rinde 54 tok/s donde con optimizaciones se llega a 89-140. La solución es CUDA graphs: grabar la secuencia entera de kernels una vez y reproducirla como una sola sumisión, eliminando el overhead por lanzamiento (~28% de la latencia por iteración). vLLM captura ~102 graphs al arrancar y rellena (padding) el batch real al bucket más cercano para poder reproducir un graph de forma fija. Este post explica SM, ocupación, streams, el launch overhead con matemáticas, los CUDA graphs, los 10 knobs, y la trampa de que esa captura es la segunda mitad del cold start del post anterior. Con escepticismo sobre qué mueve la aguja. Sobre el cluster genérico 4×H100 SXM.
Dónde estás: el silicio, por debajo del kernel launch
La analogía: el jefe que canta cada comanda
Última escena en el restaurante de la serie. La cocina está montada, la despensa subida (el post anterior). Ahora hay que emplatar. Los fogones son los SMs: 132 estaciones que cocinan en paralelo. El jefe de cocina es la CPU: canta las comandas —cada kernel launch es un grito de “¡marchando una multiplicación de matrices!”. Los cocineros (los SMs) ejecutan lo que el jefe canta.
En prefill —procesar el prompt entero— cada comanda es un plato enorme: una matmul gigante sobre cientos de tokens a la vez. El jefe canta una comanda y los fogones tardan un buen rato en sacarla. El jefe tiene tiempo de sobra para cantar la siguiente. Los fogones están a tope: compute-bound.
En decode —generar un token cada vez— cada comanda es minúscula: una matmul sobre un solo token. El fogón la termina en un instante… y se queda mirando al jefe esperando la siguiente. Pero el jefe solo puede cantar una comanda cada 5-10 µs, y hay cientos de comandas por token. Los fogones, rapidísimos, se aburren entre grito y grito. El restaurante no va lento porque los cocineros sean malos: va lento porque el jefe no canta lo bastante rápido. Ese es el régimen launch-bound.
La solución no es más fogones ni cocineros más rápidos. Es dejar de cantar comanda a comanda. Si el jefe imprime toda la secuencia de la noche en una sola hoja y se la da a la línea —“haced esto, en este orden, sin esperarme”—, los fogones corren sin pausas. Eso es un CUDA graph: grabar la secuencia de kernels una vez y reproducirla de un golpe, sin que la CPU cante cada uno. Y --enforce-eager es exactamente lo contrario: obligar al jefe a cantar comanda a comanda, toda la noche.
El mecanismo: SM, warps y ocupación
Una H100 SXM tiene ~132 SMs. Cada SM ejecuta hilos en grupos de 32 llamados warps, y puede tener varios warps “en vuelo” a la vez. La ocupación (occupancy) es la fracción de warps activos respecto al máximo que el SM soporta. ¿Para qué sirve tener muchos warps activos? Para esconder latencia: mientras un warp espera datos de la HBM (cientos de ciclos), el SM ejecuta otro warp listo. Con pocos warps, el SM se queda sin nadie a quien dar turno y se para.
Pero —y esto es clave— la ocupación es una condición necesaria, no suficiente, y solo importa si el SM tiene trabajo que hacer. En decode, el problema típico no es ocupación baja dentro de un kernel: es que entre kernels el SM no tiene nada, porque la CPU aún no ha lanzado el siguiente. Subir la ocupación de un kernel que dura 8 µs no ayuda si la GPU pasa 6 µs esperando a que lo lancen.
El mecanismo: streams, la cola de órdenes
Un CUDA stream es una cola de operaciones que la GPU ejecuta en orden. Operaciones en el mismo stream son secuenciales; operaciones en streams distintos pueden solaparse. Es lo que permite, por ejemplo, copiar datos H2D en un stream mientras otro stream computa —el solapamiento cómputo/copia. vLLM usa streams para solapar trabajo, pero el stream por sí solo no elimina el coste de lanzar cada kernel: solo decide el orden y el paralelismo. El coste de lanzamiento sigue ahí, comanda a comanda, hasta que entran los graphs.
Las matemáticas que importan: cuándo la GPU se queda esperando
El número que lo gobierna todo: un kernel launch cuesta 5-10 µs de CPU, en serie. Pongamos un Llama-70B con ~80 capas. Cada capa, sin fusión, lanza del orden de ~10 kernels (proyecciones Q/K/V, atención, proyección de salida, las dos o tres matmuls del MLP, las normalizaciones, RoPE…). Eso son:
$$ N_{\text{kernels}} \approx 80 \text{ capas} \times 10 \approx 800 \text{ lanzamientos por token} $$
A 5 µs por lanzamiento, en serie:
$$ T_{\text{launch}} \approx 800 \times 5,\mu s = 4{,}0 \text{ ms por token} $$
Esos 4 ms son solo CPU cantando comandas, sin contar lo que tardan los SMs en cocinar. Si la GPU pudiera computar instantáneamente, el techo por lanzamiento sería ~250 tok/s —y con puntos de sincronización entre kernels, peor. Ahora comparemos con el techo de memoria del decode: cada token lee los 140 GB de pesos una vez desde la HBM:
$$ T_{\text{mem}} = \frac{140 \text{ GB}}{3{,}35 \text{ TB/s}} \approx 42 \text{ ms} ;\Rightarrow; \approx 24 \text{ tok/s (una secuencia, sin batch)} $$
Aquí está la sutileza que casi nadie tiene en la cabeza. Para una sola secuencia, el decode es memory-bound a ~24 tok/s, y los 4 ms de launch caben dentro de los 42 ms de lectura: el lanzamiento se esconde. Pero el batching lo cambia todo. Al servir un batch de B secuencias, los pesos se leen una vez y sirven a las B —el coste de memoria por token se amortiza y cae. La GPU deja de ser memory-bound… y emerge lo que estaba debajo: el coste de lanzamiento, que no se amortiza con el batch porque hay que lanzar la misma secuencia de kernels igual. Resultado: cuanto mejor batcheas, más launch-bound te vuelves, y más rinden los CUDA graphs. Por eso la medida cruda lo confirma —--enforce-eager da 54 tok/s donde los graphs dan 89, y hasta 8× en configuraciones donde el decode es muy pequeño y el launch domina del todo.
Los CUDA graphs de vLLM, en concreto
vLLM no captura un graph único: captura ~102 al arrancar —del orden de 51 piecewise (para los pasos mixtos prefill+decode) y 51 full (para decode puro). Cada uno está grabado para un tamaño de batch fijo (un bucket: 1, 2, 4, 8… hasta un máximo). En servicio, el batch real casi nunca cae justo en un bucket, así que vLLM rellena con ceros (padding) hasta el bucket inmediatamente superior, reproduce ese graph, y recorta la salida al tamaño real. Es el precio de los graphs: necesitan formas estáticas, y el padding es lo que las hace estáticas.
Esto tiene dos consecuencias que aparecen en los knobs y las trampas:
La captura cuesta tiempo y memoria. Grabar 102 graphs al arranque añade segundos al cold start —la segunda mitad del arranque que el post anterior dejó pendiente— y consume HBM (cada graph retiene sus buffers). El modo FULL_AND_PIECEWISE (defecto) es el más rápido en servicio pero el que más memoria y más tiempo de captura pide; FULL_DECODE_ONLY ahorra ambos a cambio de no acelerar los pasos mixtos.
El padding desperdicia algo de cómputo. Rellenar un batch de 33 hasta el bucket de 64 computa 31 secuencias fantasma. Es un coste pequeño frente a lo que ahorra quitar el launch overhead, pero existe, y crece si los buckets están mal elegidos.
Los 10 knobs donde tocar
Knob 1 — Medir si el decode es launch-bound
Antes de tocar: ¿está la GPU computando o esperando? Con nsys (Nsight Systems) se ven los huecos entre kernels en la línea de tiempo —si hay huecos en decode, es launch-bound y los graphs ayudarán. Si la GPU está al 100% sin huecos, el cuello es otro (memoria o cómputo) y los graphs no harán milagros. nvidia-smi dmon con utilización baja en decode pero TPS pobre es la señal barata.
Knob 2 — No usar --enforce-eager en producción
--enforce-eager desactiva los CUDA graphs. Es una herramienta de depuración (para aislar qué kernel falla), no de producción. Dejarlo puesto “porque arrancaba antes” tira el 26-50% del throughput de decode. Si está en tu comando de producción, quítalo y mide.
Knob 3 — Buckets de captura (cudagraph_capture_sizes)
Qué tamaños de batch capturar. Buckets demasiado espaciados hacen padding caro; demasiados, captura lenta y mucha HBM. Ajustarlos a la distribución real de tamaños de batch que ves en producción es la afinación fina —pero solo después de medir esa distribución.
Knob 4 — Modo de CUDA graph
FULL_AND_PIECEWISE (defecto, más rápido, más memoria/captura), FULL_DECODE_ONLY (ahorra memoria y captura, ideal para pods de decode puro de disaggregated serving), PIECEWISE, o NONE (= eager). El modo correcto depende de si el pod hace decode puro o mixto.
Knob 5 — torch.compile
vLLM se apoya en torch.compile para fusionar y optimizar kernels antes de capturarlos en graphs. Menos kernels (fusión) = menos lanzamientos = menos dependencia del graph y mejor decode incluso eager. El nivel de compilación es un knob, con su coste de tiempo de arranque.
Knob 6 — Batch size: llenar los fogones
El decode memory-bound se amortiza batcheando (como vimos en continuous batching): leer los pesos una vez para B secuencias. Más batch = más ocupación de SM y más amortización de memoria. El límite lo pone la HBM disponible para el KV cache. Es el knob que más mueve el throughput agregado.
Knob 7 — No romper el solapamiento de streams
vLLM solapa cómputo y copia con streams. Parchear el código para “simplificar” puede serializar lo que estaba solapado. Si no sabes por qué hay varios streams, no los colapses.
Knob 8 — Persistence mode + clocks bloqueados
nvidia-smi -pm 1 mantiene el driver residente (evita reinicializaciones que añaden latencia de lanzamiento). Bloquear clocks a la frecuencia de boost evita que la GPU baje de P-state entre kernels diminutos de decode y pague latencia de subida. Es el mismo espíritu anti-jitter del post de NUMA, aplicado a la GPU.
Knob 9 — Kernels fusionados (FlashAttention, kernels FP8)
Menos kernels = menos comandas que cantar. FlashAttention fusiona la atención en un kernel en vez de varios; los kernels FP8 fusionados reducen el conteo. La fusión ataca el problema en la raíz: no acelera el lanzamiento, elimina lanzamientos.
Knob 10 — Aceptar el coste de captura en el cold start
La captura de graphs añade segundos al arranque. En un pod que vive horas, se amortiza sobradamente. En uno que escala arriba y abajo cada minuto, ese coste se paga una y otra vez —ahí FULL_DECODE_ONLY (captura más corta) o aceptar algo menos de throughput puede salir a cuenta. Es la misma tensión warm-vs-elástico del cold start.
Tabla resumen
| # | Knob | Qué ataca | Riesgo / coste |
|---|---|---|---|
| 1 | nsys / dmon | saber si es launch-bound | ninguno; hazlo primero |
| 2 | quitar --enforce-eager | graphs desactivados | era para depurar; reactiva el problema si vuelve un bug |
| 3 | buckets de captura | padding caro / captura lenta | requiere medir la distribución real |
| 4 | modo de graph | memoria y captura | menos cobertura en pasos mixtos |
| 5 | torch.compile | kernels sin fusionar | tiempo de arranque |
| 6 | batch size | ocupación + memoria | HBM para KV cache |
| 7 | streams | solapamiento roto | no tocar si no se entiende |
| 8 | persistence + clocks | jitter / P-states | consumo eléctrico |
| 9 | kernels fusionados | número de lanzamientos | compatibilidad del kernel |
| 10 | captura vs cold start | arranque más lento | menos throughput si se recorta |
Cómo se conecta con el resto del stack
Con el cold start. La captura de CUDA graphs es la segunda mitad del arranque que abrió el post anterior: cargar pesos + capturar graphs = el cold start completo.
Con continuous batching. El batching continuo es lo que vuelve launch-bound al decode (amortiza la memoria y deja el lanzamiento al descubierto), y por eso los graphs y el batching se potencian mutuamente.
Con el KV cache. El KV cache decide cuánto batch cabe en HBM, y el batch decide la ocupación de SM y cuánto importa el launch overhead. Todo está acoplado por la memoria.
Con el interconnect. En TP, entre los kernels de cómputo hay all-reduces (NVLink/NCCL) que también se lanzan y sincronizan. El custom all-reduce de vLLM se integra en el mismo graph para no romper la secuencia con una sincronización de CPU.
Con NUMA. Quién lanza los kernels es la CPU del post del host; si ese hilo sufre jitter o cae en el socket equivocado, el launch overhead empeora. Los graphs reducen la dependencia de ese hilo, que es otra razón por la que ayudan.
Con disaggregated serving. Los pods de decode puro del serving desagregado son el caso ideal de FULL_DECODE_ONLY: maximizan el beneficio del graph justo en la fase más launch-bound.
Trampas y cosas que no son lo que parecen
“Subir la ocupación arreglará el decode lento.” No, si el problema es launch-bound. La ocupación importa dentro de un kernel con trabajo; si la GPU está ociosa entre kernels esperando a la CPU, más ocupación no toca esa burbuja. Mide antes de optimizar lo que no es el cuello.
“Los CUDA graphs siempre aceleran.” Aceleran cuando el decode es launch-bound. Si la GPU ya está al 100% (compute-bound en prefill, o memoria saturada con batch enorme), los graphs aportan poco. Su terreno es el decode con kernels pequeños.
"--enforce-eager da resultados más estables." Da resultados más lentos. La estabilidad que parece dar es que evita bugs de captura de graphs en hardware nuevo (p. ej. una arquitectura recién soportada). Es un parche temporal, no una configuración de producción.
Capturar demasiados buckets “por si acaso”. Cada bucket añade tiempo de captura y HBM. Capturar 30 tamaños cuando en producción solo ves 4 es pagar cold start y memoria por graphs que nunca se reproducen. Ajusta a la distribución real.
Confundir utilización con eficiencia. nvidia-smi al 100% de “utilización” solo dice que hay un kernel corriendo, no que el SM esté lleno de trabajo útil. Un kernel de baja ocupación mantiene la “utilización” alta mientras desperdicia el SM. La utilización de nvidia-smi es un termómetro grueso; para saber si el silicio rinde hace falta nsys/DCGM y mirar ocupación real y huecos.
Optimizar el silicio antes que la memoria. Si el decode está limitado por ancho de banda HBM (batch grande, modelo grande), pelear con graphs y ocupación es pulir lo que no es el cuello. El orden correcto: medir el régimen (memoria / cómputo / lanzamiento) y atacar el que manda.
Conclusión
La intuición dice que una GPU generando tokens lentos está “trabajando duro”. Casi nunca: en decode está esperando órdenes. Los 132 SMs cocinan un token diminuto en un instante y se quedan mirando a la CPU, que solo puede cantar una comanda cada 5-10 µs y tiene cientos que cantar por token. Ese cuello —ni potencia, ni memoria, sino lanzamiento— es invisible en cualquier dashboard que mire “utilización de GPU”, y es la razón real por la que --enforce-eager rinde la mitad. Los CUDA graphs lo resuelven con una idea simple: dejar de cantar comanda a comanda y entregar la hoja entera de la noche, para que el silicio corra sin pausas. Y hay una verdad incómoda que reordena la prioridad de optimización: cuanto mejor batcheas, más launch-bound te vuelves —porque el batching mata el cuello de memoria y deja al descubierto el de lanzamiento. Por eso los graphs y el batching no son optimizaciones separadas: son la misma palanca vista desde dos lados. El jefe que aprende a no cantar cada plato es lo que hace que la cocina, por fin, vaya tan rápido como los fogones siempre pudieron.
Ver también
- Del disco a la HBM: cold start y carga del modelo — la primera mitad del arranque; la captura de graphs de este post es la segunda mitad del mismo cold start.
- La planta de al lado: NUMA, hugepages y aislamiento de CPU — quién lanza los kernels es ese hilo de host; su jitter es el launch overhead que los graphs reducen.
- La mesa compartida: NVLink, NVSwitch y NCCL — los all-reduces de TP se lanzan y sincronizan entre kernels; el custom all-reduce de vLLM se integra en el mismo graph.
- Continuous batching — lo que vuelve launch-bound al decode al amortizar la memoria; por eso batching y graphs se potencian.
- KV cache — la memoria que decide cuánto batch cabe, y por tanto la ocupación de SM y cuánto pesa el launch overhead.
- Disaggregated serving: prefill y decode separados — los pods de decode puro son el caso ideal de
FULL_DECODE_ONLY. - Quantization para inferencia — los kernels FP8 fusionados reducen el número de lanzamientos en la raíz.
- Observabilidad GPU con DCGM — dónde se ve la ocupación real y los contadores que distinguen “utilización” de eficiencia.
Referencias
- vLLM, CUDA Graphs (diseño, modos FULL/PIECEWISE, captura): https://docs.vllm.ai/en/stable/design/cuda_graphs/.
- vLLM, Inside vLLM: Anatomy of a High-Throughput LLM Inference System: https://blog.vllm.ai/2025/09/05/anatomy-of-vllm.html.
- NVIDIA, Getting Started with CUDA Graphs: https://developer.nvidia.com/blog/cuda-graphs/.
- NVIDIA, Achieved Occupancy (ocupación de SM): https://archive.docs.nvidia.com/gameworks/content/developertools/desktop/analysis/report/cudaexperiments/kernellevel/achievedoccupancy.htm.
- PyTorch, torch.compile y CUDA Graphs para inferencia LLM: https://docs.vllm.ai/en/stable/design/cuda_graphs/.
- Understanding the Overheads of Launching CUDA Kernels (ICPP 2019): https://www.hpcs.cs.tsukuba.ac.jp/icpp2019/data/posters/Poster17-abst.pdf.