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 assez rapidement pour que certains développeurs se demandent à voix haute si le Web est assez rapide. Cela peut être le cas pour certaines applications, mais nous savons que ce n'est pas assez rapide pour les développeurs qui travaillent sur des applications hautes performances. 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é. Réduire les cycles d'un système signifie qu'un autre système peut en faire plus. N'oubliez pas que les applications s'exécutant à 60 fps ne disposent que de 16 ms par image, sinon elles ralentissent. Lisez la suite pour découvrir comment optimiser JavaScript et profiler les applications JavaScript, dans une histoire de détectives de performances de l'équipe V8 qui traquent 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 des ressources dans une partie de votre système, vous pouvez en utiliser davantage dans une autre ou améliorer les performances globales. Exécuter plus rapidement et faire plus sont souvent des objectifs contradictoires. Les utilisateurs exigent de nouvelles fonctionnalités, tout en s'attendant à ce que votre application s'exécute plus rapidement. Les machines virtuelles JavaScript sont de plus en plus rapides, mais ce n'est pas une raison pour ignorer les problèmes de performances que vous pouvez résoudre dès aujourd'hui, comme le savent déjà les 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, il est primordial d'éviter les à-coups. Insomniac Games a publié une étude montrant qu'une fréquence d'images stable et soutenue est importante pour le succès d'un jeu : "Une fréquence d'images stable reste le signe d'un produit professionnel et bien conçu." Développeurs Web, prenez note.

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 suspectées et tester différentes solutions. Tout au long du processus, vous devez documenter vos mesures pour vous assurer que vous avez bien 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 criminels résolvent une affaire. Les détectives examinent les preuves, interrogent les suspects et mènent des expériences dans l'espoir de trouver la preuve irréfutable.

V8 CSI: Oz

Les incroyables magiciens qui ont créé Find Your Way to Oz ont contacté l'équipe V8 pour lui faire part d'un problème de performances qu'ils ne parvenaient pas à résoudre eux-mêmes. Parfois, Oz se figeait, ce qui provoquait des à-coups. Les développeurs d'Oz ont effectué une première analyse à l'aide du panneau "Timeline" des outils pour les développeurs Chrome. 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 mémoire, et les pauses de récupération de mémoire correspondaient au jank. comme dans la capture d'écran suivante de la chronologie dans les outils pour les développeurs Chrome :

Chronologie des outils de développement

Les détectives V8, Jakob et Yang, se sont emparés de l'affaire. Il s'en est suivi un long échange entre Jakob et Yang de l'équipe V8 et l'équipe Oz. J'ai résumé cette conversation aux événements importants qui ont permis de résoudre 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émo Oz est une application 3D interactive. Il est donc très sensible aux pauses causées par la récupération de mémoire. N'oubliez pas qu'une application interactive fonctionnant à 60 fps dispose de 16 ms pour effectuer toutes les tâches JavaScript et doit laisser une partie de ce temps à Chrome pour 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 rencontrons-nous ?

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

Les développeurs suivent-ils les bonnes pratiques ?

Oui, les développeurs d'Oz maîtrisent parfaitement les techniques d'optimisation et de performances des VM JavaScript. Il convient de noter que les développeurs d'Oz utilisaient CoffeeScript comme langage source et produisaient du code JavaScript via le compilateur CoffeeScript. Cela a rendu l'enquête plus difficile en raison de la déconnexion entre le code écrit par les développeurs d'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 facilité cette tâche.

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

En JavaScript, la mémoire est gérée automatiquement 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 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 assez 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 génération. C'est normal, car la collecte de la jeune génération est beaucoup moins coûteuse. On peut souvent supposer que les pauses fréquentes de récupération de mémoire sont dues à la collecte de la jeune génération.

Dans V8, l'espace 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 est appelé "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 de destination est avancé du nombre d'octets nécessaire pour le nouvel objet. Ce processus se poursuit jusqu'à ce que l'espace de destination soit épuisé. À ce stade, le programme est arrêté et la collecte commence.

Mémoire jeune V8

À ce stade, l'espace de départ et l'espace d'arrivée sont permutés. L'espace qui était l'espace de destination et qui est maintenant l'espace source est analysé du début à la fin, et tous les objets qui sont toujours actifs sont copiés dans l'espace de destination ou sont promus dans le tas de l'ancienne génération. Pour en savoir plus, nous vous suggérons de vous renseigner 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'un garbage collection et de la redoutable pause de l'application.

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

En bref, non. Le développeur ne fait rien qui puisse générer 10 Mo/s de déchets.

Suspects

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

Suspect n° 1

Appel de "new" pendant l'image. N'oubliez pas que chaque objet alloué vous rapproche d'une pause de récupération de mémoire. Les applications s'exécutant à des fréquences d'images élevées doivent en particulier s'efforcer de ne pas effectuer d'allocations par image. En général, cela nécessite un système de recyclage d'objets soigneusement pensé et spécifique à l'application. Les détectives V8 ont vérifié auprès de l'équipe Oz, et ils n'appelaient pas "new". En fait, l'équipe d'Oz était déjà bien consciente de cette exigence et a déclaré : "Ce serait embarrassant". Rayez celui-ci de la liste.

Suspect n° 2

