Aller au contenu principal

Module 7 — Fournisseurs d'exécution : CPU, GPU, TensorRT

Un modèle ONNX ne « tourne » pas en soi. Il est exécuté par un fournisseur d'exécution (ExecutionProvider, ou EP dans la documentation) qui traduit chaque nœud en appels au matériel. ONNX Runtime en propose une douzaine, dont trois sont incontournables : le fournisseur CPU par défaut, le fournisseur CUDA pour les GPU NVIDIA, et le fournisseur TensorRT pour les accélérateurs NVIDIA quand la latence prime. Ce module explique comment les choisir, les ordonner, et détecter le piège le plus coûteux : le repli silencieux sur CPU.

L'architecture en couches

Une session ONNX Runtime charge un graphe et le partitionne entre les fournisseurs disponibles. Chaque fournisseur déclare la liste des opérateurs qu'il peut exécuter ; ONNX Runtime attribue chaque nœud au premier fournisseur de la liste qui l'accepte. Un nœud non pris en charge par le premier fournisseur passe au suivant, et ainsi de suite jusqu'au fournisseur CPU, qui accepte tout (c'est la garantie).

Ce partitionnement crée parfois plusieurs sous-graphes exécutés sur des fournisseurs différents, avec des copies mémoire entre eux. Un ResNet18 tourne entièrement sur CUDA, sans copie ; un modèle exotique avec des opérateurs peu courants peut passer 80 % du temps à copier des tenseurs entre CPU et GPU. La performance dépend donc autant du taux de couverture que de la vitesse brute du fournisseur.

Le fournisseur CPU par défaut

Le fournisseur CPUExecutionProvider est toujours présent, quelle que soit l'installation d'ONNX Runtime. Il exploite :

  • BLAS (via oneMKL sur x86, OpenBLAS ou Accelerate ailleurs) pour les grandes matrices.
  • Les instructions vectorielles AVX2, AVX-512 et AVX-512 VNNI si disponibles.
  • Un pool de fils d'exécution pour paralléliser les opérateurs indépendants.
import onnxruntime as ort

options = ort.SessionOptions()
options.intra_op_num_threads = 4 # fils intra-opérateur
options.inter_op_num_threads = 1 # fils inter-opérateur
options.execution_mode = ort.ExecutionMode.ORT_SEQUENTIAL

session = ort.InferenceSession(
"resnet18_fashion.onnx",
sess_options=options,
providers=["CPUExecutionProvider"],
)

Deux paramètres à connaître. intra_op_num_threads contrôle la parallélisation à l'intérieur d'un opérateur (par exemple les batchs de convolution). inter_op_num_threads contrôle la parallélisation entre opérateurs indépendants du graphe, active uniquement en mode ORT_PARALLEL. Sur un serveur qui sert plusieurs requêtes en parallèle, il vaut mieux baisser intra_op_num_threads (2 ou 4) et laisser plusieurs sessions concurrentes, plutôt qu'une seule session avec beaucoup de fils.

Le fournisseur CUDA

CUDAExecutionProvider exécute le graphe sur un GPU NVIDIA, via cuBLAS, cuDNN et des kernels internes d'ONNX Runtime. C'est le chemin le plus direct pour accélérer un modèle sur GPU sans réécrire quoi que ce soit.

providers = [
("CUDAExecutionProvider", {
"device_id": 0,
"arena_extend_strategy": "kSameAsRequested",
"gpu_mem_limit": 4 * 1024 * 1024 * 1024, # 4 Gio
"cudnn_conv_algo_search": "EXHAUSTIVE",
}),
"CPUExecutionProvider", # repli
]

session = ort.InferenceSession(
"resnet18_fashion.onnx",
providers=providers,
)
print("Fournisseurs actifs :", session.get_providers())

Trois options importantes.

device_id sélectionne le GPU sur une machine multi-GPU ; défaut 0.

gpu_mem_limit plafonne la mémoire allouée. Sans limite, ONNX Runtime peut retenir plusieurs gigaoctets pour son arène d'allocation, ce qui gêne les autres processus.

cudnn_conv_algo_search décide comment cuDNN choisit son algorithme de convolution : HEURISTIC (rapide, choix par heuristique) ou EXHAUSTIVE (essaie plusieurs et garde le plus rapide, coûte quelques secondes au premier appel). Pour un service qui tourne longtemps, EXHAUSTIVE est rentable.

L'ordre [CUDA, CPU] de la liste providers est le repli en cas d'échec d'un nœud sur GPU. Le fournisseur CPU est toujours présent en dernier, sinon un opérateur non couvert par CUDA ferait échouer la session entière.

Le fournisseur TensorRT

TensorrtExecutionProvider va plus loin : il compile des sous-graphes ONNX en moteurs TensorRT, optimisés pour un GPU NVIDIA précis. TensorRT fait la fusion d'opérateurs qu'ONNX Runtime ne fait pas, applique de la quantification INT8 ou FP16 automatique, et choisit les kernels optimaux pour l'architecture matérielle (Ampere, Ada Lovelace, Hopper). Les gains sont typiquement de 2× à 4× sur un modèle vision comparé à CUDA seul.

providers = [
("TensorrtExecutionProvider", {
"device_id": 0,
"trt_fp16_enable": True,
"trt_engine_cache_enable": True,
"trt_engine_cache_path": "./cache_tensorrt",
"trt_max_workspace_size": 2 * 1024 * 1024 * 1024, # 2 Gio
}),
"CUDAExecutionProvider",
"CPUExecutionProvider",
]

