Post-Training Quantization (PTQ)#

TL;DR#

La PTQ (Post-Training Quantization) est une famille de méthodes qui quantifient un modèle après son entraînement, sans ré-entraînement. On utilise un petit dataset de calibration (128-1024 échantillons) pour observer les distributions des activations, puis on quantifie en une seule passe. C'est rapide, simple, et suffisant pour INT8.


Pour un néophyte#

Imagine que tu as fait cuire un gâteau parfait (ton modèle entraîné). Tu dois maintenant le couper en portions individuelles (le quantifier) pour le transporter plus facilement.

  • PTQ : Tu regardes la forme du gâteau, tu mesures, puis tu coupes. Rapide, mais quelques miettes perdues.
  • QAT : Tu ré-entraînes ta recette pour que le gâteau soit déjà à la bonne forme. Plus long, mais zéro perte.

La PTQ, c'est la méthode rapide : on observe le modèle existant, on détermine les meilleurs paramètres de quantification, on applique. Point.


Le pipeline PTQ étape par étape#

flowchart TD A["MODÈLE FP16/FP32 (entraîné)"] --> B["1. CALIBRATION
128-1024 échantillons
Observer les distributions"] B --> C["2. DÉTERMINATION DES PARAMÈTRES
Scale factors, Zero points,
Clipping thresholds"] C --> D["3. QUANTIFICATION DES POIDS
Statique, Per-channel ou per-group"] D --> E["4. QUANTIFICATION DES ACTIVATIONS
Dynamique (runtime) ou statique"] E --> F["MODÈLE QUANTIFIÉ (INT8 / INT4)
→ Prêt pour l'inférence accélérée"]

Étape 1 : La calibration#

La calibration est le cœur de la PTQ. Son but : observer les distributions des activations pour déterminer les paramètres de quantification optimaux.

flowchart TD A["Entrées de calibration
(128-1024 échantillons)"] --> B["Modèle FP16
Forward pass"] B --> C["Enregistrer les activations
de CHAQUE couche intermédiaire"] C --> D["Pour chaque couche :
• min, max observés
• Histogramme des valeurs
• Percentiles (99.9%, 99.99%)
• Détection des outliers"] D --> E["Décision des range
de quantification [α, β]"]

Combien d'échantillons de calibration ?#

Nombre d'échantillons Qualité Temps de calibration
32-64 ⚠️ Basique < 1 minute
128-512 ✅ Standard (recommandé) Quelques minutes
1024+ ✅ Optimal 10-30 minutes

💡 Au-delà de ~1024 échantillons, l'amélioration est marginale. Les distributions convergent rapidement.


Étape 2 : Scale factors et quantification#

La formule de quantification#

La quantification d'une valeur réelle x (FP16/FP32) vers un entier q (INT8) se fait ainsi :

q = round(x / scale) + zero_point

où :
  scale    = (max_real - min_real) / (max_int - min_int)
  zero_point = round(min_real / scale)    [pour asymétrique]

Méthodes de détermination des range [α, β]#

Méthode Principe Quand l'utiliser
Min-Max α = min(activations), β = max(activations) Défaut, rapide, mais sensible aux outliers
Percentile Coupe les X% extrêmes (ex: 99.9%) Quand il y a des outliers modérés
MSE Minimise l'erreur quadratique entre original et quantifié Bon compromis précision/qualité
Entropy (KL) Minimise la divergence KL entre distributions Meilleur pour préserver la sémantique (TensorRT)
xychart-beta title "Distribution des activations avec outlier" x-axis ["min", " ", " ", " ", " ", " ", " ", " ", " ", "max"] y-axis "Densité" 0 --> 100 bar [5, 15, 35, 60, 85, 60, 35, 15, 5, 20]

Comparaison des méthodes de clipping :

  • Min-Max : [min ──────── max] → scale trop large, précision perdue
  • Percentile : [···trimmed···] → ignore l'outlier, meilleur scale
  • MSE : [···optimisé···] → meilleur compromis erreur/scale

Clipping : gérer les bornes#

Le clipping consiste à tronquer les valeurs qui tombent en dehors du range [α, β]. Toute valeur x > β devient β, toute valeur x < α devient α.

flowchart LR subgraph AVANT["Avant clipping"] A1["x=5.0 → 3.0
clamped à β=3.0"] A2["x=2.5 → 2.5
inchangé"] A3["x=0.0 → 0.0
inchangé"] A4["x=-3.0 → -2.0
clamped à α=-2.0"] end

Toute valeur x > β devient β, toute valeur x < α devient α.


Variantes de quantification#

1. Symétrique vs Asymétrique#

Quantification symétrique : le range est centré autour de 0.

flowchart TD subgraph SYM["Symétrique (zero_point = 0)"] S1["INT8 : -127 ←——— 0 ———→ +127"] S2["-α ←———————— 0 ————————→ +α"] S3["scale = α / 127"] end subgraph ASYM["Asymétrique (zero_point ≠ 0)"] A1["INT8 : 0 ←————————————→ 127"] A2["α ←———————————————→ β"] A3["scale = (β-α) / 127
zero_point = round(α / scale)"] end

Quantification asymétrique : le range s'adapte à la distribution.

flowchart TD subgraph ASYM2["Asymétrique (zero_point ≠ 0)"] A1["INT8 : 0 ←————————————→ 127"] A2["α ←———————————————→ β"] A3["scale = (β-α) / 127
zero_point = round(α / scale)"] A4["✅ Utilise tous les niveaux
⚠️ Légèrement plus coûteux"] end

2. Granularité : Per-tensor / Per-channel / Per-group#

Granularité Description Précision Overhead
Per-tensor Un seul scale pour tout le tenseur ❌ Basse ✅ Minimal
Per-channel Un scale par canal de sortie ✅ Bonne ⚠️ Modéré
Per-group Un scale par groupe de N poids (ex: 128) ✅ Excellente ❌ Élevé
flowchart LR subgraph T["Per-tensor"] T1["━━━━━━━━━━━━━━━━━━━━━
1 scale pour tout le tenseur"] end subgraph C["Per-channel"] C1["━━━━
canal 0"] C2["━━━━
canal 1"] C3["━━━━
canal 2"] C4["━━━━
canal 3"] end subgraph G["Per-group"] G1["━ g0"] G2["━ g1"] G3["━ g2"] G4["..."] G5["━ gn"] end

3. Quantification statique vs dynamique#

Type Quand sont calculés les scales des activations ? Usage
Statique Pendant la calibration (avant l'inférence) INT8 complet (W8A8)
Dynamique À chaque forward pass (au runtime) Weight-only quant (ex: INT4 poids + FP16 activations)
flowchart LR subgraph S["Statique"] S1["Calibration"] --> S2["Scales fixés"] --> S3["Inférence rapide"] end subgraph D["Dynamique"] D1["Inférence"] --> D2["Calculer scales"] --> D3["Quantifier activations"] --> D4["MatMul"] end

Quantification uniforme vs non-uniforme#

  • Uniforme : les niveaux de quantification sont également espacés (INT8 standard). Simple, accéléré par le hardware.
  • Non-uniforme : les niveaux sont concentrés là où il y a le plus de valeurs (ex: NF4 de bitsandbytes). Meilleure précision à bas bitrate, mais pas toujours accéléré.
flowchart LR subgraph U["Uniforme"] U1["| | | | |
également espacés"] end subgraph N["Non-uniforme (NF4)"] N1["| || | | | |
concentrés au centre
(densité normale)"] end

Bits cibles#

  • INT8 (W8A8) — configuration standard, quasi sans perte
  • INT4 (W4A16) — configuration agressive, perte légère mais acceptable
  • FP16 → INT8 ou FP8 — conversion pour accélération hardware

Avantages#

  • Rapide : minutes à heures, pas de ré-entraînement
  • Simple à mettre en œuvre — quelques lignes de code
  • ✅ Pas besoin de données d'entraînement (juste calibration)
  • ✅ Compatible avec la plupart des frameworks (PyTorch, TF Lite, ONNX)
  • ✅ INT8 quasi sans perte de précision
  • ✅ Écosystème mature (bitsandbytes, AutoGPTQ, AutoAWQ, TensorRT)

Inconvénients#

  • ❌ Dégradation de précision plus importante qu'avec QAT, surtout à ≤ 4 bits
  • Sensibilité aux outliers dans les activations (problème principal des LLMs)
  • ❌ Pas de récupération des erreurs de quantification (pas de backprop)
  • ❌ Le choix de la méthode de calibration et de la granularité influence fortement la qualité

Exemple pratique#

Avec bitsandbytes (INT8 — le plus simple)#

from transformers import AutoModelForCausalLM

# PTQ INT8 en une seule ligne
model = AutoModelForCausalLM.from_pretrained(
    "meta-llama/Llama-2-7b",
    load_in_8bit=True,        # ← PTQ immédiate
    device_map="auto",
)
# Le modèle est quantifié au chargement, prêt à l'emploi

Avec AutoGPTQ (INT4 — weight-only quantization)#

from transformers import AutoModelForCausalLM, AutoTokenizer
from auto_gptq import AutoGPTQForCausalLM, BaseQuantizeConfig

# 1. Charger le modèle original
tokenizer = AutoTokenizer.from_pretrained("meta-llama/Llama-2-7b")
model = AutoGPTQForCausalLM.from_pretrained("meta-llama/Llama-2-7b")

# 2. Préparer les données de calibration
calibration_texts = [
    "Exemple de texte pour la calibration...",
    # ... 128-512 échantillons
]

# 3. Configuration de quantification
quantize_config = BaseQuantizeConfig(
    bits=4,              # INT4
    group_size=128,      # per-group
    desc_act=False,      # activation order
)

# 4. Quantification PTQ
model.quantize(calibration_texts, quantize_config)

# 5. Sauvegarder le modèle quantifié
model.save_quantized("./llama-2-7b-gptq-4bit")

Avec PyTorch natif (PTQ statique INT8)#

import torch
import torch.ao.quantization as quant

# 1. Préparer le modèle
model_fp32 = MyModel().eval()
model_fp32.qconfig = quant.get_default_qconfig('fbgemm')

# 2. Insertion des observateurs (pour la calibration)
model_prepared = quant.prepare(model_fp32)

# 3. Calibration : passer le dataset de calibration
with torch.no_grad():
    for batch in calibration_dataloader:
        model_prepared(batch)
    # → Les observateurs ont enregistré les min/max/percentiles

# 4. Convertir : les observateurs deviennent des nœuds de quantification
model_int8 = quant.convert(model_prepared)

# 5. Inférence avec le modèle quantifié
output = model_int8(test_input)

Avec TensorRT (calibration entropique)#

import tensorrt as trt

# Le calibrator TensorRT utilise la méthode KL-divergence
class MyCalibrator(trt.IInt8EntropyCalibrator2):
    def __init__(self, calibration_data):
        super().__init__()
        self.data = calibration_data

    def get_batch(self, names):
        # Retourner le prochain batch de calibration
        batch = next(self.data_iterator, None)
        if batch is None:
            return None
        return [batch.ptr]

    def read_calibration_cache(self):
        # Optionnel : cache pour éviter de recalibrer
        ...

    def write_calibration_cache(self, cache):
        ...

Papier arXiv source#

La PTQ est une famille de méthodes plutôt qu'un seul papier. Les références fondamentales :


Références#

ia llm quantification ptq calibration int8 int4 inference