Curso / Aplicado a tus proyectos / NVFP4 en difusión
🔧 basado en tu código real · 5 de 5

Aplicado a tus proyectos · Flux2 + LightX2V

NVFP4 en difusión: la misma cuantización, un motor completamente distinto

Ya viste NVFP4 en un decoder autoregresivo (Gemma-4). Acá es el mismo formato de 4 bits, integrado a mano en dos arquitecturas de difusión — una que genera una imagen, otra que genera video — con problemas que nunca aparecen en un LLM de texto.

Antes de leerNVFP4 · Gemma 4 §2, qué es NVFP4 · fast.ai P2 U5, Stable Diffusion

Flux2: diffusion transformer texto→imagen (BFL) Wan 2.1: image-to-video, destilado a 4 pasos Hardware: RTX 5090 (Blackwell, sm_120)

🎯 Al terminar vas a poder

  • Explicar qué es un diffusion transformer (DiT) frente a la U-Net de Stable Diffusion, y por qué las mismas matrices lineales que cuantizás en un LLM son las que se cuantizan acá.
  • Leer una llamada a torch._scaled_mm con pesos float4_e2m1fn_x2 y decir qué es cada tensor, qué escala aplica a qué y por qué la activación se cuantiza en runtime.
  • Explicar por qué un LoRA no se puede "sumar" a pesos en 4 bits y qué significa fusionarlo en el kernel.
  • Distinguir destilación de pasos, classifier-free guidance, offload por bloque e interpolación de frames — cuatro palancas distintas de velocidad y memoria — y decir cuál toca precisión y cuál no.
  • Simular la cuantización E2M1 con escala por bloque en PyTorch puro y medir cuánto cambia el error según cómo elijas la escala.

0. Misma técnica, arquitectura completamente distinta

En la página de Gemma-4, cuantizar a NVFP4 fue casi un one-liner: un flag (--qformat nvfp4_mse) que le pasás a una herramienta de NVIDIA, y vLLM se encarga del resto en runtime (--quantization modelopt_fp4). Acá no hay vLLM ni ModelOpt de por medio — es torch puro, invocando el kernel de matmul cuantizado de Blackwell directamente. Ver esta versión "a mano" es lo que te muestra qué es lo que vLLM te estaba escondiendo cuando usás el flag fácil.

Conviene tener presente desde el principio qué cambia y qué no respecto de las dos páginas anteriores. No cambia el formato: E2M1 en bloques de 16 con escala FP8, los mismos 16 valores representables, la misma cuenta de 4,5 bits efectivos por peso. Cambia todo lo que rodea al formato: quién carga los pesos, quién cuantiza la activación, quién elige el kernel, quién decide qué capas se salvan, y — lo más nuevo — un modelo que no genera un token por pasada sino que refina un tensor entero, muchas veces, antes de entregar algo.

1. Un diffusion transformer, en una frase

Un LLM decoder-only genera una vez: calcula la probabilidad del siguiente token y listo, un forward pass por token. Un modelo de difusión genera iterando: arranca de ruido puro y, en varias pasadas (típicamente 20-50, a veces mucho menos — sección 4), predice cuánto ruido hay que restarle a la imagen actual para acercarla un poco más al resultado final. Flux2 usa bloques transformer para esa predicción — de ahí "diffusion transformer" — pero no hay nada autoregresivo ni causal en juego: no genera token por token, refina la imagen entera en cada paso.

Para lo que importa en esta página, el detalle que sí es igual a un LLM: por dentro, la mayoría del cómputo son las mismas operaciones — proyecciones lineales grandes (attn.to_q/k/v, capas de feed-forward) — y son exactamente esas matrices las que se cuantizan a NVFP4.

📐 Formulación matemática de Flow Matching (Lipman et al., 2022)

A diferencia de los modelos DDPM que integran una ecuación diferencial estocástica con difusión Browniana curva, Flow Matching define una interpolación lineal directa entre la densidad de ruido Gaussiano p0=N(0,I) y la distribución latente de los datos p1=q(x1):

xt=(1-t)x0+tx1,t∈[0,1]

El campo vectorial marginal condicional es analíticamente constante a lo largo de la trayectoria:

ut(xt∣x0,x1)=dxtdt=x1-x0

La red neuronal vθ(xt,t) aprende a predecir esta velocidad mediante regresión cuadrática directa:

LCFM(θ)=Et∼U[0,1],x0∼p0,x1∼p1∥vθ(xt,t)-(x1-x0)∥22

Al eliminar la curvatura Browniana estocástica, las líneas de flujo son rectas casi paralelas en el espacio latente. Esto permite avanzar con solucionadores numéricos de orden superior en apenas 4 pasos de evaluación funcional (como en Wan 2.1 destilado).

De la U-Net al DiT

Si hiciste la Parte 2 de fast.ai, el modelo de difusión que tenés en la cabeza es el de Stable Diffusion: una U-Net, una red convolucional en forma de U que baja la resolución del latente en la mitad izquierda, la vuelve a subir en la derecha, y conecta ambas mitades con skip connections — con bloques de atención intercalados en las resoluciones más bajas. Es una arquitectura heredada de segmentación de imágenes, y funciona muy bien, pero tiene un problema práctico: los sesgos convolucionales y la estructura de resoluciones hacen que no escale tan limpiamente como un transformer cuando le das más cómputo.

