Módulo 09 · Despliegue

Fusión, GGUF y cuantización

El entrenamiento terminó, pero el modelo todavía no está listo para correr fuera del laboratorio. Lo que existe es un modelo base de Hugging Face más un adaptador de pocos megabytes. El camino al despliegue es una tubería concreta: fusionar el adaptador en los pesos base, convertir ese modelo al formato GGUF, cuantizar para que quepa y corra rápido, y medir cuánta calidad se perdió en el proceso. Este módulo fija cada paso y la matemática de la cuantización — porque bajar de fp16 a 4 bits no es gratis, y hay que saber exactamente cuánto cuesta y cómo verificar que el chat template sigue intacto.

Al terminar sabrás

  • Fusionar un adaptador LoRA con merge_and_unload y exportar modelo + tokenizer.
  • Convertir un modelo HF a GGUF con convert_hf_to_gguf.py y entender qué guarda ese formato.
  • Distinguir las cuantizaciones Q8 / Q6 / Q5 / Q4 (k-quants) por su presupuesto de bits por peso.
  • Usar una matriz de importancia (imatrix) para proteger los canales de alto impacto a bajo bpw.
  • Medir la degradación con ΔPPL y la divergencia KL, y verificar que el chat template no se rompió.

1La tubería de despliegue

Después de entrenar con LoRA (Módulo 5) no tienes un modelo nuevo: tienes el modelo base congelado más una matriz de bajo rango que lo corrige. Para desplegar hay que colapsar ambas cosas en un único conjunto de pesos, cambiar a un formato que el runtime entienda, y reducir la precisión numérica para que entre en memoria. Cada flecha de la tubería es una transformación que puede — y debe — verificarse antes de pasar a la siguiente.

modelo HF + adaptador → merge_and_unload → pesos fp16/bf16 → convert_hf_to_gguf.py → GGUF f16 → quantize (Q8…Q4 + imatrix) → GGUF cuantizado → runtime
El adaptador entra por la izquierda; por la derecha sale un archivo que corre en el dispositivo.

2Fusionar el adaptador

LoRA aprendió dos matrices pequeñas, $B$ y $A$, cuyo producto escalado modifica cada peso base $W_0$. Fusionar es literalmente aplicar esa suma y descartar las matrices de bajo rango, dejando pesos densos ordinarios:

$$ W = W_0 + \dfrac{\alpha}{r} BA $$

En la práctica, con una carga PEFT sobre el modelo base, merge_and_unload() recorre cada capa adaptada, calcula esa suma y devuelve un modelo estándar sin dependencia de PEFT. Luego se guarda tanto el modelo como el tokenizer en el mismo directorio: el tokenizer arrastra el vocabulario y — clave para lo que viene — la plantilla de chat.

model = AutoModelForCausalLM.from_pretrained(base, torch_dtype=torch.bfloat16)
model = PeftModel.from_pretrained(model, adapter_dir)
model = model.merge_and_unload()          # W = W0 + (alpha/r)·B·A
model.save_pretrained(merged_dir)         # pesos fusionados fp16/bf16
tokenizer.save_pretrained(merged_dir)     # vocab + chat_template
Del adaptador a un modelo autónomo. A partir de aquí ya no existe LoRA.
Nota · precisión

Fusiona y exporta en fp16/bf16, no en la precisión de entrenamiento en 4 bits. Si entrenaste con QLoRA, el modelo base estaba cuantizado para ahorrar VRAM, pero el artefacto que fusionas debe estar en punto flotante de 16 bits para que la cuantización posterior parta de una base limpia.

3Convertir a GGUF

GGUF es el formato de archivo que consumen los runtimes tipo llama.cpp: un solo binario con los tensores, los metadatos de arquitectura (número de capas, cabezas, dimensión), el vocabulario y la plantilla de chat. La conversión desde Hugging Face la hace el script convert_hf_to_gguf.py, que primero produce un GGUF en punto flotante:

python convert_hf_to_gguf.py ./merged_dir \
    --outfile modelo-f16.gguf \
    --outtype f16
El GGUF f16 es el "master": pesa lo mismo que el modelo, pero ya está en el formato del runtime.

Este archivo todavía es grande — no se cuantizó nada — pero es la fuente desde la cual se derivan todas las variantes cuantizadas. Conviene generarlo una sola vez y reutilizarlo.

4Cuantización: k-quants por bloques

Cuantizar es representar cada peso con menos bits. Los k-quants de llama.cpp no cuantizan pesos aislados: agrupan los pesos en bloques y, dentro de cada bloque, guardan enteros de baja precisión más una (super)escala y, según el esquema, un mínimo. El coste real en memoria se mide en bits por peso (bpw), que promedia los datos cuantizados y el overhead de las escalas sobre el tamaño del bloque:

