Deux techniques simples pour vous assurer que l'entraînement de votre LLM atteint des performances optimales.
par Bruce Fontaine et Pardis Beikzadeh
L'entraînement ou le peaufinage (fine-tuning) d'un modèle fondation de plusieurs milliards de paramètres nécessite une puissance de calcul importante, s'étalant souvent sur des centaines, voire des milliers d'heures d'entraînement sur GPU. Mais l'optimisation des performances d'entraînement pour un LLM est complexe, et les métriques de haut niveau, comme l'utilisation des GPU, peuvent ne pas refléter votre utilisation réelle. Dans cet article de blog, nous vous montrons comment franchir les couches d'abstraction et multiplier par 6 la vitesse de fine-tuning du dernier modèle Qwen 3.8 27B sur AI Runtime. Grâce à une optimisation systématique des performances, alors même que l'utilisation du GPU était déjà à 98 %, nous sommes passés de 7 heures 44 minutes à 1 heure 17 minutes pour le fine-tuning d'un jeu de données fixe.
Nous détaillerons en profondeur les techniques qui sous-entendent ces améliorations, depuis la mesure des éléments clés jusqu'aux détails d'implémentation et aux compromis de conception propres à chaque optimisation.
Si vous souhaitez commencer immédiatement, accédez directement au code d'exemple sur AI Runtime et essayez-le !
Nous commençons par une boucle d'entraînement standard pour Qwen 3.8 27B en utilisant la bibliothèque transformers et le jeu de données d'entraînement SFT HuggingFaceH4/ultrachat_200k.
Notre entraînement de référence (baseline) du modèle sur l'ensemble du jeu de données prend 7 heures et 44 minutes et, pendant l'entraînement, le GPU affiche une utilisation moyenne de 98 %. Le résultat est un entraînement à 9 396 tok/s.
Cette utilisation du GPU semble élevée, mais comme mentionné précédemment, ce chiffre est trompeur. La métrique standard d'utilisation du GPU compte uniquement le pourcentage de temps pendant lequel le GPU faisait quelque chose, ce qui ne nous indique pas si le modèle utilisait bien le GPU lorsqu'il était actif. Pour comprendre cela, nous devons analyser les choses plus en détail.
L'un des moyens d'y parvenir est le mécanisme de traçage de torch ; nous pouvons ajuster notre boucle d'entraînement comme suit (tout en limitant le nombre d'étapes pour obtenir une réponse rapide) :
Pour un exemple d'étape, nous obtenons les données suivantes :
| Opération | Nombre d'appels | Temps moyen (micros) | Temps total (millis) |
|---|---|---|---|
| aten::bmm | 31 105 | 50.5 | 1 571.80 |
| aten::mul | 46 878 | 16.2 | 758.9 |
| MmBackward0 | 497 | 1 111.4 | 552.4 |
| aten::copy_ | 69 444 | 7.2 | 500.6 |
| aten::add | 45 623 | 8.0 | 366.7 |
| aten::select | 68 645 | 3.4 | 230.5 |
| aten::exp | 9 504 | 21.7 | 206.5 |
| aten::sum | 16 194 | 12.2 | 196.8 |
| aten::empty | 40 709 | 4.7 | 190.5 |
| aten::as_strided | 218 214 | 0.8 | 173.3 |
Tableau 1 : CPU - 14,4 s avec 1 714 382 appels. Le tableau contient les principales opérations par temps total.
| Opération | Nombre d'appels | Temps moyen (micros) | Temps total (millis) |
|---|---|---|---|
| GEMM nvjet | 1923 | 1 163.1 | 2 236.8 |
| Addition en virgule flottante élément par élément | 42 657 | 51.1 | 2 180.5 |
| Multiplication en virgule flottante élément par élément | 35 012 | 24 | 841.8 |
| Remplissage en virgule flottante élément par élément | 33 557 | 21.5 | 723.1 |
| Copie en virgule flottante élément par élément | 59 282 | 11.8 | 700.7 |
| Memcpy DtoD | 5 585 | 90.5 | 505.2 |
| sm80_xmma GEMM | 31 105 | 20.9 | 651.3 |
Tableau 2 : GPU - 10,3 s avec 269 448 opérations. Le tableau contient les principales opérations par temps total.
À partir de ces tableaux, nous constatons quelques problèmes majeurs avec le modèle actuel :
De plus, nous avons un problème de remplissage (padding) excessif dans le batch : le tenseur d'entrée doit être rectangulaire et chaque ligne est complétée jusqu'au nombre maximal de tokens pour ce batch. Nous allons montrer une astuce adaptée aux LLM permettant de le minimiser.
Dans les sections suivantes, nous examinerons plus en détail chaque sujet technique, en couvrant sa définition, les meilleures pratiques recommandées et les principaux compromis à prendre en compte lors de son application.
Vous remarquerez peut-être que certains kernels apparaissent très souvent ! En particulier, pour Qwen, le kernel de multiplication matricielle par batch est exécuté plus de 30 000 fois en une seule étape.
Dans ce cas, nous allons d'abord explorer les kernels fusionnés. Un kernel fusionné prend plusieurs kernels GPU plus petits et les combine en une seule unité. Cela permet d'améliorer les performances en évitant les transferts coûteux entre la mémoire à haute bande passante du GPU et sa mémoire intégrée (on-die) à bande passante encore plus élevée. Cela permet également une planification plus efficace des cœurs GPU.
Avec autant de noyaux supplémentaires, même si torch.compile peut fonctionner, l'exécution prendra beaucoup de temps. Réduire le nombre brut d'opérations avec des noyaux fusionnés manuellement constitue donc une bonne première étape. De plus, des fusions bien connues telles que Flash Attention ne sont pas le genre de choses que torch.compile trouverait de lui-même.
La bibliothèque transformers utilise par défaut la fonction SDPA de torch pour les couches d'attention standard. Cependant, il s'avère que beaucoup de ces petites opérations proviennent des couches non standard de réseaux gated delta. Heureusement, pour ces couches, il existe deux noyaux, causal-conv1d et flash-linear-attention, qui ont été écrits pour fusionner plusieurs de ces opérations par couche. Comment le découvrir ? L'implémentation de la bibliothèque transformers de Qwen nous indique déjà quels noyaux elle prend en charge :
Il existe deux méthodes principales pour obtenir ces noyaux :
Nous recommandons généralement la première option, en particulier avec notre prise en charge des conteneurs Docker : construisez votre conteneur avec tous vos noyaux installés au moment du build afin de réduire le temps de démarrage de votre tâche.
Bien que Kernel Hub soit pratique, comme les noyaux dépendent souvent explicitement des versions de torch et de CUDA installées, un noyau compatible avec votre configuration spécifique peut ne pas être disponible. Le modèle transformers utilisera alors silencieusement un noyau non optimisé offrant de faibles performances.
Après avoir installé les packages Python flash-linear-attention et causal-conv1d, nous avons amélioré notre temps global d'un facteur 3,7x, le réduisant à 2 heures et 5 minutes. Dans les tableaux ci-dessous, vous pouvez voir les nouveaux noyaux (en surbrillance) remplacer les fonctions élément par élément des tableaux précédents. De plus, le nombre total d'opérations exécutées a été considérablement réduit, d'un facteur 15x :
| Opération | Nombre d'appels | Temps moyen (micros) | Temps total (millis) |
|---|---|---|---|
| cudaMemsetAsync | 1,854 | 283.9 | 526.3 |
| ChunkGatedDeltaRuleFunction | 96 | 871.7 | 83.7 |
| aten::mm | 1,987 | 39.6 | 78.6 |
| MmBackward0 | 497 | 134.2 | 66.7 |
| ChunkGatedDeltaRuleFunctionBackward | 48 | 1,159.9 | 55.7 |
| DaoAILab::_causal_conv1d_fwd_cpp | 96 | 55.8 | 5.4 |
| CausalConv1dFn | 96 | 51.4 | 4.9 |
Tableau 3 : CPU - 3,9 s avec 118 316 appels. Le tableau présente les principales opérations par temps total.
| Opération | Nombre d'appels | Temps moyen (micros) | Temps total (millis) |
|---|---|---|---|
| nvjet GEMM | 1,923 | 1,183.3 | 2,275.5 |
| Multiplication à virgule flottante élément par élément | 2184 | 128.1 | 279.9 |
| Copie + cast élément par élément | 1255 | 101.1 | 126.9 |
| chunk_cat flash-linear-attention | 65 | 1,402.0 | 91.2 |
| flash_attention_3 forwards | 16 | 2,693.8 | 43.1 |
| chunk_gated_delta_rule_fwd | 144 | 255.6 | 36.8 |
| flash_attention_3 backwards | 32 | 965.6 | 30.9 |
Tableau 4 : GPU - 3,9 s avec 18 060 appels. Le tableau présente les principales opérations par temps total.
Avertissement : pendant l'entraînement, vous aurez besoin d'un noyau pour les passes avant (forward) et arrière (backward), mais certains noyaux sont uniquement réservés à la passe avant, car ils n'ont été implémentés que pour accélérer l'inférence.
À retenir
Notre modèle Qwen est désormais assez rapide, mais nous pouvons aller encore plus vite. Par défaut, notre configuration de mise en lots utilise des tenseurs rectangulaires 2D de 8 192 tokens, car c'est la plus grande taille que nous puissions utiliser sans manquer de mémoire. L'inconvénient de cette approche est que nous avons tendance à gaspiller une quantité considérable d'espace dans le tenseur avec du padding.
Si Qwen n'utilisait que des mécanismes d'attention standard, la fonctionnalité NestedTensor de torch est maintenant bien prise en charge et permettrait de réduire ce coût en supprimant complètement le padding. Cependant, flash-linear-attention ne prend en charge qu'une approche plus traditionnelle de tenseur compacté 1D :

