Résolvez les mystères de performances JavaScript à l'aide de l'investigation informatique et de l'enquête

John McCutchan
John McCutchan

Introduction

Ces dernières années, les applications Web ont été considérablement accélérées. De nombreuses applications s'exécutent désormais suffisamment rapidement pour que certains développeurs se demandent à voix haute : "Le Web est-il assez rapide ?". C'est possible pour certaines applications, mais pour les développeurs qui travaillent sur des applications hautes performances, nous savons que ce n'est pas assez rapide. Malgré les avancées étonnantes de la technologie des machines virtuelles JavaScript, une récente étude a montré que les applications Google passent entre 50% et 70% de leur temps dans V8. La durée de vie de votre application est limitée. Réduire les cycles d'un système permet à un autre système d'en faire plus. N'oubliez pas que les applications fonctionnant à 60 images par seconde ne mettent que 16 ms par image, ou sinon, des à-coups. Pour en savoir plus sur l'optimisation de JavaScript et le profilage des applications JavaScript, lisez l'histoire des tranchées de l'équipe V8, qui analysent un obscur problème de performances dans Find Your Way to Oz.

Session Google I/O 2013

J'ai présenté ces supports lors de la conférence Google I/O 2013. Regardez la vidéo ci-dessous:

Pourquoi les performances sont-elles importantes ?

Les cycles de processeur sont un jeu à somme nulle. Si vous réduisez la consommation d'une partie de votre système, vous pouvez en utiliser davantage dans une autre ou bénéficier d'un fonctionnement plus fluide. Exécuter plus rapidement et en faire plus sont souvent des objectifs concurrents. Les utilisateurs exigent de nouvelles fonctionnalités tout en s'attendant à ce que votre application fonctionne plus facilement. Les machines virtuelles JavaScript sont de plus en plus rapides, mais ce n'est pas une raison d'ignorer les problèmes de performances que vous pouvez résoudre aujourd'hui, comme le savent déjà les nombreux développeurs confrontés aux problèmes de performances de leurs applications Web. En temps réel, avec une fréquence d'images élevée, la pression sur les à-coups est primordiale. Une étude menée par Insomniac Games a montré qu'une fréquence d'images stable et soutenue est importante pour le succès d'un jeu: "Une fréquence d'images stable est toujours un signe d'un produit professionnel et de qualité." Les développeurs Web en tiennent compte.

Résoudre les problèmes de performances

Résoudre un problème de performances, c'est comme résoudre un crime. Vous devez examiner attentivement les preuves, vérifier les causes probables et expérimenter différentes solutions. Tout au long du processus, vous devez documenter vos mesures afin d’être sûr d’avoir réellement résolu le problème. Il y a très peu de différence entre cette méthode et la façon dont les détectives enquêtent sur une affaire. Les détectives examinent les preuves, interrogent les suspects et effectuent des expériences dans l'espoir de retrouver l'arme qui fumeur.

V8 CSI: Oz

Les incroyables sorciers qui construisent Find Your Way to Oz ont contacté l'équipe V8 avec un problème de performances qu'elles ne pouvaient pas résoudre par elles-mêmes. Parfois, Oz se figeait, provoquant des à-coups. Les développeurs d'Oz ont effectué des recherches initiales à l'aide du panneau "Timeline" dans les outils pour les développeurs Chrome. En observant l'utilisation de la mémoire, ils ont découvert le graphique redoutable "dents de scie". Une fois par seconde, le récupérateur de mémoire collectait 10 Mo de mémoire et les suspensions de la récupération correspondaient à l'à-coup. Il s'agit d'une capture d'écran semblable à la capture d'écran suivante, tirée de la chronologie dans les outils pour les développeurs Chrome:

Chronologie des outils de développement

Jakob et Yang, les détectives du V8, se sont penchés sur l'affaire. Il s'est produit de longs allers-retours entre Jakob et Yang, de l'équipe V8 et de l'équipe Oz. J'ai résumé cette conversation en fonction des événements importants qui ont permis d'identifier ce problème.

Preuves

La première étape consiste à recueillir et à étudier les preuves initiales.

Quel type d'application examinons-nous ?

La démonstration d'Oz est une application 3D interactive. De ce fait, il est très sensible aux mises en veille dues à la récupération de mémoire. N'oubliez pas qu'une application interactive fonctionnant à 60 images par seconde dispose de 16 ms pour effectuer toutes les tâches JavaScript et doit laisser un certain temps à Chrome pour traiter les appels graphiques et dessiner l'écran.

Oz effectue de nombreux calculs arithmétiques sur les valeurs doubles et appelle fréquemment WebAudio et WebGL.

Quel type de problème de performances observons-nous ?

Nous constatons des pauses, des pertes de frames ou des à-coups. Ces pauses sont en corrélation avec les exécutions de récupération de mémoire.

