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 que certains développeurs se demandent "Le Web est-il assez rapide ?". Pour certaines applications, c'est peut-être le cas, mais pour les développeurs qui travaillent sur des applications hautes performances, nous savons que ce n'est pas assez rapide. Malgré les progrès incroyables de la technologie de machine virtuelle JavaScript, une étude récente a montré que les applications Google passent entre 50% et 70% de leur temps dans V8. Votre application dispose d'un temps limité. Si vous réduisez le nombre de cycles d'un système, un autre système peut en effectuer davantage. N'oubliez pas que les applications fonctionnant à 60 FPS ne disposent que de 16 ms par frame, sinon elles saccadent. Poursuivez votre lecture pour découvrir comment optimiser JavaScript et profiler des applications JavaScript. Découvrez l'histoire des détectives de performances de l'équipe V8 qui ont traqué un problème de performances obscur dans Find Your Way to Oz.

Session Google I/O 2013

J'ai présenté ce contenu lors de 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. En réduisant l'utilisation d'une partie de votre système, vous pouvez en utiliser davantage dans une autre ou améliorer le fonctionnement global. La rapidité d'exécution et la polyvalence sont souvent des objectifs contradictoires. Les utilisateurs exigent de nouvelles fonctionnalités, mais attendent également que votre application s'exécute plus rapidement. Les machines virtuelles JavaScript sont de plus en plus rapides, mais cela ne vous empêche pas d'ignorer les problèmes de performances que vous pouvez résoudre dès aujourd'hui, comme le savent déjà de nombreux développeurs confrontés à des problèmes de performances dans leurs applications Web. Dans les applications en temps réel à fréquence d'images élevée, la pression pour éviter les à-coups est primordiale. Insomniac Games a réalisé une étude qui 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 le signe d'un produit professionnel et bien conçu." Notez-le, développeurs Web.

Résoudre les problèmes de performances

Résoudre un problème de performances est comme résoudre un crime. Vous devez examiner attentivement les preuves, vérifier les causes présumées et tester différentes solutions. Tout au long du processus, vous devez documenter vos mesures afin de vous assurer d'avoir bien résolu le problème. Il existe très peu de différences entre cette méthode et la façon dont les détectives résolvent une affaire. Les détectives examinent les preuves, interrogent les suspects et effectuent des tests dans l'espoir de trouver la preuve irréfutable.

V8 CSI: Oz

Les magiciens extraordinaires qui créent Find Your Way to Oz ont contacté l'équipe V8 pour résoudre un problème de performances qu'ils ne pouvaient pas résoudre eux-mêmes. Oz se fige parfois, ce qui provoque des à-coups. Les développeurs Oz ont effectué une première investigation à l'aide du panneau "Timeline" dans Chrome DevTools. En examinant l'utilisation de la mémoire, ils ont rencontré le redoutable graphique en dents de scie. Une fois par seconde, le récupérateur de mémoire collectait 10 Mo de déchets, et les pauses de récupération de mémoire correspondaient aux à-coups. Il doit ressembler à la capture d'écran suivante de la chronologie dans Chrome DevTools:

Calendrier des outils de développement

Les détectives V8, Jakob et Yang, ont pris en charge l'affaire. Jakob et Yang, de l'équipe V8 et de l'équipe Oz, ont échangé de longs messages. J'ai résumé cette conversation aux événements importants qui m'ont aidé à identifier ce problème.

Preuves

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

De quel type d'application s'agit-il ?

La démonstration Oz est une application 3D interactive. Par conséquent, il est très sensible aux pauses causées par les collectes de déchets. N'oubliez pas qu'une application interactive exécutée à 60 FPS dispose de 16 ms pour effectuer toutes les tâches JavaScript et doit laisser une partie de ce temps à Chrome pour qu'il puisse traiter les appels graphiques et dessiner l'écran.

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

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

Nous constatons des pauses, également appelées pertes de frames ou à-coups. Ces pauses correspondent aux exécutions de récupération de mémoire.

Les développeurs respectent-ils les bonnes pratiques ?

Oui, les développeurs Oz sont très au fait des performances et des techniques d'optimisation des VM JavaScript. Il est à noter que les développeurs Oz utilisaient CoffeeScript comme langage source et produisaient du code JavaScript via le compilateur CoffeeScript. Cela a rendu certaines parties de l'enquête plus difficiles, en raison de la dissociation entre le code écrit par les développeurs Oz et le code consommé par V8. Les outils pour les développeurs Chrome sont désormais compatibles avec les cartes sources, ce qui aurait simplifié cette tâche.

Pourquoi le garbage collector s'exécute-t-il ?

La mémoire en JavaScript est gérée automatiquement par la VM pour le développeur. V8 utilise un système de récupération de mémoire commun dans lequel la mémoire est divisée en deux (ou plus) générations. La génération jeune contient les objets qui ont été alloués récemment. Si un objet survit suffisamment longtemps, il est déplacé vers l'ancienne génération.

