Cuantización de entrenamiento vs cuantización de servicio

En el track de fine-tuning cubrimos QLoRA — cuantizar el modelo base para poder entrenarlo con adaptadores LoRA adjuntos. Los pesos congelados residían en formato NF4 de 4 bits para ahorrar memoria, pero el objetivo completo era habilitar el fine-tuning: los gradientes fluían a través de los pesos descuantizados, las matrices LoRA se actualizaban en FP16, y al final obteníamos un adaptador entrenado. La cuantización era un medio para un fin — el fin era el entrenamiento.

La cuantización de servicio es un juego completamente diferente. Comenzamos con un modelo terminado — ya pre-entrenado, ya ajustado, ya evaluado — y comprimimos sus pesos para que la inferencia sea más rápida y barata. No hay entrenamiento. No hay adaptadores. No hay gradientes. Tomamos el checkpoint final, lo cuantizamos una vez, y desplegamos la versión cuantizada para servicio. La pregunta ya no es "¿podemos hacer caber el entrenamiento en esta GPU?" sino "¿podemos servir este modelo más rápido y a más usuarios?"

¿Por qué comprimir pesos hace la inferencia más rápida? Recuerda del artículo 1 que la fase de decodificación de la generación autorregresiva está limitada por el ancho de banda de memoria : generar cada token requiere leer todos los pesos del modelo desde la memoria de la GPU, pero la intensidad aritmética es baja (estamos multiplicando esos pesos por las activaciones de un solo token). Las unidades de cómputo de la GPU pasan la mayor parte de su tiempo esperando a que los datos lleguen de la HBM. Si reducimos a la mitad el número de bytes por peso — digamos, de flotantes de 16 bits a enteros de 8 bits — reducimos a la mitad la cantidad de datos que la GPU necesita leer por token, y el rendimiento de decodificación se duplica aproximadamente. Ir de FP16 a INT4 significa una reducción de $4\times$ en bytes, lo que se traduce en hasta $4\times$ más rápido en la decodificación.

Las palabras "hasta" importan. La aceleración real depende de la sobrecarga de descuantización (la GPU debe convertir los pesos INT4 de vuelta a FP16 antes de multiplicar), la eficiencia del kernel, y si otros cuellos de botella (como la memoria de la KV cache o la sobrecarga de CPU) se vuelven dominantes una vez que el cuello de botella de carga de pesos se relaja. Pero el principio es claro: pesos más pequeños significan menos tráfico de memoria, y menos tráfico de memoria significa generación de tokens más rápida.

💡 Un modelo mental útil: la cuantización de entrenamiento (QLoRA) trata de hacer caber un modelo grande en una GPU pequeña para poder entrenarlo. La cuantización de servicio trata de hacer que un modelo entrenado responda más rápido y barato en producción. Las técnicas se superponen (ambas usan pesos de 4 bits), pero los objetivos y restricciones son diferentes.

Cuantización solo de pesos

La estrategia de cuantización de servicio más simple es la cuantización solo de pesos : comprimir las matrices de pesos del modelo a baja precisión (INT8, INT4 o incluso INT3), pero mantener las activaciones en su formato original FP16 o BF16. Durante cada multiplicación de matrices, los pesos cuantizados se descuantizan al vuelo de vuelta a FP16, se multiplican por las activaciones FP16, y el resultado se acumula en FP16. Los pesos cuantizados nunca se usan directamente en aritmética — son un formato de almacenamiento compacto que se desempaqueta justo antes de usar.

¿Por qué cuantizar pesos pero no activaciones? Los pesos son fijos : no cambian de una entrada a la siguiente. Esto significa que el error de cuantización que introducimos es constante y predecible — la solicitud de cada usuario recibe la misma aproximación, y podemos medir ese error offline antes del despliegue. Las activaciones, por otro lado, varían con cada entrada y son propensas a valores atípicos : ciertas dimensiones ocultas pueden dispararse a valores 10-100$\times$ más grandes que la mediana, haciendo que la cuantización uniforme para activaciones sea mucho más costosa en calidad. Los canales de activación con valores atípicos INT8 que Dettmers et al. documentaron en LLM.int8() mostraron que incluso unos pocos valores extremos de activación pueden destruir la calidad del modelo si se cuantizan ingenuamente. Los pesos se comportan mejor — sus distribuciones son aproximadamente gaussianas y estables, haciéndolos más fáciles de comprimir.

