Analyse de performance et optimisation de modèle PyTorch
Performance analysis and optimization of PyTorch models.
Comment utiliser le profil PyTorch et TensorBoard pour accélérer l’entraînement et réduire les coûts
L’entraînement de modèles d’apprentissage en profondeur, en particulier des modèles volumineux, peut être une dépense coûteuse. L’un des principaux moyens à notre disposition pour gérer ces coûts est l’optimisation des performances. L’optimisation des performances est un processus itératif dans lequel nous recherchons constamment des opportunités pour augmenter les performances de notre application, puis nous exploitons ces opportunités. Dans des articles précédents (par exemple, ici ), nous avons souligné l’importance de disposer d’outils appropriés pour mener cette analyse. Les outils de choix dépendront probablement d’un certain nombre de facteurs, notamment le type d’accélérateur d’entraînement (par exemple, GPU, HPU ou autre) et le framework d’entraînement.

Cet article se concentrera sur l’entraînement en PyTorch sur GPU. Plus précisément, nous nous concentrerons sur l’analyseur de performances intégré à PyTorch, PyTorch Profiler, et sur l’une des façons de visualiser ses résultats, le plugin TensorBoard pour PyTorch Profiler.
Cet article n’a pas pour but de remplacer la documentation officielle de PyTorch sur PyTorch Profiler ou l’utilisation du plugin TensorBoard pour analyser les résultats du profileur. Notre intention est plutôt de démontrer comment ces outils pourraient être utilisés au cours du développement quotidien de chacun. En fait, si vous ne l’avez pas déjà fait, nous vous recommandons de consulter la documentation officielle avant de lire cet article.
Depuis un certain temps, je suis intrigué par une partie en particulier du tutoriel du plugin TensorBoard . Le tutoriel présente un modèle de classification (basé sur l’architecture Resnet) qui est entraîné sur l’ensemble de données Cifar10. Il montre comment PyTorch Profiler et le plugin TensorBoard peuvent être utilisés pour identifier et corriger un goulot d’étranglement dans le chargeur de données . Les goulots d’étranglement de performance dans le pipeline de données d’entrée ne sont pas rares et nous les avons longuement discutés dans certains de nos articles précédents (par exemple, ici ). Ce qui est surprenant dans le tutorial, ce sont les résultats finaux (après optimisation) qui sont présentés (au moment de la rédaction de cet article) que nous avons collés ci-dessous:
- Maîtriser la gestion de configuration en apprentissage automatique avec Hydra.
- AI Telephone – Une Bataille de Modèles Multimodaux
- Des pipelines CI/CD transparents avec GitHub Actions sur GCP vos outils pour un MLOps efficace.

