3.9e — Quantification SOTA : la même INT8, par l’écosystème torch.ao

← Retour à la série · Sœur : 3.9a — Quantification INT8 à la main

Le notebook 3.9a a écrit chaque mécanisme INT8 à la main : échelles, zero-points, calibration min/max, calibration KL, hooks dynamiques. Ce notebook est son pendant industriel, exigé par le bloc B.5 de l’issue #16060 : la même physique, mais cette fois exécutée par l’écosystème officiel — torch.ao.quantization, l’API SOTA de PyTorch pour la quantification.

Le contrat de comparaison est celui de toute la série : une seule variable expérimentale. Même ResNet-20, même CIFAR-10, même recette d’entraînement (SGD momentum, cosine), même graine 42 — seul change l’auteur de la quantification : la main de 3.9a ou la bibliothèque.

Note d’écosystème (mesurée sur ce run) : sous torch 2.13, torch.ao.quantization est marqué deprecated au profit de torchao (modes eager et PT2E). L’API reste pleinement fonctionnelle — c’est celle que ce bloc spécifie et celle sous laquelle 3.9a a été écrit (torch 2.8). La migration est discutée en Pour aller plus loin. Les bannières de dépréciation sont filtrées dans ce notebook pour la lisibilité ; aucun appel n’est contourné.

Le contrat de ce notebook

Quatre questions, chacune tranchée par une mesure et pas par un argument :

  1. quantize_dynamic couvre quoi, réellement, sur un CNN ? L’API dynamique de torch.ao cible Linear/LSTM/RNN — sur un ResNet-20 quasi entièrement convolutionnel, elle ne touche qu’une couche. Nous mesurons exactement ce qu’elle quantifie, ce qu’elle économise, et pourquoi.
  2. FX graph mode : prepare_fx → calibration → convert_fx en quatre appels. Que fait l’écosystème gratuitement que 3.9a écrivait à la main (fusion conv-bn-relu, insertion des stubs, packing des poids) ?
  3. MinMax ou Histogramme ? L’observateur histogramme de torch.ao est le cousin moteur de la calibration KL que 3.9a a reconstruite pas à pas. Nous mesurons leur écart sur le même réseau.
  4. Que coûte et que rapporte l’écosystème ? Taille du modèle, latence d’inférence INT8 réelle (moteur onednn), lignes de code — et ce que la main donne en plus : le contrôle (percentile, INT4, couche par couche).

Les valeurs de 3.9a citées en comparaison sont celles de ses outputs committés (run GPU, 40 epochs, torch 2.8) ; elles sont labellisées comme telles partout. Toutes les mesures de ce notebook viennent de ce run CPU.

import copy
import os
import time
import warnings

import numpy as np
import torch
import torch.nn as nn
import torch.nn.functional as F
from torchvision import datasets, transforms

# torch 2.13 : torch.ao.quantization est deprecie (migration torchao) mais fonctionnel.
# Les bannières sont filtrees pour la lisibilite - voir la note d'ecosysteme en tete.
warnings.filterwarnings("ignore", message=r".*torch\.ao\.quantization is deprecated.*")
warnings.filterwarnings("ignore", message=r".*quantize_per_tensor.*")

SEED = 42
torch.manual_seed(SEED)
np.random.seed(SEED)
DEV = "cuda" if torch.cuda.is_available() else "cpu"
EPOCHS = 6 if DEV == "cpu" else 40   # recette identique a 3.9a : complete sur GPU, reduite sur CPU
print(f"device={DEV}  torch={torch.__version__}  epochs={EPOCHS}")
device=cpu  torch=2.13.0+cpu  epochs=6

1. Le paysage : trois API, une même physique

La physique de la quantification — \(x_q = \mathrm{clip}(\mathrm{round}(x/s) + z, -128, 127)\), échelle par canal ou par tenseur — est celle que 3.9a §1 a construite à la main. L’écosystème propose trois façons de l’invoquer :

API Ce qu’elle quantifie Ce qu’elle exige du développeur
quantize_dynamic Poids des Linear/LSTM/RNN au vol (activations quantifiées à l’exécution) 1 appel — mais aucun Conv2d
Eager (prepare/convert) Tout, mais fusion et stubs à écrire soi-même beaucoup de plomberie manuelle
FX graph mode (prepare_fx/convert_fx) Tout : trace le graphe, fusionne conv-bn-relu, insère les stubs, packing des poids 4 appels + des données de calibration