¿Cuánto ayuda la cuantización solo de pesos? La mejora efectiva del ancho de banda de memoria es simplemente la relación entre el ancho de bits original y el cuantizado:

$$\text{bandwidth improvement} = \frac{b_{\text{original}}}{b_{\text{quantized}}}$$

donde $b_{\text{original}}$ es el número de bits por peso en el modelo original y $b_{\text{quantized}}$ es el número de bits después de la cuantización. Para pesos INT4 reemplazando FP16:

$$\frac{b_{\text{original}}}{b_{\text{quantized}}} = \frac{16}{4} = 4$$

Eso es $4\times$ menos memoria para leer por token durante la decodificación. En el caso ideal — donde la decodificación está puramente limitada por el ancho de banda de memoria y el cómputo de descuantización es despreciable — esto se traduce directamente en un rendimiento de decodificación $4\times$ mayor. En la práctica, los kernels de descuantización añaden algo de sobrecarga computacional, y la fase de prefill (que está limitada por el cómputo, no la memoria) ve menos beneficio. Pero para la fase de decodificación autorregresiva que domina el tiempo real en cargas de trabajo con generación larga, la mejora es sustancial.

Veamos los casos límite. Con $b_{\text{quantized}} = 16$ (sin cuantización), la relación es 1 — sin mejora. Con $b_{\text{quantized}} = 8$ (INT8), obtenemos $2\times$. Con $b_{\text{quantized}} = 4$ (INT4), obtenemos $4\times$. Con $b_{\text{quantized}} = 2$ (INT2), obtendríamos $8\times$ — pero la cuantización de 2 bits típicamente destruye la calidad del modelo para todas excepto las tareas más simples. El punto óptimo para los modelos de lenguaje grandes hoy es 4 bits, que proporciona una gran mejora de ancho de banda con degradación mínima de calidad cuando se hace cuidadosamente.

💡 La cuantización solo de pesos a INT4 también reduce la huella de memoria del modelo por $4\times$, lo que significa que puedes servir un modelo que normalmente requeriría 4 GPUs en una sola GPU — o hacer caber un modelo de 70B parámetros (140 GB en FP16) en aproximadamente 35 GB, que cabe en una sola A100 de 80 GB con espacio para la KV cache y las activaciones.

GPTQ: Cuantización post-entrenamiento mediante información de segundo orden

Simplemente redondear cada peso al valor INT4 más cercano ("round-to-nearest" o RTN) funciona a 8 bits, pero a 4 bits los errores de redondeo acumulados en miles de millones de parámetros degradan notablemente la calidad del modelo. ¿Podemos hacerlo mejor? ¿Qué pasa si, cuando cuantizamos un peso, ajustamos los pesos restantes para compensar el error que acabamos de introducir?

Esa es la intuición detrás de GPTQ (Frantar et al., 2022) , un método de cuantización post-entrenamiento que usa información de segundo orden (la matriz hessiana) para cuantizar pesos una columna a la vez mientras ajusta las columnas aún no cuantizadas para minimizar el error total de salida. GPTQ se construye sobre el framework Optimal Brain Quantization (OBQ), pero lo re-diseña para escalar a modelos de miles de millones de parámetros procesando columnas en lugar de pesos individuales y usando un orden fijo de columnas en lugar de uno voraz.

La idea central funciona capa por capa. Para una capa lineal dada con matriz de pesos $\mathbf{W}$ y un pequeño conjunto de entradas de calibración $\mathbf{X}$ (típicamente 128 ejemplos de un dataset representativo), queremos encontrar una matriz de pesos cuantizada $\hat{\mathbf{W}}$ que minimice el error cuadrático en la salida de la capa:

$$\min_{\hat{\mathbf{W}}} \| \mathbf{W}\mathbf{X} - \hat{\mathbf{W}}\mathbf{X} \|_2^2$$

Si cuantizamos la columna $j$ de $\mathbf{W}$ e introducimos un error $\delta_j = w_j - \hat{w}_j$ (la diferencia entre el peso original y el cuantizado para esa columna), podemos compensar las columnas restantes $j+1, \ldots, d$ desplazándolas proporcionalmente. La compensación óptima está dada por la hessiana $\mathbf{H} = 2\mathbf{X}\mathbf{X}^\top$, que captura cómo los cambios en cada peso afectan la salida de la capa. Específicamente, cuando cuantizamos la columna $j$, la actualización de los pesos no cuantizados restantes en la misma fila es:

$$\delta_{\text{remaining}} = -\frac{\delta_j}{[\mathbf{H}^{-1}]_{jj}} \cdot \mathbf{H}^{-1}_{j, j+1:d}$$

Esta actualización distribuye el error de cuantización de la columna $j$ entre las columnas restantes de una manera que minimiza el error total de salida, ponderado por cuán sensible es la salida a cada peso (capturado por $\mathbf{H}^{-1}$). El denominador $[\mathbf{H}^{-1}]_{jj}$ normaliza por la auto-sensibilidad del peso cuantizado. En un extremo, si el error de cuantización $\delta_j$ es cero (el peso ya estaba en un punto de la cuadrícula de cuantización), no se necesita compensación. En el otro extremo, si $[\mathbf{H}^{-1}]_{jj}$ es muy grande (la salida no es muy sensible a este peso), los factores de compensación son pequeños — el error no importa mucho, así que hay poco que corregir.

En la práctica, GPTQ procesa 128 columnas a la vez en bloques (para mejor utilización de la GPU), usa un factor de amortiguamiento para estabilizar la inversa de la hessiana, y ejecuta una descomposición de Cholesky para estabilidad numérica. La configuración típica es INT4 con tamaño de grupo 128 : los pesos en cada fila se dividen en grupos de 128, y cada grupo obtiene su propio factor de escala y punto cero. Esto significa que la cuadrícula de cuantización se adapta a la distribución local de pesos dentro de cada grupo, en lugar de usar una sola escala para una fila entera de potencialmente miles de valores. La sobrecarga es almacenar una escala FP16 y un punto cero FP16 por cada 128 pesos, lo que añade aproximadamente 0.25 bits por peso — así que "INT4 g128" es efectivamente alrededor de 4.25 bits por peso.

El resultado: GPTQ produce modelos de 4 bits casi sin pérdida para la mayoría de las arquitecturas (Llama, Mistral, Phi, etc.), con incrementos de perplejidad típicamente menores a 0.1 en benchmarks estándar. A 3 bits, la degradación de calidad se vuelve notable — los 8 niveles representables ($2^3 = 8$) son demasiado pocos para muchas distribuciones de pesos, e incluso la compensación basada en la hessiana no puede recuperar completamente la información perdida. La cuantización en sí toma de minutos a horas dependiendo del tamaño del modelo (un costo offline de una sola vez), pero el modelo resultante se sirve a velocidad INT4 indefinidamente.

💡 GPTQ requiere un dataset de calibración de aproximadamente 128 ejemplos para estimar la hessiana. Estos deben ser representativos del uso previsto del modelo (por ejemplo, texto C4 para un modelo de propósito general). Los datos de calibración no se usan para entrenamiento — solo para medir sensibilidades de pesos. Usar un conjunto de calibración no coincidente puede dañar la calidad: un modelo de código calibrado con artículos de noticias puede cuantizar pobremente para tareas de código.

AWQ: Cuantización de pesos consciente de activaciones

GPTQ compensa el error de cuantización usando información de segundo orden, lo cual funciona bien pero requiere calcular e invertir una matriz hessiana para cada capa. ¿Hay un enfoque más simple? ¿Qué pasa si pudiéramos determinar qué pesos importan más y darles más precisión de cuantización, sin toda la maquinaria de la hessiana?

Ese es el enfoque de AWQ (Activation-Aware Weight Quantization) (Lin et al., 2023) . La observación clave es que no todos los pesos son igualmente importantes para la calidad del modelo. Algunos canales de pesos corresponden a canales de activación salientes — dimensiones ocultas que consistentemente tienen grandes magnitudes de activación en diversas entradas. Aproximadamente el 1% de las columnas de pesos caen en esta categoría, y cuantizarlas descuidadamente causa una pérdida de calidad desproporcionada. El otro 99% de los pesos puede cuantizarse agresivamente con impacto mínimo.