$$ \text{bpw} = \dfrac{\text{datos} + \text{overhead de escala}}{\text{tamaño de bloque}} $$

Por eso los nombres no corresponden a un número entero de bits: cada esquema tiene su propio bpw efectivo una vez contado el overhead.

$$ \text{Q8\_0} \approx 8.5, \qquad \text{Q6\_K} \approx 6.6, \qquad \text{Q5\_K} \approx 5.5, \qquad \text{Q4\_K} \approx 4.5 $$

Reconstruir un peso desde su forma cuantizada (dequant) es la operación inversa: se toma el entero $q$ del bloque, se multiplica por la escala del bloque y se suma el mínimo del bloque:

$$ w = \text{escala}\cdot q + \min \qquad \text{(por bloque)} $$

El término $+\min$ es de los esquemas con punto cero. Los esquemas simétricos (Q8_0, Q6_K) no lo llevan ($\min = 0$): dequantizan con solo $w = \text{escala}\cdot q$.

./llama-quantize modelo-f16.gguf modelo-Q4_K_M.gguf Q4_K_M
./llama-quantize modelo-f16.gguf modelo-Q5_K_M.gguf Q5_K_M
./llama-quantize modelo-f16.gguf modelo-Q6_K.gguf   Q6_K
./llama-quantize modelo-f16.gguf modelo-Q8_0.gguf   Q8_0
Cada variante parte del mismo f16. El sufijo _M indica un mix (medio) de precisiones por tipo de tensor.

5imatrix: no todos los canales pesan igual

Cuantizar minimizando el error cuadrático medio trata a todos los pesos por igual — pero no lo son. Un canal por el que pasan activaciones grandes contribuye mucho más a la salida que uno casi siempre inactivo. La matriz de importancia (imatrix) estima esa importancia corriendo el modelo sobre un pequeño set de calibración y midiendo la energía de la activación por canal:

$$ I_j = \mathbb{E}_x\big[a_j(x)^2\big] $$

Con esos pesos, la cuantización deja de minimizar el error crudo y pasa a minimizar el error ponderado por importancia:

$$ \min \sum_j I_j\,(w_j - \hat w_j)^2 \qquad \text{en vez del MSE sin pesar} $$

El efecto es que los canales de alto impacto reciben más "presupuesto" de precisión y los poco importantes absorben la pérdida. La ganancia es marginal en Q6/Q8 pero decisiva a bajo bpw: en Q4 una imatrix bien calculada puede recuperar buena parte de la calidad que la cuantización naive tira.

./llama-imatrix -m modelo-f16.gguf -f calibracion.txt -o modelo.imatrix
./llama-quantize --imatrix modelo.imatrix modelo-f16.gguf modelo-Q4_K_M-imat.gguf Q4_K_M
Primero se calcula la imatrix sobre el set de calibración; luego se cuantiza usándola.
Cuidado · calibración

El set de calibración debe parecerse a lo que el modelo verá en producción. Si tu fine-tuning es de dominio (código, un idioma, un formato de chat), calibra con texto de ese dominio. Una imatrix calculada sobre texto genérico protege los canales equivocados.

6Medir la degradación

Cuantizar sin medir es adivinar. La métrica base es el cambio de perplejidad sobre un set de evaluación: cuánto empeoró el modelo cuantizado frente al f16.

$$ \Delta\mathrm{PPL} = \mathrm{PPL}_{\text{quant}} - \mathrm{PPL}_{\text{fp16}} $$

La PPL es una señal gruesa: promedia sobre todo el corpus y esconde regresiones localizadas. Una señal más fina es la divergencia KL entre la distribución del siguiente token del modelo f16 y la del cuantizado, token a token — captura cuándo el modelo cuantizado redistribuye masa de probabilidad aunque la PPL apenas se mueva:

$$ D_{\mathrm{KL}}(p_{\text{fp16}} \,\Vert\, p_{\text{quant}}) $$

En la práctica se reportan ambas: ΔPPL para el titular y la KL (media y percentiles altos) para detectar los casos peores. Una cuantización con ΔPPL pequeña pero cola de KL alta puede romper prompts concretos aunque el promedio se vea bien.

7Verificar el chat template

Un error silencioso frecuente: el modelo cuantiza bien, corre rápido, y responde como modelo base — sin respetar los roles ni los tokens especiales del formato de chat. Casi siempre la causa es que la plantilla o los tokens especiales no viajaron en la conversión. Antes de dar por bueno el artefacto, hay que confirmar que el mismo prompt, formateado con la plantilla, produce en el GGUF cuantizado una respuesta equivalente a la del modelo fusionado en Hugging Face.