El Diffusion Transformer (DiT, Peebles & Xie, 2022) reemplaza la U-Net por lo mismo que ya conocés del Capítulo 1: el latente se corta en parches (como ViT hace con una imagen), cada parche es un "token", y una pila de bloques transformer — atención + feed-forward — procesa la secuencia de parches. El condicionamiento (el paso de difusión t y, en los modelos texto→imagen, el texto) entra modulando cada bloque. El hallazgo del paper es que la calidad escala de forma predecible con los GFLOPs del transformer, igual que en LLMs — y eso es lo que llevó a que Flux, Wan y casi todos los modelos generativos recientes sean DiTs y no U-Nets.

U-Net (Stable Diffusion 1.x/2.x)DiT (Flux, Wan)
Bloque básicoConvolución + ResNet, atención en pocas capasAtención + feed-forward en todas las capas
Cómo ve la imagenComo grilla a varias resolucionesComo secuencia de parches, una sola resolución interna
Dónde está el cómputoRepartido entre convoluciones y atenciónCasi todo en matrices lineales grandes — lo que hace que NVFP4 rinda tanto acá
Qué cambia al cuantizarMuchas capas chicas y heterogéneasPocas capas grandes, repetidas N veces: mismo trabajo que en un LLM

📐 Patchification y modulación AdaLN-Zero en Diffusion Transformers

Dado un tensor latente Z∈RC×H×W con tamaño de parche p×p, la cuadrícula se proyecta a una secuencia de N=HWp2 vectores en RD mediante proyección lineal:

H0=Patchify(Z)Wpatch+Epos,Wpatch∈R(p2C)×D

En cada bloque DiT, el condicionamiento temporal t y textual y se inyecta modulando la normalización de capas según el esquema AdaLN-Zero:

h′=x+α1⊙Attention(1+γ1)⊙LayerNorm(x)+β1xout=h′+α2⊙FFN(1+γ2)⊙LayerNorm(h′)+β2

Donde (γ1,β1,α1,γ2,β2,α2)=MLP(e(t)+y). La inicialización a cero α1=0,α2=0 asegura que al paso 0 cada bloque sea una función identidad pura xout=x, garantizando estabilidad numérica total en pesos de 4 bits.

U-Net contra diffusion transformer: dónde vive el cómputo en cada uno A la izquierda, la U-Net de Stable Diffusion: bloques convolucionales que bajan la resolución del latente y la vuelven a subir, con conexiones de salto entre mitades y atención solo en las resoluciones bajas. A la derecha, el DiT de Flux y Wan: el latente se corta en parches que se tratan como tokens, y una pila de bloques transformer idénticos, atención más feed-forward modulados por el paso t y el texto, procesa la secuencia; casi todo el cómputo son matrices lineales grandes repetidas, las mismas que se cuantizan en un LLM. U-Net · Stable Diffusion 1.x / 2.x convoluciones a varias resoluciones, atención solo abajo 64×64 32×32 16×16 8×8 + attn 16×16 32×32 64×64 skip connections muchas capas chicas y heterogéneas · el cómputo se reparte entre convoluciones y atención · hereda de segmentación DiT · Flux, Wan parches del latente como tokens, N bloques transformer iguales latente → parches tokens bloque 1 atención + feed-forward bloque 2 atención + feed-forward ⋮ bloque N atención + feed-forward t, texto modulan cada bloque parches → ruido predicho casi todo el cómputo son matrices lineales grandes, repetidas N veces por paso: el mismo trabajo que en un LLM, por eso NVFP4 rinde tanto 20–50 pasadas por imagen, o 4 si el checkpoint está destilado
Dos formas de predecir el ruido. La U-Net ve el latente como una grilla a varias resoluciones; el DiT lo ve como una secuencia de parches y le aplica el transformer del capítulo 1 de HF, con el paso y el texto entrando como modulación. Lo que se cuantiza a NVFP4 son exactamente esas matrices lineales.

Flux agrega un detalle de diseño que explica los nombres que vas a ver en el §2: sus primeros bloques son de doble flujo (double_blocks) — el texto y la imagen viajan por dos pilas separadas que solo se mezclan en la atención — y después vienen bloques de flujo único donde texto e imagen ya son una sola secuencia. Cuando el checkpoint dice img_attn.qkv, está nombrando la proyección Q/K/V del lado imagen de uno de esos bloques dobles.

Lo que este cambio de arquitectura le hace a la inferencia es lo que importa para el resto de la página: si el DiT es "un transformer que corre 20-50 veces sobre la misma secuencia", entonces el costo se multiplica por los pasos, la memoria se domina por los pesos del transformer (no por un KV cache que crece), y cada palanca de velocidad tiene que actuar o sobre el costo de cada pasada (cuantización, kernels de atención) o sobre la cantidad de pasadas (destilación, §4).

2. NVFP4 sin vLLM: torch._scaled_mm a mano

blackwell_utils.py define BlackwellLinear, un reemplazo a medida para nn.Linear que hace exactamente lo que ModelOpt+vLLM hacen por vos en la página anterior, pero visible línea por línea:

