Curso / Aplicado a tus proyectos / Pipeline NVFP4 · Gemma 4
🔧 basado en tu código real · 1 de 5

Aplicado a tus proyectos · Pipeline-cloud

Tu pipeline NVFP4: meter 62 GB de modelo en 24 GB de laptop

El Capítulo 1 te dio la teoría de arquitecturas e inferencia en abstracto. Esto es esa misma teoría, más el pedazo que el curso oficial no cubre — cuantización — mirando directamente lo que ya corre en tu RTX 5090 y en "La Bestia".

Antes de leerHF cap. 1 §5, inferencia y KV cache · HF cap. 2 §5, vLLM · LLM 2026 · 1 §5, la geometría de Gemma 4

Modelo base: google/gemma-4-31B-it-qat Formato: NVFP4-mse (ModelOpt 0.44) Sirve en: vLLM 0.22 · RTX 5090 24GB (sm_120) Calibra en: RTX 6000 96GB "La Bestia" (Blackwell)

🎯 Al terminar vas a poder

  • Hacer la cuenta de memoria de cualquier modelo (parámetros × bytes) y estimar cuánto ahorra pasar de BF16 a 4 bits con escalas por bloque.
  • Explicar con tus palabras qué es NVFP4 (E2M1 + bloques de 16 + escala FP8) y por qué la elección de escala (absmax vs MSE) decide si el checkpoint sirve.
  • Distinguir cuantización de pesos y de activaciones, PTQ y QAT, y decir por qué la calibración necesita datos reales.
  • Explicar por qué el decoding especulativo no cambia la distribución de salida, y de qué depende el speedup.
  • Verificar un checkpoint cuantizado leyendo el header de sus safetensors, en vez de confiar en el log.

0. Qué construiste

main_nvfp4.py orquesta un pipeline de 5 etapas (parser LLM → generación de imagen → video → narración → edición final), todo corriendo con pesos en NVFP4 para caber en hardware Blackwell. Pero la pieza que más vale la pena entender a fondo — porque la reusás en otro proyecto tuyo, interpretante-lacaniano — es cómo se produce el checkpoint cuantizado de Gemma-4 en sí. Esa parte vive en gemma4_quant_strategy.md y los scripts bootstrap_gemma4_qat.sh / bootstrap_gemma4_finetuning.sh.

Tu pipeline produce tres checkpoints, cada uno con su propio origen:

CheckpointViene de
gemma-4-31B-it-qat-NVFP4-BlackwellModelo base de Google, sin fine-tune
gemma-4-31B-it-qat-assistant-NVFP4-BlackwellEl draft MTP del modelo base
gemma-4-31B-analyst-NVFP4-BlackwellTu fine-tune propio (LoRA con Unsloth, mergeado a FP16 antes de cuantizar)

🔧 En tu proyecto

El mismo pipeline de cuantización es compartido entre Pipeline-cloud y interpretante-lacaniano — así se produjo también gemma-4-31B-lacan-NVFP4-Blackwell, el "cuerpo" del sistema lacaniano. No son proyectos separados a nivel de infraestructura de modelos: es una sola fábrica de checkpoints con distintos consumidores.

1. La arquitectura: Gemma-4 + su asistente MTP

Del Capítulo 1 ya sabés que Gemma es decoder-only. Tu documentación agrega el detalle real: Gemma-4 es densa (no MoE, no Mamba — a diferencia de lo que asumía un borrador viejo de tu propia estrategia, corregido en el commit 0b8b647), con atención local/global estilo Gemma-3.

Lo que el Capítulo 1 no menciona porque es demasiado reciente: al lado del modelo grande vive un segundo modelo, de apenas 0.5B de parámetros, dedicado exclusivamente a predecir por adelantado los próximos tokens — Gemma4AssistantForCausalLM. No es un modelo separado que "adivina" por su cuenta: consume directamente los shared_kv_states (los mismos estados de atención full_attention / sliding_attention) que ya calculó el modelo grande. Es, literalmente, una segunda cabeza de predicción pegada al mismo backbone — por eso se llama MTP, Multi-Token Prediction.

🔧 En tu proyecto

serve_lacan_vllm.sh carga los dos a la vez: MODEL_DIR (el cuerpo, 31B) y DRAFT_DIR (el asistente MTP). Verlos como "un modelo" es el error más fácil de cometer acá — son dos checkpoints con reglas de cuantización distintas, como vas a ver en la sección 3.

2. Qué es realmente "NVFP4"

El Capítulo 1 nunca explica cuantización porque el curso original de HF asume que el modelo entra en memoria tal cual. Tu laptop te obligó a resolver este problema de verdad: Gemma-4 31B en BF16 pesa ~62 GB, y tu RTX 5090 tiene 24 GB de VRAM.

La cuenta de memoria, en general