session = ort.InferenceSession(
"resnet18_fashion.onnx",
providers=providers,
)

Deux réalités à intégrer.

La compilation est lente. Au premier chargement, TensorRT construit un moteur pour chaque sous-graphe qu'il accepte, et cela peut prendre de 30 secondes à plusieurs minutes selon la taille du modèle. Sans cache, chaque redémarrage refait ce travail. trt_engine_cache_enable=True et trt_engine_cache_path sont indispensables en production ; le cache est propre à la version de TensorRT, du GPU et du modèle.

Le cache est spécifique. Un cache généré sur un GPU Ampere (RTX 30xx, A100) n'est pas réutilisable sur Ada Lovelace (RTX 40xx). Le nom du fichier de cache encode cette information. Le premier chargement sur une nouvelle machine sera lent, c'est normal.

L'ordre des fournisseurs est un contrat

L'ordre de la liste providers définit la priorité de partitionnement. Le premier fournisseur voit tout le graphe et prend les nœuds qu'il peut ; les suivants récupèrent le reste. Un ordre [TensorRT, CUDA, CPU] demande : « TensorRT d'abord, CUDA pour ce que TensorRT ne prend pas, CPU en dernier recours ».

Deux erreurs classiques à éviter.

  • Oublier CPU en dernier : la session refuse de se charger si un opérateur reste orphelin. Ce n'est pas un piège subtil, l'erreur est explicite ; mais mieux vaut toujours terminer par CPUExecutionProvider.
  • Ne pas vérifier get_providers() : la liste passée est une demande, pas une garantie. Si TensorrtExecutionProvider n'est pas disponible dans le paquet installé, ONNX Runtime le retire silencieusement. C'est le point le plus douloureux du module, développé ci-dessous.

Le repli silencieux sur CPU

Voici le piège qui coûte le plus cher en pratique.

Une équipe installe onnxruntime (sans suffixe) dans l'image Docker, écrit providers=["CUDAExecutionProvider", "CPUExecutionProvider"], lance le service, mesure une latence trois fois plus lente que prévu. La cause : le paquet onnxruntime standard est CPU seul. Le paquet onnxruntime-gpu est requis pour bénéficier de CUDA. La liste providers passée est acceptée, CUDAExecutionProvider est retiré silencieusement, et get_providers() renvoie ["CPUExecutionProvider"].

Le contrôle qui empêche cette dérive :

session = ort.InferenceSession(...)
demande = ["CUDAExecutionProvider", "CPUExecutionProvider"]
actifs = session.get_providers()

if "CUDAExecutionProvider" in demande and "CUDAExecutionProvider" not in actifs:
raise RuntimeError(
"CUDAExecutionProvider demandé mais non disponible. "
"Installer onnxruntime-gpu ou vérifier le pilote CUDA."
)

Cette vérification à trois lignes doit être présente dans tout service qui vise un fournisseur particulier. Sans elle, un incident d'installation devient invisible jusqu'à la mesure de latence en production.

Vérifier avant de mesurer

Toute mesure de latence commence par print(session.get_providers()). Un tableau qui affiche des gains « décevants » de CUDA sur CPU est souvent un tableau où CUDA ne tournait pas. La règle est absolue : on ne mesure pas ce qu'on n'a pas vérifié.

Choisir la combinaison

Une matrice décisionnelle, à la louche.

CibleCombinaison recommandée
Serveur CPU, latence modéréeCPU seul avec ORT_ENABLE_ALL et INT8 dynamique
Serveur CPU avec VNNI, latence critiqueCPU avec INT8 statique
Serveur GPU génériqueCUDA + CPU en repli
Serveur GPU, latence critique, modèle stableTensorRT + CUDA + CPU, avec cache activé
Bureau développeurCPU seul, pour éviter les surprises de pilote
Mobile iOSCore ML via CoreMLExecutionProvider
Mobile AndroidNNAPI via NnapiExecutionProvider

Le principe général : plus le fournisseur est spécialisé, plus il est rapide et plus il est fragile aux mises à jour du matériel et des pilotes. Un service critique justifie TensorRT ; un prototype qui change de modèle chaque semaine se contente de CUDA.

Fils d'exécution et sessions

Sur GPU, une session ONNX Runtime unique est suffisante pour saturer le matériel : les requêtes concurrentes se sérialisent naturellement sur les kernels CUDA. Sur CPU, une session par requête serait un gâchis mémoire ; le bon design est une session partagée, plusieurs fils appelants, intra_op_num_threads réglé sur le nombre de cœurs divisé par le nombre attendu de requêtes concurrentes.

C'est un point détaillé au module 10, mais il faut l'anticiper à la configuration du fournisseur.

En résumé

  • Un fournisseur d'exécution traduit chaque nœud en appels matériels ; ONNX Runtime partitionne le graphe entre les fournisseurs de la liste, dans l'ordre déclaré.
  • CPUExecutionProvider doit toujours être le dernier de la liste ; il garantit qu'aucun nœud ne reste orphelin.
  • TensorrtExecutionProvider compile des moteurs lents à construire ; le cache (trt_engine_cache_enable) est indispensable en production et reste spécifique au GPU cible.
  • Le repli silencieux sur CPU est le piège le plus fréquent ; session.get_providers() doit être vérifié après création de la session pour détecter un fournisseur absent.

Le module suivant met tout cela en mesures reproductibles : protocole, échauffement, percentiles et un tableau comparatif PyTorch contre ONNX Runtime CPU, CUDA et TensorRT sur les deux modèles du fil rouge.