La jeune génération est collectée à une fréquence beaucoup plus élevée que l'ancienne. C'est intentionnel, car la collecte de la jeune génération est beaucoup moins coûteuse. Il est souvent possible de supposer que les pauses GC fréquentes sont causées par la collecte de la jeune génération.

Dans V8, l'espace de mémoire 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é. Il s'agit de l'espace de destination. Tant qu'il reste de la mémoire dans l'espace de destination, l'allocation d'un nouvel objet est peu coûteuse. Un curseur dans l'espace "to" est déplacé d'un nombre d'octets correspondant au nouvel objet. Cette opération se poursuit jusqu'à ce que l'espace soit épuisé. À ce stade, le programme est arrêté et la collecte commence.

Mémoire jeune V8

À ce stade, l'espace "de" et l'espace "à" sont échangés. L'espace "to", qui est maintenant l'espace "from", est analysé de bout en bout. Tous les objets qui sont toujours actifs sont copiés dans l'espace "to" 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.

Intuitif, 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 collecte des déchets et de la pause d'application redoutée.

Est-ce que 10 Mo/s de données indésirables sont attendus pour cette application ?

En résumé, non. Le développeur ne fait rien pour s'attendre à 10 Mo/s de données inutiles.

Suspects

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

Suspect 1

Appel de "new" pendant le frame. N'oubliez pas que chaque objet alloué vous rapproche d'une pause GC. Les applications exécutées à des fréquences d'images élevées, en particulier, doivent s'efforcer de ne pas allouer d'allocations par frame. Cela nécessite généralement un système de recyclage d'objets soigneusement pensé, spécifique à l'application. Les détectives V8 ont contacté l'équipe Oz, qui n'a pas appelé de nouveau. En fait, l'équipe Oz était déjà au courant de cette exigence et a répondu "Ce serait embarrassant". Rayez-la de la liste.

Suspect 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. Une classe masquée est alors créée pour l'objet. Lorsque le code optimisé voit cette nouvelle classe masquée, une dé-optimisation est déclenchée. Le code non optimisé s'exécute jusqu'à ce qu'il soit classé comme "chaud" et optimisé à nouveau. Ce cycle de désoptimisation et d'optimisation entraîne des à-coups,mais n'est pas strictement corrélé à la création excessive de déchets. Après un audit minutieux du code, il a été confirmé que les formes des objets étaient statiques. Le suspect 2 a donc été écarté.

Suspect 3

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

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

Cinq objets HeapNumber sont créés. Les trois premiers sont pour les variables a, b et c. Le 4e est pour la valeur anonyme (a * b), et le 5e est de la valeur 4 * c. Le 5e est finalement attribué à point.x.

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

Suspect 4

Stockage d'un nombre à double précision dans une propriété. Un objet HeapNumber doit être créé pour stocker le nombre et la propriété modifiée pour pointer vers ce nouvel objet. Modifier la propriété pour qu'elle pointe vers HeapNumber ne génère pas de déchets. Toutefois, il est possible que de nombreux nombres à double précision soient stockés en tant que propriétés d'objet. Le code est rempli d'instructions comme celles-ci:

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

Dans le code optimisé, chaque fois qu'une valeur fraîchement calculée est attribuée à x, une instruction apparemment inoffensive, un nouvel objet HeapNumber est alloué implicitement, ce qui nous rapproche d'une pause de collecte des déchets.

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 l'espace de 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 l'allocation d'un nouvel espace de stockage.

Le suspect 4 est une possibilité.

Expertise médicolégale

À ce stade, les détectives ont deux suspects possibles: le stockage de nombres de tas en tant que propriétés d'objets et le calcul arithmétique effectué dans des fonctions non optimisées. Il était temps de se rendre au laboratoire pour déterminer définitivement quel suspect était coupable. REMARQUE: Dans cette section, je vais utiliser une reproduction du problème détecté dans le code source Oz. Cette reproduction est des ordres de grandeur plus petite que le code d'origine, ce qui la rend plus facile à raisonner.

Test 1

Vérification du suspect 3 (calcul arithmétique dans des fonctions non optimisées). Le moteur JavaScript V8 dispose d'un système de journalisation intégré qui peut fournir des informations précieuses sur ce qui se passe sous le capot.

Chrome ne s'exécutant pas du tout, lancement de Chrome avec les indicateurs:

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

puis en quittant complètement Chrome, un fichier v8.log sera créé dans le répertoire actuel.

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

Une fois la compilation de la version 8 terminée, vous pouvez traiter le journal à l'aide du processeur de tic:

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

(Remplacez "linux" par "mac" ou "windows", selon votre plate-forme.) (Cet outil doit être exécuté à partir du répertoire source de premier niveau dans la version 8.)

