From 3653d10b17911a8efacc1a502190afd1ac56d2df Mon Sep 17 00:00:00 2001 From: jsboige self-bot Date: Tue, 15 Sep 2026 08:21:52 +0200 Subject: [PATCH] =?UTF-8?q?feat(ml,#16060):=203.9e=20quantization=20SOTA?= =?UTF-8?q?=20=E2=80=94=20torch.ao=20dynamic=20+=20FX=20statique=20vs=203.?= =?UTF-8?q?9a=20(bloc=20B.5)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 5 --- .../3.9e-Compression-Quantization-SOTA.ipynb | 1041 +++++++++++++++++ 1 file changed, 1041 insertions(+) create mode 100644 MyIA.AI.Notebooks/ML/DataScienceWithAgents/03-DeepLearning/3.9e-Compression-Quantization-SOTA.ipynb diff --git a/MyIA.AI.Notebooks/ML/DataScienceWithAgents/03-DeepLearning/3.9e-Compression-Quantization-SOTA.ipynb b/MyIA.AI.Notebooks/ML/DataScienceWithAgents/03-DeepLearning/3.9e-Compression-Quantization-SOTA.ipynb new file mode 100644 index 0000000000..4ca4469228 --- /dev/null +++ b/MyIA.AI.Notebooks/ML/DataScienceWithAgents/03-DeepLearning/3.9e-Compression-Quantization-SOTA.ipynb @@ -0,0 +1,1041 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "id": "c4201401", + "metadata": { + "papermill": { + "duration": 0.002485, + "end_time": "2026-09-15T06:10:22.312217", + "exception": false, + "start_time": "2026-09-15T06:10:22.309732", + "status": "completed" + }, + "tags": [] + }, + "source": [ + "# 3.9e — Quantification SOTA : la même INT8, par l'écosystème torch.ao\n", + "\n", + "[← Retour à la série](README.md) · Sœur : [3.9a — Quantification INT8 à la main](3.9a-Compression-Quantization-INT8.ipynb)\n", + "\n", + "Le notebook [3.9a](3.9a-Compression-Quantization-INT8.ipynb) 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.\n", + "\n", + "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.\n", + "\n", + "> **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](#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é.\n" + ] + }, + { + "cell_type": "markdown", + "id": "4f6959ee", + "metadata": { + "papermill": { + "duration": 0.003092, + "end_time": "2026-09-15T06:10:22.318307", + "exception": false, + "start_time": "2026-09-15T06:10:22.315215", + "status": "completed" + }, + "tags": [] + }, + "source": [ + "## Le contrat de ce notebook\n", + "\n", + "Quatre questions, chacune tranchée par une mesure et pas par un argument :\n", + "\n", + "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.\n", + "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) ?\n", + "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.\n", + "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).\n", + "\n", + "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." + ] + }, + { + "cell_type": "code", + "execution_count": 1, + "id": "4db4905c", + "metadata": { + "execution": { + "iopub.execute_input": "2026-09-15T06:10:22.325928Z", + "iopub.status.busy": "2026-09-15T06:10:22.324931Z", + "iopub.status.idle": "2026-09-15T06:10:25.321978Z", + "shell.execute_reply": "2026-09-15T06:10:25.321978Z" + }, + "papermill": { + "duration": 3.001809, + "end_time": "2026-09-15T06:10:25.323095", + "exception": false, + "start_time": "2026-09-15T06:10:22.321286", + "status": "completed" + }, + "tags": [] + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "device=cpu torch=2.13.0+cpu epochs=6\n" + ] + } + ], + "source": [ + "import copy\n", + "import os\n", + "import time\n", + "import warnings\n", + "\n", + "import numpy as np\n", + "import torch\n", + "import torch.nn as nn\n", + "import torch.nn.functional as F\n", + "from torchvision import datasets, transforms\n", + "\n", + "# torch 2.13 : torch.ao.quantization est deprecie (migration torchao) mais fonctionnel.\n", + "# Les bannières sont filtrees pour la lisibilite - voir la note d'ecosysteme en tete.\n", + "warnings.filterwarnings(\"ignore\", message=r\".*torch\\.ao\\.quantization is deprecated.*\")\n", + "warnings.filterwarnings(\"ignore\", message=r\".*quantize_per_tensor.*\")\n", + "\n", + "SEED = 42\n", + "torch.manual_seed(SEED)\n", + "np.random.seed(SEED)\n", + "DEV = \"cuda\" if torch.cuda.is_available() else \"cpu\"\n", + "EPOCHS = 6 if DEV == \"cpu\" else 40 # recette identique a 3.9a : complete sur GPU, reduite sur CPU\n", + "print(f\"device={DEV} torch={torch.__version__} epochs={EPOCHS}\")" + ] + }, + { + "cell_type": "markdown", + "id": "a75b3ffa", + "metadata": { + "papermill": { + "duration": 0.003812, + "end_time": "2026-09-15T06:10:25.328514", + "exception": false, + "start_time": "2026-09-15T06:10:25.324702", + "status": "completed" + }, + "tags": [] + }, + "source": [ + "## 1. Le paysage : trois API, une même physique\n", + "\n", + "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](3.9a-Compression-Quantization-INT8.ipynb) a construite à la main. L'écosystème propose trois façons de l'invoquer :\n", + "\n", + "| API | Ce qu'elle quantifie | Ce qu'elle exige du développeur |\n", + "|---|---|---|\n", + "| `quantize_dynamic` | Poids des `Linear`/`LSTM`/`RNN` au vol (activations quantifiées à l'exécution) | 1 appel — mais **aucun `Conv2d`** |\n", + "| Eager (`prepare`/`convert`) | Tout, mais fusion et stubs **à écrire soi-même** | beaucoup de plomberie manuelle |\n", + "| **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 |\n", + "\n", + "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." + ] + }, + { + "cell_type": "code", + "execution_count": 2, + "id": "38c34fc5", + "metadata": { + "execution": { + "iopub.execute_input": "2026-09-15T06:10:25.334266Z", + "iopub.status.busy": "2026-09-15T06:10:25.334266Z", + "iopub.status.idle": "2026-09-15T06:10:26.830784Z", + "shell.execute_reply": "2026-09-15T06:10:26.830784Z" + }, + "papermill": { + "duration": 1.501199, + "end_time": "2026-09-15T06:10:26.831799", + "exception": false, + "start_time": "2026-09-15T06:10:25.330600", + "status": "completed" + }, + "tags": [] + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "CIFAR-10 : 50000 train / 10000 test\n" + ] + } + ], + "source": [ + "DATA = os.path.join(os.path.expanduser(\"~\"), \".cache\", \"int8_39e\")\n", + "norm = transforms.Normalize((0.4914, 0.4822, 0.4465), (0.2470, 0.2435, 0.2616))\n", + "tfm = transforms.Compose([transforms.ToTensor(), norm])\n", + "tfm_train = transforms.Compose([transforms.RandomCrop(32, padding=4),\n", + " transforms.RandomHorizontalFlip(),\n", + " transforms.ToTensor(), norm])\n", + "train_set = datasets.CIFAR10(DATA, train=True, download=True, transform=tfm_train)\n", + "test_set = datasets.CIFAR10(DATA, train=False, download=True, transform=tfm)\n", + "train_loader = torch.utils.data.DataLoader(train_set, batch_size=256, shuffle=True)\n", + "test_loader = torch.utils.data.DataLoader(test_set, batch_size=512, shuffle=False)\n", + "print(f\"CIFAR-10 : {len(train_set)} train / {len(test_set)} test\")" + ] + }, + { + "cell_type": "markdown", + "id": "b9f35720", + "metadata": { + "papermill": { + "duration": 0.00292, + "end_time": "2026-09-15T06:10:26.838292", + "exception": false, + "start_time": "2026-09-15T06:10:26.835372", + "status": "completed" + }, + "tags": [] + }, + "source": [ + "## 2. Le terrain : le même ResNet-20 que 3.9a\n", + "\n", + "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." + ] + }, + { + "cell_type": "code", + "execution_count": 3, + "id": "f536a36b", + "metadata": { + "execution": { + "iopub.execute_input": "2026-09-15T06:10:26.844229Z", + "iopub.status.busy": "2026-09-15T06:10:26.844229Z", + "iopub.status.idle": "2026-09-15T06:10:26.857632Z", + "shell.execute_reply": "2026-09-15T06:10:26.857632Z" + }, + "papermill": { + "duration": 0.018836, + "end_time": "2026-09-15T06:10:26.858667", + "exception": false, + "start_time": "2026-09-15T06:10:26.839831", + "status": "completed" + }, + "tags": [] + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "ResNet-20 : 272,474 parametres, 22 couches conv/fc, dont fc = 650 parametres (0.2% du total)\n" + ] + } + ], + "source": [ + "class BasicBlock(nn.Module):\n", + " def __init__(self, cin, cout, stride=1):\n", + " super().__init__()\n", + " self.conv1 = nn.Conv2d(cin, cout, 3, stride=stride, padding=1, bias=False)\n", + " self.bn1 = nn.BatchNorm2d(cout)\n", + " self.conv2 = nn.Conv2d(cout, cout, 3, padding=1, bias=False)\n", + " self.bn2 = nn.BatchNorm2d(cout)\n", + " self.short = None\n", + " if stride != 1 or cin != cout:\n", + " self.short = nn.Sequential(\n", + " nn.Conv2d(cin, cout, 1, stride=stride, bias=False), nn.BatchNorm2d(cout))\n", + "\n", + " def forward(self, x):\n", + " y = F.relu(self.bn1(self.conv1(x)))\n", + " y = self.bn2(self.conv2(y))\n", + " y = y + (self.short(x) if self.short is not None else x)\n", + " return F.relu(y)\n", + "\n", + "\n", + "class ResNet20(nn.Module):\n", + " def __init__(self, nclass=10):\n", + " super().__init__()\n", + " self.stem = nn.Conv2d(3, 16, 3, padding=1, bias=False)\n", + " self.bn0 = nn.BatchNorm2d(16)\n", + " self.s1 = self._stage(16, 16, 3, 1)\n", + " self.s2 = self._stage(16, 32, 3, 2)\n", + " self.s3 = self._stage(32, 64, 3, 2)\n", + " self.fc = nn.Linear(64, nclass)\n", + "\n", + " @staticmethod\n", + " def _stage(cin, cout, n, stride):\n", + " L = [BasicBlock(cin, cout, stride)] + [BasicBlock(cout, cout, 1) for _ in range(n - 1)]\n", + " return nn.Sequential(*L)\n", + "\n", + " def forward(self, x):\n", + " x = F.relu(self.bn0(self.stem(x)))\n", + " x = self.s3(self.s2(self.s1(x)))\n", + " return self.fc(F.adaptive_avg_pool2d(x, 1).flatten(1))\n", + "\n", + "\n", + "model = ResNet20().to(DEV)\n", + "n_par = sum(p.numel() for p in model.parameters())\n", + "n_lin = sum(1 for m in model.modules() if isinstance(m, (nn.Conv2d, nn.Linear)))\n", + "n_fc = sum(p.numel() for m in model.modules() if isinstance(m, nn.Linear) for p in m.parameters())\n", + "print(f\"ResNet-20 : {n_par:,} parametres, {n_lin} couches conv/fc, dont fc = {n_fc} parametres ({n_fc/n_par:.1%} du total)\")" + ] + }, + { + "cell_type": "markdown", + "id": "0fac69ad", + "metadata": { + "papermill": { + "duration": 0.003069, + "end_time": "2026-09-15T06:10:26.864481", + "exception": false, + "start_time": "2026-09-15T06:10:26.861412", + "status": "completed" + }, + "tags": [] + }, + "source": [ + "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." + ] + }, + { + "cell_type": "code", + "execution_count": 4, + "id": "8dfd467d", + "metadata": { + "execution": { + "iopub.execute_input": "2026-09-15T06:10:26.870482Z", + "iopub.status.busy": "2026-09-15T06:10:26.869480Z", + "iopub.status.idle": "2026-09-15T06:18:54.045294Z", + "shell.execute_reply": "2026-09-15T06:18:54.044274Z" + }, + "papermill": { + "duration": 507.181962, + "end_time": "2026-09-15T06:18:54.048305", + "exception": false, + "start_time": "2026-09-15T06:10:26.866343", + "status": "completed" + }, + "tags": [] + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + " ep 1/6 loss=1.2572 (79.6s)\n" + ] + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + " ep 2/6 loss=0.9693 (83.4s)\n" + ] + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + " ep 4/6 loss=0.9332 (90.0s)\n" + ] + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + " ep 6/6 loss=0.4947 (88.1s)\n" + ] + } + ], + "source": [ + "opt = torch.optim.SGD(model.parameters(), lr=0.08, momentum=0.9, weight_decay=5e-4)\n", + "sched = torch.optim.lr_scheduler.CosineAnnealingLR(opt, T_max=EPOCHS)\n", + "for ep in range(EPOCHS):\n", + " model.train()\n", + " t0 = time.perf_counter()\n", + " for x, y in train_loader:\n", + " loss = F.cross_entropy(model(x.to(DEV)), y.to(DEV))\n", + " opt.zero_grad(); loss.backward(); opt.step()\n", + " sched.step()\n", + " if ep == 0 or (ep + 1) % 2 == 0:\n", + " print(f\" ep {ep+1:2d}/{EPOCHS} loss={loss.item():.4f} ({time.perf_counter()-t0:.1f}s)\")" + ] + }, + { + "cell_type": "code", + "execution_count": 5, + "id": "a8eb8a11", + "metadata": { + "execution": { + "iopub.execute_input": "2026-09-15T06:18:54.063921Z", + "iopub.status.busy": "2026-09-15T06:18:54.062920Z", + "iopub.status.idle": "2026-09-15T06:19:00.642661Z", + "shell.execute_reply": "2026-09-15T06:19:00.641141Z" + }, + "papermill": { + "duration": 6.588829, + "end_time": "2026-09-15T06:19:00.643662", + "exception": false, + "start_time": "2026-09-15T06:18:54.054833", + "status": "completed" + }, + "tags": [] + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "[FP32] exactitude test = 0.7896\n" + ] + } + ], + "source": [ + "def evaluate(m, loader):\n", + " m.eval()\n", + " good = tot = 0\n", + " with torch.no_grad():\n", + " for x, y in loader:\n", + " good += (m(x.to(DEV)).argmax(1).cpu() == y).sum().item()\n", + " tot += y.numel()\n", + " return good / tot\n", + "\n", + "\n", + "acc_fp32 = evaluate(model, test_loader)\n", + "print(f\"[FP32] exactitude test = {acc_fp32:.4f}\")" + ] + }, + { + "cell_type": "markdown", + "id": "7af99e2d", + "metadata": { + "papermill": { + "duration": 0.005984, + "end_time": "2026-09-15T06:19:00.654643", + "exception": false, + "start_time": "2026-09-15T06:19:00.648659", + "status": "completed" + }, + "tags": [] + }, + "source": [ + "**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." + ] + }, + { + "cell_type": "markdown", + "id": "1fb5829d", + "metadata": { + "papermill": { + "duration": 0.004924, + "end_time": "2026-09-15T06:19:00.665561", + "exception": false, + "start_time": "2026-09-15T06:19:00.660637", + "status": "completed" + }, + "tags": [] + }, + "source": [ + "## 3. `quantize_dynamic` : ce que « dynamique » veut dire côté écosystème\n", + "\n", + "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." + ] + }, + { + "cell_type": "code", + "execution_count": 6, + "id": "23e444d4", + "metadata": { + "execution": { + "iopub.execute_input": "2026-09-15T06:19:00.676318Z", + "iopub.status.busy": "2026-09-15T06:19:00.675082Z", + "iopub.status.idle": "2026-09-15T06:19:07.592865Z", + "shell.execute_reply": "2026-09-15T06:19:07.591856Z" + }, + "papermill": { + "duration": 6.924122, + "end_time": "2026-09-15T06:19:07.594384", + "exception": false, + "start_time": "2026-09-15T06:19:00.670262", + "status": "completed" + }, + "tags": [] + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "couches quantifiees : 1 / 22 (type : torch.ao.nn.quantized.dynamic.modules.linear.Linear — le fc seul)\n", + "fc : 640 poids -> 1.9 Ko economises sur 1064 Ko de poids (0.18%)\n" + ] + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + "[dynamic fc INT8] exactitude = 0.7898 (+0.0002)\n" + ] + } + ], + "source": [ + "from torch.ao.quantization import quantize_dynamic\n", + "\n", + "dyn = quantize_dynamic(copy.deepcopy(model), {nn.Linear}, dtype=torch.qint8)\n", + "dyn_lin = [m for m in dyn.modules() if \"quantized.dynamic\" in type(m).__module__]\n", + "print(f\"couches quantifiees : {len(dyn_lin)} / {n_lin} (type : {type(dyn_lin[0]).__module__}.{type(dyn_lin[0]).__name__} — le fc seul)\")\n", + "\n", + "n_fc_w = dyn_lin[0].weight().numel() # qint8 : 1 octet par poids\n", + "economise = n_fc_w * 3 # 4 octets -> 1 octet sur ces poids\n", + "p_fp32 = sum(t.numel() * t.element_size() for t in model.parameters())\n", + "print(f\"fc : {n_fc_w} poids -> {economise/1024:.1f} Ko economises sur {p_fp32/1024:.0f} Ko de poids ({economise/p_fp32:.2%})\")\n", + "\n", + "acc_dyn = evaluate(dyn, test_loader)\n", + "print(f\"[dynamic fc INT8] exactitude = {acc_dyn:.4f} ({acc_dyn - acc_fp32:+.4f})\")" + ] + }, + { + "cell_type": "markdown", + "id": "6f0576d3", + "metadata": { + "papermill": { + "duration": 0.005794, + "end_time": "2026-09-15T06:19:07.607028", + "exception": false, + "start_time": "2026-09-15T06:19:07.601234", + "status": "completed" + }, + "tags": [] + }, + "source": [ + "**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**." + ] + }, + { + "cell_type": "markdown", + "id": "94fd3e36", + "metadata": { + "papermill": { + "duration": 0.006914, + "end_time": "2026-09-15T06:19:07.620543", + "exception": false, + "start_time": "2026-09-15T06:19:07.613629", + "status": "completed" + }, + "tags": [] + }, + "source": [ + "## 4. FX graph mode statique : la chaîne complète en quatre appels\n", + "\n", + "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`.\n", + "\n", + "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)." + ] + }, + { + "cell_type": "code", + "execution_count": 7, + "id": "a8c37172", + "metadata": { + "execution": { + "iopub.execute_input": "2026-09-15T06:19:07.635809Z", + "iopub.status.busy": "2026-09-15T06:19:07.635809Z", + "iopub.status.idle": "2026-09-15T06:19:13.141363Z", + "shell.execute_reply": "2026-09-15T06:19:13.140835Z" + }, + "papermill": { + "duration": 5.514872, + "end_time": "2026-09-15T06:19:13.142423", + "exception": false, + "start_time": "2026-09-15T06:19:07.627551", + "status": "completed" + }, + "tags": [] + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "[FX statique MinMax] exactitude = 0.7900 (+0.0004) (10 modules conv fusionnes)\n" + ] + } + ], + "source": [ + "# torch 2.13 : import explicite du sous-module (bug d'import paresseux du paquet,\n", + "# get_native_backend_config ne se construit pas sans lui - reparation d'environnement documentee)\n", + "import torch.ao.quantization.backend_config.utils # noqa: F401\n", + "from torch.ao.quantization import (QConfig, QConfigMapping, MinMaxObserver,\n", + " HistogramObserver, PerChannelMinMaxObserver)\n", + "from torch.ao.quantization.backend_config import get_native_backend_config\n", + "from torch.ao.quantization.quantize_fx import prepare_fx, convert_fx\n", + "\n", + "BC = get_native_backend_config() # moteur natif x86/onednn\n", + "CAL_BATCHES = 8 # meme budget que 3.9a\n", + "\n", + "def fx_static(observer_act):\n", + " qmap = QConfigMapping().set_global(QConfig(\n", + " activation=observer_act.with_args(qscheme=torch.per_tensor_affine),\n", + " weight=PerChannelMinMaxObserver.with_args(dtype=torch.qint8,\n", + " qscheme=torch.per_channel_symmetric)))\n", + " prep = prepare_fx(copy.deepcopy(model).eval(), qmap, (torch.randn(8, 3, 32, 32),),\n", + " backend_config=BC)\n", + " with torch.no_grad():\n", + " for i, (x, _) in enumerate(train_loader):\n", + " if i >= CAL_BATCHES:\n", + " break\n", + " prep(x.to(DEV))\n", + " return convert_fx(prep, backend_config=BC)\n", + "\n", + "q_minmax = fx_static(MinMaxObserver)\n", + "acc_fx_minmax = evaluate(q_minmax, test_loader)\n", + "n_fused = sum(1 for n, m in q_minmax.named_modules()\n", + " if type(m).__name__ in (\"ConvReLU2d\", \"ConvBnReLU2d\", \"ConvBn2d\"))\n", + "print(f\"[FX statique MinMax] exactitude = {acc_fx_minmax:.4f} ({acc_fx_minmax - acc_fp32:+.4f}) ({n_fused} modules conv fusionnes)\")" + ] + }, + { + "cell_type": "code", + "execution_count": 8, + "id": "bc1c2206", + "metadata": { + "execution": { + "iopub.execute_input": "2026-09-15T06:19:13.152805Z", + "iopub.status.busy": "2026-09-15T06:19:13.152805Z", + "iopub.status.idle": "2026-09-15T06:19:19.035624Z", + "shell.execute_reply": "2026-09-15T06:19:19.034615Z" + }, + "papermill": { + "duration": 5.889063, + "end_time": "2026-09-15T06:19:19.035624", + "exception": false, + "start_time": "2026-09-15T06:19:13.146561", + "status": "completed" + }, + "tags": [] + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "[FX statique Histogramme] exactitude = 0.7901 (+0.0005)\n" + ] + } + ], + "source": [ + "q_hist = fx_static(HistogramObserver)\n", + "acc_fx_hist = evaluate(q_hist, test_loader)\n", + "print(f\"[FX statique Histogramme] exactitude = {acc_fx_hist:.4f} ({acc_fx_hist - acc_fp32:+.4f})\")" + ] + }, + { + "cell_type": "markdown", + "id": "a412e5ac", + "metadata": { + "papermill": { + "duration": 0.003739, + "end_time": "2026-09-15T06:19:19.044346", + "exception": false, + "start_time": "2026-09-15T06:19:19.040607", + "status": "completed" + }, + "tags": [] + }, + "source": [ + "**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." + ] + }, + { + "cell_type": "markdown", + "id": "9d83370b", + "metadata": { + "papermill": { + "duration": 0.002625, + "end_time": "2026-09-15T06:19:19.051220", + "exception": false, + "start_time": "2026-09-15T06:19:19.048595", + "status": "completed" + }, + "tags": [] + }, + "source": [ + "## 5. Taille et vitesse : ce que INT8 achète réellement ici\n", + "\n", + "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." + ] + }, + { + "cell_type": "code", + "execution_count": 9, + "id": "4a572367", + "metadata": { + "execution": { + "iopub.execute_input": "2026-09-15T06:19:19.060777Z", + "iopub.status.busy": "2026-09-15T06:19:19.060777Z", + "iopub.status.idle": "2026-09-15T06:19:20.323413Z", + "shell.execute_reply": "2026-09-15T06:19:20.322888Z" + }, + "papermill": { + "duration": 1.268651, + "end_time": "2026-09-15T06:19:20.324423", + "exception": false, + "start_time": "2026-09-15T06:19:19.055772", + "status": "completed" + }, + "tags": [] + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "taille state_dict : FP32 1071 Ko FX INT8 267 Ko (4.0x plus petit)\n" + ] + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + "latence mediane batch 64 : FP32 21.4 ms FX INT8 12.1 ms (1.77x)\n" + ] + } + ], + "source": [ + "def taille_octets(m):\n", + " t = 0\n", + " for v in m.state_dict().values():\n", + " if not isinstance(v, torch.Tensor): # les modules FX exposent aussi des dtypes nus\n", + " continue\n", + " t += v.numel() * (1 if v.dtype in (torch.quint8, torch.qint8) else v.element_size())\n", + " return t\n", + "\n", + "t_fp32, t_fx = taille_octets(model), taille_octets(q_minmax)\n", + "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)\")\n", + "\n", + "x_bench = next(iter(test_loader))[0][:64]\n", + "def latence(m, n=30, warm=3):\n", + " m.eval()\n", + " with torch.no_grad():\n", + " for _ in range(warm):\n", + " m(x_bench)\n", + " ts = []\n", + " for _ in range(n):\n", + " t0 = time.perf_counter()\n", + " m(x_bench)\n", + " ts.append(time.perf_counter() - t0)\n", + " return float(np.median(ts)) * 1000\n", + "\n", + "l_fp32, l_int8 = latence(model), latence(q_minmax)\n", + "print(f\"latence mediane batch 64 : FP32 {l_fp32:.1f} ms FX INT8 {l_int8:.1f} ms ({l_fp32/l_int8:.2f}x)\")" + ] + }, + { + "cell_type": "markdown", + "id": "15f44e3a", + "metadata": { + "papermill": { + "duration": 0.006704, + "end_time": "2026-09-15T06:19:20.335154", + "exception": false, + "start_time": "2026-09-15T06:19:20.328450", + "status": "completed" + }, + "tags": [] + }, + "source": [ + "**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.\n" + ] + }, + { + "cell_type": "markdown", + "id": "788df9af", + "metadata": { + "papermill": { + "duration": 0.006937, + "end_time": "2026-09-15T06:19:20.349098", + "exception": false, + "start_time": "2026-09-15T06:19:20.342161", + "status": "completed" + }, + "tags": [] + }, + "source": [ + "## 6. Récapitulatif : l'écosystème contre la main\n", + "\n", + "| configuration | exactitude (ce run, témoin FP32 ci-dessus) | repère 3.9a committé (GPU, 40 ep, témoin 0.9019) | lignes de quantification |\n", + "|---|---:|---:|---:|\n", + "| FP32 | témoin | 0.9019 | — |\n", + "| `quantize_dynamic` (fc seul) | mesurée §3 | — (3.9a visait les convs : 0.9018) | 1 |\n", + "| FX statique MinMax | mesurée §4 | 0.9017 | ~15 |\n", + "| FX statique Histogramme | mesurée §4 | 0.9012 (KL) | ~15 |\n", + "| 3.9a à la main (per-channel, static) | — | 0.9017 / 0.9012 | ~150 |\n", + "\n", + "Ce que l'écosystème donne : la fusion, les stubs, le packing, les kernels, la portabilité — en une quinzaine de lignes. 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)." + ] + }, + { + "cell_type": "markdown", + "id": "5100770d", + "metadata": { + "papermill": { + "duration": 0.006564, + "end_time": "2026-09-15T06:19:20.361671", + "exception": false, + "start_time": "2026-09-15T06:19:20.355107", + "status": "completed" + }, + "tags": [] + }, + "source": [ + "## Exercice 1 — Quantification aware de l'entraînement (QAT)\n", + "\n", + "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 ?\n", + "\n", + "# Indice : from torch.ao.quantization.quantize_fx import prepare_qat_fx — la calibration\n", + "# est remplacee par l'entrainement lui-meme (les faux-quantifieurs collectent les echelles).\n", + "# Etape 1 : preparer le modele (meme QConfigMapping). Etape 2 : 1 epoch de SGD (lr faible, 1e-2).\n", + "# Etape 3 : convert_fx puis evaluate — comparer l'ecart a celui du MinMax du §4." + ] + }, + { + "cell_type": "code", + "execution_count": 10, + "id": "cd4ef7a2", + "metadata": { + "execution": { + "iopub.execute_input": "2026-09-15T06:19:20.375744Z", + "iopub.status.busy": "2026-09-15T06:19:20.374194Z", + "iopub.status.idle": "2026-09-15T06:19:20.380771Z", + "shell.execute_reply": "2026-09-15T06:19:20.380266Z" + }, + "papermill": { + "duration": 0.014094, + "end_time": "2026-09-15T06:19:20.381777", + "exception": false, + "start_time": "2026-09-15T06:19:20.367683", + "status": "completed" + }, + "tags": [] + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "Exercice a completer\n", + "[QAT 1 epoch] exactitude = None\n" + ] + } + ], + "source": [ + "def qat_1_epoch(lr=1e-2):\n", + " # TODO etudiant\n", + " # Etape 1 : qat = prepare_qat_fx(copy.deepcopy(model), qmap, (exemple,), backend_config=BC)\n", + " # Etape 2 : 1 epoch d'entrainement SGD sur qat (mode fake-quant actif par defaut)\n", + " # Etape 3 : conv = convert_fx(qat.eval(), backend_config=BC) ; return evaluate(conv, test_loader)\n", + " print(\"Exercice a completer\")\n", + " return None\n", + "\n", + "\n", + "acc_qat = qat_1_epoch()\n", + "print(f\"[QAT 1 epoch] exactitude = {acc_qat}\")" + ] + }, + { + "cell_type": "markdown", + "id": "d7b8eb85", + "metadata": { + "papermill": { + "duration": 0.005004, + "end_time": "2026-09-15T06:19:20.392286", + "exception": false, + "start_time": "2026-09-15T06:19:20.387282", + "status": "completed" + }, + "tags": [] + }, + "source": [ + "## Exercice 2 — Un observateur percentile\n", + "\n", + "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.\n", + "\n", + "# Indice : MinMaxObserver.min_vals/max_vals sont remplies dans forward(). Une voie simple :\n", + "# collecter les activations dans un buffer pendant la calibration, puis redefinir\n", + "# min/max par np.percentile au moment du calcul. Etape 1 : sous-classer MinMaxObserver.\n", + "# Etape 2 : accumuler les valeurs (attention a la memoire : echantillonner). Etape 3 :\n", + "# brancher via QConfig(activation=MonObserver.with_args(p=99.9), ...) dans fx_static()." + ] + }, + { + "cell_type": "code", + "execution_count": 11, + "id": "0f258a4a", + "metadata": { + "execution": { + "iopub.execute_input": "2026-09-15T06:19:20.404965Z", + "iopub.status.busy": "2026-09-15T06:19:20.404965Z", + "iopub.status.idle": "2026-09-15T06:19:20.410114Z", + "shell.execute_reply": "2026-09-15T06:19:20.410114Z" + }, + "papermill": { + "duration": 0.01353, + "end_time": "2026-09-15T06:19:20.411326", + "exception": false, + "start_time": "2026-09-15T06:19:20.397796", + "status": "completed" + }, + "tags": [] + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "PercentileObserver declare : PercentileObserver\n" + ] + } + ], + "source": [ + "class PercentileObserver(MinMaxObserver):\n", + " # TODO etudiant\n", + " # Reimplementer le calcul des bornes au percentile p plutot qu'aux extremes.\n", + " def __init__(self, p=99.9, **kw):\n", + " super().__init__(**kw)\n", + " self.p = p\n", + "\n", + " def forward(self, x_orig):\n", + " # TODO etudiant : accumuler, puis borner au percentile self.p\n", + " print(\"Exercice a completer\")\n", + " return super().forward(x_orig)\n", + "\n", + "\n", + "print(f\"PercentileObserver declare : {PercentileObserver(p=99.9).__class__.__name__}\")" + ] + }, + { + "cell_type": "markdown", + "id": "5f4f585a", + "metadata": { + "papermill": { + "duration": 0.0052, + "end_time": "2026-09-15T06:19:20.419034", + "exception": false, + "start_time": "2026-09-15T06:19:20.413834", + "status": "completed" + }, + "tags": [] + }, + "source": [ + "## Exercice 3 — Épargner la première couche\n", + "\n", + "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 ?\n", + "\n", + "# Indice : un qconfig \"nul\" garde un module en FP32 — QConfig(activation=None, weight=None)\n", + "# (deja importe dans la cellule du §4). Etape 1 : QConfigMapping().set_global().set_module_name(\"stem\", QConfig(activation=None, weight=None)).\n", + "# Etape 2 : prepare_fx -> calibration (CAL_BATCHES) -> convert_fx.\n", + "# Etape 3 : comparer (exactitude, taille) au FX MinMax du §4 — l'ecart type attendu sur un\n", + "# petit CNN est faible, mesurez-le quand meme." + ] + }, + { + "cell_type": "code", + "execution_count": 12, + "id": "8b617276", + "metadata": { + "execution": { + "iopub.execute_input": "2026-09-15T06:19:20.432945Z", + "iopub.status.busy": "2026-09-15T06:19:20.432945Z", + "iopub.status.idle": "2026-09-15T06:19:20.438180Z", + "shell.execute_reply": "2026-09-15T06:19:20.437175Z" + }, + "papermill": { + "duration": 0.012799, + "end_time": "2026-09-15T06:19:20.438180", + "exception": false, + "start_time": "2026-09-15T06:19:20.425381", + "status": "completed" + }, + "tags": [] + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "Exercice a completer\n", + "[FX MinMax sans stem] exactitude = None\n" + ] + } + ], + "source": [ + "def fx_static_sans_stem():\n", + " # TODO etudiant\n", + " # Etape 1 : qmap avec set_module_name(\"stem\", float_qconfig)\n", + " # Etape 2 : prepare_fx -> calibration (CAL_BATCHES) -> convert_fx\n", + " # Etape 3 : return evaluate(conv, test_loader)\n", + " print(\"Exercice a completer\")\n", + " return None\n", + "\n", + "\n", + "acc_sans_stem = fx_static_sans_stem()\n", + "print(f\"[FX MinMax sans stem] exactitude = {acc_sans_stem}\")" + ] + }, + { + "cell_type": "markdown", + "id": "6d03b8d6", + "metadata": { + "papermill": { + "duration": 0.006667, + "end_time": "2026-09-15T06:19:20.451847", + "exception": false, + "start_time": "2026-09-15T06:19:20.445180", + "status": "completed" + }, + "tags": [] + }, + "source": [ + "## Résumé\n", + "\n", + "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).\n", + "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.\n", + "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.\n", + "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.\n", + "5. **Écosystème vs main** : ~15 lignes contre ~150, 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).\n", + "\n", + "## Pour aller plus loin\n", + "\n", + "- [3.9a — Compression par quantification INT8 à la main](3.9a-Compression-Quantization-INT8.ipynb) — la physique : échelles, zero-points, KL, la falaise INT4\n", + "- [3.7 — Distillation maître-élève](3.7-Distillation-Maitre-Eleve.ipynb) — l'autre famille de compression : le savoir plutôt que les nombres\n", + "- [FT-02 — QLoRA](../../../GenAI/FineTuning/FT-02-QLoRA-Quantization.ipynb) — la quantification 4 bits au service du fine-tuning de LLM\n", + "- **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](https://github.com/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`." + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": "Python 3", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.11.9" + }, + "papermill": { + "default_parameters": {}, + "duration": 540.603638, + "end_time": "2026-09-15T06:19:21.351724", + "environment_variables": {}, + "exception": null, + "input_path": "MyIA.AI.Notebooks/ML/DataScienceWithAgents/03-DeepLearning/3.9e-Compression-Quantization-SOTA.ipynb", + "output_path": "MyIA.AI.Notebooks/ML/DataScienceWithAgents/03-DeepLearning/3.9e-Compression-Quantization-SOTA.ipynb", + "parameters": {}, + "start_time": "2026-09-15T06:10:20.748086", + "version": "2.6.0" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} \ No newline at end of file