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, optimiser ses performances, vous assurer qu'elle se charge rapidement et qu'elle offre des interactions fluides est essentiel pour l'expérience utilisateur et le succès de l'application. Pour ce faire, vous pouvez inspecter l'activité d'une application à l'aide d'outils de profilage afin de voir ce qui se passe en arrière-plan pendant son exécution au cours d'une période donnée. Le panneau "Performances" de DevTools est un excellent outil de profilage pour analyser et optimiser les performances des applications Web. Si votre application s'exécute dans Chrome, elle vous fournit une vue visuelle détaillée de ce que fait le navigateur pendant l'exécution de votre application. Comprendre cette activité peut vous aider à identifier des tendances, des goulots d'étranglement et des points chauds de performances sur lesquels vous pouvez agir pour améliorer les performances.

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

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. Nous voulions notamment qu'il charge plus rapidement d'importants volumes de données de performances. C'est le cas, par exemple, lorsque vous effectuez le profilage de processus longs ou complexes, ou lorsque vous collectez des données à haute granularité. Pour ce faire, il a d'abord fallu comprendre comment l'application fonctionnait et pourquoi elle fonctionnait de cette manière. Pour ce faire, nous avons utilisé un outil de profilage.

Comme vous le savez peut-être, les outils pour les développeurs sont eux-mêmes une application Web. Il peut donc être profilé à l'aide du panneau Performances. Pour profiler ce panneau lui-même, vous pouvez ouvrir les outils pour les développeurs, puis une autre instance d'outils pour les développeurs qui y est associée. Chez Google, cette configuration s'appelle 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'origine des outils pour les développeurs sera appelée"première instance des outils pour les développeurs", et la fenêtre qui inspecte la première instance sera appelée "deuxième instance des outils pour les développeurs".

Capture d'écran d'une instance DevTools inspectant les éléments dans DevTools elle-même.
DevTools sur DevTools: inspection des outils de développement avec les outils de développement.

Dans la deuxième instance DevTools, le panneau Performances (qui sera appelé panneau "perf" à partir de maintenant) observe la première instance DevTools pour recréer le scénario, qui charge un profil.

Dans la deuxième instance DevTools, un enregistrement en direct est lancé, tandis que dans 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 du traitement de grandes entrées. Une fois le chargement des deux instances terminé, les données de profilage des performances (communément appelées trace) s'affichent dans la deuxième instance des outils de développement du panneau des performances qui charge un profil.

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

Une fois le chargement terminé, la capture d'écran suivante montre ce qui s'est passé avec notre deuxième instance de panneau de performances. Concentrez-vous sur l'activité du thread principal, qui est visible sous la piste intitulée Main (Principal). Comme vous pouvez le voir, le graphique de type "flamme" comporte cinq grands groupes d'activité. 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 "Performances" permet de se concentrer sur chacun de ces groupes d'activités pour voir ce qui peut être trouvé.

Capture d'écran du panneau "Performances" dans DevTools, qui inspecte le chargement d'une trace de performances dans le panneau "Performances" d'une autre instance DevTools. Le chargement du profil prend environ 10 secondes. Ce temps est principalement réparti en cinq groupes d'activités principaux.

Premier groupe d'activités: travail inutile

Il est devenu évident que le premier groupe d'activités était constitué d'un ancien code toujours exécuté, mais qui n'était pas vraiment nécessaire. En gros, tout ce qui se trouvait dans le bloc vert intitulé processThreadEvents a gaspillé des efforts. Vous avez gagné rapidement. La suppression de cet appel de fonction a permis de gagner environ 1,5 seconde. C'est parfait !

Deuxième groupe d'activités

Dans le deuxième groupe d'activités, la solution n'était pas aussi simple que dans le premier. L'opération 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, qui inspecte une autre instance du panneau "Performances". Une tâche associée à la fonction buildProfileCalls prend environ 0,5 seconde.

Par simple curiosité, nous avons activé l'option Memory (Mémoire) dans le panneau "Perf" afin d'approfondir nos recherches. Nous avons constaté que l'activité buildProfileCalls utilisait également beaucoup de mémoire. Vous pouvez voir ici que le graphique de la ligne bleue saute 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 évaluant la consommation de mémoire du panneau des performances. L'inspecteur suggère que la fonction buildProfileCalls est responsable d'une fuite de mémoire.

