3.9g — Comparatif compression : from scratch (Bloc A) vs écosystème (Bloc B)
Ce notebook est le capstone de la série compression (issue #16060, bloc 7). Les six notebooks précédents ont construit chaque méthode isolée :
3.9 / 3.9a : quantization FP16/BF16 puis INT8 dynamique à la main (numpy, scale + zéro-point)
3.9b / 3.9c : magnitude pruning à la main (masque, structure, loterie)
3.9d : knowledge distillation from scratch (T, hint FitNets)
3.9e : quantization torch.ao (quantize_dynamic, FX statique)
3.9f : pruning torch.nn.utils.prune
Ici, le même modèle (ResNet-20 sur CIFAR-10) passe sous chaque méthode, avec les mêmes instruments de mesure : accuracy, taille du stockage, latence d’inference, FLOPs, lignes de code. Le tableau final répond à la question pédagogique du pattern from scratch -> SOTA (comme 3.1 rétropropagation -> 3.2 optimiseurs) : pourquoi le from scratch (comprendre la représentation) et quand le SOTA (deployer : noyaux int8, graph capture, edge).
Plan : baseline FP32 entraîne une fois, puis INT8 dynamique (A manuel vs B torch.ao), pruning magnitude 50 % (A manuel vs B prune.global_unstructured), FP16, tableau, exercices.
import copyimport inspectimport osimport timeimport warningsimport zlibimport numpy as npimport torchimport torch.nn as nnimport torch.nn.functional as Ffrom torchvision import datasets, transforms# torch 2.8 : torch.ao.quantization emet une banniere de deprecation (migration torchao)# qui traine un chemin kernel temporaire dans la sortie - filtree comme en 3.9e.warnings.filterwarnings("ignore", message=r".*torch\.ao\.quantization is deprecated.*")SEED =42torch.manual_seed(SEED)np.random.seed(SEED)DEV ="cuda"if torch.cuda.is_available() else"cpu"EPOCHS =40if DEV =="cuda"else6# recette complete sur GPU, reduite sur CPU# Les mesures de latence sont faites sur CPU, comparables entre toutes les lignes.# La synthese mesurera qu'aucune methode de ce notebook n'y gagne : la voie dynamique# ne quantifie que les Linear (0,2 % des poids ici) - le gain INT8 des CNN vit dans# la voie statique FX de 3.9e.print(f"device_entrainement={DEV} torch={torch.__version__} epochs={EPOCHS}")
CIFAR-10, normalisation standard, augmentation légère à l’entraînement (crop + flip). Le modèle est le ResNet-20 de la série (BasicBlock, 3 stages). Reutiliser exactement la même recette que 3.9a rend les lignes du tableau comparables entre notebooks.
Le ResNet-20 de la série (identique à 3.9a/3.9b/3.9e/3.9f) : 3 stages de BasicBlocks, inchannels 16 -> 32 -> 64, une tête Linear. ~270 k paramètres, soit ~1,1 Mo en FP32.
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, num_classes=10):super().__init__()self.stem = nn.Sequential(nn.Conv2d(3, 16, 3, padding=1, bias=False), nn.BatchNorm2d(16), nn.ReLU())self.stage1 = nn.Sequential(*[BasicBlock(16, 16) for _ inrange(3)])self.stage2 = nn.Sequential(BasicBlock(16, 32, 2), *[BasicBlock(32, 32) for _ inrange(2)])self.stage3 = nn.Sequential(BasicBlock(32, 64, 2), *[BasicBlock(64, 64) for _ inrange(2)])self.head = nn.Linear(64, num_classes)def forward(self, x): x =self.stem(x) x =self.stage3(self.stage2(self.stage1(x))) x = F.adaptive_avg_pool2d(x, 1).flatten(1)returnself.head(x)model = ResNet20().to(DEV)n_params =sum(p.numel() for p in model.parameters())print(f"parametres={n_params:,} (~{n_params *4/1024:.0f} Ko en FP32)")
parametres=272,474 (~1064 Ko en FP32)
Entraînement du baseline FP32
Une seule phase d’entraînement : toutes les méthodes de compression comparent ensuite leur effet à partir de ce même point de depart (post-training : pas de re-entraînement, pas de QAT). SGD + momentum + cosine, comme la série.
def entrainer(model, epochs): opt = torch.optim.SGD(model.parameters(), lr=0.1, momentum=0.9, weight_decay=5e-4) sched = torch.optim.lr_scheduler.CosineAnnealingLR(opt, T_max=epochs)for ep inrange(epochs): model.train() tot, cor, loss_sum =0, 0, 0.0for x, y in train_loader: x, y = x.to(DEV), y.to(DEV) opt.zero_grad() out = model(x) loss = F.cross_entropy(out, y) loss.backward() opt.step() loss_sum += loss.item() *len(y) cor += (out.argmax(1) == y).sum().item() tot +=len(y) sched.step()print(f"epoch {ep +1}/{epochs} loss={loss_sum / tot:.4f} acc_train={100* cor / tot:.2f} %")def accuracy(model, loader, device): model.eval() cor, tot =0, 0with torch.inference_mode():for x, y in loader: out = model(x.to(device)).cpu() cor += (out.argmax(1) == y).sum().item() tot +=len(y)return100.0* cor / totentrainer(model, EPOCHS)acc_fp32 = accuracy(model, test_loader, DEV)print(f"\naccuracy test FP32 = {acc_fp32:.2f} %")
Le baseline FP32 fixe les trois références du tableau : 90,36 % d’accuracy (jumeau du 0,9019 de 3.9a, même recette), 1070,6 Ko de stockage (zlib : 1003,6 Ko — les poids entraînés compressent mal) et 25,68 ms de latence CPU médiane batch 64. Chaque méthode sera jugée sur son delta par rapport à ces trois axes.
Instruments de mesure communs
Quatre instruments, définis une fois et appliqués à toutes les lignes du tableau :
taille_octets : somme des buffers/poids, conscient des dtypes quantifiés (quint8 = 1 octet) — facon 3.9e ;
taille_compressee : les mêmes octets passés à zlib — c’est là que la sparsité non structurée paie (des zéros compressent) ;
latence_mediane : médiane sur 15 passes d’un batch 64, sur CPU, après échauffement ;
compter_flops : compteur de MACs par hooks (Conv2d, Linear) sur un batch de 1 — from scratch, sans dépendance externe.
def taille_octets(m): t =0for v in m.state_dict().values():ifnotisinstance(v, torch.Tensor):continue t += v.numel() * (1if v.dtype in (torch.quint8, torch.qint8, torch.uint8) else v.element_size())return tdef taille_compressee(m):import io as _io buf = _io.BytesIO() torch.save(m.state_dict(), buf)returnlen(zlib.compress(buf.getvalue(), 9))def latence_mediane(m, n=15): m = m.to("cpu").eval() xs =next(iter(test_loader))[0][:64]with torch.inference_mode():for _ inrange(3): m(xs) ts = []for _ inrange(n): t0 = time.perf_counter() m(xs) ts.append((time.perf_counter() - t0) *1000)returnfloat(np.median(ts))def compter_flops(m): flops = [0] hooks = []def h_conv(mod, inp, out): k2 = mod.kernel_size[0] * mod.kernel_size[1] # 9 pour un conv 3x3 per_out = out.numel() // out.shape[0] flops[0] += per_out * (mod.in_channels // mod.groups) * k2def h_lin(mod, inp, out): flops[0] += (out.numel() // out.shape[0]) * mod.in_featuresfor mod in m.modules():ifisinstance(mod, nn.Conv2d): hooks.append(mod.register_forward_hook(h_conv))elifisinstance(mod, nn.Linear) or mod.__class__.__name__in ("Linear", "LinearInt8Manuel"): hooks.append(mod.register_forward_hook(h_lin)) m.cpu().eval()with torch.inference_mode(): m(next(iter(test_loader))[0][:1])for h in hooks: h.remove()return flops[0]def loc_de(f):returnlen([l for l in inspect.getsource(f).splitlines() if l.strip() andnot l.strip().startswith("#")])model_cpu = copy.deepcopy(model).cpu()ROWS = [{"methode": "Baseline FP32", "bloc": "-", "accuracy": round(acc_fp32, 2),"taille_Ko": round(taille_octets(model_cpu) /1024, 1),"zlib_Ko": round(taille_compressee(model_cpu) /1024, 1),"latence_ms": round(latence_mediane(model_cpu), 2),"flops_M": round(compter_flops(model_cpu) /1e6, 1), "loc": 0,}]print("ligne baseline :", ROWS[0])
Les instruments posent d’emblée un fait structurel : compression de stockage et compression de calcul sont deux axes indépendants. La colonne FLOPs (40,8 M ici, jumeau du 40,81 M de 3.9b) ne bougera sur aucune ligne : aucune méthode post-training de ce notebook ne change la forme du calcul. La colonne qui bougera est zlib_Ko — c’est là que la sparsité non structurée paie (des zéros compressent). Et une colonne qui ne bougera quasiment pas est taille_Ko sur les lignes INT8 — la section suivante explique pourquoi, et c’est une leçon en soi.
INT8 dynamique — Bloc A : à la main
Reprise du geste de 3.9a, réduit à sa forme minimale pour le comparatif : chaque Linear stocke ses poids en uint8 (per-tensor : un scale + un offset pour tout le tenseur) et les dequantifie une fois au chargement. La représentation est celle de torch.ao ; notez le périmètre : sur ce ResNet-20, la tête Linear ne porte que 640 des 272 474 poids — 0,2 % du modèle. Le tableau mesurera ce que ce périmètre vaut réellement.
Mesure : accuracy 90,38 % (0,02), taille 1071,3 Ko — identique au baseline à l’arrondi près, latence 29,11 ms. Aucune surprise : la quantification dynamique ne touche que la tête Linear (640 poids, 0,2 % du modèle) sur un réseau dominé par les convolutions. La version manuelle fait exactement ce qu’elle annonce — sur un périmètre qui ne pèse rien sur ce modèle. Sa valeur est ailleurs : un code où le scale, l’offset, l’arrondi et le clamp sont lisibles, contre une API opaque.
INT8 dynamique — Bloc B : torch.ao.quantization.quantize_dynamic
Le même geste par l’API standard : poids de la tête en qint8, noyaux entiers pour les modules quantifiés. (Torch 2.8 émet une note de migration vers torchao — filtrée en tête de notebook comme en 3.9e ; l’API reste celle de la série.) Le même périmètre que le Bloc A — Linear seulement : le tableau dit ce que l’outillage change vraiment sur ce modèle.
Les deux lignes convergent : 90,38 % (A, 0,02) contre 90,36 % (B, 0,00), tailles 1071,3 vs 1068,1 Ko. La représentation manuelle et celle de l’API sont équivalentes — c’est la vérification croisée du geste de 3.9a. Le résultat contre-intuitif est la latence : 25,68 ms (FP32) → 29,11 (A) → 29,45 ms (B). Aucune des deux ne va plus vite, parce que les noyaux entiers n’existent que pour les modules quantifiés — ici 0,2 % du modèle. « INT8 = plus rapide » n’est pas automatique : sur un CNN, le gain vit dans la voie statique FX, qui fusionne et quantifie les convolutions — 3.9e l’a mesuré : 1071 → 267 Ko (4,0x) et 21,4 → 12,1 ms. Le from scratch démontre la représentation, le SOTA déploie — mais seulement là où son périmètre atteint les poids qui comptent.
Pruning magnitude 50 % — Bloc A : à la main
Reprise du geste de 3.9b : on conserve les 50 % de poids de plus grande magnitude, les autres sont mis à zéro dans le tenseur FP32 (pas de re-entraînement : mesure post-training immédiate, volontairement pessimiste).
Mesure : 81,22 % (-9,14 pts), taille brute 1070,6 Ko — inchangée, zlib 1003,6 → 597,2 Ko (x1,68). Deux lecons de 3.9b se rejouent chiffres en main : sans re-entraînement, couper 50 % des poids coûte cher ; et sparse n’est pas smaller — les zéros occupent leur place en FP32, seul le stockage compresse paye la sparsité. La loterie (3.9b) dit qu’une partie des -9,14 points se récupère en re-entrainant sous masque.
Pruning — Bloc B : torch.nn.utils.prune.global_unstructured
Le geste par l’API, avec une différence qui compte : la sélection est globale — un seul seuil L1 sur l’ensemble des poids du modèle, là où le Bloc A coupait 50 % par couche. A budget egal (50 % de zéros), l’allocation des coupes n’est pas la même : le tableau mesure ce que cette politique change. Masques gérés par le module, prune.remove pour materialiser les zéros dans les poids.
Mesure : 87,18 % (-3,18) pour la sélection globale contre 81,22 % (-9,14) pour la sélection par couche — à budget de sparsité identique, l’allocation globale conserve 5,96 points de plus. C’est la leçon d’allocation de 3.9f (globale contre uniforme) vue depuis l’axe A-vs-B : la différence ne vient pas de l’outillage mais de la politique de coupe, et l’API embarque la meilleure par défaut. Le reste du contrat tient : mêmes octets bruts (1070,6 Ko), même zlib (~594,9 Ko), mêmes FLOPs — et la latence ne bouge pour aucune des deux (30,26/29,00 ms) : zéros ou pas, le calcul reste dense FP32. Le SOTA n’apporte pas de noyau ici ; il apporte la normalisation du geste ET une politique d’allocation plus fine.
FP16 : le geste gratuit du GPU
La conversion model.half() divise les octets par deux sans aucune mesure de calibration. La mesure de latence ci-dessous se fait sur GPU (le half CPU est lent et non représentatif) — et sans témoin FP32-GPU dans ce notebook, elle se lit comme un ordre de grandeur, pas comme une comparaison contrôlée.
model_fp16 = copy.deepcopy(model).half().to(DEV)acc_fp16 = accuracy(model_fp16, [(x.half(), y) for x, y in test_loader], DEV)def latence_gpu(m, n=15): xs =next(iter(test_loader))[0][:64].half().to(DEV)with torch.inference_mode():for _ inrange(3): m(xs) torch.cuda.synchronize() ts = []for _ inrange(n): t0 = time.perf_counter() m(xs) torch.cuda.synchronize() ts.append((time.perf_counter() - t0) *1000)returnfloat(np.median(ts))taille_fp16 =sum(v.numel() * v.element_size() for v in model_fp16.state_dict().values() ifisinstance(v, torch.Tensor))lat16 = latence_gpu(model_fp16) if DEV =="cuda"elsefloat("nan")ROWS.append({"methode": "FP16 (GPU)", "bloc": "-", "accuracy": round(acc_fp16, 2),"taille_Ko": round(taille_fp16 /1024, 1), "zlib_Ko": "-","latence_ms": round(lat16, 2), "flops_M": "-", "loc": 1,})print(f"accuracy={acc_fp16:.2f} % taille={taille_fp16 /1024:.1f} Ko latence_gpu={lat16:.2f} ms")
accuracy=90.38 % taille=535.4 Ko latence_gpu=2.53 ms
Lecture du résultat
Mesure : 90,38 % (0,02 — aucune perte mesurable), 535,4 Ko (x2,0 exactement), 2,53 ms par batch 64 sur GPU. FP16 est le rappel que toutes les compressions ne se valent pas : c’est la seule ligne du tableau qui divise réellement les octets ici. La contrepartie est la même que pour INT8 : le gain de latence n’existe que sur le matériel qui a des noyaux half — et la colonne latence de cette ligne ne se compare pas à la colonne CPU des autres.
Le tableau recapitulatif (livrable du bloc 7)
Chaque ligne est mesurée par les mêmes instruments, sur le même baseline, dans ce notebook. La colonne loc compte les lignes non vides non commentaire de l’implémentation (le bloc A : __init__ + forward pour la classe manuelle, plus sa fonction d’application) ou de l’appel d’API (le bloc B) — convention déclarée : inspect.getsource sur les definitions de ce notebook.
Lecture du tableau — pourquoi from scratch, quand SOTA
Le tableau final, tel que mesure dans ce notebook (pruning A = sélection par couche, pruning B = sélection globale) :
Méthode
Bloc
Acc. %
Δ pts
Taille Ko
zlib Ko
Latence CPU ms
FLOPs M
LOC
Baseline FP32
-
90,36
0,00
1070,6
1003,6
25,68
40,8
-
INT8 dyn. manuel (A)
A
90,38
0,02
1071,3
1003,6
29,11
40,8
25
INT8 dyn. torch.ao (B)
B
90,36
0,00
1068,1
1002,1
29,45
40,8
1
Pruning manuel 50 % (A)
A
81,22
-9,14
1070,6
597,2
30,26
40,8
14
Pruning torch.nn.utils 50 % (B)
B
87,18
-3,18
1070,6
594,9
29,00
40,8
1
FP16 (GPU)
-
90,38
0,02
535,4
-
2,53 (GPU)
-
1
1. Stockage et calcul ne se compressent pas par les mêmes leviers. La colonne FLOPs est 40,8 M partout : aucune méthode post-training ici ne change la forme du calcul. INT8 dynamique est un non-événement sur ce modèle (0,2 % des poids atteints) ; le pruning ne paie qu’a l’archive (zlib 1003,6 → ~594,9 Ko) ; FP16 seul divise réellement les octets (x2,0) sans rien perdre.
2. La vraie différence A-vs-B n’est pas l’outillage, c’est la politique. INT8 : les deux implémentations convergent à ±0,02 (représentation équivalente). Pruning : 5,96 points séparent les lignes — mais ils viennent de l’allocation (par couche vs globale), pas de l’API. Le SOTA vaut par ses defaults plus malins et ses noyaux quand son périmètre atteint les poids qui comptent (FX statique, 3.9e : x4,0 en taille et latence CPU 21,4 → 12,1 ms).
3. Pourquoi le from scratch, quand le SOTA. Le LinearInt8Manuel et le pruneur manuel rendent scale, offset, arrondi, masque lisibles et vérifiables — ce qu’aucun appel d’API n’enseigne. Le SOTA devient le bon outil des que le périmètre est large (CNN complet : FX), la cible est la production (edge, export), ou que la politique d’allocation embarquée (globale) est celle qu’on veut. La distillation (3.9d) reste l’axe orthogonal : elle transfère une capacité, les trois familles se composent (exercice 3), elles ne se remplacent pas.
Exercices
Les trois exercices prolongent le comparatif. Les cellules sont des squelettes : elles s’exécutent sans erreur telles quelles, à vous d’écrire le corps.
Exercice 1 — INT8 per-channel
Le quantifier manuel est per-tensor (un scale pour tout le tenseur). Etendez-le en per-channel (un scale par ligne de sortie), mesurez l’accuracy et la taille, et comparez aux deux lignes INT8 du tableau : quel ecart d’accuracy per-tensor vs per-channel ?
Exercice 2 — le taux de coupure du pruning
A 50 % post-training, l’accuracy chute. Trouvez par dichotomie le taux de pruning au-delà duquel la chute dépasse 2 points (structure du squelette : boucle de dichotomie sur amount, appel au pruner_magnitude_manuel, critere sur accuracy).
Exercice 3 — le pipeline combiné
Prunez à 30 %, quantifiez ensuite le modèle pruné en INT8 dynamique (torch.ao), et mesurez la ligne combinée (accuracy, taille, zlib, latence). Les gains de stockage se composent-ils ?
# Exercice 1 — INT8 per-channel# Indice : dans LinearInt8Manuel, remplacez le scalaire `scale` par un vecteur# (out_features,) calcule sur chaque ligne de w, et adaptez la dequantification.# Etape 1 : definir LinearInt8PerChannel (copie de LinearInt8Manuel, scale par ligne)# Etape 2 : quantifier le modele, mesurer accuracy + taille# Etape 3 : comparer aux lignes INT8 du tableauresultat_exo1 =None# TODO etudiantprint("Exercice a completer")
Exercice a completer
# Exercice 2 — taux de coupure par dichotomie# Indice : borne basse 0.0, borne haute 1.0, une dizaine d'iterations suffisent.# Etape 1 : boucle : amount = (lo + hi) / 2 ; pruner_magnitude_manuel(model, amount)# Etape 2 : si la chute depasse 2 points, hi = amount, sinon lo = amount# Etape 3 : retourner le seuilresultat_exo2 =None# TODO etudiantprint("Exercice a completer")
Exercice a completer
# Exercice 3 — pipeline pruning 30 % puis INT8 dynamique# Indice : composez pruner_magnitude_manuel(model, 0.3) puis quantize_dynamic.# Etape 1 : construire le modele combine# Etape 2 : mesurer accuracy, taille_octets, taille_compressee, latence_mediane# Etape 3 : ajouter la ligne au tableau et commenter la composition des gainsresultat_exo3 =None# TODO etudiantprint("Exercice a completer")
Exercice a completer
Conclusion
Mesure après mesure : INT8 dynamique sur un CNN à dominance convolutions ne touche que 0,2 % des poids (±0,02 pt ici) — le vrai gain INT8 des CNN vit dans la voie statique FX (3.9e : x4,0) ; le pruning non structuré divise la taille compressée par ~1,68 (1003,6 → ~594,9 Ko) sans rien changer au brut ni à la latence ; à budget de coupes égales, l’allocation globale conserve 5,96 points de plus que l’allocation par couche (87,18 contre 81,22) ; FP16 divise par deux sans perte mesurable (0,02).
Le pattern from scratch → SOTA garde sa valeur des deux côtés : la version manuelle rend la représentation lisible (scale, offset, masque — le genre de chose qu’on re-implémente mal sous pression si on ne l’a jamais écrite) ; l’API apporte la normalisation, une allocation plus fine, et les noyaux quand son périmètre les atteint.
La distillation (3.9d) reste l’axe orthogonal : elle ne comprime pas le stockage, elle transfère une capacité — les trois familles se combinent (exercice 3), elles ne se remplacent pas.
Références croisées : 3.9 (FP from scratch), 3.9a (INT8 from scratch), 3.9b/3.9c (pruning from scratch), 3.9d (distillation), 3.9e (quantization torch.ao — le x4,0 FX statique), 3.9f (pruning torch.nn.utils — allocation globale contre random). Hors scope, assume par l’issue : GPTQ/AWQ sur LLM (FT-02 QLoRA), NAS, multi-teacher.