Antes de cualquier detalle de formato, la aritmética que gobierna todo esto es una multiplicación: memoria de pesos = cantidad de parámetros × bytes por parámetro. Con eso ya podés estimar qué entra y qué no en cualquier GPU, para cualquier modelo:

FormatoBits por peso31B parámetrosQué se pierde
FP3232~124 GBNada — es como se entrena normalmente (o BF16 con acumulación FP32)
BF1616~62 GBMantisa corta, pero mismo rango que FP32; el default de inferencia
FP8 (E4M3)8~31 GBRango y precisión reducidos; suele pedir escalas por tensor o por canal
4 bits (E2M1 / int4)4~15,5 GBSolo 16 valores representables por peso — sin escalas por bloque sería inusable
NVFP4 real4 + escalas~17,5 GBUna escala E4M3 (8 bits) cada 16 pesos agrega 0,5 bit por peso: 4,5 bits efectivos

La última fila es la que importa: 31 × 10⁹ × 4,5 / 8 ≈ 17,4 GB. Esa es la cuenta de los pesos cuantizados solos — a eso hay que sumarle las capas que se dejan en BF16 (§3), el draft en BF16 (§4) y el KV cache, que crece con la longitud de contexto y con la cantidad de requests simultáneas. Por eso "entra en 24 GB" nunca es una propiedad del checkpoint: es una propiedad del checkpoint más la configuración de serving.

La idea de fondo de cualquier cuantización es simple: en vez de guardar cada peso del modelo con 16 bits de precisión (BF16), lo guardás con muchos menos bits, aceptando algo de error a cambio de un modelo drásticamente más chico. NVFP4 es el formato de 4 bits que NVIDIA diseñó específicamente para que las GPU Blackwell (tu RTX 5090, y la RTX 6000 de "La Bestia") lo puedan multiplicar en hardware sin pasar por un paso de conversión lento.

E2M1

Cada peso individual se guarda en 4 bits: 1 de signo, 2 de exponente, 1 de mantisa. Muy pocos valores representables — por sí solo sería demasiado impreciso.

Bloques de 16

Los pesos no se cuantizan uno por uno: se agrupan de a 16, y ese bloque comparte una única escala.

Escala E4M3 por bloque

Esa escala compartida sí se guarda con más precisión (FP8, 4 bits de exponente + 3 de mantisa) — es lo que le devuelve rango dinámico al bloque entero.

📐 Fundamento matemático: bitfield E2M1 y cuantización jerárquica

Un valor en punto flotante empaquetado en 4 bits bajo el estándar E2M1 se descompone en un bit de signo s∈{0,1}, dos bits de exponente sesgado e∈{0,1,2,3} con sesgo B=1, y un bit de mantisa m∈{0,1}. La evaluación numérica del valor decodificado v responde a la formulación IEEE por partes:

v(s,e,m)=(-1)s× {0si e=0,m=021-B×(0+m·2-1)=0.5si e=0,m=1 (subnormal)2e-B×(1+m·2-1)si e>0 (normal)

Evaluando las combinaciones posibles para s=0: e=1⇒1.0,1.5; e=2⇒2.0,3.0; e=3⇒4.0,6.0. El reticulado discreto resultante es exactamente:

GE2M1={0,±0.5,±1.0,±1.5,±2.0,±3.0,±4.0,±6.0}

Para cuantizar un peso continuo Wi,j en un bloque de 16 elementos con escala de bloque sb∈FP8 (E4M3) y escala global del tensor sg∈FP32, el mapeo al reticulado y la reconstrucción aproximada son:

qi,j=argminq∈GE2M1Wi,jsb·sg-q,W^i,j=qi,j·sb·sg

Un detalle más del formato, que explica por qué una escala de 8 bits alcanza: NVFP4 usa dos niveles de escala. Cada bloque de 16 tiene su escala E4M3, y además todo el tensor tiene una única escala global en FP32. La escala de bloque no tiene que cubrir el rango completo de la matriz — solo el rango relativo de su bloque respecto del tensor. Los 16 valores de E2M1 son exactamente estos: 0, 0,5, 1, 1,5, 2, 3, 4 y 6, con sus negativos. Cada peso real se guarda como "uno de esos 16 × la escala de su bloque × la escala del tensor".

Qué guarda NVFP4: 16 pesos de 4 bits, una escala FP8 por bloque, una escala FP32 por tensor Un tensor de pesos se recorre en bloques de 16 valores a lo largo de la dimensión de entrada. Cada valor se guarda en E2M1, 4 bits, que solo puede tomar 16 niveles: 0, 0,5, 1, 1,5, 2, 3, 4 y 6 con sus negativos. Cada bloque comparte una escala E4M3 de 8 bits, y todo el tensor una escala FP32. Son 72 bits por cada 16 pesos: 4,5 bits efectivos por peso. tensor de pesos · out × in un bloque = 16 pesos consecutivos a lo largo de la dimensión de entrada + 1 escala FP32 para todo el tensor el bloque, expandido 4b 4b 4b 4b 4b 4b 4b 4b 4b 4b 4b 4b 4b 4b 4b 4b escala E4M3 16 × E2M1 (1 signo, 2 exponente, 1 mantisa) 16 × 4 + 8 = 72 bits por 16 pesos → 4,5 bits por peso 31B parámetros × 4,5 bits / 8 ≈ 17,4 GB, contra 62 GB en BF16 los 16 valores posibles de un peso E2M1 (× escala del bloque × escala del tensor): -6 -4 -3 -2 0 2 3 4 6 densos cerca de cero, ralos lejos: la escala del bloque decide qué se resuelve bien peso real ≈ nivel × escala del bloque × escala del tensor · la escala FP8 no cubre el rango de toda la matriz, solo el relativo de su bloque
El formato, desarmado. Cuatro bits por peso serían inusables solos: lo que devuelve rango dinámico es la escala compartida por cada bloque de 16, más una global por tensor. Esa media unidad extra de bits es la diferencia entre 15,5 y 17,4 GB para los 31B de Gemma-4.

Tres preguntas que cualquier esquema de cuantización tiene que responder

1. ¿Qué se cuantiza: pesos, activaciones, o ambos? La notación habitual es WxAy: bits de pesos y bits de activaciones. W4A16 guarda los pesos en 4 bits pero los desempaqueta a 16 bits para multiplicar — ahorra memoria y ancho de banda, pero el matmul sigue siendo el de siempre. W4A4 cuantiza también la activación (la entrada de cada capa) a 4 bits, en runtime, con sus propias escalas por bloque, para que el tensor core multiplique 4 bits × 4 bits directamente. NVFP4 en Blackwell es de este segundo tipo: por eso el hardware importa tanto — el kernel necesita un tensor core que sepa hacer esa multiplicación de forma nativa. Y por eso cuantizar activaciones es más delicado que cuantizar pesos: los pesos son fijos y se pueden estudiar con calma; las activaciones cambian con cada input y tienen outliers que dependen del texto.

2. ¿Cuándo se cuantiza: después de entrenar o durante? PTQ (Post-Training Quantization) toma un modelo ya entrenado y le busca las escalas con un pequeño conjunto de datos, sin volver a entrenar — es lo que hace hf_ptq.py, como dice su nombre. QAT (Quantization-Aware Training) es otra cosa: durante el entrenamiento (o un fine-tuning corto al final) el forward pass simula la cuantización — redondea pesos y activaciones a los niveles que va a tener el formato final, y deja que el gradiente ajuste el modelo para que tolere ese ruido. El resultado sigue siendo un checkpoint en alta precisión, pero cuyos pesos ya "saben" que van a ser redondeados. El sufijo -qat del checkpoint base refiere a esa técnica: un modelo preparado para cuantizarse con menos pérdida que uno entrenado sin pensar en eso. Lo que tu pipeline hace después es PTQ sobre ese punto de partida — las dos cosas no compiten, se apilan.

3. ¿De dónde salen las escalas? Para los pesos, en principio de los pesos mismos — podés mirar el bloque y elegir. Pero las escalas de activación (W4A4) y cualquier criterio que mire el error de salida de una capa necesitan ver activaciones reales. De ahí la calibración: se pasan por el modelo unos cientos de muestras de texto y se registran los rangos y distribuciones de cada capa. Con pocos datos, o con datos que no se parecen a los de producción, las escalas quedan mal ajustadas — un modelo calibrado solo con inglés puede degradar en español, por ejemplo. No es entrenamiento: no hay gradiente, no cambian los pesos; solo se observan estadísticas para decidir cómo redondear.

📐 Derivación del objetivo de optimización MSE vs Absmax

Mientras que Absmax fija la escala del bloque forzando que el elemento de mayor magnitud absoluta coincida con el extremo del reticulado (sabsmax=maxk|Wk|6.0), el algoritmo MSE formula la selección de escala como un problema variacional de minimización de error cuadrático:

sb*=argminsb∑k=116Wk-sb·sg·QE2M1Wksb·sg2

El error cuadrático esperado se descompone analíticamente en dos fuentes contrapuestas:

E[(W-W^)2]=∫|w|>6seff(|w|-6seff)2p(w)dw⏟Error de truncamiento (clipping)+∑k∫Rk(w-seffqk)2p(w)dw⏟Error de granularidad

Bajo distribuciones de pesos leptocúrticas (Gaussianas o Student-t), Absmax anula el error de truncamiento fijando seff sobre el outlier extremo, pero multiplica el error de granularidad en los 15 pesos restantes que residen cerca de cero. La optimización MSE contrae la escala a sb*≈0.70·sabsmax: acepta un pequeño truncamiento en el outlier y comprime la distancia entre escalones, reduciendo la varianza del ruido de cuantización en más de un 25%.

La parte interesante — y la que tu documentación registra con más cuidado que cualquier paper — es cómo se elige esa escala por bloque. No es un detalle menor: es la diferencia entre un modelo que sirve y uno que no.

AlgoritmoCómo elige la escalaEn tu pipeline
Max / absmaxEl valor más grande del bloque define la escala — un solo outlier aplasta el resto del bloque hacia ceroLo que usabas antes
MSE (nvfp4_mse)Prueba distintas escalas y elige la que minimiza el error de reconstrucción del bloqueLo que usás ahora — adoptado de la receta de NVIDIA para Nemotron 3 Super
Four-over-sixCada bloque elige entre dos valores máximos posibles (4 o 6), el que minimice errorEvaluado, no adoptado — necesita ModelOpt ≥0.46 (aún en dev)

🔧 En tu proyecto

El cambio de --qformat nvfp4 a --qformat nvfp4_mse en hf_ptq.py fue la única línea que tocaste para este upgrade — nada de código propio, solo un flag de ModelOpt. Costo real medido: calibración de ~3 min (absmax) a ~7 min (MSE) para el 31B en la RTX 6000, porque MSE además barre escalas candidatas de activación en FP8. Diferencia despreciable frente al tiempo de descarga/subida del checkpoint.

El mismo bloque de 16 pesos con la escala de absmax y con la escala que minimiza el error Un bloque de 16 pesos gaussianos sin outlier dominante. Con absmax, el valor más grande cae exacto en el nivel 6 y los demás quedan en pocos niveles gruesos. Con la escala elegida por búsqueda de error mínimo, más chica, el valor más grande se recorta al máximo representable pero los otros quince se resuelven con niveles más finos, y el error cuadrático medio del bloque baja alrededor de un 25 por ciento. absmax: el máximo del bloque cae en el nivel 6 escala del bloque = 0,0050 error cuadrático medio del bloque: 2,15·10⁻6 búsqueda: la escala que minimiza el error escala del bloque = 0,0048 error cuadrático medio: 1,72·10⁻6 · 1,25× menor barras grises: los 16 pesos originales · barras naranjas: su reconstrucción · líneas punteadas: los niveles representables con esa escala cuando un outlier domina el bloque (×12, ×30) ninguna escala salva a los demás y la búsqueda termina eligiendo casi absmax
La diferencia entre nvfp4 y nvfp4_mse en un bloque de 16 valores, calculada. Absmax fija la escala para que el valor más grande quede exacto y deja a los demás con pocos niveles; la búsqueda acepta recortar un poco al grande a cambio de resolver bien a los otros quince. Fue tu único cambio de este ciclo, y es un flag.

3. Por qué no se cuantiza todo

Si cuantizaras literalmente cada matriz del modelo, algunas partes se rompen mucho más fácil que otras. Tu pipeline excluye tres tipos de capas, cada una por una razón distinta:

  • Embeddings (embed_tokens) y lm_head: excluidas por default en ModelOpt en cualquier preset NVFP4 — son las capas que traducen entre tokens discretos y espacio vectorial, y son especialmente sensibles a la pérdida de precisión.
  • pre_projection / post_projection del asistente MTP: el "empalme" entre el backbone grande y el draft chico. Necesitaste un monkeypatch propio (patch_modelopt.py) para que la calibración corra con shared_kv_states sintéticos, e inyectar estos dos nombres en hf_ptq.py para que nunca se cuanticen — sin importar qué modelo se esté procesando.
  • El propio draft model, completo: ver sección 4 — no es que se excluya una capa, es que el modelo entero se sirve en BF16.

📐 Análisis de sensibilidad de Hessian en capas críticas

La degradación de la función de pérdida del modelo ΔL ante una perturbación de pesos ΔW=W^-W se modela formalmente mediante su expansión de Taylor de segundo orden:

ΔL≈g⊤vec(ΔW)+12vec(ΔW)⊤Hvec(ΔW)

En el mínimo local de convergencia de un modelo preentrenado, el vector gradiente g≈0, por lo que el impacto depende estrictamente de la curvatura de la matriz Hessiana H=∇W2L≈E[xx⊤]⊗I.

En la cabeza final de predicción de vocabulario (Wvocab∈RV×d, con V≈256.000), cada fila genera un logit zv=wv⊤h. El error inducido por cuantización Δzv=(w^v-wv)⊤h altera las diferencias relativas entre los candidatos más probables. Si maxv|Δzv|>z(1)-z(2), el operador argmax invierte la predicción del token óptimo, induciendo una catástrofe de perplejidad. Por esta razón, preservar Wvocab e Wembed en 16 bits es un imperativo físico-matemático de estabilidad.

🔧 Bug real que ya pisaste

El 2026-07-10 un re-quant corrió contra un hf_ptq.py sin parchear porque los marcadores .done del clone/patch eran de junio (previos a la exclusión MTP). Resultado: pre_projection/post_projection quedaron cuantizadas a FP4 con las dimensiones a la mitad — el mismo bug de shape-mismatch reportado públicamente por AQLabs. Se detectó inspeccionando los headers de los safetensors antes de publicar. La regla que quedó: para re-cuantizar, borrar todos los marcadores (rm -f data/.gemma4_qat_bootstrap/*.done), no solo los de cuantización — los pasos de patch no tienen output verificable, así que un marcador viejo se salta en silencio.

4. Servir rápido: KV cache + decoding especulativo

El Capítulo 1 explicó que generar texto es autoregresivo: un token depende de todos los anteriores, y el KV cache evita recalcular la atención sobre toda la conversación en cada paso. Tu configuración de serve_lacan_vllm.sh usa --kv-cache-dtype fp8 — el cache también se comprime, con escalas dinámicas calculadas al vuelo durante el serving.

🔧 Un hallazgo tuyo que no está en ningún paper

Probaste exportar el checkpoint con escalas FP8 calibradas y guardadas para el KV cache (--kv_cache_qformat fp8). Resultado: salida degenerada, byte-idéntica entre el checkpoint absmax y el MSE — la prueba de que la falla estaba en las escalas de KV, no en los pesos. Hipótesis más probable: las dimensiones heterogéneas de cabeza de Gemma-4 (head_dim=256, global_head_dim=512) fuerzan el backend TRITON_ATTN de vLLM, que aplica mal esas escalas por capa. Conclusión operativa: --kv_cache_qformat none en el checkpoint, y dejar que vLLM calcule las escalas dinámicamente en runtime. Sin pérdida real de ahorro de memoria.

La parte que el Capítulo 1 ni siquiera menciona porque no la necesita para explicar transformers en general: decoding especulativo. La idea es contra-intuitiva — para generar más rápido, primero generás de más.

  1. El modelo chico (el draft MTP, 0.5B) propone varios tokens seguidos de una, rápido y barato — justamente su único trabajo es predecir tokens por adelantado, nada más.
  2. El modelo grande verifica esos tokens propuestos en paralelo, en una sola pasada — verificar es mucho más barato que generar token a token.
  3. Los tokens que el modelo grande acepta se guardan; en el primer rechazo, el grande genera él mismo ese token y el ciclo vuelve a empezar.

Por qué no cambia lo que el modelo diría

Lo primero que uno sospecha es que esto degrada la calidad: "si el chico propone, ¿no termino sirviendo el modelo chico?". No — y la razón es una regla precisa de aceptación (Leviathan et al., 2023; Chen et al., 2023). Llamemos q(x) a la probabilidad que el draft le dio al token propuesto x, y p(x) a la que le da el modelo grande al verificarlo:

  • Si p(x) ≥ q(x), el grande lo acepta siempre: el chico propuso algo que el grande consideraba al menos igual de probable.
  • Si p(x) < q(x), lo acepta con probabilidad p(x) / q(x) — el chico estaba "demasiado seguro", y se descuenta exactamente ese exceso.
  • Si lo rechaza, el grande muestrea un token de reemplazo de la distribución máx(0, p − q), normalizada: justo la parte de p que el draft subestimó.

Sumando los tres casos, la probabilidad de que salga cada token es exactamente p(x) — la del modelo grande, token por token. Es la misma lógica que el muestreo por rechazo en estadística: la propuesta puede ser mala, y lo único que cambia es cuántas veces hay que reintentar, nunca qué distribución se obtiene. Con greedy (temperatura 0) el criterio se simplifica a "¿el token del draft coincide con el argmax del grande?", y la salida es idéntica a la del grande solo.

📐 Demostración formal de invarianza distribucional (Leviathan et al., 2023)

Sea q(x) la probabilidad asignada por el modelo borrador (draft) y p(x) la del modelo destino (target). La probabilidad de aceptación condicional es α(x)=min1,p(x)q(x). La probabilidad total de que el draft sea aceptado en esa posición es:

P(aceptar)=∑yq(y)α(y)=∑ymin(q(y),p(y))

En caso de rechazo (evento con probabilidad 1-P(aceptar)), se muestrea de la distribución residual normalizada:

p′(x)=max(0,p(x)-q(x))∑ymax(0,p(y)-q(y))=max(0,p(x)-q(x))1-∑ymin(q(y),p(y))

Calculando la probabilidad marginal de emitir el token x por ley de probabilidad total:

Pemit(x)=q(x)α(x)+(1-P(aceptar))·p′(x)=min(q(x),p(x))+1-∑ymin(q(y),p(y))max(0,p(x)-q(x))1-∑ymin(q(y),p(y))=min(q(x),p(x))+max(0,p(x)-q(x))=p(x)

Queda matemáticamente demostrado que la distribución final generada es estrictamente idéntica a la del modelo grande, sin aproximación ni sesgo estocástico.

Una ronda de decoding especulativo: el draft propone, el modelo grande verifica en paralelo El draft MTP de 0,5B propone tres tokens seguidos, uno por paso barato. El modelo grande de 31B verifica los tres, más la posición siguiente, en una sola pasada cara. Cada token propuesto se acepta si su probabilidad bajo el modelo grande es mayor o igual que bajo el draft, y si no, con probabilidad p sobre q; en el primer rechazo el grande muestrea un reemplazo de la parte de p que el draft subestimó. Medido: de 23 a 52,1 tokens por segundo con 54 por ciento de aceptación. draft MTP 0,5B · BF16 · barato lee los estados del grande t₁ t₂ t₃ γ = 3 pasos chicos, uno tras otro · propone t₁ t₂ t₃ con probabilidades q modelo grande 31B · NVFP4 · caro limitado por ancho de banda: lee 17 GB de pesos una vez una sola pasada verifica las 4 posiciones en paralelo: p(t₁), p(t₂), p(t₃) y la siguiente acepta t si p(t) ≥ q(t); si p < q, acepta con probabilidad p/q; al primer rechazo, muestrea de máx(0, p − q) verificar varios tokens cuesta casi lo mismo que generar uno salida de la ronda t₁ ✓ aceptado t₂ ✓ aceptado t₃ ✗ rechazado t₃′ del grande 3 tokens por una pasada del grande; la distribución de salida es exactamente la del grande, token por token medido en La Bestia: 23,0 → 52,1 tok/s (2,26×) · aceptación 54 % (78 % en la primera posición propuesta)
Para generar más rápido, primero se genera de más. El draft, que reusa los estados internos del modelo grande, propone barato; el grande verifica todo en una pasada cuyo costo lo domina leer los pesos, no el cómputo. La regla de aceptación descuenta exactamente lo que el draft sobreestimó, así que la calidad no cambia: solo cuántas veces hay que reintentar.

De qué depende el speedup

Si el draft propone γ tokens por ronda y cada uno se acepta con probabilidad α (independiente, para simplificar), la cantidad esperada de tokens que salen por cada pasada del modelo grande es (1 − α^(γ+1)) / (1 − α) — el token rechazado (o el extra si acepta todos) también cuenta. El speedup real es ese número dividido por el costo relativo de la ronda: una pasada del grande más γ pasadas del draft. Dos consecuencias prácticas:

  • α manda. Con α = 0,5 y γ = 3 salen ≈1,9 tokens por pasada; con α = 0,8, ≈3,0. Un draft que se parece mucho al grande (mismo tokenizer, entrenado sobre sus salidas, o — como acá — leyendo sus propios estados internos) vale más que uno más chico pero peor.
  • El draft tiene que ser casi gratis. Si costara la mitad que el grande, γ = 3 ya consumiría 1,5 pasadas equivalentes y el speedup se evaporaría. Por eso el asistente MTP de 0,5B pegado al backbone es una configuración tan buena: no recalcula nada que el grande ya calculó.

Hay un segundo argumento, menos visible: decodificar un token a la vez es una operación limitada por ancho de banda de memoria (hay que leer los 17 GB de pesos enteros para producir un solo token), no por cómputo. Verificar γ+1 tokens en una pasada lee los pesos una sola vez para varios tokens — el hardware que estaba ocioso esperando memoria pasa a trabajar. Eso es lo que hace que "verificar varios" cueste casi lo mismo que "generar uno".

Medido por vos en La Bestia (vLLM 0.22.1, 300 tokens, temperatura 0.2, una sola request):

Configuracióntok/s
Sin decoding especulativo23.0
Con draft MTP, 3 tokens especulativos52.1 (2.26×)

Con una tasa de aceptación real de 210/387 tokens (54%, 78% en la primera posición del lote propuesto).

Vale la pena poner esos dos números en la fórmula: con α ≈ 0,54 y γ = 3 salen ≈2 tokens por pasada del grande, del mismo orden que el 2,26× medido. La fórmula asume α constante en cada posición, y tu propia medición muestra que no lo es (78% en la primera, mucho menos después): los primeros tokens propuestos son casi siempre fáciles y los últimos casi siempre difíciles. Esa es la razón por la que subir γ tiene rendimientos decrecientes — cada token extra que propone el draft tiene menos chances de sobrevivir que el anterior.

🔧 La trampa que ya te costó un error en runtime

El draft tiene que quedarse sin cuantizar (0.5B parámetros, BF16, ~927 MB — la cuenta cierra: 0.5×10⁹ parámetros × 2 bytes ≈ 1 GB). Si le pasás la versión NVFP4 como draft, vLLM la carga sin aplicar la config de ModelOpt y explota con RuntimeError: start (0) + length (8192) exceeds dimension size (4096) — está leyendo un peso empaquetado en 4 bits como si fuera BF16. El ahorro de VRAM del draft nunca fue el punto: es tan chico que no importa: lo que importa es que decodifique rápido y sin bugs de dtype.

5. La lección de ingeniería: verificá el artefacto, no el log

Tu propia documentación deja una regla que vale para cualquier pipeline de ML, no solo este: los logs de hf_ptq.py imprimen texto degenerado en sus "example outputs" incluso para checkpoints que después sirven perfecto — porque esa prueba interna no aplica el chat template. Un log que se ve mal no significa un checkpoint roto, y viceversa: el bug del §3 pasó una corrida "exitosa" según el log.

Lo único que de verdad confirma que la cuantización salió bien es inspeccionar el artefacto: que hf_quant_config.json liste pre_projection/post_projection en exclude_modules, y que el header de safetensors muestre esas capas en BF16 con sus dimensiones completas — un dtype U8 o dimensiones a la mitad es la señal inequívoca de que el patch no corrió.

6. Lo que generaliza

Sacando los nombres propios, lo que esta página deja para cualquier proyecto que cuantice o sirva un modelo:

LecciónDónde la viste acáCómo se aplica en otro proyecto
La memoria se calcula, no se adivina62 GB en BF16 → ~17,5 GB en NVFP4 más lo que queda en BF16 y el KV cacheAntes de elegir GPU o formato, hacé la cuenta parámetros × bytes y sumá el cache a la longitud de contexto que vas a servir.
Nunca se cuantiza todoEmbeddings, lm_head, proyecciones del draft, y el draft enteroIdentificá las capas que traducen entre espacios (entrada/salida, empalmes entre modelos) y dejalas en alta precisión por default.
La elección de escala es el algoritmoabsmax vs MSE, un solo flagCuando un checkpoint cuantizado "sirve mal", el primer sospechoso son las escalas, no el formato de 4 bits en sí.
Un modelo de dos piezas tiene dos reglasCuerpo NVFP4, draft BF16Cada componente que se carga por separado necesita su propia decisión de precisión y su propia verificación.
Los pasos sin output verificable se saltan en silencioMarcadores .done viejos que dejaron pasar un patchLos sistemas de "hacer una sola vez" necesitan invalidarse cuando cambia lo que hacen — o inspeccioná el resultado final en vez del marcador.
Verificá el artefacto, no el logHeaders de safetensors vs "example outputs"Definí qué propiedad del archivo final prueba que el paso salió bien, y chequeala en código antes de publicar.

7. Mapa de tu propio pipeline

Uniendo todo lo de arriba con la teoría del Capítulo 1:

  1. Gemma-4 es un decoder-only denso — la arquitectura dominante del Capítulo 1, sin sorpresas ahí.
  2. Un segundo modelo (MTP) vive pegado al primero para acelerar generación — una pieza que el curso oficial no cubre porque es demasiado nueva.
  3. NVFP4 comprime cada peso a 4 bits en bloques de 16 con una escala FP8 compartida; cómo se elige esa escala (MSE > absmax) fue tu único cambio real este ciclo.
  4. Embeddings, lm_head, las proyecciones MTP y el draft completo se salvan de la cuantización — cada uno por una razón distinta y verificable.
  5. El decoding especulativo te dio 2.26× de velocidad real, a costa de mantener el draft en BF16 y las escalas de KV en modo dinámico, no calibrado.

Este es exactamente el mismo cuerpo de conocimiento que sostiene a interpretante-lacaniano — con el añadido de RAG, fine-tuning generacional, y un motor de razonamiento simbólico encima.

8. Ponete a prueba

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

1. Un modelo de 8B parámetros en BF16, ¿cuánto pesa? ¿Y en NVFP4 con escalas de bloque de 16?

8 × 10⁹ × 2 bytes = 16 GB en BF16. En NVFP4 son 4 bits por peso más 8 bits de escala cada 16 pesos: 4,5 bits efectivos → 8 × 10⁹ × 4,5 / 8 ≈ 4,5 GB, sin contar las capas que se dejan en BF16 ni el KV cache. (§2)

2. ¿Por qué un bloque de 16 pesos con un solo outlier grande sale mal con absmax, y qué hace distinto MSE?

Con absmax la escala se fija para que el outlier caiga en el valor máximo representable (6); los otros 15 pesos, mucho más chicos, quedan aplastados en los niveles cercanos a cero y pierden casi toda su información. MSE prueba varias escalas y elige la que minimiza el error total del bloque: acepta recortar el outlier a cambio de resolver bien los 15 restantes. (§2)

3. ¿Qué diferencia hay entre W4A16 y W4A4, y por qué NVFP4 necesita Blackwell?

W4A16 guarda pesos en 4 bits pero multiplica en 16: ahorra memoria, no cómputo. W4A4 cuantiza también la activación en runtime y multiplica 4×4 en el tensor core. NVFP4 es W4A4: necesita un tensor core que haga esa multiplicación de forma nativa, que es lo que Blackwell agrega. (§2)

4. ¿Qué significa que el checkpoint base sea "-qat" y qué relación tiene con lo que hace hf_ptq.py?

QAT: durante el entrenamiento se simula la cuantización para que los pesos aprendan a tolerar el redondeo; el checkpoint sigue en alta precisión. hf_ptq.py hace PTQ: cuantiza ese checkpoint ya entrenado con datos de calibración, sin gradiente. Se apilan: QAT prepara, PTQ ejecuta. (§2)

5. Si el draft acepta el 30% de los tokens en vez del 54%, ¿qué pasa con el speedup? ¿Y con la calidad del texto?

El speedup baja: con α = 0,3 y γ = 3 salen ≈1,4 tokens por pasada del grande en vez de ≈2, y con el costo del draft puede llegar a ser más lento que sin especulación. La calidad no cambia en absoluto: la regla de aceptación garantiza que la distribución de salida es la del modelo grande, sea cual sea el draft. (§4)

6. Un log de cuantización imprime texto degenerado en sus ejemplos. ¿Qué concluís?

Nada todavía. Esa prueba interna no aplica el chat template, así que un log feo es compatible con un checkpoint sano — y un log lindo fue compatible con el bug de proyecciones cuantizadas. Lo que prueba algo es el artefacto: exclude_modules en hf_quant_config.json y los dtypes/shapes en el header de los safetensors. (§5)

9. Para practicar

✏️ Ejercicio 1 — Inspeccioná el artefacto: dtype y shape de cada tensor

Tomá cualquier checkpoint en formato safetensors que tengas a mano (uno cuantizado si tenés, uno chico de Hugging Face si no) y listá cada tensor con su dtype y su forma. El header se lee sin cargar los pesos a memoria, así que corre en CPU en segundos aunque el modelo pese decenas de GB. Buscá: capas en U8 (empaquetadas en 4 bits), tensores de escala (nombres con scale), y cualquier capa que debería estar en BF16 y no lo está.

# pip install safetensors
import glob, collections
from safetensors import safe_open

carpeta = "ruta/al/checkpoint"
por_dtype = collections.Counter()
for archivo in sorted(glob.glob(f"{carpeta}/*.safetensors")):
    with safe_open(archivo, framework="pt") as f:
        for nombre in f.keys():
            t = f.get_slice(nombre)          # solo metadata, no carga el tensor
            dtype, shape = t.get_dtype(), tuple(t.get_shape())
            por_dtype[dtype] += 1
            if "proj" in nombre or "embed" in nombre or "lm_head" in nombre:
                print(f"{dtype:<8} {str(shape):<22} {nombre}")
print(por_dtype)
Pista / qué esperar

En un checkpoint NVFP4 típico vas a ver las matrices grandes en U8 con la última dimensión a la mitad de la del modelo (dos valores por byte), sus escalas en F8_E4M3 con la última dimensión dividida por 16, y las capas excluidas en BF16 con dimensiones completas. Si una capa que tenía que estar excluida aparece en U8 con la dimensión a la mitad, ese es exactamente el bug del §3 — detectado sin cargar nada en la GPU. El Counter final te da la foto global: cuántos tensores quedaron en cada dtype.

✏️ Ejercicio 2 — La cuenta del KV cache a partir de un config

El KV cache guarda, por cada token del contexto y por cada capa, un vector K y uno V de tamaño num_kv_heads × head_dim. Este script lee esa geometría de un config.json cualquiera (o usa una de ejemplo estilo Llama-3-8B) y calcula cuánto ocupa el cache para una longitud de contexto dada, en BF16 y en FP8. Corre en CPU, sin dependencias.

import json, sys

def kv_bytes_por_token(cfg, bytes_por_valor=2):
    L = cfg["num_hidden_layers"]
    kv_heads = cfg.get("num_key_value_heads", cfg["num_attention_heads"])
    head_dim = cfg.get("head_dim") or cfg["hidden_size"] // cfg["num_attention_heads"]
    return 2 * L * kv_heads * head_dim * bytes_por_valor   # 2 = K y V

# cfg = json.load(open(sys.argv[1]))        # el config.json de cualquier checkpoint
cfg = {"num_hidden_layers": 32, "num_attention_heads": 32,
       "num_key_value_heads": 8, "hidden_size": 4096}   # geometría estilo Llama-3-8B
for dtype, b in [("bf16", 2), ("fp8", 1)]:
    por_token = kv_bytes_por_token(cfg, b)
    print(f"{dtype}: {por_token/1024:.0f} KiB/token -> {por_token*26000/2**30:.2f} GiB para 26k tokens")
→ bf16: 128 KiB/token -> 3.17 GiB para 26k tokens · fp8: 64 KiB/token -> 1.59 GiB para 26k tokens
Pista / qué esperar

Dos cosas para probar después: (a) multiplicá por la cantidad de requests simultáneas que querés servir — el cache es por secuencia; (b) para un modelo con geometría heterogénea por capa, como Gemma-4 (head_dim distinto en capas de ventana y globales), la función tiene que sumar capa por capa en vez de multiplicar por L: ese es justo el tipo de detalle que un cálculo "global" se come, y el tema de la página de vLLM.

10. Fuentes y para seguir