¿Cómo identifica AWQ estos canales salientes? Ejecutando un pequeño conjunto de calibración a través del modelo y observando qué canales de activación tienen las mayores magnitudes promedio. Si el canal $j$ consistentemente produce activaciones grandes $\bar{a}_j = \mathbb{E}[|a_j|]$, los pesos en la columna $j$ de la matriz de pesos son salientes — los errores en esos pesos se amplifican por las grandes activaciones que multiplican.

Una solución ingenua sería mantener los pesos salientes en FP16 mientras se cuantiza el resto a INT4. Pero el almacenamiento de precisión mixta rompe el diseño uniforme de memoria que los kernels de GPU necesitan para cómputo eficiente — perderías la mayor parte del beneficio de velocidad. En cambio, AWQ aplica un truco de escalado por canal . Antes de la cuantización, multiplica las columnas de pesos salientes por un factor de escala $s_j > 1$, efectivamente "haciendo zoom" en su rango de valores:

$$w_j' = w_j \cdot s_j, \qquad a_j' = a_j / s_j$$

El producto $w_j \cdot a_j = w_j' \cdot a_j'$ no cambia, así que la salida del modelo es matemáticamente idéntica antes de la cuantización. Pero ahora $w_j'$ ocupa un rango numérico más amplio, lo que significa que la cuadrícula de cuantización INT4 (que está espaciada uniformemente a través del rango) asigna más de sus 16 niveles a los valores que más importan. El factor de escala se absorbe en los pesos de la capa anterior o los parámetros de normalización, así que la división de activación ocurre implícitamente — sin sobrecarga en tiempo de ejecución.

El factor de escala óptimo para cada canal se encuentra mediante una búsqueda en cuadrícula simple que minimiza el error de cuantización en el conjunto de calibración:

$$s_j^* = \arg\min_{s_j} \| Q(w_j \cdot s_j) \cdot (a_j / s_j) - w_j \cdot a_j \|^2$$

donde $Q(\cdot)$ denota la operación de cuantización (redondear al punto más cercano de la cuadrícula INT4). Con $s_j = 1$, obtenemos cuantización estándar sin escalado — la línea base. A medida que $s_j$ aumenta, los valores de los pesos se extienden sobre un rango más amplio y obtienen mayor granularidad de cuantización relativa a la magnitud de la activación, reduciendo el error de cuantización para ese canal. Pero hay una compensación: escalar los pesos también amplifica su error de redondeo absoluto (los pasos de la cuadrícula INT4 se hacen más grandes en términos absolutos), así que hay un punto óptimo donde la reducción en error relativo supera el aumento en error absoluto. La búsqueda en cuadrícula típicamente evalúa valores del conjunto $\{s_j^{\alpha} : \alpha \in [0, 1]\}$ donde el $s_j$ inicial es proporcional a la magnitud de la activación $\bar{a}_j$.

Comparado con GPTQ, AWQ es más simple y rápido de ejecutar (sin cálculo de hessiana ni inversiones de matrices), y empíricamente iguala o supera la calidad de GPTQ en la mayoría de los benchmarks. Ambos métodos están ampliamente soportados en frameworks de servicio: vLLM, TGI (Text Generation Inference), y TensorRT-LLM todos tienen kernels optimizados para modelos INT4 tanto de GPTQ como de AWQ.

💡 AWQ también requiere un pequeño conjunto de calibración (típicamente 128 ejemplos), pero solo lo usa para medir magnitudes de activación — no para calcular una hessiana. Esto hace que la cuantización AWQ sea más rápida (minutos para un modelo de 70B) y menos sensible a los datos de calibración específicos elegidos.

GGUF e inferencia CPU/híbrida

GPTQ y AWQ están diseñados para servicio en GPU: producen matrices de pesos cuantizados que se cargan en memoria de GPU y se descuantizan mediante kernels CUDA personalizados durante la inferencia. Pero ¿qué pasa si no tienes una GPU — o no tienes suficiente memoria de GPU para contener el modelo completo? ¿Qué pasa si quieres ejecutar un modelo de 70B en un MacBook con 64 GB de memoria unificada, o en un servidor con una GPU pequeña complementada por RAM del sistema?