C’est la voie FX, standard depuis torch 1.8 pour les CNN, que ce notebook mesure — avec quantize_dynamic en préalable, pour mesurer précisément ce que l’API dynamique couvre et ne couvre pas sur un réseau convolutionnel.

DATA = os.path.join(os.path.expanduser("~"), ".cache", "int8_39e")
norm = transforms.Normalize((0.4914, 0.4822, 0.4465), (0.2470, 0.2435, 0.2616))
tfm = transforms.Compose([transforms.ToTensor(), norm])
tfm_train = transforms.Compose([transforms.RandomCrop(32, padding=4),
                                transforms.RandomHorizontalFlip(),
                                transforms.ToTensor(), norm])
train_set = datasets.CIFAR10(DATA, train=True, download=True, transform=tfm_train)
test_set = datasets.CIFAR10(DATA, train=False, download=True, transform=tfm)
train_loader = torch.utils.data.DataLoader(train_set, batch_size=256, shuffle=True)
test_loader = torch.utils.data.DataLoader(test_set, batch_size=512, shuffle=False)
print(f"CIFAR-10 : {len(train_set)} train / {len(test_set)} test")
CIFAR-10 : 50000 train / 10000 test

2. Le terrain : le même ResNet-20 que 3.9a

Copie exacte de la cellule modèle de 3.9a : stem 3×3, trois étages de blocs résiduels (16→32→64, trois blocs chacun), pool global, classifieur 10. ~271 k poids — assez petit pour s’entraîner dans le notebook, assez réel pour que les mesures signifient quelque chose.

class BasicBlock(nn.Module):
    def __init__(self, cin, cout, stride=1):
        super().__init__()
        self.conv1 = nn.Conv2d(cin, cout, 3, stride=stride, padding=1, bias=False)
        self.bn1 = nn.BatchNorm2d(cout)
        self.conv2 = nn.Conv2d(cout, cout, 3, padding=1, bias=False)
        self.bn2 = nn.BatchNorm2d(cout)
        self.short = None
        if stride != 1 or cin != cout:
            self.short = nn.Sequential(
                nn.Conv2d(cin, cout, 1, stride=stride, bias=False), nn.BatchNorm2d(cout))

    def forward(self, x):
        y = F.relu(self.bn1(self.conv1(x)))
        y = self.bn2(self.conv2(y))
        y = y + (self.short(x) if self.short is not None else x)
        return F.relu(y)


class ResNet20(nn.Module):
    def __init__(self, nclass=10):
        super().__init__()
        self.stem = nn.Conv2d(3, 16, 3, padding=1, bias=False)
        self.bn0 = nn.BatchNorm2d(16)
        self.s1 = self._stage(16, 16, 3, 1)
        self.s2 = self._stage(16, 32, 3, 2)
        self.s3 = self._stage(32, 64, 3, 2)
        self.fc = nn.Linear(64, nclass)

    @staticmethod
    def _stage(cin, cout, n, stride):
        L = [BasicBlock(cin, cout, stride)] + [BasicBlock(cout, cout, 1) for _ in range(n - 1)]
        return nn.Sequential(*L)

    def forward(self, x):
        x = F.relu(self.bn0(self.stem(x)))
        x = self.s3(self.s2(self.s1(x)))
        return self.fc(F.adaptive_avg_pool2d(x, 1).flatten(1))


model = ResNet20().to(DEV)
n_par = sum(p.numel() for p in model.parameters())
n_lin = sum(1 for m in model.modules() if isinstance(m, (nn.Conv2d, nn.Linear)))
n_fc = sum(p.numel() for m in model.modules() if isinstance(m, nn.Linear) for p in m.parameters())
print(f"ResNet-20 : {n_par:,} parametres, {n_lin} couches conv/fc, dont fc = {n_fc} parametres ({n_fc/n_par:.1%} du total)")
ResNet-20 : 272,474 parametres, 22 couches conv/fc, dont fc = 650 parametres (0.2% du total)

La recette d’entraînement est celle de 3.9a, à l’identique : SGD momentum 0.9, lr 0.08, décroissance cosine, weight decay 5e-4, augmentation crop+flip. Sur CPU, les 6 epochs de la recette réduite — la même que le code de 3.9a applique quand aucun GPU n’est présent.

