Module 9 — Opérateurs non pris en charge et contournements
Jusqu'ici, tout s'est bien passé. La réalité est que plus d'un projet sur deux rencontre un jour un opérateur qui refuse de s'exporter, ou qui s'exporte mais qu'un fournisseur d'exécution ne reconnaît pas. Ce module systématise le diagnostic : lire le message d'erreur, décider entre quatre stratégies, et illustrer chacune sur un cas réel — l'encodeur de texte du fil rouge et son opérateur d'attention.
Trois classes d'échecs
Il faut distinguer trois échecs, parce que les réponses diffèrent.
Un échec à l'export. torch.onnx.export ou tf2onnx lève une exception. Le mécanisme d'export du cadre d'origine ne connaît pas l'opérateur ou ne sait pas le traduire. Le message dit typiquement : « Exporting the operator XYZ to ONNX opset version 17 is not supported. »
Un échec à onnx.checker.check_model. L'export a produit un fichier, mais le graphe ne respecte pas les invariants du format. Un attribut manque, un tenseur n'a pas le bon type. C'est un bug soit du cadre exportateur, soit d'une couche personnalisée mal enregistrée.
Un échec à la création de la session. ONNX Runtime rejette le graphe parce qu'un opérateur n'est pas implémenté par le fournisseur choisi. Le message ressemble à : « Fatal error: XYZ(-1) is not a registered function/op ». C'est le cas classique quand on cible TensorRT sur un modèle qui utilise ScatterND avec un mode non couvert.
Chaque classe amène à des stratégies différentes. On ne réécrit pas le modèle pour un problème de checker ; on ne monte pas d'opset pour un fournisseur qui n'implémente pas l'opérateur.
Stratégie 1 : monter en opset
C'est la première chose à essayer. Un opérateur qui n'existe pas en opset 13 peut être apparu en opset 17. Un LayerNorm monolithique est apparu en opset 17 alors qu'il fallait le décomposer en opset 13. Attention est apparu en opset 22 et n'est utilisable qu'à condition que la cible d'inférence le supporte.
torch.onnx.export(
modele,
exemple,
"sortie.onnx",
opset_version=20, # au lieu de 17
input_names=["entree"],
output_names=["logits"],
)
La règle prudente : la version d'opset la plus récente supportée à la fois par le cadre exportateur, la version d'ONNX Runtime cible, et le fournisseur d'exécution. Un tableau de compatibilité maintenu par le projet ONNX Runtime liste ces intersections ; on le consulte avant de choisir.
Stratégie 2 : réécrire le module fautif
Un opérateur PyTorch qui ne s'exporte pas est souvent une combinaison qu'il vaut mieux réécrire en primitives exportables. torch.nn.functional.interpolate avec mode="area" a longtemps refusé de s'exporter ; on le remplaçait par un AvgPool2d équivalent.
# Avant : refuse de s'exporter en opset 13
class MonReducteur(nn.Module):
def forward(self, x):
return F.interpolate(x, scale_factor=0.5, mode="area")
# Après : s'exporte proprement en opset 13
class MonReducteurExportable(nn.Module):
def __init__(self):
super().__init__()
self.pool = nn.AvgPool2d(kernel_size=2, stride=2)
def forward(self, x):
return self.pool(x)
Cette voie exige de conserver la précision numérique — la vérification du module 4 est le juge : atol=1e-4 doit tenir sur un jeu d'entrées varié. Une réécriture qui casse la précision est une réécriture ratée, même si elle exporte.
Stratégie 3 : décomposer un opérateur composite
Un cas très courant : l'attention multi-têtes d'un transformeur. En opset ancien, elle n'existe pas en un seul opérateur ONNX. PyTorch la trace en MatMul + Softmax + MatMul — trois opérateurs primitifs — ce qui est parfaitement exportable. Mais les moteurs d'inférence ne reconnaissent pas ce motif et ne l'accélèrent pas comme un Attention monolithique.
Pour l'encodeur de texte du fil rouge, la solution 2026 est :
- Exporter avec un opset récent (20 ou plus) pour bénéficier de
Attentionunique quand PyTorch et ONNX Runtime le supportent. - Sinon, laisser PyTorch tracer la décomposition et compter sur les optimisations de graphe d'ONNX Runtime (module 5,
ORT_ENABLE_EXTENDED) qui remontent le motif enFusedAttentioncôté runtime.
# La classe reste standard : l'attention multi-têtes de PyTorch
class BlocEncodeur(nn.Module):
def __init__(self, d_modele=256, n_tetes=4):
super().__init__()
self.attn = nn.MultiheadAttention(d_modele, n_tetes, batch_first=True)
self.norme1 = nn.LayerNorm(d_modele)
self.ffn = nn.Sequential(
nn.Linear(d_modele, 4 * d_modele),
nn.GELU(),
nn.Linear(4 * d_modele, d_modele),
)
self.norme2 = nn.LayerNorm(d_modele)
def forward(self, x, masque):
y, _ = self.attn(x, x, x, key_padding_mask=masque)
x = self.norme1(x + y)
x = self.norme2(x + self.ffn(x))
return x
À l'export en opset 20, PyTorch trace correctement l'attention en primitives. ONNX Runtime reconnaît le motif à l'optimisation et le remonte en FusedAttention — la mesure du module 8 confirme le gain, on n'a rien à faire de plus.
Stratégie 4 : l'opérateur personnalisé
Ultime recours : écrire une fonction ONNX personnalisée (custom op). C'est un opérateur qu'on enregistre auprès d'ONNX Runtime avec son implémentation C++ (ou Python via PyOp). Le graphe fait référence à un opérateur nommé (par exemple MonMasque:Custom) et le runtime appelle la fonction à l'exécution.
C'est puissant et coûteux. Un opérateur personnalisé casse la portabilité : le modèle ne se charge plus que sur un runtime où l'opérateur est enregistré. Les autres cadres (Core ML, TensorRT) ne le connaissent pas non plus. On y va quand toutes les autres stratégies ont échoué, et jamais pour un modèle qu'on veut voir tourner ailleurs.
# Enregistrer un opérateur personnalisé côté PyTorch
from torch.onnx import register_custom_op_symbolic
def mon_op_symbolique(g, x, seuil):
return g.op("com.mon.entreprise::MonMasque", x, seuil_f=float(seuil))
register_custom_op_symbolic("::mon_op", mon_op_symbolique, opset_version=17)
# Côté ONNX Runtime, on charge une bibliothèque partagée qui déclare
# l'implémentation compilée : session.set_custom_op_shared_library(...)
Lire un message d'erreur d'export
Un message typique de torch.onnx.export :
RuntimeError: Exporting the operator aten::im2col to ONNX opset version 17
is not supported. Support for this operator was added in version 18, try
exporting with this version.
Trois informations à extraire : le nom de l'opérateur (aten::im2col), la version d'opset demandée (17), la version où il est supporté (18). La réponse est claire : monter en opset 18.
Un autre message classique :
Warning: Constant folding - Only steps=1 can be constant folded for opset >= 10
onnx Slice op. Constant folding not applied.
C'est un avertissement, pas une erreur : l'export continue, mais un Slice n'a pas été plié. Le graphe est un peu moins optimisé. On peut ignorer et mesurer, ou reformuler le découpage pour qu'il soit repliable.
Lire un message d'erreur d'ONNX Runtime
Un message typique à la création de la session :
onnxruntime.capi.onnxruntime_pybind11_state.NotImplemented:
[ONNXRuntimeError] : 9 : NOT_IMPLEMENTED :
Could not find an implementation for MyOp(1) node with name 'monop_0'
Le code d'erreur 9 est NOT_IMPLEMENTED. Le nom de l'opérateur (MyOp) et son numéro de version ((1)) sont explicites. Trois voies : implémenter côté custom op, décomposer côté modèle, ou changer de fournisseur.
Documenter les compromis
Chaque contournement laisse une trace. Documenter, à côté du fichier .onnx, un petit fichier contournements.md qui explique :
- Ce qu'on a changé (réécriture, opset, opérateur personnalisé).
- Pourquoi (nom de l'opérateur, version d'origine, cible).
- Ce que la vérification numérique donne (écart maximum sur le jeu de test).
- Ce que la mesure de performance donne (avant / après).
C'est un investissement de dix minutes qui économise des heures quand un successeur reprend le dépôt.
Le cas complet de l'encodeur de texte
Pour clore le fil rouge, voici l'histoire résumée de l'export du transformeur.
- Premier essai en opset 13 : échec. L'export produit une chaîne de
MatMuletSoftmaxavec unWheresur le masque qui ne se convertit pas correctement. - Réécriture du masque en utilisant
torch.whereexplicite plutôt que lekey_padding_maskdeMultiheadAttention: l'export réussit. - Vérification numérique :
atolà3e-5, acceptable. - Sur ONNX Runtime CPU avec
ORT_ENABLE_ALL, le motif est remonté enFusedAttention: gain de 30 % de latence contre la décomposition brute. - Sur TensorRT, l'opérateur
Erf(utilisé parGELU) tombait en repli CUDA en TensorRT 8 ; en TensorRT 10, il est nativement supporté. Gain supplémentaire de 40 %.
C'est un chemin en cinq étapes qu'un premier échec pouvait faire abandonner. La leçon : lire le message, choisir la stratégie la plus légère qui répond, vérifier numériquement, mesurer. Répéter.
En résumé
- Trois classes d'échecs — à l'export, au checker, à la session — appellent des stratégies distinctes : ne pas les confondre.
- Quatre stratégies de contournement, par ordre de coût croissant : monter en opset, réécrire en primitives, décomposer et laisser ONNX Runtime remonter le motif, écrire un opérateur personnalisé.
- Un opérateur personnalisé casse la portabilité ; c'est l'ultime recours, réservé aux cas où le modèle ne quittera jamais son runtime cible.
- Chaque contournement doit être documenté à côté du fichier
.onnx: ce qu'on a changé, pourquoi, l'écart numérique et le gain de performance obtenus.
Le module suivant range tout : la session partagée, le prétraitement identique à l'entraînement, FastAPI minimal, ONNX Runtime Web en aperçu — bref, la mise en service qui tient sur un dépôt réel.