11. Quantization

# Parameters
BATCH_MODE = False
# Parameters
BATCH_MODE = "true"

11. Quantization des LLMs

Navigation : Index | << Précédent | Suivant >>

Duree estimee : 60 minutes

Prerequis : Notebook 10 (LocalLlama), Python 3.10+, GPU recommande (même un petit)


Objectifs d’apprentissage

A la fin de ce notebook, vous saurez : 1. Pourquoi et comment la quantization reduit la taille des LLMs 2. Calculer l’empreinte memoire d’un modèle par precision 3. Distinguer les méthodes BitsAndBytes, AWQ et GPTQ 4. Quantifier un modèle avec llmcompressor (méthode production) 5. Deployer et valider un modèle quantifie avec vLLM


Contexte

Dans le notebook précédent, nous avons deploye des LLMs localement avec vLLM. Vous avez peut-etre remarque que nos modèles etaient en AWQ 4-bit ou GPTQ 4-bit. Ce notebook explique pourquoi et comment on passe d’un modèle BF16 de 16 GB a un modèle 4-bit de 5 GB – et pourquoi cela accelere l’inference au lieu de la ralentir.

Concepts cles

Pourquoi quantifier ?

Le goulot d’etranglement principal de l’inference LLM est la bande passante memoire, pas la puissance de calcul. A chaque token genere, le GPU doit lire tous les poids du modèle depuis la VRAM. Reduire la taille des poids signifie :

  1. Moins de VRAM : Un modèle 8B passe de 16 GB (BF16) a 5 GB (4-bit)
  2. Inference plus rapide : Moins de données a lire = plus de tokens/seconde
  3. Plus de batching : La VRAM economisee sert au KV cache = plus d’utilisateurs simultanes
  4. Deployement moins cher : Un modèle 70B tient sur 2 GPUs au lieu de 4

Insight contre-intuitif : La quantization accelere l’inference. On pourrait penser que reduire la precision ralentit le modèle. En realite, le bottleneck est le memory bandwidth, pas le compute. Lire 4 bits au lieu de 16 bits par poids = 4x moins de données a transferer.

Formats de precision

Format Octets/param Description Usage typique
FP32 4 Full precision Entrainement, reference
BF16 / FP16 2 Half precision Entrainement mixte, inference standard
INT8 1 8-bit integer Quantization simple (BitsAndBytes)
W4A16 (INT4) 0.5 4-bit poids, 16-bit activations Production (AWQ, GPTQ)

Trois lecons cles

  1. La quantization accelere l’inference (contre-intuitif) : moins de memoire a lire = plus de tokens/seconde
  2. Les modèles multimodaux necessitent des exclusions : ne jamais quantifier l’encodeur vision (ViT/SigLIP)
  3. Le format de sortie determine le runtime : compressed-tensors vers Marlin, GPTQ vers GPTQMarlin, GGUF vers llama.cpp

Installation et imports

Nous installons les bibliotheques necessaires pour ce notebook. Les demonstrations de quantization lourde (llmcompressor, BitsAndBytes) sont protegees par BATCH_MODE et presentees en lecture seule.

# Dependances pre-provisionnees (torch, transformers, accelerate, datasets,
# huggingface_hub, bitsandbytes, openai, requests, python-dotenv) :
# voir GenAI/requirements.txt. Regle F : pas d'install runtime sur le PUBLIC repo.
from pathlib import Path
import os
import json
import time
import requests
from dotenv import load_dotenv
load_dotenv('../.env')
# Respecter la valeur injectee par Papermill si deja definie dans la cellule parameters
if 'BATCH_MODE' not in globals():
    BATCH_MODE = os.getenv("BATCH_MODE", "false").lower() == "true"
print(f"Mode batch: {BATCH_MODE}")
# Verification GPU
try:
    import torch
    if torch.cuda.is_available():
        gpu_name = torch.cuda.get_device_name(0)
        gpu_mem = torch.cuda.get_device_properties(0).total_memory / 1024**3
        print(f"GPU: {gpu_name} ({gpu_mem:.1f} GB)")
    else:
        print("Pas de GPU disponible")
except Exception as e:
    print(f"Erreur GPU: {e}")
Mode batch: true
GPU: NVIDIA GeForce RTX 4090 (24.0 GB)

Interpretation de l’installation

Bibliotheques installees :

Package Rôle
torch Backend GPU, mesure memoire VRAM
transformers Chargement de modèles HuggingFace
accelerate Distribution multi-GPU, device_map="auto"
datasets Chargement du dataset de calibration
openai Client API compatible vLLM
python-dotenv Chargement de la configuration .env

Variable BATCH_MODE : Lue depuis le .env après le paramètre Papermill. Les cellules lourdes (chargement de modèles, quantization) sont ignorees en mode batch pour permettre une validation automatisee rapide.


Section 1 : Pourquoi quantifier ? Calcul de l’empreinte memoire

La formule fondamentale pour estimer la VRAM requise par un modèle est :

\[ \text{Memoire (GB)} = \text{nombre\_params} \times \text{octets\_par\_param} \div 10^9 \]

En pratique, il faut ajouter environ 20% de surcharge pour le KV cache, les activations et le framework d’inference.

Modèles courants et leur empreinte

Modèle Params BF16 INT8 W4A16
Qwen3.5-0.8B 0.8B 1.6 GB 0.8 GB 0.4 GB
ZwZ-8B 8B 16 GB 8 GB 4 GB
Qwen3.5-35B-A3B (MoE, total) 35B 70 GB 35 GB 17.5 GB
Llama 3.1 70B 70B 140 GB 70 GB 35 GB

On voit immediatement que la quantization W4A16 divise par 4 l’empreinte memoire par rapport au BF16. Un modèle 70B qui necessite 4 GPUs tient alors sur 2 GPUs.

def estimate_model_memory(num_params_billions, precision="bf16"):
    """Estime la memoire GPU necessaire pour un modele LLM."""
    bytes_per_param = {
        "fp32": 4, "bf16": 2, "fp16": 2,
        "int8": 1, "int4": 0.5, "w4a16": 0.5
    }
    bpp = bytes_per_param.get(precision, 2)
    memory_gb = num_params_billions * bpp
    # Ajouter ~20% de surcharge pour le KV cache, activations, framework
    total_gb = memory_gb * 1.2
    return memory_gb, total_gb


# Table comparative
models = [
    ("Qwen3.5-0.8B", 0.8),
    ("ZwZ-8B", 8),
    ("Qwen3.5-35B-A3B (MoE, total)", 35),
    ("Llama 3.1 70B", 70),
]

print(f"{'Modele':<35} {'BF16':>8} {'INT8':>8} {'W4A16':>8}")
print("-" * 65)
for name, params in models:
    bf16, _ = estimate_model_memory(params, "bf16")
    int8, _ = estimate_model_memory(params, "int8")
    w4, _ = estimate_model_memory(params, "w4a16")
    print(f"{name:<35} {bf16:>6.1f}GB {int8:>6.1f}GB {w4:>6.1f}GB")

print("\n--- Donnees reelles (workspace vLLM) ---")
print("ZwZ-8B:           BF16 ~17 GB  ->  AWQ 4-bit ~5.5 GB  (ratio 3.1x)")
print("Qwen3.5-35B-A3B:  BF16 ~70 GB  ->  GPTQ 4-bit ~24 GB  (ratio 2.9x)")
Modele                                  BF16     INT8    W4A16
-----------------------------------------------------------------
Qwen3.5-0.8B                           1.6GB    0.8GB    0.4GB
ZwZ-8B                                16.0GB    8.0GB    4.0GB
Qwen3.5-35B-A3B (MoE, total)          70.0GB   35.0GB   17.5GB
Llama 3.1 70B                        140.0GB   70.0GB   35.0GB

--- Donnees reelles (workspace vLLM) ---
ZwZ-8B:           BF16 ~17 GB  ->  AWQ 4-bit ~5.5 GB  (ratio 3.1x)
Qwen3.5-35B-A3B:  BF16 ~70 GB  ->  GPTQ 4-bit ~24 GB  (ratio 2.9x)

Interpretation : empreinte memoire

Sortie obtenue : Table comparative des tailles memoire par precision.

Aspect Valeur Signification
Ratio BF16 -> W4A16 4x Division par 4 de la memoire des poids
Ratio reel ZwZ-8B 3.1x Legerement inferieur car le ViT n’est pas quantifie
Ratio reel Qwen3.5-35B 2.9x Les couches MoE non actives prennent aussi de la place

Points cles : 1. La théorie (4x) et la pratique (3x) différent car certaines couches ne sont pas quantifiees 2. La surcharge de 20% est une estimation ; elle varie selon la longueur du contexte 3. Pour les modèles MoE, tous les experts sont en memoire même si peu sont actifs par token

Note technique : Les données reelles proviennent de notre stack de production avec 3x RTX 4090 (72 GB VRAM total). Les mesures incluent le framework vLLM.

Exemple guidé 1 : Planification GPU pour le deploiement de modèles

Résolution de l’Exercice 1 initial : la fonction plan_gpu_deployment() calcule la memoire requise par precision (surcharge de 20% incluse), le nombre de RTX 4090, la memoire par GPU et le taux d’utilisation, puis recommande le tensor-parallel-size (puissance de 2 exigee par vLLM) pour Mistral 7B, Mixtral 8x7B et Llama 3.1 405B.

Contribution étudiante de Youssef AGREBAOUI (@YoussefAG1337), PR #18568, intégrée comme exemple guidé.

Un nouvel exercice sur le meme theme (empreinte du cache KV) suit la lecture du résultat ci-dessous.

import math

BYTES_PER_PARAM = {"fp32": 4, "bf16": 2, "fp16": 2, "int8": 1, "int4": 0.5, "w4a16": 0.5}


