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 garantir l'expérience utilisateur et la réussite 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 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 obtenez un aperçu détaillé de ce que fait le navigateur pendant son exécution. Comprendre cette activité peut vous aider à identifier les modèles, les goulots d'étranglement et les points d'accès aux 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 en particulier qu'il charge plus rapidement d'importants volumes de données de performances. C'est le cas, par exemple, lors du profilage de processus complexes ou de longue durée, ou lors de la capture de données très précises. Pour cela, il fallait d'abord comprendre comment l'application fonctionnait et pourquoi elle a obtenu ce résultat. Elle a été réalisée à l'aide d'un outil de profilage.

Comme vous le savez peut-être, les outils de développement sont eux-mêmes une application Web. Par conséquent, il peut être profilé à l'aide du panneau Performance. Pour profiler ce panneau, vous pouvez ouvrir les outils de développement, puis ouvrir une autre instance d'outils de développement 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 de développement sera appelée "première instance DevTools" et la fenêtre qui inspecte la première instance sera appelée "deuxième instance DevTools".

<ph type="x-smartling-placeholder">
</ph> Capture d&#39;écran d&#39;une instance DevTools inspectant les éléments directement dans DevTools <ph type="x-smartling-placeholder">
</ph> DevTools-on-DevTools: inspecter les outils de développement avec les outils de développement

Dans la deuxième instance des outils de développement, le panneau Performances, qui sera désormais appelé panneau des performances, observe la première instance des outils de développement pour recréer le scénario, qui charge un profil.

<ph type="x-smartling-placeholder">

Dans la deuxième instance des outils de développement, un enregistrement en direct est démarré, 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 avec précision les performances du traitement d'entrées volumineuses. 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é, nous avons observé ce qui suit sur notre deuxième instance de panneau de performances dans la capture d'écran suivante. Concentrez-vous sur l'activité du thread principal, visible sous le canal intitulé Main. 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" est utilisé pour examiner en détail chacun de ces groupes d'activités.

Capture d&#39;écran du panneau des performances dans les outils de développement inspectant le chargement d&#39;une trace des performances dans le panneau des performances d&#39;une autre instance des outils de développement Le chargement du profil prend environ 10 secondes. Ce temps est majoritairement réparti entre cinq grands groupes d&#39;activités.

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 perdu un peu d'effort. Celle-ci a été rapidement gagnée. La suppression de cet appel de fonction a permis d'économiser environ 1,5 seconde. C'est parfait !

Deuxième groupe d'activité

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

Capture d&#39;écran du panneau &quot;Performances&quot; dans les outils de développement inspectant une autre instance du panneau &quot;Performances&quot; 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. Ici, vous pouvez voir comment le graphique en courbe bleue saute soudainement au moment de l'exécution de buildProfileCalls, ce qui suggère une fuite de mémoire potentielle.

Capture d&#39;écran du profileur de mémoire dans les outils de développement qui évalue la consommation de mémoire du panneau des performances. L&#39;outil d&#39;inspection suggère que la fonction buildProfileCalls est responsable d&#39;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 "Memory" (Mémoire), l'option "Allocation sampling" (Échantillonnage de l'allocation) Le type de profilage a été sélectionné. Celui-ci a enregistré l'instantané du segment de mémoire pour le panneau "Perf" chargé du profil de processeur.

Capture d&#39;écran de l&#39;état initial du Profileur de mémoire. L&#39;échantillonnage d&#39;allocation est encadrée en rouge et indique qu&#39;elle convient mieux au profilage de la mémoire JavaScript.

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

<ph type="x-smartling-placeholder">
Capture d&#39;écran du Profileur de mémoire, avec une opération basée sur l&#39;ensemble nécessitant une utilisation intensive de la mémoire

À partir de cet instantané de segment de mémoire, il a été constaté que la classe Set utilisait beaucoup de mémoire. En vérifiant les points d'appel, il s'est avéré que nous attribuions inutilement des propriétés de type Set à des objets créés en gros volumes. Ces coûts augmentaient et une grande quantité de mémoire était consommée, au point qu'il était courant que l'application plante sur des entrées volumineuses.

Les ensembles sont utiles pour stocker des éléments uniques et fournir des opérations qui utilisent l'unicité de leur contenu, comme la déduplication des ensembles de données et l'efficacité des recherches. Cependant, ces fonctionnalités n'étaient pas nécessaires, car l'unicité des données stockées était garantie. 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, le deuxième avantage était que l'application plantait moins fréquemment.

Capture d&#39;écran du Profileur de mémoire. L&#39;opération basée sur Set, qui utilisait auparavant beaucoup de mémoire, a été modifiée pour utiliser un tableau simple, ce qui a considérablement réduit le coût de la mémoire.

Troisième groupe d'activités: peser les compromis en matière de structure des données

La troisième section est particulière: vous pouvez voir dans le graphique de flammes qu'elle est composée 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 regardant 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 peut s'agir d'un goulot d'étranglement.

