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 :

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 copy
import inspect
import os
import time
import warnings
import zlib

import numpy as np
import torch
import torch.nn as nn
import torch.nn.functional as F
from 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 = 42
torch.manual_seed(SEED)
np.random.seed(SEED)
DEV = "cuda" if torch.cuda.is_available() else "cpu"
EPOCHS = 40 if DEV == "cuda" else 6   # 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}")
device_entrainement=cuda  torch=2.8.0+cu126  epochs=40

Données et modèle — même recette que 3.9a

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.

DATA = os.path.join(os.path.expanduser("~"), ".cache", "int8_39g")
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=256)
print(f"train={len(train_set)}  test={len(test_set)}")
train=50000  test=10000

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 = 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, 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 _ in range(3)])
        self.stage2 = nn.Sequential(BasicBlock(16, 32, 2), *[BasicBlock(32, 32) for _ in range(2)])
        self.stage3 = nn.Sequential(BasicBlock(32, 64, 2), *[BasicBlock(64, 64) for _ in range(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)
        return self.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 in range(epochs):
        model.train()
        tot, cor, loss_sum = 0, 0, 0.0
        for 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, 0
    with torch.inference_mode():
        for x, y in loader:
            out = model(x.to(device)).cpu()
            cor += (out.argmax(1) == y).sum().item()
            tot += len(y)
    return 100.0 * cor / tot


entrainer(model, EPOCHS)
acc_fp32 = accuracy(model, test_loader, DEV)
print(f"\naccuracy test FP32 = {acc_fp32:.2f} %")
epoch 1/40  loss=1.7116  acc_train=35.71 %
epoch 2/40  loss=1.2715  acc_train=53.58 %
epoch 3/40  loss=1.0302  acc_train=63.15 %
epoch 4/40  loss=0.8472  acc_train=70.14 %
epoch 5/40  loss=0.7285  acc_train=74.54 %
epoch 6/40  loss=0.6499  acc_train=77.39 %
epoch 7/40  loss=0.5994  acc_train=79.20 %
epoch 8/40  loss=0.5589  acc_train=80.89 %
epoch 9/40  loss=0.5327  acc_train=81.54 %
epoch 10/40  loss=0.5061  acc_train=82.57 %
epoch 11/40  loss=0.4901  acc_train=83.29 %
epoch 12/40  loss=0.4736  acc_train=83.52 %
epoch 13/40  loss=0.4543  acc_train=84.22 %
epoch 14/40  loss=0.4310  acc_train=85.19 %
epoch 15/40  loss=0.4181  acc_train=85.62 %
epoch 16/40  loss=0.4059  acc_train=85.81 %
epoch 17/40  loss=0.3903  acc_train=86.46 %
epoch 18/40  loss=0.3794  acc_train=87.10 %
epoch 19/40  loss=0.3596  acc_train=87.54 %
epoch 20/40  loss=0.3490  acc_train=87.96 %
epoch 21/40  loss=0.3368  acc_train=88.29 %
epoch 22/40  loss=0.3172  acc_train=89.00 %
epoch 23/40  loss=0.3064  acc_train=89.37 %
epoch 24/40  loss=0.2927  acc_train=89.85 %
epoch 25/40  loss=0.2766  acc_train=90.33 %
epoch 26/40  loss=0.2605  acc_train=91.04 %
epoch 27/40  loss=0.2432  acc_train=91.41 %
epoch 28/40  loss=0.2272  acc_train=92.21 %
epoch 29/40  loss=0.2138  acc_train=92.59 %
epoch 30/40  loss=0.1976  acc_train=93.31 %
epoch 31/40  loss=0.1804  acc_train=93.80 %
epoch 32/40  loss=0.1670  acc_train=94.30 %
epoch 33/40  loss=0.1546  acc_train=94.64 %
epoch 34/40  loss=0.1441  acc_train=95.19 %
epoch 35/40  loss=0.1307  acc_train=95.69 %
epoch 36/40  loss=0.1218  acc_train=95.92 %
epoch 37/40  loss=0.1165  acc_train=96.16 %
epoch 38/40  loss=0.1107  acc_train=96.40 %
epoch 39/40  loss=0.1086  acc_train=96.50 %
epoch 40/40  loss=0.1048  acc_train=96.59 %

accuracy test FP32 = 90.36 %

Lecture du résultat

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 = 0
    for v in m.state_dict().values():
        if not isinstance(v, torch.Tensor):
            continue
        t += v.numel() * (1 if v.dtype in (torch.quint8, torch.qint8, torch.uint8) else v.element_size())
    return t


def taille_compressee(m):
    import io as _io
    buf = _io.BytesIO()
    torch.save(m.state_dict(), buf)
    return len(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 _ in range(3):
            m(xs)
        ts = []
        for _ in range(n):
            t0 = time.perf_counter()
            m(xs)
            ts.append((time.perf_counter() - t0) * 1000)
    return float(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) * k2

    def h_lin(mod, inp, out):
        flops[0] += (out.numel() // out.shape[0]) * mod.in_features

    for mod in m.modules():
        if isinstance(mod, nn.Conv2d):
            hooks.append(mod.register_forward_hook(h_conv))
        elif isinstance(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):
    return len([l for l in inspect.getsource(f).splitlines() if l.strip() and not 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])
ligne baseline : {'methode': 'Baseline FP32', 'bloc': '-', 'accuracy': 90.36, 'taille_Ko': 1070.6, 'zlib_Ko': 1003.6, 'latence_ms': 25.68, 'flops_M': 40.8, 'loc': 0}

Lecture du résultat

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.

class LinearInt8Manuel(nn.Module):
    """Linear dont les poids sont stockes en uint8 + scale/offset, dequantifies une fois."""

    def __init__(self, lin):
        super().__init__()
        w = lin.weight.detach().float()
        wmin, wmax = w.min(), w.max()
        scale = (wmax - wmin) / 255.0
        wq = torch.round((w - wmin) / scale).clamp_(0, 255).to(torch.uint8)
        self.register_buffer("w_q", wq)
        self.register_buffer("scale", scale.reshape(()))
        self.register_buffer("w_min", wmin.reshape(()))
        self.in_features, self.out_features = lin.in_features, lin.out_features
        w_deq = wq.float() * scale + wmin
        self.register_buffer("w_deq", w_deq)   # cache dequantifie : le calcul reste FP32
        self.bias = lin.bias

    def forward(self, x):
        return F.linear(x, self.w_deq, self.bias)


def quantifier_int8_manuel(m):
    q = copy.deepcopy(m).cpu()

    def remplacer(mod):
        for name, sub in list(mod.named_children()):
            if isinstance(sub, nn.Linear):
                setattr(mod, name, LinearInt8Manuel(sub))
            else:
                remplacer(sub)

    remplacer(q)
    return q


model_a_int8 = quantifier_int8_manuel(model)
acc_a_int8 = accuracy(model_a_int8, test_loader, "cpu")
taille_a = taille_octets(model_a_int8)
loc_a = (loc_de(LinearInt8Manuel.__init__) + loc_de(LinearInt8Manuel.forward)
         + loc_de(quantifier_int8_manuel))
ROWS.append({
    "methode": "INT8 dyn. manuel (A)", "bloc": "A", "accuracy": round(acc_a_int8, 2),
    "taille_Ko": round(taille_a / 1024, 1), "zlib_Ko": round(taille_compressee(model_a_int8) / 1024, 1),
    "latence_ms": round(latence_mediane(model_a_int8), 2),
    "flops_M": round(compter_flops(model_a_int8) / 1e6, 1), "loc": loc_a,
})
print(f"accuracy={acc_a_int8:.2f} %  taille={taille_a / 1024:.1f} Ko  LOC={loc_a}")
accuracy=90.38 %  taille=1071.3 Ko  LOC=25

Lecture du résultat

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.

from torch.ao.quantization import quantize_dynamic

model_b_int8 = quantize_dynamic(copy.deepcopy(model).cpu(), {nn.Linear}, dtype=torch.qint8)
acc_b_int8 = accuracy(model_b_int8, test_loader, "cpu")
taille_b = taille_octets(model_b_int8)
ROWS.append({
    "methode": "INT8 dyn. torch.ao (B)", "bloc": "B", "accuracy": round(acc_b_int8, 2),
    "taille_Ko": round(taille_b / 1024, 1), "zlib_Ko": round(taille_compressee(model_b_int8) / 1024, 1),
    "latence_ms": round(latence_mediane(model_b_int8), 2),
    "flops_M": round(compter_flops(model_b_int8) / 1e6, 1), "loc": 1,
})
print(f"accuracy={acc_b_int8:.2f} %  taille={taille_b / 1024:.1f} Ko")
accuracy=90.36 %  taille=1068.1 Ko

Lecture du résultat — A contre B sur l’INT8

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).

@torch.no_grad()
def pruner_magnitude_manuel(m, amount=0.5):
    q = copy.deepcopy(m).cpu()
    n_zero, n_tot = 0, 0
    for mod in q.modules():
        if isinstance(mod, (nn.Conv2d, nn.Linear)):
            w = mod.weight
            k = max(1, int(w.numel() * amount))
            seuil = w.abs().flatten().kthvalue(k).values
            masque = (w.abs() > seuil).float()
            mod.weight.mul_(masque)
            n_zero += int((masque == 0).sum())
            n_tot += w.numel()
    return q, 100.0 * n_zero / n_tot


model_a_prune, sparsite = pruner_magnitude_manuel(model)
acc_a_prune = accuracy(model_a_prune, test_loader, "cpu")
loc_ap = loc_de(pruner_magnitude_manuel)
ROWS.append({
    "methode": "Pruning manuel 50 % (A)", "bloc": "A", "accuracy": round(acc_a_prune, 2),
    "taille_Ko": round(taille_octets(model_a_prune) / 1024, 1),
    "zlib_Ko": round(taille_compressee(model_a_prune) / 1024, 1),
    "latence_ms": round(latence_mediane(model_a_prune), 2),
    "flops_M": round(compter_flops(model_a_prune) / 1e6, 1), "loc": loc_ap,
})
print(f"sparsite={sparsite:.1f} %  accuracy={acc_a_prune:.2f} %  LOC={loc_ap}")
sparsite=50.0 %  accuracy=81.22 %  LOC=14

Lecture du résultat

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.

from torch.nn.utils import prune

def pruner_sota(m, amount=0.5):
    q = copy.deepcopy(m).cpu()
    cibles = [(mod, "weight") for mod in q.modules() if isinstance(mod, (nn.Conv2d, nn.Linear))]
    prune.global_unstructured(cibles, pruning_method=prune.L1Unstructured, amount=amount)
    for mod, _ in cibles:
        prune.remove(mod, "weight")
    return q


model_b_prune = pruner_sota(model)
acc_b_prune = accuracy(model_b_prune, test_loader, "cpu")
ROWS.append({
    "methode": "Pruning torch.nn.utils 50 % (B)", "bloc": "B", "accuracy": round(acc_b_prune, 2),
    "taille_Ko": round(taille_octets(model_b_prune) / 1024, 1),
    "zlib_Ko": round(taille_compressee(model_b_prune) / 1024, 1),
    "latence_ms": round(latence_mediane(model_b_prune), 2),
    "flops_M": round(compter_flops(model_b_prune) / 1e6, 1), "loc": 1,
})
print(f"accuracy={acc_b_prune:.2f} %")
accuracy=87.18 %

Lecture du résultat — A contre B sur le pruning

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 _ in range(3):
            m(xs)
        torch.cuda.synchronize()
        ts = []
        for _ in range(n):
            t0 = time.perf_counter()
            m(xs)
            torch.cuda.synchronize()
            ts.append((time.perf_counter() - t0) * 1000)
    return float(np.median(ts))


taille_fp16 = sum(v.numel() * v.element_size() for v in model_fp16.state_dict().values() if isinstance(v, torch.Tensor))
lat16 = latence_gpu(model_fp16) if DEV == "cuda" else float("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.

import pandas as pd

df = pd.DataFrame(ROWS)
acc_base = df.loc[df["methode"] == "Baseline FP32", "accuracy"].iloc[0]
df["delta_acc_pts"] = (df["accuracy"] - acc_base).round(2)
df["compression_x"] = (df.loc[0, "taille_Ko"] / df["taille_Ko"].astype(float)).round(2)
cols = ["methode", "bloc", "accuracy", "delta_acc_pts", "taille_Ko", "compression_x",
        "zlib_Ko", "latence_ms", "flops_M", "loc"]
print(df[cols].to_string(index=False))
                        methode bloc  accuracy  delta_acc_pts  taille_Ko  compression_x zlib_Ko  latence_ms flops_M  loc
                  Baseline FP32    -     90.36           0.00     1070.6            1.0  1003.6       25.68    40.8    0
           INT8 dyn. manuel (A)    A     90.38           0.02     1071.3            1.0  1003.6       29.11    40.8   25
         INT8 dyn. torch.ao (B)    B     90.36           0.00     1068.1            1.0  1002.1       29.45    40.8    1
        Pruning manuel 50 % (A)    A     81.22          -9.14     1070.6            1.0   597.2       30.26    40.8   14
Pruning torch.nn.utils 50 % (B)    B     87.18          -3.18     1070.6            1.0   594.9       29.00    40.8    1
                     FP16 (GPU)    -     90.38           0.02      535.4            2.0       -        2.53       -    1

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 tableau
resultat_exo1 = None  # TODO etudiant
print("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 seuil
resultat_exo2 = None  # TODO etudiant
print("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 gains
resultat_exo3 = None  # TODO etudiant
print("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.

Retour au sommet