opt = torch.optim.SGD(model.parameters(), lr=0.08, momentum=0.9, weight_decay=5e-4)
sched = torch.optim.lr_scheduler.CosineAnnealingLR(opt, T_max=EPOCHS)
for ep in range(EPOCHS):
    model.train()
    t0 = time.perf_counter()
    for x, y in train_loader:
        loss = F.cross_entropy(model(x.to(DEV)), y.to(DEV))
        opt.zero_grad(); loss.backward(); opt.step()
    sched.step()
    if ep == 0 or (ep + 1) % 2 == 0:
        print(f"  ep {ep+1:2d}/{EPOCHS}  loss={loss.item():.4f}  ({time.perf_counter()-t0:.1f}s)")
  ep  1/6  loss=1.2572  (79.6s)
  ep  2/6  loss=0.9693  (83.4s)
  ep  4/6  loss=0.9332  (90.0s)
  ep  6/6  loss=0.4947  (88.1s)
def evaluate(m, loader):
    m.eval()
    good = tot = 0
    with torch.no_grad():
        for x, y in loader:
            good += (m(x.to(DEV)).argmax(1).cpu() == y).sum().item()
            tot += y.numel()
    return good / tot


acc_fp32 = evaluate(model, test_loader)
print(f"[FP32] exactitude test = {acc_fp32:.4f}")
[FP32] exactitude test = 0.7896

Lecture. Exactitude FP32 de référence de ce run : acc_fp32 ci-dessus (recette CPU 6 epochs). Le run de référence 3.9a (GPU, 40 epochs) affiche 0.9019 dans ses outputs committés — l’écart entre les deux vient de la longueur d’entraînement et du matériel, pas de la quantification ; chaque écart de ce notebook se mesure donc contre son propre témoin FP32, et les valeurs 3.9a ne servent que de repères labellisés.

3. quantize_dynamic : ce que « dynamique » veut dire côté écosystème

Un appel : quantize_dynamic(model, {nn.Linear}, dtype=torch.qint8). Les poids des couches listées sont quantifiés à l’avance, les activations le sont au vol à chaque appel. Sur un ResNet-20, une seule couche est éligible — le classifieur fc. Les 21 convolutions ne sont pas dans la liste des types supportés par l’API dynamique : elle vise les charges de travail denses en Linear (transformeurs, LLM), pas les CNN. Mesurons ce que cela donne quand même — la mesure est la réponse.

from torch.ao.quantization import quantize_dynamic

dyn = quantize_dynamic(copy.deepcopy(model), {nn.Linear}, dtype=torch.qint8)
dyn_lin = [m for m in dyn.modules() if "quantized.dynamic" in type(m).__module__]
print(f"couches quantifiees : {len(dyn_lin)} / {n_lin}  (type : {type(dyn_lin[0]).__module__}.{type(dyn_lin[0]).__name__} — le fc seul)")

n_fc_w = dyn_lin[0].weight().numel()          # qint8 : 1 octet par poids
economise = n_fc_w * 3                        # 4 octets -> 1 octet sur ces poids
p_fp32 = sum(t.numel() * t.element_size() for t in model.parameters())
print(f"fc : {n_fc_w} poids -> {economise/1024:.1f} Ko economises sur {p_fp32/1024:.0f} Ko de poids ({economise/p_fp32:.2%})")

acc_dyn = evaluate(dyn, test_loader)
print(f"[dynamic fc INT8] exactitude = {acc_dyn:.4f}  ({acc_dyn - acc_fp32:+.4f})")
couches quantifiees : 1 / 22  (type : torch.ao.nn.quantized.dynamic.modules.linear.Linear — le fc seul)
fc : 640 poids -> 1.9 Ko economises sur 1064 Ko de poids (0.18%)
[dynamic fc INT8] exactitude = 0.7898  (+0.0002)

Lecture. L’écosystème fait exactement ce qu’il promet — et pas plus : sur ce CNN, quantize_dynamic économise une fraction dérisoire d’octets (le fc pèse ~0,2 % du réseau) et ne change pas l’exactitude. À comparer au « dynamique » de 3.9a, qui posait des hooks pour quantifier les convolutions au vol (outputs 3.9a : dynamic w-channel 0.9018 sur son run GPU) : la main faisait plus que l’API publique sur ce terrain. Ce n’est pas un défaut de l’API — c’est un ciblage différent : pour un LLM à 99 % de Linear, cet appel unique quantifie l’essentiel du modèle. La leçon de mesure : le mot « dynamique » ne garantit pas la couverture ; la liste des types éligibles, si.

4. FX graph mode statique : la chaîne complète en quatre appels

La voie FX prend le réseau entier. prepare_fx trace le graphe symboliquement, fusionne conv-bn-relu (l’équivalent du plombier que 3.9a n’avait pas à écrire), insère stubs et observateurs ; on calibre en faisant passer quelques batchs ; convert_fx emballe les poids en tenseurs packés et branche les kernels INT8 du moteur onednn.

