Introduction : Le Rôle Crucial de l’Optimisation d’Inferred
Dans le domaine en évolution rapide de l’intelligence artificielle, l’entraînement des modèles capte souvent l’attention. Cependant, la véritable valeur d’un modèle entraîné se révèle lors de sa phase d’inférence—lorsqu’il fait des prédictions sur de nouvelles données non vues. Pour de nombreuses applications, allant des recommandations en temps réel à la conduite autonome, la rapidité et l’efficacité de ce processus d’inférence sont primordiales. Une inférence lente peut conduire à de mauvaises expériences utilisateur, à une augmentation des coûts opérationnels, et même à des pannes critiques du système. Ce guide avancé examine les aspects pratiques de l’optimisation GPU pour l’inférence, allant au-delà du simple traitement par lots pour explorer des techniques sophistiquées et fournir des exemples concrets afin de maximiser le débit et de minimiser la latence.
Comprendre le Flux de Travail d’Inferred sur GPU
Avant d’optimiser, il est essentiel de comprendre le flux de travail typique lors de l’inférence sur un GPU :
- Transfert de Données (Hôte vers Appareil) : Les données d’entrée sont transférées de la mémoire CPU (hôte) vers la mémoire GPU (appareil).
- Exécution du Noyau : Le GPU effectue des calculs (noyaux) tels que définis par les couches du modèle.
- Transfert de Données (Appareil vers Hôte) : Les données de sortie sont renvoyées de la mémoire GPU vers la mémoire CPU.
Chacune de ces étapes présente des opportunités d’optimisation. Bien que l’étape computationnelle soit souvent le goulot d’étranglement, le coût du transfert de données peut être significatif, notamment pour les petits modèles ou les scénarios à fort débit.
Au-delà du Traitement de Base par Lots : Stratégies Avancées de Débit
Traitement Dynamique par Lots et Pipelining
Le traitement par lots statique—regrouper plusieurs demandes d’inférence en un seul tenseur plus grand—est fondamental pour l’utilisation des GPU. Cependant, les demandes du monde réel arrivent souvent de manière asynchrone et avec des latences variées. Le traitement dynamique par lots répond à cela en collectant les demandes entrantes sur une courte période et en formant un lot à la volée. Cela nécessite un mécanisme de mise en file d’attente solide et une gestion soigneuse des tailles de lot pour équilibrer le débit et la latence.
Le pipelining étend ce concept en chevauchant différentes étapes du processus d’inférence. Par exemple, alors qu’un lot est en cours de calcul sur le GPU, le lot suivant peut être transféré de l’hôte vers l’appareil, et les résultats du lot précédent peuvent être renvoyés à l’hôte. Cela masque efficacement la latence liée au transfert de données.
Exemple Pratique : Traitement Dynamique par Lots avec NVIDIA Triton Inference Server
NVIDIA Triton Inference Server est un excellent exemple d’un système conçu pour des inférences performantes, offrant un support intégré pour le traitement dynamique par lots et le pipelining. Regardons un extrait d’un config.pbtxt de Triton pour un modèle :
model_configuration {
backend: "pytorch"
max_batch_size: 128
dynamic_batching {
preferred_batch_size: [8, 16, 32]
max_queue_delay_microseconds: 100000 # 100ms
preserve_ordering: true
}
instance_group [
{
count: 1
kind: KIND_GPU
gpus: [0]
}
]
input [
{
name: "input__0"
data_type: TYPE_FP32
dims: [-1, 224, 224, 3]
}
]
output [
{
name: "output__0"
data_type: TYPE_FP32
dims: [-1, 1000]
}
]
}
Ici, max_batch_size fixe la limite supérieure. preferred_batch_size guide Triton pour privilégier ces tailles pour l’efficacité. max_queue_delay_microseconds détermine combien de temps Triton attendra d’autres demandes avant de traiter un lot potentiellement plus petit. preserve_ordering: true garantit que les résultats sont renvoyés dans l’ordre où les demandes ont été reçues, ce qui est crucial pour de nombreuses applications.
Exécution Concurrente de Modèles (Service Multi-Modèle)
Les GPU modernes sont suffisamment puissants pour exécuter plusieurs flux d’inférence ou même plusieurs modèles distincts simultanément. Cela est particulièrement utile lorsqu’il s’agit de servir un ensemble divers de modèles ou lorsque qu’un unique grand modèle peut être partitionné et exécuté en parallèle.
Service multi-instance : Exécution de plusieurs instances du même modèle sur différents flux GPU ou même différents GPU si disponible. Cela augmente le débit global en parallélisant le travail.
Service multi-modèle : Déploiement de différents modèles sur le même GPU en même temps. Cela peut être complexe, nécessitant une gestion minutieuse de la mémoire et une synchronisation des flux pour éviter les conflits.
Exemple Pratique : Instances de Modèle Concurrentes avec PyTorch et CUDA Streams
Dans PyTorch, les flux CUDA permettent l’exécution asynchrone d’opérations. En utilisant plusieurs flux, vous pouvez chevaucher les calculs et les transferts de données, ou même exécuter différentes instances de modèles en parallèle.
import torch
import time
# Supposons que model1 et model2 soient préchargés sur le GPU
# model1 = MyModel1().cuda()
# model2 = MyModel2().cuda()
# Créer deux flux CUDA
stream1 = torch.cuda.Stream()
stream2 = torch.cuda.Stream()
def infer_on_stream(model, input_data, stream):
with torch.cuda.stream(stream):
# Transférer les données vers le GPU dans ce flux
input_gpu = input_data.to('cuda')
# Effectuer l'inférence
output = model(input_gpu)
# En option, transférer la sortie en retour dans ce flux (si besoin immédiat)
# output_cpu = output.to('cpu')
return output
# Générer des entrées fictives
input1 = torch.randn(1, 3, 224, 224)
input2 = torch.randn(1, 3, 224, 224)
start_time = time.time()
# Lancer l'inférence sur des flux séparés
output1_future = infer_on_stream(model1, input1, stream1)
output2_future = infer_on_stream(model2, input2, stream2)
# Attendre que les deux flux soient terminés
stream1.synchronize()
stream2.synchronize()
end_time = time.time()
print(f"Temps d'inférence concurrent : {end_time - start_time:.4f} secondes")
# Pour comparaison, inférence séquentielle
start_time_seq = time.time()
_ = infer_on_stream(model1, input1, stream1)
stream1.synchronize()
_ = infer_on_stream(model2, input2, stream1)
stream1.synchronize()
end_time_seq = time.time()
print(f"Temps d'inférence séquentielle : {end_time_seq - start_time_seq:.4f} secondes")
Ce exemple illustre le principe. Dans un scénario réel, model1 et model2 seraient des modèles différents ou différentes instances du même modèle, et les données d’entrée seraient de vraies demandes.
Optimisation de Précision : Au-delà de FP32
La précision des points flottants impacte considérablement les performances et l’empreinte mémoire. Bien que la plupart des modèles soient entraînés en FP32 (précision simple), l’inférence tolère souvent une précision inférieure sans chute significative de la précision.
FP16 (Précision À Moitié)
FP16 offre le double de la bande passante mémoire et des calculs potentiellement plus rapides sur les GPU avec des Tensor Cores (par exemple, architectures NVIDIA Volta, Turing, Ampere, Hopper). C’est une optimisation courante et très efficace.
INT8 (Quantification Entière)
La quantification INT8 convertit les poids et les activations du modèle de points flottants en entiers sur 8 bits. Cela peut permettre jusqu’à 4x d’économies de mémoire et des accélérations significatives, en particulier sur le matériel optimisé pour INT8 (par exemple, Tensor Cores). Cependant, elle nécessite un étalonnage minutieux et peut parfois entraîner une dégradation de la précision si elle n’est pas gérée correctement.
Exemple Pratique : Quantification avec ONNX Runtime et TensorRT
ONNX Runtime prend en charge diverses techniques de quantification. Voici un exemple conceptuel de quantification statique après l’entraînement :
from onnxruntime.quantization import quantize_static, QuantFormat, QuantType
from onnxruntime.quantization.calibrate import create_calibrator, CalibrationMethod
# 1. Exporter le modèle vers ONNX (si ce n'est pas déjà fait)
# torch.onnx.export(model, dummy_input, "model.onnx", ...)
# 2. Créer un lecteur de données pour l'étalonnage (sous-ensemble de vos données d'inférence)
class MyDataReader(onnxruntime.quantization.CalibrationDataReader):
def __init__(self, data):
self.enum_data = iter(data)
def get_next(self):
return next(self.enum_data, None)
# Supposons que 'calibration_data' soit une liste de tenseurs d'entrée
calib_reader = MyDataReader(calibration_data)
# 3. Quantifier le modèle
quantize_static(
'model.onnx', # Modèle ONNX d'entrée
'model_quantized.onnx', # Modèle ONNX de sortie
calib_reader, # Lecteur de données d'étalonnage
quant_format=QuantFormat.QOperator, # Quantifier les opérateurs
per_channel=True, # Quantification par canal pour les poids
weight_type=QuantType.QInt8, # Quantifier les poids en INT8
activation_type=QuantType.QInt8 # Quantifier les activations en INT8
)
print("Modèle quantifié enregistré sous model_quantized.onnx")
NVIDIA TensorRT est un puissant SDK pour une inférence de deep learning haute performance. Il effectue automatiquement des optimisations de graphes, la fusion de couches et la réduction de la précision (FP16, INT8). Pour INT8, TensorRT nécessite une étape de calibration similaire à ONNX Runtime.
Optimisations de Graphe et Compilation de Modèle
Fusion de Couches et Fusion de Noyaux
Les modèles de deep learning se composent de séquences d’opérations (couches). Souvent, plusieurs couches consécutives peuvent être fusionnées en un seul noyau GPU plus efficace. Par exemple, une convolution suivie d’une activation ReLU peut être combinée en un noyau Conv+ReLU, réduisant l’accès à la mémoire et les frais de lancement de noyau. Les compilateurs comme TensorRT et XLA (Accelerated Linear Algebra) excellent dans ces optimisations.
Optimisation de la Disposition de la Mémoire (NHWC vs. NCHW)
La disposition des tenseurs (par exemple, [Batch, Channels, Height, Width] – NCHW vs. [Batch, Height, Width, Channels] – NHWC) peut impacter les performances. Les GPU NVIDIA préfèrent généralement NHWC pour les opérations de convolution, en particulier lorsqu’ils utilisent des Tensor Cores. Les frameworks gèrent souvent cette conversion automatiquement, mais un ajustement manuel ou l’assurance que votre modèle est optimisé pour la disposition cible peut parfois apporter des gains.
TensorRT : Le Compilateur Ultime d’Inferred sur GPU
TensorRT est l’outil phare de NVIDIA pour optimiser les modèles d’apprentissage profond pour l’inférence sur les GPU NVIDIA. Il réalise une suite d’optimisations :
- Optimisation du Graphe : Fusion de couches, élimination des couches redondantes, consolidation verticale et horizontale des couches.
- Ajustement Automatique des Kernels : Sélection des meilleurs algorithmes de kernel pour une architecture GPU donnée et des dimensions de tenseur.
- Optimisation de la Mémoire : Réutilisation de la mémoire lorsque cela est possible et minimisation de l’empreinte mémoire.
- Calibration de la Précision : Support des précisions FP32, FP16 et INT8 avec des outils de calibration pour INT8.
Exemple Pratique : Construction d’un Engine TensorRT
import tensorrt as trt
import pycuda.driver as cuda
import pycuda.autoinit # Initialiser CUDA
TRT_LOGGER = trt.Logger(trt.Logger.WARNING)
def build_engine(onnx_file_path, precision):
builder = trt.Builder(TRT_LOGGER)
config = builder.create_builder_config()
network = builder.create_network(1 << int(trt.NetworkDefinitionCreationFlag.EXPLICIT_BATCH))
parser = trt.OnnxParser(network, TRT_LOGGER)
with open(onnx_file_path, 'rb') as model:
if not parser.parse(model.read()):
print('ERREUR : Échec de l\'analyse du fichier ONNX.')
for error in range(parser.num_errors):
print(parser.get_error(error))
return None
# Définir la taille maximale du batch et l'espace de travail
builder.max_batch_size = 128 # Obsolète dans TensorRT 8+, mais encore courant
config.max_workspace_size = 1 << 30 # 1 Go
if precision == 'FP16':
config.set_flag(trt.BuilderFlag.FP16)
elif precision == 'INT8':
config.set_flag(trt.BuilderFlag.INT8)
# Nécessite une implémentation d'Int8Calibrator
# config.int8_calibrator = MyInt8Calibrator(...)
print(f"Construction de l'engin avec une précision {precision}...")
engine = builder.build_engine(network, config)
if engine is None:
print("Échec de la construction de l'engin TensorRT.")
return engine
# Exemple d'utilisation :
# onnx_model_path = "path/to/your/model.onnx"
# trt_engine = build_engine(onnx_model_path, 'FP16')
# Pour sauvegarder/charger l'engin :
# with open("model.engine", "wb") as f:
# f.write(trt_engine.serialize())
# ...
# runtime = trt.Runtime(TRT_LOGGER)
# with open("model.engine", "rb") as f:
# engine = runtime.deserialize_cuda_engine(f.read())
Ce snippet démontre le processus de base pour prendre un modèle ONNX et construire un engine TensorRT. Pour INT8, vous devrez implémenter un Int8Calibrator pour fournir des données d'entrée représentatives pour la quantification.
Gestion de la Mémoire et Utilisation du Dispositif
Fixation de la Mémoire Hôte
Lors du transfert de données entre le CPU et le GPU, utiliser de la mémoire hôte « pinnée » (verrouillée en pages) peut considérablement accélérer les transferts. La mémoire pinnée est allouée dans une région spéciale de la RAM à laquelle le GPU peut accéder directement, contournant ainsi les mécanismes de mise en cache du CPU.
Exemple Pratique : Mémoire Pinnée dans PyTorch
import torch
# Créer un tenseur sur le CPU
host_tensor = torch.randn(1024, 1024)
# Allouer de la mémoire pinnée pour un tenseur
pinned_tensor = torch.randn(1024, 1024).pin_memory()
start_time_unpinned = torch.cuda.Event(enable_timing=True)
end_time_unpinned = torch.cuda.Event(enable_timing=True)
start_time_pinned = torch.cuda.Event(enable_timing=True)
end_time_pinned = torch.cuda.Event(enable_timing=True)
# Transférer le tenseur non pinné
start_time_unpinned.record()
_ = host_tensor.to('cuda')
end_time_unpinned.record()
torch.cuda.synchronize()
print(f"Temps de transfert non pinné : {start_time_unpinned.elapsed_time(end_time_unpinned):.2f} ms")
# Transférer le tenseur pinné
start_time_pinned.record()
_ = pinned_tensor.to('cuda', non_blocking=True) # non_blocking est essentiel pour la mémoire pinnée
end_time_pinned.record()
torch.cuda.synchronize()
print(f"Temps de transfert pinné : {start_time_pinned.elapsed_time(end_time_pinned):.2f} ms")
Fragmentation de la Mémoire GPU
L'allocation et la désallocation répétées de mémoire GPU peuvent entraîner une fragmentation, où il y a beaucoup de mémoire libre au total, mais pas de bloc contigu suffisamment grand pour une nouvelle allocation. Cela peut provoquer des erreurs de manque de mémoire (OOM). Les stratégies incluent la pré-allocation de pools de mémoire, l'utilisation d'allocateurs de mémoire qui défragmentent, ou le redémarrage du processus d'inférence si les OOM deviennent fréquents.
Profilage et Évaluation des Performances
L'optimisation est un processus itératif. Sans un bon profilage, vous devinez les goulets d'étranglement. Des outils comme NVIDIA Nsight Systems et PyTorch Profiler sont inestimables.
- NVIDIA Nsight Systems : Fournit une chronologie détaillée des activités CPU et GPU, des lancements de kernel, des transferts de mémoire et des événements de synchronisation. Essentiel pour identifier les véritables goulets d'étranglement.
- PyTorch Profiler : S'intègre directement dans le code PyTorch, offrant des informations sur les temps d'exécution des opérateurs, la consommation de mémoire et les lancements de kernel CUDA dans votre flux de travail PyTorch.
Exemple Pratique : Utilisation de Base de PyTorch Profiler
import torch
from torch.profiler import profile, schedule, tensorboard_trace_handler, ProfilerActivity
model = torch.nn.Linear(1000, 1000).cuda() # Modèle exemple
inputs = torch.randn(64, 1000).cuda()
with profile(
activities=[ProfilerActivity.CPU, ProfilerActivity.CUDA],
schedule=schedule(wait=1, warmup=1, active=3, repeat=1),
on_trace_ready=tensorboard_trace_handler("./log/inference_profile"),
with_stack=True
) as prof:
for i in range(5):
_ = model(inputs)
prof.step()
print(prof.key_averages().table(sort_by="cuda_time_total", row_limit=10))
Cela générera un fichier de trace pour TensorBoard, permettant une analyse visuelle de l'exécution de votre modèle sur le CPU et le GPU.
Conclusion : Une Approche Holistique de l'Optimisation de l'Inférence
L'optimisation GPU pour l'inférence n'est pas une tâche unique mais un processus continu d'analyse, d'expérimentation et de perfectionnement. Cela nécessite une compréhension globale de votre modèle, du matériel sous-jacent et des exigences de performance spécifiques à votre application. En utilisant des techniques comme le batching dynamique, la réduction de précision, la compilation de graphes avec des outils comme TensorRT, et un profilage minutieux, les développeurs peuvent réaliser des gains de performance significatifs, réduire les coûts opérationnels et offrir des expériences utilisateur supérieures. Le parcours d'un modèle fonctionnel à un point d'inférence hautement optimisé est difficile mais extrêmement gratifiant, repoussant les limites de ce qui est possible avec l'IA dans les environnements de production.
🕒 Published: