Aller au contenu principal

Module 1 — Le format ONNX : graphe, opérateurs, versions

Avant de convertir un modèle, il faut comprendre à quoi ressemble le fichier .onnx qu'on va produire. Ce n'est ni un vidage de poids, ni un script à interpréter : c'est un graphe de calcul sérialisé, avec des nœuds nommés, des tenseurs typés, des attributs, et surtout une version de jeu d'opérateurs qui définit ce que chaque nœud signifie. Ce module ouvre le capot avant les modules 2 et 3, où l'on produit ce même fichier depuis PyTorch et TensorFlow.

Pourquoi un format d'échange plutôt qu'un format natif

Chaque cadre d'apprentissage — PyTorch, TensorFlow, JAX, MXNet, PaddlePaddle — définit son propre format de sauvegarde. Ce format porte les poids, mais aussi la logique du forward sous une forme qui n'est lisible que par le cadre en question. Le résultat, en 2018, était un mur : un modèle entraîné sous PyTorch ne pouvait pas être servi par un moteur écrit pour TensorFlow, ni tourner sur un accélérateur mobile qui ne supportait que son propre runtime.

ONNX (Open Neural Network Exchange) est né de ce constat. C'est un format ouvert et neutre, gouverné par la Linux Foundation, dont l'objectif est de servir de langue commune entre les cadres d'entraînement et les moteurs d'inférence. Un modèle exporté au format ONNX peut être :

  • Servi par ONNX Runtime, moteur d'inférence multi-langages soutenu par Microsoft.
  • Compilé pour TensorRT (NVIDIA), OpenVINO (Intel), Core ML (Apple), NNAPI (Android).
  • Chargé dans un service C++, Java, Rust, Go, C#, JavaScript, sans jamais démarrer d'interpréteur Python.

Le prix à payer, c'est la conversion. Le cadre d'origine ne peut pas exporter n'importe quel modèle sans compromis : certaines opérations n'ont pas d'équivalent ONNX, d'autres en ont mais dans une version d'opérateur récente que tous les moteurs ne supportent pas. Comprendre le format, c'est éviter les mauvaises surprises quand la conversion échoue.

Un modèle ONNX est un graphe de calcul

Le cœur d'un fichier .onnx est un graphe orienté acyclique dont :

  • Les entrées sont des tenseurs nommés avec leur type et leur forme (dont les axes dynamiques).
  • Les nœuds sont des applications d'opérateurs, chacun avec ses attributs (par exemple la taille du pas d'une convolution).
  • Les sorties sont les tenseurs finaux à récupérer côté client.
  • Les initialiseurs portent les poids constants du modèle (matrices, biais, tables de plongement).

Contrairement à un script Python, ce graphe est entièrement statique : toutes les formes, tous les types, tous les branchements sont figés au moment de l'export. Un contrôle de flux if conditionné par la valeur d'un tenseur devient un nœud If avec ses deux sous-graphes ; une boucle devient un Loop. Rien n'est laissé à l'exécution Python.

import onnx

modele = onnx.load("resnet18_fashion.onnx")
print("Opset :", modele.opset_import[0].version)
print("Producteur :", modele.producer_name, modele.producer_version)
print("Entrées :")
for e in modele.graph.input:
print(" ", e.name, [d.dim_value or d.dim_param for d in e.type.tensor_type.shape.dim])
print("Sorties :")
for s in modele.graph.output:
print(" ", s.name, [d.dim_value or d.dim_param for d in s.type.tensor_type.shape.dim])
print("Nombre de nœuds :", len(modele.graph.node))

Ce petit script suffit pour vérifier qu'un export est bien formé et pour lire ses métadonnées. Le champ dim_param porte les axes dynamiques déclarés lors de l'export : par exemple "batch" pour la dimension de lot du ResNet18. Un dim_value explicite comme 224 signale au contraire une dimension figée.

Le jeu d'opérateurs (opset) : la version qui compte vraiment

Un modèle ONNX déclare une version d'opset. C'est le numéro du jeu d'opérateurs qu'il utilise, publié par le projet ONNX. Chaque version ajoute, corrige ou étend des opérateurs ; un opérateur Resize de l'opset 11 n'a pas la même signature que celui de l'opset 13, et un moteur d'inférence qui ne connaît que jusqu'à l'opset 15 refuse de charger un graphe qui utilise l'opset 18.