Une chose a été remarquée dans l'implémentation de la fonction appendEventAtLevel. 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 suivait la position verticale des entrées de la chronologie. Cela posait un 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. Cette amélioration significative a confirmé que le goulot d'étranglement était bien lié aux frais généraux engendrés par l'ajout de toutes les données à la carte. Temps passé par le groupe d'activité est passé d'environ 1,4 seconde à environ 200 millisecondes.

Avant :

Capture d&#39;écran du panneau &quot;Performances&quot; avant l&#39;apport d&#39;optimisations à la fonction &quot;appendEventAtLevel&quot;. La durée totale d&#39;exécution de la fonction était de 1 372,51 millisecondes.

Après :

Capture d&#39;écran du panneau &quot;Performances&quot; après que des optimisations ont été apportées à la fonction &quot;appendEventAtLevel&quot;. La durée totale d&#39;exécution de la fonction était 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 zoomant 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 sont constitués de code qui crée des arbres (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 arborescences 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&#39;écran du panneau &quot;Performances&quot; montrant plusieurs tâches répétitives qui s&#39;exécutent même si elles ne sont pas nécessaires. Ces tâches peuvent être différées pour s&#39;exécuter à la demande plutôt qu&#39;à l&#39;avance.

Cette image présente deux problèmes:

  1. Une tâche non critique engendrait 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, même si les données ne changent pas.

Nous avons commencé par reporter le calcul de l'arborescence au moment où l'utilisateur l'a ouverte manuellement. 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 double exécution était d'environ 3, 4 secondes.Le report de l'exécution 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: éviter les hiérarchies d'appels complexes dans la mesure du possible

En examinant de près ce groupe, il est évident qu'une chaîne d'appel spécifique était appelé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&#39;écran du panneau &quot;Performances&quot; montrant six appels de fonction distincts permettant de générer la même mini-carte de trace, chacun ayant des piles d&#39;appels profondes.

Le code associé, appelé plusieurs fois, est la partie qui traite les données à afficher sur la "mini-carte". (l'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'avère que le code associé a été appelé en raison de l'appel direct ou indirect de plusieurs parties du pipeline de chargement à la fonction qui calcule la mini-carte. En effet, la complexité du graphique des appels du programme a évolué au fil du temps, et d'autres dépendances à ce code ont été ajoutées à votre insu. 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&#39;écran du panneau &quot;Performances&quot; montrant les six appels de fonction distincts permettant de générer la même mini-carte de trace, dont la taille est réduite à deux fois seulement.

Notez que l'exécution du rendu de la mini-carte se produit deux fois, et non une fois. En effet, deux mini-cartes sont dessinées pour chaque profil: l'une pour l'aperçu en haut du panneau et l'autre pour le menu déroulant qui sélectionne le profil actuellement visible dans l'historique (chaque élément de ce menu contient une vue d'ensemble du profil sélectionné). Néanmoins, ces deux types de contenus ont exactement le même contenu. L'un doit 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 fallu utiliser l'utilitaire canevas 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&#39;écran du panneau &quot;Performances&quot; montrant le chargement de la trace avant les optimisations. Le processus a pris environ dix secondes.

Après :

<ph type="x-smartling-placeholder">
</ph> Capture d&#39;écran du panneau des performances montrant le chargement de la trace après les optimisations. Le processus prend à présent environ deux secondes.
<ph type="x-smartling-placeholder">

Après les améliorations, le temps de chargement était de 2 secondes. Cela signifie qu'une amélioration d'environ 80% a été obtenue avec un effort relativement faible, la majeure partie de l'opération consistant à réaliser des correctifs rapides. Bien sûr, il était essentiel d'identifier correctement les actions à effectuer 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 spécifiques à un profil utilisé comme sujet d'étude. Ce profil nous intéressait, car il était particulièrement volumineux. Néanmoins, 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 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 modèles de performances lors de l'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 des opportunité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 d'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 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 retirer était le fruit le plus évident.

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

Utiliser des structures de données pour optimiser les performances, mais également comprendre les coûts et les compromis que chaque type de structure de données entraîne pour 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 doublons lors d'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 vous en aurez besoin. Il est également judicieux de procéder ainsi si l'opération est effectuée plusieurs fois, même si chaque exécution n'est pas particulièrement coûteuse.

6. Reporter 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 étend le chemin critique, envisagez de la différer en l'appelant en différé lorsque sa sortie est réellement nécessaire.

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

Pour les entrées volumineuses, il est crucial d'optimiser les algorithmes de complexité temporelle. Nous n'avons pas étudié cette catégorie dans cet exemple, mais son importance est loin d'être surestimée.

8. Bonus: comparer vos pipelines

Pour vous assurer que l'évolution de votre code reste rapide, il est judicieux de surveiller son comportement et de le comparer aux normes. De cette façon, vous identifiez les régressions de manière proactive et améliorez la fiabilité globale, ce qui vous donne les moyens de réussir sur le long terme.