El roofline se invierte: por qué optimizar modelos pequeños es otro partido de rendimiento
Este post es el ancla de una mini-serie sobre rendimiento de inferencia en modelos pequeños (SLM). Casi todos los posts de optimización del blog —KV cache, decode, quantization— se escribieron con un 70B en la cabeza. Aquí defiendo que cuando el modelo encoge un orden de magnitud, el roofline cambia de régimen y varias de esas intuiciones se invierten. No es un matiz: es otro partido.
TL;DR
El decode autoregresivo de un LLM grande está memory-bandwidth-bound: en cada step hay que mover todos los pesos del modelo desde la HBM hasta los registros de los SM, y eso domina sobre las operaciones aritméticas. La GPU se pasa el rato esperando bytes, no calculando. Esa única frase —que el decode “espera a la HBM”— es la raíz de la mitad de las optimizaciones del blog. En un modelo pequeño (SLM, digamos 0.5B–7B) la frase deja de ser cierta de la forma simple en que la contábamos. A batch 1 sigues siendo memory-bound respecto al hardware, sí, pero el forward pass es tan barato (mover 6 GB a 1 TB/s son ~6 ms, no 70 ms) que los costes fijos por step —lanzamiento de kernels, overhead del scheduler de Python, el sampler, las copias host↔device, los synchronize— dejan de ser ruido y pasan a comerse un 20-30 % del tiempo. El cuello se desplaza de la HBM a la orquestación. Consecuencias concretas y cuantitativas: (1) los CUDA graphs y reducir el overhead del scheduler rinden más en SLM que en modelos grandes; (2) la cuantización de pesos da menos mejora de latencia a batch 1 en SLM, porque proporcionalmente hay menos pesos que mover frente a activaciones, KV cache y overhead fijo; (3) el batching tiene más headroom porque cruzas el ridge point tarde; (4) el KV cache puede dominar la memoria relativa. Todo esto sale de un único modelo —el roofline— aplicado con honestidad numérica.
La analogía: la despensa y el camarero
Una cocina con dos servicios muy distintos.
Servicio de degustación, un plato enorme y lento (el LLM de 70B). Cada plato lleva ingredientes pesados que el ayudante tiene que ir a buscar a la despensa del fondo, varias veces, cargando cajas. El cocinero, en cambio, monta el plato en un momento: lo lento es traer los ingredientes, no cocinarlos. Si quieres que el servicio vaya más rápido, no compras un cocinero más hábil: ensanchas el pasillo a la despensa o haces que cada viaje traiga más cajas. La despensa es la HBM; el viaje es el ancho de banda de memoria; cocinar es el compute. El plato grande está bound por la despensa.
Servicio de tapas, platillos minúsculos (el SLM). Ahora cada tapa lleva dos ingredientes y se monta en un segundo. El viaje a la despensa por tapa es brevísimo. Pero aparece un coste que en el plato grande era despreciable: el camarero. Por cada tapa, el camarero tiene que ir a la cocina, recoger el platillo, llevarlo a la barra, volver, anotar la comanda, cantarla. Ese ir y venir es fijo: cuesta lo mismo para una tapa que para el plato enorme. Cuando la tapa se monta en un segundo, el camarero —no la despensa— es el cuello de botella. Acortar el pasillo a la despensa (ensanchar la HBM, cuantizar los pesos) ya casi no mejora el servicio; lo que mejora es que el camarero encadene varias comandas sin volver a la cocina cada vez (CUDA graphs) o que sirva varias mesas de una pasada (batching).
El roofline es la herramienta que dice, con números, a partir de qué punto el camarero domina sobre la despensa. Esa frontera es el ridge point, y el chiste del título es que en SLM cruzamos el régimen mucho antes de lo que la intuición de los modelos grandes nos hizo creer.
El mecanismo desnudo: qué dice el roofline
El modelo roofline (Williams, Waterman y Patterson, 2009) parte de una sola magnitud: la arithmetic intensity (intensidad aritmética), que es cuántas operaciones haces por cada byte que mueves desde memoria.
$$\text{AI} = \frac{\text{FLOPs}}{\text{bytes movidos desde memoria}} \quad [\text{FLOP/byte}]$$
El hardware tiene dos techos: el de cómputo (peak FLOPS) y el de memoria (peak bandwidth × AI). El rendimiento alcanzable es el mínimo de ambos:
$$\text{Perf} = \min\big(\text{peak FLOPS},; \text{BW} \times \text{AI}\big)$$
Donde se cortan las dos líneas está el ridge point, la AI a partir de la cual dejas de estar limitado por memoria y pasas a estarlo por cómputo:
$$\text{AI}_{\text{ridge}} = \frac{\text{peak FLOPS}}{\text{peak BW}}$$
Si tu kernel tiene AI por debajo del ridge, estás memory-bound (la GPU espera bytes). Por encima, compute-bound (la GPU calcula a tope y la memoria sobra). Lo importante es que el ridge point es una propiedad del hardware, no del modelo. Veamos los números —aproximados, y los marco como tales porque las cifras de marketing mezclan dense y sparse, distintos dtypes y condiciones térmicas irreales.
Cluster genérico 4×H100 SXM (320 GB, NVLink, FP8 nativo). Por GPU, ~989 TFLOPS BF16 dense (~1979 TFLOPS FP8 dense; la cifra con sparsity es el doble y casi nunca aplica a inferencia LLM). HBM3 ~3.35 TB/s. El ridge en BF16:
$$\text{AI}_{\text{ridge}}^{\text{H100,BF16}} \approx \frac{989 \times 10^{12}}{3.35 \times 10^{12}} \approx 295 \ \text{FLOP/byte}$$
En FP8 el ridge sube a ~590 FLOP/byte (el doble de FLOPS contra el mismo BW). Cuidado: estas son cifras de pico de datasheet; en la práctica un kernel real raramente pasa del 70-80 % de cualquiera de los dos techos.
RTX 4090 (24 GB, Ada Lovelace). ~330 TFLOPS FP16 con acumulación FP16 vía tensor cores (la cifra “660 TOPS” que circula es con sparsity), y ~1 TB/s de GDDR6X. El ridge:
$$\text{AI}_{\text{ridge}}^{\text{4090,FP16}} \approx \frac{330 \times 10^{12}}{1.0 \times 10^{12}} \approx 330 \ \text{FLOP/byte}$$
Curiosamente del mismo orden que la H100 en BF16: la 4090 tiene menos BW pero también menos FLOPS, y el cociente queda parecido. El ridge ronda 300 FLOP/byte en ambos casos. Quédate con ese número.
¿Y dónde cae el decode? En decode a batch 1, cada peso se carga una vez desde HBM y se usa para una sola multiplicación-acumulación (un token, una fila de activación). La AI del GEMM de decode a batch 1 es del orden de AI ≈ 1-2 FLOP/byte (cada byte de peso participa en ~2 FLOP). Con batch B, el mismo peso cargado una vez sirve a B filas de activación, así que la AI escala aproximadamente lineal:
$$\text{AI}_{\text{decode}}(B) \approx 2B \ \text{FLOP/byte} \quad (\text{para la parte GEMM de los pesos})$$
Cruzas el ridge cuando 2B ≈ 300, es decir B ≈ 150 en orden de magnitud (en la práctica antes, por atención y overheads, pero ese es el marco). Conclusión limpia: el decode a batch bajo está siempre profundamente memory-bound, lejísimos del ridge. Por eso decimos que “el decode espera a la HBM” y por eso cuantizar pesos (mover menos bytes) acelera el decode de un modelo grande casi linealmente. Hasta aquí, todo es el discurso estándar de los posts de modelos grandes.
El matiz del título: por qué se invierte en SLM
El roofline clásico tiene un punto ciego que en modelos grandes no importa y en pequeños lo es todo: solo modela el trabajo dentro del kernel. Asume que el único tiempo es bytes/BW o FLOPs/FLOPS. Pero un step de decode real no es solo el GEMM. Es una secuencia de decenas de kernels (proyecciones QKV, atención, las dos capas del MLP, normalizaciones, residuales, la cabeza de logits, el sampling) y, alrededor de cada uno, hay un coste fijo de orquestación:
- Lanzamiento de kernels (
kernel launch): cadacudaLaunchKernelcuesta del orden de 5-10 µs de overhead de CPU/driver, independientemente del tamaño del kernel. Un forward de decode con ~30-60 kernels lanzados secuencialmente arrastra ~0.3-0.6 ms solo en lanzar. - Overhead del scheduler de Python: el bucle de scheduler de vLLM prepara metadatos, decide qué requests entran en el step, construye los tensores de entrada. En Python puro esto son cientos de µs a un par de ms por step, sobre todo a concurrencia baja donde no se amortiza.
- Sampling y post-proceso: aplicar temperatura, top-p, penalizaciones, el
argmax/multinomial, copiar el token de vuelta. Otro bloque de cientos de µs. - Sincronizaciones y copias host↔device: cada
synchronizeo copia pequeña añade latencia que no es ni FLOPs ni bytes de HBM.
Llamemos a la suma de todo esto T_fijo, el coste por step independiente del tamaño del modelo, del orden de 1-3 ms en un stack Python sin optimizar. Ahora el tiempo real de un step es:
$$T_{\text{step}} \approx \underbrace{\frac{\text{bytes de pesos}}{\text{BW}}}{T{\text{HBM}} \text{ (memory-bound)}} + ; T_{\text{fijo}}$$
En un 70B BF16, mover ~140 GB a 3.35 TB/s son ~42 ms de T_HBM. Frente a eso, T_fijo de 1-3 ms es ruido (2-7 %). El roofline clásico acierta: el modelo está memory-bound y punto. Pero en un 3B BF16, T_HBM cae a unos pocos ms, y de pronto T_fijo es del mismo orden que T_HBM. El cuello deja de ser la despensa y pasa a ser el camarero. Esto es la inversión del título, y de ella se derivan cuatro consecuencias contraintuitivas:
(a) A batch 1 sigues memory-bound respecto al hardware. La AI no ha cambiado: sigue siendo ~2 FLOP/byte, debajo del ridge. Quien lea solo el roofline concluirá “memory-bound, cuantiza los pesos”. Es cierto pero incompleto: el roofline no ve T_fijo.
(b) Los costes fijos pasan a ser una fracción enorme del step. Es el punto central. En el 70B, T_fijo / T_step ≈ 5 %. En el 3B puede ser 20-30 %. El cuello efectivo del 3B es mitad HBM, mitad orquestación.
(c) Por eso los CUDA graphs y reducir el overhead del scheduler rinden MÁS en SLM. Un CUDA graph captura toda la secuencia de kernels del step y la relanza con un único cudaGraphLaunch, eliminando casi todo el overhead de lanzamiento por kernel y buena parte del trabajo del scheduler de Python por iteración. En el 70B, recortar 0.5 ms de un step de 42 ms es un +1 % que apenas se nota. En el 3B, recortar esos mismos 0.5 ms de un step de ~7 ms es un +7 %, y si te llevas casi todo T_fijo puedes ganar 20-30 %. La misma optimización, distinto premio, porque el denominador cambió.
(d) La cuantización de pesos da MENOS mejora de latencia a batch 1 en SLM. Esta es la más contraintuitiva. En el 70B, T_HBM es casi todo el step; pasar de BF16 a INT4 cuadruplica el ancho de banda efectivo de pesos y casi cuadruplica la velocidad de decode. En el 3B, T_HBM es solo parte del step (el resto es T_fijo + atención + KV). Por la ley de Amdahl, si los pesos son el 60 % del step y los aceleras 4×, el step total mejora solo 1/(0.4 + 0.6/4) = 1.8×, no 4×. Y proporcionalmente hay menos pesos que mover frente a activaciones, KV cache y el overhead fijo. La cuantización agresiva en SLM ayuda, sí, pero no por la latencia pura a batch 1 —ahí da rendimientos decrecientes— sino por capacidad y concurrencia (lo veremos al final).
(e) El KV cache puede dominar la memoria relativa. Con pesos de 6 GB (3B BF16), una sola sesión de contexto largo puede acercarse a ese orden de magnitud en KV cache. En un 70B (140 GB de pesos) el KV es proporcionalmente pequeño hasta concurrencias altas. En SLM el balance de VRAM se inclina hacia el KV mucho antes (el detalle está en KV cache), y eso cambia qué optimización de memoria es la palanca.
La matemática que importa: el 3B en una 4090
Hagamos el cálculo entero, que es donde se ve la inversión sin retórica.
Modelo: 3B parámetros, BF16 → 2 bytes/param → ~6 GB de pesos. Hardware: RTX 4090, BW ≈ 1 TB/s.
Techo memory-bound del decode (batch 1). Cada token requiere cargar los 6 GB una vez:
$$T_{\text{HBM}} = \frac{6 \times 10^{9} \ \text{bytes}}{1 \times 10^{12} \ \text{bytes/s}} = 6 \times 10^{-3}\ \text{s} = 6\ \text{ms/token}$$
$$\text{Techo} = \frac{1}{6\ \text{ms}} \approx 166\ \text{tok/s}$$
Eso es el techo teórico memory-bound: 166 tok/s, asumiendo que mover los pesos es el único coste. El roofline clásico se pararía aquí y diría “166 tok/s, ve a por más BW o cuantiza”.
Ahora el overhead fijo. Pongamos T_fijo ≈ 2 ms/step (un valor razonable de scheduler de Python + ~40 kernels lanzados + sampling, sin CUDA graphs). El step real:
$$T_{\text{step}} = T_{\text{HBM}} + T_{\text{fijo}} = 6 + 2 = 8\ \text{ms} ;\Rightarrow; \frac{1}{8\ \text{ms}} = 125\ \text{tok/s}$$
El overhead se ha comido 41 tok/s de los 166 teóricos: el T_fijo es el 25 % del step (2 de 8 ms). Compara con el 70B: T_HBM ≈ 42 ms, T_step ≈ 44 ms, T_fijo es el 4.5 %. Mismo overhead absoluto, impacto relativo 5-6× mayor en el SLM.
Qué pasa si aplicas CUDA graphs y te llevas, digamos, 1.5 de los 2 ms de T_fijo:
$$T_{\text{step}}^{\text{graphs}} = 6 + 0.5 = 6.5\ \text{ms} ;\Rightarrow; 154\ \text{tok/s}$$
De 125 a 154 tok/s: +23 % solo por orquestación, sin tocar el modelo ni el hardware de memoria. En el 70B la misma intervención habría dado de 44 a 42.5 ms, +3.5 %. Aquí está, en dos números, “otro partido”.
Qué pasa si cuantizas los pesos a INT4 (1.5 GB en vez de 6 GB), con T_fijo aún en 2 ms:
$$T_{\text{HBM}}^{\text{INT4}} = \frac{1.5 \times 10^{9}}{1 \times 10^{12}} = 1.5\ \text{ms};\quad T_{\text{step}} = 1.5 + 2 = 3.5\ \text{ms} ;\Rightarrow; 285\ \text{tok/s}$$
La cuantización 4× de pesos no dio 4× de latencia: pasó de 125 a 285 tok/s, un 2.3×, porque el T_fijo de 2 ms ahora domina (es el 57 % del step). En el 70B, cuantizar a INT4 da casi el 4× completo porque T_fijo sigue siendo ruido. La misma cuantización rinde el doble de aceleración en el grande que en el pequeño, a batch 1. Y si además aplicas CUDA graphs sobre el INT4 (T_fijo → 0.5 ms): 1.5 + 0.5 = 2 ms → 500 tok/s. El orden de las optimizaciones importa: en SLM atacar T_fijo primero desbloquea el resto.
| Configuración (3B, 4090, batch 1) | T_HBM | T_fijo | T_step | tok/s | vs. base |
|---|---|---|---|---|---|
| BF16, sin graphs (base) | 6.0 ms | 2.0 ms | 8.0 ms | 125 | 1.00× |
| BF16 + CUDA graphs | 6.0 ms | 0.5 ms | 6.5 ms | 154 | 1.23× |
| INT4, sin graphs | 1.5 ms | 2.0 ms | 3.5 ms | 285 | 2.28× |
| INT4 + CUDA graphs | 1.5 ms | 0.5 ms | 2.0 ms | 500 | 4.00× |
(Cifras ilustrativas con T_fijo redondeado; el punto es el patrón, no el decimal. El T_fijo real depende del stack, la versión de PyTorch/CUDA y si hay tensor parallelism. Mídelo en tu setup antes de creerte ninguna fila.)
Implicaciones por optimización
Con el modelo en la mano, las palancas del blog se reordenan al cambiar de régimen.
Batching: mucho más headroom en SLM. Recuerda que cruzas el ridge en B ≈ ridge/2 ≈ 150 en orden de magnitud. En un modelo grande, la VRAM se acaba mucho antes de saturar compute (los pesos + KV no te dejan llegar a batch 150). En un SLM los pesos ocupan poco, así que puedes meter batches grandes en VRAM y seguir memory-bound durante mucho más rango: el T_HBM de los pesos se amortiza entre las B requests (lo cargas una vez, sirve a B), de modo que el throughput agregado por GPU sube casi linealmente con B hasta muy arriba. Es justo lo contrario del miedo del 70B a saturar compute. En SLM, batchear es la palanca de throughput por excelencia porque saturas compute tarde; el grid search de batch en vLLM tiene una meseta de buen comportamiento mucho más ancha. Ojo: batchear mejora throughput, no latencia por request; para latencia single-stream el premio está en T_fijo.
Speculative decoding: otro punto de cruce. Speculative gana cuando el verify de γ tokens es “casi gratis” por estar memory-bound. En SLM el target ya es barato, así que el draft tiene que ser minúsculo para que c = T_draft/T_target siga siendo pequeño, y el T_fijo del propio draft (lanzar sus kernels) muerde más. El cruce a compute-bound con batch también llega antes en términos absolutos de tok/s servidos. La variante que mejor encaja aquui evita un draft separado: self-speculative / early-exit reutiliza capas tempranas del propio modelo y ahorra el T_fijo de orquestar dos modelos.
Cuantización: ayuda por capacidad, no por latencia a batch 1. Como mostró la tabla, INT4 en un SLM a batch 1 da rendimientos decrecientes en latencia. Su verdadero premio en SLM es capacidad: pesos 4× más pequeños liberan VRAM para más KV cache → más concurrencia, y es a concurrencia alta (throughput agregado) donde el ahorro de bytes vuelve a pagar. La cuantización agresiva sub-4-bit y ternaria lleva esto al extremo: en SLM tiene sentido sobre todo para encajar más sesiones por GPU, no para bajar la latencia de una sola. Y conviene recordar (ver quantization) que a batch 1 el dequantize añade trabajo de cómputo que, en un régimen ya rozado por T_fijo, no siempre sale gratis.
Arquitectura: MoE de grano fino cambia qué bytes mueves. Un MoE device-native de grano fino activa pocos parámetros por token, así que T_HBM baja respecto a un denso del mismo tamaño total —pero la fracción T_fijo sube todavía más, y el router añade su propio overhead fijo. Es el régimen SLM llevado a su límite: casi todo el partido se juega en la orquestación.
Scheduler y CUDA graphs primero. La conclusión operacional invertida respecto a los posts de modelos grandes: en SLM, antes de tocar el modelo, mata el T_fijo. CUDA graphs (ver SM, streams y graphs), un scheduler de vLLM con su parte de Python minimizada o compilada, y persistencia de kernels son las palancas de primer orden. En un 70B serían un pulido marginal; en un 3B son la mitad del speedup disponible.
Aplicado a hardware on-premise
En una RTX 4090 (24 GB, Ada Lovelace). Es el escenario donde la inversión es más visible, porque la 4090 tiene ~1 TB/s (un tercio de la H100) pero el T_fijo es el mismo en términos absolutos. Un 3B BF16 sin CUDA graphs deja ~125 tok/s sobre la mesa cuando el techo memory-bound son 166; activar graphs y limpiar el scheduler recupera la mayor parte. La 4090 cabe holgada para SLM en VRAM, así que el cuello casi nunca es la memoria total sino la orquestación y, a alta concurrencia, el KV cache. Regla de pulgar: en 4090 con SLM, perfila primero el overhead por step (Nsight Systems sobre el gap entre kernels) antes de cuantizar.
En un cluster genérico 4×H100 SXM (320 GB, NVLink, FP8 nativo). La H100 tiene 3.35 TB/s, así que T_HBM de un SLM es aún más pequeño (un 3B FP8 son ~3 GB → ~0.9 ms) y el T_fijo domina todavía antes: un SLM mal orquestado en H100 puede pasar más tiempo en el scheduler de Python que moviendo pesos. Es casi un desperdicio servir un único SLM single-stream en una H100; el modo correcto es batching agresivo (saturas compute tarde, así que metes batches grandes y el throughput por GPU se dispara) o multiplexar muchos SLM/sesiones por GPU vía MPS/MIG. Aquí conecta con capacity planning: para SLM el cálculo de capacidad lo gobiernan concurrencia y KV cache, no los pesos. Y con el dilema de una grande vs N pequeñas: replicar SLM tiene sentido precisamente porque cada réplica satura compute tarde y el TP no aporta (el modelo ya cabe; el TP solo añadiría T_fijo de comunicación).
Lo que no hemos cubierto
- El
T_fijoexacto medido, kernel a kernel, con Nsight Systems: cuánto es launch, cuánto scheduler, cuánto sampling. Es el contenido del siguiente post de la serie. torch.compile/ capturas parciales: alternativas y complementos a los CUDA graphs cuando hay control flow dinámico.- El régimen prefill en SLM: el prefill es compute-bound incluso en modelos pequeños (procesa muchos tokens a la vez, AI alta), así que su roofline es el opuesto del decode; ver prefill.
- Atención y KV como segundo término de
T_HBM: aquí los hemos metido implícitamente; el desglose fino de la atención (que escala con la longitud de secuencia, no con los pesos) merece su propio tratamiento.
Ver también
- KV cache: la memoria de trabajo de la inferencia — el fenómeno memory-bound del decode nace del KV cache; en SLM el KV pasa a dominar la VRAM relativa antes que en modelos grandes.
- Grid search de batch sizing en vLLM — la meseta de buen batch es mucho más ancha en SLM porque cruzas el ridge tarde; este post da el método empírico.
- Optimizando el decode en vLLM — los flags concretos (CUDA graphs, eager vs captured) cuyo impacto este post reordena para el caso SLM.
- Optimizando el prefill en vLLM — el reverso compute-bound del roofline: el prefill ya vive por encima del ridge incluso en modelos pequeños.
- SM, CUDA streams y CUDA graphs — el mecanismo que ataca el
T_fijo; aquí explicamos por qué su premio es desproporcionado en SLM. - El scheduler step de vLLM — buena parte de
T_fijovive en este bucle de Python; en SLM minimizarlo es palanca de primer orden. - Quantization para inferencia — por qué la cuantización de pesos rinde menos latencia a batch 1 en SLM (ley de Amdahl sobre
T_HBM) y más por capacidad. - Speculative decoding: fundamentos — el punto de cruce memory/compute se desplaza en SLM, cambiando cuándo speculative paga.
- Capacity planning de inferencia on-premise — para SLM la capacidad la gobiernan concurrencia y KV, no los pesos; este post da las fórmulas.
- Una grande vs N pequeñas — replicar SLM bate al TP porque cada réplica satura compute tarde y el TP solo añade
T_fijode comunicación. - Self-speculative decoding / early-exit — hermano de serie: acelerar sin draft separado, evitando el
T_fijode orquestar dos modelos, encaje natural en SLM. - MoE de grano fino device-native — hermano de serie: el régimen SLM llevado al límite, donde el router y la orquestación dominan sobre el
T_HBM. - Cuantización agresiva sub-4-bit y ternaria — hermano de serie: por qué en SLM sub-4-bit paga sobre todo en capacidad/concurrencia, no en latencia a batch 1.
Referencias
- Williams, S., Waterman, A., Patterson, D. Roofline: An Insightful Visual Performance Model for Multicore Architectures. Communications of the ACM, 52(4), 2009. https://doi.org/10.1145/1498765.1498785
- Mind the Memory Gap: Unveiling GPU Bottlenecks in Large-Batch LLM Inference. arXiv:2503.08311, 2025. https://arxiv.org/abs/2503.08311
- Databricks. LLM Inference Performance Engineering: Best Practices. https://www.databricks.com/blog/llm-inference-performance-engineering-best-practices
- NVIDIA. NVIDIA H100 Tensor Core GPU Datasheet. https://resources.nvidia.com/en-us-tensor-core/nvidia-tensor-core-gpu-datasheet
- NVIDIA. GeForce RTX 4090 — especificaciones de producto (cifras de tensor cores Ada Lovelace; tratar como aproximadas, mezclan dense/sparse).
- Yuan, Z. et al. LLM Inference Unveiled: Survey and Roofline Model Insights. arXiv:2402.16363, 2024 — aplicación del roofline específicamente a inferencia LLM.