Panneau d'amélioration des performances 400% plus rapide grâce aux performances

Andrés Olivares
Andrés Olivares
Nancy Li
Nancy Li

Quel que soit le type d'application que vous développez, il est essentiel d'optimiser ses performances, de s'assurer qu'elle se charge rapidement et offre des interactions fluides pour assurer l'expérience utilisateur et la réussite de l'application. Pour ce faire, vous pouvez, par exemple, inspecter l'activité d'une application à l'aide d'outils de profilage pour voir ce qui se passe en arrière-plan pendant son exécution pendant une période donnée. Le panneau Performances des outils de développement est un excellent outil de profilage qui permet d'analyser et d'optimiser les performances des applications Web. Si votre application s'exécute dans Chrome, vous bénéficiez d'un aperçu visuel détaillé de ce que fait le navigateur pendant son exécution. Comprendre cette activité peut vous aider à identifier des modèles, des goulots d'étranglement et des hotspots de performances sur lesquels vous pouvez agir pour améliorer les performances.

L'exemple suivant vous montre comment utiliser le panneau Performance.

Configurer et recréer notre scénario de profilage

Nous nous sommes récemment fixé pour objectif d'améliorer les performances du panneau Performances. Plus précisément, nous voulions qu'il charge plus rapidement d'importants volumes de données sur les performances. C'est le cas, par exemple, pour profiler des processus longs ou complexes, ou pour capturer des données très précises. Pour ce faire, il était d'abord nécessaire de comprendre les performances de l'application et les raisons qu'elle avait adoptées. Cela a été fait à l'aide d'un outil de profilage.

Comme vous le savez peut-être, DevTools est lui-même une application Web. Vous pouvez donc la profiler à l'aide du panneau Performances. Pour profiler ce panneau lui-même, vous pouvez ouvrir les outils de développement, puis ouvrir une autre instance d'outils de développement qui lui est associée. Chez Google, cette configuration est connue sous le nom de DevTools-on-DevTools.

Une fois la configuration prête, le scénario à profiler doit être recréé et enregistré. Pour éviter toute confusion, la fenêtre d'outils de développement d'origine sera appelée "première instance des outils de développement", et la fenêtre qui inspecte la première instance sera appelée "deuxième instance d'outils de développement".

Capture d'écran d'une instance DevTools inspectant les éléments directement dans les outils de développement.
DevTools-on-DevTools: inspection des outils de développement à l'aide des outils de développement.

Sur la deuxième instance d'outils de développement, le panneau Performances, qui sera à partir de maintenant appelé panneau Perf, observe la première instance DevTools qui recrée le scénario qui charge un profil.

Sur la deuxième instance DevTools, un enregistrement en direct est lancé, tandis que sur la première instance, un profil est chargé à partir d'un fichier sur le disque. Un fichier volumineux est chargé afin de profiler précisément les performances de traitement des entrées volumineuses. Une fois le chargement des deux instances terminé, les données de profilage des performances (communément appelées trace) sont visibles dans la deuxième instance des outils de développement du panneau "Perf" qui charge un profil.

L'état initial: identifier les possibilités d'amélioration

Une fois le chargement terminé, ce qui suit sur notre deuxième instance de panneau de performances a été observé dans la capture d'écran suivante. Concentrez-vous sur l'activité du thread principal, qui est visible sous le canal intitulé Main (Principal). On peut voir qu'il y a cinq grands groupes d'activité dans le graphique de flammes. Il s'agit des tâches pour lesquelles le chargement prend le plus de temps. La durée totale de ces tâches était d'environ 10 secondes. Dans la capture d'écran suivante, le panneau des performances permet de se concentrer sur chacun de ces groupes d'activités afin de voir ce que l'on peut trouver.

Capture d'écran du panneau des performances dans les outils de développement inspectant le chargement d'une trace des performances dans le panneau des performances d'une autre instance des outils de développement. Le chargement du profil prend environ 10 secondes. Ce temps est principalement réparti entre cinq groupes d'activité principaux.

Premier groupe d'activité: travail inutile

Il est devenu évident que le premier groupe d'activités était constitué de code ancien qui fonctionnait encore, mais qui n'était pas vraiment nécessaire. En fait, tout ce qui se trouve sous le bloc vert intitulé processThreadEvents a été un gaspillage d'énergie. Celle-ci a été une victoire rapide. La suppression de cet appel de fonction a permis de gagner environ 1,5 seconde de temps. C'est parfait !

Deuxième groupe d'activité

Dans le deuxième groupe d'activités, la solution n'était pas aussi simple que dans le premier. L'activité buildProfileCalls a pris environ 0,5 seconde, et cette tâche ne pouvait pas être évitée.