def plan_gpu_deployment(model_name, num_params_billions, gpu_vram_gb=24, precision="w4a16"):
    """
    Calcule le nombre de GPUs necessaires pour deployer un modele LLM.
    
    Args:
        model_name: Nom du modele (ex: "Llama 3.1 70B")
        num_params_billions: Nombre de parametres en milliards
        gpu_vram_gb: VRAM disponible par GPU en GB (defaut: 24 GB pour RTX 4090)
        precision: Precision cible ("bf16", "int8", "w4a16")
    
    Returns:
        dict: Plan de deploiement avec nombre de GPUs et details memoire
    """
    # TODO etudiant : calculer la memoire requise avec surcharge 20%
    bytes_per_param = BYTES_PER_PARAM[precision]
    memory_required_gb = bytes_per_param * num_params_billions * 1e9 * 1.2 / 1e9
    
    # TODO etudiant : calculer le nombre de GPUs necessaires
    num_gpus = math.ceil(memory_required_gb / gpu_vram_gb)
    
    return {
        "model": model_name,
        "memory_required_gb": round(memory_required_gb, 2),
        "num_gpus": num_gpus,  # TODO etudiant
        "precision": precision,
        "memory_per_gpu_gb": round(memory_required_gb / num_gpus, 2),
        "vram_utilization": round(memory_required_gb / (num_gpus * gpu_vram_gb), 2),
    }


# Modeles a evaluer
models_to_plan = [
    ("Mistral 7B", 7),
    ("Mixtral 8x7B (MoE)", 46.7),
    ("Llama 3.1 405B", 405),
]

print(f"{'Modele':<22} {'Precision':<8} {'Memoire':>10} {'GPUs':>5} {'Par GPU':>9} {'Utilisation':>12}")
print("-" * 72)
for name, params in models_to_plan:
    for prec in ["bf16", "int8", "w4a16"]:
        p = plan_gpu_deployment(name, params, precision=prec)
        print(f"{name:<22} {prec:<8} {p['memory_required_gb']:>8.1f}GB {p['num_gpus']:>5} "
              f"{p['memory_per_gpu_gb']:>7.1f}GB {p['vram_utilization']:>11.0%}")
    print()

print("--- Recommandation (W4A16, RTX 4090 24 GB) ---")
for name, params in models_to_plan:
    p = plan_gpu_deployment(name, params)
    # vLLM exige un tensor parallelism en puissance de 2 pour la plupart des modeles
    tp = 1 << (p["num_gpus"] - 1).bit_length()
    print(f"  {name} ({params}B params) -> {p['num_gpus']} RTX 4090 necessaires "
          f"({p['memory_required_gb']:.1f} GB), --tensor-parallel-size {tp}")
Modele                 Precision    Memoire  GPUs   Par GPU  Utilisation
------------------------------------------------------------------------
Mistral 7B             bf16         16.8GB     1    16.8GB         70%
Mistral 7B             int8          8.4GB     1     8.4GB         35%
Mistral 7B             w4a16         4.2GB     1     4.2GB         18%

Mixtral 8x7B (MoE)     bf16        112.1GB     5    22.4GB         93%
Mixtral 8x7B (MoE)     int8         56.0GB     3    18.7GB         78%
Mixtral 8x7B (MoE)     w4a16        28.0GB     2    14.0GB         58%

Llama 3.1 405B         bf16        972.0GB    41    23.7GB         99%
Llama 3.1 405B         int8        486.0GB    21    23.1GB         96%
Llama 3.1 405B         w4a16       243.0GB    11    22.1GB         92%

--- Recommandation (W4A16, RTX 4090 24 GB) ---
  Mistral 7B (7B params) -> 1 RTX 4090 necessaires (4.2 GB), --tensor-parallel-size 1
  Mixtral 8x7B (MoE) (46.7B params) -> 2 RTX 4090 necessaires (28.0 GB), --tensor-parallel-size 2
  Llama 3.1 405B (405B params) -> 11 RTX 4090 necessaires (243.0 GB), --tensor-parallel-size 16

Lecture du résultat : planification GPU

Ce que la table montre : avec la marge de 20 % du calcul, Mistral 7B tient sur une seule RTX 4090 dans les trois précisions (70 % de la carte en BF16). Mixtral 8x7B demande 5 cartes en BF16 mais 2 en W4A16 : c’est un modèle MoE, et tous ses experts (46.7 milliards de paramètres) restent en mémoire, même si seuls deux d’entre eux sont actifs pour chaque token. Llama 3.1 405B passe de 41 cartes en BF16 à 11 en W4A16.

Le piège de la dernière ligne : « 11 RTX 4090 nécessaires » et --tensor-parallel-size 16 ne disent pas la même chose. Le tensor parallelism découpe chaque couche d’attention entre les cartes : sa taille doit diviser le nombre de têtes d’attention (128 pour Llama 3.1 405B). 11 ne divise pas 128 ; le code arrondit donc à la puissance de 2 suivante, et le déploiement en tensor parallelism pur coûte 16 cartes, pas 11. Pour s’en approcher, on combine tensor et pipeline parallelism : par exemple 4 × 3 = 12 cartes, à environ 20 GB chacune.

À retenir : la mémoire donne un nombre minimum de cartes ; la façon de découper le modèle entre elles donne le nombre réel.

Exercice 1 : Empreinte memoire du cache KV

Les poids ne sont pas la seule memoire a planifier : le cache KV croit avec la longueur de contexte et la taille de batch. Pour un modele en attention groupee (GQA), chaque token met en cache une cle et une valeur par couche et par tete KV.

Objectif : Ecrire une fonction qui estime la memoire du cache KV en GB pour un batch et un contexte donnes, puis la marge restante sur le GPU.

Indices : - # Indice : Octets par token = 2 (cle + valeur) * n_layers * n_kv_heads * head_dim * octets_par_element - # Indice : bf16 = 2 octets par element, fp8 = 1 octet - # Indice : 1 GB = 1e9 octets - # Étape 1 : Determiner les octets par element selon la precision - # Étape 2 : Calculer les octets par token, puis la memoire du batch complet - # Étape 3 : Convertir en GB et soustraire du budget pour la marge restante

def kv_cache_memory_gb(n_layers, n_kv_heads, head_dim, batch_size, seq_len,
                       precision="bf16", budget_gb=24.0):
    """
    Estime la memoire du cache KV en GB et la marge restante sur le GPU.

    Args:
        n_layers: nombre de couches du decodeur (ex: 28)
        n_kv_heads: nombre de tetes KV en GQA (ex: 8)
        head_dim: dimension par tete (ex: 64)
        batch_size: nombre de requetes simultanees
        seq_len: longueur de contexte (prompt + generation)
        precision: "bf16" (2 octets par element) ou "fp8" (1 octet)
        budget_gb: VRAM totale du GPU en GB

    Returns:
        dict: octets par token, memoire KV en GB, marge restante en GB
    """
    # TODO etudiant : octets par element selon la precision (2 pour bf16, 1 pour fp8)
    bytes_per_element = None  # TODO etudiant

    # TODO etudiant : octets par token (cle + valeur, toutes couches, toutes tetes KV)
    bytes_per_token = None  # TODO etudiant : 2 * n_layers * n_kv_heads * head_dim * bytes_per_element

    # TODO etudiant : memoire totale du cache pour le batch complet, en GB
    kv_cache_gb = None  # TODO etudiant : bytes_per_token * batch_size * seq_len / 1e9

    return {
        "bytes_per_token": bytes_per_token,
        "kv_cache_gb": kv_cache_gb,
        "budget_remaining_gb": None,  # TODO etudiant : budget - kv_cache_gb
    }


# Cas a etudier : decodeur 28 couches, 8 tetes KV, head_dim 64, batch 4, contexte 8192
print("Exercice a completer : implementer kv_cache_memory_gb()")
print("Comparaison attendue : bf16 puis fp8 sur le meme batch/contexte")
Exercice a completer : implementer kv_cache_memory_gb()
Comparaison attendue : bf16 puis fp8 sur le meme batch/contexte

Section 2 : Méthodes de quantization

Il existe plusieurs approches pour quantifier un LLM. Elles se distinguent par le moment de la quantization (runtime vs offline), le besoin de calibration, et le runtime cible.

Comparaison des méthodes

Critere BitsAndBytes AutoAWQ GPTQ (llmcompressor) GGUF
Type Runtime Offline Offline Offline
Precision INT8, NF4 W4A16 W4A16 Q4_K_M, Q5, etc.
Calibration Non Oui Oui (Open-Platypus, 512 samples) Conversion
Runtime cible Transformers vLLM, Transformers vLLM (Marlin kernels natifs) llama.cpp, Ollama
Facilite Très simple Simple Moyen Simple
Production Non Oui Oui (recommande) Oui (CPU/edge)

BitsAndBytes

La méthode la plus simple : ajoutez load_in_8bit=True ou load_in_4bit=True au chargement. Aucune calibration necessaire, la quantization se fait a la volee. Avantage : Zero configuration. Inconvenient : Plus lent que les méthodes offline car le dequantize se fait a chaque forward pass sans kernels optimises.

AutoAWQ (Activation-aware Weight Quantization)

Méthode offline qui utilise un petit dataset de calibration pour determiner les meilleures echelles de quantization par canal. Produit des modèles en format AWQ lisibles par vLLM et Transformers.

GPTQ via llmcompressor (recommande pour vLLM)

Méthode offline developpee par Neural Magic (équipe integree a vLLM). Produit le format compressed-tensors natif pour vLLM avec les kernels Marlin optimises. C’est la méthode recommandee pour la production.

GGUF (llama.cpp)

Format spécifique a llama.cpp et Ollama. Optimise pour l’inference CPU ou GPU partiel. Ideal pour le deploiement sur machines sans GPU puissant (laptops, edge devices). Nombreux niveaux de quantization disponibles (Q2_K, Q4_K_M, Q5_K_M, Q8_0, etc.).

Demonstration BitsAndBytes (quantization runtime)