Deux calibrations, les mêmes que 3.9a opposait : MinMax (bornes min/max des activations — 3.9a §5) et Histogramme (2048 bins, cousin moteur de la KL de 3.9a §6). Poids en per-channel symétrique — le réglage que 3.9a mesurait comme le bon (~27 % d’erreur relative en moins).

# torch 2.13 : import explicite du sous-module (bug d'import paresseux du paquet,
# get_native_backend_config ne se construit pas sans lui - reparation d'environnement documentee)
import torch.ao.quantization.backend_config.utils  # noqa: F401
from torch.ao.quantization import (QConfig, QConfigMapping, MinMaxObserver,
                                   HistogramObserver, PerChannelMinMaxObserver)
from torch.ao.quantization.backend_config import get_native_backend_config
from torch.ao.quantization.quantize_fx import prepare_fx, convert_fx

BC = get_native_backend_config()          # moteur natif x86/onednn
CAL_BATCHES = 8                            # meme budget que 3.9a

def fx_static(observer_act):
    qmap = QConfigMapping().set_global(QConfig(
        activation=observer_act.with_args(qscheme=torch.per_tensor_affine),
        weight=PerChannelMinMaxObserver.with_args(dtype=torch.qint8,
                                                  qscheme=torch.per_channel_symmetric)))
    prep = prepare_fx(copy.deepcopy(model).eval(), qmap, (torch.randn(8, 3, 32, 32),),
                      backend_config=BC)
    with torch.no_grad():
        for i, (x, _) in enumerate(train_loader):
            if i >= CAL_BATCHES:
                break
            prep(x.to(DEV))
    return convert_fx(prep, backend_config=BC)

q_minmax = fx_static(MinMaxObserver)
acc_fx_minmax = evaluate(q_minmax, test_loader)
n_fused = sum(1 for n, m in q_minmax.named_modules()
              if type(m).__name__ in ("ConvReLU2d", "ConvBnReLU2d", "ConvBn2d"))
print(f"[FX statique MinMax] exactitude = {acc_fx_minmax:.4f}  ({acc_fx_minmax - acc_fp32:+.4f})   ({n_fused} modules conv fusionnes)")
[FX statique MinMax] exactitude = 0.7900  (+0.0004)   (10 modules conv fusionnes)
q_hist = fx_static(HistogramObserver)
acc_fx_hist = evaluate(q_hist, test_loader)
print(f"[FX statique Histogramme] exactitude = {acc_fx_hist:.4f}  ({acc_fx_hist - acc_fp32:+.4f})")
[FX statique Histogramme] exactitude = 0.7901  (+0.0005)

Lecture. Quatre appels, et l’écosystème a refait le chemin de 3.9a : fusion des trios conv-bn-relu, échelles per-channel sur les poids, observateurs calibrés sur 8 batchs, poids réemballés. Les repères de 3.9a (run GPU, 40 epochs) : static min/max w-channel 0.9017, static KL w-channel 0.9012 — des écarts de l’ordre du millième de point sur son témoin 0.9019. Sur notre témoin CPU 6 epochs, les écarts mesurés ci-dessus jouent dans le même registre : la chaîne FX livre la qualité de la main, sans écrire la main. L’histogramme ne domine pas le MinMax ici — 3.9a faisait le même constat (KL ≈ min/max, écart 0,0005 « dans le bruit ») : sur un CNN aux activations bien behaving, le choix d’observateur n’est pas le levier décisif.

5. Taille et vitesse : ce que INT8 achète réellement ici

Deux mesures, les mêmes que 3.9a : la taille des poids (FP32 contre le packing INT8 de la chaîne FX) et la latence d’inférence sur batch — médiane sur plusieurs passes, témoin FP32 et modèle FX MinMax. Le moteur de ce run est onednn (build CPU Windows) : la mesure dit ce que ce moteur donne, pas ce qu’un profil embarqué donnerait.

def taille_octets(m):
    t = 0
    for v in m.state_dict().values():
        if not isinstance(v, torch.Tensor):   # les modules FX exposent aussi des dtypes nus
            continue
        t += v.numel() * (1 if v.dtype in (torch.quint8, torch.qint8) else v.element_size())
    return t

t_fp32, t_fx = taille_octets(model), taille_octets(q_minmax)
print(f"taille state_dict : FP32 {t_fp32/1024:.0f} Ko   FX INT8 {t_fx/1024:.0f} Ko   ({t_fp32/t_fx:.1f}x plus petit)")

