Quantizzazione dei Modelli: INT8, INT4, GPTQ, AWQ e Beyond
Un modello GPT-4 full-precision occupa centinaia di gigabyte. Un Llama-3 70B in FP16 richiede 140 GB di VRAM. Questi numeri rendono impossibile eseguire modelli di grandi dimensioni su hardware consumer — a meno di non applicare la quantizzazione. Con la quantizzazione INT4, lo stesso Llama-3 70B scende a 35 GB, rientrando in due RTX 4090 o in un sistema con 64 GB di RAM. Il tutto con una perdita di accuratezza spesso inferiore all'1%.
La quantizzazione dei modelli e diventata una delle tecniche più importanti nell'ecosistema LLM moderno. Non si tratta più di un trucco per risparmiare memoria: e la chiave per rendere i modelli accessibili, deployabili su edge devices, eseguibili su hardware consumer e competitive in termini di latenza. Algoritmi come GPTQ, AWQ, SmoothQuant e il formato GGUF di llama.cpp hanno democratizzato l'accesso ai LLM.
In questa guida esploriamo la quantizzazione da zero: dalla matematica di base alla scelta del metodo giusto per il tuo caso d'uso, con esempi di codice funzionanti per ciascuna tecnica.
Cosa Imparerai
- perchè la quantizzazione e fondamentale per l'AI moderna
- Differenza tra PTQ (Post-Training Quantization) e QAT (Quantization-Aware Training)
- Quantizzazione INT8 con bitsandbytes e SmoothQuant
- Quantizzazione INT4 con NF4, FP4 e QLoRA
- Algoritmo GPTQ: come funziona e quando usarlo
- AWQ (Activation-Aware Weight Quantization): vantaggi su hardware eterogeneo
- Formato GGUF e quantizzazione con llama.cpp
- Benchmark accuratezza vs velocità vs memoria
- Quantization-Aware Training con PyTorch
- Best practices e casi d'uso reali
perchè Quantizzare? Il Problema della VRAM
Un parametro in FP32 occupa 4 byte. Un parametro in FP16/BF16 occupa 2 byte. Con la quantizzazione a 8 bit, ogni parametro occupa 1 byte; con INT4, solo 0.5 byte. La tabella seguente illustra il consumo di memoria per Llama-3 70B con diverse precisioni:
| Precisione | Byte per param | Memoria (70B) | GPU richiesta |
|---|---|---|---|
| FP32 | 4 byte | 280 GB | Impossibile consumer |
| BF16 / FP16 | 2 byte | 140 GB | 2x A100 80GB |
| INT8 | 1 byte | 70 GB | 1x A100 80GB |
| INT4 / NF4 | 0.5 byte | 35 GB | 2x RTX 4090 (24GB) |
| INT3 / Q3_K_M | 0.375 byte | ~26 GB | RTX 3090 + RAM offload |
Oltre alla VRAM, la quantizzazione porta benefici in termini di throughput (token/sec), latenza di inferenza e costo cloud. Su hardware come i processori Apple M-series o i Raspberry Pi, la quantizzazione e l'unico modo per eseguire modelli oltre 1B parametri.
Dati di Mercato 2026
Gartner prevede che entro il 2027 i Small Language Models (SLM) cuantizzati supereranno i LLM cloud per frequenza di utilizzo con un fattore 3x. L'on-device AI riduce i costi operativi del 70% rispetto al cloud, eliminando latenza di rete e costi di API. Il mercato della quantizzazione e diventato critico per edge AI, mobile deployment e sistemi embedded.
Fondamenti Matematici della Quantizzazione
La quantizzazione mappa un valore floating-point continuo in un intero discreto. Il processo si divide in due operazioni: quantizzazione e de-quantizzazione.
Data una matrice di pesi W in FP16, la quantizzazione INT8 avviene come segue:
# Quantizzazione uniforme (schema base)
# W_quantized = round(W / scale) + zero_point
# W_dequantized = (W_quantized - zero_point) * scale
import torch
import numpy as np
def quantize_tensor_int8(tensor: torch.Tensor, symmetric: bool = True):
"""
Quantizzazione INT8 uniforme.
symmetric=True: zero_point=0, range [-127, 127]
symmetric=False: range asimmetrico con zero_point
"""
if symmetric:
# Scale basato sul valore assoluto massimo
max_val = tensor.abs().max().item()
scale = max_val / 127.0
zero_point = 0
else:
# Scale basato su min/max
min_val = tensor.min().item()
max_val = tensor.max().item()
scale = (max_val - min_val) / 255.0
zero_point = round(-min_val / scale)
zero_point = max(0, min(255, zero_point))
# Quantizzazione: FP16 -> INT8
quantized = torch.clamp(
torch.round(tensor / scale) + zero_point,
-128, 127
).to(torch.int8)
# De-quantizzazione: INT8 -> FP16 (per verifica errore)
dequantized = (quantized.float() - zero_point) * scale
# Errore di quantizzazione
error = (tensor - dequantized).abs().mean().item()
return quantized, scale, zero_point, error
# Esempio pratico
W = torch.randn(1024, 1024, dtype=torch.float16)
q, scale, zp, err = quantize_tensor_int8(W, symmetric=True)
print(f"Originale: {W.dtype}, {W.element_size() * W.numel() / 1024:.1f} KB")
print(f"Quantizzato: {q.dtype}, {q.element_size() * q.numel() / 1024:.1f} KB")
print(f"Riduzione memoria: {(1 - q.element_size()/W.element_size()) * 100:.0f}%")
print(f"Errore medio assoluto: {err:.6f}")
# Output tipico:
# Originale: torch.float16, 2048.0 KB
# Quantizzato: torch.int8, 1024.0 KB
# Riduzione memoria: 50%
# Errore medio assoluto: 0.000394
PTQ vs QAT: Scegliere l'Approccio Giusto
Esistono due paradigmi fondamentali per la quantizzazione dei modelli:
- PTQ (Post-Training Quantization): si applica dopo il training, su un modello già addestrato. Richiede solo un piccolo dataset di calibrazione. E veloce e pratico, ma può degradare l'accuratezza su modelli piccoli o a bassissima precisione (INT2, INT3).
- QAT (Quantization-Aware Training): simula la quantizzazione durante il training, permettendo al modello di adattare i propri pesi alla perdita di precisione. Produce risultati migliori ma richiede risorse computazionali paragonabili al fine-tuning completo.
PTQ vs QAT: Confronto Pratico
| Aspetto | PTQ | QAT |
|---|---|---|
| Tempo | Minuti-ore | Ore-giorni |
| Dataset calibrazione | Piccolo (512-2048 sample) | Dataset completo |
| VRAM per quantizzazione | Bassa (solo forward pass) | Alta (backward pass completo) |
| Accuratezza INT8 | Eccellente (<0.5% loss) | Eccellente |
| Accuratezza INT4 | Buona (1-3% loss) | Molto buona (<1% loss) |
| Caso d'uso | Modelli grandi, prod rapido | Modelli piccoli, massima qualità |
Per i LLM moderni (oltre 7B parametri), la PTQ e generalmente sufficiente: la ridondanza dei parametri fa si che la quantizzazione INT4 preservi quasi completamente le capacità del modello. Per modelli sotto 3B parametri, la QAT e consigliata quando l'accuratezza e critica.
INT8 con bitsandbytes: Il Metodo più Semplice
bitsandbytes e la libreria più usata per la quantizzazione pratica di LLM. Sviluppata originalmente da Tim Dettmers, supporta sia INT8 che INT4 (NF4, FP4) ed e integrata nativamente in Hugging Face Transformers. Il grande vantaggio: nessun dataset di calibrazione, quantizzazione on-the-fly al momento del caricamento del modello.
# Installazione
# pip install bitsandbytes transformers accelerate
from transformers import AutoModelForCausalLM, AutoTokenizer, BitsAndBytesConfig
import torch
# === CONFIGURAZIONE INT8 ===
config_int8 = BitsAndBytesConfig(
load_in_8bit=True,
llm_int8_threshold=6.0, # Outlier threshold (default ottimale)
llm_int8_has_fp16_weight=False
)
# === CONFIGURAZIONE INT4 NF4 (QLoRA style) ===
config_int4 = BitsAndBytesConfig(
load_in_4bit=True,
bnb_4bit_quant_type="nf4", # NF4: ottimale per pesi normalmente distribuiti
bnb_4bit_compute_dtype=torch.bfloat16, # Compute in BF16 (non INT4!)
bnb_4bit_use_double_quant=True, # Double quantization: -0.4 bit/param extra
bnb_4bit_quant_storage=torch.uint8 # Storage format
)
model_name = "meta-llama/Llama-3.1-8B-Instruct"
# Caricamento con INT4 NF4
model = AutoModelForCausalLM.from_pretrained(
model_name,
quantization_config=config_int4,
device_map="auto", # Distribuisce automaticamente su GPU/CPU
torch_dtype=torch.bfloat16,
trust_remote_code=True
)
tokenizer = AutoTokenizer.from_pretrained(model_name)
# Verifica memoria usata
mem_gb = model.get_memory_footprint() / 1024**3
print(f"Memoria modello (INT4): {mem_gb:.2f} GB")
# Llama-3.1-8B: ~4.5 GB vs 16 GB in BF16
# Inferenza standard (identica al modello full-precision)
inputs = tokenizer("Spiegami la quantizzazione dei modelli:", return_tensors="pt").to("cuda")
with torch.no_grad():
outputs = model.generate(
**inputs,
max_new_tokens=200,
temperature=0.7,
do_sample=True
)
print(tokenizer.decode(outputs[0], skip_special_tokens=True))
Limitazioni di bitsandbytes
- La quantizzazione avviene al momento del caricamento: non si salva il modello quantizzato direttamente (usa GPTQ o AWQ per quello)
- INT8 di bitsandbytes usa una tecnica mista: pesi quantizzati a 8 bit, ma le attivazioni con outlier vengono gestite in FP16 (LLM.int8())
- Il calcolo avviene sempre in BF16/FP16, non in INT4: la quantizzazione riduce la memoria ma non accelera il compute quanto GPTQ/AWQ
- Su CPU e sistemi senza GPU CUDA, le performance possono essere scarse
L'Algoritmo GPTQ: Quantizzazione Layer-by-Layer
GPTQ (Generative Pre-Trained Transformer Quantization, Frantar et al. 2022) e un algoritmo PTQ avanzato che quantizza ogni layer separatamente minimizzando l'errore di ricostruzione. Utilizza la Hessian matrix (approssimata tramite dati di calibrazione) per determinare quali pesi sono più sensibili alla quantizzazione e come compensare l'errore residuo.
Il processo GPTQ quantizza colonna per colonna di ogni matrice di pesi, aggiornando le colonne rimanenti per compensare l'errore introdotto. Questo rende GPTQ molto più accurato della semplice quantizzazione uniforme, specialmente a INT4 e INT3.
# Installazione AutoGPTQ
# pip install auto-gptq optimum
from transformers import AutoTokenizer
from auto_gptq import AutoGPTQForCausalLM, BaseQuantizeConfig
model_name = "meta-llama/Llama-3.1-8B-Instruct"
output_dir = "./llama-3.1-8b-gptq-int4"
# === DATASET DI CALIBRAZIONE ===
# GPTQ richiede un piccolo dataset (128-512 sample) per calibrare
# la quantizzazione. Più rappresentativo = migliore accuratezza.
from datasets import load_dataset
tokenizer = AutoTokenizer.from_pretrained(model_name, use_fast=True)
# Dataset Wikitext per calibrazione (standard per LLM)
calibration_data = load_dataset("wikitext", "wikitext-2-raw-v1", split="train")
calibration_texts = [
text for text in calibration_data["text"]
if len(text.strip()) > 100
][:128] # 128 sample di calibrazione
# Tokenizza i testi di calibrazione
calibration_tokens = [
tokenizer(text, return_tensors="pt", truncation=True, max_length=2048)
for text in calibration_texts
]
# === CONFIGURAZIONE QUANTIZZAZIONE ===
quantize_config = BaseQuantizeConfig(
bits=4, # INT4 quantization
group_size=128, # Dimensione gruppo (più piccolo = più accurato, più lento)
damp_percent=0.01, # Damping factor per stabilità numerica
desc_act=True, # Activation ordering (migliora qualità, opzionale)
sym=True, # Quantizzazione simmetrica
)
# === CARICAMENTO E QUANTIZZAZIONE ===
print("Caricamento modello FP16...")
model = AutoGPTQForCausalLM.from_pretrained(
model_name,
quantize_config=quantize_config,
torch_dtype="auto"
)
print("Avvio quantizzazione GPTQ (richiede ~30-60 min su A100)...")
model.quantize(
calibration_tokens,
use_triton=False, # True per inferenza ottimizzata con triton
batch_size=1,
cache_examples_on_gpu=True
)
# === SALVATAGGIO ===
model.save_quantized(output_dir, use_safetensors=True)
tokenizer.save_pretrained(output_dir)
print(f"Modello GPTQ salvato in: {output_dir}")
# === CARICAMENTO MODELLO GPTQ GIA QUANTIZZATO ===
print("Caricamento modello GPTQ pre-quantizzato...")
model_gptq = AutoGPTQForCausalLM.from_quantized(
output_dir,
use_triton=False,
device_map="auto",
inject_fused_attention=True, # Ottimizzazione memoria attention
inject_fused_mlp=True # Ottimizzazione memoria MLP
)
# Inferenza
inputs = tokenizer("La quantizzazione GPTQ funziona:", return_tensors="pt").to("cuda")
with torch.no_grad():
outputs = model_gptq.generate(**inputs, max_new_tokens=100)
print(tokenizer.decode(outputs[0], skip_special_tokens=True))
GPTQ produce modelli quantizzati salvabili e distribuibili. Molti modelli su Hugging Face Hub
con il suffisso -GPTQ o -4bit sono stati quantizzati con questo algoritmo.
La quantizzazione richiede tempo (tipicamente 30-90 minuti per un modello 13B su A100) ma avviene
una volta sola: il modello quantizzato può essere riutilizzato senza ri-quantizzare.
AWQ: Activation-Aware Weight Quantization
AWQ (Lin et al. 2023) e un'alternativa a GPTQ che parte da un'osservazione diversa: non tutti i pesi sono ugualmente importanti. Una piccola percentuale di pesi (circa l'1%) corrisponde ad attivazioni di grande magnitudine e contribuisce in modo sproporzionato alle predizioni del modello. Se questi "pesi salienti" vengono preservati con maggiore precisione, l'errore complessivo di quantizzazione si riduce drasticamente.
AWQ scala i pesi importanti prima della quantizzazione, riducendo l'errore sui canali critici. Il risultato e una qualità comparabile o superiore a GPTQ, con un processo di quantizzazione spesso più veloce e migliori performance su hardware eterogeneo (CPU, Mac M-series, mobile).
# pip install autoawq
from awq import AutoAWQForCausalLM
from transformers import AutoTokenizer
model_name = "meta-llama/Llama-3.1-8B-Instruct"
output_dir = "./llama-3.1-8b-awq-int4"
# Caricamento tokenizer
tokenizer = AutoTokenizer.from_pretrained(model_name, trust_remote_code=True)
# Caricamento modello da quantizzare
print("Caricamento modello per quantizzazione AWQ...")
model = AutoAWQForCausalLM.from_pretrained(
model_name,
safetensors=True,
device_map="cuda",
trust_remote_code=True
)
# Configurazione AWQ
quant_config = {
"zero_point": True, # Quantizzazione asimmetrica (migliore per LLM)
"q_group_size": 128, # Dimensione gruppo
"w_bit": 4, # 4-bit quantization
"version": "GEMM" # GEMM: bilanciamento velocità/qualità
# GEMV: ottimizzato per batch size 1 (chatbot)
}
# Dataset di calibrazione personalizzato
calib_data = [
"La quantizzazione AWQ permette di eseguire modelli grandi su hardware limitato.",
"I Transformer hanno rivoluzionato il natural language processing con il meccanismo di attention.",
"Il fine-tuning con LoRA riduce significativamente il numero di parametri addestrabili.",
# ... aggiungere 128-256 esempi rappresentativi del task target
]
print("Avvio quantizzazione AWQ...")
model.quantize(
tokenizer,
quant_config=quant_config,
calib_data=calib_data
)
# Salvataggio
model.save_quantized(output_dir)
tokenizer.save_pretrained(output_dir)
print(f"Modello AWQ salvato: {output_dir}")
# === CARICAMENTO E INFERENZA MODELLO AWQ ===
model_awq = AutoAWQForCausalLM.from_quantized(
output_dir,
fuse_layers=True, # Ottimizzazione kernel fused
trust_remote_code=True,
safetensors=True
)
from transformers import pipeline
pipe = pipeline(
"text-generation",
model=model_awq,
tokenizer=tokenizer,
device_map="auto"
)
result = pipe("Spiegami AWQ in un paragrafo:", max_new_tokens=150)
print(result[0]["generated_text"])
GPTQ vs AWQ: Quale Scegliere?
- GPTQ: migliore per GPU NVIDIA con CUDA. Veloce in inferenza con Triton kernels. Standard de facto per GPU deployment. Meglio per batch processing.
- AWQ: migliore per hardware eterogeneo (CPU, Mac, mobile). Più veloce da quantizzare. Preferibile per applicazioni chatbot (batch=1). Efficace con GEMV kernel per singolo token.
- Regola pratica: GPTQ per server GPU dedicati, AWQ per deployment multi-piattaforma e edge.
GGUF e llama.cpp: Quantizzazione per CPU e Edge
Il formato GGUF (GGML Unified Format) e stato creato dal progetto llama.cpp per abilitare l'inferenza LLM su CPU, con supporto opzionale per GPU tramite Metal (Apple), CUDA o OpenCL. GGUF succede a GGML e risolve i problemi di compatibilità forward tra versioni.
La nomenclatura GGUF segue un pattern preciso: Q[bits]_[variant] dove la variante
indica il tipo di quantizzazione. I più comuni sono:
| Formato | Bit medi | qualità | Uso consigliato |
|---|---|---|---|
| Q8_0 | 8.0 bit | Quasi lossless | Massima qualità su CPU potente |
| Q6_K | 6.6 bit | Eccellente | Bilanciamento qualità/dimensione |
| Q5_K_M | 5.7 bit | Molto buona | Desktop con 16+ GB RAM |
| Q4_K_M | 4.8 bit | Buona (95%) | Default consigliato, laptop 8+ GB |
| Q3_K_M | 3.9 bit | Accettabile | Hardware molto limitato |
| Q2_K | 2.6 bit | Degradata | Solo per test estremi |
Il suffisso _K_M indica "K-quantization" di taglia "Medium": una tecnica avanzata che usa quantizzazione a blocchi con scale factors a più alta precisione per i layer più critici, risultando in migliore qualità rispetto alla quantizzazione uniforme.
# Conversione e quantizzazione con llama.cpp
# Prima: installare llama.cpp
# git clone https://github.com/ggerganov/llama.cpp
# cd llama.cpp && make -j4
# 1. Convertire modello HuggingFace -> GGUF FP16
python convert_hf_to_gguf.py \
meta-llama/Llama-3.1-8B-Instruct \
--outfile llama-3.1-8b-f16.gguf \
--outtype f16
# 2. Quantizzare in vari formati
./llama-quantize llama-3.1-8b-f16.gguf llama-3.1-8b-q4_k_m.gguf Q4_K_M
./llama-quantize llama-3.1-8b-f16.gguf llama-3.1-8b-q8_0.gguf Q8_0
./llama-quantize llama-3.1-8b-f16.gguf llama-3.1-8b-q5_k_m.gguf Q5_K_M
# 3. Benchmark performance con llama.cpp
./llama-bench \
-m llama-3.1-8b-q4_k_m.gguf \
-p 512 \ # Token prompt
-n 128 \ # Token da generare
-t 8 # Thread CPU
# Output tipico su M2 Pro (16 GB):
# Q4_K_M: prompt 45.2 t/s, generate 28.1 t/s
# Q8_0: prompt 24.1 t/s, generate 15.8 t/s
# === UTILIZZO CON PYTHON via llama-cpp-python ===
# pip install llama-cpp-python
from llama_cpp import Llama
llm = Llama(
model_path="./llama-3.1-8b-q4_k_m.gguf",
n_ctx=4096, # Context window
n_threads=8, # Thread CPU
n_gpu_layers=35, # Offload 35 layer su GPU (0 = solo CPU)
verbose=False
)
response = llm(
"Q: Come funziona la quantizzazione dei modelli? A:",
max_tokens=256,
stop=["Q:", "\n\n"],
echo=True
)
print(response["choices"][0]["text"])
# === GGUF CON OLLAMA (più semplice) ===
# Crea Modelfile per Ollama
modelfile_content = """
FROM ./llama-3.1-8b-q4_k_m.gguf
PARAMETER temperature 0.7
PARAMETER num_ctx 4096
SYSTEM "Sei un assistente tecnico esperto di deep learning."
"""
with open("Modelfile", "w") as f:
f.write(modelfile_content)
# ollama create mio-llama -f Modelfile
# ollama run mio-llama
Benchmark: Accuratezza, Velocita e Memoria
Le metriche più usate per valutare la qualità di un modello quantizzato sono: perplexity su dataset standard (Wikitext-2), benchmark di reasoning (HellaSwag, MMLU) e task specifici del dominio applicativo.
# Benchmark automatico con lm-evaluation-harness
# pip install lm-eval
# Valutazione modello BF16 (baseline)
lm_eval --model hf \
--model_args "pretrained=meta-llama/Llama-3.1-8B-Instruct" \
--tasks hellaswag,mmlu \
--batch_size 4 \
--output_path results_bf16/
# Valutazione modello quantizzato GPTQ
lm_eval --model hf \
--model_args "pretrained=./llama-3.1-8b-gptq-int4,use_auto_gptq=True" \
--tasks hellaswag,mmlu \
--batch_size 4 \
--output_path results_gptq_int4/
# === SCRIPT BENCHMARK MEMORIA E VELOCITA ===
import torch
import time
from transformers import AutoModelForCausalLM, AutoTokenizer, BitsAndBytesConfig
def benchmark_model(model_name_or_path, quant_config=None, n_tokens=100, n_runs=5):
"""Benchmark completo: memoria, latenza, throughput."""
tokenizer = AutoTokenizer.from_pretrained(model_name_or_path)
model = AutoModelForCausalLM.from_pretrained(
model_name_or_path,
quantization_config=quant_config,
device_map="cuda",
torch_dtype=torch.bfloat16 if quant_config is None else None
)
# Memoria usata
mem_gb = model.get_memory_footprint() / 1024**3
# Prompt di test
prompt = "Explain the transformer architecture in detail:" * 3
inputs = tokenizer(prompt, return_tensors="pt").to("cuda")
# Warm-up
with torch.no_grad():
model.generate(**inputs, max_new_tokens=10)
# Benchmark
torch.cuda.synchronize()
latencies = []
for _ in range(n_runs):
start = time.perf_counter()
with torch.no_grad():
out = model.generate(**inputs, max_new_tokens=n_tokens)
torch.cuda.synchronize()
elapsed = time.perf_counter() - start
latencies.append(elapsed)
avg_latency = sum(latencies) / len(latencies)
throughput = n_tokens / avg_latency
return {
"memoria_gb": round(mem_gb, 2),
"latenza_sec": round(avg_latency, 3),
"throughput_tps": round(throughput, 1)
}
# Confronto BF16 vs INT8 vs INT4
results = {}
results["BF16"] = benchmark_model("meta-llama/Llama-3.1-8B-Instruct")
config_8bit = BitsAndBytesConfig(load_in_8bit=True)
results["INT8"] = benchmark_model("meta-llama/Llama-3.1-8B-Instruct", config_8bit)
config_4bit = BitsAndBytesConfig(
load_in_4bit=True, bnb_4bit_quant_type="nf4",
bnb_4bit_compute_dtype=torch.bfloat16, bnb_4bit_use_double_quant=True
)
results["INT4-NF4"] = benchmark_model("meta-llama/Llama-3.1-8B-Instruct", config_4bit)
for name, r in results.items():
print(f"{name:10} | Mem: {r['memoria_gb']:5.2f} GB | "
f"Latenza: {r['latenza_sec']:.3f}s | "
f"Throughput: {r['throughput_tps']:.1f} t/s")
# Risultati tipici Llama-3.1-8B su RTX 3090:
# BF16 | Mem: 16.02 GB | Latenza: 3.821s | Throughput: 26.2 t/s
# INT8 | Mem: 8.51 GB | Latenza: 4.103s | Throughput: 24.4 t/s
# INT4-NF4 | Mem: 4.89 GB | Latenza: 3.412s | Throughput: 29.3 t/s
Risultati Benchmark Indicativi (Llama-3.1-8B su RTX 4090)
| Metodo | Memoria | Throughput | HellaSwag | Perplexity |
|---|---|---|---|---|
| BF16 (baseline) | 16.0 GB | 38 t/s | 82.1% | 6.14 |
| INT8 (bitsandbytes) | 8.5 GB | 35 t/s | 81.8% | 6.21 |
| INT4 NF4 (bnb) | 4.9 GB | 42 t/s | 81.2% | 6.47 |
| GPTQ INT4 | 4.8 GB | 55 t/s | 81.5% | 6.39 |
| AWQ INT4 | 4.7 GB | 52 t/s | 81.6% | 6.35 |
| Q4_K_M (GGUF, CPU) | 4.9 GB | 18 t/s | 81.3% | 6.42 |
Nota: valori indicativi, variano con hardware, modello specifico e batch size.
Quantization-Aware Training con PyTorch
Per scenari dove la PTQ non e sufficiente — tipicamente modelli piccoli sotto i 3B parametri o quantizzazione a 2-3 bit — la QAT permette di recuperare significativa accuratezza. PyTorch include un modulo nativo per QAT a partire dalla versione 2.0, con supporto per INT8 statico e dinamico.
import torch
import torch.nn as nn
from torch.quantization import (
prepare_qat, convert, get_default_qat_qconfig,
QConfigMapping
)
from torch.ao.quantization import get_default_qat_qconfig_mapping
# === DEFINIZIONE MODELLO SEMPLICE ===
class SimpleTransformerBlock(nn.Module):
def __init__(self, d_model=256, nhead=4, ff_dim=1024):
super().__init__()
self.attn = nn.MultiheadAttention(d_model, nhead, batch_first=True)
self.norm1 = nn.LayerNorm(d_model)
self.ff = nn.Sequential(
nn.Linear(d_model, ff_dim),
nn.ReLU(),
nn.Linear(ff_dim, d_model)
)
self.norm2 = nn.LayerNorm(d_model)
def forward(self, x):
attn_out, _ = self.attn(x, x, x)
x = self.norm1(x + attn_out)
ff_out = self.ff(x)
return self.norm2(x + ff_out)
class SimpleModel(nn.Module):
def __init__(self, vocab_size=1000, d_model=256, n_layers=4):
super().__init__()
self.embed = nn.Embedding(vocab_size, d_model)
self.blocks = nn.Sequential(*[
SimpleTransformerBlock(d_model) for _ in range(n_layers)
])
self.head = nn.Linear(d_model, vocab_size)
def forward(self, x):
x = self.embed(x)
x = self.blocks(x)
return self.head(x)
# === TRAINING BASELINE (FP32) ===
model = SimpleModel()
model.train()
# === CONFIGURAZIONE QAT ===
# QConfig specifica come quantizzare activations e weights
qconfig_mapping = get_default_qat_qconfig_mapping("x86")
# Prepara il modello per QAT: inserisce FakeQuantize nodes
# che simulano la quantizzazione durante il forward pass
from torch.ao.quantization import prepare_qat_fx
# Traccia il modello con esempio di input
example_input = torch.randint(0, 1000, (2, 32))
model_prepared = prepare_qat_fx(model, qconfig_mapping, example_input)
# === QAT TRAINING LOOP ===
optimizer = torch.optim.Adam(model_prepared.parameters(), lr=1e-4)
criterion = nn.CrossEntropyLoss()
def train_qat(model, n_epochs=10, freeze_quantizer_epoch=8):
for epoch in range(n_epochs):
# Dati sintetici (sostituire con dataset reale)
x = torch.randint(0, 1000, (32, 64))
y = torch.randint(0, 1000, (32, 64))
optimizer.zero_grad()
out = model(x)
loss = criterion(out.view(-1, 1000), y.view(-1))
loss.backward()
optimizer.step()
# Freeze quantizer dopo N epoche: stabilizza le scale
if epoch == freeze_quantizer_epoch:
model.apply(torch.quantization.disable_observer)
print(f"Epoch {epoch}: FakeQuantize observers disabilitati")
if epoch % 2 == 0:
print(f"Epoch {epoch}/{n_epochs}, Loss: {loss.item():.4f}")
train_qat(model_prepared)
# === CONVERSIONE FINALE INT8 ===
model_prepared.eval()
model_int8 = convert(model_prepared)
# Salvataggio
torch.save(model_int8.state_dict(), "model_qat_int8.pt")
# Confronto dimensioni
fp32_size = sum(p.numel() * 4 for p in model.parameters()) / 1024**2
int8_size = sum(p.numel() * 1 for p in model_int8.parameters()) / 1024**2
print(f"Modello FP32: {fp32_size:.1f} MB")
print(f"Modello QAT INT8: {int8_size:.1f} MB")
print(f"Riduzione: {(1 - int8_size/fp32_size)*100:.0f}%")
SmoothQuant: Migliore INT8 per LLM
SmoothQuant (Xiao et al. 2022) affronta un problema specifico della quantizzazione INT8 dei LLM: le attivazioni outlier. Alcuni canali di attivazione nei Transformer hanno valori enormemente più grandi degli altri, rendendo difficile la quantizzazione uniforme (si spreca range per coprire gli outlier, riducendo la precisione dei valori normali).
SmoothQuant trasferisce la difficolta di quantizzazione dalle attivazioni ai pesi: divide le attivazioni per un fattore di scaling per canale e moltiplica i pesi corrispondenti per lo stesso fattore. Il risultato e matematicamente equivalente, ma ora sia attivazioni che pesi sono più facili da quantizzare.
# Concetto di SmoothQuant (implementazione semplificata)
import torch
import torch.nn as nn
def compute_smooth_scale(activations: torch.Tensor, weights: torch.Tensor,
alpha: float = 0.5) -> torch.Tensor:
"""
Calcola il fattore di smoothing per SmoothQuant.
alpha: bilanciamento tra difficolta attivazioni e pesi (0.5 = equo)
"""
# Max assoluto per canale nelle attivazioni
act_max = activations.abs().max(dim=0).values # [hidden_dim]
# Max assoluto per canale nei pesi
w_max = weights.abs().max(dim=0).values # [hidden_dim]
# Fattore di scaling: riduce attivazioni, aumenta pesi proporzionalmente
scale = (act_max.pow(alpha) / w_max.pow(1 - alpha)).clamp(min=1e-5)
return scale
def smooth_layer(layer: nn.Linear, calibration_acts: torch.Tensor,
alpha: float = 0.5):
"""
Applica SmoothQuant a un layer Linear.
Divide le attivazioni in input per scale, moltiplica i pesi per scale.
"""
scale = compute_smooth_scale(calibration_acts, layer.weight.T, alpha)
# Modifica in-place (equivalente matematico ma più facile da quantizzare)
layer.weight.data = layer.weight.data * scale.unsqueeze(0)
# Ritorna la scala inversa da applicare alle attivazioni
return scale
# Uso pratico: SmoothQuant e tipicamente applicato con librerie come
# quanto (Hugging Face) o mediante ottimum:
# pip install optimum[quanto]
from transformers import AutoModelForCausalLM, AutoTokenizer
from optimum.quanto import quantize, freeze, qint8
model_name = "meta-llama/Llama-3.1-8B-Instruct"
model = AutoModelForCausalLM.from_pretrained(model_name, torch_dtype=torch.float16)
tokenizer = AutoTokenizer.from_pretrained(model_name)
# Quantizzazione INT8 con Quanto (include SmoothQuant internamente)
quantize(model, weights=qint8, activations=qint8)
# Calibrazione con alcuni esempi
with torch.no_grad():
for text in ["Calibration text 1", "Calibration text 2"]:
inputs = tokenizer(text, return_tensors="pt")
model(**inputs)
# Freeze: consolida i parametri quantizzati
freeze(model)
print("Modello quantizzato INT8 con Quanto/SmoothQuant pronto!")
Best Practices e Anti-Pattern
Best Practices per la Quantizzazione
- Scegli la precisione in base al caso d'uso: per deployment su server GPU usa GPTQ INT4; per CPU/edge usa GGUF Q4_K_M; per fine-tuning usa NF4 con bitsandbytes.
- Dataset di calibrazione rappresentativo: per GPTQ e AWQ, usa dati simili al dominio target, non generici. 128-512 sample sono sufficienti ma devono essere significativi.
- Valuta sempre con metriche specifiche del dominio: la perplexity su Wikitext e un proxy, ma non cattura problemi su task specifici (codice, matematica, lingue non-inglesi).
- Group size ottimale: group_size=128 e il default sicuro; 64 migliora qualità a costo di più memoria; 256 riduce memoria ma peggiora qualità.
- Double quantization (bnb): abilita sempre bnb_4bit_use_double_quant=True; risparmia ~0.4 bit/param con impatto minimo sulla qualità.
- Usa BF16 come compute dtype: bnb_4bit_compute_dtype=torch.bfloat16 e più stabile di FP16 e supportato da Ampere+ (RTX 3000+, A100).
Anti-Pattern da Evitare
- Non quantizzare i layer critici: il primo e ultimo layer (embedding, LM head) sono spesso più sensibili alla quantizzazione. GPTQ e AWQ li escludono automaticamente.
- Non usare FP4 per inference: NF4 e superiore a FP4 per i pesi di LLM, che sono normalmente distribuiti. FP4 e utile solo per scenari molto specifici.
- Non confrontare quantizzazioni diverse senza benchmark: un modello "INT4" con metodo GPTQ e diverso da "INT4" con bitsandbytes. La precisione effettiva dipende dall'implementazione.
- Non ignorare la latenza di de-quantizzazione: INT4 bitsandbytes deve de-quantizzare durante il forward pass. Su GPU piccole con banda memoria limitata, questo può essere più lento di INT8.
- Non usare Q2_K in produzione: la qualità e troppo degradata per la maggior parte degli use case. Q3_K_M e il minimo accettabile per task semplici.
Scenari di Deployment: Guida alla Scelta
La scelta del metodo di quantizzazione dipende dal contesto di deployment. Ecco una guida pratica:
| Scenario | Hardware | Metodo consigliato | Formato |
|---|---|---|---|
| Server produzione GPU | A100/H100 80GB | GPTQ INT4 o AWQ INT4 | Safetensors GPTQ |
| Workstation consumer | RTX 4090 24GB | GPTQ INT4 (modelli fino 70B) | Safetensors GPTQ |
| Laptop Windows/Linux | GPU 8-16GB VRAM | bitsandbytes NF4 o AWQ | HuggingFace Hub |
| Laptop Apple M-series | Unified Memory 16-96GB | GGUF Q4_K_M o Q5_K_M | GGUF + llama.cpp/Ollama |
| Raspberry Pi 5 | 8GB RAM | GGUF Q3_K_M o Q4_K_M (modelli 1-3B) | GGUF + llama.cpp |
| NVIDIA Jetson Orin | 16GB unified mem | GPTQ INT4 o GGUF Q4_K_M | GPTQ o GGUF |
| Fine-tuning su GPU limitata | RTX 3090 24GB | QLoRA (NF4 + LoRA) | bitsandbytes NF4 |
Conclusioni
La quantizzazione dei modelli e passata dall'essere una tecnica di nicchia a uno strumento fondamentale per chiunque lavori con LLM. Il panorama nel 2026 e ricco: bitsandbytes per prototipazione rapida e QLoRA fine-tuning, GPTQ per deployment GPU ottimizzato, AWQ per hardware eterogeneo e multi-piattaforma, GGUF per CPU e edge devices.
La chiave e scegliere il metodo giusto per il contesto specifico. INT4 con GPTQ su una RTX 4090 e spesso più veloce di BF16 grazie ai kernel ottimizzati. GGUF Q4_K_M su un MacBook Pro con M3 permette di eseguire Llama-3.1-8B a 28 token/s senza GPU dedicata. Queste non sono concessioni qualitative: sono l'abilitazione di scenari prima impossibili.
Il prossimo passo naturale e combinare la quantizzazione con la distillazione dei modelli, che tratteremo nell'articolo successivo: come trasferire la conoscenza da un modello grande quantizzato a un modello più piccolo, ottenendo il meglio di entrambe le tecniche di compressione.
Prossimi Passi
- Articolo successivo: Distillazione Modelli: Knowledge Transfer
- Articolo correlato: Fine-tuning con LoRA e QLoRA
- Vedi anche: Ollama: LLM Locali su Laptop e Raspberry
- Serie MLOps: Model Serving e Deployment