BitsAndBytes est la porte d’entree la plus simple vers la quantization. Nous allons charger un petit modèle (0.8B paramètres) en BF16 puis en INT8 pour comparer l’empreinte memoire.

Note : Cette cellule necessite un GPU CUDA et la bibliotheque bitsandbytes. Elle est ignoree en BATCH_MODE car le chargement de modèles est lent.

# BitsAndBytes : mesures reelles du compromis taille-qualite sur le MEME modele
# Meme checkpoint (Qwen3.5-0.8B), meme corpus, meme seed -> VRAM, taille,
# perplexite et debit mesures, tableau derive des sorties du run.

has_gpu = False
try:
    import torch
    has_gpu = torch.cuda.is_available()
except Exception:
    has_gpu = False

if has_gpu:
    from transformers import AutoModelForCausalLM, AutoTokenizer, BitsAndBytesConfig
    import math, time
    import logging
    # Le message 'MatMul8bitLt: inputs will be cast...' est un bruit benign de bitsandbytes
    # (cast bf16->fp16 dans le matmul 8-bit) ; on reduit son logger pour garder une sortie lisible.
    import warnings as _w
    _w.filterwarnings("ignore", message=r"MatMul8bitLt.*")  # UserWarning bitsandbytes: embarque le chemin site-packages de la machine
    logging.getLogger("bitsandbytes").setLevel(logging.ERROR)
    # FutureWarning emis par bitsandbytes (torch._check_is_size) : meme fuite du chemin site-packages
    _w.filterwarnings("ignore", message=r"_check_is_size will be removed.*")
    # torch signale « triton not found » au chargement : sans objet pour ces mesures
    logging.getLogger("torch.utils.flop_counter").setLevel(logging.ERROR)

    model_id = "Qwen/Qwen3.5-0.8B"  # Petit modele ideal pour la demo
    tokenizer = AutoTokenizer.from_pretrained(model_id, trust_remote_code=True)
    tokenizer.pad_token = tokenizer.eos_token

    # Corpus fixe (anglais : in-distribution du modele, la perplexite est lisible)
    corpus = [
        "The quantization of language models reduces the memory required for deployment.",
        "There is a trade-off between model size and output quality after quantization.",
        "BitsAndBytes loads a model in 8-bit or 4-bit precision directly from Hugging Face.",
        "Perplexity measures how well a probabilistic language model predicts a corpus.",
        "A quantized model can run on a single consumer graphics card.",
    ]
    enc = tokenizer(corpus, return_tensors="pt", padding=True, truncation=True, max_length=64).to("cuda")
    labels = enc["input_ids"].clone()
    labels[enc["attention_mask"] == 0] = -100
    PROMPT = "Quantizing a large language model"

    def measure_bnb(kind):
        """Charge Qwen3.5-0.8B en BF16 / 8-bit / 4-bit et mesure VRAM, taille, perplexite, debit."""
        torch.cuda.empty_cache()
        torch.cuda.reset_peak_memory_stats()
        if kind == "bf16":
            model = AutoModelForCausalLM.from_pretrained(model_id, dtype=torch.bfloat16,
                                                         device_map="auto", trust_remote_code=True)
        elif kind == "int8":
            model = AutoModelForCausalLM.from_pretrained(
                model_id, quantization_config=BitsAndBytesConfig(load_in_8bit=True),
                device_map="auto", trust_remote_code=True)
        else:
            model = AutoModelForCausalLM.from_pretrained(
                model_id, quantization_config=BitsAndBytesConfig(load_in_4bit=True),
                device_map="auto", trust_remote_code=True)
        vram_gb = torch.cuda.max_memory_allocated() / 1e9
        footprint_gb = model.get_memory_footprint() / 1e9
        model.eval()
        with torch.no_grad():
            loss = model(**enc, labels=labels).loss.item()
        perpl = math.exp(loss)
        inp = tokenizer(PROMPT, return_tensors="pt").to("cuda")
        with torch.no_grad():
            model.generate(**inp, max_new_tokens=8, do_sample=False)  # warmup
            torch.cuda.synchronize()
        n = 40
        t0 = time.time()
        with torch.no_grad():
            gen = model.generate(**inp, max_new_tokens=n, do_sample=False)
            torch.cuda.synchronize()
        n_new = gen.shape[1] - inp["input_ids"].shape[1]
        tok_s = n_new / (time.time() - t0)
        del model
        return {"kind": kind, "vram_gb": round(vram_gb, 3),
                "footprint_gb": round(footprint_gb, 3), "perpl": round(perpl, 2),
                "tok_s": round(tok_s, 1)}

    # Les prints auto-docstring de transformers 5.12 embarquent le chemin site-packages
    # machine : on les avale (ratchet MACHINE_PATH, cf docstring check_output_failure_text)
    import io, contextlib
    with contextlib.redirect_stdout(io.StringIO()):
        rows = [measure_bnb(k) for k in ["bf16", "int8", "int4"]]

    print(f"{'Precision':<10} {'VRAM (GB)':>10} {'Taille (GB)':>12} {'Perplexite':>11} {'Debit (tok/s)':>13}")
    print("-" * 58)
    for r in rows:
        print(f"{r['kind']:<10} {r['vram_gb']:>10.3f} {r['footprint_gb']:>12.3f} {r['perpl']:>11.2f} {r['tok_s']:>13.1f}")

    bf = next(r for r in rows if r["kind"] == "bf16")
    i8 = next(r for r in rows if r["kind"] == "int8")
    i4 = next(r for r in rows if r["kind"] == "int4")
    print("\n--- Verdict honnete ---")
    print(f"VRAM: bf16 {bf['vram_gb']:.2f} -> int8 {i8['vram_gb']:.2f} ({i8['vram_gb']/bf['vram_gb']:.2f}x) -> int4 {i4['vram_gb']:.2f} ({i4['vram_gb']/bf['vram_gb']:.2f}x)")
    print(f"Perplexite (plus bas = mieux): bf16 {bf['perpl']:.1f} -> int8 {i8['perpl']:.1f} (+{(i8['perpl']/bf['perpl']-1)*100:.1f}%) -> int4 {i4['perpl']:.1f} (+{(i4['perpl']/bf['perpl']-1)*100:.1f}%)")
    _gpu = torch.cuda.get_device_name(0)
    print(f"Debit: bf16 {bf['tok_s']:.0f} -> int8 {i8['tok_s']:.0f} -> int4 {i4['tok_s']:.0f} tok/s")
    print(f"VERDICT: int8 = perte de qualite negligeable mais debit plus lent sur cette GPU ({_gpu}) ; int4 = gain memoire maximal ({i4['vram_gb']/bf['vram_gb']:.2f}x) mais degradation de qualite marquee (+{(i4['perpl']/bf['perpl']-1)*100:.0f}% perpl). Le compromis n'est PAS gratuit.")
    perpl_delta_i4 = (i4['perpl']/bf['perpl'] - 1) * 100
    mem_gain_i4 = i4['vram_gb']/bf['vram_gb']
    v_class = "NO BEATS" if (perpl_delta_i4 > 5 or i8['tok_s'] < bf['tok_s']) else "INCONCLUSIVE"
    print()
    print(f"VERDICT (classification): {v_class}")
    print(f"  La quantification ne domine pas BF16 sur cette GPU ({_gpu}) : elle REDUIT la memoire (objectif, int4 = {mem_gain_i4:.2f}x)")
    print(f"  mais DEGRADE la qualite en int4 (+{perpl_delta_i4:.0f}% perpl) et RALENTIT le debit (int8 {i8['tok_s']:.0f} vs bf16 {bf['tok_s']:.0f} tok/s).")
    print("  C'est un compromis, pas un gagnant : utile pour un deployement contraint en memoire, jamais gratuit.")
else:
    print("Pas de GPU - mesure BitsAndBytes non disponible (execution GPU requise)")
Precision   VRAM (GB)  Taille (GB)  Perplexite Debit (tok/s)
----------------------------------------------------------
bf16            1.505        1.505       36.55          21.9
int8            1.052        1.007       37.11           5.9
int4            0.814        0.758       54.21          13.4

--- Verdict honnete ---
VRAM: bf16 1.50 -> int8 1.05 (0.70x) -> int4 0.81 (0.54x)
Perplexite (plus bas = mieux): bf16 36.5 -> int8 37.1 (+1.5%) -> int4 54.2 (+48.3%)
Debit: bf16 22 -> int8 6 -> int4 13 tok/s
VERDICT: int8 = perte de qualite negligeable mais debit plus lent sur cette GPU (NVIDIA GeForce RTX 4090) ; int4 = gain memoire maximal (0.54x) mais degradation de qualite marquee (+48% perpl). Le compromis n'est PAS gratuit.

VERDICT (classification): NO BEATS
  La quantification ne domine pas BF16 sur cette GPU (NVIDIA GeForce RTX 4090) : elle REDUIT la memoire (objectif, int4 = 0.54x)
  mais DEGRADE la qualite en int4 (+48% perpl) et RALENTIT le debit (int8 6 vs bf16 22 tok/s).
  C'est un compromis, pas un gagnant : utile pour un deployement contraint en memoire, jamais gratuit.

Interpretation : BitsAndBytes

Resultats mesures (Qwen3.5-0.8B, NVIDIA GeForce RTX 4090, kernel coursia-sae, 5 prompts anglais fixes, 40 tokens generes) :

Metrique BF16 INT8 INT4
VRAM pic (GB) 1.505 1.052 0.814
Taille du modele (GB) 1.505 1.007 0.758
Perplexite (5 prompts) 36.55 37.11 54.21
Ratio memoire vs BF16 1.00x 0.70x 0.54x
Delta perplexite vs BF16 – +1.5 % +48.3 %

Note : le debit de generation varie d’une execution a l’autre (sur cette carte, BF16 entre 20 et 22 tok/s et int4 entre 13 et 18 tok/s sur nos executions du 30/09), donc le tableau omet le debit pour eviter un faux sentiment de precision. La tendance reste stable : int8 est nettement plus lent que BF16, int4 aussi, dans une moindre mesure.