Si vous regardez de près, vous verrez que l’utilisation du GPU après l’optimisation est de 40,46 %. Il n’y a pas de moyen de l’adoucir : ces résultats sont absolument désastreux et devraient vous tenir éveillé la nuit. Comme nous l’avons déjà souligné dans le passé (par exemple, ici ), le GPU est la ressource la plus coûteuse de notre machine d’entraînement et notre objectif devrait être de maximiser son utilisation. Un résultat d’utilisation de 40,46 % représente généralement une opportunité importante pour accélérer l’entraînement et économiser des coûts. Nous pouvons certainement faire mieux ! Dans ce blog, nous allons essayer de faire mieux. Nous commencerons par essayer de reproduire les résultats présentés dans le tutoriel officiel et voir si nous pouvons utiliser les mêmes outils pour améliorer encore les performances d’entraînement.
Exemple simple
Le bloc de code ci-dessous contient la boucle d’entraînement définie par le tutoriel du plugin TensorBoard, avec deux modifications mineures :
- Nous utilisons un ensemble de données fictif avec les mêmes propriétés et comportements que l’ensemble de données CIFAR10 qui a été utilisé dans le tutoriel. La motivation de ce changement peut être trouvée ici .
- Nous initialisons le torch.profiler.schedule avec le drapeau warmup réglé sur 3 et le drapeau repeat réglé sur 1 . Nous avons constaté que cette légère augmentation du nombre d’étapes de réchauffement améliore la stabilité des résultats de profilage.
import numpy as npimport torchimport torch.nnimport torch.optimimport torch.profilerimport torch.utils.dataimport torchvision.datasetsimport torchvision.modelsimport torchvision.transforms as Tfrom torchvision.datasets.vision import VisionDatasetfrom PIL import Imageclass FakeCIFAR(VisionDataset): def __init__(self, transform): super().__init__(root=None, transform=transform) self.data = np.random.randint(low=0,high=256,size=(10000,32,32,3),dtype=np.uint8) self.targets = np.random.randint(low=0,high=10,size=(10000),dtype=np.uint8).tolist() def __getitem__(self, index): img, target = self.data[index], self.targets[index] img = Image.fromarray(img) if self.transform is not None: img = self.transform(img) return img, target def __len__(self) -> int: return len(self.data)transform = T.Compose( [T.Resize(224), T.ToTensor(), T.Normalize((0.5, 0.5, 0.5), (0.5, 0.5, 0.5))])train_set = FakeCIFAR(transform=transform)train_loader = torch.utils.data.DataLoader(train_set, batch_size=32, shuffle=True)device = torch.device("cuda:0")model = torchvision.models.resnet18(weights='IMAGENET1K_V1').cuda(device)criterion = torch.nn.CrossEntropyLoss().cuda(device)optimizer = torch.optim.SGD(model.parameters(), lr=0.001, momentum=0.9)model.train()# étape d'entraînementdef train(data): inputs, labels = data[0].to(device=device), data[1].to(device=device) outputs = model(inputs) loss = criterion(outputs, labels) optimizer.zero_grad() loss.backward() optimizer.step()# boucle d'entraînement enveloppée dans un objet profilerwith torch.profiler.profile( schedule=torch.profiler.schedule(wait=1, warmup=4, active=3, repeat=1), on_trace_ready=torch.profiler.tensorboard_trace_handler('./log/resnet18'), record_shapes=True, profile_memory=True, with_stack=True) as prof: for step, batch_data in enumerate(train_loader): if step >= (1 + 4 + 3) * 1: break train(batch_data) prof.step() # Need to call this at the end of each step
La carte graphique utilisée dans le tutoriel était une Tesla V100-DGXS-32GB. Dans cet article, nous tentons de reproduire, et d’améliorer les résultats de performance du tutoriel en utilisant une instance Amazon EC2 p3.2xlarge contenant une carte graphique Tesla V100-SXM2-16GB. Bien qu’ils partagent la même architecture, il y a des différences entre les deux cartes graphiques que vous pouvez apprendre ici. Nous avons exécuté le script d’entraînement en utilisant une image Docker AWS PyTorch 2.0. Les résultats de performance du script d’entraînement, tels qu’affichés dans la page d’aperçu du visualiseur TensorBoard, sont capturés dans l’image ci-dessous :

Nous notons d’abord que, contrairement au tutoriel, la page d’aperçu (de la version 0.4.1 de torch-tb-profiler) dans notre expérience a combiné les trois étapes de profilage en une seule. Ainsi, le temps moyen de chaque étape est de 80 millisecondes et non de 240 millisecondes comme indiqué. Cela peut être clairement vu dans l’onglet Trace (qui, selon notre expérience, fournit presque toujours un rapport plus précis) où chaque étape prend environ 80 millisecondes.

Remarquez que notre point de départ de 31,65% d’utilisation du GPU et un temps d’étape de 80 millisecondes est différent du point de départ présenté dans le tutoriel de 23,54% et 132 millisecondes, respectivement. Cela est probablement dû à des différences dans l’environnement d’entraînement, y compris le type de GPU et la version de PyTorch. Nous notons également que, bien que les résultats de référence du tutoriel diagnostiquent clairement le goulot d’étranglement dans le DataLoader, nos résultats ne le font pas. Nous avons souvent constaté que les goulots d’étranglement de chargement de données se déguisent en un pourcentage élevé de “CPU Exec” ou “Autre” dans l’onglet Aperçu.
Optimisation #1 : Chargement de données multi-processus
Commençons par appliquer le chargement de données multi-processus tel que décrit dans le tutoriel. Étant donné que l’instance Amazon EC2 p3.2xlarge dispose de 8 vCPU, nous avons fixé le nombre de travailleurs DataLoader à 8 pour une performance maximale :
train_loader = torch.utils.data.DataLoader(train_set, batch_size=32, shuffle=True, num_workers=8)
Les résultats de cette optimisation sont affichés ci-dessous :

