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
🎯 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_mmcon pesosfloat4_e2m1fn_x2y 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 y la distribución latente de los datos :
El campo vectorial marginal condicional es analíticamente constante a lo largo de la trayectoria:
La red neuronal aprende a predecir esta velocidad mediante regresión cuadrática directa:
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ásico | Convolución + ResNet, atención en pocas capas | Atención + feed-forward en todas las capas |
| Cómo ve la imagen | Como grilla a varias resoluciones | Como secuencia de parches, una sola resolución interna |
| Dónde está el cómputo | Repartido entre convoluciones y atención | Casi todo en matrices lineales grandes — lo que hace que NVFP4 rinda tanto acá |
| Qué cambia al cuantizar | Muchas capas chicas y heterogéneas | Pocas capas grandes, repetidas N veces: mismo trabajo que en un LLM |
📐 Patchification y modulación AdaLN-Zero en Diffusion Transformers
Dado un tensor latente con tamaño de parche , la cuadrícula se proyecta a una secuencia de vectores en mediante proyección lineal:
En cada bloque DiT, el condicionamiento temporal y textual se inyecta modulando la normalización de capas según el esquema AdaLN-Zero:
Donde . La inicialización a cero asegura que al paso 0 cada bloque sea una función identidad pura , garantizando estabilidad numérica total en pesos de 4 bits.
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.
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.
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.
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écnica | Qué resuelve |
|---|---|
dit_quant_scheme: nvfp4 | El 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_attn3 | SageAttention, 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.0 | Este 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: block | Té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).
| Palanca | Qué reduce | ¿Toca la precisión? | ¿Toca la cantidad de pasadas? |
|---|---|---|---|
| NVFP4 en el DiT | Memoria de pesos y costo por matmul | Sí | No |
| SageAttention | Costo de la atención por pasada | Sí (en la atención) | No |
| Destilación a 4 pasos | Cantidad de pasadas | No (es otro checkpoint) | Sí |
Sin CFG (guidance_scale = 1.0) | Forwards por paso: 2 → 1 | No | Sí (a la mitad) |
| Offload por bloque | Memoria de GPU | No | No (agrega transferencias) |
| RIFE | Frames que tiene que generar el DiT | No (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ón | Dónde la viste acá | Cómo se aplica en otro proyecto |
|---|---|---|
| El formato es portable; la integración no | Mismo E2M1 en bloques de 16 en Gemma-4, Flux2 y Wan 2.1, con tres integraciones distintas | Cuando 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 vocabularios | double_blocks.N.img_attn.qkv vs transformer_blocks.N.attn.to_q | Escribí 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 alta | out_dtype=torch.bfloat16; escalas como tensores aparte | Definí en qué dtype entra y sale cada capa cuantizada; los bugs viven en las fronteras. |
| Discretizar cierra puertas | merge() → NotImplementedError | Antes 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ón | T5 y CLIP en BF16; embeddings y lm_head en Gemma-4 | Buscá 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 independientes | NVFP4 / destilación + sin CFG / offload por bloque | Cuando 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 golden | Salida NVFP4 vs referencia BF16, misma semilla | Para modelos sin "respuesta correcta", fijá semilla y prompt y compará contra una referencia guardada, en código, en cada cambio. |
6. Resumen
- 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.
- 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.
- 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.
- 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.
- 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.
- El text encoder y CLIP se quedan en BF16 por la misma razón que embeddings y
lm_headen 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 BPista / 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
- Peebles & Xie — Scalable Diffusion Models with Transformers (arXiv:2212.09748) — el paper del DiT: parches del latente como tokens, condicionamiento por modulación, y la curva de escala con GFLOPs que explica por qué Flux y Wan no son U-Nets.
- Black Forest Labs — repositorio de Flux — el código de referencia de la familia Flux: bloques de doble flujo y flujo único, y la convención de nombres (
double_blocks.N.img_attn.qkv) que tu mapeo traduce. - Li et al. — SVDQuant: Absorbing Outliers by Low-Rank Components for 4-Bit Diffusion Models (arXiv:2411.05007) — el paper detrás de
nunchaku: migración de outliers, rama de bajo rango en 16 bits, y por qué el LoRA se fusiona en el kernel en vez de mergearse. - Zhang et al. — SageAttention: Accurate 8-Bit Attention for Plug-and-play Inference Acceleration (arXiv:2410.02367) — la primera versión de la familia de kernels que cuantiza la atención misma; leelo para entender qué se cuantiza (Q, K) y cómo se corrige antes del softmax.
- Luo et al. — Latent Consistency Models: Synthesizing High-Resolution Images with Few-Step Inference (arXiv:2310.04378) — destilación por consistencia sobre modelos latentes: la explicación más directa de cómo se llega de 50 pasos a 4.
- Huang et al. — RIFE: Real-Time Intermediate Flow Estimation for Video Frame Interpolation (arXiv:2011.06294) — el interpolador de frames del post-proceso: estima flujo óptico intermedio con una red chica, sin tocar el modelo de difusión.
- Wan-Video — Wan 2.1 (repositorio) — el modelo de video del §4: arquitectura, VAE 3D, text encoder y CLIP para image-to-video, y la configuración de referencia contra la que comparar la tuya.