Verdict : NO BEATS sur cette GPU. La quantification realise bien son objectif memoire (int4 = 0.54x BF16), mais le compromis n’est PAS gratuit :

  1. int8 = qualite preservee, debit effondre : la perte de qualite est negligeable (+1.5 % de perplexite), mais le dequant a la volee de BitsAndBytes est lent : le debit est divise par 3 a 4 par rapport a BF16 sur nos executions.
  2. int4 = -46 % memoire, +48 % perplexite : le gain memoire est maximal, mais la degradation de qualite est marquee (perplexite 36.5 -> 54.2). Sur un modele de 0.8B parametres, chaque poids porte beaucoup d’information : le bruit de quantification 4-bit se voit tout de suite.
  3. Les ratios ne sont PAS 2x / 4x : les buffers et activations restent en FP16/BF16, donc le ratio memoire observe est 0.70x (int8) et 0.54x (int4). Pour un modele 70B, le ratio se rapproche de la theorie car la fraction des poids dans le total domine.
  4. Le ralentissement vient de la methode, pas de la carte : meme sur une RTX 4090 de bureau, BitsAndBytes dequantifie les poids en logiciel a chaque passage. Les formats pre-calcules servis par vLLM (GPTQ, AWQ, avec des kernels dedies comme Marlin) sont concus pour eviter ce cout ; c’est l’objet des sections 3 a 5.

Quand utiliser BitsAndBytes ? : Exploration rapide et fine-tuning QLoRA sur GPU contrainte (8-16 GB VRAM). Pour la production, preferer AWQ/GPTQ pre-calcules servis par vLLM.


Section 3 : GPTQ avec llmcompressor (méthode production)

Pour deployer un modèle quantifie en production avec vLLM, la méthode recommandee est GPTQ via llmcompressor. Voici pourquoi :

  1. Format compressed-tensors : Natif pour vLLM, aucune conversion necessaire
  2. Exclusions fines via regex : Essentiel pour les modèles vision-language (voir section 4)
  3. Developpe par Neural Magic : L’équipe integree a vLLM, kernels Marlin optimises
  4. One-shot : Une seule passe sur le dataset de calibration

Dataset de calibration

llmcompressor utilise un dataset de calibration pour determiner les echelles de quantization optimales. Le choix par defaut est Open-Platypus :

  • 25K paires instruction/reponse couvrant des sujets STEM varies
  • 512 samples est le point optimal (bon compromis temps/qualite)
  • Au-dela de 512, le gain est marginal

Paramètres cles

Paramètre Valeur Description
targets "Linear" Quantifier toutes les couches lineaires
scheme "W4A16" 4-bit poids, 16-bit activations
ignore ["lm_head"] Exclure la couche de sortie (sensible)
dampening_frac 0.01 Stabilisation numérique
block_size 128 Taille des blocs de quantization
# Exemple de script de quantization GPTQ avec llmcompressor
# Adapte des scripts de production (workspace vLLM)
#
# NOTE: Ce code est presente a titre educatif.
# L'execution reelle necessite un GPU puissant et ~30 min,
# il est donc presente en lecture seule.

QUANTIZATION_SCRIPT = '''
# Installation requise :
# conda create -n llmcompressor python=3.11 -y
# pip install torch==2.5.1 --index-url https://download.pytorch.org/whl/cu124
# pip install "llmcompressor>=0.9.0" "transformers>=4.48.0" accelerate datasets

from llmcompressor.modifiers.quantization import GPTQModifier
from llmcompressor import oneshot
from datasets import load_dataset
from transformers import AutoTokenizer

# ============================================================
# 1. Configuration
# ============================================================
model_id = "Qwen/Qwen3.5-0.8B"    # Modele source (BF16)
output_dir = "./models/Qwen3.5-0.8B-GPTQ-W4A16"

# ============================================================
# 2. Dataset de calibration
# ============================================================
# Open-Platypus : 25K paires instruction/reponse, diversite STEM
# 512 samples = bon compromis temps/qualite
ds = load_dataset("garage-bAInd/Open-Platypus", split="train")
ds = ds.shuffle(seed=42).select(range(512))

def preprocess(example):
    return {"text": example["instruction"] + "\\n" + example.get("output", "")}
ds = ds.map(preprocess)

# ============================================================
# 3. Configuration GPTQ
# ============================================================
recipe = GPTQModifier(
    targets="Linear",              # Toutes les couches lineaires
    scheme="W4A16",                # 4-bit poids, 16-bit activations
    ignore=["lm_head"],            # Ne pas quantifier la couche de sortie
    dampening_frac=0.01,           # Stabilisation numerique
    block_size=128,                # Taille des blocs de quantization
)

# ============================================================
# 4. Quantization one-shot
# ============================================================
oneshot(
    model=model_id,
    dataset=ds,
    recipe=recipe,
    output_dir=output_dir,
    max_seq_length=4096,
    num_calibration_samples=512,
)

print(f"Modele quantifie sauvegarde dans: {output_dir}")
'''

print("=== Script de quantization GPTQ (llmcompressor) ===")
print(QUANTIZATION_SCRIPT)

if not BATCH_MODE:
    print("\n--- Ce script est presente a titre educatif ---")
    print("Pour l'executer reellement :")
    print("1. Creer un environnement conda dedie")
    print("2. Installer les dependances ci-dessus")
    print("3. Copier le script dans un fichier .py")
    print("4. Executer (~5-10 min pour 0.8B, ~35 min pour 8B)")
else:
    print("\n[BATCH_MODE] Script presente en lecture seule")
=== Script de quantization GPTQ (llmcompressor) ===

# Installation requise :
# conda create -n llmcompressor python=3.11 -y
# pip install torch==2.5.1 --index-url https://download.pytorch.org/whl/cu124
# pip install "llmcompressor>=0.9.0" "transformers>=4.48.0" accelerate datasets

from llmcompressor.modifiers.quantization import GPTQModifier
from llmcompressor import oneshot
from datasets import load_dataset
from transformers import AutoTokenizer

# ============================================================
# 1. Configuration
# ============================================================
model_id = "Qwen/Qwen3.5-0.8B"    # Modele source (BF16)
output_dir = "./models/Qwen3.5-0.8B-GPTQ-W4A16"

# ============================================================
# 2. Dataset de calibration
# ============================================================
# Open-Platypus : 25K paires instruction/reponse, diversite STEM
# 512 samples = bon compromis temps/qualite
ds = load_dataset("garage-bAInd/Open-Platypus", split="train")
ds = ds.shuffle(seed=42).select(range(512))

def preprocess(example):
    return {"text": example["instruction"] + "\n" + example.get("output", "")}
ds = ds.map(preprocess)

# ============================================================
# 3. Configuration GPTQ
# ============================================================
recipe = GPTQModifier(
    targets="Linear",              # Toutes les couches lineaires
    scheme="W4A16",                # 4-bit poids, 16-bit activations
    ignore=["lm_head"],            # Ne pas quantifier la couche de sortie
    dampening_frac=0.01,           # Stabilisation numerique
    block_size=128,                # Taille des blocs de quantization
)

# ============================================================
# 4. Quantization one-shot
# ============================================================
oneshot(
    model=model_id,
    dataset=ds,
    recipe=recipe,
    output_dir=output_dir,
    max_seq_length=4096,
    num_calibration_samples=512,
)

print(f"Modele quantifie sauvegarde dans: {output_dir}")


[BATCH_MODE] Script presente en lecture seule

Interpretation : GPTQ avec llmcompressor

Structure du script :

Étape Description Duree (~8B)
1. Configuration Définir modèle source et sortie < 1 sec
2. Dataset Charger et preparer Open-Platypus ~30 sec
3. GPTQModifier Définir la recette de quantization < 1 sec
4. oneshot() Quantization effective ~35 min

Impact du nombre d’echantillons de calibration :

Samples Temps (~8B) Recommandation
128 ~15 min Prototypage rapide
256 ~20 min Tests
512 ~35 min Production (defaut)
1024 ~70 min Gain marginal

Points cles : 1. Le GPTQModifier est l’élément central : il définit la recette de quantization 2. ignore=["lm_head"] est crucial : la couche de sortie projette vers le vocabulaire et sa precision affecte directement la qualite de generation 3. scheme="W4A16" signifie : poids en 4 bits, activations en 16 bits au runtime 4. Le format de sortie est automatiquement compressed-tensors, natif pour vLLM

Exemple guidé 2 : Configuration d’une recette de quantification GPTQ

Résolution de l’Exercice 2 initial : la fonction configure_gptq_recipe() construit la recette GPTQ W4A16 (targets, scheme, ignore, dampening_frac, block_size), estime la taille du modele quantifie, et VERIFIE la compatibilite vLLM par condition (scheme supporte, targets Linear, lm_head preserve) plutot que de la coder en dur — avec des assertions d’auto-controle.

Contribution étudiante de Youssef AGREBAOUI (@YoussefAG1337), PR #18568, intégrée comme exemple guidé.

Un nouvel exercice sur le même thème (diagnostiquer une recette invalide) suit la lecture du résultat ci-dessous.

VLLM_SUPPORTED_SCHEMES = {"W4A16", "W8A16", "W8A8", "FP8"}
SCHEME_BYTES_PER_PARAM = {"W4A16": 0.5, "W8A16": 1, "W8A8": 1, "FP8": 1}