# Pesos: (out, in // 2) uint8 — dos valores FP4 empaquetados por byte
self.register_buffer("qweight", torch.empty((out_features, in_features // 2), dtype=torch.uint8))
# Escala: un float8_e4m3fn cada 16 valores — mismo esquema de bloque que Gemma-4
self.register_buffer("weight_scale", torch.empty((out_features, in_features // 16), dtype=torch.float8_e4m3fn))

Hacé la cuenta con esas dos formas, porque es la misma que la de la página de Gemma-4 vista desde el otro lado: para una matriz de out × in, los pesos ocupan out × in / 2 bytes (4 bits cada uno) y las escalas out × in / 16 bytes (una por bloque de 16, a lo largo de la dimensión de entrada). Total: out × in × (0,5 + 0,0625) bytes = 4,5 bits por peso, contra 16 en BF16. Las escalas son el 11 % del tamaño — no son gratis, pero son lo que hace que 4 bits alcancen.

En el forward, la activación se cuantiza dinámicamente a FP4 (a diferencia de los pesos, que ya vienen cuantizados desde el checkpoint), y el matmul cuantizado corre directo en hardware:

res = torch._scaled_mm(
    qact.view(torch.float4_e2m1fn_x2),
    self.qweight.view(torch.float4_e2m1fn_x2).t(),
    scale_a=act_scale,
    scale_b=self.weight_scale,
    out_dtype=torch.bfloat16,
)

float4_e2m1fn_x2 es exactamente el E2M1 de la página de Gemma-4 (1 signo, 2 exponente, 1 mantisa), con el "x2" del nombre del dtype indicando que cada elemento de PyTorch empaqueta dos valores FP4 en un byte — la razón por la que el tensor de pesos declara la mitad de columnas (in_features // 2). torch._scaled_mm es la función que sabe desempaquetar eso y multiplicar en el tensor core de Blackwell sin pasar por una conversión intermedia a BF16.

Leé la llamada como la ecuación que es: out = (A · sa) × (B · sb)ᵀ, donde A es la activación en FP4, B los pesos en FP4, y las escalas se aplican por bloque dentro del kernel. Dos cosas que la llamada deja explícitas y que vLLM esconde: (1) las escalas viajan como tensores aparte, no dentro de los pesos — el kernel las multiplica sobre la marcha; (2) out_dtype es BF16: el resultado de acumular productos de 4 bits se devuelve en 16 bits, porque el siguiente paso (normalización, residual, la próxima capa) no sabe ni quiere saber de FP4. Cada capa cuantizada es una isla de 4 bits con costas en BF16.

BlackwellLinear: qué tensores entran a torch._scaled_mm y en qué precisión Los pesos viven en dos buffers: qweight, de out por in medio en uint8 con dos valores FP4 por byte, y weight scale, de out por in dieciseisavo en float8 e4m3, una escala por bloque de 16. La activación llega en bfloat16 y se cuantiza en cada forward a FP4 con sus propias escalas por bloque. torch scaled mm multiplica FP4 por FP4 en el tensor core aplicando las escalas y devuelve bfloat16, que es lo que espera la siguiente capa. Total: 4,5 bits por peso. OFFLINE · EN EL CHECKPOINT qweight · uint8 (out, in // 2): 2 valores FP4 por byte 0,5 byte por peso weight_scale · float8_e4m3 (out, in // 16): una por bloque de 16 0,0625 byte por peso · el 11 % del total 4 + 0,5 = 4,5 bits por peso, contra 16 en BF16 EN CADA FORWARD x · bf16 la activación cuantizar en runtime qact FP4 + act_scale por bloque distinta en cada paso de denoising: de ruido puro a casi imagen torch._scaled_mm · tensor core FP4 × FP4 qact.view(float4_e2m1fn_x2), qweight.view(float4_e2m1fn_x2).t(), scale_a=act_scale, scale_b=weight_scale, out_dtype=torch.bfloat16 out = (A · sa) × (B · sb)ᵀ · las escalas se aplican adentro del kernel salida · bf16 norma, residual, la próxima capa una isla de 4 bits con costas en BF16 W4A4: pesos y activación en 4 bits al multiplicar
Lo que ModelOpt y vLLM esconden detrás de un flag, visible línea por línea. Los pesos y sus escalas vienen del checkpoint; la activación se cuantiza cuando existe, en cada paso; el kernel multiplica 4 bits por 4 bits y devuelve 16, porque nada más en el modelo sabe de FP4.

Por qué la activación se cuantiza en runtime

Es la distinción W4A16 / W4A4 de la página de Gemma-4, vista en código. Los pesos son fijos, así que se cuantizan una vez, offline, con toda la calma del mundo. La activación es distinta en cada forward — y en un modelo de difusión, distinta en cada paso de denoising, porque el latente cambia de "ruido puro" a "casi imagen" — así que sus escalas solo se pueden calcular cuando la activación existe: qact y act_scale se producen en cada llamada, bloque de 16 por bloque de 16, con el mismo absmax o criterio que use la implementación. Esto es lo que compra el tensor core FP4 × FP4; el precio es que un outlier de activación en un solo paso de difusión puede degradar ese paso, y el ejercicio 1 del §8 te muestra cuánto.

🔧 Un problema que no existe en un LLM de texto

El checkpoint NVFP4 de Flux2 lo publica Black Forest Labs con su propia convención de nombres (double_blocks.N.img_attn.qkv, heredada de Flux 1), pero el modelo que corre en memoria usa la convención de diffusers (transformer_blocks.N.attn.to_q). Ningún framework resuelve ese mapeo por vos. _nvfp4_key_for_module() es, literalmente, una tabla de traducción escrita a mano entre los dos esquemas de nombres — incluyendo el caso de to_q/to_k/to_v separados en diffusers mapeando a una única matriz qkv fusionada del lado de BFL, con un índice de slice para saber qué tercio de esa matriz corresponde a cada uno.

El mapeo de nombres parece un detalle administrativo hasta que pensás qué pasa si está mal: no hay error. Una matriz de to_q cargada con el tercio de to_k tiene exactamente la misma forma y el mismo dtype — el modelo corre, y genera imágenes que no se parecen a nada. Es el mismo tipo de "basura silenciosa" de la página de vLLM, con una diferencia: acá el eval no es un GSM8K con respuesta exacta, es mirar la imagen. Por eso conviene tener un golden (misma semilla, mismo prompt, salida en BF16 de referencia) y comparar la salida NVFP4 contra ese, como parte del pipeline y no como inspección a ojo.

3. ¿Cómo fine-tuneás algo que ya está en 4 bits?

Con un modelo en BF16, aplicar un LoRA es casi trivial: sumás la matriz de bajo rango entrenada (A·B) directamente a los pesos originales, o la dejás separada y la sumás en runtime. Con pesos empaquetados en NVFP4 eso deja de ser posible — no hay forma de "sumarle" una corrección continua a un valor que ya está discretizado a uno de 16 niveles por bloque.

Pensalo con números: en un bloque cuya escala es 0,01, los pesos cuantizados solo pueden valer 0, ±0,005, ±0,01, ±0,015, ±0,02, ±0,03, ±0,04 o ±0,06. Un LoRA típico corrige cada peso en cantidades del orden de 0,001 — un décimo de la distancia entre niveles. Si "mergeás" y volvés a cuantizar, casi todas esas correcciones se redondean a cero, y las pocas que cruzan un umbral saltan un nivel entero: el LoRA queda entre borrado y amplificado, al azar. Por eso la única alternativa correcta es no tocar los pesos cuantizados.

benchmark_lora_nunchaku.py lo deja explícito en el código: el método merge() de la capa LoRA-sobre-NVFP4 directamente levanta NotImplementedError("Merging into NVFP4 relies on fusion at runtime."). La única salida es fusionar el cómputo del LoRA dentro del kernel en cada forward — nunca modificar los pesos cuantizados en sí. nunchaku (la librería de la que viene svdq_quantize_w4a4_act_fuse_lora_cuda) hace justamente eso: cuantiza la activación y aplica la corrección de LoRA en la misma pasada CUDA.

En una ecuación: en vez de y = (W + BA)·x con un W ya redondeado, el kernel calcula y = W4bit·x + B·(A·x), donde el término de la derecha corre en 16 bits — es chico, porque A tiene rango r (decenas) contra los miles de la matriz completa — y se suma al resultado del matmul cuantizado antes de devolver. "Fusionar" significa que ese cálculo pasa dentro del mismo kernel que cuantiza la activación y hace el matmul FP4, sin que la activación tenga que salir a memoria y volver; si no, el LoRA costaría un lanzamiento de kernel extra y una lectura extra de la activación por capa.

Por qué un LoRA no se puede sumar a pesos NVFP4, y cómo se fusiona en el kernel A la izquierda, un bloque con escala 0,01 solo puede valer 0, más menos 0,005, 0,01, 0,015, 0,02, 0,03, 0,04 o 0,06; una corrección típica de LoRA, del orden de 0,001, es un décimo de la distancia entre niveles: al fusionar y recuantizar, casi todas se redondean a cero y las pocas que cruzan un umbral saltan un nivel entero. A la derecha, la alternativa: el kernel calcula W en 4 bits por x, más B por A por x en 16 bits, la rama de bajo rango, y suma antes de devolver, en la misma pasada que cuantiza la activación. fusionar y recuantizar: la corrección se pierde un bloque con escala 0,01 · niveles representables en la recta 0 0,005 0,01 0,015 0,02 0,03 peso = 0,01 + 0,001 de LoRA: 1/10 de la distancia entre niveles al redondear vuelve a 0,01: la corrección desaparece; si cruza un umbral, salta un nivel entero merge() → NotImplementedError el LoRA queda entre borrado y amplificado, al azar fusionar el cómputo en el kernel: los pesos no se tocan nunchaku · svdq_quantize_w4a4_act_fuse_lora_cuda x · bf16 activación rama principal, 4 bits cuantizar x → W₄ᵇⁱᵗ · x tensor core FP4 × FP4, pesos intactos rama de bajo rango, 16 bits B · (A · x) · r chico y = W₄ᵇⁱᵗ·x + B·(A·x) → bf16 una sola pasada CUDA cuantiza, multiplica y suma el LoRA sin que la activación salga a memoria · SVDQuant ya usa una rama así
Discretizar cierra puertas. Con dieciséis niveles por bloque no existe «sumarle un número chico» a un peso: la corrección se redondea a nada o a un salto entero. La salida es no tocar los pesos y calcular la corrección aparte, en 16 bits, dentro del mismo kernel, que es lo que el capítulo 11 de HF llama el límite de «fusionar».

nunchaku es el motor del paper SVDQuant, y vale la pena saber qué resuelve además del LoRA, porque conecta con el §2: cuantizar activaciones a 4 bits en un DiT es difícil por sus outliers. SVDQuant primero "migra" esos outliers de la activación a los pesos (escalando canal por canal), y después le saca a los pesos una componente de bajo rango con SVD que se queda en 16 bits — la misma forma que un LoRA — dejando un residuo mucho más fácil de cuantizar a 4 bits. Que el LoRA del usuario y esa rama de bajo rango tengan la misma forma es lo que hace natural fusionarlos en el mismo kernel.

Cuantizar no es solo comprimir el modelo — es cerrarle una puerta a cualquier técnica que asuma que podés sumarle un número real a un peso.

4. Video: lo mismo, con una dimensión más

Tu configuración de producción para Wan 2.1 (image-to-video) en LightX2V agrega, sobre la misma base de NVFP4, un conjunto de técnicas que no tienen equivalente en un LLM de texto:

TécnicaQué resuelve
dit_quant_scheme: nvfp4El transformer de difusión (DiT) va a 4 bits. El text encoder (T5) y CLIP se quedan en BF16 — mismo patrón que embeddings/lm_head en Gemma-4: las capas que traducen entre modalidades se salvan de la cuantización.
self_attn/cross_attn: sage_attn3SageAttention, el kernel de atención optimizado para Blackwell — el mismo tipo de rol que Flash Attention o PagedAttention cumplen para LLMs de texto.
infer_steps: 4, guidance_scale: 1.0Este checkpoint está destilado: entrenado para necesitar solo 4 pasos de denoising en vez de 20-50. La destilación también elimina la necesidad de classifier-free guidance (correr el modelo dos veces por paso, con y sin condicionamiento, para reforzar la adherencia al prompt) — guidance_scale=1.0 apaga esa segunda pasada, la mitad del cómputo por paso.
cpu_offload, offload_granularity: blockTécnica de memoria distinta a la cuantización: los bloques del transformer que no están calculando activamente se mandan a RAM del sistema y vuelven a la GPU cuando les toca — la forma de hacer entrar un modelo de video en 24 GB sin tocar su precisión.
RIFE (interpolación de frames)Un paso de post-proceso separado del modelo de difusión: en vez de generar más frames con el DiT (caro), un modelo chico de interpolación estima los frames intermedios — 16 fps generados se convierten en 60 fps de salida.

Por qué "una dimensión más" cambia tanto

Un DiT de video es el mismo transformer del §1 con parches en tres dimensiones — alto, ancho y tiempo — sobre un latente que un VAE 3D ya comprimió también en el eje temporal. El problema es la cantidad de tokens: donde una imagen tiene, digamos, unos miles de parches, un clip de decenas de frames tiene decenas de miles, y la atención completa escala con el cuadrado de esa cantidad. Por eso en video el kernel de atención deja de ser un detalle y pasa a ser el costo dominante — y por eso un kernel que cuantiza la atención misma (SageAttention: Q y K en pocos bits, con corrección para que el softmax no se rompa) rinde acá mucho más de lo que rendiría en un LLM decodificando de a un token.

Destilación de pasos, en una frase cada una

Un modelo de difusión se entrena para dar un paso chico desde cualquier nivel de ruido; generar exige encadenar muchos. Destilar pasos es entrenar un segundo modelo (o afinar el mismo) para que un paso grande suyo reproduzca lo que el original hacía en varios. Las tres familias que vas a encontrar:

  • Consistency models / LCM: el modelo aprende a mapear cualquier punto de la trayectoria de denoising directamente a su origen limpio, con la restricción de que dos puntos de la misma trayectoria den la misma respuesta ("consistencia"). LCM aplica la idea a modelos latentes como Stable Diffusion, y llega a 2-4 pasos.
  • Destilación adversarial: además de imitar al maestro, un discriminador penaliza las salidas que no parecen imágenes reales — recupera nitidez que la destilación pura tiende a perder, al costo de un entrenamiento más delicado (es la familia de los modelos "turbo").
  • Destilación de guidance: el estudiante aprende a producir directamente la salida que el maestro daba con classifier-free guidance, así no hace falta la segunda pasada sin condicionamiento. Es la razón por la que un checkpoint destilado se sirve con guidance_scale = 1.0: la guía ya está adentro de los pesos.

Lo que las tres tienen en común, y que importa para esta página: ninguna cambia la arquitectura ni el formato de los pesos — un DiT destilado a 4 pasos se cuantiza a NVFP4 exactamente igual que uno de 50. Se apilan: 4 pasos en vez de 50 es ~12× menos pasadas; sin CFG, cada pasada cuesta la mitad; NVFP4 hace cada pasada más barata todavía. Las tres palancas son independientes y multiplicativas.

Por qué el text encoder se queda en BF16

Tres razones, y las tres generalizan. Primero, se ejecuta una sola vez por prompt, no una vez por paso: el embedding de texto se calcula al principio y se reutiliza en los 4 (o 50) pasos del DiT. Cuantizarlo ahorraría memoria pero casi nada de tiempo — y de hecho, una vez calculado el embedding, el encoder entero se puede sacar de la GPU. Segundo, su error se propaga a todo lo demás: un embedding levemente mal es una condición levemente mal en cada uno de los pasos, sin posibilidad de corregirse. Tercero, es la capa que traduce entre modalidades — texto a espacio vectorial — que es exactamente la clase de capa que Gemma-4 también excluye (embed_tokens, lm_head). El mismo criterio para CLIP en image-to-video: es la traducción de la imagen de partida a condición, se corre una vez, y se queda en BF16.

Offload por bloque

Cuando ni la cuantización alcanza, queda mover pesos entre la RAM del sistema y la GPU durante el forward. La granularidad es la decisión: mover el modelo entero de a uno (útil para alternar text encoder → DiT → VAE, que nunca corren a la vez) o mover bloque por bloque del transformer — mientras el bloque n calcula, el n+1 viaja por PCIe hacia la GPU y el n−1 vuelve a RAM. Con bloques grandes y regulares, como los de un DiT, la transferencia se solapa casi por completo con el cómputo; con capas chicas y heterogéneas (una U-Net), el costo fijo de cada transferencia se come la ventaja. Lo que hay que saber para usarlo bien: no toca la precisión (los pesos van y vienen intactos), el límite es el ancho de banda de PCIe, y necesita RAM de sistema suficiente para alojar lo que no está en la GPU. Es una palanca de memoria, ortogonal a la de precisión (NVFP4) y a la de pasos (destilación).

PalancaQué reduce¿Toca la precisión?¿Toca la cantidad de pasadas?
NVFP4 en el DiTMemoria de pesos y costo por matmulSíNo
SageAttentionCosto de la atención por pasadaSí (en la atención)No
Destilación a 4 pasosCantidad de pasadasNo (es otro checkpoint)Sí
Sin CFG (guidance_scale = 1.0)Forwards por paso: 2 → 1NoSí (a la mitad)
Offload por bloqueMemoria de GPUNoNo (agrega transferencias)
RIFEFrames que tiene que generar el DiTNo (post-proceso)Indirectamente: menos frames

🟢 El principio que se repite en las tres páginas de NVFP4

Gemma-4 excluye embeddings/lm_head/proyecciones MTP de la cuantización. Flux2 mapea a mano el checkpoint cuantizado a la arquitectura de diffusers. Wan 2.1 deja el text encoder y CLIP en BF16. Ningún caso cuantiza el modelo entero de forma uniforme — en los tres, el trabajo real está en decidir qué no cuantizar, y verificar que las piezas que sí se cuantizaron sigan hablando el mismo idioma que las que no.

5. Lo que generaliza

Sacando los nombres propios, lo que esta página deja para cualquier proyecto que cuantice o sirva un modelo generativo — de texto, imagen o video:

LecciónDónde la viste acáCómo se aplica en otro proyecto
El formato es portable; la integración noMismo E2M1 en bloques de 16 en Gemma-4, Flux2 y Wan 2.1, con tres integraciones distintasCuando cambies de framework o de familia de modelo, lo que hay que reescribir es la carga, el mapeo y la selección de kernel — no el formato.
Un checkpoint y un modelo son dos vocabulariosdouble_blocks.N.img_attn.qkv vs transformer_blocks.N.attn.to_qEscribí el mapeo de nombres como tabla explícita y testeala: mismos shapes y dtype no prueban que el peso esté en el lugar correcto.
Toda isla de baja precisión tiene costas en altaout_dtype=torch.bfloat16; escalas como tensores aparteDefiní en qué dtype entra y sale cada capa cuantizada; los bugs viven en las fronteras.
Discretizar cierra puertasmerge() → NotImplementedErrorAntes de cuantizar, listá qué operaciones asumen pesos continuos (merge de LoRA, EMA, fine-tune directo) y decidí cómo las vas a hacer sin tocar los pesos.
Las capas que traducen entre modalidades se quedan en alta precisiónT5 y CLIP en BF16; embeddings y lm_head en Gemma-4Buscá lo que se ejecuta una vez y condiciona todo lo demás: ahí la cuantización ahorra poco y arriesga mucho.
Precisión, pasadas y memoria son palancas independientesNVFP4 / destilación + sin CFG / offload por bloqueCuando algo no entra o es lento, identificá primero cuál de las tres es el cuello de botella; cada palanca tiene un costo distinto.
Sin métrica exacta, hacé un goldenSalida NVFP4 vs referencia BF16, misma semillaPara modelos sin "respuesta correcta", fijá semilla y prompt y compará contra una referencia guardada, en código, en cada cambio.

6. Resumen

  1. NVFP4 es un formato de pesos — no le importa si el modelo es un decoder autoregresivo o un diffusion transformer; el mismo E2M1 en bloques de 16 aplica a los dos.
  2. Un DiT reemplaza la U-Net por una pila de bloques transformer sobre parches del latente; casi todo su cómputo son matrices lineales grandes, repetidas en cada paso de denoising — por eso cuantizarlas rinde tanto.
  3. Integrarlo sin un framework que lo resuelva por vos expone lo que ModelOpt+vLLM esconden: empaquetado de bits, matmul cuantizado explícito con escalas por bloque como tensores aparte, activación cuantizada en runtime, y el problema de mapear convenciones de nombres entre checkpoints de distinto origen.
  4. Fine-tunear con LoRA sobre pesos en 4 bits no se puede hacer por fusión de pesos — se fusiona el cómputo en el kernel, en cada forward; SVDQuant/nunchaku ya usan una rama de bajo rango en 16 bits para absorber outliers, y el LoRA viaja por la misma rama.
  5. Video agrega memoria (offload por bloque), velocidad de sampling (destilación, sin CFG), atención cuantizada (SageAttention) y un truco de post-proceso (interpolación de frames) que no tienen contraparte en texto. Son palancas independientes: precisión, cantidad de pasadas y memoria.
  6. El text encoder y CLIP se quedan en BF16 por la misma razón que embeddings y lm_head en Gemma-4: corren una vez, condicionan todo, y traducen entre modalidades.

7. Ponete a prueba

Respondé antes de abrir cada respuesta. Si fallás una, volvé a la sección indicada.

1. Un LLM y un DiT hacen "el mismo cómputo por dentro". ¿Qué es lo que sí es igual y qué es lo que cambia radicalmente en la inferencia?

Igual: la mayoría del cómputo son proyecciones lineales grandes (Q/K/V, feed-forward), y esas son las que se cuantizan. Cambia: el LLM hace un forward por token, autoregresivo y causal; el DiT hace un forward sobre toda la secuencia de parches en cada paso de denoising, y repite 4-50 veces. El costo se multiplica por los pasos, y las palancas de velocidad actúan o sobre el costo por pasada o sobre la cantidad de pasadas. (§1)

2. qweight tiene forma (out, in // 2) y weight_scale tiene (out, in // 16). ¿Por qué esos dos divisores, y cuántos bits por peso resultan?

El // 2 porque cada byte uint8 empaqueta dos valores FP4 de 4 bits; el // 16 porque hay una escala E4M3 (1 byte) por cada bloque de 16 pesos a lo largo de la dimensión de entrada. Total: 4 bits + 8/16 bits = 4,5 bits por peso, contra 16 en BF16. (§2)

3. ¿Por qué los pesos se cuantizan offline pero la activación (qact, act_scale) se cuantiza en cada forward?

Los pesos son fijos: se cuantizan una vez y quedan en el checkpoint. La activación es distinta en cada llamada — y en difusión, distinta en cada paso, porque el latente va de ruido a imagen — así que sus escalas por bloque solo pueden calcularse cuando existe. Es lo que hace posible el matmul FP4 × FP4 en el tensor core (W4A4), a costa de que un outlier de activación degrade ese paso. (§2)

4. Si el mapeo _nvfp4_key_for_module() asigna el tercio de to_k a to_q, ¿qué error ves?

Ninguno. Las dos matrices tienen exactamente la misma forma y dtype, así que la carga y el forward corren sin quejarse; la salida son imágenes que no se parecen a nada. Es basura silenciosa como la de la página de vLLM, pero sin un GSM8K que la detecte: hay que comparar contra un golden (misma semilla y prompt, referencia BF16) en el pipeline. (§2)

5. ¿Por qué merge() levanta NotImplementedError en vez de hacer la suma y re-cuantizar?

Porque los pesos ya están discretizados a 16 niveles por bloque, y las correcciones de un LoRA son mucho más chicas que la distancia entre niveles: al re-cuantizar, casi todas se redondean a cero y las pocas que cruzan un umbral saltan un nivel entero. El resultado sería un LoRA a medio borrar y a medio amplificar. La alternativa correcta es calcular W4bit·x + B·(A·x) en cada forward, con la rama de bajo rango en 16 bits, fusionada en el mismo kernel. (§3)

6. Un checkpoint destilado a 4 pasos se sirve con guidance_scale = 1.0. ¿Qué pasaría si le pusieras 7.5 como a un Stable Diffusion clásico?

Volverías a correr el modelo dos veces por paso (con y sin condicionamiento), duplicando el costo, para aplicar una guía que la destilación ya metió adentro de los pesos — y probablemente sobre-saturarías la salida, porque estarías guiando dos veces. El 1.0 no es una elección de calidad: es la señal de que ese checkpoint no necesita CFG. (§4)

7. Un modelo de video no entra en 24 GB. ¿Qué palanca elegís primero, y qué costo tiene cada una?

Depende del cuello de botella. Si es memoria de pesos: NVFP4 (toca precisión) u offload por bloque (no toca precisión, pero agrega transferencias por PCIe y necesita RAM de sistema). Si es tiempo: destilación (otro checkpoint, menos pasos) y quitar CFG (mitad de forwards por paso). Las palancas son independientes y se apilan; el text encoder y CLIP no son buenos candidatos a cuantizar porque corren una vez y condicionan todo. (§4)

8. Para practicar

✏️ Ejercicio 1 — E2M1 a mano: absmax vs búsqueda de escala

Sin Blackwell ni _scaled_mm: simulá en PyTorch puro (CPU) la cuantización a los 16 valores de E2M1 con una escala por bloque de 16, y medí el error cuadrático medio (MSE) de dos formas de elegir la escala — absmax (el máximo del bloque cae en 6) y una búsqueda que prueba escalas más chicas y se queda con la de menor MSE. Es exactamente la diferencia nvfp4 vs nvfp4_mse de la página de Gemma-4, con las escalas en float (sin el redondeo a E4M3) para aislar el efecto.

import torch
torch.manual_seed(0)

POS = torch.tensor([0.0, 0.5, 1.0, 1.5, 2.0, 3.0, 4.0, 6.0])
NIVELES = torch.cat([-POS.flip(0)[:-1], POS])     # los 16 valores de E2M1 (el -0 no aporta)

def cuantizar(x, escala):
    """x: (..., 16) bloques; escala: (..., 1). Devuelve la reconstrucción en float."""
    y = x / escala
    idx = (y.unsqueeze(-1) - NIVELES).abs().argmin(-1)   # nivel más cercano
    return NIVELES[idx] * escala

def escala_absmax(b):
    return b.abs().amax(-1, keepdim=True).clamp_min(1e-8) / 6.0   # 6 = máximo de E2M1

def escala_mse(b, factores=torch.linspace(0.3, 1.0, 36)):
    base = escala_absmax(b)
    mejor_err = mejor_esc = None
    for f in factores:                              # barre escalas más chicas que absmax
        esc = base * f
        err = ((cuantizar(b, esc) - b) ** 2).mean(-1, keepdim=True)
        if mejor_err is None:
            mejor_err, mejor_esc = err, esc
        else:
            gana = err < mejor_err
            mejor_err = torch.where(gana, err, mejor_err)
            mejor_esc = torch.where(gana, esc, mejor_esc)
    return mejor_esc

def mse(w, elegir_escala):
    return ((cuantizar(w, elegir_escala(w)) - w) ** 2).mean().item()

print(f"{'outlier ×':>10} {'MSE absmax':>12} {'MSE búsqueda':>13} {'mejora':>7}")
for factor in (1, 3, 6, 12, 30):
    w = torch.randn(4096, 16) * 0.02            # 4096 bloques de 16 "pesos"
    w[:, 0] *= factor                          # un outlier por bloque
    a, m = mse(w, escala_absmax), mse(w, escala_mse)
    print(f"{factor:>10} {a:12.3e} {m:13.3e} {a/m:6.2f}×")
→ outlier ×1: mejora ≈1.2× · ×3: ≈1.1× · ×6: ≈1.05× · ×12 y ×30: ≈1.00×
Pista / qué esperar

Dos lecturas. (a) Sin outliers dominantes, la búsqueda de escala gana un 20 % de MSE: el máximo de un bloque gaussiano no es representativo del bloque, y una escala un poco más chica resuelve mejor a los otros 15 a cambio de recortar apenas al más grande. (b) Cuando un outlier domina (×12, ×30), ninguna escala salva al bloque: recortarlo cuesta más que aplastar a los demás, así que la búsqueda termina eligiendo casi la misma escala que absmax. Eso es lo que motiva las técnicas que sacan los outliers de los pesos antes de cuantizar — la migración y la rama de bajo rango de SVDQuant del §3. Para seguir: imprimí un bloque con outlier y su reconstrucción con cada escala (vas a ver los 15 pesos chicos colapsados a 0 y ±1 nivel); después reemplazá las escalas float por su redondeo a torch.float8_e4m3fn (esc.to(torch.float8_e4m3fn).float()) y medí cuánto error extra agrega la escala de 8 bits.

✏️ Ejercicio 2 — La cuenta de memoria de un DiT, de un config

Igual que con Gemma-4, "entra en 24 GB" no es una propiedad del checkpoint sino del checkpoint más la configuración. Este script toma la geometría de un DiT genérico (cantidad de bloques, dimensión oculta, expansión del feed-forward) y calcula cuánto pesan los pesos del transformer en BF16, en NVFP4 (4,5 bits), y cuánto queda si además el text encoder se queda en BF16. Cambiá los números por los de cualquier config.json real.

def params_dit(bloques, d, expansion_ff=4):
    attn = 4 * d * d                    # q, k, v, o
    ff = 2 * d * (expansion_ff * d)      # subida y bajada
    return bloques * (attn + ff)         # sin normas ni modulación (despreciables)

cfg = {"bloques": 40, "d": 5120, "expansion_ff": 4}   # geometría de ejemplo, ~12,6B
p = params_dit(**cfg)
text_encoder_bf16_gb = 4.7 * 2       # p. ej. un encoder de ~4.7B parámetros × 2 bytes ≈ 9.4 GB
for nombre, bits in [("BF16", 16), ("FP8", 8), ("NVFP4 (4.5 efectivos)", 4.5)]:
    gb = p * bits / 8 / 1e9
    print(f"{nombre:<22} DiT {gb:5.1f} GB  + text encoder BF16 {text_encoder_bf16_gb:.1f} GB = {gb + text_encoder_bf16_gb:5.1f} GB")
print(f"parámetros del DiT: {p/1e9:.1f} B")
→ BF16 DiT 25.2 GB + text encoder BF16 9.4 GB = 34.6 GB · FP8 12.6 + 9.4 = 22.0 GB · NVFP4 7.1 + 9.4 = 16.5 GB · parámetros del DiT: 12.6 B
Pista / qué esperar

Lo que la tabla muestra: con un DiT de ese tamaño, en BF16 no entra en 24 GB ni sin text encoder; en FP8 entra justo, sin lugar para activaciones; en NVFP4 entra con margen — si el text encoder no está en la GPU al mismo tiempo, que es exactamente lo que resuelve el offload del §4 (calculás el embedding, sacás el encoder, cargás el DiT). Faltan las activaciones, que en un DiT de video crecen con la cantidad de parches (frames × alto × ancho) y con la atención — probá estimar la matriz de atención de un bloque para 30.000 tokens en BF16 y vas a ver por qué los kernels que no la materializan (Flash, Sage) no son opcionales en video.

9. Fuentes y para seguir