Le changement d’une seule ligne de code a augmenté l’utilisation du GPU de plus de 200% (de 31,65% à 72,81%) et a plus que divisé par deux notre temps d’étape d’entraînement (de 80 millisecondes à 37).
C’est là que le processus d’optimisation dans le tutoriel se termine. Bien que notre utilisation du GPU (72,81%) soit considérablement plus élevée que les résultats du tutoriel (40,46%), je ne doute pas que, comme nous, vous trouverez ces résultats encore insatisfaisants.
Commentaire personnel que vous pouvez sauter librement : Imaginez combien d’argent pourrait être économisé à l’échelle mondiale si PyTorch appliquait le chargement de données multi-processus par défaut lors de l’entraînement sur GPU ! Certes, il peut y avoir des effets secondaires indésirables liés à l’utilisation du multiprocessing. Néanmoins, il doit exister une forme d’algorithme de détection automatique qui pourrait être exécuté pour éliminer la présence de scénarios potentiellement problématiques et appliquer cette optimisation en conséquence.
Optimisation #2 : Epinglage de la mémoire
Si nous analysons la vue Trace de notre dernière expérience, nous pouvons constater qu’une quantité significative de temps (10 sur 37 millisecondes) est encore consacrée au chargement des données d’entraînement dans le GPU.

Pour remédier à cela, nous appliquerons une autre optimisation recommandée par PyTorch pour optimiser le flux d’entrée de données, le verrouillage de mémoire. L’utilisation de la mémoire verrouillée peut accélérer la copie des données de l’hôte vers le GPU et, plus important encore, nous permettre de les rendre asynchrones. Cela signifie que nous pouvons préparer le prochain lot d’entraînement dans le GPU en parallèle de l’exécution de l’étape d’entraînement sur le lot courant. Pour plus de détails ainsi que les effets secondaires potentiels du verrouillage de mémoire, veuillez consulter la documentation de PyTorch.
Cette optimisation nécessite des modifications de deux lignes de code. Tout d’abord, nous définissons le drapeau pin_memory du DataLoader sur True.
train_loader = torch.utils.data.DataLoader(train_set, batch_size=32, shuffle=True, num_workers=8, pin_memory=True)
Ensuite, nous modifions le transfert de mémoire de l’hôte au périphérique (dans la fonction d’entraînement) pour qu’il ne soit pas bloquant :
inputs, labels = data[0].to(device=device, non_blocking=True), \ data[1].to(device=device, non_blocking=True)
Les résultats de l’optimisation du verrouillage de mémoire sont affichés ci-dessous :

Notre utilisation du GPU est maintenant respectable, à 92,37 %, et notre temps d’étape a encore diminué. Mais nous pouvons encore faire mieux. Notez que malgré cette optimisation, le rapport de performance continue d’indiquer que nous passons beaucoup de temps à copier les données dans le GPU. Nous y reviendrons à l’étape 4 ci-dessous.
Optimisation n°3 : Augmentation de la taille du lot
Pour notre prochaine optimisation, nous attirons notre attention sur la vue mémoire de la dernière expérience :

Le graphique montre que sur 16 Go de mémoire GPU, nous atteignons moins de 1 Go d’utilisation maximale. C’est un exemple extrême de sous-utilisation des ressources qui indique souvent (mais pas toujours) une opportunité d’améliorer les performances. Une façon de contrôler l’utilisation de la mémoire est d’augmenter la taille du lot. Dans l’image ci-dessous, nous affichons les résultats de performance lorsque nous augmentons la taille du lot à 512 (et l’utilisation de la mémoire à 11,3 Go).