Pour faire suite à ce soupçon, nous avons utilisé le panneau "Memory" (Mémoire) (un autre panneau dans les outils de développement, différent du panneau "Memory" (Mémoire) du panneau "Perf" (Perf) pour examiner la situation. Dans le panneau "Mémoire", le type de profilage "Échantillonnage d'allocation" a été sélectionné, ce qui a enregistré l'instantané du tas de mémoire pour le panneau "Performances" qui charge le profil CPU.

Capture d'écran de l'état initial du profileur de mémoire. L'option "Allocation sampling" (Échantillonnage d'allocation) est encadrée en rouge, ce qui indique qu'elle convient mieux au profilage de la mémoire JavaScript.

La capture d'écran suivante montre l'instantané de tas qui a été collecté.

Capture d'écran du profileur de mémoire, avec une opération basée sur des ensembles gourmande en mémoire sélectionnée.

À partir de cet instantané de tas, il a été observé que la classe Set consommait beaucoup de mémoire. Après vérification des points d'appel, nous avons constaté que nous attribuions inutilement des propriétés de type Set à des objets créés en grande quantité. Ce coût s'accumulait et une grande quantité de mémoire était consommée, au point que l'application plantait fréquemment en cas d'entrées volumineuses.

Les ensembles sont utiles pour stocker des éléments uniques et fournir des opérations qui exploitent l'unicité de leur contenu, comme la déduplication des ensembles de données et la fourniture de recherches plus efficaces. Cependant, ces fonctionnalités n'étaient pas nécessaires, car les données stockées étaient garanties comme étant uniques par rapport à la source. Par conséquent, les ensembles n'étaient pas nécessaires au départ. Pour améliorer l'allocation de mémoire, le type de propriété est passé d'un Set à un tableau simple. Après l'application de cette modification, un autre instantané de segment de mémoire a été pris, ce qui a entraîné une réduction de l'allocation de mémoire. Bien que cette modification n'ait pas permis d'améliorer considérablement la vitesse, elle a eu un autre avantage : l'application plantait moins fréquemment.

Capture d'écran du profileur de mémoire. L'opération basée sur des ensembles, qui était auparavant gourmande en mémoire, a été modifiée pour utiliser un tableau simple, ce qui a considérablement réduit les coûts de mémoire.

Troisième groupe d'activités: évaluer les compromis liés à la structure des données

La troisième section est particulière: vous pouvez voir dans le graphique de type "flamme" qu'elle se compose de colonnes étroites mais hautes, qui indiquent 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érait qu'il pourrait s'agir d'un goulot d'étranglement.

Dans l'implémentation de la fonction appendEventAtLevel, une chose se démarque. Pour chaque entrée de données de l'entrée (appelée "événement" dans le code), un élément a été ajouté à une carte qui a suivi la position verticale des entrées de la chronologie. Cela posait problème, car le nombre d'éléments stockés était très important. Les cartes sont rapides pour les recherches basées sur des clés, mais cet avantage n'est pas sans frais. Lorsqu'une carte s'agrandit, l'ajout de données peut, par exemple, s'avérer coûteux en raison du nouveau hachage. Ce coût devient perceptible lorsque de grandes quantités d'éléments sont ajoutées 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 à une carte pour chaque entrée du graphique de flammes. L'amélioration a été significative, ce qui confirme que le goulot d'étranglement était bien lié aux frais généraux générés par l'ajout de toutes les données à la carte. La durée du groupe d'activités est passée d'environ 1,4 seconde à environ 200 millisecondes.

Avant :

Capture d'écran du panneau "Performances" avant l'apport d'optimisations à la fonction "appendEventAtLevel". 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 avoir optimisé la fonction appendEventAtLevel. La durée totale d'exécution de la fonction a été de 207,2 millisecondes.

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

En faisant un zoom avant sur cette fenêtre, vous pouvez voir qu'il existe deux blocs d'appels de fonction presque identiques. En examinant le nom des fonctions appelées, vous pouvez déduire que ces blocs consistent en du code qui crée des arborescences (par exemple, avec des noms comme refreshTree ou buildChildren). En fait, c'est le code associé qui crée les vues d'arborescence dans le tiroir inférieur du panneau. Il est intéressant de noter que ces vues d'arborescence ne s'affichent pas immédiatement après le chargement. À la place, l'utilisateur doit sélectionner une arborescence (les onglets "Ascendant", "Arborescence d'appel" et "Journal des événements" dans le panneau) pour qu'elles s'affichent. De plus, comme vous pouvez le constater sur la capture d'écran, le processus de création de l'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.

Cette image présente deux problèmes:

  1. Une tâche non critique freinait 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 critique pour le 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, même si les données n'ont pas changé.