Ese es el nicho que llena el formato GGUF y el ecosistema de llama.cpp (Gerganov et al.) . GGUF (GGML Universal Format) es un formato de archivo diseñado específicamente para el almacenamiento de modelos cuantizados e inferencia eficiente en CPU. Empaqueta los metadatos de arquitectura del modelo, el tokenizador, y los pesos cuantizados en un solo archivo que llama.cpp (y herramientas construidas sobre él, como Ollama, LM Studio, y koboldcpp) puede cargar y ejecutar sin dependencias externas — sin Python, sin PyTorch, sin toolkit CUDA requerido.

GGUF soporta una rica variedad de niveles de cuantización, cada uno identificado por un código corto:

  • Q2_K: cuantización de 2 bits con estructura k-quant. Compresión muy agresiva, pérdida de calidad notable. Útil solo cuando la memoria es extremadamente limitada.
  • Q3_K_S / Q3_K_M / Q3_K_L: 3 bits con tamaños de grupo pequeño/mediano/grande. Calidad marginal, pero permite que modelos muy grandes quepan en RAM limitada.
  • Q4_K_S / Q4_K_M: 4 bits pequeño/mediano. La opción más popular — buen balance entre calidad y compresión. Q4_K_M usa ligeramente más bits para capas importantes.
  • Q5_K_S / Q5_K_M: 5 bits. Calidad casi FP16 para la mayoría de tareas, con aproximadamente $3\times$ de compresión.
  • Q6_K: 6 bits. Muy cercano a la calidad FP16, útil cuando tienes suficiente memoria y quieres degradación mínima.
  • Q8_0: 8 bits. Esencialmente sin pérdida para todos los propósitos prácticos, con $2\times$ de compresión.

La "K" en estos nombres significa k-quants — un esquema de cuantización que asigna diferentes precisiones a diferentes capas dentro del mismo modelo. La intuición es que no todas las capas son igualmente sensibles a la cuantización. Las capas de atención y las primeras/últimas capas de la red tienden a ser más sensibles que las capas feed-forward en el medio. Los k-quants asignan más bits (mayor precisión) a las capas sensibles y menos bits a las robustas, logrando mejor calidad al mismo ancho de bits promedio que la cuantización uniforme. Por ejemplo, Q4_K_M podría usar cuantización de 6 bits para las proyecciones Q/K de atención y las primeras/últimas capas, mientras usa 4 bits para la mayoría de las capas FFN.

La diferencia más importante entre GGUF y GPTQ/AWQ es el hardware objetivo. Los modelos GPTQ y AWQ se sirven mediante runtimes optimizados para GPU (vLLM, TGI, TensorRT-LLM) que necesitan el modelo completo en memoria de GPU. Los modelos GGUF están diseñados para inferencia en CPU e inferencia híbrida CPU+GPU . En modo híbrido, llama.cpp coloca tantas capas como quepan en la GPU, y las capas restantes se ejecutan en la CPU usando RAM del sistema. Esto hace posible ejecutar modelos que exceden la memoria de la GPU — un modelo de 70B Q4_K_M de aproximadamente 40 GB puede ejecutarse en un sistema con 24 GB de VRAM más 64 GB de RAM del sistema, con la GPU manejando las capas que puede alojar y la CPU manejando el resto.

La compensación es la velocidad. La inferencia en CPU es mucho más lenta que la inferencia en GPU para modelos grandes — el ancho de banda de la RAM del sistema (DDR5 a ~50-80 GB/s) es muy inferior al ancho de banda de la HBM (A100 a ~2 TB/s). Pero para muchos casos de uso — desarrollo local, despliegues sensibles a la privacidad, dispositivos de borde, o simplemente experimentar con modelos que no puedes pagar servir en GPUs — GGUF hace accesibles los modelos de lenguaje grandes en hardware que de otra manera no podría ejecutarlos en absoluto.

💡 Los Macs con Apple Silicon son particularmente adecuados para inferencia GGUF porque su arquitectura de memoria unificada da a la GPU acceso directo a la RAM del sistema a ~100-200 GB/s, mucho más rápido que una configuración discreta CPU+GPU. Un Mac Studio con 192 GB de memoria unificada puede ejecutar un modelo de 70B a Q4_K_M completamente "en el dispositivo" con velocidades razonables de generación de tokens.

Elegir la cuantización correcta

Con múltiples métodos y formatos de cuantización disponibles, ¿cómo eliges? La decisión se reduce a tres factores: dónde estás sirviendo (GPU, CPU o híbrido), cuánta memoria tienes, y cuánta degradación de calidad puedes tolerar. La tabla a continuación resume las compensaciones.