Bien que la mesure d’utilisation du GPU n’ait pas beaucoup changé, notre vitesse d’entraînement a considérablement augmenté, passant de 1200 échantillons par seconde (46 millisecondes pour une taille de lot de 32) à 1584 échantillons par seconde (324 millisecondes pour une taille de lot de 512).
Attention : Contrairement à nos précédentes optimisations, l’augmentation de la taille du lot pourrait avoir un impact sur le comportement de votre application d’entraînement. Différents modèles présentent différents niveaux de sensibilité à un changement de taille de lot. Certains peuvent nécessiter simplement un réglage des paramètres d’optimisation. Pour d’autres, l’adaptation à une grande taille de lot peut être plus difficile, voire impossible. Consultez ce billet précédent pour certains des défis liés à l’entraînement sur de grands lots.
Optimisation n°4 : Réduire la copie de l’hôte vers le périphérique
Vous avez probablement remarqué la grande tache rouge représentant la copie de données de l’hôte vers le périphérique dans le graphique circulaire de nos résultats précédents. La manière la plus directe de tenter de résoudre ce type de goulot d’étranglement est de voir si nous pouvons réduire la quantité de données dans chaque lot. Notez que dans le cas de notre entrée d’image, nous convertissons le type de données d’un entier non signé de 8 bits en un flottant de 32 bits et appliquons une normalisation avant d’effectuer la copie de données. Dans le bloc de code ci-dessous, nous proposons un changement dans le flux de données d’entrée dans lequel nous retardons la conversion du type de données et la normalisation jusqu’à ce que les données soient sur le GPU :
# maintenir l'entrée d'image en tant que tenseur uint8 de 8 bits transform = T.Compose( [T.Resize(224), T.PILToTensor() ])train_set = FakeCIFAR(transform=transform)train_loader = torch.utils.data.DataLoader(train_set, batch_size=1024, shuffle=True, num_workers=8, pin_memory=True)device = torch.device("cuda:0")model = torch.compile(torchvision.models.resnet18(weights='IMAGENET1K_V1').cuda(device), fullgraph=True)criterion = torch.nn.CrossEntropyLoss().cuda(device)optimizer = torch.optim.SGD(model.parameters(), lr=0.001, momentum=0.9)model.train()# étape d'entraînementdef train(data): inputs, labels = data[0].to(device=device, non_blocking=True), \ data[1].to(device=device, non_blocking=True) # convertir en float32 et normaliser inputs = (inputs.to(torch.float32) / 255. - 0.5) / 0.5 outputs = model(inputs) loss = criterion(outputs, labels) optimizer.zero_grad() loss.backward() optimizer.step()
En conséquence de ce changement, la quantité de données copiées du CPU vers le GPU est réduite de 4 fois et la tache rouge disparaît pratiquement :

Nous atteignons maintenant un nouveau sommet de 97,51 % (!!) d’utilisation du GPU et une vitesse d’entraînement de 1670 échantillons par seconde ! Voyons ce que nous pouvons faire d’autre.
Optimisation n°5 : Définir les gradients à None
À ce stade, nous semblons utiliser pleinement le GPU, mais cela ne signifie pas que nous ne pouvons pas l’utiliser de manière plus efficace. Une optimisation populaire qui est censée réduire les opérations de mémoire dans le GPU consiste à définir les gradients des paramètres du modèle à None plutôt qu’à zéro à chaque étape d’entraînement. Veuillez consulter la documentation de PyTorch pour plus de détails sur cette optimisation. Tout ce qui est nécessaire pour mettre en œuvre cette optimisation est de définir set_to_none de l’appel optimizer.zero_grad à True :
optimizer.zero_grad(set_to_none=True)
Dans notre cas, cette optimisation n’a pas amélioré notre performance de manière significative.
Optimisation n°6 : Précision mixte automatique
La vue du noyau GPU affiche la quantité de temps pendant laquelle les noyaux GPU étaient actifs et peut être une ressource utile pour améliorer l’utilisation du GPU :