Nous avons commencé par reporter le calcul de l'arborescence au moment où l'utilisateur l'a ouverte manuellement. C'est seulement à ce moment-là que la création de ces arbres en vaut la peine. Le temps total d'exécution de cette opération deux fois était d'environ 3,4 secondes. Le report a donc eu un impact significatif sur le temps de chargement. Nous étudions toujours la mise en cache de ces types de tâches.

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

En examinant attentivement ce groupe, il était clair qu'une chaîne d'appels particulière était appelée à plusieurs reprises. Le même motif est apparu six fois à différents endroits du graphique en forme de flamme, 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 minicarte de trace, chacun d'eux comportant des piles d'appels profondes.

Le code associé appelé plusieurs fois est la partie qui traite les données à afficher sur le "mini-plan" (aperçu de l'activité de la chronologie en haut du panneau). Je n'ai pas compris pourquoi cela se produisait plusieurs fois, 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'est avéré que le code associé était appelé en raison de plusieurs parties du pipeline de chargement qui appelaient directement ou indirectement la fonction qui calcule la minicarte. En effet, la complexité du graphique des appels du programme a évolué au fil du temps, et d'autres dépendances ont été ajoutées à ce code à l'insu des développeurs. Il n'existe pas de solution rapide à ce problème. La solution dépend de l'architecture du codebase en question. Dans notre cas, nous avons dû réduire un peu 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 sont restées inchangées. Après la mise en œuvre, nous avons obtenu les perspectives suivantes:

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

Notez que l'exécution du rendu de la minicarte se produit deux fois, et non une seule fois. En effet, deux mini-cartes sont dessinées pour chaque profil: une pour l'aperçu en haut du panneau et une pour le menu déroulant qui sélectionne le profil actuellement visible dans l'historique (chaque élément de ce menu contient un aperçu du profil qu'il sélectionne). Toutefois, ces deux éléments ont exactement le même contenu. L'un devrait donc pouvoir être réutilisé pour l'autre.

Étant donné que ces mini-cartes sont toutes deux des images dessinées sur un canevas, il a suffi d'utiliser l'utilitaire de canevas drawImage, puis d'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à), le calendrier de chargement du profil a évolué comme suit:

Avant :

Capture d'écran du panneau "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 les améliorations était de deux secondes, ce qui signifie qu'une amélioration d'environ 80% a été obtenue avec un effort relativement faible, car la plupart des corrections étaient rapides. Bien sûr, il était essentiel d'identifier correctement ce que faire au départ, et le panneau des performances était 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 particulièrement, car il était particulièrement volumineux. Toutefois, comme le pipeline de traitement est le même pour chaque profil, l'amélioration significative obtenue s'applique à chaque profil chargé dans le panneau des performances.

Points à retenir

Voici quelques enseignements à tirer de ces résultats en termes d'optimisation des performances de votre application:

1. Utiliser des outils de profilage pour identifier les tendances de performances d'exécution

Les outils de profilage sont extrêmement utiles pour comprendre ce qui se passe dans votre application pendant son exécution, en particulier pour identifier les possibilités d'amélioration des performances. Le panneau "Performances" des outils pour les développeurs Chrome constitue 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 pour bénéficier des dernières fonctionnalités de la plate-forme Web. De plus, il est désormais beaucoup plus rapide ! 😉

Utilisez des exemples pouvant servir de charges de travail représentatives et découvrez ce que vous pouvez y trouver.

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

Dans la mesure du possible, évitez de rendre votre graphique des appels trop compliqué. 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 ainsi, ce qui complique la mise en place d'améliorations.

3. Identifier les tâches inutiles

Il est courant que les bases de code anciennes 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 de cette fonctionnalité était la solution la plus simple.

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

Utilisez des structures de données pour optimiser les performances, mais tenez également compte des coûts et des compromis associés à chaque type de structure de données lorsque vous choisissez lesquelles utiliser. Il ne s'agit pas seulement de la complexité spatiale 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 logique de stocker ses résultats pour la prochaine fois que vous en aurez besoin. Cela est également logique si l'opération est effectuée plusieurs fois, même si chaque fois n'est pas particulièrement coûteuse.

6. Reportez les tâches non critiques.

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

7. Utiliser des algorithmes efficaces pour de grandes entrées

Pour les entrées volumineuses, les algorithmes de complexité temporelle optimale deviennent cruciaux. Nous n'avons pas examiné cette catégorie dans cet exemple, mais son importance est difficile à exagérer.

8. Bonus: Comparer vos pipelines

Pour vous assurer que l'évolution de votre code reste rapide, il est judicieux de surveiller le comportement et de le comparer aux normes. Vous pouvez ainsi identifier de manière proactive les régressions et améliorer la fiabilité globale, ce qui vous permet de réussir sur le long terme.