<?xml version="1.0" encoding="utf-8" standalone="yes"?><rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom"><channel><title>Streaming-Multiprocessor on lo0 — Blog Técnico</title><link>https://blog.lo0.es/tags/streaming-multiprocessor/</link><description>Recent content in Streaming-Multiprocessor on lo0 — Blog Técnico</description><generator>Hugo -- gohugo.io</generator><language>es</language><lastBuildDate>Sun, 07 Jun 2026 09:00:00 +0200</lastBuildDate><atom:link href="https://blog.lo0.es/tags/streaming-multiprocessor/index.xml" rel="self" type="application/rss+xml"/><item><title>El jefe que canta cada comanda: SMs, CUDA streams y CUDA graphs, o por qué la GPU se aburre generando tokens</title><link>https://blog.lo0.es/posts/sm-cuda-streams-cuda-graphs-inferencia/</link><pubDate>Sun, 07 Jun 2026 09:00:00 +0200</pubDate><guid>https://blog.lo0.es/posts/sm-cuda-streams-cuda-graphs-inferencia/</guid><description>&lt;blockquote>
&lt;p>Cierra el par &amp;ldquo;fuera de la API&amp;rdquo;. El &lt;a href="https://blog.lo0.es/posts/del-disco-a-la-hbm-cold-start-carga-modelo/">post anterior&lt;/a> subió los pesos del disco a la HBM; aquí miramos qué pasa &lt;strong>una vez están dentro&lt;/strong>, en el silicio que los ejecuta. Es el piso por debajo del kernel launch que el &lt;a href="https://blog.lo0.es/posts/numa-hugepages-aislamiento-cpu-inferencia/">post de NUMA&lt;/a> mencionaba sin abrir: &lt;em>quién&lt;/em> lanza esos kernels, &lt;em>cómo&lt;/em>, y por qué en decode la GPU pasa más tiempo esperando órdenes que computando.&lt;/p>
&lt;/blockquote>
&lt;h2 id="tldr">TL;DR&lt;/h2>
&lt;p>Una H100 tiene &lt;strong>~132 streaming multiprocessors (SMs)&lt;/strong> —los &amp;ldquo;fogones&amp;rdquo; que ejecutan el cómputo— y la &lt;strong>ocupación&lt;/strong> mide cuántos &lt;em>warps&lt;/em> (grupos de 32 hilos) tiene activos para esconder latencia. Pero el cuello del &lt;strong>decode&lt;/strong> raramente es la potencia de esos SMs. Cada paso de decode lanza &lt;strong>cientos de kernels diminutos&lt;/strong> (varias proyecciones por capa × ~80 capas), y &lt;strong>cada kernel launch cuesta 5-10 µs de CPU en serie&lt;/strong>. Como en decode los kernels son pequeños (batch pequeño, un solo token), la GPU &lt;strong>los termina antes de que la CPU cante el siguiente&lt;/strong>: aparecen burbujas y la GPU se aburre esperando órdenes. Ese régimen se llama &lt;strong>launch-bound&lt;/strong>, y es la razón profunda —no la potencia, no la memoria— por la que &lt;code>--enforce-eager&lt;/code> rinde &lt;strong>54 tok/s&lt;/strong> donde con optimizaciones se llega a &lt;strong>89-140&lt;/strong>. La solución es &lt;strong>CUDA graphs&lt;/strong>: grabar la secuencia entera de kernels &lt;strong>una vez&lt;/strong> y reproducirla como &lt;strong>una sola sumisión&lt;/strong>, eliminando el overhead por lanzamiento (~28% de la latencia por iteración). vLLM captura ~&lt;strong>102 graphs&lt;/strong> al arrancar y &lt;strong>rellena (padding)&lt;/strong> el batch real al &lt;em>bucket&lt;/em> 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 &lt;strong>es la segunda mitad del cold start&lt;/strong> del post anterior. Con escepticismo sobre qué mueve la aguja. Sobre el cluster genérico 4×H100 SXM.&lt;/p>
&lt;h2 id="dónde-estás-el-silicio-por-debajo-del-kernel-launch">Dónde estás: el silicio, por debajo del kernel launch&lt;/h2>
&lt;div class="diagram" style="max-width:560px;margin:1.2rem auto;">
&lt;svg viewBox="0 0 560 320" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="Estás en el silicio de ejecución, por debajo del kernel launch del host">
&lt;text x="280" y="24" text-anchor="middle" font-family="sans-serif" font-size="13" font-weight="700" fill="currentColor">El stack vertical · estás en el silicio&lt;/text>
&lt;rect x="120" y="40" width="320" height="38" rx="6" fill="currentColor" fill-opacity="0.05" stroke="#888" stroke-width="1.3"/>
&lt;text x="280" y="64" text-anchor="middle" font-family="sans-serif" font-size="12" fill="currentColor">Motor · vLLM (batching, sampling, scheduler)&lt;/text>
&lt;rect x="120" y="84" width="320" height="38" rx="6" fill="currentColor" fill-opacity="0.05" stroke="#888" stroke-width="1.3"/>
&lt;text x="280" y="108" text-anchor="middle" font-family="sans-serif" font-size="12" fill="currentColor">Host · CPU lanza kernels (post NUMA)&lt;/text>
&lt;rect x="120" y="128" width="320" height="58" rx="6" fill="#dceede" stroke="#3c8c54" stroke-width="3"/>
&lt;text x="280" y="152" text-anchor="middle" font-family="sans-serif" font-size="12.5" font-weight="700" fill="#1f5c34">ESTÁS AQUÍ · CUDA: streams, kernels, CUDA graphs&lt;/text>
&lt;text x="280" y="170" text-anchor="middle" font-family="sans-serif" font-size="10.5" fill="#2c6b42">la cola de órdenes que llega al silicio&lt;/text>
&lt;rect x="120" y="192" width="320" height="38" rx="6" fill="#eef3fb" stroke="#4a6fa5" stroke-width="1.4"/>
&lt;text x="280" y="216" text-anchor="middle" font-family="sans-serif" font-size="12" fill="currentColor">SMs · 132 fogones ejecutan los warps&lt;/text>
&lt;rect x="120" y="236" width="320" height="38" rx="6" fill="currentColor" fill-opacity="0.05" stroke="#888" stroke-width="1.3"/>
&lt;text x="280" y="260" text-anchor="middle" font-family="sans-serif" font-size="12" fill="currentColor">HBM · 3,35 TB/s — los pesos que leen los SMs&lt;/text>
&lt;text x="280" y="298" text-anchor="middle" font-family="sans-serif" font-size="11" fill="currentColor" opacity="0.75">la pregunta del post: ¿los SMs computan, o esperan órdenes?&lt;/text>
&lt;/svg>
&lt;/div>
&lt;h2 id="la-analogía-el-jefe-que-canta-cada-comanda">La analogía: el jefe que canta cada comanda&lt;/h2>
&lt;p>Última escena en el restaurante de la serie. La cocina está montada, la despensa subida (el &lt;a href="https://blog.lo0.es/posts/del-disco-a-la-hbm-cold-start-carga-modelo/">post anterior&lt;/a>). Ahora hay que &lt;strong>emplatar&lt;/strong>. Los &lt;strong>fogones&lt;/strong> son los SMs: 132 estaciones que cocinan en paralelo. El &lt;strong>jefe de cocina&lt;/strong> es la CPU: canta las comandas —cada &lt;em>kernel launch&lt;/em> es un grito de &amp;ldquo;¡marchando una multiplicación de matrices!&amp;rdquo;. Los cocineros (los SMs) ejecutan lo que el jefe canta.&lt;/p>
&lt;p>En &lt;strong>prefill&lt;/strong> —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 &lt;strong>a tope&lt;/strong>: compute-bound.&lt;/p>
&lt;p>En &lt;strong>decode&lt;/strong> —generar un token cada vez— cada comanda es minúscula: una matmul sobre &lt;strong>un solo token&lt;/strong>. El fogón la termina en un instante&amp;hellip; y se queda mirando al jefe esperando la siguiente. Pero el jefe solo puede cantar &lt;strong>una comanda cada 5-10 µs&lt;/strong>, y hay &lt;strong>cientos de comandas por token&lt;/strong>. Los fogones, rapidísimos, &lt;strong>se aburren&lt;/strong> entre grito y grito. El restaurante no va lento porque los cocineros sean malos: va lento porque &lt;strong>el jefe no canta lo bastante rápido&lt;/strong>. Ese es el régimen &lt;em>launch-bound&lt;/em>.&lt;/p>
&lt;p>La solución no es más fogones ni cocineros más rápidos. Es &lt;strong>dejar de cantar comanda a comanda&lt;/strong>. Si el jefe imprime &lt;strong>toda la secuencia de la noche en una sola hoja&lt;/strong> y se la da a la línea —&amp;ldquo;haced esto, en este orden, sin esperarme&amp;rdquo;—, los fogones corren sin pausas. Eso es un &lt;strong>CUDA graph&lt;/strong>: grabar la secuencia de kernels una vez y reproducirla de un golpe, sin que la CPU cante cada uno. Y &lt;code>--enforce-eager&lt;/code> es exactamente lo contrario: obligar al jefe a cantar comanda a comanda, toda la noche.&lt;/p>
&lt;h2 id="el-mecanismo-sm-warps-y-ocupación">El mecanismo: SM, warps y ocupación&lt;/h2>
&lt;p>Una H100 SXM tiene &lt;strong>~132 SMs&lt;/strong>. Cada SM ejecuta hilos en grupos de 32 llamados &lt;strong>warps&lt;/strong>, y puede tener varios warps &amp;ldquo;en vuelo&amp;rdquo; a la vez. La &lt;strong>ocupación&lt;/strong> (&lt;em>occupancy&lt;/em>) es la fracción de warps activos respecto al máximo que el SM soporta. ¿Para qué sirve tener muchos warps activos? Para &lt;strong>esconder latencia&lt;/strong>: 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.&lt;/p>
&lt;p>Pero —y esto es clave— la ocupación es una condición &lt;strong>necesaria, no suficiente&lt;/strong>, y solo importa si el SM &lt;strong>tiene trabajo que hacer&lt;/strong>. En decode, el problema típico no es ocupación baja &lt;strong>dentro&lt;/strong> de un kernel: es que &lt;strong>entre&lt;/strong> 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.&lt;/p>
&lt;h2 id="el-mecanismo-streams-la-cola-de-órdenes">El mecanismo: streams, la cola de órdenes&lt;/h2>
&lt;p>Un &lt;strong>CUDA stream&lt;/strong> es una cola de operaciones que la GPU ejecuta &lt;strong>en orden&lt;/strong>. Operaciones en el mismo stream son secuenciales; operaciones en streams distintos pueden &lt;strong>solaparse&lt;/strong>. 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 &lt;strong>no elimina&lt;/strong> 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.&lt;/p>
&lt;h2 id="las-matemáticas-que-importan-cuándo-la-gpu-se-queda-esperando">Las matemáticas que importan: cuándo la GPU se queda esperando&lt;/h2>
&lt;p>El número que lo gobierna todo: &lt;strong>un kernel launch cuesta 5-10 µs de CPU&lt;/strong>, en serie. Pongamos un Llama-70B con ~80 capas. Cada capa, sin fusión, lanza del orden de &lt;strong>~10 kernels&lt;/strong> (proyecciones Q/K/V, atención, proyección de salida, las dos o tres matmuls del MLP, las normalizaciones, RoPE&amp;hellip;). Eso son:&lt;/p>
&lt;p>$$ N_{\text{kernels}} \approx 80 \text{ capas} \times 10 \approx 800 \text{ lanzamientos por token} $$&lt;/p>
&lt;p>A 5 µs por lanzamiento, &lt;strong>en serie&lt;/strong>:&lt;/p>
&lt;p>$$ T_{\text{launch}} \approx 800 \times 5,\mu s = 4{,}0 \text{ ms por token} $$&lt;/p>
&lt;p>Esos 4 ms son &lt;strong>solo CPU cantando comandas&lt;/strong>, 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 &lt;strong>de memoria&lt;/strong> del decode: cada token lee los 140 GB de pesos una vez desde la HBM:&lt;/p>
&lt;p>$$ 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)} $$&lt;/p>
&lt;p>Aquí está la sutileza que casi nadie tiene en la cabeza. Para &lt;strong>una sola secuencia&lt;/strong>, 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. &lt;strong>Pero el batching lo cambia todo.&lt;/strong> Al servir un batch de B secuencias, los pesos se leen &lt;strong>una vez&lt;/strong> y sirven a las B —el coste de memoria por token se amortiza y cae. La GPU deja de ser memory-bound&amp;hellip; y emerge lo que estaba debajo: el coste de lanzamiento, que &lt;strong>no se amortiza con el batch&lt;/strong> porque hay que lanzar la misma secuencia de kernels igual. Resultado: &lt;strong>cuanto mejor batcheas, más launch-bound te vuelves&lt;/strong>, y más rinden los CUDA graphs. Por eso la medida cruda lo confirma —&lt;code>--enforce-eager&lt;/code> da &lt;strong>54 tok/s&lt;/strong> donde los graphs dan &lt;strong>89&lt;/strong>, y hasta &lt;strong>8×&lt;/strong> en configuraciones donde el decode es muy pequeño y el launch domina del todo.&lt;/p>
&lt;div class="diagram" style="max-width:680px;margin:1.4rem auto;">
&lt;svg viewBox="0 0 680 250" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="Eager lanza kernel a kernel con burbujas; el CUDA graph reproduce todo de un golpe sin huecos">
&lt;text x="340" y="22" text-anchor="middle" font-family="sans-serif" font-size="13" font-weight="700" fill="currentColor">Eager vs CUDA graph en la línea de tiempo de la GPU&lt;/text>
&lt;text x="60" y="58" font-family="sans-serif" font-size="11.5" font-weight="600" fill="#c1121f">Eager (comanda a comanda)&lt;/text>
&lt;rect x="60" y="66" width="40" height="26" rx="3" fill="#e76f51" fill-opacity="0.85" stroke="#c1121f" stroke-width="1"/>
&lt;rect x="118" y="66" width="40" height="26" rx="3" fill="#e76f51" fill-opacity="0.85" stroke="#c1121f" stroke-width="1"/>
&lt;rect x="176" y="66" width="40" height="26" rx="3" fill="#e76f51" fill-opacity="0.85" stroke="#c1121f" stroke-width="1"/>
&lt;rect x="234" y="66" width="40" height="26" rx="3" fill="#e76f51" fill-opacity="0.85" stroke="#c1121f" stroke-width="1"/>
&lt;rect x="292" y="66" width="40" height="26" rx="3" fill="#e76f51" fill-opacity="0.85" stroke="#c1121f" stroke-width="1"/>
&lt;rect x="100" y="72" width="18" height="14" fill="currentColor" fill-opacity="0.12"/>
&lt;rect x="158" y="72" width="18" height="14" fill="currentColor" fill-opacity="0.12"/>
&lt;rect x="216" y="72" width="18" height="14" fill="currentColor" fill-opacity="0.12"/>
&lt;rect x="274" y="72" width="18" height="14" fill="currentColor" fill-opacity="0.12"/>
&lt;text x="340" y="84" font-family="sans-serif" font-size="10.5" fill="currentColor" opacity="0.7">kernel&lt;/text>
&lt;text x="430" y="84" font-family="sans-serif" font-size="10.5" fill="currentColor" opacity="0.7">↑ huecos = GPU esperando que la CPU lance&lt;/text>
&lt;text x="60" y="150" font-family="sans-serif" font-size="11.5" font-weight="600" fill="#2a9d8f">CUDA graph (hoja entera)&lt;/text>
&lt;rect x="60" y="158" width="40" height="26" rx="3" fill="#2a9d8f" fill-opacity="0.85" stroke="#1f7a6e" stroke-width="1"/>
&lt;rect x="100" y="158" width="40" height="26" rx="3" fill="#2a9d8f" fill-opacity="0.85" stroke="#1f7a6e" stroke-width="1"/>
&lt;rect x="140" y="158" width="40" height="26" rx="3" fill="#2a9d8f" fill-opacity="0.85" stroke="#1f7a6e" stroke-width="1"/>
&lt;rect x="180" y="158" width="40" height="26" rx="3" fill="#2a9d8f" fill-opacity="0.85" stroke="#1f7a6e" stroke-width="1"/>
&lt;rect x="220" y="158" width="40" height="26" rx="3" fill="#2a9d8f" fill-opacity="0.85" stroke="#1f7a6e" stroke-width="1"/>
&lt;text x="340" y="176" font-family="sans-serif" font-size="10.5" fill="currentColor" opacity="0.7">sin huecos: una sola sumisión, replay&lt;/text>
&lt;line x1="60" y1="210" x2="620" y2="210" stroke="currentColor" stroke-width="1.2" opacity="0.4"/>
&lt;text x="60" y="228" font-family="sans-serif" font-size="10.5" fill="currentColor" opacity="0.8">tiempo → mismos kernels, misma GPU; el graph quita las burbujas de lanzamiento&lt;/text>
&lt;/svg>
&lt;/div>
&lt;h2 id="los-cuda-graphs-de-vllm-en-concreto">Los CUDA graphs de vLLM, en concreto&lt;/h2>
&lt;p>vLLM no captura un graph único: captura &lt;strong>~102&lt;/strong> al arrancar —del orden de 51 &lt;em>piecewise&lt;/em> (para los pasos mixtos prefill+decode) y 51 &lt;em>full&lt;/em> (para decode puro). Cada uno está grabado para un &lt;strong>tamaño de batch fijo&lt;/strong> (un &lt;em>bucket&lt;/em>: 1, 2, 4, 8&amp;hellip; hasta un máximo). En servicio, el batch real casi nunca cae justo en un bucket, así que vLLM &lt;strong>rellena con ceros (padding)&lt;/strong> hasta el bucket inmediatamente superior, reproduce ese graph, y recorta la salida al tamaño real. Es el precio de los graphs: necesitan &lt;strong>formas estáticas&lt;/strong>, y el padding es lo que las hace estáticas.&lt;/p>
&lt;p>Esto tiene dos consecuencias que aparecen en los knobs y las trampas:&lt;/p>
&lt;p>&lt;strong>La captura cuesta tiempo y memoria.&lt;/strong> Grabar 102 graphs al arranque añade segundos al cold start —&lt;strong>la segunda mitad&lt;/strong> del arranque que el &lt;a href="https://blog.lo0.es/posts/del-disco-a-la-hbm-cold-start-carga-modelo/">post anterior&lt;/a> dejó pendiente— y consume HBM (cada graph retiene sus buffers). El modo &lt;code>FULL_AND_PIECEWISE&lt;/code> (defecto) es el más rápido en servicio pero el que más memoria y más tiempo de captura pide; &lt;code>FULL_DECODE_ONLY&lt;/code> ahorra ambos a cambio de no acelerar los pasos mixtos.&lt;/p>
&lt;p>&lt;strong>El padding desperdicia algo de cómputo.&lt;/strong> 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.&lt;/p>
&lt;h2 id="los-10-knobs-donde-tocar">Los 10 knobs donde tocar&lt;/h2>
&lt;h3 id="knob-1--medir-si-el-decode-es-launch-bound">Knob 1 — Medir si el decode es launch-bound&lt;/h3>
&lt;p>Antes de tocar: ¿está la GPU computando o esperando? Con &lt;code>nsys&lt;/code> (Nsight Systems) se ven los &lt;strong>huecos entre kernels&lt;/strong> 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. &lt;code>nvidia-smi dmon&lt;/code> con utilización baja en decode pero TPS pobre es la señal barata.&lt;/p>
&lt;h3 id="knob-2--no-usar---enforce-eager-en-producción">Knob 2 — No usar &lt;code>--enforce-eager&lt;/code> en producción&lt;/h3>
&lt;p>&lt;code>--enforce-eager&lt;/code> &lt;strong>desactiva los CUDA graphs&lt;/strong>. Es una herramienta de &lt;strong>depuración&lt;/strong> (para aislar qué kernel falla), no de producción. Dejarlo puesto &amp;ldquo;porque arrancaba antes&amp;rdquo; tira el 26-50% del throughput de decode. Si está en tu comando de producción, quítalo y mide.&lt;/p>
&lt;h3 id="knob-3--buckets-de-captura-cudagraph_capture_sizes">Knob 3 — Buckets de captura (&lt;code>cudagraph_capture_sizes&lt;/code>)&lt;/h3>
&lt;p>Qué tamaños de batch capturar. Buckets demasiado espaciados hacen padding caro; demasiados, captura lenta y mucha HBM. Ajustarlos a la &lt;strong>distribución real&lt;/strong> de tamaños de batch que ves en producción es la afinación fina —pero solo después de medir esa distribución.&lt;/p>
&lt;h3 id="knob-4--modo-de-cuda-graph">Knob 4 — Modo de CUDA graph&lt;/h3>
&lt;p>&lt;code>FULL_AND_PIECEWISE&lt;/code> (defecto, más rápido, más memoria/captura), &lt;code>FULL_DECODE_ONLY&lt;/code> (ahorra memoria y captura, ideal para pods de decode puro de &lt;a href="https://blog.lo0.es/posts/disaggregated-serving-prefill-decode/">disaggregated serving&lt;/a>), &lt;code>PIECEWISE&lt;/code>, o &lt;code>NONE&lt;/code> (= eager). El modo correcto depende de si el pod hace decode puro o mixto.&lt;/p>
&lt;h3 id="knob-5--torchcompile">Knob 5 — &lt;code>torch.compile&lt;/code>&lt;/h3>
&lt;p>vLLM se apoya en &lt;code>torch.compile&lt;/code> 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.&lt;/p>
&lt;h3 id="knob-6--batch-size-llenar-los-fogones">Knob 6 — Batch size: llenar los fogones&lt;/h3>
&lt;p>El decode memory-bound se amortiza batcheando (como vimos en &lt;a href="https://blog.lo0.es/posts/continuous-batching-fundamentos/">continuous batching&lt;/a>): leer los pesos una vez para B secuencias. Más batch = más ocupación de SM &lt;strong>y&lt;/strong> más amortización de memoria. El límite lo pone la HBM disponible para el &lt;a href="https://blog.lo0.es/posts/kv-cache-fundamentos/">KV cache&lt;/a>. Es el knob que más mueve el throughput agregado.&lt;/p>
&lt;h3 id="knob-7--no-romper-el-solapamiento-de-streams">Knob 7 — No romper el solapamiento de streams&lt;/h3>
&lt;p>vLLM solapa cómputo y copia con streams. Parchear el código para &amp;ldquo;simplificar&amp;rdquo; puede serializar lo que estaba solapado. Si no sabes por qué hay varios streams, no los colapses.&lt;/p>
&lt;h3 id="knob-8--persistence-mode--clocks-bloqueados">Knob 8 — Persistence mode + clocks bloqueados&lt;/h3>
&lt;p>&lt;code>nvidia-smi -pm 1&lt;/code> 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 &lt;em>anti-jitter&lt;/em> del &lt;a href="https://blog.lo0.es/posts/numa-hugepages-aislamiento-cpu-inferencia/">post de NUMA&lt;/a>, aplicado a la GPU.&lt;/p>
&lt;h3 id="knob-9--kernels-fusionados-flashattention-kernels-fp8">Knob 9 — Kernels fusionados (FlashAttention, kernels FP8)&lt;/h3>
&lt;p>Menos kernels = menos comandas que cantar. &lt;a href="https://blog.lo0.es/posts/speculative-decoding-fundamentos/">FlashAttention&lt;/a> 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, &lt;strong>elimina lanzamientos&lt;/strong>.&lt;/p>
&lt;h3 id="knob-10--aceptar-el-coste-de-captura-en-el-cold-start">Knob 10 — Aceptar el coste de captura en el cold start&lt;/h3>
&lt;p>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í &lt;code>FULL_DECODE_ONLY&lt;/code> (captura más corta) o aceptar algo menos de throughput puede salir a cuenta. Es la misma tensión warm-vs-elástico del &lt;a href="https://blog.lo0.es/posts/del-disco-a-la-hbm-cold-start-carga-modelo/">cold start&lt;/a>.&lt;/p>
&lt;h3 id="tabla-resumen">Tabla resumen&lt;/h3>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>#&lt;/th>
&lt;th>Knob&lt;/th>
&lt;th>Qué ataca&lt;/th>
&lt;th>Riesgo / coste&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>1&lt;/td>
&lt;td>&lt;code>nsys&lt;/code> / &lt;code>dmon&lt;/code>&lt;/td>
&lt;td>saber si es launch-bound&lt;/td>
&lt;td>ninguno; hazlo primero&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>2&lt;/td>
&lt;td>quitar &lt;code>--enforce-eager&lt;/code>&lt;/td>
&lt;td>graphs desactivados&lt;/td>
&lt;td>era para depurar; reactiva el problema si vuelve un bug&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>3&lt;/td>
&lt;td>buckets de captura&lt;/td>
&lt;td>padding caro / captura lenta&lt;/td>
&lt;td>requiere medir la distribución real&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>4&lt;/td>
&lt;td>modo de graph&lt;/td>
&lt;td>memoria y captura&lt;/td>
&lt;td>menos cobertura en pasos mixtos&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>5&lt;/td>
&lt;td>&lt;code>torch.compile&lt;/code>&lt;/td>
&lt;td>kernels sin fusionar&lt;/td>
&lt;td>tiempo de arranque&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>6&lt;/td>
&lt;td>batch size&lt;/td>
&lt;td>ocupación + memoria&lt;/td>
&lt;td>HBM para KV cache&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>7&lt;/td>
&lt;td>streams&lt;/td>
&lt;td>solapamiento roto&lt;/td>
&lt;td>no tocar si no se entiende&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>8&lt;/td>
&lt;td>persistence + clocks&lt;/td>
&lt;td>jitter / P-states&lt;/td>
&lt;td>consumo eléctrico&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>9&lt;/td>
&lt;td>kernels fusionados&lt;/td>
&lt;td>número de lanzamientos&lt;/td>
&lt;td>compatibilidad del kernel&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>10&lt;/td>
&lt;td>captura vs cold start&lt;/td>
&lt;td>arranque más lento&lt;/td>
&lt;td>menos throughput si se recorta&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;h2 id="cómo-se-conecta-con-el-resto-del-stack">Cómo se conecta con el resto del stack&lt;/h2>
&lt;p>&lt;strong>Con el cold start.&lt;/strong> La captura de CUDA graphs es la &lt;strong>segunda mitad&lt;/strong> del arranque que abrió el &lt;a href="https://blog.lo0.es/posts/del-disco-a-la-hbm-cold-start-carga-modelo/">post anterior&lt;/a>: cargar pesos + capturar graphs = el cold start completo.&lt;/p>
&lt;p>&lt;strong>Con continuous batching.&lt;/strong> El &lt;a href="https://blog.lo0.es/posts/continuous-batching-fundamentos/">batching continuo&lt;/a> es lo que &lt;strong>vuelve launch-bound&lt;/strong> al decode (amortiza la memoria y deja el lanzamiento al descubierto), y por eso los graphs y el batching se potencian mutuamente.&lt;/p>
&lt;p>&lt;strong>Con el KV cache.&lt;/strong> El &lt;a href="https://blog.lo0.es/posts/kv-cache-fundamentos/">KV cache&lt;/a> 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.&lt;/p>
&lt;p>&lt;strong>Con el interconnect.&lt;/strong> En TP, entre los kernels de cómputo hay &lt;strong>all-reduces&lt;/strong> (&lt;a href="https://blog.lo0.es/posts/nvlink-nvswitch-nccl-tensor-parallel/">NVLink/NCCL&lt;/a>) 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.&lt;/p>
&lt;p>&lt;strong>Con NUMA.&lt;/strong> &lt;em>Quién&lt;/em> lanza los kernels es la CPU del &lt;a href="https://blog.lo0.es/posts/numa-hugepages-aislamiento-cpu-inferencia/">post del host&lt;/a>; 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.&lt;/p>
&lt;p>&lt;strong>Con disaggregated serving.&lt;/strong> Los pods de &lt;strong>decode puro&lt;/strong> del &lt;a href="https://blog.lo0.es/posts/disaggregated-serving-prefill-decode/">serving desagregado&lt;/a> son el caso ideal de &lt;code>FULL_DECODE_ONLY&lt;/code>: maximizan el beneficio del graph justo en la fase más launch-bound.&lt;/p>
&lt;h2 id="trampas-y-cosas-que-no-son-lo-que-parecen">Trampas y cosas que no son lo que parecen&lt;/h2>
&lt;p>&lt;strong>&amp;ldquo;Subir la ocupación arreglará el decode lento.&amp;rdquo;&lt;/strong> No, si el problema es launch-bound. La ocupación importa &lt;strong>dentro&lt;/strong> de un kernel con trabajo; si la GPU está ociosa &lt;strong>entre&lt;/strong> kernels esperando a la CPU, más ocupación no toca esa burbuja. Mide antes de optimizar lo que no es el cuello.&lt;/p>
&lt;p>&lt;strong>&amp;ldquo;Los CUDA graphs siempre aceleran.&amp;rdquo;&lt;/strong> 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.&lt;/p>
&lt;p>&lt;strong>&amp;quot;&lt;code>--enforce-eager&lt;/code> da resultados más estables.&amp;quot;&lt;/strong> Da resultados más &lt;strong>lentos&lt;/strong>. 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.&lt;/p>
&lt;p>&lt;strong>Capturar demasiados buckets &amp;ldquo;por si acaso&amp;rdquo;.&lt;/strong> 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.&lt;/p>
&lt;p>&lt;strong>Confundir utilización con eficiencia.&lt;/strong> &lt;code>nvidia-smi&lt;/code> al 100% de &amp;ldquo;utilización&amp;rdquo; solo dice que &lt;strong>hay un kernel corriendo&lt;/strong>, no que el SM esté lleno de trabajo útil. Un kernel de baja ocupación mantiene la &amp;ldquo;utilización&amp;rdquo; alta mientras desperdicia el SM. La utilización de &lt;code>nvidia-smi&lt;/code> es un termómetro grueso; para saber si el silicio rinde hace falta &lt;code>nsys&lt;/code>/DCGM y mirar ocupación real y huecos.&lt;/p>
&lt;p>&lt;strong>Optimizar el silicio antes que la memoria.&lt;/strong> 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.&lt;/p>
&lt;h2 id="conclusión">Conclusión&lt;/h2>
&lt;p>La intuición dice que una GPU generando tokens lentos está &amp;ldquo;trabajando duro&amp;rdquo;. Casi nunca: en decode está &lt;strong>esperando órdenes&lt;/strong>. 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 &lt;strong>lanzamiento&lt;/strong>— es invisible en cualquier dashboard que mire &amp;ldquo;utilización de GPU&amp;rdquo;, y es la razón real por la que &lt;code>--enforce-eager&lt;/code> rinde la mitad. Los CUDA graphs lo resuelven con una idea simple: dejar de cantar comanda a comanda y entregar la &lt;strong>hoja entera&lt;/strong> de la noche, para que el silicio corra sin pausas. Y hay una verdad incómoda que reordena la prioridad de optimización: &lt;strong>cuanto mejor batcheas, más launch-bound te vuelves&lt;/strong> —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.&lt;/p>
&lt;h2 id="ver-también">Ver también&lt;/h2>
&lt;ul>
&lt;li>&lt;a href="https://blog.lo0.es/posts/del-disco-a-la-hbm-cold-start-carga-modelo/">Del disco a la HBM: cold start y carga del modelo&lt;/a> — la primera mitad del arranque; la captura de graphs de este post es la segunda mitad del mismo cold start.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/posts/numa-hugepages-aislamiento-cpu-inferencia/">La planta de al lado: NUMA, hugepages y aislamiento de CPU&lt;/a> — &lt;em>quién&lt;/em> lanza los kernels es ese hilo de host; su jitter es el launch overhead que los graphs reducen.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/posts/nvlink-nvswitch-nccl-tensor-parallel/">La mesa compartida: NVLink, NVSwitch y NCCL&lt;/a> — los all-reduces de TP se lanzan y sincronizan entre kernels; el custom all-reduce de vLLM se integra en el mismo graph.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/posts/continuous-batching-fundamentos/">Continuous batching&lt;/a> — lo que vuelve launch-bound al decode al amortizar la memoria; por eso batching y graphs se potencian.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/posts/kv-cache-fundamentos/">KV cache&lt;/a> — la memoria que decide cuánto batch cabe, y por tanto la ocupación de SM y cuánto pesa el launch overhead.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/posts/disaggregated-serving-prefill-decode/">Disaggregated serving: prefill y decode separados&lt;/a> — los pods de decode puro son el caso ideal de &lt;code>FULL_DECODE_ONLY&lt;/code>.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/posts/quantization-fundamentos-inferencia/">Quantization para inferencia&lt;/a> — los kernels FP8 fusionados reducen el número de lanzamientos en la raíz.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/posts/observabilidad-gpu-dcgm-llm/">Observabilidad GPU con DCGM&lt;/a> — dónde se ve la ocupación real y los contadores que distinguen &amp;ldquo;utilización&amp;rdquo; de eficiencia.&lt;/li>
&lt;/ul>
&lt;h2 id="referencias">Referencias&lt;/h2>
&lt;ul>
&lt;li>vLLM, &lt;em>CUDA Graphs&lt;/em> (diseño, modos FULL/PIECEWISE, captura): &lt;a href="https://docs.vllm.ai/en/stable/design/cuda_graphs/">https://docs.vllm.ai/en/stable/design/cuda_graphs/&lt;/a>.&lt;/li>
&lt;li>vLLM, &lt;em>Inside vLLM: Anatomy of a High-Throughput LLM Inference System&lt;/em>: &lt;a href="https://blog.vllm.ai/2025/09/05/anatomy-of-vllm.html">https://blog.vllm.ai/2025/09/05/anatomy-of-vllm.html&lt;/a>.&lt;/li>
&lt;li>NVIDIA, &lt;em>Getting Started with CUDA Graphs&lt;/em>: &lt;a href="https://developer.nvidia.com/blog/cuda-graphs/">https://developer.nvidia.com/blog/cuda-graphs/&lt;/a>.&lt;/li>
&lt;li>NVIDIA, &lt;em>Achieved Occupancy&lt;/em> (ocupación de SM): &lt;a href="https://archive.docs.nvidia.com/gameworks/content/developertools/desktop/analysis/report/cudaexperiments/kernellevel/achievedoccupancy.htm">https://archive.docs.nvidia.com/gameworks/content/developertools/desktop/analysis/report/cudaexperiments/kernellevel/achievedoccupancy.htm&lt;/a>.&lt;/li>
&lt;li>PyTorch, &lt;em>torch.compile y CUDA Graphs para inferencia LLM&lt;/em>: &lt;a href="https://docs.vllm.ai/en/stable/design/cuda_graphs/">https://docs.vllm.ai/en/stable/design/cuda_graphs/&lt;/a>.&lt;/li>
&lt;li>&lt;em>Understanding the Overheads of Launching CUDA Kernels&lt;/em> (ICPP 2019): &lt;a href="https://www.hpcs.cs.tsukuba.ac.jp/icpp2019/data/posters/Poster17-abst.pdf">https://www.hpcs.cs.tsukuba.ac.jp/icpp2019/data/posters/Poster17-abst.pdf&lt;/a>.&lt;/li>
&lt;/ul></description></item></channel></rss>