x_bench = next(iter(test_loader))[0][:64]
def latence(m, n=30, warm=3):
    m.eval()
    with torch.no_grad():
        for _ in range(warm):
            m(x_bench)
        ts = []
        for _ in range(n):
            t0 = time.perf_counter()
            m(x_bench)
            ts.append(time.perf_counter() - t0)
    return float(np.median(ts)) * 1000

l_fp32, l_int8 = latence(model), latence(q_minmax)
print(f"latence mediane batch 64 : FP32 {l_fp32:.1f} ms   FX INT8 {l_int8:.1f} ms   ({l_fp32/l_int8:.2f}x)")
taille state_dict : FP32 1071 Ko   FX INT8 267 Ko   (4.0x plus petit)
latence mediane batch 64 : FP32 21.4 ms   FX INT8 12.1 ms   (1.77x)

Lecture honnête. Les trois promesses tiennent sur ce run : taille 4,0× plus petite (1071 → 267 Ko), exactitude intacte (§4), et latence 1,8× plus rapide (21,4 → 12,1 ms, médiane batch 64) — les kernels INT8 du moteur onednn s’amortissent sur les 22 couches. Deux garde-fous : le facteur dépend du moteur et du profil (1,8× sur CPU desktop n’est pas le 10× d’un DSP embarqué ; un modèle encore plus petit verrait l’overhead par couche dominer le gain arithmétique) ; et 3.9a ne pouvait pas mesurer cela — sa quantification simulait les arrondis en flottants, sans kernels INT8. C’est la ligne de partage exacte entre les deux notebooks : la main explique la grille, l’écosystème l’exécute.

6. Récapitulatif : l’écosystème contre la main

configuration exactitude (ce run, témoin FP32 ci-dessus) repère 3.9a committé (GPU, 40 ep, témoin 0.9019)
FP32 témoin 0.9019
quantize_dynamic (fc seul) mesurée §3 — (3.9a visait les convs : 0.9018)
FX statique MinMax mesurée §4 0.9017
FX statique Histogramme mesurée §4 0.9012 (KL)
3.9a à la main (per-channel, static) — 0.9017 / 0.9012

Ce que l’écosystème donne : la fusion, les stubs, le packing, les kernels, la portabilité. Ce que la main donne en plus : le contrôle — percentile custom, INT4 et sa falaise (3.9a §7), sensibilité couche par couche, le pourquoi de chaque échelle. Les deux leçons de la série se complètent : on apprend le mécanisme à la main (3.9a), on le déploie avec l’écosystème (ce notebook).

Exercice 1 — Quantification aware de l’entraînement (QAT)

La quantification post-entraînement part d’un réseau entraîné FP32. La voie aware (prepare_qat_fx) insère les faux-quantifieurs pendant l’entraînement : le réseau apprend à vivre dans la grille INT8. Reprenez la chaîne FX de la section 4 avec prepare_qat_fx, ré-entraînez 1 epoch, convertissez, mesurez : l’écart au témoin FP32 se resserre-t-il par rapport au post-entraînement du §4 ?

Indice : from torch.ao.quantization.quantize_fx import prepare_qat_fx — la calibration est remplacee par l’entrainement lui-meme (les faux-quantifieurs collectent les echelles). Etape 1 : preparer le modele (meme QConfigMapping). Etape 2 : 1 epoch de SGD (lr faible, 1e-2). Etape 3 : convert_fx puis evaluate — comparer l’ecart a celui du MinMax du §4.

def qat_1_epoch(lr=1e-2):
    # TODO etudiant
    # Etape 1 : qat = prepare_qat_fx(copy.deepcopy(model), qmap, (exemple,), backend_config=BC)
    # Etape 2 : 1 epoch d'entrainement SGD sur qat (mode fake-quant actif par defaut)
    # Etape 3 : conv = convert_fx(qat.eval(), backend_config=BC) ; return evaluate(conv, test_loader)
    print("Exercice a completer")
    return None


acc_qat = qat_1_epoch()
print(f"[QAT 1 epoch] exactitude = {acc_qat}")
Exercice a completer
[QAT 1 epoch] exactitude = None

Exercice 2 — Un observateur percentile

MinMax suit les valeurs extrêmes ; l’histogramme optimise un critère d’entropie. La voie intermédiaire utilisée en production : couper au percentile 99,9 — sacrifier le millième de sorties les plus extrêmes pour resserrer l’échelle du corps. Écrivez un observateur qui hérite de MinMaxObserver mais ignore, au calcul des bornes, ce qui dépasse un percentile donné des valeurs observées.