import json, js

rows = [
    ["GPTQ INT4 (g128)", "4.25", "GPU", "Near-lossless", "Fast (vLLM, TGI)", "GPU serving at scale"],
    ["AWQ INT4",         "4.25", "GPU", "Near-lossless", "Fast (vLLM, TGI)", "GPU serving at scale"],
    ["GPTQ INT3 (g128)", "3.25", "GPU", "Noticeable loss", "Fast", "Max compression (GPU)"],
    ["GGUF Q4_K_M",      "~4.5", "CPU / hybrid", "Good", "Moderate", "Local / laptop / Ollama"],
    ["GGUF Q5_K_M",      "~5.5", "CPU / hybrid", "Very good", "Moderate", "Local, quality-sensitive"],
    ["GGUF Q8_0",        "8.0",  "CPU / hybrid", "Lossless*", "Slower (more bytes)", "Max quality, enough RAM"],
    ["FP16 (no quant)",  "16.0", "GPU", "Baseline", "Memory-bound", "When memory allows"],
    ["INT8 (LLM.int8)", "8.0",   "GPU", "Lossless*", "~1.5x vs FP16", "Simple, safe compression"],
]

js.window.py_table_data = json.dumps({
    "headers": ["Method", "Bits/weight", "Hardware", "Quality", "Speed", "Use case"],
    "rows": rows
})

print("* 'Lossless' means perplexity increase < 0.01 on standard benchmarks.")
print("  Actual quality depends on the model and task.")

Algunas reglas generales para escenarios comunes:

  • Servicio en GPU a escala (vLLM, TGI, TensorRT-LLM): usa AWQ o GPTQ INT4 con tamaño de grupo 128. Ambos están bien soportados por frameworks de servicio en producción, dan calidad casi sin pérdida, y proporcionan la mejora completa de $4\times$ en ancho de banda de memoria. AWQ es frecuentemente un poco más fácil de producir y marginalmente mejor en calidad; GPTQ tiene un historial más largo y mayor soporte de herramientas. Cualquiera funciona.
  • Local / laptop / borde (llama.cpp, Ollama, LM Studio): usa GGUF Q4_K_M para la mejor calidad por byte, o Q5_K_M si tienes la RAM y quieres mayor calidad. Q4_K_M es la recomendación por defecto de la comunidad por una razón — alcanza el punto óptimo donde la calidad es aún buena y el modelo cabe en memoria razonable.
  • Máxima calidad, la memoria no es una restricción: sirve en FP16 o BF16. Si quieres algo de compresión sin pérdida medible de calidad, INT8 (vía LLM.int8() o FP8 en GPUs Hopper) es efectivamente gratis.
  • Máxima compresión, la calidad es secundaria: GPTQ 3-bit o GGUF Q3_K_M. Espera degradación notable en tareas de razonamiento e intensivas en conocimiento, pero aceptable para tareas de generación más simples o cuando el modelo es muy grande en relación con la memoria disponible.

Un punto final: la cuantización se compone con todas las demás optimizaciones en este track. Puedes servir un modelo AWQ INT4 con grouped-query attention (GQA), PagedAttention para gestión de KV cache, batching continuo para maximizar la utilización de la GPU, y decodificación especulativa para reducir la latencia — todo al mismo tiempo. Estas técnicas son ortogonales: la cuantización reduce los bytes por peso, GQA reduce los bytes por entrada de KV cache, el batching continuo amortiza la sobrecarga fija entre solicitudes, y la decodificación especulativa intercambia cómputo extra por menos pasos secuenciales de decodificación. Apilarlas es cómo los sistemas de servicio en producción logran los números de rendimiento y latencia que hacen económicamente viable la inferencia de modelos grandes.

Quiz

Pon a prueba tu comprensión de la cuantización para servicio.

¿Por qué la cuantización solo de pesos a INT4 acelera la fase de decodificación de la generación autorregresiva?

¿Para qué usa GPTQ la matriz hessiana durante la cuantización?

¿Cómo maneja AWQ el ~1% de canales de pesos salientes en lugar de mantenerlos en mayor precisión?

¿Cuál es la ventaja clave de los k-quants de GGUF sobre la cuantización uniforme?