Mais nous ne pouvons pas faire cela de manière arbitraire : transmettre simplement le tenseur 1D au modèle peut être acceptable pour le préentraînement, mais pour le réglage fin (finetuning), nous devons nous assurer que les tokens d'un exemple ne prêtent attention qu'aux tokens du même exemple. Pour résoudre ce problème, nous devons transmettre les bons position_ids d'exemple au modèle. À son tour, le modèle les transmettra aux noyaux d'attention fusionnés, qui appliqueront le masque d'attention correct. Si vous consultez le code d'exemple ici, vous verrez comment nous calculons les entrées nécessaires pour que cela fonctionne.
Grâce à ce changement en apparence simple, notre temps d'entraînement passe de 2 heures 5 minutes à 1 heure 17 minutes.
Il y a deux raisons à cela :
Compromis : ce type de modification rend le pipeline d'entrée plus complexe et augmente le risque d'introduire des erreurs qui affectent la qualité du modèle. Mais dans ce cas, l'économie de 36 % en vaut la peine. Il existe d'autres techniques pour réduire le padding, comme conserver le mécanisme de mise en lots d'origine mais trier toutes les entrées par longueur pour minimiser le padding, mais cet ordre peut introduire une corrélation inattendue entre les échantillons adjacents et biaiser le modèle.
À retenir : Lors de l'entraînement de LLM sur un jeu de données contenant des entrées plus courtes que votre nombre maximal de tokens entraînabiles (par exemple 8 192 dans ce cas), le compactage efficace des données en lots permet de gagner un temps considérable. Pour ce jeu de données, nous avons constaté une amélioration de 1,6x de la vitesse d'entraînement, ce qui a réduit notre temps d'entraînement de 2 heures 5 minutes à 1 heure 17 minutes.
Dans ce blog, nous vous avons montré comment multiplier par 6 l'efficacité de votre entraînement. Cela s'est résumé à : (1) mesurer d'abord ce qui compte, pas seulement l'utilisation des GPU, mais aussi les étapes où votre modèle passe du temps lors de l'entraînement, (2) optimiser et fusionner les noyaux à l'aide de bibliothèques connues comme Flash Linear Attention, et (3) adapter la manière dont les lots de données sont transmis au modèle.
Pour faire cela à grande échelle, vous avez besoin d'une plateforme qui propose par défaut une trajectoire performante. Dans AIR, bon nombre de ces améliorations de performances sont intégrées. Nous incluons les dernières versions de CUDA et de flash attention dans nos environnements de base, suivons automatiquement pour vous des métriques clés telles que l'utilisation des GPU, la température et leur état de santé, et veillons à ce que les versions de torch (ainsi que les profilers) soient à jour. Notre agent interne, Genie Code, est également réglé pour rechercher ce type d'opportunités afin d'optimiser davantage votre entraînement. De plus, la lecture de données depuis Databricks est optimisée grâce à notre UCVolumeDataset.
N'hésitez pas à essayer AIR pour votre prochaine charge de travail d'entraînement ! Prêt à tenter l'expérience ? Consultez le cookbook AI Runtime pour découvrir comment exécuter vous-même ce modèle.
(Cet article de blog a été traduit à l'aide d'outils basés sur l'intelligence artificielle) Article original
Abonnez-vous à notre blog et recevez les derniers articles directement dans votre boîte mail.