Indice : MinMaxObserver.min_vals/max_vals sont remplies dans forward(). Une voie simple : collecter les activations dans un buffer pendant la calibration, puis redefinir min/max par np.percentile au moment du calcul. Etape 1 : sous-classer MinMaxObserver. Etape 2 : accumuler les valeurs (attention a la memoire : echantillonner). Etape 3 : brancher via QConfig(activation=MonObserver.with_args(p=99.9), …) dans fx_static().

class PercentileObserver(MinMaxObserver):
    # TODO etudiant
    # Reimplementer le calcul des bornes au percentile p plutot qu'aux extremes.
    def __init__(self, p=99.9, **kw):
        super().__init__(**kw)
        self.p = p

    def forward(self, x_orig):
        # TODO etudiant : accumuler, puis borner au percentile self.p
        print("Exercice a completer")
        return super().forward(x_orig)


print(f"PercentileObserver declare : {PercentileObserver(p=99.9).__class__.__name__}")
PercentileObserver declare : PercentileObserver

Exercice 3 — Épargner la première couche

Toutes les couches ne souffrent pas également de la quantification — et la littérature désigne les premières convolutions comme les plus sensibles (elles portent l’image brute, distribution large). Le QConfigMapping sait donner un traitement de faveur : set_module_name("stem", float_qconfig) laisse une couche en FP32. Testez : quantifiez tout sauf le stem, mesurez l’exactitude et la taille — le gain d’exactité vaut-il les 0,7 Ko perdus ?

Indice : un qconfig “nul” garde un module en FP32 — QConfig(activation=None, weight=None) (deja importe dans la cellule du §4). Etape 1 : QConfigMapping().set_global(<le qconfig quantifie du §4>).set_module_name(“stem”, QConfig(activation=None, weight=None)). Etape 2 : prepare_fx -> calibration (CAL_BATCHES) -> convert_fx. Etape 3 : comparer (exactitude, taille) au FX MinMax du §4 — l’ecart type attendu sur un petit CNN est faible, mesurez-le quand meme.

def fx_static_sans_stem():
    # TODO etudiant
    # Etape 1 : qmap avec set_module_name("stem", float_qconfig)
    # Etape 2 : prepare_fx -> calibration (CAL_BATCHES) -> convert_fx
    # Etape 3 : return evaluate(conv, test_loader)
    print("Exercice a completer")
    return None


acc_sans_stem = fx_static_sans_stem()
print(f"[FX MinMax sans stem] exactitude = {acc_sans_stem}")
Exercice a completer
[FX MinMax sans stem] exactitude = None

Résumé

  1. quantize_dynamic ne couvre que Linear : sur un CNN, une couche sur vingt-deux — l’API vise les transformeurs, la mesure le montre (économie dérisoire, exactitude intacte).
  2. La chaîne FX refait le chemin de 3.9a en quatre appels : fusion conv-bn-relu, échelles per-channel, observateurs, packing — même qualité d’exactitude que la main.
  3. MinMax ≈ Histogramme sur ce CNN — comme KL ≈ min/max chez 3.9a : le choix d’observateur n’est pas le levier décisif sur des activations sages.
  4. Taille 4,0×, latence 1,8× sous onednn (1071 → 267 Ko ; 21,4 → 12,1 ms) — un gain réel, mais dépendant du moteur et du profil : 3.9a, qui simulait les arrondis sans kernels INT8, ne pouvait pas le mesurer.
  5. Écosystème vs main : la fusion et les kernels offerts ; en échange, la main garde le contrôle (percentile, INT4, couche par couche). Les deux notebooks forment la paire complète : comprendre (3.9a), déployer (3.9e).

Pour aller plus loin

  • 3.9a — Compression par quantification INT8 à la main — la physique : échelles, zero-points, KL, la falaise INT4
  • 3.7 — Distillation maître-élève — l’autre famille de compression : le savoir plutôt que les nombres
  • FT-02 — QLoRA — la quantification 4 bits au service du fine-tuning de LLM
  • Migration torchao : sous torch 2.13, torch.ao.quantization est déprécié — les modes eager et PT2E migrent vers le paquet torchao (pytorch/ao). Ce notebook mesure l’API FX stable sous laquelle 3.9a a été écrit ; un déploiement neuf sur torch ≥ 2.13 choisira torchao.
Retour au sommet