Les développeurs suivent-ils les bonnes pratiques ?

Oui, les développeurs Oz maîtrisent parfaitement les performances et les techniques d'optimisation des VM JavaScript. Il convient de noter que les développeurs Oz utilisaient CoffeeScript comme langage source et produisaient du code JavaScript via le compilateur CoffeeScript. Cela a rendu certaines des investigations plus délicates en raison du décalage entre le code écrit par les développeurs Oz et le code utilisé par V8. Les outils pour les développeurs Chrome sont désormais compatibles avec les cartes sources, ce qui aurait simplifié cette opération.

Pourquoi le récupérateur de mémoire s'exécute-t-il ?

La mémoire en JavaScript est automatiquement gérée pour le développeur par la VM. V8 utilise un système de récupération de mémoire courant dans lequel la mémoire est divisée en au moins deux generations. La jeune génération contient des objets qui ont été alloués récemment. Si un objet survit suffisamment longtemps, il est transféré vers l'ancienne génération.

La collecte des jeunes générations est bien plus élevée que celle de l'ancienne. C'est par nature, car la collection jeune génération est beaucoup moins chère. On peut souvent supposer que les pauses fréquentes de récupération de mémoire sont dues à la collecte d'une jeune génération.

Dans V8, l'espace mémoire du jeune est divisé en deux blocs de mémoire contigus de taille égale. Un seul de ces deux blocs de mémoire est utilisé à un moment donné, appelé "à espace". Bien qu'il reste de la mémoire dans l'espace, l'allocation d'un nouvel objet est peu coûteuse. Un curseur dans l'espace vers l'espace est déplacé vers l'avant du nombre d'octets nécessaires pour le nouvel objet. Le processus se poursuit jusqu'à ce que l'espace de destination soit épuisé. Le programme s'arrête alors et la collecte commence.

V8 souvenir jeune

À ce stade, l'échange entre l'espace et l'espace est interverti. Ce qui était dans l'espace et ce qui est désormais à l'espace est analysé du début à la fin, et tous les objets encore actifs sont copiés dans l'espace ou sont promus dans le tas de mémoire de l'ancienne génération. Pour en savoir plus, je vous suggère de lire l'article sur l'algorithme de Cheney.

Intuitivement, vous devez comprendre que chaque fois qu'un objet est alloué implicitement ou explicitement (via un appel à new, [] ou {}), votre application se rapproche de plus en plus d'une récupération de mémoire et la redoutable application s'interrompt.

L'application peut-elle récupérer 10 Mo/s de mémoire système ?

En bref, non. Le développeur ne fait rien pour s'attendre à un volume de données de 10 Mo/s.

Suspects

La phase suivante de l'enquête consiste à identifier les suspects potentiels, puis à les réduire.

Suspect 1

Appeler de nouveaux pendant le frame. N'oubliez pas que chaque objet alloué vous rapproche de plus en plus d'une pause de récupération de mémoire. Les applications exécutées à des fréquences d'images élevées doivent s'efforcer de n'avoir aucune allocation par image. Cela nécessite généralement un système de recyclage des objets soigneusement conçu et spécifique à l'application. Les détectives du V8 ont vérifié auprès de l'équipe d'Oz et ils n'appelaient pas de nouveaux. En fait, l'équipe d'Oz était déjà bien au courant de cette exigence et a déclaré : "Ce serait embarrassant". Grattez celui-ci sur la liste.

Suspect n° 2

Modification de la "forme" d'un objet en dehors du constructeur Cela se produit chaque fois qu'une nouvelle propriété est ajoutée à un objet en dehors du constructeur. Cette opération crée une classe cachée pour l'objet. Lorsque le code optimisé détecte que cette nouvelle classe cachée est déclenchée, le code non optimisé s'exécute jusqu'à ce qu'il soit classé comme "chaud" et optimisé à nouveau. Cette perte de l'optimisation et de la réoptimisation entraîne des à-coups,mais n'est pas strictement corrélé avec une création excessive de mémoire. Un audit minutieux du code a permis de confirmer que les formes des objets étaient statiques, ce qui a exclu le suspect n° 2.

Suspect 3

Arithmétique dans un code non optimisé. Dans le code non optimisé, tous les calculs entraînent l'allocation d'objets réels. Par exemple, l'extrait suivant:

var a = p * d;
var b = c + 3;
var c = 3.3 * dt;
point.x = a * b * c;

Cinq objets HeapNumber sont alors créés. Les trois premiers concernent les variables : a, b et c. Le 4 correspond à la valeur anonyme (a * b) et le 5 correspond à la valeur n° 4 * c. La 5 est finalement attribuée à point.x.