def configure_gptq_recipe(model_params_billions, model_type="text"):
    """
    Configure une recette de quantification GPTQ pour un modele LLM.
    
    Args:
        model_params_billions: Nombre de parametres en milliards (ex: 7.0)
        model_type: "text" pour texte-only, "vl" pour vision-language
    
    Returns:
        dict: Configuration avec parametres GPTQ et taille estimee
    """
    # TODO etudiant : construire le dictionnaire de configuration
    ignore = ["lm_head"]
    if model_type == "vl":
        ignore += ["re:.*visual.*", "re:.*merger.*"]

    config = {
        "targets": "Linear",
        "scheme": "W4A16",
        "ignore": ignore,
        "dampening_frac": 0.01,
        "block_size": 128,
    }
    
    # TODO etudiant : calculer la taille estimee du modele quantifie
    bytes_per_param = SCHEME_BYTES_PER_PARAM[config["scheme"]]
    estimated_size_gb = model_params_billions * 1e9 * bytes_per_param / 1e9 * 1.2
    
    return {
        "config": config,
        "estimated_size_gb": round(estimated_size_gb, 2),
        # TODO etudiant : verifier la compatibilite
        "compatible_vllm": (
            config["scheme"] in VLLM_SUPPORTED_SCHEMES
            and config["targets"] == "Linear"
            and "lm_head" in config["ignore"]
        ),
    }


# Test
result = configure_gptq_recipe(7.0, "text")
print(f"Modele cible : 7B text-only, destine a vLLM")
print(f"Taille BF16 attendue : ~{7.0 * 2:.1f} GB")
print(f"Taille W4A16 estimee : ~{result['estimated_size_gb']:.1f} GB")
print(f"Compatible vLLM (compressed-tensors) : {result['compatible_vllm']}")
print("\nRecette GPTQModifier :")
for k, v in result["config"].items():
    print(f"  {k:<15} = {v!r}")

assert abs(result["estimated_size_gb"] - 4.2) < 1e-6
assert result["compatible_vllm"] is True
Modele cible : 7B text-only, destine a vLLM
Taille BF16 attendue : ~14.0 GB
Taille W4A16 estimee : ~4.2 GB
Compatible vLLM (compressed-tensors) : True

Recette GPTQModifier :
  targets         = 'Linear'
  scheme          = 'W4A16'
  ignore          = ['lm_head']
  dampening_frac  = 0.01
  block_size      = 128

Lecture du résultat : recette GPTQ

Ce que la sortie montre : la recette vise toutes les couches Linear, en 4 bits pour les poids et 16 bits pour les activations, et garde lm_head en pleine précision. La taille estimée, 4.2 GB, reprend la marge de 20 % de l’exemple guidé 1 : sans elle, 7 milliards de poids à 0.5 octet font 3.5 GB.

Ce que Compatible vLLM : True ne prouve pas : la fonction vérifie la recette qu’elle vient elle-même de construire. Le schéma W4A16 est écrit en dur, targets vaut Linear et lm_head est exclu dès la première ligne : le test ne peut pas échouer ici. Une vérification utile porte sur une recette écrite par quelqu’un d’autre ; c’est l’objet de l’exercice 2.

Exercice 2 : Diagnostiquer une recette GPTQ invalide

L’exemple guidé 2 construit une recette correcte et vérifie qu’elle l’est. En pratique, on reçoit souvent une recette écrite par quelqu’un d’autre, et un simple « compatible : False » ne dit pas quoi corriger. Il faut la liste des causes.

Objectif : écrire diagnose_gptq_recipe(recipe), qui renvoie la liste des problèmes empêchant vLLM de servir le modèle quantifié (liste vide si la recette est correcte), puis l’appliquer aux quatre recettes fournies. L’une d’elles cumule deux défauts : le diagnostic doit les signaler tous les deux.

Indices : - # Indice : les trois conditions sont celles de l’exemple guidé 2 : schéma parmi ceux que vLLM sait servir, targets égal à "Linear", lm_head présent dans ignore. - # Indice : une recette peut ne pas avoir de clé ignore du tout : recipe.get("ignore", []). - # Étape 1 : tester chaque condition séparément et ajouter un message explicite à la liste quand elle échoue. - # Étape 2 : afficher le diagnostic de chaque recette. - # Étape 3 : pour chaque problème trouvé, écrire en une phrase ce qu’il coûterait à l’exécution (refus de chargement par vLLM, perte de qualité, etc.).

# EXERCICE 2 : diagnostiquer une recette GPTQ

SCHEMES_SERVIS_PAR_VLLM = {"W4A16", "W8A16", "W8A8", "FP8"}


def diagnose_gptq_recipe(recipe):
    """
    Liste les problemes qui empechent vLLM de servir le modele quantifie.

    Args:
        recipe: dict avec les cles "scheme", "targets" et (eventuellement) "ignore"

    Returns:
        list[str]: un message par probleme ; liste vide si la recette est correcte
    """
    problems = []
    # TODO etudiant : Etape 1 - tester le schema, la cible et la presence de lm_head
    return problems


recettes = {
    "correcte": {"scheme": "W4A16", "targets": "Linear", "ignore": ["lm_head"]},
    "schema_exotique": {"scheme": "W3A16", "targets": "Linear", "ignore": ["lm_head"]},
    "tete_quantifiee": {"scheme": "W4A16", "targets": "Linear", "ignore": []},
    "deux_defauts": {"scheme": "W2A16", "targets": "Linear"},
}

# Etape 2 : afficher le diagnostic de chaque recette
for nom, recette in recettes.items():
    print(f"{nom:<16} -> {diagnose_gptq_recipe(recette)}")
print("Exercice a completer : seule la recette 'correcte' doit renvoyer une liste vide")
correcte         -> []
schema_exotique  -> []
tete_quantifiee  -> []
deux_defauts     -> []
Exercice a completer : seule la recette 'correcte' doit renvoyer une liste vide

Section 4 : Cas special – Modèles multimodaux (Vision-Language)

Les modèles Vision-Language (VL) comme Qwen3-VL ou ZwZ-8B combinent un encodeur vision (ViT/SigLIP) et un decodeur langage (LLM). La quantization de ces modèles necessite une attention particuliere.

Pourquoi exclure l’encodeur vision ?

L’encodeur vision utilise des opérations softmax et LayerNorm extremement sensibles a la precision numérique. Quantifier le ViT entraine typiquement :

  • -20 points sur les benchmarks OCR (lecture de texte dans les images)
  • -15 points sur les benchmarks de comprehension visuelle
  • Des artefacts dans la representation des patches d’image

Cout en memoire de l’exclusion

L’encodeur vision ne represente qu’une petite fraction du modèle total :

Composant Params (8B VL) Memoire BF16 % du total
Visual encoder (ViT) ~300M ~0.6 GB 3.7%
Patch merger ~50M ~0.1 GB 0.6%
Language model ~7.6B ~15.2 GB 95.7%

Le surpoids de garder le ViT en BF16 est negligeable (~0.7 GB) par rapport au gain de quantifier le LLM (15.2 GB -> 3.8 GB).

Patterns d’exclusion

ignore=[
    "lm_head",          # Couche de sortie (sensible)
    "re:.*visual.*",    # Encodeur vision complet
    "re:.*merger.*",    # Patch merger vision -> langage
]

Attention : Le script doit pre-charger le modèle avec la bonne classe (Qwen3VLForConditionalGeneration) car oneshot() utilise AutoModelForCausalLM en interne, qui ne supporte pas les modèles VL.

# Quantization d'un modele Vision-Language
# Les couches vision sont EXCLUES pour preserver la qualite

VL_QUANTIZATION_SCRIPT = '''
# Difference cle vs modele text-only :
# 1. Utiliser le bon model class (pas AutoModelForCausalLM)
# 2. Exclure le visual encoder et le merger
# 3. Utiliser processor au lieu de tokenizer seul

from transformers import Qwen3VLForConditionalGeneration, AutoProcessor
from llmcompressor.modifiers.quantization import GPTQModifier
from llmcompressor import oneshot

model_id = "inclusionAI/ZwZ-8B"  # Modele VL (finetune Qwen3-VL-8B)

# CRITIQUE: Pre-charger avec la bonne classe
# AutoModelForCausalLM ne supporte PAS les modeles VL!
model = Qwen3VLForConditionalGeneration.from_pretrained(
    model_id,
    torch_dtype="auto",
    device_map="auto",
)
processor = AutoProcessor.from_pretrained(model_id)

# Configuration GPTQ avec EXCLUSIONS vision
recipe = GPTQModifier(
    targets="Linear",
    scheme="W4A16",
    ignore=[
        "lm_head",          # Couche de sortie (sensible)
        "re:.*visual.*",    # Visual encoder ViT (tres sensible!)
        "re:.*merger.*",    # Patch merger vision->language
    ],
    dampening_frac=0.01,
)

# Le dataset de calibration est TEXT-ONLY
# (les couches vision sont exclues, pas besoin d images)
# ... meme dataset Open-Platypus que pour text-only ...

oneshot(
    model=model,              # Objet model (pas string!)
    dataset=ds,
    recipe=recipe,
    output_dir="./models/ZwZ-8B-AWQ-4bit",
    max_seq_length=4096,
    num_calibration_samples=512,
    save_compressed=False,    # Contourner bug packing VL
)
'''

print("=== Script quantization VL (avec exclusions vision) ===")
print(VL_QUANTIZATION_SCRIPT)

# Comment inspecter les couches d'un modele
print("\n--- Pour identifier les couches a exclure ---")
print("from transformers import Qwen3VLForConditionalGeneration")
print("model = Qwen3VLForConditionalGeneration.from_pretrained(model_id)")
print("for name, _ in model.named_modules():")
print('    if \"visual\" in name or \"merger\" in name:')
print('        print(name)  # Ces couches doivent etre exclues')
=== Script quantization VL (avec exclusions vision) ===

# Difference cle vs modele text-only :
# 1. Utiliser le bon model class (pas AutoModelForCausalLM)
# 2. Exclure le visual encoder et le merger
# 3. Utiliser processor au lieu de tokenizer seul

from transformers import Qwen3VLForConditionalGeneration, AutoProcessor
from llmcompressor.modifiers.quantization import GPTQModifier
from llmcompressor import oneshot

model_id = "inclusionAI/ZwZ-8B"  # Modele VL (finetune Qwen3-VL-8B)