OpsetAnnéeNouveautés notables
112019Resize révisé, Range, Round
132020Reduce* avec axes en entrée, quantification INT8 étendue
172022Améliorations de la quantification, plus de couverture des transformeurs
19-212024Bloc de fonctions, meilleure couverture LLM

Le choix de l'opset au moment de l'export est le paramètre le plus important après le modèle lui-même. Un opset trop récent bloque certains runtimes ; un opset trop ancien fait échouer l'export sur des couches modernes (attention, LayerNorm, GELU). En 2026, opset 17 est un compromis raisonnable pour un modèle CNN classique ; opset 20 ou 21 est nécessaire pour les transformeurs récents.

Un modèle porte aussi un numéro de version IR (Intermediate Representation) — la version du format binaire — distinct de l'opset. IR 8, sorti en 2022, est le socle actuel.

La sérialisation : c'est du protobuf, pas du JSON

Le fichier .onnx est un message protobuf binaire, décrit par le schéma onnx.proto. C'est un format compact, lisible par tout langage qui a une bibliothèque protobuf, mais illisible à l'œil nu. Un modèle de 45 Mio ne « pèse » pas la taille de ses poids : le graphe, les métadonnées et les noms représentent quelques dizaines de kilo-octets à côté des matrices en float32.

Deux limites à connaître. La première : protobuf plafonne à 2 Gio par message. Les modèles au-delà — un LLM de 7 milliards de paramètres — doivent utiliser le format ONNX avec poids externes, où le graphe reste dans le .onnx et les poids sont écrits dans des fichiers .data séparés. La seconde : la portabilité binaire n'est pas complète en float16 sur toutes les plateformes ; garder l'export en float32 par défaut évite les surprises de dénormalisation.

Netron, votre premier outil de débogage

Netron est un visualisateur de modèles neuronaux qui lit ONNX, TorchScript, TensorFlow SavedModel et Core ML. Ouvrir le fichier .onnx dans Netron affiche le graphe couche par couche, avec les formes des tenseurs, les attributs de chaque nœud, et les noms des initialiseurs.

C'est le premier réflexe quand quelque chose ne va pas :

  • Le modèle exporte mais l'inférence rend du bruit : Netron montre-t-il la couche de sortie attendue, ou une couche Reshape sortie de nulle part ?
  • Une couche Constant géante apparaît au milieu du graphe : un tenseur qu'on croyait dynamique a été figé pendant le traçage.
  • Un opérateur non pris en charge par TensorRT : Netron affiche son type et sa version d'opset, ce qui indique par où contourner (module 9).
# Un script pour ouvrir Netron sans dépendance
import subprocess
subprocess.run(["netron", "resnet18_fashion.onnx", "--port", "8080"])
# Puis pointer le navigateur sur http://localhost:8080

Le vérificateur intégré : onnx.checker

Avant même de charger le modèle dans un moteur d'inférence, on peut demander à la bibliothèque onnx de vérifier que le graphe respecte les invariants du format : chaque tenseur consommé est produit par un nœud antérieur, chaque opérateur reçoit le bon nombre d'entrées avec les bons types, aucun cycle n'existe.

import onnx

modele = onnx.load("resnet18_fashion.onnx")
onnx.checker.check_model(modele)
print("Graphe ONNX valide.")

Un modèle qui ne passe pas check_model ne se chargera jamais dans ONNX Runtime ; c'est le premier filet, gratuit et instantané, avant les vérifications numériques du module 4.

En résumé

  • Un modèle ONNX est un graphe statique sérialisé en protobuf, avec des tenseurs nommés en entrée et en sortie, des nœuds opérateurs et des initialiseurs pour les poids.
  • Le jeu d'opérateurs (opset) décide de la signature de chaque nœud ; le choix au moment de l'export conditionne la portabilité vers les runtimes cibles.
  • Le champ dim_param distingue les axes dynamiques (par exemple la taille de lot) des dimensions figées ; c'est lui qui rend l'inférence par lots possible.
  • onnx.checker.check_model et Netron sont les deux outils gratuits à connaître avant tout diagnostic plus fin : le premier valide la structure, le second révèle ce que l'export a réellement écrit.

Le module suivant transforme cette théorie en pratique : on part d'un modèle PyTorch, on l'exporte, on choisit les axes dynamiques, et on relit ce que torch.onnx.export a produit.