Esta semana empezamos a pasar nuestros Qwen3.6-35B-A3B de FP8 a NVFP4. Suena a un cambio de configuración. No lo fue.
Acabó en un core dump de la GPU, una sesión de cuda-gdb, y un bug que resultó vivir en cuDNN, no en vLLM. Pero el lío ha merecido la pena porque ahora el modelo sirve más rápido y con más margen que el build FP8 del que veníamos, en exactamente el mismo hardware. Aquí está todo, de principio a fin.
De dónde partíamos: FP8
Nuestra flota de Qwen3.6 corre un modelo por GPU. La línea base, y lo que servía cada nodo salvo el canary, es el checkpoint FP8 sobre vLLM 0.21 con los kernels de Triton. Este es nuestro modelo mas demandado y con esta configuración, hasta hoy, siempre nos ha ido muy bien.
Este es el número de referencia para el resto del post ya que es el test de carga que utilizamos para medir la concurrencia y escalabilidad del modelo: 32 clientes concurrentes, ~81k tokens de entrada, 256 de salida (vllm bench serve, aleatorio, --ignore-eos).
| Config | Total tok/s | TPOT | E2EL | KV pico | Estable |
|---|---|---|---|---|---|
| FP8-Triton (base) | 12 820 | 602 ms | 202 s | 55% | Sí |
12,8k tokens/s agregados, ~600 ms por token de salida, la KV cache tocando techo al 55% bajo esa carga. Esa es la vara que NVFP4 tenía que superar para que mereciera la pena el lío.
Por qué molestarse con NVFP4
El checkpoint NVFP4 de Qwen3.6-35B de NVIDIA ModelOpt es un build de precisión mixta: los expertos MoE son de 4 bits (W4A16_NVFP4), con FP8 en el resto y una KV cache en FP8. Expertos de 4 bits significan menos VRAM gastada en pesos, lo que significa más VRAM libre para la KV cache, lo que significa más peticiones concurrentes en la misma tarjeta. Además, en Blackwell el formato más estrecho debería decodificar más rápido.
Así que la promesa era: más throughput, más concurrencia, la misma GPU. Tocaba ponerse manos a la obra a probar el cambio.
El canary se cayó
Pasamos un nodo (llamémoslo canary) al checkpoint NVFP4 sobre vLLM 0.25.1 y le pasamos un pequeño test de carga. Todo parecía correcto así que le volvimos a meter en el cluster para que empiece a recibir tráfico real. Empezó a morir:
CUDA error: an illegal memory access was encountered
CUDA error: misaligned address (cudaErrorMisalignedAddress) Aproximadamente una vez por hora en producción. Cada caída mataba el EngineCore de vLLM, un watchdog externo que tenemos vigilando el proceso lo reiniciaba, y cada petición en vuelo en esa ventana se perdía. No es seguro para producción. Bloqueaba todo el rollout de la flota.
La caída tenía una huella clara:
- Solo con los CUDA graphs activos.
--enforce-eageraguantó limpio un soak de 40 minutos. - Solo con tensor-parallel 1 en esta tarjeta Blackwell.
- Estocástico. Batches pequeños y normales, sin correlación con el tamaño del prompt, más o menos una caída por cada decenas de miles de forward passes.
Persiguiendo el kernel equivocado
Antes de ir a fondo, barrimos las palancas obvias en el nodo canary el cual sacamos del trafico para poder hacer pruebas con él. Simplemente este es un resumen de las configs que probamos:
| Config | Total tok/s | TPOT | E2EL | KV pico | Estable |
|---|---|---|---|---|---|
| FP8-Triton | 12 820 | 602 ms | 202 s | 55% | Sí |
| NVFP4-Marlin + cudagraphs (E8M0 on) | 14 306 | 549 ms | 181 s | 43% | No · crash ~4 min |
| NVFP4-Marlin + enforce-eager | 11 414 | 760 ms | 213 s | 42% | Sí, pero lento |
| NVFP4-b12x + enforce-eager | 6 181 | 973 ms | 303 s | 73% | Sí, muy lento |
| NVFP4-Marlin + cudagraphs + E8M0 off | 14 273 | 530 ms | 188 s | 43% | Parecía estable |
Dos cosas saltan a la vista. NVFP4 con CUDA graphs es genuinamente más rápido que FP8 (~+11% tok/s, menor TPOT). Y la respuesta "segura", --enforce-eager, tira ese beneficio a la basura y acaba más lento que FP8. El backend MoE b12x no es una opción a esta escala.
La última configuración nos engaño. Desactivar el path de escalas DeepGEMM E8M0 que vLLM trae integrado (VLLM_USE_DEEP_GEMM_E8M0=0) hizo la caída lo bastante rara como para que un test de carga de diez minutos corriera limpio. Parecía el fix. No lo era: de vuelta en producción, esa misma config seguía cayéndose, solo que menos a menudo. Que un test de carga corto salga en verde no es lo mismo que ser estable, y este es justo el tipo de bug que te castiga por creerlo.
El core dump
Cada traceback de Python apuntaba al kernel MoE de Marlin (fused_marlin_moe). Una lectura cuidadosa del código de vLLM incluso produjo una escritura plausible que se pasaba una posición del final de los buffers de barrera Stream-K de Marlin, con un parche candidato de una línea. Estaba equivocada.
La clave es que los errores de CUDA son asíncronos: para cuando el host ve "illegal memory access", el kernel que realmente falló hace rato que se fue y el error aflora en la siguiente sincronización de stream, donde sea que caiga. El traceback de Marlin era solo donde llegó la factura, no donde se gastó el dinero. Necesitábamos pillar al kernel culpable con las manos en la masa.
Así que eso hicimos. Compilamos vLLM 0.25.1 desde fuente con -lineinfo, lo corrimos con los core dumps de GPU activados, reprodujimos la caída bajo carga, y abrimos el dump en cuda-gdb:
CUDA_ENABLE_COREDUMP_ON_EXCEPTION=1 CUDA_COREDUMP_FILE=/tmp/cuda_coredump_%h.%p.%t
# ... reproducir, luego:
cuda-gdb -batch -ex "target cudacore /tmp/cuda_coredump_..." -ex "bt" El dump nombró al kernel culpable, y no era Marlin:
CUDA Exception: Warp Out-of-range Address
Kernel: cudnn_generated_fort_native_sm120_matMul_pointwise_pointwise_knob_3_128x128x128
Faulting instruction: STS.128 [R6+0xe910], R92 STS.128 es un store de 128 bits a memoria compartida, en el offset ~0xe910 (unos 58 KB), más allá de la memoria compartida asignada al bloque. El offset es una constante de tiempo de compilación y la memoria compartida es por bloque, así que esto no es corrupción metida desde otro sitio. El kernel escribe fuera de límites sobre sí mismo. Y es un GEMM del motor de fusión en runtime de cuDNN, no nada de vLLM.
Causa raíz
Aquí está por qué toda la huella cobra sentido.
La GPU Blackwell workstation (sm120, nuestra RTX PRO 6000) tiene unos **99 KB de memoria compartida por bloque**. La Blackwell de datacenter (sm100) tiene unos 228 KB. El motor de fusión de cuDNN elige un "knob" (el tamaño de tile con el que trocea la GEMM) usando una heurística pensada para la GPU grande, la de datacenter. En sm_120, con menos memoria por bloque, el epílogo de ese tile escribe más allá de lo que el bloque tiene reservado.
- TP=1 produce las formas de GEMM que seleccionan el knob malo. Los shards de TP=4 producen formas distintas y lo esquivan.
- Los CUDA graphs capturan esa única configuración de lanzamiento mala y la reproducen tal cual, así que falla de forma determinista en esa forma. El modo eager reselecciona en cada llamada, así que normalmente cae en algún sitio inocuo. Por eso enforce-eager "funcionaba" y por eso nunca fue realmente seguro.
- Estocástico porque que el store fuera de límites pegue en una asignación viva depende del layout de memoria en ese momento.
Es un bug conocido y arreglado. Las release notes de cuDNN 9.23.1 lo dicen claro: "se ha corregido un problema por el que la multiplicación de matrices con fusiones de mainloop podía producir resultados incorrectos en ... sm120 y sm121". torch 2.11.0+cu130 trae cuDNN 9.19.0.56, anterior al fix. Hay un bug de store en memoria compartida en sm_120 estructuralmente idéntico reportado también contra llama.cpp, así que como en casi todo en tech, no eramos los únicos que lo pillamos.
El fix es una línea
cuDNN 9.x es compatible hacia adelante a nivel de ABI, así que no hace falta recompilar torch ni vLLM. Solo instalas un cuDNN más nuevo en el entorno de serving:
pip install -U "nvidia-cudnn-cu13>=9.23.1" # nosotros usamos 9.24.0.43
python -c "import torch; print(torch.backends.cudnn.version())" # -> 92400 Eso es todo. Sin --enforce-eager, sin parche de Marlin, sin cirugía de CUDA graphs. Con cuDNN 9.24 y la config exacta que antes se caía en cada test de carga a los ~23 minutos, el nodo aguantó un soak de ~50 minutos con cero caídas, y luego volvió a producción y ahí sigue.
Publicamos toda la evidencia del core dump y este fix upstream en vllm-project/vllm#35566 , porque mucha gente estaba (con razón) culpando a vLLM de lo que en realidad es un bug de cuDNN de NVIDIA que ya está arreglado en una release posterior.
La config que funciona
Para quien esté haciendo lo mismo, esta es la config de serving estable y rápida en una sola RTX PRO 6000 (sm_120), TP=1, contexto de 262k:
python -m vllm.entrypoints.openai.api_server \
--model /models/qwen3.6-35b-a3b-nvfp4 \
--gpu-memory-utilization 0.95 \
--max-model-len 262144 \
--max-num-seqs 128 \
--enable-chunked-prefill --max-num-batched-tokens 32768 \
--enable-prefix-caching \
--kv-cache-dtype fp8 # NO uses KV en nvfp4, se cae aparte en sm_120
# Backend MoE: Marlin (auto), CUDA graphs ON (no fuerces enforce-eager)
# entorno:
# VLLM_USE_DEEP_GEMM_E8M0=0 # evita un corruptor de formato de escalas aparte
# nvidia-cudnn-cu13 >= 9.23.1 # el fix de verdad Las dos cosas que más importan: cuDNN >= 9.23.1 (el fix), y mantener la KV cache en FP8, porque la KV en NVFP4 tiene su propia caída aparte en sm_120.
NVFP4 vs FP8, la recompensa
Volvamos a los números, el mismo test de carga (32 concurrentes, 81k tokens de entrada / 256 de salida), ahora comparando la base FP8 contra la config NVFP4 estable:
| Métrica | FP8-Triton | NVFP4 (arreglado) | Delta |
|---|---|---|---|
| Throughput total | 12 820 tok/s | 14 273 tok/s | +11,3% |
| Tiempo por token de salida (TPOT) | 602 ms | 530 ms | -12% |
| Latencia extremo a extremo (E2EL) | 202 s | 188 s | -7% |
| Pico de KV cache | 55% | 43% | ~12 pts menos |
La misma tarjeta de 96 GB, la misma concurrencia de 32, los mismos prompts de 81k tokens. NVFP4 decodifica más rápido (el menor TPOT es lo importante para un agente de código que manda muchos turnos pequeños), empuja ~11% más de throughput agregado, y, como los expertos de 4 bits liberan VRAM, la KV cache toca techo 12 puntos más abajo. Ese margen de KV es margen real de concurrencia: sitio para pasar de 32 clientes antes de que la cache se sature.
Una nota sobre la latencia al primer token: con prompts de 81k tokens, el TTFT está dominado por el prefill y cae en las decenas de segundos independientemente del formato, ya que el cómputo de prefill es similar. Para prompts de tamaño normal de la comunidad, el primer token llega en torno a un segundo. La ganancia de NVFP4 está en el throughput de decode y la memoria, que es justo lo que queremos en un nodo que va a servir inferencia a escala.
Lo que enviamos
El canary está de vuelta en producción con NVFP4, y el rollout al resto de nodos Qwen es un simple bump de cuDNN por nodo. Por el camino también le enseñamos a nuestro watchdog a detectar un atasco silencioso del engine (la API respondiendo 200 mientras el engine no avanza nada), al que el viejo sondeo de "¿puedo hacer GET /v1/models?" era ciego, pero esa es una historia para otro post.
La lección que nos llevamos: un error asíncrono de CUDA te va a mentir sobre de dónde vino, un test de carga verde de diez minutos te va a mentir sobre la estabilidad, y lo único que no mintió fue el core dump. Cuando un bug de GPU es estocástico y apunta a todos lados, deja de leer tracebacks y pilla la instrucción que falla directamente.
Más modelos abiertos, sobre hardware que de verdad entendemos. ¡Vamos! 🚀