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

El stack vertical · estás en el silicioMotor · vLLM (batching, sampling, scheduler)Host · CPU lanza kernels (post NUMA)ESTÁS AQUÍ · CUDA: streams, kernels, CUDA graphsla cola de órdenes que llega al silicioSMs · 132 fogones ejecutan los warpsHBM · 3,35 TB/s — los pesos que leen los SMsla pregunta del post: ¿los SMs computan, o esperan órdenes?

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 en configuraciones donde el decode es muy pequeño y el launch domina del todo.

Eager vs CUDA graph en la línea de tiempo de la GPUEager (comanda a comanda)kernel↑ huecos = GPU esperando que la CPU lanceCUDA graph (hoja entera)sin huecos: una sola sumisión, replaytiempo → mismos kernels, misma GPU; el graph quita las burbujas de lanzamiento

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

#KnobQué atacaRiesgo / coste
1nsys / dmonsaber si es launch-boundninguno; hazlo primero
2quitar --enforce-eagergraphs desactivadosera para depurar; reactiva el problema si vuelve un bug
3buckets de capturapadding caro / captura lentarequiere medir la distribución real
4modo de graphmemoria y capturamenos cobertura en pasos mixtos
5torch.compilekernels sin fusionartiempo de arranque
6batch sizeocupación + memoriaHBM para KV cache
7streamssolapamiento rotono tocar si no se entiende
8persistence + clocksjitter / P-statesconsumo eléctrico
9kernels fusionadosnúmero de lanzamientoscompatibilidad del kernel
10captura vs cold startarranque más lentomenos 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

Referencias