PasoQué transformaQué verificar
merge_and_unload$W_0 + \frac{\alpha}{r}BA \to W$Sale un modelo fp16 autónomo, sin PEFT
convert_hf_to_ggufHF → GGUF f16Metadatos, vocab y chat template presentes
quantizef16 → Q8…Q4Tamaño esperado por bpw; sin errores de tensor
evaluarΔPPL y KL bajo umbral; respuestas con roles correctos

La regla operativa del módulo: cada flecha de la tubería se verifica antes de cruzar la siguiente. Un chat template roto detectado al final obliga a repetir conversión y cuantización de todas las variantes.

En el teléfono

Este es el módulo que produce el archivo que corre en el teléfono. El .gguf cuantizado (p.ej. Q4_K_M) es el artefacto de despliegue: pequeño, mmap-eable y listo para llama.cpp o candle. Elegir el bpw es un trade-off directo entre tamaño/velocidad en el dispositivo y calidad.

Cómo practicar
  1. Fusiona el LoRA (merge_and_unload) y exporta a fp16.
  2. Convierte a GGUF con convert_hf_to_gguf.py.
  3. Cuantiza a Q8_0 / Q6_K / Q5_K_M / Q4_K_M.
  4. Repite con una imatrix calculada sobre un set de calibración.
  5. Grafica ΔPPL y tamaño vs bpw y elige el bpw más bajo bajo tu umbral.
Herramientas: llama.cpp (convert + quantize + imatrix)
Rust · on-device

El GGUF es el formato nativo de llama.cpp, y candle también lo lee: el mismo archivo cuantizado lo carga tu app en Rust.

Aquí converge todo el curso: entrenas en Python, empaquetas a GGUF y el runtime Rust del teléfono lo consume.

Lecturas y recursos

Ejercicios

De menor a mayor complejidad. El último es el que hace un practicante de verdad.

Ejercicio 1 · calentamiento

Fusiona y guarda

Carga el modelo base con su adaptador LoRA, aplica merge_and_unload() y guarda modelo + tokenizer en un directorio. Confirma que el chat_template quedó en el tokenizer guardado.

Entrega: directorio fusionado + la línea del chat_template impresa.   Pista: tokenizer.chat_template no debe ser None.

Ejercicio 2

Convierte a GGUF f16

Ejecuta convert_hf_to_gguf.py sobre el directorio fusionado con --outtype f16. Inspecciona los metadatos del GGUF resultante y localiza el número de capas, cabezas y el vocabulario.

Entrega: tamaño del f16 + un volcado de sus metadatos clave.   Pista: usa el lector de metadatos GGUF de llama.cpp o gguf-dump.

Ejercicio 3

Cuantiza a cuatro niveles

Genera Q8_0, Q6_K, Q5_K_M y Q4_K_M desde el mismo f16. Tabula el tamaño de archivo de cada una y compáralo con el bpw teórico.

Entrega: tabla esquema → tamaño (MB) → bpw.   Pista: $\text{bpw} \approx \frac{\text{bytes}\times 8}{\text{nº de parámetros}}$.

Ejercicio 4

Mide la degradación

Calcula la perplejidad de la variante f16 y de cada cuantización sobre un set de evaluación fijo, y reporta $\Delta\mathrm{PPL}$. Añade la divergencia KL media frente al f16 y observa dónde diverge de la PPL.

Entrega: tabla esquema → ΔPPL → KL media.   Pista: llama-perplexity sobre el mismo corpus para todas las variantes.

Ejercicio 5 · ejercicio top

Curva bpw vs ΔPPL, naive contra imatrix

Fusiona el adaptador LoRA en Qwen3-0.6B, convierte a GGUF (convert_hf_to_gguf.py), y cuantiza a Q8_0, Q6_K, Q5_K_M, Q4_K_M — una vez de forma naive y otra con una imatrix calculada sobre un set de calibración — y grafica $\Delta$PPL (y tamaño de archivo) vs bpw para ambos, identificando el bpw más bajo que se mantiene bajo tu umbral aceptable de degradación.

Entrega: una curva bpw-vs-$\Delta$PPL/tamaño mostrando la ganancia de la imatrix en Q4 y tu cuantización de despliegue elegida.   Pista: calcula la imatrix con llama-imatrix sobre texto de tu dominio antes de cuantizar la rama con imatrix.

Puntos clave
  • La tubería es fusionar → convertir a GGUF → cuantizar → medir; cada flecha se verifica antes de la siguiente.
  • merge_and_unload colapsa $W_0 + \frac{\alpha}{r}BA$ en pesos densos fp16; guarda modelo y tokenizer.
  • Los k-quants cuantizan por bloques con escala/mínimo; el coste real se mide en bpw, no en bits nominales.
  • La imatrix pondera el error por importancia de activación y es decisiva a bajo bpw (Q4).
  • Mide con ΔPPL y KL, y confirma que el chat template viajó intacto antes de desplegar.