Curso / Aplicado a tus proyectos / Fixes en vLLM
🔧 basado en tu código real · 4 de 5

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

Hardware: RTX 5090 (Blackwell, sm_120) 3 fixes propios en tu fork de vLLM Uno con revisión real de un maintainer (hmellor)

🎯 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 t de la secuencia s, con tamaño de bloque Bs (ej. 16 tokens), cantidad de cabezas KV Hkv, dimensión de cabeza Dhead y bytes por elemento b (ej. 1 byte en FP8):

Block_ID=tBs,Offset=tmodBs

La dirección física exacta en la memoria global (HBM/VRAM) para la cabeza h del vector de clave o valor se calcula resolviendo la tabla de páginas lógica a física:

Addr(t,h)=Base+Tables[Block_ID]×(BsHkvDheadb)+Offset×(HkvDheadb)+h×(Dheadb)

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:

BackendQué esCuá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.
FlashInferUna 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.

CapabilityArquitecturaGPUs típicas
7.5TuringRTX 20xx, T4
8.0 / 8.6AmpereA100 / RTX 30xx
8.9Ada LovelaceRTX 40xx, L4, L40
9.0HopperH100, H200
10.0Blackwell (datacenter)B200, GB200
12.0Blackwell (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.

El mapa de vLLM y en qué pieza vive cada uno de los tres bugs Una request entra al motor, el scheduler decide qué secuencias forman el batch, el KV cache paginado guarda K y V en bloques de tamaño fijo, el forward del modelo usa un backend de atención y kernels de cuantización, y el sampler convierte logits en un token. El bug de FlashInfer vive en el sampler y en la detección de arquitectura; el de Gemma-4 en cómo la geometría por capa llega al backend de atención; el de NVFP4 en el selector de kernels de cuantización en SM 12.x. motor vllm serve · API tokeniza, coordina scheduler qué secuencias entran al batch en cada paso KV cache paginado bloques de 16 tokens tabla lógico → físico forward del modelo backend de atención FlashAttention · FlashInfer · Triton kernels de cuantización CUTLASS (W4A4) · Marlin (W4A16) sampler logits → token nativo o FlashInfer §3 · geometría por capa de Gemma-4 head_dim 256 / 512 según la capa, y el código leía un solo valor global del config → crash en o_proj, y 2 bugs silenciosos §4 · NVFP4 en SM 12.x el selector elegía el kernel CUTLASS, que corre y devuelve basura: GSM8K 0,0 → fix: excluirlo y caer en Marlin (0,89) §2 · sampler de FlashInfer la detección de arquitectura tragaba el error real (CUDA < 12.9 sobre SM 12.x) y salía «sm75 or higher» → fix: recuperar la razón, degradar solo el default
El mismo plano, con otros nombres, que tiene cualquier servidor de inferencia. Los tres fixes tocan tres piezas distintas, pero rompen el mismo tipo de supuesto: «todas las capas son iguales», «si el kernel corre, funciona», «si es Blackwell, el kernel de Blackwell es el correcto».

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.

Un error enmascarado y su fix: recuperar la razón real antes de decidir Antes: la detección de arquitectura de FlashInfer lanza la excepción real, SM 12.x requiere CUDA 12.9 o posterior, pero la función la traga y deja un set de arquitecturas vacío; al compilar un kernel JIT, ese vacío dispara el mensaje genérico sm75 or higher, absurdo en una RTX 5090. Con el fix: el gate del sampler llama a la detección, si falla recupera la razón real con normalize cuda arch, y entonces degrada a sampling nativo con un warning si FlashInfer era el default, o lanza el error real si el usuario lo forzó. ANTES · EL SÍNTOMA check_cuda_arch() toolkit < 12.9 sobre SM 12.x → lanza «SM 12.x requires CUDA ≥ 12.9» se traga la excepción archs = set() un estado «vacío» sigue de largo compilar kernel JIT interpreta el set vacío con su propia lógica «requires GPUs with sm75 or higher» absurdo en una sm_120: pista clave CON EL FIX · EN EL GATE DEL SAMPLER check_cuda_arch() llamada directa, antes de activar el sampler de FlashInfer falla _normalize_cuda_arch(12, 0) recupera la razón real: «SM 12.x requires CUDA ≥ 12.9» FlashInfer era el default: warning + fallback sampling nativo de vLLM, el motor arranca VLLM_USE_FLASHINFER_SAMPLER=1: error real pidió FlashInfer a propósito: sabe qué actualizar la regla: si un mensaje contradice un hecho cierto, preguntá qué tuvo que fallar antes para llegar acá con este estado test: monkeypatch de las dos funciones, sin GPU SM 12.x · issue #42393
El mensaje que se ve no es el error: es lo que otro código dice cuando le llega un estado vacío. El fix va un paso atrás, recupera la razón verdadera, y recién ahí decide, con una asimetría deliberada: degradar en silencio lo que el usuario no pidió, romper con la razón real lo que pidió a propósito.

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.

Geometría de atención heterogénea: por qué un valor global de head_dim rompe en o_proj Gemma-4 alterna capas de ventana deslizante, con head dim 256 y 8 cabezas KV, y capas de atención completa, con head dim 512 y una sola cabeza KV compartida. El backend construía cada atención con el head dim global del config, 256. En una capa completa, la salida concatenada de las cabezas tiene la mitad de las columnas que la matriz de o proj espera, y el forward falla con un shape mismatch. Los dos bugs que aparecieron al generalizar no fallan: devuelven la geometría equivocada en silencio. las capas de Gemma-4, por tipo ventana · 256 × 8 KV ventana · 256 × 8 KV completa · 512 × 1 KV ventana · 256 × 8 KV ventana · 256 × 8 KV completa · 512 × 1 KV el config describe la geometría por capa; el código leía un valor global: head_dim = 256 para todas dentro de una capa completa, construida con el valor global atención head_dim = 256 (global) ✗ debería ser 512 en esta capa […, h × 256] salida de las cabezas o_proj espera [hidden, h × 512] shape mismatch: crash el fix, generalizado a pedido del maintainer, destapó dos bugs que no crashean · get_total_num_kv_heads() devolvía un valor global degradado con attention_k_eq_v=True · el layout nuevo se detectaba por un mixin que Transformers agrega a todos los configs y una regresión de arranque resolver la geometría por capa indexando por tipo: 8,3 s de arranque en 48 capas · indexando por posición: 350 ms un flag de compatibilidad temporal quedó con un solo uso; se arregló ese uso y se borró el flag
Un supuesto que ningún modelo homogéneo rompe: «todas las capas tienen el mismo 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 con attention_k_eq_v=True — cualquier variante de Gemma-4 con ese flag hacía que flash_attn.py, triton_attn.py y 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=256 para 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:

  1. 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.
  2. El cambio mínimo que corrige la causa, sin tocar nada que ese diagnóstico no justifique.
  3. Un test que reproduce el bug sin depender de tener el hardware exacto a mano (el monkeypatch de FlashInfer corre en cualquier CI).
  4. Atado a un issue o número de PR real, nunca "arreglado porque sí".
  5. Abierto a la revisión — el caso de Gemma-4 es mejor código después de que hmellor pidió 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:

  1. 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.
  2. 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.
  3. pre-commit. vLLM usa hooks de pre-commit para formato y lint (pre-commit install una vez; después corren solos en cada commit, o a mano con pre-commit run --all-files). Un PR que falla el lint no llega a que un humano lo mire.
  4. Tests con pytest. Los tests viven bajo tests/, espejando la estructura de vllm/. 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).
  5. DCO sign-off. vLLM exige el Developer Certificate of Origin: cada commit lleva una línea Signed-off-by: Nombre <email>, que git commit -s agrega 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.
  6. 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.
  7. 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ónDó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_120Buscá 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ícitaWarning 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 hechohead_dim y num_kv_heads globales vs por capaCada 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 proxyDetectar el layout nuevo por un mixin que todos los configs tienenLos 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íaDos bugs silenciosos aparecidos al quitar los nombres hardcodeados de Gemma-4Cuando un review pide generalizar, tratalo como una segunda ronda de debugging, no como burocracia.
Un kernel que corre no es un kernel correctoGSM8K 0.0 con CUTLASS, 0.8867 con MarlinToda 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 chipBlackwell 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 vencimientoallow_global_per_layer_attribute_access borrado tras instrumentar un arranqueMedí 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.

  1. 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).
  2. 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).
  3. 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).
  4. 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).
  5. 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).
  6. 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.x
Pista / 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