Comment nous avons adapté Bert pour répondre à plus d'un millard de demandes quotidiennes sur les CPU
Les sciences de l'information et machine learning : pour aller au fond du problème de l'œuf et de la poule.
Voici un problème classique pour les spécialistes des données et les ingénieurs en machine learning : lorsque vous développez un nouveau modèle de machine learning pour votre entreprise, vous souciez-vous d'abord de le rendre précis, puis de le rendre rapide en production ? Ou bien vous assurez-vous d'abord qu'il peut être rapide puis qu'il est précis ? Posez la question et de vifs débats suivront !
Début 2019, nous avons été confrontés à ce scénario précis alors que nous commencions à développer notre nouvelle génération de classificateurs de texte pour Roblox, en utilisant (à l'époque) les nouveaux modèles d'apprentissage en profondeur de Bert. Nous n'étions pas sûrs que les modèles de Bert, qui sont de grands réseaux neuronaux profonds dont le modèle de base "d'entrée de gamme" commence à 110 millions de paramètres, puissent répondre à nos exigences de vitesse et de débit en production. Nous avons continué à rendre nos classificateurs de texte basés sur Bert très précis et dans les mois tendus qui ont suivi, nous avons heureusement pu les rendre assez rapides pour la production. Donc pour nous, la "poule" (la précision) est venue en premier, puis l'"œuf" (la vitesse) est venu après. Si cette expérience a été stressante pour nous, elle ne l'est pas forcément pour vous, car dans cet article, nous allons partager les optimisations qui ont permis à Bert de faire une inférence rapide pour nous. Vous pouvez donc commencer par un œuf (un moyen connu pour rendre certains modèles de Bert rapides en production), puis vous concentrer sur la poule (pour rendre votre modèle de Bert précis).
Notre cheminement vers la production rapide de Bert
La magie de Bert pour le traitement du langage naturel (NLP) était bien synchronisée en 2019, et ici, à Roblox, nous avons vu cette magie opérer de près sur nos propres données. En affinant les modèles d'apprentissage profond de Bert, nous avons radicalement transformé nombre de nos applications de classification de textes et de reconnaissance d'entités nommées (NER), améliorant souvent les performances de leurs modèles (scores F1) de 10 points de pourcentage ou plus par rapport aux modèles précédents.
Cependant, alors que le développement de notre modèle Bert a été accéléré grâce à de nombreuses ressources étonnantes et des bibliothèques géniales, nous n'avons trouvé que quelques ressources sur la façon d'adapter Bert sur PyTorch pour des cas d'utilisation en production à faible latence et à haut débit. Et pour nombre de nos services de PNL, nous devions traiter plus de 25 000 inférences par seconde (et plus d'un milliard d'inférences par jour), avec une latence inférieure à 20 millisecondes.
L'objectif de cet article est de partager notre parcours visant à accélérer de plus de 30 fois l'inférence de Bert pour la classification des textes à Roblox. Grâce à ces améliorations (résumées ci-dessous), nous avons pu rendre Bert non seulement assez rapide pour nos utilisateurs mais aussi assez économique pour le faire fonctionner en production à un coût gérable sur l'unité centrale.
Le point de repère
Pour les besoins de cet article, nous nous concentrerons sur l'amélioration de la vitesse d'inférence d'un service de classification de texte qui a été développé par le perfectionnement de Bert. Ici, "inférence" fait référence à la chaîne qui consiste à prendre un texte en entrée, à le transformer en un tenseur d'entrée, à alimenter ce tenseur dans notre modèle basé sur Bert et à renvoyer une étiquette de classification produite par le modèle.
Chaque fois que nous cherchons à améliorer systématiquement la vitesse, nous devons choisir un point de référence. Le point de référence choisi pour notre classificateur Bert comportait deux éléments :
- Latence: le temps médian nécessaire pour répondre à une demande d'inférence (nous avons également le 99e percentile plus des points de référence en interne)
- Débit : le nombre d'inférences que nous pouvons servir en une seconde
Pour des comparaisons de débit cohérentes, notre code de référence a lancé exactement 32 processus de traitement qui ont chacun fait l'objet de 1000 demandes d'inférences de manière séquentielle. Ce benchmark a été exécuté sur un seul serveur avec 36 cœurs de processeurs évolutifs Xeon.
Cette référence a simulé un cadre de production où un service de classification de textes était soutenu par un ensemble de processus de traitement. Sous charge, chaque processus était occupé à traiter des demandes d'inférence consécutives pendant des périodes prolongées. Dans notre environnement de production actuel, nous avions des centaines de ces processus répartis sur de nombreuses machines, de sorte que nous pouvions prendre en charge plus de 25 000 demandes par seconde avec des latences médianes inférieures à 20 ms.
Outils
Notre modèle Bert affiné a été développé à l'aide de la bibliothèque Huggingface Transformers v2.3.0. Nous avons choisi Huggingface spécifiquement pour son support PyTorch, et nous sommes actuellement en production avec PyTorch 1.4.0. Huggingface nous a permis d'expérimenter facilement les nombreux autres modèles à base de transformateurs, notamment Roberta, XLNet, DistilBert, Albert et bien d'autres encore. C'est la façon dont nous avons découvert DistilBert qui a considérablement amélioré notre vitesse d'inférence (nous en parlerons plus loin).
Première étape : CPU ou GPU pour l'inférence ?
Notre première grande décision a été de savoir s'il fallait exécuter l'inférence pour notre classificateur de texte basé sur Bert sur le CPU ou le GPU.
Pour notre formation de modèle, le GPU était sans aucun doute beaucoup plus rapide que le CPU. La mise au point (formation) de notre modèle de classification de texte Bert a pris plus de 10 fois plus de temps sur le CPU que sur le GPU même en comparant un GPU Tesla V100 à un gros serveur Xeon Scalable à 36 cœurs basé sur le CPU.
Pour l'inférence, le choix entre GPU et CPU dépend de l'application. Ce sont les facteurs qui ont fait basculer notre décision vers l'unité centrale de traitement pour l'inférence :
- Simplicité. Les GPU s'adaptent mieux lorsque les entrées sont regroupées. Notre classificateur de texte, cependant, fonctionne en temps réel sur la base de requêtes et de réponses. Par conséquent, regrouper ces demandes en temps réel ne ferait que créer des contraintes et des complications. L'exécution de l'inférence sur le CPU nous a offert une voie plus simple, car sur le CPU, notre modèle Bert s'est extrêmement bien comporté même lorsque nous n'avons pas regroupé ses entrées.
- Économie de coût favorable. Pour notre classificateur de texte, l'économie de coût de l'inférence sur le CPU était meilleure que sur le GPU. Après avoir appliqué toutes les améliorations d'échelle dans cet article, le débit de notre service de classification de textes était 6 fois plus élevé par dollar sur le CPU par rapport au GPU. Plus précisément, nous pourrions étendre nos services basés sur Bert à plus de 3 000 inférences par seconde sur un serveur Intel Xeon Scalable 36 cœurs, contre 500 inférences par seconde sur un GPU Tesla V100 équivalent en coût.
Recommandation sur le CPU
Pour notre analyse comparative, nous avons constaté que les processeurs Intel Xeon Scalable de deuxième génération fonctionnaient mieux grâce à un excellent support pour l'inférence d'apprentissage profond. Par exemple, certaines de nos accélérations les plus importantes s'appuient sur des instructions de réseaux neuronaux vectoriels et sur des cœurs Fused-Multiply-Add (FMA) doubles figurant sur les processeurs Xeon évolutifs de deuxième génération. Tous les résultats que nous rapportons dans cet article concernent les puces de la série Intel Xeon Scalable Processor. Votre millage peut varier sur d'autres configurations de CPU, car nos tests sur différentes puces ont été limités.
Réglage du fil
Le premier bloqueur que nous avons rencontré avec la mise à l'échelle de l'inférence de Bert sur le CPU était que PyTorch doit être correctement réglé sur les threads avant que les processus de plusieurs travaux puissent faire une inférence de modèle simultanée. Ceci est dû au fait que dans chaque processus, le modèle PyTorch a tenté d'utiliser plusieurs cœurs pour traiter même une seule demande d'inférence. Par conséquent, chaque processus était en concurrence pour les mêmes ressources limitées (noyaux physiques). Il en résultait une stagnation lorsque trop de ces travaux fonctionnaient en même temps dans la même machine.
Heureusement, le remède à cela était simple : dans chaque processus d'inférence des travaux, nous avons simplement fixé le nombre de fils à 1 comme indiqué à la ligne 9 ci-dessous.
Quel a été l'impact de cette action ? Lorsque plusieurs travaux de travail tournaient sur la même machine, la mise à l'échelle était plus ordonnée car chaque travail n'utilisait qu'un seul cœur. C'est ce qui nous a permis de mettre à l'échelle de nombreux processus sur une seule machine, afin d'augmenter le débit sur chaque machine tout en maintenant une latence acceptable.
Scénario n°1 : Bert comme référence
La première ligne de base était un modèle Bert standard pour la classification des textes ou l'architecture décrite dans le document original de Bert. Ce modèle Bert a été créé en utilisant le modèle BertForSequenceClassication Pytorch de la bibliothèque Huggingface Transformers 2.3.0. Dans ce scénario, nous avons également fait un choix de conception intentionnellement naïf - nous avons mis à zéro le remplissage de toutes les entrées des tenseurs dans une longueur fixe de 128 tokens. La raison en est qu'il est nécessaire d'éviter tout remplissage lors de la répartition des entrées, afin que toutes les entrées soient de la même taille. Comme il est facile de mettre accidentellement à zéro le remplissage des entrées même lorsque la taille du lot est de 1, nous voulions quantifier la pénalité de performance de le faire dans ce scénario de base.
Dans l'encadré rose ci-dessous, nous indiquons le résultat de référence pour ce scénario "Bert Baseline". Même à 330 ms, la latence était trop élevée pour nos besoins de production et le débit était terrible pour le nombre de noyaux utilisés par le benchmark.
Scénario n°2 : Modèle plus petit (DistilBert)
Après la sortie de Bert en octobre 2018, de nombreux modèles ont tenté de faire avancer les choses en créant des modèles avec plus de paramètres. Alors que la base de Bert compte 110 millions de paramètres, certains de ces nouveaux modèles ont plus d'un milliard de paramètres avec des performances statistiques légèrement plus élevées et des performances de calcul nettement moins bonnes. Ce que nous voulions, cependant, c'était une meilleure performance de calcul, et nous étions prêts à sacrifier un peu de performance statistique pour l'obtenir.
Heureusement pour nous, en octobre 2019, Sanh et al ont présenté DistilBert, l'un des nombreux modèles qui ont fait échec à la tendance des grands modèles de type Bert. Ces "petits modèles" se sont concentrés sur la réduction de la taille de Bert, ce qui a permis une inférence et une formation beaucoup plus rapides, avec une perte minimale de performance statistique.
Nous avons choisi DistilBert pour deux raisons principales. Tout d'abord, DistilBert est à peu près deux fois plus rapide que Bert, mais sa performance statistique (score F1) sur notre classification de texte était à moins de 1% de Bert. Deuxièmement, la bibliothèque Huggingface Transformers nous a permis d'échanger très facilement DistilBert contre Bert ce qui nous a permis aussi de conserver nos pipelines de formation et d'inférence intacts.
Voici le résultat de référence lorsque nous avons utilisé le petit modèle DistilBert au lieu du grand Bert. Comme on peut le voir, nous avons réduit de moitié notre latence tout en doublant presque notre débit.
Scénario n°3 : De plus petits flux (formes dynamiques)
L'amélioration suivante est venue de la fabrication d'un autre objet plus petit, en l'occurrence les entrées du tenseur du modèle DistilBert.
Tout d'abord, un peu de contexte sur le remplissage zéro. Un modèle d'apprentissage profond attend généralement un lot de tenseurs comme entrée. Pour notre classificateur de texte, cela signifie représenter la saisie textuelle comme un tenseur par la tokenisation de Bert, puis regrouper ces tenseurs. Comme le texte est de longueur variable, nous devons également mettre à zéro le remplissage des tenseurs de longueur variable qui en résultent pour produire un lot de forme fixe, comme illustré ici :
Comme mentionné précédemment, notre classificateur de texte fonctionne en temps réel selon le principe de requête-réponse. En tant que tel, notre taille de lot idéale est de 1. Il s'ensuit que nous n'avons pas du tout eu besoin de remplir les tenseurs de zéro, puisque tous les tenseurs ont la même taille lorsque la taille du lot est de 1. Voici à quoi ressemblaient les textes d'entrée ci-dessus lorsque nous les avons envoyés au modèle par lots séparés sans aucun padding :
Nous appelons cela des "formes dynamiques" car nous autorisons des tenseurs de longueur variable dans chaque lot. Le passage à des entrées aux formes dynamiques a considérablement amélioré notre débit et notre latence. Intuitivement, cela a du sens car nous avons réduit les entrées du modèle en ne supprimant pas le padding. De plus, il n'y a pas eu d'impact négatif sur nos scores F1 puisque les probabilités de sortie de notre modèle étaient les mêmes, que nous utilisions des formes dynamiques ou un padding nul.
Voici le résultat de référence lorsque nous avons utilisé DistilBert + Dynamic Shapes. Comme nous pouvons le constater, nous avons réduit de plus de moitié notre latence tout en doublant notre débit.
Scénario n° 4 : des poids plus faibles (quantification)
Nous avons vu notre plus grande amélioration de performance grâce à des poids plus petits, obtenus par une technique appelée quantification.
La quantification implique l'amélioration de l'efficacité des calculs d'apprentissage profond par des représentations plus petites des poids du modèle, par exemple en représentant les poids en virgule flottante de 32 bits comme des entiers de 8 bits. La technique de quantification spécifique que nous avons utilisée pour notre modèle DistilBert était la quantification dynamique. Cette technique consiste à quantifier les poids APRÈS l'entraînement, par opposition à la quantification pendant l'entraînement (ce que l'on appelle la quantification de l'entraînement conscient).
La prise en charge de la quantification dynamique dans Pytorch 1.3+ a été très facile à mettre en œuvre et un changement d'une ligne dans notre code a permis d'utiliser des représentations entières de 8 bits des poids dans toutes les couches linéaires du modèle DistilBert :
Voici une visualisation de la façon dont cette quantification a changé le modèle original de DistilBert. Comme vous pouvez le voir, PyTorch remplacera les couches "linéaires" telles que la requête d'attention (Q), la clé (K) et la valeur (V) par une couche "linéaire quantifiée dynamique", qui utilisera les nombres entiers quantifiés de 8 bits dans ses opérations internes de multiplication/ajout.
Voici le résultat de référence lorsque nous avons utilisé Distilbert + Dynamic Shapes + Dynamic Quantization. Nous pouvons voir que l'effet combiné de ces trois optimisations crée une très grande amélioration de 30x par rapport à notre ligne de base de Bert à la vanille, à la fois en termes de latence et de débit.
Nous observons aussi un petit impact négatif F1 (< 1%) après la quantification, mais l'amélioration du débit et de la latence en a valu la peine.
Scénario n° 5 : réduction du nombre de demandes (mise en cache)
Une dernière optimisation a consisté à réduire efficacement le nombre de demandes qui sont envoyées au modèle DistilBert. Nous y sommes parvenus en mettant en cache les réponses pour les entrées de texte courantes en utilisant les identifiants de jeton comme clé. Cela a fonctionné car le modèle de réponse pour une même saisie de texte est toujours le même. En outre, plus la distribution du texte sous-jacent est non uniforme, plus son efficacité augmente.
Pour nos données, nous avons empiriquement observé un taux d'accès au cache de 40% en production lorsque nous avons mis en cache 1 million d'entrées dans la mémoire du processus. Étant donné qu'un succès de cache signifie un coût effectif de zéro pour l'inférence, notre cache a presque doublé notre débit.
Nous n'avons pas inclus l'impact de la mise en cache dans notre benchmark, car il dépend des données. Mais nous voulions nous assurer de mentionner l'impact significatif d'une simple cache.
Une note sur l'extensibilité horizontale
Tous les résultats de benchmark que vous avez vus dans l'article proviennent d'une exécution avec 32 processus simultanés sur le même serveur. Pour atteindre plus d'un milliard de requêtes par jour en production, nous avons simplement mis nos travaux à l'échelle horizontale sur de nombreux serveurs.
Futures possibilités
Bien que nous ayons discuté de nombreuses optimisations nécessaires pour faire passer nos modèles Bert PyTorch de nos laboratoires à la production, nous reconnaissons qu'il y a beaucoup d'innovations dans ce domaine qui pourraient avoir un impact sur notre stratégie d'extensibilité à l'avenir. Par exemple, nous surveillons de près Onnx Runtime parce qu'il a été très performant par rapport à nos critères non quantifiés Au moment où nous écrivons cet article, Microsoft est en train d'optimiser Bert en open source pour Onnx Runtime et nous travaillons également en étroite collaboration avec Intel pour explorer les améliorations possibles avec OpenVino.
Conclusion et points à retenir
Notre principale conclusion est qu'il existe une bonne histoire de scalabilité pour DistilBert/Bert sur CPU, en particulier pour la classification de texte en temps réel. Nous décrivons dans cet article comment nous avons réussi à augmenter d'au moins 30 fois la latence et le débit de la classification des textes de Bert en réduisant la taille du modèle (DistilBert), les entrées (Dynamic Shapes) et les poids (Quantization). Nous avons été très heureux de pouvoir répondre à plus d'un milliard de demandes par jour grâce à notre classificateur d'apprentissage profond, et de pouvoir le faire à un coût raisonnable sur l'unité centrale à des latences médianes inférieures à 20 ms.
Un grand merci à Edin Mulalić et à Saša Anđelković pour leur aide dans l'étude de nombre de ces techniques.
Ni Roblox Corporation ni ce blog ne cautionnent ni ne soutiennent aucune entreprise ou service. En outre, aucune garantie ou promesse n'est faite quant à l'exactitude, la fiabilité ou l'exhaustivité des informations contenues dans ce blog.
Cet article de blog a été publié à l'origine sur Roblox Tech Blog.