L’un des détails les plus flagrants de ce rapport est le manque d’utilisation des cœurs Tensor du GPU. Disponibles sur des architectures GPU relativement récentes, les cœurs Tensor sont des unités de traitement dédiées à la multiplication de matrices qui peuvent booster significativement les performances des applications d’IA. Leur non-utilisation peut représenter une opportunité majeure d’optimisation.
Étant donné que les cœurs Tensor sont spécifiquement conçus pour le calcul à précision mixte, une manière directe d’augmenter leur utilisation est de modifier notre modèle pour utiliser la Précision Mixte Automatique (AMP). En mode AMP, des parties du modèle sont automatiquement converties en flottants de 16 bits de précision inférieure et exécutées sur les cœurs Tensor du GPU.
Il est important de noter qu’une mise en œuvre complète d’AMP peut nécessiter une mise à l’échelle de gradient que nous n’incluons pas dans notre démonstration. Assurez-vous de consulter la documentation sur l’entraînement à précision mixte avant de l’adapter.
La modification de l’étape d’entraînement requise pour activer AMP est démontrée dans le bloc de code ci-dessous.
def train(data): inputs, labels = data[0].to(device=device, non_blocking=True), \ data[1].to(device=device, non_blocking=True) inputs = (inputs.to(torch.float32) / 255. - 0.5) / 0.5 with torch.autocast(device_type='cuda', dtype=torch.float16): outputs = model(inputs) loss = criterion(outputs, labels) # Note - torch.cuda.amp.GradScaler() may be required optimizer.zero_grad(set_to_none=True) loss.backward() optimizer.step()
L’impact sur l’utilisation du Tensor Core est affiché dans l’image ci-dessous. Bien qu’il continue à indiquer une opportunité d’amélioration supplémentaire, avec une seule ligne de code, l’utilisation est passée de 0 % à 26,3 %.

En plus d’augmenter l’utilisation du Tensor Core, l’utilisation d’AMP réduit l’utilisation de la mémoire GPU, libérant ainsi plus d’espace pour augmenter la taille de lot. L’image ci-dessous capture les résultats de performance d’entraînement suite à l’optimisation AMP et la taille de lot fixée à 1024 :

Bien que l’utilisation GPU ait légèrement diminué, notre principale métrique de débit a encore augmenté d’environ 50 %, passant de 1670 échantillons par seconde à 2477. Nous sommes sur une lancée !
Attention : La réduction de la précision de certaines parties de votre modèle pourrait avoir un effet significatif sur sa convergence. Comme dans le cas de l’augmentation de la taille de lot (voir ci-dessus), l’impact de l’utilisation de la précision mixte variera selon le modèle. Dans certains cas, AMP fonctionnera avec peu ou pas d’effort. D’autres fois, vous devrez travailler un peu plus dur pour régler l’autoscaleur. D’autres fois encore, vous devrez définir explicitement les types de précision de différentes parties du modèle (c’est-à-dire une précision mixte manuelle).
Pour plus de détails sur l’utilisation de la précision mixte comme méthode d’optimisation de la mémoire, veuillez consulter notre précédent billet de blog sur le sujet.
Optimisation n°7 : Entraînement en mode graphique
La dernière optimisation que nous allons appliquer est la compilation du modèle. Contrairement au mode d’exécution PyTorch par défaut en mode d’exécution immédiate, dans lequel chaque opération PyTorch est exécutée “immédiatement”, l’API de compilation convertit votre modèle en un graphe de calcul intermédiaire qu’elle compile ensuite en noyaux de calcul de bas niveau d’une manière qui est optimale pour l’accélérateur d’entraînement sous-jacent. Pour en savoir plus sur la compilation de modèles dans PyTorch 2, consultez notre précédent billet sur le sujet.
Le bloc de code suivant montre le changement requis pour appliquer la compilation du modèle :
model = torchvision.models.resnet18(weights='IMAGENET1K_V1').cuda(device)model = torch.compile(model)
Les résultats de l’optimisation de la compilation du modèle sont affichés ci-dessous :

La compilation du modèle augmente encore notre débit à 3268 échantillons par seconde, comparé à 2477 dans l’expérience précédente, soit une augmentation de performance supplémentaire de 32 % (!!).
La manière dont la compilation du graphe modifie l’étape d’entraînement est très visible dans les différentes vues du plugin TensorBoard. La vue du noyau, par exemple, indique l’utilisation de nouveaux noyaux GPU fusionnés, et la vue de trace (affichée ci-dessous) affiche un motif totalement différent de ce que nous avons vu précédemment.