Modifier 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 nouvelle classe cachée est alors créée pour l'objet. Lorsque le code optimisé voit cette nouvelle classe masquée, une désoptimisation est déclenchée. Le code non optimisé s'exécute jusqu'à ce que le code soit classé comme chaud et optimisé à nouveau. Cette désoptimisation et cette réoptimisation entraînent des à-coups,mais ne sont pas strictement corrélées à une création excessive de déchets. Après un audit minutieux du code, il a été confirmé que les formes des objets étaient statiques, ce qui a permis d'écarter le suspect 2.

Suspect 3

Arithmétique dans du code non optimisé. Dans le 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 concernent les variables a, b et c. La quatrième est pour la valeur anonyme (a * b) et la cinquième est pour #4 * c. La cinquième est finalement attribuée à point.x.

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

Suspect 4

Stocker un nombre à double précision dans une propriété. Un objet HeapNumber doit être créé pour stocker le nombre, et la propriété doit être modifiée pour pointer vers ce nouvel objet. La modification de la propriété pour qu'elle pointe vers le HeapNumber ne générera pas de déchets. Cependant, 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 celle-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 anodine, un nouvel objet HeapNumber est implicitement alloué, ce qui nous rapproche d'une pause de 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 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 des nombres de tas en tant que propriétés d'objet et le calcul arithmétique se produisant dans des fonctions non optimisées. Il était temps de se rendre au laboratoire et de déterminer définitivement quel suspect était coupable. REMARQUE : Dans cette section, j'utiliserai une reproduction du problème trouvé dans le code source réel d'Oz. Cette reproduction est beaucoup plus petite que le code d'origine, ce qui facilite l'analyse.

Test n° 1

Recherche du suspect n° 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 en arrière-plan.

En commençant par Chrome qui ne s'exécute pas du tout, lancez Chrome avec les flags :

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

puis en quittant complètement Chrome, vous obtiendrez un fichier journal v8.log 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 (vérifiez about:version) et la compiler.

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

$ 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 v8.)

Le processeur de ticks affiche un tableau textuel des fonctions JavaScript qui ont eu 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 demo.js comporte trois fonctions : opt, unopt et main. Les fonctions optimisées sont signalées par un astérisque (*) à côté de leur nom. Notez que la fonction opt est optimisée et que la fonction unopt ne l'est pas.

Un autre outil important de la boîte à outils du détective V8 est 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 nommé timer-events.png se trouve dans le répertoire actuel. Lorsque vous l'ouvrez, vous devriez voir quelque chose comme ceci :

Événements de minuteur

En dehors du graphique en bas, les données sont affichées en lignes. L'axe X représente le temps (ms). Le côté gauche inclut des étiquettes pour chaque ligne :

Axe Y des événements de minuteur

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

L'une des lignes les plus importantes est "code kind being executed" (type de code en cours d'exécution). Il sera vert lorsque du code optimisé est en cours d'exécution, et rouge et bleu lorsque du code non optimisé est en cours d'exécution. La capture d'écran suivante montre la transition d'un code optimisé à un code non optimisé, puis à un code optimisé :

Type de code en cours d'exécution

Idéalement, mais jamais immédiatement, cette ligne sera verte et continue. Cela signifie que votre programme est passé à un état stable optimisé. Un code non optimisé s'exécutera toujours plus lentement qu'un code optimisé.

Si vous en êtes arrivé là, sachez que vous pouvez travailler beaucoup plus rapidement en refactorisant votre application pour qu'elle puisse s'exécuter dans l'interface système de débogage v8 : d8. L'utilisation de d8 vous permet d'accélérer 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'isoler le problème réel, ce qui réduit la quantité de bruit présente dans les données.

En examinant le graphique des événements de minuteur du code source d'Oz, on constate 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) :

Tracé des événements de minuteur

Si vous regardez de plus près, vous pouvez voir que les lignes noires indiquant quand V8 exécute le code JavaScript sont absentes précisément aux mêmes moments que les collectes de nouvelle génération (lignes bleues). Cela montre clairement que le script est mis en pause pendant la récupération de mémoire.

En examinant la sortie du processeur de ticks du code source d'Oz, on constate que la fonction principale (updateSprites) n'a pas été optimisée. En d'autres termes, la fonction dans laquelle le programme a passé le plus de temps n'était pas non plus optimisée. Cela indique fortement que le suspect 3 est le coupable. La source de updateSprites contenait des boucles qui ressemblaient à ceci :

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 construction de la boucle for-i-in n'est parfois pas optimisée par V8. En d'autres termes, si une fonction contient une construction de boucle for-i-in, elle ne sera peut-être pas optimisée. Il s'agit d'un cas particulier aujourd'hui, qui changera probablement à l'avenir. En effet, 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 sur le bout des doigts, comment pouvons-nous déterminer pourquoi updateSprites n'a pas été optimisé ?

Test n° 2

Exécution de Chrome avec ce flag :

--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 trouvons :

[disabled optimization for updateSprites, reason: ForInStatement is not fast case]

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

Affaire classée

Après avoir découvert la raison pour laquelle updateSprites n'était pas optimisé, la correction é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 une diminution du nombre d'objets HeapNumber et, par conséquent, des pauses de récupération de mémoire moins fréquentes. Vous devriez pouvoir le confirmer facilement en effectuant les mêmes tests avec un nouveau code. Le lecteur attentif remarquera que les nombres doubles sont toujours stockés en tant que propriétés. Si le profilage indique que cela en 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 d'Oz ne se sont pas arrêtés là. Grâce aux outils et aux techniques partagés par les détectives V8, ils ont pu trouver quelques autres fonctions qui étaient bloquées dans l'enfer de la désoptimisation et ont factorisé le code de calcul en fonctions 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 !