Multiplication par 10 des traces de la pile des outils pour les développeurs Chrome

Benedikt Meurer
Benedikt Meurer

Les développeurs Web s'attendent désormais à peu ou pas à aucun impact sur les performances lors du débogage de leur code. Cependant, cette attente n'est en aucun cas universelle. Un développeur C++ ne s'attendrait jamais à ce qu'une version de débogage de son application atteigne les performances de production. Dans les premières années de Chrome, le simple fait d'ouvrir les outils de développement a eu un impact significatif sur les performances de la page.

Cette dégradation des performances n'est plus ressentie après des années d'investissement dans les fonctionnalités de débogage de DevTools et V8. Néanmoins, nous ne serons jamais en mesure de réduire à zéro l'impact des outils de développement sur les performances. La définition de points d'arrêt, l'exécution du code, la collecte de traces de la pile, la capture d'une trace des performances, etc. ont tous un impact plus ou moins élevé sur la vitesse d'exécution. Après tout, observer quelque chose la change.

Toutefois, les frais généraux associés aux outils de développement, comme n'importe quel débogueur, devraient être raisonnables. Nous avons récemment constaté une augmentation significative du nombre de rapports indiquant que, dans certains cas, les outils de développement ralentiraient l'application à tel point qu'elle ne serait plus utilisable. Vous pouvez voir ci-dessous une comparaison côte à côte du rapport chromium:1069425, qui illustre l'impact sur les performances de l'ouverture des Outils de développement.

Comme vous pouvez le constater dans la vidéo, le ralentissement est de l'ordre de 5 à 10 fois, ce qui n'est clairement pas acceptable. La première étape consistait à comprendre où le temps passait et les causes de cet énorme ralentissement lorsque les outils de développement étaient ouverts. L'utilisation de Linux perf dans le processus du moteur de rendu Chrome a révélé la répartition suivante de la durée d'exécution globale du moteur de rendu:

Temps d'exécution du moteur de rendu Chrome

Nous nous attendions à voir quelque chose en lien avec la collecte des traces de pile, mais nous ne nous attendions pas à ce qu'environ 90% du temps d'exécution global concerne la symbolisation des blocs de pile. La symbolisation ici fait référence à l'acte de résoudre les noms des fonctions et les positions de source concrètes (numéros de ligne et de colonne dans les scripts) à partir de blocs de pile bruts.

Inférence de nom de méthode

Ce qui est encore plus surprenant, c'est le fait que presque tout le temps est consacré à la fonction JSStackFrame::GetMethodName() dans la version 8, même si nous savions d'après les enquêtes précédentes que JSStackFrame::GetMethodName() n'était pas étranger au monde des problèmes de performances. Cette fonction tente de calculer le nom de la méthode pour les frames considérés comme des appels de méthode (frames qui représentent des appels de fonction de la forme obj.func() plutôt que func()). Un rapide aperçu du code a révélé qu'il fonctionne en effectuant un balayage complet de l'objet et de sa chaîne de prototype et en recherchant

  1. Propriétés de données dont les value correspondent à la fermeture de func, ou
  2. Propriétés d'accesseur où get ou set est égal à la fermeture de func.

Bien que cela ne semble pas particulièrement bon marché, cela ne semble pas non plus expliquer cet horrible ralentissement. Nous avons donc commencé à approfondir l'exemple indiqué dans chromium:1069425 et nous avons constaté que les traces de la pile avaient été collectées pour des tâches asynchrones ainsi que pour les messages de journal provenant de classes.js, un fichier JavaScript de 10 Mio. Un examen plus approfondi a révélé qu'il s'agissait essentiellement d'un environnement d'exécution Java et d'un code d'application compilé en JavaScript. Les traces de pile contenaient plusieurs frames avec des méthodes appelées sur un objet A. Nous avons donc pensé qu'il serait intéressant de comprendre de quel type d'objet nous tentons.

traces de pile d'un objet

Apparemment, le compilateur Java vers JavaScript a généré un seul objet comportant un nombre incroyable de 82 203 fonctions, ce qui commençait clairement à devenir intéressant. Nous sommes ensuite revenu au JSStackFrame::GetMethodName() de V8 afin de déterminer s'il y avait des fruits faciles à trouver à cet endroit.

  1. Elle commence par rechercher la propriété "name" de la fonction en tant que propriété de l'objet et, s'il est trouvé, vérifie que la valeur de la propriété correspond à la fonction.
  2. Si la fonction n'a pas de nom ou si l'objet n'a pas de propriété correspondante, elle effectue une recherche inversée en parcourant toutes les propriétés de l'objet et de ses prototypes.

Dans notre exemple, toutes les fonctions sont anonymes et ont des propriétés "name" vides.

A.SDV = function() {
   // ...
};