Résultats intermédiaires
Dans le tableau ci-dessous, nous résumons les résultats des optimisations successives que nous avons appliquées.

En appliquant notre approche itérative d’analyse et d’optimisation à l’aide du PyTorch Profiler et du plugin TensorBoard, nous avons pu augmenter les performances de 817% !!
Notre travail est-il terminé? Absolument pas! Chaque optimisation que nous mettons en œuvre découvre de nouvelles opportunités potentielles d’amélioration des performances. Ces opportunités se présentent sous la forme de ressources libérées (par exemple, la manière dont le passage à la précision mixte a permis d’augmenter la taille du lot) ou sous la forme de goulets d’étranglement de performance nouvellement découverts (par exemple, la manière dont notre dernière optimisation a découvert un goulot d’étranglement dans le transfert de données hôte-appareil). De plus, il existe de nombreuses autres formes d’optimisation bien connues que nous n’avons pas tenté dans ce post (par exemple, voir ici et ici). Et enfin, de nouvelles optimisations de bibliothèques (par exemple, la fonction de compilation de modèle que nous avons présentée à l’étape 7) sont constamment publiées, ce qui permet de mieux atteindre nos objectifs d’amélioration des performances. Comme nous l’avons souligné dans l’introduction, pour exploiter pleinement de telles opportunités, l’optimisation des performances doit être une partie itérative et cohérente de votre flux de travail de développement.
Résumé
Dans ce post, nous avons démontré le potentiel significatif d’optimisation des performances sur un modèle de classification jouet. Bien qu’il existe d’autres analyseurs de performances que vous pouvez utiliser, chacun ayant ses avantages et ses inconvénients, nous avons choisi le PyTorch Profiler et le plugin TensorBoard en raison de leur facilité d’intégration.
Nous devons souligner que le chemin vers une optimisation réussie variera considérablement en fonction des détails du projet de formation, notamment de l’architecture du modèle et de l’environnement de formation. En pratique, atteindre vos objectifs peut être plus difficile que dans l’exemple que nous avons présenté ici. Certaines des techniques que nous avons décrites peuvent avoir peu d’impact sur vos performances ou même les aggraver. Nous notons également que les optimisations précises que nous avons choisies et l’ordre dans lequel nous avons choisi de les appliquer étaient quelque peu arbitraires. Vous êtes vivement encouragé à développer vos propres outils et techniques pour atteindre vos objectifs d’optimisation en fonction des détails spécifiques de votre projet.
L’optimisation des performances des charges de travail d’apprentissage automatique est parfois considérée comme secondaire, non critique et fastidieuse. J’espère que nous avons réussi à vous convaincre que le potentiel d’économie de temps et de coûts de développement justifie un investissement significatif dans l’analyse et l’optimisation des performances. Et, hé, vous pourriez même trouver cela amusant :).
Et maintenant?
Ceci n’était que la partie émergée de l’iceberg. Il y a beaucoup plus à l’optimisation des performances que ce que nous avons couvert ici. Dans une suite à ce post, nous aborderons un problème de performance assez courant dans les modèles PyTorch dans lequel une quantité excessive de calcul est effectuée sur le CPU plutôt que sur le GPU, souvent de manière inconnue du développeur. Nous vous encourageons également à consulter nos autres posts sur IPGirl, qui couvrent de nombreux autres éléments d’optimisation des performances des charges de travail d’apprentissage automatique.
We will continue to update IPGirl; if you have any questions or suggestions, please contact us!
Was this article helpful?
93 out of 132 found this helpful
Related articles
- Détection de la croissance du cancer à l’aide de l’IA et de la vision par ordinateur
- Problème du Gradient Disparu Causes, Conséquences et Solutions
- Classer et localiser les différentes formes de harcèlement sexuel.
- Recherche de similarité, Partie 2 Quantification de Produit
- Suppression et distillation architecturales une voie vers une compression efficace dans les modèles de diffusion texte-image d’IA
- Google AI dévoile Imagen Editor et EditBench pour améliorer et évaluer l’Inpainting d’image guidée par le texte.
- Forged in Flames Une start-up fusionne l’IA générative et la vision par ordinateur pour lutter contre les incendies de forêt.