Module 6 — Quantification ONNX
Après l'optimisation de graphe qui ne change pas la précision, on aborde la technique qui la change délibérément : la quantification. Passer les poids et souvent les activations de float32 en INT8 divise la taille du modèle par quatre, exploite les instructions vectorielles INT8 des processeurs modernes, et fait tourner sur mobile un modèle qui ne rentre pas en float32. Le prix : une perte de précision qu'il faut mesurer et accepter.
Deux régimes : dynamique et statique
ONNX Runtime propose deux régimes de quantification.
La quantification dynamique convertit les poids en INT8 une fois, à la conversion ; les activations restent en float32 en entrée de chaque nœud, converties à la volée en INT8 avant le calcul, puis remises en float32 en sortie. C'est simple : aucune donnée d'entraînement n'est nécessaire, la conversion prend quelques secondes, et la perte de précision est modérée. C'est le premier essai à faire.
La quantification statique convertit poids et activations en INT8. Les échelles et zéros des activations sont calibrés sur un petit jeu de données représentatif. Le modèle final ne contient plus de float32 sur son chemin critique : les calculs se font entièrement en INT8, et l'accélération matérielle est maximale. Le prix : il faut fournir un CalibrationDataReader qui itère sur des entrées réalistes.
La quantification dynamique en pratique
from onnxruntime.quantization import quantize_dynamic, QuantType
quantize_dynamic(
model_input="resnet18_fashion.onnx",
model_output="resnet18_fashion_int8_dyn.onnx",
weight_type=QuantType.QInt8,
)
Deux points à retenir. QuantType.QInt8 (signé) est le choix par défaut sur x86 avec instructions AVX-512 VNNI ; QuantType.QUInt8 (non signé) est parfois nécessaire sur d'anciens jeux d'instructions et sur certains accélérateurs mobiles. La différence pratique est mineure — les deux permettent 256 niveaux de quantification. L'important est la cohérence entre modèle et matériel cible.
Le fichier résultant est environ quatre fois plus petit : 45 Mio en float32 deviennent 12 Mio en INT8. La latence sur CPU baisse de 30 % à 60 % selon le modèle et la génération du processeur. Sur GPU, la quantification dynamique n'apporte souvent rien : les activations en float32 restent le goulot d'étranglement.
La quantification statique en pratique
Pour la statique, il faut fournir un lecteur de données de calibration. Un CalibrationDataReader implémente get_next() et renvoie un dictionnaire {nom_entree: tenseur_numpy} pour chaque exemple, ou None en fin de flux.
import numpy as np
from onnxruntime.quantization import (
CalibrationDataReader,
quantize_static,
QuantType,
QuantFormat,
CalibrationMethod,
)
class LecteurCalibration(CalibrationDataReader):
def __init__(self, lot_iterable, nom_entree="entree"):
self.iter = iter(lot_iterable)
self.nom = nom_entree
def get_next(self):
exemple = next(self.iter, None)
if exemple is None:
return None
return {self.nom: exemple.astype(np.float32)}
# Générer environ 100 à 500 lots représentatifs
def flux_calibration():
for _ in range(200):
yield np.random.randn(1, 3, 224, 224).astype(np.float32)
quantize_static(
model_input="resnet18_fashion.onnx",
model_output="resnet18_fashion_int8_static.onnx",
calibration_data_reader=LecteurCalibration(flux_calibration()),
quant_format=QuantFormat.QDQ,
activation_type=QuantType.QInt8,
weight_type=QuantType.QInt8,
calibrate_method=CalibrationMethod.MinMax,
)
Trois choix qui comptent.
Le nombre d'exemples de calibration : entre 100 et 500 exemples représentatifs suffisent. Un jeu de calibration trop petit rate les extrêmes ; un jeu trop grand ne gagne pas de précision. La distribution doit ressembler à celle de production, pas au bruit gaussien de l'exemple ci-dessus — c'est un exemple pédagogique, en pratique on itère sur un vrai lot de validation.
Le format QuantFormat.QDQ insère des paires QuantizeLinear/DequantizeLinear autour de chaque tenseur quantifié. C'est le format recommandé depuis ONNX Runtime 1.11 : plus portable, mieux optimisé par les fournisseurs. L'alternative historique QOperator insère des opérateurs INT8 directement, ce qui est moins souple.
La méthode de calibration :
MinMax: la plus simple et la plus rapide, prend les extrêmes observés comme bornes. Fonctionne bien sur les couches sans queue longue.Percentile: coupe à un percentile (par défaut 99,99 %) pour ignorer les valeurs aberrantes. Améliore la précision quand les activations ont des pics.Entropy: minimise la divergence de Kullback-Leibler entre la distribution d'origine et la distribution quantifiée. Le plus lent, mais le plus précis sur les réseaux profonds.
Pour un premier essai sur un CNN classique, MinMax suffit. Sur un transformeur, Percentile ou Entropy améliorent la précision de plusieurs points d'exactitude.
Effet sur les deux modèles du fil rouge
Voici l'ordre de grandeur attendu, après vérification numérique sur le jeu de validation.
| Modèle | Format | Taille | Latence CPU | Exactitude top-1 |
|---|---|---|---|---|
| ResNet18 Fashion-MNIST | float32 | 45 Mio | 37 ms/lot32 | 91,4 % |
| ResNet18 Fashion-MNIST | INT8 dynamique | 12 Mio | 22 ms/lot32 | 91,3 % |
| ResNet18 Fashion-MNIST | INT8 statique | 12 Mio | 15 ms/lot32 | 91,1 % |
| Encodeur texte | float32 | 55 Mio | 18 ms/lot16 | 87,2 % |
| Encodeur texte | INT8 dynamique | 15 Mio | 12 ms/lot16 | 87,0 % |
| Encodeur texte | INT8 statique | 15 Mio | 8 ms/lot16 | 86,3 % |
Ces chiffres sont indicatifs : le protocole exact de mesure est traité au module 8. Ce qui compte ici :
- La taille baisse d'un facteur quatre, quasi indépendamment du modèle.
- La dynamique perd très peu de précision (
0,1à0,2point) et gagne significativement en vitesse. - La statique gagne encore en vitesse mais perd davantage sur le transformeur — l'attention est plus sensible que les convolutions.
Règle pratique : commencer par la dynamique. Si l'objectif de latence n'est pas atteint et que l'écart de précision reste acceptable, passer à la statique.
Le piège des opérateurs non quantifiables
Tous les opérateurs ONNX ne se quantifient pas. quantize_static ne quantifie que ceux qu'il connaît — Conv, MatMul, Gemm, Add, essentiellement — et laisse les autres en float32. Un modèle avec beaucoup d'opérateurs exotiques finit partiellement quantifié, avec des conversions Quant/Dequant intercalées qui coûtent parfois plus qu'elles ne rapportent.
# Netron révèle immédiatement le motif :
# → un Conv devient QuantizeLinear → QLinearConv → DequantizeLinear
# → un Softmax reste en float32, forçant deux conversions
Deux réponses. On peut exclure explicitement les couches sensibles avec nodes_to_exclude=[...] — utile pour laisser la dernière couche de classification en float32, ce qui préserve la précision des probabilités. On peut aussi inclure explicitement un sous-ensemble avec op_types_to_quantize=["Conv", "MatMul"] — pratique pour un premier essai contrôlé.
Compatibilité matérielle
Toutes les cibles ne bénéficient pas également de la quantification INT8.
- CPU x86 avec VNNI (AVX-512 VNNI ou AVX2 VNNI) : accélération importante, 2× à 3× sur les convolutions.
- CPU ARM : bon support depuis les puces Neoverse ; sur Apple Silicon, tirer parti d'ANE via Core ML est souvent plus payant.
- GPU CUDA : la quantification INT8 y est possible mais moins mature que sur CPU ; TensorRT (module 7) exploite mieux INT8 sur GPU.
- NPU mobiles : accélération majeure, souvent obligatoire pour rentrer dans les budgets mémoire.
Un modèle « quantifié » qui n'accélère pas en pratique signale un mauvais couplage matériel. Sur un vieux CPU sans VNNI, INT8 peut être plus lent que float32 parce que les kernels INT8 optimisés font défaut. Toujours mesurer avant de conclure qu'une plateforme cible bénéficie de la quantification.
Vérifier la précision du modèle quantifié
La quantification change la sortie. Le contrôle du module 4 ne s'applique pas tel quel : atol=1e-4 ne tiendra jamais. On change de critère.
# Sur le jeu de validation complet
correct_fp32 = 0
correct_int8 = 0
for images, etiquettes in loader_validation:
s_fp32 = session_fp32.run(None, {"entree": images.numpy()})[0]
s_int8 = session_int8.run(None, {"entree": images.numpy()})[0]
correct_fp32 += (s_fp32.argmax(1) == etiquettes.numpy()).sum()
correct_int8 += (s_int8.argmax(1) == etiquettes.numpy()).sum()
print(f"Précision float32 : {correct_fp32 / len(dataset):.3%}")
print(f"Précision INT8 : {correct_int8 / len(dataset):.3%}")
Un écart supérieur à 1 point pour un CNN, ou à 2 points pour un transformeur, signale que la quantification est trop agressive et qu'il faut adoucir : monter en Percentile, exclure la dernière couche, ou revenir à la dynamique.
En résumé
- La quantification dynamique (
quantize_dynamic) convertit uniquement les poids et se passe de données de calibration ; c'est le premier essai à faire. - La quantification statique (
quantize_static) calibre aussi les activations sur un petit jeu représentatif, gagne davantage en vitesse mais coûte en mise en place. - Le format QDQ est la voie recommandée depuis ONNX Runtime 1.11 ; les méthodes
MinMax,PercentileetEntropycouvrent des modèles de complexité croissante. - La quantification change la sortie ; on vérifie la précision sur le jeu de validation entier, pas avec
np.allclose, et un écart supérieur à 1 point signale un réglage trop agressif.
Le module suivant introduit un autre levier orthogonal : les fournisseurs d'exécution. On y verra qu'un modèle INT8 servi via TensorRT réunit deux accélérations dont il faut connaître les pièges.