# CRITIQUE: Pre-charger avec la bonne classe
# AutoModelForCausalLM ne supporte PAS les modeles VL!
model = Qwen3VLForConditionalGeneration.from_pretrained(
    model_id,
    torch_dtype="auto",
    device_map="auto",
)
processor = AutoProcessor.from_pretrained(model_id)

# Configuration GPTQ avec EXCLUSIONS vision
recipe = GPTQModifier(
    targets="Linear",
    scheme="W4A16",
    ignore=[
        "lm_head",          # Couche de sortie (sensible)
        "re:.*visual.*",    # Visual encoder ViT (tres sensible!)
        "re:.*merger.*",    # Patch merger vision->language
    ],
    dampening_frac=0.01,
)

# Le dataset de calibration est TEXT-ONLY
# (les couches vision sont exclues, pas besoin d images)
# ... meme dataset Open-Platypus que pour text-only ...

oneshot(
    model=model,              # Objet model (pas string!)
    dataset=ds,
    recipe=recipe,
    output_dir="./models/ZwZ-8B-AWQ-4bit",
    max_seq_length=4096,
    num_calibration_samples=512,
    save_compressed=False,    # Contourner bug packing VL
)


--- Pour identifier les couches a exclure ---
from transformers import Qwen3VLForConditionalGeneration
model = Qwen3VLForConditionalGeneration.from_pretrained(model_id)
for name, _ in model.named_modules():
    if "visual" in name or "merger" in name:
        print(name)  # Ces couches doivent etre exclues

Interpretation : quantization Vision-Language

Différences cles avec un modèle text-only :

Aspect Text-only Vision-Language
Model class AutoModelForCausalLM Qwen3VLForConditionalGeneration
Argument model= String (model_id) Objet model pre-charge
Exclusions ["lm_head"] ["lm_head", "re:.*visual.*", "re:.*merger.*"]
save_compressed Par defaut (True) False (bug de packing VL)
Surpoids ViT N/A ~0.7 GB (negligeable)

Points cles : 1. Le pre-chargement avec la bonne classe est obligatoire car oneshot() utilise AutoModelForCausalLM en interne, incompatible avec les modèles VL 2. Le dataset de calibration reste text-only : les couches vision sont exclues, donc pas besoin d’images pour la calibration 3. save_compressed=False contourne un bug connu de packing avec les modèles VL 4. L’inspection des couches avec named_modules() est la méthode fiable pour identifier les patterns a exclure

Exemple guidé 3 : Identification des couches a exclure pour un modèle VL

Résolution de l’Exercice 3 initial : la fonction identify_exclusions() accepte un modele HuggingFace ou une simple liste de noms de couches, construit les patterns d’exclusion GPTQ (lm_head, tour visuelle, merger, normalizations), compte les couches exclues et estime le surcout memoire d’un plan mixed-precision (vision en BF16, language en W4A16).

Contribution étudiante de Youssef AGREBAOUI (@YoussefAG1337), PR #18568, intégrée comme exemple guidé.

Un nouvel exercice sur le même thème (auditer une liste d’exclusion existante) suit la lecture du résultat ci-dessous.

VISION_KEYWORDS = ["visual", "vision", "patch", "merger"]


def identify_exclusions(model):
    """
    Analyse les couches d'un modele et retourne la liste des patterns d'exclusion
    pour la quantification GPTQ.
    
    Args:
        model: Un modele HuggingFace (ex: Qwen3VLForConditionalGeneration)
    
    Returns:
        list: Patterns d'exclusion pour GPTQModifier(ignore=[...])
    """
    # TODO etudiant : iterer sur les modules et identifier les couches a exclure
    # Accepte un modele HF ou une simple liste de noms de couches (test sans GPU)
    if hasattr(model, "named_modules"):
        named = [(n, m) for n, m in model.named_modules() if n]
    else:
        named = [(n, None) for n in model]

    ignore = ["lm_head"]
    excluded_layers = []
    for name, _ in named:
        lname = name.lower()
        kw = next((k for k in VISION_KEYWORDS if k in lname), None)
        if kw:
            pattern = f"re:.*{kw}.*"
            if pattern not in ignore:
                ignore.append(pattern)
            excluded_layers.append(name)
        elif "norm" in lname.split(".")[-1]:
            if "re:.*norm.*" not in ignore:
                ignore.append("re:.*norm.*")
            excluded_layers.append(name)
        elif name == "lm_head" or name.endswith(".lm_head"):
            excluded_layers.append(name)

    # Etape 4 : surcout memoire des couches exclues (uniquement avec un vrai modele)
    stats = None
    if hasattr(model, "named_parameters"):
        total = sum(p.numel() for p in model.parameters())
        excluded = sum(p.numel() for n, p in model.named_parameters()
                       if any(n == l or n.startswith(l + ".") for l in excluded_layers))
        stats = {"total_params": total, "excluded_params": excluded,
                 "excluded_ratio": excluded / total if total else 0.0}

    identify_exclusions.excluded_layers = excluded_layers
    identify_exclusions.stats = stats
    return ignore


# Test avec les noms de couches simules (sans charger de modele reel)
# Ces noms sont representatifs d'un modele Qwen3-VL
sample_layer_names = [
    "model.layers.0.self_attn.q_proj",
    "model.layers.0.self_attn.k_proj",
    "visual.encoder.block.0.attn.qkv",
    "visual.patch_embed.proj",
    "model.merger.adapter",
    "lm_head",
    "model.norm",
]

ignore = identify_exclusions(sample_layer_names)
print(f"Couches a analyser : {len(sample_layer_names)}")
print(f"Couches exclues ({len(identify_exclusions.excluded_layers)}) :")
for n in identify_exclusions.excluded_layers:
    print(f"  - {n}")
print(f"\nignore = {ignore}")

quantized = [n for n in sample_layer_names if n not in identify_exclusions.excluded_layers]
print(f"\nCouches quantifiees en W4A16 : {quantized}")

# Estimation du surcout memoire avec les ordres de grandeur d'un 8B VL (cf. tableau Section 4)
vit_params, merger_params, llm_params = 0.30, 0.05, 7.6
excluded_bf16 = (vit_params + merger_params) * 2
full_w4 = (vit_params + merger_params + llm_params) * 0.5
mixed = excluded_bf16 + llm_params * 0.5
print(f"\nSurcout de garder vision en BF16 : {mixed - full_w4:.2f} GB "
      f"({mixed:.2f} GB vs {full_w4:.2f} GB tout en W4A16, +{(mixed / full_w4 - 1) * 100:.0f}%)")
Couches a analyser : 7
Couches exclues (5) :
  - visual.encoder.block.0.attn.qkv
  - visual.patch_embed.proj
  - model.merger.adapter
  - lm_head
  - model.norm

ignore = ['lm_head', 're:.*visual.*', 're:.*merger.*', 're:.*norm.*']

Couches quantifiees en W4A16 : ['model.layers.0.self_attn.q_proj', 'model.layers.0.self_attn.k_proj']

Surcout de garder vision en BF16 : 0.53 GB (4.50 GB vs 3.97 GB tout en W4A16, +13%)

Lecture du résultat : couches à exclure

Ce que la sortie montre : sur 7 noms de couches simulés, 5 sont exclus et 2 seulement (les projections q_proj et k_proj du modèle de langage) seront quantifiés. La liste ignore résume ces 5 couches en 4 motifs : visual.patch_embed.proj est couvert par re:.*visual.*, car le mot-clé visual est testé avant patch.

Une exclusion sans effet : model.norm est exclu par le motif re:.*norm.*. Or la recette vise targets="Linear", et une couche de normalisation n’est pas une couche Linear : GPTQ ne l’aurait pas quantifiée de toute façon. Le motif ne coûte rien, mais il ne protège rien non plus.

Le surcoût est une estimation : les 0.53 GB (+13 %) viennent d’ordres de grandeur posés dans le code (0.30 milliard de paramètres pour l’encodeur visuel, 0.05 pour le merger, 7.6 pour le modèle de langage), pas d’un modèle chargé. Ils restent du même ordre que les ~0.7 GB du tableau de la section 4.

Exercice 3 : Auditer une liste d’exclusion existante

L’exemple guidé 3 construit la liste ignore à partir des noms de couches. Le cas inverse est aussi fréquent : une liste d’exclusion existe déjà, recopiée d’un autre modèle ou écrite à la main, et elle contient des erreurs qui ne provoquent aucun message. Un motif mal orthographié n’exclut rien, et la tour visuelle se retrouve quantifiée en silence ; un motif trop large exclut des couches du modèle de langage, et le modèle grossit sans raison.

Objectif : écrire audit_ignore_list(ignore_patterns, layer_names), qui renvoie trois listes : - les motifs morts, qui ne correspondent à aucune couche ; - les couches du modèle de langage exclues par erreur ; - les couches de vision non couvertes, qui seront donc quantifiées.

Indices : - # Indice : un motif est soit un nom exact ("lm_head"), soit une expression régulière préfixée par re: ("re:.*visual.*"), testée avec re.match sur le nom complet de la couche. - # Indice : une couche est « de vision » si son nom contient visual ou merger, comme dans l’exemple guidé 3. - # Étape 1 : écrire motif_couvre(motif, nom). - # Étape 2 : en déduire les trois listes. - # Étape 3 : proposer la liste ignore corrigée et vérifier qu’elle ne produit plus aucune alerte.

# EXERCICE 3 : auditer une liste d'exclusion GPTQ existante
import re


def motif_couvre(motif, nom):
    """Vrai si le motif d'exclusion (nom exact ou 're:<regex>') couvre la couche `nom`."""
    # TODO etudiant : Etape 1 - distinguer le nom exact du motif 're:'
    return False


def audit_ignore_list(ignore_patterns, layer_names):
    """
    Audite une liste d'exclusion GPTQ.

    Returns:
        dict: motifs morts, couches de langage exclues par erreur, couches de vision non couvertes
    """
    # TODO etudiant : Etape 2 - construire les trois listes avec motif_couvre()
    return {
        "motifs_morts": [],
        "langage_exclu_par_erreur": [],
        "vision_non_couverte": [],
    }