Oz effectue des milliers d'opérations par frame. Si l'un de ces calculs est effectué dans des fonctions qui ne sont jamais optimisées, il peut être à l'origine de la récupération. Parce que les calculs non optimisés allouent de la mémoire, même pour des résultats temporaires.

Suspect n° 4

Stockage d'un nombre à double précision dans une propriété Vous devez créer un objet HeapNumber afin d'y stocker le nombre, et la propriété modifiée pour qu'elle pointe vers ce nouvel objet. Si vous modifiez la propriété pour qu'elle pointe vers le HeapNumber, aucun stockage n'est généré. Cependant, il est possible que de nombreux nombres à double précision soient stockés en tant que propriétés d'objet. Le code contient des instructions comme celles-ci:

sprite.position.x += 0.5 * (dt);

Dans le code optimisé, chaque fois qu'une valeur récemment calculée (une instruction apparemment anodine) est affectée à x, un nouvel objet HeapNumber est implicitement alloué, ce qui nous rapproche d'une interruption de la récupération de mémoire.

Notez qu'en utilisant un tableau typé (ou un tableau standard qui ne contient que des doubles), vous pouvez éviter complètement ce problème spécifique, car le stockage du nombre à double précision n'est alloué qu'une seule fois, et la modification répétée de la valeur ne nécessite pas d'allouer un nouvel espace de stockage.

Le suspect n°4 est une possibilité.

Expertise médicolégale

À ce stade, les détectives ont deux suspects possibles: le stockage du nombre de segments de mémoire en tant que propriétés d'objet et les calculs arithmétiques effectués dans des fonctions non optimisées. Il était temps de se rendre au laboratoire pour déterminer avec certitude quel suspect était coupable. REMARQUE: Dans cette section, je vais utiliser une reproduction du problème trouvé dans le code source Oz réel. Cette reproduction est beaucoup plus petite que le code d'origine, ce qui facilite le raisonnement.

Test 1

Recherche du suspect 3 (calcul arithmétique dans des fonctions non optimisées). Le moteur JavaScript V8 intègre un système de journalisation qui peut fournir de précieuses informations sur ce qui se passe en arrière-plan.

À partir du moment où Chrome ne s'exécute pas du tout, lancez Chrome avec les indicateurs suivants:

--no-sandbox --js-flags="--prof --noprof-lazy --log-timer-events"

avant de quitter Chrome, le répertoire actuel génère un fichier v8.log.

Pour interpréter le contenu du fichier v8.log, vous devez télécharger la même version de v8 que celle utilisée par Chrome (vérifier about:version), puis la compiler.

Après avoir compilé la version 8, vous pouvez traiter le journal à l'aide du processeur tick:

$ tools/linux-tick-processor /path/to/v8.log

(Remplacez Linux par Mac ou Windows en fonction de votre plate-forme.) Notez que cet outil doit être exécuté à partir du répertoire source de premier niveau dans la version 8.

Le processeur de tick affiche un tableau textuel des fonctions JavaScript ayant le plus de ticks:

[JavaScript]:
ticks  total  nonlib   name
167   61.2%   61.2%  LazyCompile: *opt demo.js:12
 40   14.7%   14.7%  LazyCompile: unopt demo.js:20
 15    5.5%    5.5%  Stub: KeyedLoadElementStub
 13    4.8%    4.8%  Stub: BinaryOpStub_MUL_Alloc_Number+Smi
  6    2.2%    2.2%  Stub: BinaryOpStub_ADD_OverwriteRight_Number+Number
  4    1.5%    1.5%  Stub: KeyedStoreElementStub
  4    1.5%    1.5%  KeyedLoadIC:  {12}
  2    0.7%    0.7%  KeyedStoreIC:  {13}
  1    0.4%    0.4%  LazyCompile: ~main demo.js:30

Vous pouvez voir que le fichier demo.js avait trois fonctions: opt, unopt et main. Un astérisque (*) s'affiche à côté du nom des fonctions optimisées. Notez que l'optimisation de la fonction est optimisée, mais pas l'option non optimisée.

Un autre outil important du sac à outils du détective V8 est le plot-timer-event. Il peut être exécuté comme suit:

$ tools/plot-timer-event /path/to/v8.log

Après l'exécution, un fichier png appelé minuteur-events.png se trouve dans le répertoire actuel. En l'ouvrant, vous devriez voir quelque chose qui ressemble à ceci:

Événements de minuteur

À côté du graphique situé en bas, les données sont affichées sous forme de lignes. L'axe des X représente le temps (ms). La partie gauche comprend des libellés pour chaque ligne:

Axe Y des événements de minuteur

La ligne V8.Execute est représentée par une ligne verticale noire à chaque tick de profil, où V8 exécutait du code JavaScript. V8.GCScavenger est représenté par une ligne verticale bleue à chaque tick de profil où V8 effectuait une collecte nouvelle génération. Il en va de même pour les autres états V8.

