Introduction : La quête d’une inférence plus rapide
Dans l’espace en évolution rapide de l’intelligence artificielle, former des modèles n’est que la moitié de la bataille. La véritable mesure de l’utilité d’un modèle réside souvent dans sa capacité à effectuer des inférences — faire des prédictions ou générer des sorties — rapidement et efficacement. Pour de nombreuses applications dans le monde réel, allant de la détection d’objets en temps réel aux réponses de grands modèles de langage, la vitesse d’inférence est primordiale. Bien que l’inférence basée sur le CPU ait sa place, la puissance de traitement parallèle des unités de traitement graphique (GPU) fait d’elles les champions incontestés de l’inférence AI à haut débit et à faible latence.
Ce tutoriel vous guidera à travers des stratégies et des techniques pratiques pour optimiser l’utilisation des GPU pendant l’inférence. Nous irons au-delà des concepts théoriques et explorerons des étapes concrètes, accompagnées d’exemples de code, pour vous aider à tirer le meilleur parti des performances de votre matériel. À la fin, vous aurez une compréhension solide de la façon d’identifier les goulets d’étranglement et de mettre en œuvre des optimisations efficaces pour vos charges de travail d’inférence en apprentissage profond.
Comprendre les goulets d’étranglement de l’inférence GPU
Avant d’optimiser, il est crucial de comprendre ce qui ralentit potentiellement votre inférence. L’inférence GPU n’est pas toujours limitée par le calcul ; souvent, d’autres facteurs jouent le rôle de goulets d’étranglement. Les coupables courants incluent :
- Transfert de données (Hôte vers appareil/Appareil vers hôte) : Déplacer des données entre la mémoire du CPU (hôte) et la mémoire du GPU (appareil) est lent. Minimise cela.
- Taille de lot petite : Les GPU prospèrent grâce au parallélisme. Des tailles de lot très petites peuvent ne pas pleinement utiliser les unités de calcul du GPU.
- Surcoût de lancement de noyau : Chaque fois qu’un noyau GPU (un petit programme exécuté sur le GPU) est lancé, il y a un léger surcoût. De nombreuses petites opérations séquentielles peuvent accumuler un surcoût significatif.
- Modèles d’accès mémoire : Un accès mémoire inefficace (par exemple, des lectures non contiguës) peut entraîner des échecs de cache et des performances plus lentes.
- Unités de calcul sous-utilisées : L’architecture du modèle ou la stratégie d’inférence peut ne pas engager pleinement la puissance de traitement du GPU.
- Formes dynamiques/Contrôle de flux : Les opérations qui empêchent la compilation de graphes statiques (par exemple, des branches if-else basées sur les données d’entrée) peuvent entraver l’optimisation.
- Surcoût du cadre : Le cadre d’apprentissage profond lui-même peut introduire des surcoûts.
Stratégies d’optimisation pratiques
1. Quantification du modèle : Réduire votre empreinte et augmenter la vitesse
La quantification est le processus de réduction de la précision des nombres utilisés pour représenter les poids et les activations d’un modèle, généralement de 32 bits en virgule flottante (FP32) à des formats de plus faible précision comme 16 bits en virgule flottante (FP16 ou BFloat16) ou 8 bits entiers (INT8). Cela présente plusieurs avantages :
- Réduction de l’empreinte mémoire : Des modèles plus petits nécessitent moins de mémoire, permettant des tailles de lot plus grandes ou un déploiement sur des appareils à ressources limitées.
- Calcul plus rapide : Les opérations arithmétiques à plus faible précision sont généralement plus rapides et consomment moins d’énergie. Les GPU modernes ont souvent du matériel spécialisé (par exemple, Tensor Cores) pour les opérations FP16 et INT8.
- Réduction du transfert de données : Moins de données doivent être transférées.
Exemple : Quantification avec PyTorch (FP16)
La plupart des GPU modernes supportent le FP16 (précision demi). PyTorch facilite la conversion de votre modèle.
import torch
import torch.nn as nn
# Supposons que 'model' soit votre modèle PyTorch entraîné (par exemple, un ResNet)
model = nn.Sequential(
nn.Linear(784, 128),
nn.ReLU(),
nn.Linear(128, 10)
)
model.eval() # Met le modèle en mode évaluation
# Déplacer le modèle vers le GPU
device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
model = model.to(device)
# Option 1 : Précision mixte automatique (AMP) pour l'inférence
# Cela est généralement recommandé car cela gère le transfert de type uniquement là où c'est bénéfique
from torch.cuda.amp import autocast
# Exemple de boucle d'inférence avec AMP
input_data = torch.randn(64, 784).to(device)
with autocast():
output = model(input_data)
print(f"Type de sortie d'inférence AMP : {output.dtype}")
# Option 2 : Convertir explicitement l'ensemble du modèle en FP16 (moins courant pour l'inférence)
# model_fp16 = model.half() # Convertit tous les paramètres et buffers en FP16
# input_data_fp16 = input_data.half()
# output_fp16 = model_fp16(input_data_fp16)
# print(f"Type de sortie d'inférence FP16 explicite : {output_fp16.dtype}")
# Pour la quantification INT8, vous utiliseriez généralement les outils de quantification natifs de PyTorch
# ou exporter vers un runtime comme ONNX Runtime/TensorRT qui gère ça.
2. Optimisation de la taille de lot : Trouver le bon compromis
Les GPU atteignent un haut débit en traitant de nombreux points de données en parallèle. Augmenter la taille du lot permet au GPU d’effectuer plus de calculs en même temps, conduisant souvent à une meilleure utilisation et à un temps d’inférence global plus rapide, jusqu’à un certain point. Cependant, une taille de lot trop grande peut entraîner des erreurs de mémoire ou des rendements décroissants si la bande passante ou les unités de calcul de la mémoire du GPU deviennent saturées.
Stratégie : Réglage de la taille du lot
Faites des expériences avec différentes tailles de lot. Commencez par une petite taille de lot (par exemple, 1, 4, 8) et augmentez-la progressivement jusqu’à observer des rendements décroissants en termes de vitesse d’inférence ou atteindre des limites de mémoire. Profilez votre modèle pour comprendre comment la taille du lot impacte l’utilisation du GPU.
import time
# ... (configuration du modèle et de l'appareil comme ci-dessus)
batch_sizes = [1, 16, 32, 64, 128, 256]
times = []
print("\nBenchmarking de différentes tailles de lot :")
for bs in batch_sizes:
input_data = torch.randn(bs, 784).to(device)
# Exécution d'échauffement
with autocast():
_ = model(input_data)
torch.cuda.synchronize() # Attendre que le GPU termine
start_time = time.time()
num_runs = 100
for _ in range(num_runs):
with autocast():
_ = model(input_data)
torch.cuda.synchronize()
end_time = time.time()
avg_time_per_batch = (end_time - start_time) / num_runs
times.append(avg_time_per_batch)
print(f"Taille de lot : {bs}, Temps moyen par lot : {avg_time_per_batch:.4f}s")
# Tracer ou analyser la liste 'times' montrerait la taille de lot optimale.
3. Compilation de graphes et compilateurs JIT (Just-In-Time)
Les cadres d’apprentissage profond comme PyTorch et TensorFlow exécutent généralement les modèles de manière interprétative (mode impatient). Bien que flexible, cela peut introduire des surcoûts liés à Python et empêcher des optimisations globales qu’un compilateur pourrait effectuer. La compilation de graphes convertit votre modèle en un graphique de calcul statique, qui peut ensuite être optimisé et compilé en code machine hautement efficace.
Exemple : TorchScript avec PyTorch
TorchScript est un moyen de créer des modèles sérialisables et optimisables à partir de code PyTorch. Il peut tracer un module existant ou le convertir via la rédaction de scripts.
# ... (configuration du modèle et de l'appareil)
# Option 1 : Traçage (pour les modèles avec un flux de contrôle statique)
# Fournir une entrée factice pour tracer les opérations
example_input = torch.randn(1, 784).to(device)
traced_model = torch.jit.trace(model, example_input)
print("\nType de modèle tracé :", type(traced_model))
# Inférence avec le modèle tracé
start_time = time.time()
num_runs = 100
for _ in range(num_runs):
with autocast():
_ = traced_model(example_input)
torch.cuda.synchronize()
end_time = time.time()
print(f"Temps d'inférence du modèle tracé (par exécution) : {(end_time - start_time)/num_runs:.6f}s")
# Option 2 : Rédaction de scripts (pour les modèles avec un flux de contrôle dynamique, mais nécessite une syntaxe spécifique)
# @torch.jit.script
# def my_scripted_function(x):
# if x.mean() > 0:
# return x * 2
# else:
# return x / 2
# scripted_output = my_scripted_function(torch.randn(10, 10).to(device))
Torch.compile (PyTorch 2.0+)
PyTorch 2.0 a introduit torch.compile, un puissant compilateur JIT qui utilise des technologies comme TorchInductor pour accélérer considérablement les modèles sans nécessiter de conversion manuelle en TorchScript. C’est souvent l’optimisation au niveau graphique la plus simple et la plus efficace.
# ... (configuration du modèle et de l'appareil)
# Compiler le modèle
compiled_model = torch.compile(model)
# Inférence avec le modèle compilé
example_input = torch.randn(64, 784).to(device) # Utilisez une taille de lot plus grande pour un meilleur effet
# Exécution d'échauffement pour la compilation
with autocast():
_ = compiled_model(example_input)
torch.cuda.synchronize()
start_time = time.time()
num_runs = 100
for _ in range(num_runs):
with autocast():
_ = compiled_model(example_input)
torch.cuda.synchronize()
end_time = time.time()
print(f"\nTemps d'inférence avec Torch.compile (par exécution) : {(end_time - start_time)/num_runs:.6f}s")
4. Environnements d’inférence dédiés : Au-delà des cadres
Pour des performances maximales et une flexibilité de déploiement, envisagez des environnements d’inférence dédiés. Ces environnements sont optimisés pour les environnements de production et incluent souvent des optimisations avancées de graphes, la fusion de noyaux et le support de divers accélérateurs matériels.
- NVIDIA TensorRT : Un optimiseur d’inférence d’apprentissage profond haute performance et un environnement d’exécution de NVIDIA. Il prend un réseau formé, l’optimise (par exemple, quantification, fusion de couches, réglage automatique de noyaux) et produit un moteur d’exécution optimisé. Il est spécifiquement conçu pour les GPU NVIDIA.
- ONNX Runtime : Supporte les modèles au format Open Neural Network Exchange (ONNX). Il fournit un moteur d’inférence unifié sur divers matériels et systèmes d’exploitation, avec des backends pour CPU, GPU (CUDA, ROCm, DirectML) et des accélérateurs AI spécialisés.
Stratégie : Exporter vers ONNX et inférence avec ONNX Runtime
Exporter votre modèle PyTorch vers ONNX est une première étape courante pour utiliser des environnements comme ONNX Runtime ou TensorRT.
import onnx
import onnxruntime as ort
# ... (configuration du modèle)
# Exporter le modèle PyTorch vers ONNX
onnx_path = "model.onnx"
example_input = torch.randn(1, 784).to(device)
torch.onnx.export(
model.cpu(), # L'exportation ONNX se fait généralement d'abord sur le CPU
example_input.cpu(),
onnx_path,
input_names=["input"],
output_names=["output"],
dynamic_axes={
"input": {0: "batch_size"}, # Permet la taille de lot dynamique
"output": {0: "batch_size"}
},
opset_version=14
)
print(f"Modèle exporté vers {onnx_path}")
# Vérifier le modèle ONNX
onnx_model = onnx.load(onnx_path)
onnx.checker.check_model(onnx_model)
print("Modèle ONNX vérifié avec succès.")
# Inférence avec ONNX Runtime
# Créer une session d'inférence
sess_options = ort.SessionOptions()
# Optionnel : Définir le niveau d'optimisation du graphe pour les meilleures performances
sess_options.graph_optimization_level = ort.GraphOptimizationLevel.ORT_ENABLE_ALL
# Utiliser le fournisseur CUDA pour l'inférence GPU
providers = ['CUDAExecutionProvider', 'CPUExecutionProvider']
ort_session = ort.InferenceSession(onnx_path, sess_options=sess_options, providers=providers)
# Préparer l'entrée pour ONNX Runtime
input_name = ort_session.get_inputs()[0].name
output_name = ort_session.get_outputs()[0].name
# Exemple d'inférence avec une taille de lot de 64
input_data_np = torch.randn(64, 784).cpu().numpy().astype(import numpy as np; np.float32)
start_time = time.time()
num_runs = 100
for _ in range(num_runs):
ort_outputs = ort_session.run([output_name], {input_name: input_data_np})
end_time = time.time()
print(f"\nTemps d'inférence ONNX Runtime (par exécution) : {(end_time - start_time)/num_runs:.6f}s")
5. Exécution Asynchrone et Pipelining
Les opérations GPU sont asynchrones. Le CPU lance un noyau et passe immédiatement à autre chose, tandis que le GPU l’exécute en arrière-plan. Comprendre cela est essentiel pour un pipelining efficace.
Stratégie : Superposer le Transfert de Données et le Calcul
Au lieu d’attendre qu’un lot soit entièrement terminé avant de traiter le suivant, vous pouvez superposer le chargement des données pour le lot suivant avec le calcul du lot actuel. Le DataLoader de PyTorch avec num_workers > 0 et pin_memory=True aide à transférer les données vers la mémoire épinglée, qui est plus rapide pour l’accès GPU.
import torchvision.datasets as datasets
import torchvision.transforms as transforms
from torch.utils.data import DataLoader
# Jeu de données dummy et DataLoader
transform = transforms.Compose([
transforms.ToTensor(),
transforms.Normalize((0.5,), (0.5,))
])
dataset = datasets.MNIST(root='./data', train=False, download=True, transform=transform)
# Important : pin_memory=True pour des transferts plus rapides du hôte au périphérique
dataloader = DataLoader(dataset, batch_size=64, shuffle=False, num_workers=4, pin_memory=True)
# ... (configuration du modèle et du périphérique, par exemple, en utilisant torch.compile ou traced_model)
compiled_model = torch.compile(model)
# Boucle d'inférence avec chargement asynchrone des données
start_time = time.time()
for i, (images, labels) in enumerate(dataloader):
images = images.view(images.shape[0], -1).to(device, non_blocking=True) # non_blocking=True est crucial
with autocast():
outputs = compiled_model(images)
# Si vous avez besoin d'utiliser les sorties sur le CPU, ajoutez un point de synchronisation
# Par exemple, pour calculer des métriques après un certain nombre de lots
# if (i+1) % 100 == 0:
# torch.cuda.synchronize()
# # Traitez les sorties ici
torch.cuda.synchronize() # Assurez-vous que toutes les opérations GPU sont terminées avant la fin du chronométrage
end_time = time.time()
print(f"\nTemps d'Inference Asynchrone pour {len(dataloader.dataset)} échantillons : {end_time - start_time:.4f}s")
6. Gestion de la Mémoire et Allocation
Une utilisation efficace de la mémoire est critique. Les erreurs de mémoire épuisée interrompent l’inférence, et des réallocations fréquentes peuvent introduire des surcoûts.
Stratégie : Vider le Cache et Utiliser des Gestionnaires de Contexte
Videz périodiquement le cache de mémoire GPU, surtout si vous chargez/déchargez des modèles ou traitez des tailles d’entrée très différentes.
import gc
# ... quelques tâches d'inférence ...
del model # Supprimez le modèle s'il n'est plus nécessaire
gc.collect()
torch.cuda.empty_cache() # Vide le cache de mémoire GPU de PyTorch
print("Cache GPU vidé.")
Stratégie : Pré-alloquer des Tenseurs (pour des entrées de taille fixe)
Si la taille de votre tenseur d’entrée est fixe, pré-allouez les tenseurs d’entrée et de sortie sur le GPU pour éviter des allocations répétées.
# ... (configuration du modèle et du périphérique)
# Pré-allouer les tenseurs d'entrée et de sortie
fixed_batch_size = 64
fixed_input_shape = (fixed_batch_size, 784)
pre_allocated_input = torch.empty(fixed_input_shape, dtype=torch.float32, device=device)
# Exécution dummy pour obtenir la taille de sortie
with autocast():
dummy_output = model(pre_allocated_input)
pre_allocated_output = torch.empty(dummy_output.shape, dtype=dummy_output.dtype, device=device)
# Maintenant, dans votre boucle d'inférence, copiez les données dans pre_allocated_input
# et utilisez pre_allocated_output pour stocker les résultats
# Exemple : (en supposant que vous avez le tableau numpy 'new_batch_data')
# pre_allocated_input.copy_(torch.from_numpy(new_batch_data))
# with autocast():
# model(pre_allocated_input, out=pre_allocated_output) # Certains modèles/opérations prennent en charge l'argument 'out'
Profilage et Débogage des Performances
L’optimisation est un processus itératif. Vous avez besoin d’outils pour identifier où votre temps est passé.
- PyTorch Profiler : Utilisez
torch.profilerpour obtenir des rapports détaillés sur les opérations CPU et GPU, les temps de lancement de noyaux, l’utilisation de la mémoire et le transfert de données. - NVIDIA Nsight Systems / Nsight Compute : Outils autonomes puissants pour un profilage approfondi des GPU, montrant les chronologies d’exécution des noyaux, la bande passante mémoire et l’utilisation du calcul.
- Le module
timede Python : Simple mais efficace pour le chronométrage de blocs de code à un niveau élevé.
Exemple : PyTorch Profiler
from torch.profiler import profile, schedule, tensorboard_trace_handler, ProfilerActivity
# ... (configuration du modèle et du périphérique)
with profile(
schedule=schedule(wait=1, warmup=1, active=3, repeat=1),
on_trace_ready=tensorboard_trace_handler("./log/profiler_inference"),
activities=[ProfilerActivity.CPU, ProfilerActivity.CUDA],
record_shapes=True,
with_stack=True
) as prof:
for step in range(1 + 1 + 3 + 1): # wait, warmup, active, repeat_delay
input_data = torch.randn(64, 784).to(device)
with autocast():
_ = model(input_data)
prof.step()
print("\nRésultats du profiler sauvegardés dans ./log/profiler_inference. Voir avec 'tensorboard --logdir=./log'")
Conclusion
Optimiser l’inférence GPU est un défi multifacette, mais en appliquant systématiquement les stratégies décrites dans ce tutoriel, vous pouvez obtenir des gains de vitesse significatifs. Commencez par la quantification, expérimentez avec les tailles de lot, utilisez des compilateurs de graphes comme torch.compile, et envisagez des environnements d’exécution dédiés comme ONNX Runtime ou TensorRT pour des déploiements en production. N’oubliez jamais de profiler votre code pour identifier les vrais goulets d’étranglement, car une optimisation prématurée peut être contre-productive. Avec ces outils et techniques, vous êtes bien équipé pour libérer le plein potentiel de vos GPU pour une inférence IA ultra-rapide.
🕒 Published: