Salta al contenuto principale

03 - Quantizzazione Modelli: GPTQ, AWQ, INT8

Guida completa a Quantizzazione Modelli: GPTQ, AWQ, INT8: architettura, implementazione pratica e best practices per developer e team tecnici.

Cosa ne pensi di questo articolo?

03 - Quantizzazione Modelli: GPTQ, AWQ, INT8
Deep Learning AvanzatoFoto:Ruben BoekelooPexels

In Breve

Guida completa a Quantizzazione Modelli: GPTQ, AWQ, INT8: architettura, implementazione pratica e best practices per developer e team tecnici.

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.

Commenti

Caricamento commenti...

Accedi per commentare

Discuti questo articolo nella community

Unisciti al gruppo Telegram per discutere con altri sviluppatori, fare domande e condividere le tue esperienze.

Ti è piaciuto questo articolo?

Esplora altri contenuti sul blog o scopri i miei progetti