layer_names = [
    "model.layers.0.self_attn.q_proj",
    "model.layers.0.self_attn.o_proj",
    "model.layers.0.mlp.down_proj",
    "visual.blocks.0.attn.qkv",
    "visual.blocks.0.mlp.fc1",
    "visual.merger.mlp.0",
    "lm_head",
]

# Liste recopiee d'un autre projet : elle contient une faute de frappe et un motif trop large
ignore_patterns = ["lm_head", "re:.*visaul.*", "re:.*merger.*", "re:.*down_proj"]

for cle, valeurs in audit_ignore_list(ignore_patterns, layer_names).items():
    print(f"{cle:<26} {valeurs}")
print("Exercice a completer : les trois listes sont vides tant que motif_couvre() n'est pas ecrite")
motifs_morts               []
langage_exclu_par_erreur   []
vision_non_couverte        []
Exercice a completer : les trois listes sont vides tant que motif_couvre() n'est pas ecrite

Section 5 : Deploiement avec vLLM

Une fois le modèle quantifie, le deploiement avec vLLM est straightforward. vLLM detecte automatiquement le format compressed-tensors et charge les kernels Marlin optimises.

Commande de lancement

vllm serve ./models/ZwZ-8B-AWQ-4bit \
    --dtype auto \
    --kv-cache-dtype fp8 \
    --gpu-memory-utilization 0.85 \
    --max-model-len 131072 \
    --port 5001

Paramètres critiques

Paramètre Valeur Explication
--dtype auto auto Pas half! vLLM choisit le bon dtype selon le modèle
--kv-cache-dtype fp8 fp8 Reduit la memoire du KV cache de 2x
--gpu-memory-utilization 0.85 Reserve 85% de la VRAM (15% de marge)
--max-model-len 131072 Contexte maximum supporte

KV cache FP8 : compromis vitesse/capacite

Config KV cache Capacite tokens Debit decode Cas d’usage
fp8 335K tokens 86 tok/s Multi-utilisateurs, long contexte
auto (fp16) 206K tokens 96-109 tok/s Mono-utilisateur, max vitesse

Le KV cache FP8 permet de stocker 63% de tokens en plus avec une perte de vitesse de seulement ~12%. C’est le choix par defaut pour les deployements multi-utilisateurs.

