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 :
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.
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) ?
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.
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 copyimport osimport timeimport warningsimport numpy as npimport torchimport torch.nn as nnimport torch.nn.functional as Ffrom 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 =42torch.manual_seed(SEED)np.random.seed(SEED)DEV ="cuda"if torch.cuda.is_available() else"cpu"EPOCHS =6if DEV =="cpu"else40# recette identique a 3.9a : complete sur GPU, reduite sur CPUprint(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.
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 =Noneif stride !=1or 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) ifself.short isnotNoneelse 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)@staticmethoddef _stage(cin, cout, n, stride): L = [BasicBlock(cin, cout, stride)] + [BasicBlock(cout, cout, 1) for _ inrange(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)))returnself.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(1for m in model.modules() ifisinstance(m, (nn.Conv2d, nn.Linear)))n_fc =sum(p.numel() for m in model.modules() ifisinstance(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 inrange(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 ==0or (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 =0with torch.no_grad():for x, y in loader: good += (m(x.to(DEV)).argmax(1).cpu() == y).sum().item() tot += y.numel()return good / totacc_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_dynamicdyn = quantize_dynamic(copy.deepcopy(model), {nn.Linear}, dtype=torch.qint8)dyn_lin = [m for m in dyn.modules() if"quantized.dynamic"intype(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 poidseconomise = n_fc_w *3# 4 octets -> 1 octet sur ces poidsp_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: F401from torch.ao.quantization import (QConfig, QConfigMapping, MinMaxObserver, HistogramObserver, PerChannelMinMaxObserver)from torch.ao.quantization.backend_config import get_native_backend_configfrom torch.ao.quantization.quantize_fx import prepare_fx, convert_fxBC = get_native_backend_config() # moteur natif x86/onednnCAL_BATCHES =8# meme budget que 3.9adef 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, _) inenumerate(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(1for n, m in q_minmax.named_modules()iftype(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)")
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 =0for v in m.state_dict().values():ifnotisinstance(v, torch.Tensor): # les modules FX exposent aussi des dtypes nuscontinue t += v.numel() * (1if v.dtype in (torch.quint8, torch.qint8) else v.element_size())return tt_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 _ inrange(warm): m(x_bench) ts = []for _ inrange(n): t0 = time.perf_counter() m(x_bench) ts.append(time.perf_counter() - t0)returnfloat(np.median(ts)) *1000l_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")returnNoneacc_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 = pdef forward(self, x_orig):# TODO etudiant : accumuler, puis borner au percentile self.pprint("Exercice a completer")returnsuper().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")returnNoneacc_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é
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).
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.
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.
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.
É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).
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.