Aplicado a tus proyectos · vLLM (fork propio)
Aguas arriba: tres bugs que arreglaste vos, no NVIDIA ni Google
Servir Gemma-4 en una RTX 5090 te puso en la intersección de hardware nuevo (Blackwell, SM 12.x) y una arquitectura de modelo nueva (atención heterogénea por capa). En esa intersección, casi nadie más pisó estos bugs antes que vos.
Antes de leerHF cap. 2 §5, vLLM y PagedAttention · LLM 2026 · 1 §5, head_dim y cabezas KV por capa · tu pipeline NVFP4
🎯 Al terminar vas a poder
- Dibujar el mapa de vLLM (motor, scheduler, KV cache paginado, backends de atención, kernels de cuantización) y decir en qué pieza vive cada uno de los tres bugs.
- Explicar qué es una compute capability (sm_XX), por qué una GPU nueva necesita un toolkit de CUDA nuevo, y leer las dos cosas desde PyTorch.
- Distinguir un kernel CUTLASS de uno Marlin y explicar por qué un kernel puede correr "bien" y devolver basura.
- Reconocer un error enmascarado (un mensaje que es síntoma de otro error silenciado antes) y buscar la causa un paso más atrás.
- Armar un fix aguas arriba con la forma que un maintainer acepta: causa real, cambio mínimo, test sin hardware, issue enlazado, DCO.
0. El patrón: sos el primero en pisar esto
vLLM se testea principalmente contra GPUs de datacenter (A100, H100) con meses de uso acumulado. La
RTX 5090 es Blackwell de consumo (SM 12.0), una combinación mucho menos transitada. Y Gemma-4
introdujo algo que casi ningún modelo anterior tenía: geometría de atención distinta por capa (las capas
de ventana deslizante usan head_dim=256 con 8 cabezas KV; las de atención completa usan
head_dim=512 con una sola cabeza KV compartida; esa es la geometría del 12B, y el 31B sigue el
mismo patrón con 16 cabezas KV locales y 4 globales). Cada uno de los tres bugs de esta página
vive exactamente en esa intersección — hardware de punta + arquitectura nueva — donde el código
existente simplemente nunca fue ejercitado.
Vale la pena decir por qué eso es estructural y no mala suerte. Un motor de inferencia es una pila de
supuestos: "todas las capas tienen el mismo head_dim", "si el kernel compila, funciona",
"si la GPU es Blackwell, el kernel de Blackwell es el correcto". Cada supuesto es razonable para el
hardware y los modelos con los que se escribió el código. Cuando aparecen a la vez una GPU que no estaba
y un modelo que no estaba, dos o tres de esos supuestos se rompen juntos — y como nadie más corre esa
combinación, nadie más los ve. El que la corre primero es, por definición, el que los encuentra.
1. Un mapa de vLLM para ubicar los tres bugs
Los tres fixes tocan partes distintas del motor. Para entender por qué cada uno fue difícil de ver, conviene tener el plano general de cómo se organiza vLLM — es el mismo plano, con otros nombres, que tiene cualquier servidor de inferencia moderno (TensorRT-LLM, SGLang, TGI).
Motor (engine)
Recibe requests, las tokeniza, coordina los pasos de generación y devuelve tokens. Es la capa que ve el usuario: vllm serve, la clase LLM, el servidor compatible con la API de OpenAI.
Scheduler
Decide, en cada paso, qué secuencias entran al batch: cuáles siguen decodificando, cuáles arrancan su prefill, cuáles esperan por falta de memoria. Es lo que hace posible el continuous batching: el batch cambia en cada iteración, no al final de todas las secuencias.
KV cache paginado
La memoria para K y V se reparte en bloques de tamaño fijo (por ejemplo 16 tokens), y cada secuencia tiene una tabla que mapea sus posiciones lógicas a bloques físicos — como la memoria virtual de un sistema operativo. Es la idea central del paper de vLLM (PagedAttention).
Backend de atención
El kernel que, dado Q y las páginas de K/V, calcula la atención. Hay varios (FlashAttention, FlashInfer, Triton…) porque ninguno cubre todo el hardware ni todas las geometrías de modelo.
Kernels de cuantización
Las capas Linear del modelo se reemplazan por versiones que leen pesos comprimidos y multiplican con un kernel específico (CUTLASS, Marlin, FlashInfer…). Un selector elige cuál, según el formato y la GPU.
Sampler
Convierte los logits del último token en un token: temperatura, top-k, top-p, penalizaciones. También es un kernel — y también puede venir de una librería externa como FlashInfer.
📐 Traducción analítica de direcciones en PagedAttention
Para un token lógico en la posición de la secuencia , con tamaño de bloque (ej. 16 tokens), cantidad de cabezas KV , dimensión de cabeza y bytes por elemento (ej. 1 byte en FP8):
La dirección física exacta en la memoria global (HBM/VRAM) para la cabeza del vector de clave o valor se calcula resolviendo la tabla de páginas lógica a física:
Este mapeo amortizado desacopla la contigüidad lógica de la reserva física, reduciendo la fragmentación de memoria del 60–80% al 3–4% en servidores concurrentes.
Por qué el KV cache se pagina
Sin paginación, cada secuencia reserva de antemano un bloque contiguo de memoria del tamaño máximo de contexto posible, aunque después use una fracción. El paper de PagedAttention (Kwon et al., 2023) midió que en sistemas anteriores gran parte de la memoria de KV cache se desperdiciaba así, entre reservas que nunca se usan y fragmentación. Con bloques chicos asignados a demanda, casi toda la memoria reservada guarda tokens reales — y eso significa más secuencias simultáneas por GPU, que es lo que define el throughput de un servidor. El costo es que el kernel de atención tiene que saber leer K/V de bloques no contiguos, siguiendo la tabla: por eso un backend de atención "de vLLM" no es el mismo kernel que usa Transformers en un forward pass común.
Qué es un "attention backend" y por qué hay varios
Un backend de atención es una implementación concreta del cálculo softmax(QKᵀ/√d)·V, empaquetada con
las reglas de qué necesita para funcionar: qué compute capability mínima, qué head_dim
soporta (muchos kernels solo aceptan potencias de 2 hasta 256), qué dtypes de KV cache, si sabe leer
bloques paginados, si soporta ventanas deslizantes. vLLM elige uno al arrancar según la GPU y el
modelo — y podés forzarlo con VLLM_ATTENTION_BACKEND (o el flag equivalente de la línea
de comandos en versiones recientes). Existen varios porque cada uno resuelve un compromiso distinto:
| Backend | Qué es | Cuándo lo elegís (o te lo eligen) |
|---|---|---|
| FlashAttention (FLASH_ATTN) | El kernel fusionado de Dao et al.: no materializa la matriz N×N, procesa por bloques dentro de la SRAM del chip. Versión 2 en arXiv:2307.08691; la 3 es específica de Hopper. | El default en la mayoría de las GPUs NVIDIA con geometrías estándar. |
| FlashInfer | Una librería aparte (arXiv:2501.01005) pensada para serving: atención sobre KV cache paginado, prefill y decode, y también kernels de sampling. Compila kernels JIT para la arquitectura presente. | Cuando su cobertura es mejor para el caso (por ejemplo, ciertos formatos de KV cache); y su sampler es el que vLLM activa por default cuando está disponible — de ahí el §2. |
| Triton (TRITON_ATTN) | Un kernel escrito en Triton, el lenguaje de kernels de OpenAI que compila a GPU desde Python. Más lento que los kernels a mano, pero mucho más flexible en geometrías. | El fallback cuando el head_dim o el layout no entran en los otros — tu caso con Gemma-4 en la página de cuantización. |
Compute capability: qué significa "sm_120"
Cada generación de GPU NVIDIA tiene un número de compute capability — mayor.menor —
que describe qué instrucciones y qué recursos tiene el chip: tamaño de shared memory, qué tipos de
tensor core, qué dtypes multiplica en hardware. Un kernel se compila para una capability concreta
(sm_80, sm_90, sm_120…), y el toolkit de CUDA solo sabe generar
código para las arquitecturas que existían cuando ese toolkit salió. Por eso hardware nuevo exige
toolkit nuevo: Blackwell necesita CUDA 12.8 o posterior, y con un toolkit anterior el compilador
simplemente no conoce el target.
| Capability | Arquitectura | GPUs típicas |
|---|---|---|
| 7.5 | Turing | RTX 20xx, T4 |
| 8.0 / 8.6 | Ampere | A100 / RTX 30xx |
| 8.9 | Ada Lovelace | RTX 40xx, L4, L40 |
| 9.0 | Hopper | H100, H200 |
| 10.0 | Blackwell (datacenter) | B200, GB200 |
| 12.0 | Blackwell (consumo / workstation) | RTX 5090 y el resto de la serie 50 |
El detalle que explica buena parte de esta página: 10.0 y 12.0 son las dos Blackwell, pero no son el mismo chip. Comparten el nombre de generación y los tensor cores FP4, pero no todo el conjunto de features del chip de datacenter está en el de consumo. Un kernel escrito y validado en B200 no es automáticamente correcto en una RTX 5090 — y un chequeo del tipo "¿es Blackwell? entonces usá el kernel de Blackwell" es exactamente el supuesto que se rompe en el §4. Del mismo modo, un mensaje como "requiere sm75 o superior" en una GPU sm_120 no puede ser literalmente cierto: es la señal de que la detección de arquitectura falló antes (§2).
CUTLASS y Marlin, en una frase cada uno
- CUTLASS es la librería de plantillas C++ de NVIDIA para escribir GEMMs que usan directamente los tensor cores de cada arquitectura — un kernel NVFP4 "CUTLASS" multiplica FP4 × FP4 en hardware (W4A4), y por eso depende al máximo de que el chip sea exactamente el que el kernel espera.
- Marlin (arXiv:2408.11743) es un kernel de pesos comprimidos que los desempaqueta a 16 bits dentro del kernel y multiplica con los tensor cores de 16 bits (W4A16), pensado para batches chicos — ahorra memoria y ancho de banda, no necesita hardware FP4 nativo, y esa independencia es justo lo que lo hace correcto donde el kernel CUTLASS no lo es.
Con el mapa armado: el bug del §2 vive en el sampler (y en la detección de arquitectura de FlashInfer); el del §3, en cómo el modelo describe su geometría a los backends de atención; el del §4, en el selector de kernels de cuantización. Tres piezas distintas, un mismo tipo de supuesto roto.
2. FlashInfer: un error real disfrazado de otro
El síntoma: al arrancar vLLM en la RTX 5090, el sampler de FlashInfer (activado por default) tira
"FlashInfer requires GPUs with sm75 or higher" — un mensaje que no tiene sentido para una
GPU sm_120. El motor muere durante el profiling de arranque, antes de servir ni un token.
La causa real, encontrada leyendo el código de FlashInfer: su detección de arquitectura
(check_cuda_arch()) traga el error real cuando el toolkit de CUDA es
anterior al 12.9 sobre SM 12.x — deja un set de arquitecturas objetivo vacío, y ese set vacío es lo que
después dispara el mensaje genérico "sm75 or higher" en cualquier intento de compilar un kernel JIT.
El mensaje que ves no es el error — es el síntoma de un error que ya fue silenciado un paso antes.
El mecanismo general se llama enmascaramiento de errores y es de los más frustrantes
de depurar: una función atrapa una excepción, la reemplaza por un estado "vacío" (un set sin
elementos, un None, un 0) y sigue. Más adelante, otro código interpreta ese vacío según su
propia lógica y emite un mensaje razonable para el vacío, pero absurdo para la situación real.
La pista para reconocerlo: el mensaje contradice un hecho que sabés cierto ("mi GPU es sm_120, no
puede ser menor que sm75"). Cuando eso pasa, la pregunta no es "¿por qué dice esto?" sino "¿qué tuvo
que fallar antes para que llegue acá con este estado?".
🔧 El fix real
En vez de confiar en el mensaje genérico, el fix llama a check_cuda_arch() directamente
en el gate del sampler, y si falla, vuelve a llamar al método interno de FlashInfer que sabe derivar
la razón real (CompilationContext._normalize_cuda_arch(major, minor)) para recuperar el
mensaje verdadero — algo como "SM 12.x requires CUDA >= 12.9". Con esa razón real en
mano: si el usuario no pidió FlashInfer explícitamente, vLLM cae a sampling nativo con un warning claro;
si lo pidió a propósito (VLLM_USE_FLASHINFER_SAMPLER=1), lanza el error real — no el
genérico — para que sepa exactamente qué actualizar.
Fijate en la asimetría del comportamiento, porque es una decisión de diseño y no un detalle: la misma condición produce un warning y fallback cuando el usuario no eligió nada, y un error cuando eligió explícitamente. Degradar en silencio algo que el usuario pidió a propósito sería mentirle; romper el arranque por un default que el usuario ni sabía que existía sería castigarlo por nada. Un buen fallback respeta esa diferencia.
El fix viene con un test que monkey-parchea check_cuda_arch y
_normalize_cuda_arch para simular exactamente esta condición sin necesitar hardware SM12x
real en CI, y referencia el issue público (#42393) — la forma correcta de mandar un fix: reproducible, con test, y atado a un reporte real.
🟢 No fue la única vez con FlashInfer
serve_lacan_vllm.sh ya documenta otra fragilidad de la misma pareja
FlashInfer/CUDA-en-sm_120: forzar CUDA_HOME=/usr/local/cuda-13.1 explícitamente porque
"ninja recompiló el kernel FP4 con 12.8 y falló" (lección propia, 2026-08-01). Es el mismo patrón de
fondo — FlashInfer asume una alineación de versiones de CUDA que en sm_120 todavía no está bien
probada — resuelto una vez operativamente (fijando la versión) y otra vez en el código fuente
(arreglando la detección).
Ese último punto tiene nombre en el §1: FlashInfer compila kernels JIT con el toolkit que
encuentra en el sistema (CUDA_HOME), no con el que trae PyTorch. Por eso en una misma
máquina pueden convivir tres "versiones de CUDA" — la del driver, la con la que se compiló PyTorch
(torch.version.cuda) y la del toolkit instalado — y un kernel JIT usa la tercera. Cuando
las tres no están alineadas, los síntomas son exactamente estos. El ejercicio 1 del §9 imprime las tres.
3. Gemma-4 en el backend de Transformers
Este es el caso más largo — cinco commits propios, con revisión real de un maintainer de vLLM
(hmellor) en el medio — y conecta directo con algo que ya viste en
la página de cuantización: la geometría de atención heterogénea
de Gemma-4 (head_dim=256 en capas de ventana, head_dim=512 en capas
completas) es la misma razón por la que tuviste que forzar el backend TRITON_ATTN al servir el modelo
cuantizado. Acá es la causa de fondo, atacada en el código en vez de sorteada en runtime.
Un poco de contexto sobre qué es "el backend de Transformers" en vLLM: además de sus implementaciones
propias de cada arquitectura, vLLM puede cargar un modelo usando la clase de transformers
directamente, reemplazando solo la atención por la suya (paginada, con su backend de kernels). Es la
vía para modelos que todavía no tienen implementación nativa en vLLM — y es donde más importa que la
descripción de la geometría que viene del config de Transformers se traduzca bien a lo que
los kernels esperan.
El síntoma inicial: el backend nativo de Transformers en vLLM construía cada instancia
de atención con el head count y head size globales del config. Un modelo con geometría
homogénea nunca lo nota. Gemma-4 sí: crash inmediato en el primer forward pass, shape mismatch en
o_proj.
Por qué justo en o_proj: la proyección de salida de la atención recibe la concatenación
de las cabezas (num_heads × head_dim) y la devuelve a hidden_size. Si el
módulo de atención se construyó con un head_dim de 256 pero la capa es de 512, el tensor
que llega a o_proj tiene la mitad de las columnas que su matriz de pesos espera — y ese es
el primer lugar del forward donde la geometría equivocada se vuelve un error de shape en vez de un
número silenciosamente mal.
head_dim».
En Gemma-4 el primer lugar donde eso se vuelve un error de forma es o_proj; los otros dos
bugs producían el número equivocado sin decir nada, que es la clase de fallo más cara de las tres.
🔧 Dos bugs silenciosos que aparecieron al generalizar
El primer fix funcionó, pero hmellor pidió en review que se generalizara — el código
tenía hardcodeados los nombres de atributo específicos de Gemma-4
({"head_dim", "num_key_value_heads"}), una asunción que se rompe con el próximo modelo
heterogéneo. Al generalizarlo aparecieron dos bugs que nadie había visto:
- Un
get_total_num_kv_heads()sin override propio para configs conattention_k_eq_v=True— cualquier variante de Gemma-4 con ese flag hacía queflash_attn.py,triton_attn.pyy los conectores de transferencia de KV leyeran un valor global degradado en vez del máximo real entre capas. Sin crashear — simplemente corriendo con la cabeza KV equivocada. - La detección de layout viejo vs. nuevo del config estaba atada a la presencia de un mixin que
Transformers agrega a todos los configs desde su versión 5.15 — no a si el modelo
realmente usa el layout nuevo. Resultado: cualquier checkpoint publicado de Gemma-4 (que todavía
usa el layout viejo) se enviaba por el camino nuevo, donde silenciosamente devolvía
head_dim=256para capas que necesitaban 512.
Ambos son la misma clase de bug que ya viste en la página de cuantización: no truenan, simplemente producen el número equivocado. La diferencia es que acá el "número equivocado" es la geometría misma del modelo, no un peso cuantizado.
El segundo de los dos merece una regla general: detectar una versión por la presencia de una clase o un atributo es frágil, porque las librerías agregan cosas a todo el mundo sin que eso signifique que todo el mundo las usa. Lo que hay que chequear es la propiedad concreta que importa ("¿este config describe la geometría por capa?"), no un proxy de ella ("¿este config tiene el mixin nuevo?"). Es el mismo error de fondo que "¿es Blackwell? entonces el kernel de Blackwell" del §4.
El mismo trabajo destapó, de yapa, una regresión de performance real: resolver la geometría por capa de la forma ingenua agregaba 8.3 segundos al arranque de un modelo de 48 capas — indexar por tipo de capa copiaba y comparaba cada config de ese tipo en cada llamada. Resuelto indexando por posición en vez de por tipo: 350 milisegundos.
El cierre de la cadena es tan prolijo como el resto: un commit final borra por completo un flag de
compatibilidad temporal (allow_global_per_layer_attribute_access) después de instrumentar
un arranque completo de Gemma-4 y confirmar que quedaba exactamente un lugar que
todavía lo necesitaba — y arreglar ese lugar en vez de mantener el flag indefinidamente.
4. NVFP4 en SM12x: cuando el bug no truena
El tercer caso es el más inquietante de los tres, precisamente porque no hay ningún error en pantalla.
En SM 12.x, el kernel NVFP4 que vLLM auto-selecciona por default
(FlashInferCutlassNvFp4LinearKernel) corre sin quejarse — y produce basura. Medido en un
benchmark real: GSM8K 0.0 con ese kernel, contra 0.8867 con Marlin
(otra implementación de kernel NVFP4), mismo checkpoint, mismo hardware.
Con el §1 a mano, el resultado deja de ser misterioso aunque siga siendo grave. El kernel CUTLASS es el camino W4A4: usa los tensor cores FP4 del chip tal como los expone la arquitectura para la que fue escrito y validado. Marlin es el camino W4A16: desempaqueta a 16 bits y multiplica con la maquinaria de 16 bits que existe igual en Ampere, Ada, Hopper y Blackwell. Cuando el chip de consumo no se comporta exactamente como el de datacenter en el camino FP4, el kernel que no depende de ese camino sigue siendo correcto, y el que sí depende puede fallar — sin lanzar nada, porque desde el punto de vista de CUDA el kernel se lanzó y terminó. Un GEMM que devuelve números equivocados no es un error para el runtime: es un resultado.
🔧 El fix
Excluir ambos kernels basados en CUTLASS (el auto-seleccionado y el nativo — los dos dan mal en SM12x)
de la auto-selección en esa arquitectura, para que la selección automática caiga en Marlin — que sí
es correcto ahí. Un --linear-backend explícito sigue permitiendo forzar cualquiera de
los dos para debug.
Esto es la versión más extrema de una lección que ya apareció dos veces en este curso: un log limpio, o incluso una respuesta que suena coherente, no prueba nada. Acá ni siquiera hace falta un log sospechoso — hace falta correr un eval real para descubrir que el 100% de las respuestas están mal.
Por eso GSM8K es un buen detector de "basura silenciosa": son problemas de aritmética escolar con una respuesta numérica exacta, y la métrica es exact match sobre ese número final. Un modelo que produce texto fluido pero con los pesos multiplicados mal puede seguir generando frases; lo que no puede es llegar al número correcto. Un 0.0 en una tarea donde el mismo checkpoint da 0.88 con otro kernel no es "degradación": es un kernel roto. Y notá el orden de magnitud del costo: correr GSM8K sobre unos cientos de ejemplos lleva minutos; encontrar este bug por síntomas hubiera llevado semanas. El ejercicio 2 del §9 arma ese eval mínimo.
5. Qué hace bueno a un fix aguas arriba
Los tres casos comparten la misma forma, y vale la pena nombrarla:
- Diagnóstico hasta la causa real, no hasta el primer síntoma — el mensaje de FlashInfer, el crash de shape, el score de GSM8K son todos síntomas; el fix ataca lo que hay debajo.
- El cambio mínimo que corrige la causa, sin tocar nada que ese diagnóstico no justifique.
- Un test que reproduce el bug sin depender de tener el hardware exacto a mano (el monkeypatch de FlashInfer corre en cualquier CI).
- Atado a un issue o número de PR real, nunca "arreglado porque sí".
- Abierto a la revisión — el caso de Gemma-4 es mejor código después de que
hmellorpidió generalizarlo, no a pesar de eso.
El circuito de contribución en vLLM, en general
Lo anterior es la forma del contenido. La forma del proceso, según la guía de contribución pública de vLLM, es la misma para cualquier fix — y conviene tenerla en la cabeza antes de escribir la primera línea, porque cada paso condiciona cómo estructurás el cambio:
- Fork y branch por fix. Un branch por problema, con nombre descriptivo — exactamente como los tres de esta página. Mezclar dos fixes en un branch es la forma más segura de que ninguno se mergee.
- Instalación en modo desarrollo (
pip install -e .o el equivalente de la guía) para que los tests corran contra tu código y no contra el paquete instalado. pre-commit. vLLM usa hooks depre-commitpara formato y lint (pre-commit installuna vez; después corren solos en cada commit, o a mano conpre-commit run --all-files). Un PR que falla el lint no llega a que un humano lo mire.- Tests con
pytest. Los tests viven bajotests/, espejando la estructura devllm/. El test nuevo tiene que fallar sin el fix y pasar con él — y, siempre que se pueda, correr sin la GPU que disparó el bug (mocks, monkeypatch, tensores chicos en CPU). - DCO sign-off. vLLM exige el Developer Certificate of Origin: cada commit lleva una línea
Signed-off-by: Nombre <email>, quegit commit -sagrega sola. No es una firma criptográfica — es tu declaración de que tenés derecho a contribuir ese código. Un PR sin sign-off en todos sus commits falla el chequeo automático. - PR con título prefijado e issue enlazado. La guía pide prefijos por área (del estilo
[Bugfix],[Kernel],[Model],[Core]), una descripción que diga qué se rompe, por qué, y cómo se verificó, y un enlace al issue ("Fixes #NNNNN") para que se cierre solo al mergear. - Revisión. Un maintainer va a pedir cambios; la respuesta útil es un commit nuevo por pedido, no un force-push que borre la historia de la discusión.
⚠️ Cuidado
Los detalles del flujo (nombres de hooks, prefijos exactos, qué CI corre en un PR de un fork) cambian
con el tiempo — la fuente de verdad es CONTRIBUTING y la sección "Contributing" de
docs.vllm.ai, no esta
página. Lo que no cambia es la forma: causa real, cambio mínimo, test reproducible, sign-off, issue.
6. Lo que generaliza
Sacando los nombres propios, lo que esta página deja para cualquier proyecto donde toques un motor de inferencia o una librería de kernels:
| Lección | Dónde la viste acá | Cómo se aplica en otro proyecto |
|---|---|---|
| Un mensaje que contradice un hecho conocido es un síntoma, no el error | "requires sm75 or higher" en una GPU sm_120 | Buscá la excepción que fue tragada un paso antes; el estado "vacío" que dejó es lo que dispara el mensaje absurdo. |
| Un fallback tiene que distinguir default de elección explícita | Warning si el sampler de FlashInfer era default; error si el usuario lo forzó | Degradá en silencio solo lo que el usuario no pidió; lo que pidió a propósito, rompelo con la razón real. |
| "Global" es un supuesto, no un hecho | head_dim y num_kv_heads globales vs por capa | Cada vez que un valor se lee del config una sola vez y se usa para todas las capas, preguntate qué modelo lo rompería. |
| Chequeá la propiedad, no el proxy | Detectar el layout nuevo por un mixin que todos los configs tienen | Los tests de "¿tiene esta clase/atributo?" envejecen mal; preguntá por la propiedad que de verdad necesitás. |
| Generalizar destapa bugs que el caso particular escondía | Dos bugs silenciosos aparecidos al quitar los nombres hardcodeados de Gemma-4 | Cuando un review pide generalizar, tratalo como una segunda ronda de debugging, no como burocracia. |
| Un kernel que corre no es un kernel correcto | GSM8K 0.0 con CUTLASS, 0.8867 con Marlin | Toda combinación nueva de kernel + hardware necesita un eval numérico antes de ir a producción; un log limpio no cuenta. |
| Misma generación no es mismo chip | Blackwell datacenter (10.0) vs consumo (12.0) | Condicioná la selección de kernels por compute capability exacta, no por nombre de familia. |
| Los flags de compatibilidad temporales tienen fecha de vencimiento | allow_global_per_layer_attribute_access borrado tras instrumentar un arranque | Medí cuántos usos quedan y arreglalos; un flag "temporal" sin plan de retiro se vuelve permanente. |
7. Resumen
Tres bugs, un mismo origen: hardware de consumo de última generación corriendo una arquitectura de modelo con una geometría que el código existente nunca anticipó. Ninguno de los tres se resolvió "esperando a que NVIDIA o Google lo arreglaran" — se diagnosticaron y se mandaron aguas arriba, con tests, con referencia al issue real, y en el caso más grande, iterando sobre revisión real de un maintainer del proyecto.
- vLLM es un motor + scheduler + KV cache paginado + backends de atención + kernels de cuantización + sampler; cada bug de esta página vive en una pieza distinta de ese mapa (§1).
- Una compute capability (sm_XX) describe el chip; un toolkit de CUDA solo compila para los chips que conocía al salir. Blackwell de consumo es 12.0, no 10.0, y esa diferencia importa a los kernels (§1, §4).
- FlashInfer: el error visible era el síntoma de una excepción tragada antes; el fix recupera la razón real y degrada solo lo que el usuario no pidió (§2).
- Gemma-4 en el backend de Transformers: geometría por capa contra código que asumía geometría global; la generalización pedida en review destapó dos bugs silenciosos y una regresión de arranque de 8.3 s → 350 ms (§3).
- NVFP4 en SM12x: el kernel CUTLASS corre y devuelve basura (GSM8K 0.0); Marlin, que no depende del camino FP4 nativo, da 0.8867. Solo un eval numérico lo hubiera detectado (§4).
- Un fix aguas arriba tiene una forma de contenido (causa real, cambio mínimo, test sin hardware, issue) y una forma de proceso (fork, pre-commit, pytest, DCO, PR enlazado, revisión) (§5).
8. Ponete a prueba
Respondé antes de abrir cada respuesta. Si fallás una, volvé a la sección indicada.
1. ¿Por qué "FlashInfer requires GPUs with sm75 or higher" no puede ser el error real en una RTX 5090, y qué te dice eso sobre dónde buscar?
Porque la RTX 5090 es sm_120, muy por encima de sm75: el mensaje contradice un hecho cierto. Eso indica que el código que lo emite está razonando sobre un estado vacío (un set de arquitecturas sin elementos) que dejó una excepción tragada un paso antes — en check_cuda_arch(), cuando el toolkit de CUDA es anterior a lo que SM 12.x necesita. Hay que buscar la causa aguas arriba del mensaje, no en el mensaje. (§2)
2. El fix del sampler se comporta distinto según VLLM_USE_FLASHINFER_SAMPLER=1 esté o no. ¿Cuál es la lógica?
Si el usuario no pidió FlashInfer, el sampler era un default que ni sabía que existía: degradar a sampling nativo con un warning es lo correcto. Si lo forzó explícitamente, degradar en silencio sería ocultarle que no obtuvo lo que pidió: hay que lanzar el error, y con la razón real (qué versión de CUDA necesita), no el mensaje genérico. (§2)
3. ¿Por qué el primer síntoma de Gemma-4 fue un shape mismatch en o_proj y no en otra capa?
Porque o_proj recibe la concatenación de las cabezas (num_heads × head_dim): si el módulo de atención se construyó con el head_dim global (256) en una capa que usa 512, el tensor que llega tiene la mitad de las columnas que la matriz de o_proj espera. Es el primer punto del forward donde la geometría equivocada deja de ser un número mal y se vuelve un error de forma. (§3)
4. ¿Qué tenía de malo detectar el "layout nuevo" del config por la presencia de un mixin de Transformers?
Que a partir de cierta versión Transformers agrega ese mixin a todos los configs, usen o no el layout nuevo. El chequeo medía un proxy ("¿tiene el mixin?") y no la propiedad ("¿describe la geometría por capa?"), así que los checkpoints publicados de Gemma-4, todavía en layout viejo, iban por el camino nuevo y recibían head_dim=256 donde necesitaban 512 — sin crashear. (§3)
5. Un kernel NVFP4 corre sin errores en la RTX 5090 y el modelo genera texto fluido. ¿Está bien? ¿Cómo lo sabés?
No lo sabés hasta medir. Un GEMM que devuelve números equivocados no es un error para CUDA: el kernel se lanzó y terminó. Un eval con respuesta exacta (GSM8K) lo revela de inmediato: 0.0 con el kernel CUTLASS contra 0.8867 con Marlin, mismo checkpoint. Texto fluido no es evidencia; una métrica numérica sí. (§4)
6. ¿Por qué Marlin es correcto en SM12x donde el kernel CUTLASS no lo es, si los dos "hacen NVFP4"?
Porque son caminos distintos: CUTLASS multiplica FP4 × FP4 en los tensor cores FP4 (W4A4) y depende de que el chip sea exactamente el que el kernel espera; Marlin desempaqueta los pesos a 16 bits dentro del kernel y multiplica con la maquinaria de 16 bits (W4A16), que es la misma en Ampere, Ada, Hopper y las dos Blackwell. Blackwell de consumo (12.0) no es el mismo chip que el de datacenter (10.0), y solo el camino que no depende de esa diferencia sobrevive. (§1, §4)
9. Para practicar
✏️ Ejercicio 1 — Las tres versiones de CUDA y tu compute capability
Antes de pelearte con un kernel, sabé con qué estás peleando. Este script imprime la compute
capability de la GPU, la versión de CUDA con la que se compiló PyTorch, la lista de arquitecturas
para las que ese PyTorch trae kernels, y el toolkit que usaría un kernel JIT (CUDA_HOME
+ nvcc). Corre en cualquier máquina; sin GPU imprime lo que puede y lo dice.
import os, shutil, subprocess, torch
print("torch", torch.__version__, "| CUDA de PyTorch:", torch.version.cuda)
if torch.cuda.is_available():
major, minor = torch.cuda.get_device_capability()
print(f"GPU: {torch.cuda.get_device_name()} capability {major}.{minor} (sm_{major}{minor})")
print("arquitecturas compiladas en este PyTorch:", torch.cuda.get_arch_list())
print("¿sm de esta GPU en la lista?", f"sm_{major}{minor}" in torch.cuda.get_arch_list())
else:
print("sin GPU visible — el resto igual sirve para diagnosticar el toolkit")
cuda_home = os.environ.get("CUDA_HOME") or os.environ.get("CUDA_PATH")
nvcc = shutil.which("nvcc") or (cuda_home and os.path.join(cuda_home, "bin", "nvcc"))
print("CUDA_HOME:", cuda_home, "| nvcc:", nvcc)
if nvcc and os.path.exists(nvcc):
print(subprocess.run([nvcc, "--version"], capture_output=True, text=True).stdout.strip().splitlines()[-1])
torch 2.x.x | CUDA de PyTorch: 12.8 · GPU: … capability 12.0 (sm_120) · ¿sm de esta GPU en la lista? True/False · nvcc: release 12.x / 13.xPista / qué esperar
Tres cosas para mirar. (a) Si sm_120 no está en get_arch_list(), ese PyTorch no trae kernels compilados para tu GPU: puede correr por compatibilidad de PTX o directamente fallar según la operación. (b) Si la versión de nvcc es distinta de torch.version.cuda, cualquier kernel JIT (FlashInfer, extensiones compiladas al vuelo) se compila con un toolkit que no es el de PyTorch — el escenario de CUDA_HOME=/usr/local/cuda-13.1 del §2. (c) Un toolkit anterior a 12.8 no conoce Blackwell: ahí es donde una detección de arquitectura puede tragar el error y dejar un set vacío.
✏️ Ejercicio 2 — Un eval mínimo que detecta basura silenciosa
Reproducí el detector del §4 en chico con lm-evaluation-harness. La idea no es medir
el modelo sino tener un número con respuesta exacta que caiga a cero cuando algo está roto. Primero
corré la línea de base con un modelo chico en CPU (o con vLLM si tenés GPU); guardá el
exact_match. Después, cada vez que cambies kernel, dtype, formato de cuantización o
backend, volvé a correr exactamente el mismo comando y compará.
# pip install lm_eval
# Línea de base en CPU con un modelo chico (tarda unos minutos con --limit 20):
lm_eval --model hf \
--model_args pretrained=Qwen/Qwen2.5-0.5B-Instruct,dtype=float32 \
--tasks gsm8k --num_fewshot 5 --limit 20 --batch_size 4 \
--output_path evals/baseline
# El mismo eval, servido por vLLM (GPU), cambiando solo el backend a comparar:
lm_eval --model vllm \
--model_args pretrained=ruta/al/checkpoint,dtype=auto,gpu_memory_utilization=0.8 \
--tasks gsm8k --num_fewshot 5 --limit 200 --batch_size auto \
--output_path evals/kernel-A
| gsm8k | exact_match,strict-match | 0.xx | … | exact_match,flexible-extract | 0.xx |Pista / qué esperar
Con 20 ejemplos el número es ruidoso — no sirve para decir "A es 2 puntos mejor que B", pero sí para distinguir "funciona" de "0.0". Para un modelo de 0.5B en CPU esperá un valor bajo pero claramente distinto de cero en flexible-extract (que rescata el último número de la respuesta aunque el formato no sea el del few-shot). Si querés simular el bug sin tener un kernel roto: corré el mismo comando con dtype=float16 en una CPU que no lo soporte bien, o con un checkpoint al que le hayas corrompido una matriz a propósito (ejercicio 1 de la página de cuantización te muestra cómo leer los tensores) — un modelo que "habla" pero da 0.0 es exactamente lo que viste en el §4. Si la sintaxis exacta de lm_eval cambió en tu versión, lm_eval --help y lm_eval --tasks list son la fuente de verdad; el flujo (baseline guardada → mismo comando por cada cambio → comparar exact match) es lo que importa.
✏️ Ejercicio 3 — Reproducí un error enmascarado, y arreglalo con un test
Sin GPU ni vLLM: este módulo de juguete tiene la estructura exacta del bug del §2 — una función que
traga la excepción real y deja un set vacío, y otra que interpreta el vacío con un mensaje genérico.
Escribí el fix (recuperar la razón real) y un test con pytest que monkey-parchee la
detección para simular la condición, como hizo tu fix real.
# archs.py
def normalize_arch(major, minor, toolkit=(12, 4)):
if major >= 12 and toolkit < (12, 8):
raise RuntimeError(f"SM {major}.{minor} requires CUDA >= 12.8, got {toolkit}")
return f"sm_{major}{minor}"
def target_archs(major, minor): # la función que "traga" el error
try:
return {normalize_arch(major, minor)}
except RuntimeError:
return set()
def compile_kernel(major, minor):
archs = target_archs(major, minor)
if not archs: # el mensaje absurdo nace acá
raise RuntimeError("requires GPUs with sm75 or higher")
return f"kernel for {archs}"
# test_archs.py
import pytest, archs
def test_sm120_reports_real_reason(monkeypatch):
monkeypatch.setattr(archs, "target_archs", lambda ma, mi: set())
with pytest.raises(RuntimeError, match="requires CUDA"):
archs.compile_kernel(12, 0)
Pista / qué esperar
Tal como está, el test falla: el mensaje es "sm75 or higher". El fix mínimo es que compile_kernel, al encontrar el set vacío, vuelva a llamar a normalize_arch(major, minor) para recuperar la excepción real y la relance — sin tocar target_archs, que es "código de otra librería". Después agregá la segunda mitad del comportamiento del §2: un parámetro explicit=False que, cuando es falso, devuelva None con un warning en lugar de lanzar, y testeá los dos caminos. Fijate que el test no necesita ninguna GPU: simula la condición parcheando la detección, que es exactamente lo que hace que un fix sea verificable en CI.
10. Fuentes y para seguir
- Kwon et al. — Efficient Memory Management for Large Language Model Serving with PagedAttention (arXiv:2309.06180) — el paper de vLLM: por qué el KV cache se pagina, cuánta memoria se desperdiciaba antes, y el mapa del §1 explicado por sus autores.
- Dao — FlashAttention-2: Faster Attention with Better Parallelism and Work Partitioning (arXiv:2307.08691) — el backend de atención por default en la mayoría de las GPUs: qué significa "fusionado" y "IO-aware", y por qué un kernel de atención tiene restricciones de
head_dim. - Ye et al. — FlashInfer: Efficient and Customizable Attention Engine for LLM Inference Serving (arXiv:2501.01005) — la librería del §2: atención sobre KV paginado, kernels de sampling y compilación JIT; leelo para entender por qué depende tanto del toolkit instalado.
- Frantar et al. — MARLIN: Mixed-Precision Auto-Regressive Parallel Inference on Large Language Models (arXiv:2408.11743) — el kernel W4A16 del §4: cómo desempaqueta pesos de 4 bits dentro del kernel y por qué eso lo hace independiente del hardware FP4.
- vLLM — documentación — buscá "Contributing" (pre-commit, tests, DCO, formato de PR) y "Attention backends" / "Quantization"; es la fuente de verdad para el proceso del §5.
- NVIDIA CUDA C++ Programming Guide — Compute Capabilities — la tabla oficial de qué tiene cada capability (shared memory, tensor cores, dtypes); la referencia para la tabla del §1.
- NVIDIA — CUDA GPUs: compute capability por modelo — para confirmar el número de cualquier GPU antes de asumir en qué familia cae.