Le processeur de code temporel affiche un tableau textuel des fonctions JavaScript qui ont enregistré le plus de code temporel:

[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 demo.js comporte trois fonctions: opt, unopt et main. Les fonctions optimisées sont identifiées par un astérisque (*) à côté de leur nom. Notez que la fonction opt est optimisée et que la fonction unopt n'est pas optimisée.

plot-timer-event est un autre outil important de la boîte à outils du détective V8. Il peut être exécuté comme suit:

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

Une fois exécuté, un fichier PNG nommé "timer-events.png" se trouve dans le répertoire actuel. L'écran ci-dessous doit s'afficher:

Événements de minuteur

À l'exception du graphique en bas, les données sont affichées en lignes. L'axe X représente le temps (ms). Le côté gauche contient les libellés de chaque ligne:

Axe Y des événements de minuteur

Une ligne verticale noire est tracée sur la ligne V8.Execute à chaque coche de profil où V8 exécutait du code JavaScript. Une ligne verticale bleue est tracée sur V8.GCScavenger à chaque coche de profil où V8 effectuait une collecte de nouvelle génération. Il en va de même pour les autres états V8.

L'une des lignes les plus importantes est "Type de code exécuté". Elle est verte lorsque le code optimisé est exécuté et rouge et bleue lorsque le code non optimisé est exécuté. La capture d'écran suivante montre la transition du code optimisé au code non optimisé, puis de nouveau au code optimisé:

Type de code exécuté

Idéalement, mais jamais immédiatement, cette ligne doit être verte. 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 déjà effectué cette opération, notez que vous pouvez travailler beaucoup plus rapidement en refactorisant votre application afin qu'elle puisse s'exécuter dans le shell de débogage v8: d8. L'utilisation de d8 accélère les temps d'itération avec les outils tick-processor et plot-timer-event. Un autre effet secondaire de l'utilisation de d8 est qu'il devient plus facile d'identifier le problème réel, ce qui réduit le bruit présent dans les données.

L'examen du graphique des événements du minuteur du code source Oz a révélé une transition du code optimisé au 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 illustré dans la capture d'écran suivante (notez que l'heure a été supprimée au milieu):

Graphique des événements de minuteur

Si vous regardez attentivement, vous pouvez constater que les lignes noires indiquant quand V8 exécute du code JavaScript sont manquantes aux mêmes instants de profil que les collections de nouvelle génération (lignes bleues). Cela montre clairement que le script est mis en pause pendant la collecte des déchets.

En examinant la sortie du processeur de tic du code source Oz, la fonction principale (updateSprites) n'était pas optimisée. En d'autres termes, la fonction dans laquelle le programme a passé le plus de temps n'était pas optimisée. Cela indique fortement que le suspect 3 est le coupable. La source de updateSprites contenait des boucles qui se présentaient comme suit:

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

Connaissant V8 aussi bien qu'eux, ils ont immédiatement reconnu que la structure de boucle for-i-in n'était parfois pas optimisée par V8. En d'autres termes, si une fonction contient une structure de boucle for-i-in, elle risque de ne pas être optimisée. Il s'agit d'un cas particulier aujourd'hui, qui devrait changer à l'avenir. Autrement dit, V8 pourrait un jour optimiser cette structure de boucle. Étant donné que nous ne sommes pas des détectives V8 et que nous ne connaissons pas V8 sur le bout des doigts, comment pouvons-nous déterminer pourquoi updateSprites n'a pas été optimisé ?

Test 2

Exécuter Chrome avec cet indicateur:

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

affiche un journal détaillé des données d'optimisation et de dé-optimisation. En recherchant des updateSprites dans les données, nous trouvons:

[désactivation de l'optimisation pour updateSprites, raison: ForInStatement n'est pas un cas rapide]

Comme les détectives l'avaient supposé, la structure de boucle for-i-in était en cause.

Dossier clôturé

Après avoir découvert pourquoi updateSprites n'était pas optimisé, la solution était simple : il suffisait 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 entraînera beaucoup moins d'objets HeapNumber, ce qui réduira les pauses GC. Vous devriez pouvoir le vérifier facilement en effectuant les mêmes tests avec le nouveau code. Le lecteur attentif remarquera que les nombres à virgule flottante sont toujours stockés en tant que propriétés. Si le profilage indique que cela vaut la peine, remplacer la position par un tableau de doubles ou un tableau de données typées réduirait encore le nombre d'objets créés.

Épilogue

Les développeurs Oz ne se sont pas arrêtés là. Armés des outils et des techniques partagés par les détectives V8, ils ont pu trouver quelques autres fonctions bloquées dans l'enfer de la dé-optimisation et ont factorisé le code de calcul en fonctions de feuilles qui ont été optimisées, ce qui a permis d'améliorer encore les performances.

Lancez-vous et commencez à résoudre des crimes de performances !