Aller au contenu principal

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 pour ORT_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.

MoteurPrécisionp50p95p99Coût mémoire
PyTorch CPU eagerfp3292 ms105 ms118 ms380 Mio
PyTorch CUDAfp323,8 ms4,5 ms5,1 ms1,2 Gio
ONNX Runtime CPUfp3237 ms41 ms45 ms220 Mio
ONNX Runtime CPU INT8 dyn.int8/fp3222 ms25 ms28 ms90 Mio
ONNX Runtime CPU INT8 stat.int815 ms17 ms19 ms75 Mio
ONNX Runtime CUDAfp322,9 ms3,4 ms3,8 ms900 Mio
ONNX Runtime TensorRT fp16fp160,9 ms1,1 ms1,3 ms750 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 :

MoteurPrécisionp50p95p99
PyTorch CPU eagerfp3232 ms38 ms44 ms
ONNX Runtime CPUfp3218 ms21 ms25 ms
ONNX Runtime CPU INT8 dyn.int8/fp3212 ms14 ms16 ms
ONNX Runtime CUDAfp321,7 ms2,0 ms2,2 ms
ONNX Runtime TensorRT fp16fp160,7 ms0,9 ms1,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 IOBinding pour 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_test est 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 ; IOBinding et 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.