La première constatation que la recherche inversée a été divisée en deux étapes (effectuées pour l'objet lui-même et pour chaque objet de sa chaîne de prototype):

  1. extraire les noms de toutes les propriétés énumérables ;
  2. Recherchez une propriété générique pour chaque nom, et vérifiez si la valeur obtenue correspond à la route fermée que nous recherchions.

Cela ressemblait à un fruit assez facile à retenir, car l'extraction des noms nécessite déjà de parcourir toutes les propriétés. Au lieu d'effectuer les deux passes, O(N) pour l'extraction des noms et O(N log(N)) pour les tests, nous pourrions tout faire en une seule fois et vérifier directement les valeurs des propriétés. Cela rendait l'ensemble de la fonction plus rapide entre 2 et 10 fois.

Le deuxième résultat était encore plus intéressant. Bien que ces fonctions soient techniquement anonymes, le moteur V8 a néanmoins enregistré ce que nous appelons un nom déduit. Pour les littéraux de fonction qui apparaissent à droite des attributions sous la forme obj.foo = function() {...}, l'analyseur V8 mémorise "obj.foo" en tant que nom déduit pour le littéral de fonction. Ainsi, dans notre cas, cela signifie que, même si nous n'avions pas le nom propre que nous pourrions simplement rechercher, nous avions quelque chose d'assez proche: pour l'exemple A.SDV = function() {...} ci-dessus, nous avions "A.SDV" comme nom déduit, et nous pourrions déduire le nom de la propriété à partir du nom déduit en recherchant le dernier point, puis nous partirons à la recherche de la propriété "SDV" sur l'objet. L'astuce a fonctionné dans presque tous les cas, en remplaçant un balayage complet coûteux par une seule recherche de propriété. Ces deux améliorations font partie de cette CL et ont considérablement réduit le ralentissement pour l'exemple signalé dans chromium:1069425.

Error.stack

On aurait pu s'accorder un jour ici. Mais il se passe quelque chose de louche, car les outils de développement n'utilisent jamais le nom de la méthode pour les blocs de pile. En fait, la classe v8::StackFrame de l'API C++ n'offre même aucun moyen d'accéder au nom de la méthode. Il nous semble donc incorrect d'appeler JSStackFrame::GetMethodName() en premier lieu. Le seul endroit où nous utilisons (et expliquons) le nom de la méthode se trouve dans l'API de trace de la pile JavaScript. Pour comprendre cette utilisation, prenons l'exemple simple suivant, error-methodname.js:

function foo() {
    console.log((new Error).stack);
}

var object = {bar: foo};
object.bar();

Ici, nous avons une fonction foo installée sous le nom "bar" sur object. L'exécution de cet extrait dans Chromium génère le résultat suivant:

Error
    at Object.foo [as bar] (error-methodname.js:2)
    at error-methodname.js:6

Ici, la recherche du nom de la méthode est en cours de lecture: le bloc de pile supérieur est illustré pour appeler la fonction foo sur une instance de Object via la méthode nommée bar. La propriété error.stack non standard fait donc un usage intensif de JSStackFrame::GetMethodName(). D'ailleurs, nos tests de performances indiquent également que nos modifications ont permis d'accélérer considérablement le processus.

Accélérer le fonctionnement des microbenchmarks StackTrace

Pour revenir sur les outils pour les développeurs Chrome, il semblerait que le nom de la méthode soit calculé même si error.stack n'est pas utilisé. La présence d'un certain historique nous aide à le comprendre: traditionnellement, V8 disposait de deux mécanismes distincts pour collecter et représenter une trace de pile pour les deux API décrites ci-dessus (l'API v8::StackFrame C++ et l'API de trace de pile JavaScript). Le fait d'avoir deux méthodes (à peu près) différentes pour faire la même chose était sujette à erreur et entraînait souvent des incohérences et des bugs. C'est pourquoi fin 2018, nous avons lancé un projet visant à nous conformer à un seul goulot d'étranglement pour la capture des traces de pile.

Ce projet a connu un grand succès et a considérablement réduit le nombre de problèmes liés à la collecte des traces de la pile. La plupart des informations fournies via la propriété non standard error.stack avaient également été calculées de manière différée et uniquement lorsqu'elles étaient vraiment nécessaires. Toutefois, lors de la refactorisation, nous avons appliqué la même astuce aux objets v8::StackFrame. Toutes les informations sur le bloc de pile sont calculées la première fois qu'une méthode est appelée sur celui-ci.

Cela améliore généralement les performances, mais malheureusement, il s'est avéré un peu contraire à l'utilisation de ces objets d'API C++ dans Chromium et dans les outils de développement. En particulier, comme nous avions introduit une nouvelle classe v8::internal::StackFrameInfo, qui contenait toutes les informations sur un bloc de pile exposé via v8::StackFrame ou error.stack, nous devrions toujours calculer le sur-ensemble des informations fournies par les deux API. Par conséquent, pour les utilisations de v8::StackFrame (et en particulier pour les outils de développement), nous calculions également le nom de la méthode, dès qu'une information sur un bloc de pile est demandée. Il s'avère que les outils de développement demandent immédiatement les informations sur la source et le script.

Grâce à cette prise de conscience, nous avons pu refactoriser et simplifier considérablement la représentation des frames de pile et la rendre encore plus différée. Ainsi, les utilisateurs de V8 et de Chromium ne paient que le coût du calcul des informations qu'ils demandent. Cela a permis d'améliorer considérablement les performances des outils de développement et d'autres cas d'utilisation de Chromium, qui ne nécessitent qu'une fraction des informations sur les blocs de pile (essentiellement le nom du script et l'emplacement de la source sous la forme d'un décalage de ligne et de colonne), ce qui a ouvert la voie à d'autres améliorations des performances.

Noms des fonctions

Les refactorisations mentionnées ci-dessus ont été éliminées. Le coût du décodage (temps passé dans v8_inspector::V8Debugger::symbolize) a été réduit à environ 15% du temps d'exécution global. Nous avons ainsi pu voir plus clairement où V8 passait du temps au moment de collecter (et de symboliser) des blocs de pile pour les utiliser dans les outils de développement.

Coût de la symbolisation

Le premier élément qui s'est marqué est le coût cumulé de la ligne de calcul et du numéro de colonne. Ici, la partie la plus coûteuse consiste à calculer le décalage des caractères dans le script (en fonction du décalage du bytecode obtenu à partir de V8). Il s'est avéré que, en raison de la refactorisation ci-dessus, nous l'avons fait deux fois, une fois pour le calcul du numéro de ligne et une autre fois pour le calcul du numéro de colonne. La mise en cache de la position source sur les instances v8::internal::StackFrameInfo a permis de résoudre rapidement ce problème et d'éliminer complètement v8::internal::StackFrameInfo::GetColumnNumber de tous les profils.

Le résultat le plus intéressant pour nous est que v8::StackFrame::GetFunctionName était étonnamment élevé dans tous les profils que nous avons examinés. En creusant plus en détail, nous avons réalisé qu'il était inutilement coûteux de calculer le nom de la fonction dans le bloc de pile des outils de développement.

  1. en recherchant d'abord la propriété "displayName" non standard. Si cela génère une propriété de données avec une valeur de chaîne, nous l'utilisons.
  2. Sinon, vous devrez rechercher la propriété "name" standard et vérifier à nouveau si cela génère une propriété de données dont la valeur est une chaîne.
  3. et finalement revenir à un nom de débogage interne inféré par l'analyseur V8 et stocké dans le littéral de fonction.

La propriété "displayName" a été ajoutée en tant que solution de contournement pour la propriété "name" des instances Function étant en lecture seule et non configurables en JavaScript, mais n'a jamais été standardisée et n'a pas été utilisée à grande échelle, car les outils pour les développeurs de navigateur ajoutaient l'inférence de nom de fonction qui fait le travail dans 99,9% des cas. De plus, ES2015 a rendu la propriété "name" sur les instances Function configurable, éliminant ainsi la nécessité d'une propriété "displayName" spéciale. La recherche négative pour "displayName" étant assez coûteuse et pas vraiment nécessaire (ES2015 a été publié il y a plus de cinq ans), nous avons décidé de supprimer la prise en charge de la propriété fn.displayName non standard dans V8 (et les outils de développement).

La recherche négative de "displayName" a été abandonnée, et la moitié du coût de v8::StackFrame::GetFunctionName a été supprimée. L'autre moitié est destinée à la recherche de la propriété "name" générique. Heureusement, nous avions déjà mis en place une logique pour éviter les recherches coûteuses de la propriété "name" sur les instances Function (non modifiées), que nous avons introduites dans V8 il y a quelque temps pour accélérer Function.prototype.bind(). Nous avons transféré les vérifications nécessaires, ce qui nous permet d'éviter la recherche générique coûteuse. Par conséquent, v8::StackFrame::GetFunctionName n'apparaît plus dans les profils que nous avons envisagés.

Conclusion

Grâce aux améliorations ci-dessus, nous avons considérablement réduit les frais généraux liés aux outils de développement en termes de traces de pile.

Nous sommes conscients qu'il existe encore plusieurs améliorations possibles. Par exemple, les frais généraux liés à l'utilisation de MutationObserver sont toujours perceptibles, comme indiqué dans chromium:1077657. Toutefois, pour le moment, nous avons résolu les principaux problèmes et nous pourrions revenir plus tard pour simplifier encore plus les performances de débogage.

Télécharger les canaux de prévisualisation

Nous vous conseillons d'utiliser Chrome Canary, Dev ou Beta comme navigateur de développement par défaut. Ces versions preview vous permettent d'accéder aux dernières fonctionnalités des outils de développement, de tester des API de pointe de plates-formes Web et de détecter les problèmes sur votre site avant même que vos utilisateurs ne le fassent.

Contacter l'équipe des outils pour les développeurs Chrome

Utilisez les options suivantes pour discuter des nouvelles fonctionnalités et des modifications dans le message, ou de tout autre sujet lié aux outils de développement.

  • Envoyez-nous une suggestion ou des commentaires via crbug.com.
  • Signalez un problème lié aux outils de développement en accédant à Plus d'options   More > Aide > Signaler un problème dans les outils de développement.
  • Envoyez un tweet à @ChromeDevTools.
  • Laissez des commentaires sur les nouveautés des outils de développement vidéos YouTube ou les vidéos YouTube de nos conseils sur les outils de développement.