L'une des lignes les plus importantes est le "type de code en cours d'exécution". La couleur s'affiche en vert lorsque du code optimisé est en cours d'exécution, et en rouge et en bleu lorsque du code non optimisé est exécuté. La capture d'écran suivante montre la transition du code optimisé vers le code non optimisé, puis du code optimisé:

Type de code en cours d'exécution

Dans l'idéal, mais jamais immédiatement, cette ligne sera verte unie. Cela signifie que votre programme est passé à un état stable optimisé. Le code non optimisé s'exécute toujours plus lentement que le code optimisé.

Si vous avez atteint cette longueur, sachez que vous pouvez travailler beaucoup plus rapidement en refactorisant votre application afin qu'elle puisse s'exécuter dans le shell de débogage de la version 8: d8. L'utilisation de d8 vous permet de raccourcir les délais d'itération avec les outils de processeur de tic-tac et d'événements de temps de tracé. Un autre effet secondaire de l'utilisation de d8 est qu'il devient plus facile d'isoler un problème réel, ce qui réduit la quantité de bruit présent dans les données.

En examinant le graphique des événements de minuteur à partir du code source Oz, nous avons constaté une transition du code optimisé vers le code non optimisé. Lors de l'exécution du code non optimisé, de nombreuses collections de nouvelle génération ont été déclenchées, comme dans la capture d'écran suivante (l'heure a été supprimée au milieu):

Graphique des événements de minuteur

Si vous examinez attentivement le contenu, vous pouvez constater que les lignes noires indiquant l'exécution du code JavaScript par V8 sont absentes aux mêmes moments de repère des profils que les collections de la nouvelle génération (lignes bleues). Cela montre clairement que pendant la récupération de mémoire, le script est mis en pause.

En examinant la sortie du processeur tic à partir du code source Oz, la fonction top (updateSprites) n'a pas été optimisée. En d'autres termes, la fonction dans laquelle le programme passait le plus de temps n'était pas non plus optimisée. Cela indique clairement que le suspect 3 est le coupable. La source de updateSprites contenait des boucles ressemblant à ceci:

function updateSprites(dt) {
    for (var sprite in sprites) {
        sprite.position.x += 0.5 * dt;
        // 20 more lines of arithmetic computation.
    }
}

Consciente de la même version de V8, elle a immédiatement compris que la structure en boucle for-i-in n'était parfois pas optimisée par V8. En d'autres termes, si une fonction contient une construction de boucle for-i-in, il se peut qu'elle ne soit pas optimisée. Il s'agit d'un cas particulier qui sera probablement modifié à l'avenir. En d'autres termes, V8 pourrait un jour optimiser cette construction de boucle. Comme nous ne sommes pas des détectives V8 et que nous ne connaissons pas V8 comme nous le faisons, comment déterminer pourquoi updateSprites n'a pas été optimisé ?

Test 2

Exécutez Chrome avec cet indicateur:

--js-flags="--trace-deopt --trace-opt-verbose"

affiche un journal détaillé des données d'optimisation et de désoptimisation. En recherchant "updateSprites" dans les données, nous avons trouvé:

[Optimisation désactivée pour updateSprites, motif: ForInStatement n'est pas un cas rapide]

Comme l'ont supposé les détectives, la structure en boucle for-i-in en est la raison.

Demande clôturée

Après avoir découvert la raison pour laquelle updateSprites n'était pas optimisé, la correction a été simple : il suffit de déplacer le calcul dans sa propre fonction, à savoir :

function updateSprite(sprite, dt) {
    sprite.position.x += 0.5 * dt;
    // 20 more lines of arithmetic computation.
}

function updateSprites(dt) {
    for (var sprite in sprites) {
        updateSprite(sprite, dt);
    }
}

updateSprite sera optimisé, ce qui se traduira par un nombre d'objets HeapNumber réduit et par des pauses de récupération de mémoire moins fréquentes. Vous devriez pouvoir le vérifier facilement en effectuant les mêmes tests avec le nouveau code. Le lecteur attentif remarquera que les nombres doubles sont toujours stockés en tant que propriétés. Si le profilage montre que cela en vaut la peine, la modification de la position par un tableau de doubles ou un tableau de données typé réduirait davantage le nombre d'objets créés.

Épilogue

Les développeurs d'Oz ne se sont pas arrêtés là. Grâce aux outils et techniques partagés avec eux par les détectives V8, ils ont pu identifier d'autres fonctions bloquées dans l'enfer de la désoptimisation et intégrer le code de calcul dans des fonctions feuilles optimisées, ce qui a permis d'améliorer encore les performances.

Lancez-vous et résolvez des crimes liés aux performances !