Module 8 — Mesure comparative des performances
Un « mon modèle est 3× plus rapide en ONNX Runtime » se dit vite et se prouve rarement. Un benchmark utile respecte un protocole strict et rapporte des chiffres qu'un tiers peut refaire à l'identique. Ce module fixe ce protocole, présente l'outil interne d'ONNX Runtime, et compare les six variantes obtenues dans les modules précédents sur les deux modèles du fil rouge.
Six règles pour ne pas se raconter d'histoires
Un protocole de mesure fiable respecte six règles.
Fixer les graines. NumPy, PyTorch, TensorFlow et ONNX Runtime doivent tous démarrer avec la même graine. Sans cela, une différence entre deux mesures peut refléter deux entrées différentes, pas deux moteurs différents.
Échauffer avant de mesurer. La première inférence charge des kernels cuDNN, alloue des tampons, compile un moteur TensorRT. Elle est 10× à 100× plus lente que la deuxième. Ignorer les 20 à 100 premières inférences ; c'est l'échauffement. Sans lui, la médiane est tirée par des valeurs qui n'ont rien à voir avec la latence de service.
Isoler la charge. Sur un poste avec un navigateur ouvert, une réunion visio active et un antivirus qui scrute, aucune mesure sérieuse n'est possible. Sur un serveur, s'assurer que rien d'autre ne s'exécute — pas d'entraînement en arrière-plan, pas de swap, pas de mise à jour de pilote en cours.
Mesurer par lots réalistes. Un service qui reçoit une requête à la fois se mesure en lot 1. Un pipeline batch se mesure en lot 32 ou 64. Un chiffre « 5 ms/image » en lot 128 n'a rien à voir avec un chiffre en lot 1 ; les deux sont utiles, mais pas comparables.
Rapporter des percentiles, pas la moyenne. La latence n'est pas gaussienne : elle a une queue longue. Rapporter la médiane (p50), le p95 et le p99 décrit ce qu'un client verra. La moyenne masque les pics.
Répéter plusieurs fois. Une seule série de 200 mesures peut être biaisée par un ralentissement système. Trois séries indépendantes montrent la variance, et permettent de rejeter une série aberrante.
Un script canonique
Un squelette réutilisable pour comparer plusieurs sessions sur les mêmes entrées.
import numpy as np
import time
import onnxruntime as ort
def mesurer(session, entrees, echauffement=30, mesures=300):
"""Renvoie médiane, p95 et p99 en millisecondes."""
for _ in range(echauffement):
session.run(None, entrees)
latences = []
for _ in range(mesures):
t0 = time.perf_counter()
session.run(None, entrees)
latences.append((time.perf_counter() - t0) * 1000)
latences.sort()
return {
"p50": latences[len(latences) // 2],
"p95": latences[int(len(latences) * 0.95)],
"p99": latences[int(len(latences) * 0.99)],
"min": latences[0],
"max": latences[-1],
}
def session_pour(chemin, providers):
options = ort.SessionOptions()
options.graph_optimization_level = ort.GraphOptimizationLevel.ORT_ENABLE_ALL
s = ort.InferenceSession(chemin, sess_options=options, providers=providers)
actifs = s.get_providers()
print("Fournisseurs actifs :", actifs)
return s
np.random.seed(42)
entree = np.random.randn(32, 3, 224, 224).astype(np.float32)
pour_mesure = [
("ONNX CPU", "resnet18_fashion.onnx", ["CPUExecutionProvider"]),
("ONNX CPU INT8", "resnet18_fashion_int8_dyn.onnx", ["CPUExecutionProvider"]),
("ONNX CUDA", "resnet18_fashion.onnx", ["CUDAExecutionProvider", "CPUExecutionProvider"]),
("ONNX TensorRT", "resnet18_fashion.onnx", ["TensorrtExecutionProvider", "CUDAExecutionProvider", "CPUExecutionProvider"]),
]
for etiquette, chemin, providers in pour_mesure:
session = session_pour(chemin, providers)
stats = mesurer(session, {"entree": entree})
print(f"{etiquette:16s} p50={stats['p50']:6.2f} ms p95={stats['p95']:6.2f} p99={stats['p99']:6.2f}")
Ce script comporte tout ce qui compte : échauffement, mesure en série, percentiles, vérification des fournisseurs, taille de lot fixée. Chaque ligne du tableau final est reproductible avec les mêmes fichiers .onnx et le même matériel.
L'outil interne : onnxruntime_perf_test
Le paquet onnxruntime fournit un binaire de benchmark, plus rigoureux que le script maison quand on veut mesurer sans écrire de code.
onnxruntime_perf_test \
-m times -r 500 -c 30 \
-e cuda \
-o 99 \
resnet18_fashion.onnx
Ses options importantes :
-m times: mesurer par nombre d'itérations (l'alternative,duration, mesure sur une durée).-r 500: 500 itérations effectives.-c 30: 30 itérations d'échauffement.-e cuda: fournisseur d'exécution (cpu,cuda,tensorrt).-o 99: niveau d'optimisation de graphe (99 pourORT_ENABLE_ALL).
L'outil imprime la moyenne, l'écart-type et les percentiles, avec la mémoire vive et la mémoire GPU utilisées. C'est le format qu'on cite dans un rapport ou une revue de code.
Tableau comparatif sur le ResNet18 Fashion-MNIST
Voici l'ordre de grandeur attendu sur un serveur x86 récent, avec GPU RTX 4090 pour les mesures GPU. Lot de 32 images 3×224×224.
| Moteur | Précision | p50 | p95 | p99 | Coût mémoire |
|---|---|---|---|---|---|
| PyTorch CPU eager | fp32 | 92 ms | 105 ms | 118 ms | 380 Mio |
| PyTorch CUDA | fp32 | 3,8 ms | 4,5 ms | 5,1 ms | 1,2 Gio |
| ONNX Runtime CPU | fp32 | 37 ms | 41 ms | 45 ms | 220 Mio |
| ONNX Runtime CPU INT8 dyn. | int8/fp32 | 22 ms | 25 ms | 28 ms | 90 Mio |
| ONNX Runtime CPU INT8 stat. | int8 | 15 ms | 17 ms | 19 ms | 75 Mio |
| ONNX Runtime CUDA | fp32 | 2,9 ms | 3,4 ms | 3,8 ms | 900 Mio |
| ONNX Runtime TensorRT fp16 | fp16 | 0,9 ms | 1,1 ms | 1,3 ms | 750 Mio |
Ce tableau ne prétend pas être universel. Les ratios varient selon le processeur, la génération du GPU et la charge concurrente. Mais l'ordre relatif est robuste : TensorRT FP16 > ONNX CUDA > ONNX CPU INT8 > ONNX CPU FP32 > PyTorch CPU, avec les gains les plus spectaculaires sur GPU.
Tableau comparatif sur l'encodeur de texte
Le même exercice sur le petit encodeur de texte, lot de 16 séquences de longueur 128 :
| Moteur | Précision | p50 | p95 | p99 |
|---|---|---|---|---|
| PyTorch CPU eager | fp32 | 32 ms | 38 ms | 44 ms |
| ONNX Runtime CPU | fp32 | 18 ms | 21 ms | 25 ms |
| ONNX Runtime CPU INT8 dyn. | int8/fp32 | 12 ms | 14 ms | 16 ms |
| ONNX Runtime CUDA | fp32 | 1,7 ms | 2,0 ms | 2,2 ms |
| ONNX Runtime TensorRT fp16 | fp16 | 0,7 ms | 0,9 ms | 1,0 ms |
L'encodeur de texte gagne moins que le ResNet sur INT8 statique — l'attention est plus sensible et le module 6 le mentionne — mais gagne autant sur TensorRT FP16, parce que les GEMM du transformeur tirent excellent parti des Tensor Cores.
Interpréter les percentiles
Le rapport p95 / p50 mesure la queue de la distribution. Un p95 à 1,1× la médiane est très serré : les latences sont concentrées. Un p95 à 2× signale des ralentissements sporadiques : ramassage de miettes, contention mémoire, kernel qui se recompile. Ce ratio est parfois plus important que la médiane elle-même : un service qui garantit un p99 sous 100 ms ne peut pas se contenter d'une médiane à 40 ms si le p99 dépasse 200 ms.
Sur GPU, la variance est souvent supérieure à celle sur CPU parce que les allocations d'arène et le partage entre requêtes ajoutent du bruit. C'est pour cela qu'on mesure p99 même sur GPU.
Le piège du lot 1
Sur GPU, un lot de 1 image sous-utilise le matériel. Le noyau CUDA a un coût de lancement — environ 10 microsecondes — qui devient dominant quand le calcul lui-même dure moins de 100 microsecondes. Un ResNet18 en lot 1 sur GPU a souvent une latence de 1,5 ms, non parce que le calcul le demande, mais parce que le lancement des kernels s'accumule.
Deux voies pour améliorer.
- Regrouper les requêtes côté service : accumuler 8 à 16 requêtes sur une fenêtre de quelques millisecondes et les servir en un lot. Utile quand la latence acceptable est de l'ordre de 10 ms.
- Utiliser
IOBindingpour pré-allouer les tampons GPU et éviter les copies mémoire à chaque appel. Réduit la latence en lot 1 de 30 % à 50 % sur les modèles courts.
io_binding = session.io_binding()
io_binding.bind_input(
name="entree",
device_type="cuda",
device_id=0,
element_type=np.float32,
shape=(1, 3, 224, 224),
buffer_ptr=tampon_gpu_ptr,
)
io_binding.bind_output(name="logits", device_type="cuda")
session.run_with_iobinding(io_binding)
IOBinding est un sujet à part entière ; on en retient qu'il existe et qu'il est le levier à activer dès que le lot 1 est le régime de service.
Rapporter une mesure honnête
Un tableau de benchmark digne de foi précise systématiquement :
- Le modèle exact (nom du fichier
.onnx, hachage SHA-256 idéalement). - La taille du lot et la forme d'entrée.
- Le matériel : CPU (marque, modèle, fréquence, jeux d'instructions VNNI ou pas), GPU (modèle, architecture, mémoire, version du pilote).
- La version d'ONNX Runtime et du fournisseur (par exemple TensorRT 10.4, CUDA 12.4).
- Le nombre d'itérations d'échauffement et de mesure.
- Les percentiles p50, p95, p99, et la variance sur trois séries.
Un chiffre sans ce contexte est un chiffre inutilisable — pire, il induit en erreur.
En résumé
- Un protocole de mesure fiable fixe les graines, échauffe, isole la charge, mesure par lots réalistes, rapporte des percentiles et répète les séries.
onnxruntime_perf_testest l'outil interne d'ONNX Runtime pour un benchmark reproductible sans écrire de code.- TensorRT FP16 > ONNX CUDA > ONNX CPU INT8 > ONNX CPU FP32 > PyTorch CPU est l'ordre relatif robuste, mais les ratios exacts dépendent fortement du matériel.
- Sur GPU en lot 1, le coût de lancement des kernels domine ;
IOBindinget le regroupement côté service sont les leviers pour ne pas laisser le GPU sous-utilisé.
Le module suivant traite le cas où la mesure est impossible parce que l'export lui-même a échoué : l'opérateur non pris en charge, comment le lire, et comment le contourner sans réentraîner le modèle.