Attention : Pour les modèles MoE, les kernels Marlin MoE ont une allocation memoire variable qui peut causer des erreurs CUDA OOM au runtime (bug vLLM #27951). Prevoir une marge de 15% minimum.

# Test des modeles quantifies deployes localement
# Utilise les endpoints configures dans .env (OPENAI_API_KEY_2/3, OPENAI_BASE_URL_2/3)

from openai import OpenAI

# Chargement robuste de la configuration .env AVANT de lire les variables.
# On cherche le .env dans tous les parents (Papermill change le cwd).
env_loaded = False
current_path = Path.cwd()
for _ in range(10):
    env_path = current_path / ".env"
    if env_path.exists():
        load_dotenv(env_path)
        env_loaded = True
        break
    if current_path.name == "GenAI" or len(current_path.parts) <= 1:
        break
    current_path = current_path.parent
if env_loaded:
    print(f".env charge depuis: {env_path.name}")
else:
    print("WARNING: .env non trouve, utilisation variables environnement")
    print("Configurez OPENAI_API_KEY_2/3, OPENAI_BASE_URL_2/3, OPENAI_CHAT_MODEL_ID_2/3")
    print("Voir le fichier .env.example pour les details de configuration")

endpoints = []

# Endpoint mini (ZwZ-8B AWQ)
mini_key = os.getenv("OPENAI_API_KEY_2")
mini_url = os.getenv("OPENAI_BASE_URL_2")
mini_model = os.getenv("OPENAI_CHAT_MODEL_ID_2")
if mini_key and mini_url:
    endpoints.append(("mini (ZwZ-8B AWQ)", mini_url, mini_key, mini_model))

# Endpoint medium (Qwen3.5 GPTQ)
med_key = os.getenv("OPENAI_API_KEY_3")
med_url = os.getenv("OPENAI_BASE_URL_3")
med_model = os.getenv("OPENAI_CHAT_MODEL_ID_3")
if med_key and med_url:
    endpoints.append(("medium (Qwen3.5 GPTQ)", med_url, med_key, med_model))

for name, base_url, api_key, model in endpoints:
    print(f"\n=== Test {name} ===")
    try:
        client = OpenAI(base_url=base_url, api_key=api_key)

        # Lister les modeles disponibles
        models = client.models.list()
        print(f"Modeles disponibles: {[m.id for m in models.data]}")

        # Test de chat
        t0 = time.time()
        resp = client.chat.completions.create(
            model=model or models.data[0].id,
            messages=[{
                "role": "user",
                "content": "Explique la quantization des LLMs en 2 phrases."
            }],
            max_completion_tokens=1000,
        )
        elapsed = time.time() - t0
        tokens = resp.usage.completion_tokens

        print(f"Reponse: {resp.choices[0].message.content}")
        print(f"Tokens: {tokens}, Temps: {elapsed:.2f}s, Vitesse: {tokens/elapsed:.1f} tok/s")

    except Exception as e:
        print(f"Erreur: {e}")

if not endpoints:
    print("Aucun endpoint vLLM local configure (OPENAI_BASE_URL_2/3 absents du .env).")
    print("Les benchmarks ci-apres proviennent de la stack de production (3x RTX 4090).")
WARNING: .env non trouve, utilisation variables environnement
Configurez OPENAI_API_KEY_2/3, OPENAI_BASE_URL_2/3, OPENAI_CHAT_MODEL_ID_2/3
Voir le fichier .env.example pour les details de configuration
Aucun endpoint vLLM local configure (OPENAI_BASE_URL_2/3 absents du .env).
Les benchmarks ci-apres proviennent de la stack de production (3x RTX 4090).

Interpretation : deploiement vLLM

Scénario 1 : Endpoints configures

Si les endpoints locaux sont configures dans .env, la cellule affiche : - La liste des modèles servis par chaque instance vLLM - La reponse generee et la vitesse en tokens/seconde - Le temps de première reponse (TTFT)

Scénario 2 : Pas d’endpoints

Si aucun endpoint n’est configure, c’est normal : les endpoints vLLM locaux ne sont disponibles que sur la machine de production (3x RTX 4090). En cours, l’enseignant peut les fournir via .env.

Points cles : 1. L’API vLLM est 100% compatible OpenAI : même client, même code 2. Le changement cloud -> local se fait en changeant uniquement base_url et api_key 3. La vitesse depend du modèle, de la precision, et du nombre d’utilisateurs simultanes

Portee honnete : ces valeurs sont rapportees de notre stack de production (vLLM, modeles quantifies 4-bit), pas re-mesurees dans ce notebook - les modeles concernes (8B, 35B MoE) depassent le GPU de demonstration. La mesure reelle du compromis BF16 -> quantifie sur un meme petit modele (Qwen3.5-0.8B, VRAM / taille / perplexite / debit) est dans la cellule BitsAndBytes de la section precedente.


Section 6 : Validation qualite – Benchmarks reels

La question fondamentale est : la quantization 4-bit degrade-t-elle la qualite ?

Voici les résultats de benchmarks reels executes sur notre stack de production (trimestre Q1-Q2 2026, vLLM avec modèles AWQ/GPTQ 4-bit).

Point pedagogique cle

ZwZ-8B (8B paramètres, specialise vision) bat Qwen3.5 (35B MoE) sur MMStar (63% vs 53%). Cela montre que la specialisation par fine-tuning peut compenser la taille du modèle. Mais sur MME (benchmark plus large), le modèle plus gros reprend l’avantage.

# Resultats de benchmarks reels (workspace vLLM, Q1-Q2 2026)
# Ces resultats sont sur des modeles AWQ/GPTQ 4-bit en production

print("=" * 70)
print("BENCHMARKS QUALITE - Modeles quantifies 4-bit (production)")
print("=" * 70)

# Donnees issues des benchmarks de production
benchmarks = {
    "GSM8K (math CoT 0-shot)": {"Qwen3.5 GPTQ": "88.0%", "ZwZ-8B AWQ": "N/A"},
    "IFEval (instruction following)": {"Qwen3.5 GPTQ": "88.5%", "ZwZ-8B AWQ": "N/A"},
    "MME (vision perception+cognition)": {"Qwen3.5 GPTQ": "1294.7 (91%)", "ZwZ-8B AWQ": "1248.1 (89%)"},
    "MMStar (vision multi-choice)": {"Qwen3.5 GPTQ": "53.2%", "ZwZ-8B AWQ": "63.0% (!)"},
}

print(f"\n{'Benchmark':<35} {'Qwen3.5 (35B MoE)':>18} {'ZwZ-8B (8B)':>15}")
print("-" * 70)
for bench, scores in benchmarks.items():
    print(f"{bench:<35} {scores['Qwen3.5 GPTQ']:>18} {scores['ZwZ-8B AWQ']:>15}")

print("\n" + "=" * 70)
print("BENCHMARKS PERFORMANCE - Vitesse d'inference")
print("=" * 70)

perf = [
    ("Decode single-user", "86.2 tok/s", "135 tok/s"),
    ("Concurrent 5 users", "269.6 tok/s", "392 tok/s"),
    ("Context max", "262K tokens", "131K tokens"),
    ("KV cache (FP8)", "335K tokens", "N/A"),
]

print(f"\n{'Metrique':<25} {'Qwen3.5 (TP=2)':>18} {'ZwZ-8B (1 GPU)':>18}")
print("-" * 65)
for metric, q35, zwz in perf:
    print(f"{metric:<25} {q35:>18} {zwz:>18}")

print("\n--- Point pedagogique cle ---")
print("ZwZ-8B (8B params) bat Qwen3.5 (35B MoE) sur MMStar (63% vs 53%)!")
print("La specialisation par fine-tuning peut compenser la taille du modele.")
print("Mais sur MME (benchmark plus large), le modele plus gros reprend l'avantage.")
======================================================================
BENCHMARKS QUALITE - Modeles quantifies 4-bit (production)
======================================================================

Benchmark                            Qwen3.5 (35B MoE)     ZwZ-8B (8B)
----------------------------------------------------------------------
GSM8K (math CoT 0-shot)                          88.0%             N/A
IFEval (instruction following)                   88.5%             N/A
MME (vision perception+cognition)         1294.7 (91%)    1248.1 (89%)
MMStar (vision multi-choice)                     53.2%       63.0% (!)

======================================================================
BENCHMARKS PERFORMANCE - Vitesse d'inference
======================================================================

Metrique                      Qwen3.5 (TP=2)     ZwZ-8B (1 GPU)
-----------------------------------------------------------------
Decode single-user                86.2 tok/s          135 tok/s
Concurrent 5 users               269.6 tok/s          392 tok/s
Context max                      262K tokens        131K tokens
KV cache (FP8)                   335K tokens                N/A

--- Point pedagogique cle ---
ZwZ-8B (8B params) bat Qwen3.5 (35B MoE) sur MMStar (63% vs 53%)!
La specialisation par fine-tuning peut compenser la taille du modele.
Mais sur MME (benchmark plus large), le modele plus gros reprend l'avantage.

Interpretation : benchmarks

Qualite (AWQ/GPTQ 4-bit) :

Benchmark Qwen3.5 GPTQ ZwZ-8B AWQ Commentaire
GSM8K (math) 88.0% N/A Excellent pour un 4-bit
IFEval (instructions) 88.5% N/A Bonne qualite de suivi d’instructions
MME (vision) 1294.7 1248.1 Qwen3.5 > ZwZ-8B (taille superieure)
MMStar (vision multi-choice) 53.2% 63.0% ZwZ-8B gagne (specialisation)

Performance (vitesse) :

Metrique Qwen3.5 (TP=2) ZwZ-8B (1 GPU)
Decode single-user 86.2 tok/s 135 tok/s
Concurrent 5 users 269.6 tok/s 392 tok/s
Context max 262K tokens 131K tokens
KV cache (FP8) 335K tokens N/A

Points cles : 1. La quantization 4-bit preserve remarquablement bien la qualite (88% sur GSM8K) 2. ZwZ-8B (specialise vision) bat le modèle 4x plus gros sur MMStar 3. Le modèle plus petit (8B, 1 GPU) est 1.5x plus rapide que le MoE (35B, 2 GPUs) 4. Le MoE 35B tire parti du tensor-parallel (TP=2) pour doubler son contexte max 5. Le choix du modèle depend du cas d’usage : specialise vs generaliste

Conclusion pratique : La quantization 4-bit est un no-brainer pour la production. La perte de qualite est marginale, les gains en memoire et vitesse sont majeurs.


Section 7 : AWQ vs GPTQ – Comparaison sur le même modèle

Pour comprendre les différences pratiques entre AWQ et GPTQ, comparons deux quantizations 4-bit du même modèle de base : Qwen3.5-35B-A3B.

  • AWQ : Version communautaire (cyankiwi), format compressed-tensors
  • GPTQ-Int4 : Version officielle Qwen, format GPTQ standard

Enjeu : Multi-Token Prediction (MTP)

Le MTP est une technique de decodage speculatif ou un “draft model” predit plusieurs tokens a l’avance. Si les predictions sont acceptees, on gagne en vitesse. Le taux d’acceptation depend de la fidelite des logits.

# Comparaison AWQ vs GPTQ sur le meme modele de base
# Qwen3.5-35B-A3B : deux quantizations 4-bit differentes

print("=" * 70)
print("AWQ vs GPTQ - Meme modele, methodes differentes")
print("=" * 70)

comparison = [
    ("Modele source", "Qwen3.5-35B-A3B", "Qwen3.5-35B-A3B"),
    ("Quantization", "AWQ (cyankiwi)", "GPTQ-Int4 (officiel Qwen)"),
    ("Format", "compressed-tensors", "GPTQ standard"),
    ("Kernels vLLM", "Marlin AWQ (fused dequant)", "Marlin GPTQ (WNA16)"),
    ("MTP (speculative)", "0% acceptance (!)", "Potentiellement fonctionnel"),
    ("Auto-detection vLLM", "Oui (pas de flag)", "Flag --quantization moe_wna16"),
    ("Createur", "Communaute", "Equipe Qwen officielle"),
]

print(f"\n{'Critere':<25} {'AWQ':>25} {'GPTQ':>30}")
print("-" * 82)
for critere, awq, gptq in comparison:
    print(f"{critere:<25} {awq:>25} {gptq:>30}")

print("\n--- Pourquoi MTP ne marche pas avec AWQ ? ---")
print("Multi-Token Prediction utilise un draft model pour predire les tokens suivants.")
print("La precision 4-bit AWQ introduit trop de bruit dans les logits,")
print("rendant les predictions du draft quasi-aleatoires (0% acceptance).")
print("Le GPTQ standard semble mieux preserver les distributions de logits.")
======================================================================
AWQ vs GPTQ - Meme modele, methodes differentes
======================================================================

Critere                                         AWQ                           GPTQ
----------------------------------------------------------------------------------
Modele source                       Qwen3.5-35B-A3B                Qwen3.5-35B-A3B
Quantization                         AWQ (cyankiwi)      GPTQ-Int4 (officiel Qwen)
Format                           compressed-tensors                  GPTQ standard
Kernels vLLM              Marlin AWQ (fused dequant)            Marlin GPTQ (WNA16)
MTP (speculative)                 0% acceptance (!)    Potentiellement fonctionnel
Auto-detection vLLM               Oui (pas de flag)  Flag --quantization moe_wna16
Createur                                 Communaute         Equipe Qwen officielle

--- Pourquoi MTP ne marche pas avec AWQ ? ---
Multi-Token Prediction utilise un draft model pour predire les tokens suivants.
La precision 4-bit AWQ introduit trop de bruit dans les logits,
rendant les predictions du draft quasi-aleatoires (0% acceptance).
Le GPTQ standard semble mieux preserver les distributions de logits.

Interpretation : AWQ vs GPTQ

Resume de la comparaison :

Aspect AWQ GPTQ Gagnant
Facilite de deploiement Auto-detecte Flag necessaire AWQ
MTP (decodage speculatif) 0% acceptance Fonctionnel GPTQ
Ecosysteme Communautaire Officiel Qwen GPTQ
Kernels vLLM Marlin AWQ Marlin GPTQ Equivalent

Points cles : 1. Les deux méthodes produisent des modèles de qualite comparable en inference standard 2. La différence apparait sur les techniques avancees comme le MTP 3. Pour un deploiement simple sans MTP, les deux conviennent 4. Pour exploiter le MTP (gain de vitesse potentiel de 30-50%), privilegier GPTQ

Recommandation pratique : Pour un nouveau deploiement, verifier d’abord si une version GPTQ officielle existe sur HuggingFace. Sinon, utiliser llmcompressor pour créer sa propre quantization GPTQ.


Conclusion

Trois lecons a retenir

  1. La quantization accelere l’inference : Le bottleneck des LLMs est la bande passante memoire, pas le compute. Reduire les poids de 16 bits a 4 bits divise par 4 les données a transferer, ce qui accelere la generation de tokens.

  2. Les modèles multimodaux necessitent des exclusions selectrices : Ne jamais quantifier l’encodeur vision (ViT/SigLIP) car les opérations softmax et LayerNorm sont extremement sensibles a la precision. Le cout en memoire est negligeable (~0.7 GB pour un modèle 8B VL).

  3. Le format de sortie determine le runtime : compressed-tensors -> Marlin (vLLM), GPTQ -> GPTQMarlin (vLLM), GGUF -> llama.cpp/Ollama. Choisissez le format selon votre runtime cible.

Tableau recapitulatif

Méthode Cas d’usage Difficulte Runtime
BitsAndBytes Prototypage, QLoRA Très simple Transformers
AutoAWQ Production, communautaire Simple vLLM, Transformers
GPTQ (llmcompressor) Production (recommande) Moyen vLLM (natif)
GGUF CPU, edge, Ollama Simple llama.cpp

Ressources


Pour aller plus loin

Ces pistes ne sont pas corrigees dans ce carnet : elles demandent un environnement GPU dedie. Le calcul d’empreinte memoire de l’ancienne liste est traite par l’exemple guide 1.

Piste 1 : Quantification avec llmcompressor (intermediaire)

Adaptez le script de la section 3 pour quantifier Qwen/Qwen3.5-0.8B : 1. Créez un environnement conda dedie 2. Installez les dependances 3. Executez la quantization 4. Mesurez la taille du repertoire avant/après 5. Verifiez que le modèle se charge sans erreur

Piste 2 : Deploiement vLLM (intermediaire)

Deployez votre modèle quantifie avec vLLM : 1. Lancez vllm serve sur le modèle quantifie 2. Testez avec un client OpenAI 3. Mesurez les tokens/seconde 4. Comparez avec le modèle BF16 original

Piste 3 : Perplexite avant/après (avance)

Comparez la perplexite du modèle original et quantifie sur WikiText-2 :

from datasets import load_dataset
ds = load_dataset("wikitext", "wikitext-2-raw-v1", split="test")
# Calculer la perplexite pour les deux versions

Piste 4 : Exclusion vision (avance)

Explorez les couches d’un modèle VL : 1. Chargez Qwen/Qwen3-VL-2B avec AutoModel.from_pretrained() 2. Listez toutes les couches avec named_modules() 3. Identifiez les couches visual et merger 4. Calculez le pourcentage de paramètres qu’elles representent

Retour au sommet