Capture d'écran du panneau "Performances" dans les outils de développement inspectant une autre instance du panneau "Performances". Une tâche associée à la fonction buildProfileCalls prend environ 0,5 seconde.

Par curiosité, nous avons activé l'option Memory (Mémoire) dans le panneau de performances afin d'approfondir l'examen, et nous avons constaté que l'activité buildProfileCalls utilisait également beaucoup de mémoire. Ici, vous pouvez voir comment le graphique en courbe bleue varie soudainement au moment de l'exécution de buildProfileCalls, ce qui suggère une fuite de mémoire potentielle.

Capture d'écran du profileur de mémoire dans les outils de développement, qui évalue la consommation de mémoire du panneau "Performances". L'inspecteur suggère que la fonction buildProfileCalls est responsable d'une fuite de mémoire.

Pour poursuivre sur cette suspicion, nous avons utilisé le panneau "Memory" (Mémoire) (un autre panneau de DevTools, différent du panneau "Memory" (Mémoire) dans le panneau "Perf" (Perf). Dans le panneau "Memory" (Mémoire), le type de profilage "Allocation sampling" (Échantillonnage de l'allocation) a été sélectionné. Il a enregistré l'instantané du tas de mémoire pour le panneau "Perf" qui charge le profil de processeur.

Capture d'écran de l'état initial du profileur de mémoire. L'option "Allocation sampling" (Échantillonnage d'allocation) est mise en évidence par un cadre rouge et indique qu'elle est idéale pour le profilage de mémoire JavaScript.

La capture d'écran suivante montre l'instantané du segment de mémoire qui a été collecté.

Capture d'écran du Profileur de mémoire, avec une opération "Set" qui utilise beaucoup de mémoire et est sélectionnée.

D'après cet instantané de segment de mémoire, il a été observé que la classe Set consommait beaucoup de mémoire. En vérifiant les points d'appel, il s'avère que nous attribuons inutilement des propriétés de type Set aux objets créés dans de grands volumes. Ce coût s'élevait et une grande quantité de mémoire était consommée, au point que l'application plantait souvent sur des entrées volumineuses.

Les ensembles sont utiles pour stocker des éléments uniques et fournissent des opérations qui exploitent l'unicité de leur contenu, comme la déduplication des ensembles de données et l'amélioration de l'efficacité des recherches. Cependant, ces fonctionnalités n'étaient pas nécessaires, car le caractère unique des données stockées était garanti par rapport à la source. Les décors n'étaient donc pas nécessaires au départ. Pour améliorer l'allocation de mémoire, le type de propriété Set a été remplacé par un tableau simple. Après l'application de cette modification, un autre instantané de segment de mémoire a été pris et une réduction de l'allocation de mémoire a été observée. Bien que ce changement n'ait pas permis d'améliorer considérablement la vitesse, l'avantage secondaire était que l'application plantait moins fréquemment.

Capture d'écran du Profileur de mémoire. Modification de l'opération basée sur Set, qui était auparavant très gourmande en mémoire, pour utiliser une matrice simple, ce qui a considérablement réduit le coût de la mémoire.

Troisième groupe d'activité: Pondération des compromis en matière de structure des données

La troisième section est étrange: vous pouvez voir dans le graphique des flammes qu'elle se compose de colonnes étroites, mais hautes, qui désignent des appels de fonction profonds, et des récursions profondes dans ce cas. Au total, cette section a duré environ 1,4 seconde. En examinant le bas de cette section, il est apparu que la largeur de ces colonnes était déterminée par la durée d'une fonction, appendEventAtLevel, ce qui suggère qu'il pourrait s'agir d'un goulot d'étranglement

Une chose est apparue dans l'implémentation de la fonction appendEventAtLevel. Pour chaque entrée de données (appelée "événement") dans le code, un élément a été ajouté à une carte qui suivait la position verticale des entrées de la chronologie. Cela posait problème, car la quantité d'éléments stockés était très importante. Les cartes sont rapides pour les recherches par clé, mais cet avantage n'est pas sans frais. À mesure qu'une carte s'agrandit, l'ajout de données à celle-ci peut, par exemple, devenir coûteux en raison du rehachage. Ce coût devient perceptible lorsque de grandes quantités d'éléments sont ajoutés successivement à la carte.

/**
 * Adds an event to the flame chart data at a defined vertical level.
 */
function appendEventAtLevel (event, level) {
  // ...

  const index = data.length;
  data.push(event);
  this.indexForEventMap.set(event, index);

  // ...
}

Nous avons testé une autre approche qui ne nous obligeait pas à ajouter un élément dans une carte pour chaque entrée du graphique de flammes. L'amélioration a été significative, confirmant que le goulot d'étranglement était bien lié aux frais généraux induits par l'ajout de toutes les données à la carte. Le temps nécessaire au groupe d'activité est passé d'environ 1,4 seconde à environ 200 millisecondes.

Avant :

Capture d'écran du panneau des performances avant les optimisations de la fonction addEventAtLevel. La durée totale d'exécution de la fonction était de 1 372,51 millisecondes.

Après :

Capture d'écran du panneau des performances après les optimisations apportées à la fonction addEventAtLevel. La durée totale d'exécution de la fonction était de 207,2 millisecondes.

Quatrième groupe d'activités: reporter les tâches non critiques et mettre en cache les données pour éviter les tâches en double

Si on fait un zoom avant sur cette fenêtre, on constate qu'il y a deux blocs d'appels de fonction presque identiques. En examinant le nom des fonctions appelées, vous pouvez déduire que ces blocs sont constitués de code qui crée des arborescences (par exemple, avec des noms tels que refreshTree ou buildChildren). En fait, le code associé est celui qui crée les arborescences dans le panneau inférieur du panneau. Il est intéressant de noter que ces vues sous forme d'arborescence ne s'affichent pas juste après le chargement. À la place, l'utilisateur doit sélectionner une arborescence (les onglets "Ascendant", "Arborescence d'appel" et "Journal des événements" du panneau) pour afficher les arborescences. De plus, comme vous pouvez le constater sur la capture d'écran, le processus de création d'arborescence a été exécuté deux fois.

Capture d'écran du panneau des performances montrant plusieurs tâches répétitives qui s'exécutent même si elles ne sont pas nécessaires. Ces tâches peuvent être différées pour être exécutées à la demande, plutôt qu'à l'avance.

Nous avons identifié deux problèmes avec cette image:

  1. Une tâche non critique entrave les performances du temps de chargement. Les utilisateurs n'ont pas toujours besoin de sa sortie. Par conséquent, la tâche n'est pas essentielle au chargement du profil.
  2. Le résultat de ces tâches n'a pas été mis en cache. C'est pourquoi les arbres ont été calculés deux fois, alors que les données ne changent pas.

Nous avons commencé par reporter le calcul de l'arborescence au moment où l'utilisateur a ouvert manuellement l'arborescence. Ce n'est qu'alors que cela vaut la peine de payer le prix de la création de ces arbres. Le temps total d'exécution de cette commande à deux reprises était d'environ 3, 4 secondes.Le fait de reporter cette exécution a donc considérablement amélioré le temps de chargement. Nous étudions toujours la mise en cache de ces types de tâches également.

Cinquième groupe d'activités: évitez les hiérarchies d'appels complexes si possible

En examinant de près ce groupe, il est apparu clairement qu'une chaîne d'appel particulière était invoquée à plusieurs reprises. Le même schéma est apparu six fois à différents endroits du graphique de flammes, et la durée totale de cette fenêtre était d'environ 2,4 secondes.

Capture d'écran du panneau "Performances" montrant six appels de fonction distincts pour générer la même mini-map de trace, chacun avec des piles d'appels profonds

Le code associé appelé plusieurs fois est la partie qui traite les données à afficher sur la mini-carte (la présentation de l'activité de la timeline en haut du panneau). La raison pour laquelle cela se produisait plusieurs fois n'était pas claire, mais cela n'a certainement pas dû se produire six fois ! En fait, la sortie du code doit rester à jour si aucun autre profil n'est chargé. En théorie, le code ne doit s'exécuter qu'une seule fois.

Après examen, il s'avère que le code associé a été appelé en raison de plusieurs parties du pipeline de chargement qui appellent directement ou indirectement la fonction qui calcule la mini-carte. En effet, la complexité du graphique d'appel du programme a évolué au fil du temps, et d'autres dépendances à ce code ont été ajoutées à l'insu. Il n'existe pas de solution rapide pour résoudre ce problème. La manière de le résoudre dépend de l'architecture du codebase en question. Dans notre cas, nous avons dû réduire légèrement la complexité de la hiérarchie des appels et ajouter une vérification pour empêcher l'exécution du code si les données d'entrée restaient inchangées. Après avoir implémenté cette fonctionnalité, nous avons eu cette idée du calendrier:

Capture d'écran du panneau des performances montrant les six appels de fonction distincts pour générer la même mini-map de trace réduite à deux fois seulement.

Notez que le rendu de la mini-carte est exécuté deux fois, et non une fois. En effet, deux minicartes sont tracées pour chaque profil: l'une pour la vue d'ensemble en haut du panneau, et l'autre pour le menu déroulant permettant de sélectionner le profil actuellement visible dans l'historique (chaque élément de ce menu contient une vue d'ensemble du profil sélectionné). Malgré tout, ces deux éléments ont exactement le même contenu, l'un doit donc pouvoir être réutilisé pour l'autre.

Comme ces mini-cartes sont des images dessinées sur un canevas, il a fallu utiliser l'utilitaire Canvas drawImage, puis exécuter le code une seule fois pour gagner du temps. Grâce à cet effort, la durée du groupe est passée de 2,4 secondes à 140 millisecondes.

Conclusion

Après avoir appliqué toutes ces corrections (et quelques autres plus petites ici et là), la modification de la chronologie de chargement du profil se présente comme suit:

Avant :

Capture d'écran du panneau des performances montrant le chargement de la trace avant les optimisations. Le processus a pris environ dix secondes.

Après :

Capture d'écran du panneau "Performances" montrant le chargement de la trace après les optimisations. Le processus prend désormais environ deux secondes.

Le temps de chargement après amélioration était de deux secondes. Ainsi, une amélioration d'environ 80% a été obtenue avec un effort relativement faible, car la plupart des opérations consistaient à apporter des corrections rapides. Bien sûr, il était essentiel d'identifier correctement l'action à effectuer au départ, et le panneau de performances s'est avéré être l'outil idéal pour cela.

Il est également important de souligner que ces chiffres sont propres à un profil utilisé comme sujet d'étude. Ce profil nous intéressait, car il était particulièrement volumineux. Toutefois, comme le pipeline de traitement est le même pour tous les profils, l'amélioration significative obtenue s'applique à chaque profil chargé dans le panneau de performances.

Points à retenir

Vous pouvez tirer des enseignements de ces résultats en termes d'optimisation des performances de votre application:

1. Identifier les modèles de performances d'exécution à l'aide d'outils de profilage

Les outils de profilage sont incroyablement utiles pour comprendre ce qui se passe dans votre application pendant son exécution, en particulier pour identifier les opportunités d'amélioration des performances. Le panneau "Performances" des outils pour les développeurs Chrome est une excellente option pour les applications Web, car il s'agit de l'outil de profilage Web natif du navigateur. De plus, il fait l'objet d'une maintenance active afin de toujours disposer des dernières fonctionnalités de la plate-forme Web. En outre, il est désormais beaucoup plus rapide ! 😉

Utilisez des exemples pouvant être utilisés comme charges de travail représentatives et voyez ce que vous pouvez trouver.

2. Éviter les hiérarchies d'appels complexes

Dans la mesure du possible, évitez de compliquer le graphique des appels. Avec des hiérarchies d'appels complexes, il est facile d'introduire des régressions de performances et il est difficile de comprendre pourquoi votre code s'exécute tel quel. Il est donc difficile d'apporter des améliorations.

3. Identifier les tâches inutiles

Il est courant que les codebases vieillissants contiennent du code qui n'est plus nécessaire. Dans notre cas, le code ancien et inutile occupait une grande partie du temps de chargement total. La suppression était la tâche la plus simple.

4. Utiliser les structures de données de manière appropriée

Utilisez les structures de données pour optimiser les performances, mais comprenez également les coûts et les inconvénients que chaque type de structure de données entraîne au moment de décider laquelle utiliser. Il ne s'agit pas seulement de la complexité de l'espace de la structure de données elle-même, mais aussi de la complexité temporelle des opérations applicables.

5. Mettre en cache les résultats pour éviter les tâches en double pour les opérations complexes ou répétitives

Si l'exécution de l'opération est coûteuse, il est judicieux de stocker ses résultats pour la prochaine fois que cela est nécessaire. Il est également logique de procéder ainsi si l'opération est effectuée plusieurs fois, même si chaque fois qu'un utilisateur n'est pas particulièrement coûteux.

6. Reporter les tâches non critiques

Si la sortie d'une tâche n'est pas nécessaire immédiatement et que son exécution prolonge le chemin critique, envisagez de la reporter en l'appelant de manière différée lorsque sa sortie est réellement nécessaire.

7. Utiliser des algorithmes efficaces sur les entrées volumineuses

Pour les entrées volumineuses, il est primordial d'utiliser des algorithmes de complexité temporelle optimaux. Nous n'avons pas examiné cette catégorie dans cet exemple, mais son importance ne peut être exagérée.

8. Bonus: comparer vos pipelines

Pour vous assurer que votre code évolue rapidement, il est judicieux de surveiller le comportement et de le comparer aux normes. De cette façon, vous identifiez de manière proactive les régressions et améliorez la fiabilité globale, ce qui vous prépare à votre réussite à long terme.