Conseils pour optimiser les performances de JavaScript dans V8

Chris Wilson
Chris Wilson

Introduction

Daniel Clifford a donné une excellente conférence lors de Google I/O sur les conseils et astuces pour améliorer les performances JavaScript dans V8. Daniel nous a encouragés à "demander plus vite", c'est-à-dire à analyser attentivement les différences de performances entre C++ et JavaScript, et à écrire du code en tenant compte du fonctionnement de JavaScript. Cet article contient un résumé des points les plus importants de la présentation de Daniel. Nous le mettrons à jour au fur et à mesure que les conseils sur les performances changeront.

Le conseil le plus important

Il est important de mettre en perspective les conseils sur les performances. Les conseils d'amélioration des performances sont addictifs, et parfois se concentrer d'abord sur des conseils avisés peut détourner l'attention des vrais problèmes. Vous devez adopter une approche globale des performances de votre application Web. Avant de vous concentrer sur ces conseils, vous devriez probablement analyser votre code à l'aide d'outils tels que PageSpeed et améliorer votre score. Vous éviterez ainsi une optimisation prématurée.

Voici le meilleur conseil de base pour obtenir de bonnes performances dans les applications Web :

  • Préparez-vous avant de rencontrer (ou de remarquer) un problème
  • Ensuite, identifiez et comprenez le nœud de votre problème
  • Enfin, corrigez ce qui compte

Pour effectuer ces étapes, il peut être important de comprendre comment V8 optimise le code JavaScript afin de pouvoir écrire du code en tenant compte de la conception de l'environnement d'exécution JavaScript. Il est également important de découvrir les outils disponibles et comment ils peuvent vous aider. Daniel explique plus en détail comment utiliser les outils pour les développeurs dans sa présentation. Ce document ne reprend que quelques-uns des points les plus importants de la conception du moteur V8.

Passons maintenant aux conseils sur V8 !

Cours masqués

JavaScript dispose d'informations de type limitées au moment de la compilation: les types peuvent être modifiés au moment de l'exécution. Il est donc naturel de prévoir qu'il est coûteux d'analyser les types JS au moment de la compilation. Vous vous demandez peut-être comment les performances JavaScript pourraient jamais s'approcher de celles de C++. Toutefois, V8 crée des types masqués en interne pour les objets au moment de l'exécution. Les objets de la même classe masquée peuvent alors utiliser le même code généré optimisé.

Exemple :

function Point(x, y) {
  this.x = x;
  this.y = y;
}

var p1 = new Point(11, 22);
var p2 = new Point(33, 44);
// At this point, p1 and p2 have a shared hidden class
p2.z = 55;
// warning! p1 and p2 now have different hidden classes!```

Tant que l'instance d'objet p2 n'a pas ajouté le membre supplémentaire ".z", p1 et p2 ont en interne la même classe cachée. V8 peut donc générer une version unique d'assemblage optimisé pour le code JavaScript qui manipule p1 ou p2. Plus vous pouvez éviter de faire diverger les classes masquées, meilleures seront les performances.

Par conséquent

  • Initialisez tous les membres d'objet dans les fonctions de constructeur (afin que les instances ne changent pas de type plus tard).
  • Toujours initialiser les membres d'objets dans le même ordre

Numbers

V8 utilise l'ajout de tags pour représenter efficacement les valeurs lorsque les types peuvent changer. V8 déduit du type de valeurs que vous utilisez le type de nombre avec lequel vous travaillez. Une fois que V8 a effectué cette inférence, il utilise le taggage pour représenter les valeurs de manière efficace, car ces types peuvent changer de manière dynamique. Toutefois, la modification de ces balises de type peut parfois entraîner des coûts. Il est donc préférable d'utiliser des types de nombres de manière cohérente. En général, il est optimal d'utiliser des entiers signés 31 bits lorsque cela est approprié.

Exemple :

var i = 42;  // this is a 31-bit signed integer
var j = 4.2;  // this is a double-precision floating point number```

Par conséquent

  • Privilégiez les valeurs numériques pouvant être représentées sous forme d'entiers signés de 31 bits.

Tableaux

Pour gérer des tableaux volumineux et clairsemés, deux types de stockage de tableaux sont disponibles en interne:

  • Éléments rapides: stockage linéaire pour des ensembles de clés compacts
  • Éléments de dictionnaire: stockage dans une table de hachage, sinon

Il est préférable de ne pas faire basculer le stockage du tableau d'un type à un autre.

Par conséquent,

  • Utiliser des clés contiguës à partir de 0 pour les tableaux
  • Ne préallouez pas la taille maximale des grands tableaux (par exemple, plus de 64 000 éléments), mais agrandissez-les au fur et à mesure.
  • Ne pas supprimer d'éléments de tableaux, en particulier de tableaux numériques
  • Ne chargez pas d'éléments non initialisés ou supprimés:
for (var b = 0; b < 10; b++) {
  a[0] |= b;  // Oh no!
}
//vs.
a = new Array();
a[0] = 0;
for (var b = 0; b < 10; b++) {
  a[0] |= b;  // Much better! 2x faster.
}

De plus, les tableaux de doubles sont plus rapides : la classe masquée du tableau suit les types d'éléments, et les tableaux ne contenant que des doubles sont déboîtés (ce qui entraîne un changement de classe masquée). Toutefois, une manipulation imprudente des tableaux peut entraîner des tâches supplémentaires en raison du boîtier et du déboîtement, par exemple :

var a = new Array();
a[0] = 77;   // Allocates
a[1] = 88;
a[2] = 0.5;   // Allocates, converts
a[3] = true; // Allocates, converts```

est moins efficace que:

var a = [77, 88, 0.5, true];

En effet, dans le premier exemple, les attributions individuelles sont effectuées l'une après l'autre. L'attribution de a[2] entraîne la conversion du tableau en tableau de doubles non empaquetés, mais l'attribution de a[3] le convertit à nouveau en tableau pouvant contenir n'importe quelle valeur (nombres ou objets). Dans le second cas, le compilateur connaît les types de tous les éléments du littéral, et la classe cachée peut être déterminée à l'avance.

  • Initialiser à l'aide de littéraux de tableau pour de petits tableaux de taille fixe
  • Préallouer de petites matrices (<64 ko) à la taille correcte avant de les utiliser
  • Ne pas stocker de valeurs non numériques (objets) dans des tableaux numériques
  • Veillez à ne pas reconvertir de petits tableaux si vous effectuez une initialisation sans littéraux.

Compilation JavaScript

Bien que JavaScript soit un langage très dynamique et que ses implémentations d'origine étaient des interpréteurs, les moteurs d'exécution JavaScript modernes utilisent la compilation. V8 (JavaScript de Chrome) dispose en fait de deux compilateurs JIT (Just In Time, juste à temps) différents:

  • Le compilateur "Full", qui peut générer du code de qualité pour n'importe quel code JavaScript
  • Le compilateur d'optimisation, qui génère du code de qualité pour la plupart des scripts JavaScript, mais prend plus de temps à compiler.

Le compilateur complet

Dans V8, le compilateur complet s'exécute sur tout le code et commence à exécuter du code dès que possible, générant rapidement du code de qualité, mais pas de qualité. Ce compilateur ne suppose pratiquement rien sur les types au moment de la compilation. Il s'attend à ce que les types de variables puissent et changeront au moment de l'exécution. Le code généré par le compilateur complet utilise des caches intégrés (IC) pour affiner les connaissances sur les types pendant l'exécution du programme, ce qui améliore l'efficacité instantanément.

L'objectif des caches intégrés est de gérer efficacement les types en mettant en cache le code dépendant des types pour les opérations. Lorsque le code s'exécute, il valide d'abord les hypothèses de type, puis utilise le cache intégré pour raccourcir l'opération. Toutefois, cela signifie que les opérations qui acceptent plusieurs types seront moins performantes.

Par conséquent

  • L'utilisation monomorphe des opérations est préférable aux opérations polymorphes

Les opérations sont monomorphes si les classes d'entrées cachées sont toujours les mêmes. Dans le cas contraire, elles sont polymorphes, ce qui signifie que certains arguments peuvent changer de type lors des différents appels à l'opération. Par exemple, le deuxième appel add() de cet exemple provoque un polymorphisme:

function add(x, y) {
  return x + y;
}

add(1, 2);      // + in add is monomorphic
add("a", "b");  // + in add becomes polymorphic```

Le compilateur d'optimisation

En parallèle du compilateur complet, V8 recompile les fonctions "chaudes" (c'est-à-dire les fonctions exécutées plusieurs fois) avec un compilateur optimisant. Ce compilateur utilise le retour d'information sur le type pour accélérer le code compilé. En fait, il utilise les types issus des circuits intégrés dont nous venons de parler.

Dans le compilateur d'optimisation, les opérations sont insérées de manière spéculative (elles sont placées directement à l'endroit où elles sont appelées). Cela accélère l'exécution (au détriment de l'espace mémoire), mais permet également d'autres optimisations. Les fonctions et les constructeurs monomorphes peuvent être entièrement intégrés (c'est une autre raison pour laquelle le monomorphisme est une bonne idée dans V8).

Vous pouvez consigner ce qui est optimisé à l'aide de la version autonome "d8" du moteur V8:

d8 --trace-opt primes.js

(cela consigne les noms des fonctions optimisées dans la sortie standard).

Cependant, toutes les fonctions ne peuvent pas être optimisées. Certaines fonctionnalités empêchent le compilateur d'optimisation d'exécuter une fonction donnée (un "abandon"). En particulier, le compilateur d'optimisation arrête actuellement les fonctions avec des blocs try {} catch {}.

Par conséquent

  • Placez le code sensible aux performances dans une fonction imbriquée si vous disposez de blocs try {} catch {} : ```js function perf_sensitive() { // Effectuez ici le travail sensible aux performances }

try { perf_sensitive() } catch (e) { // Gérer les exceptions ici } ```

Ces conseils seront probablement modifiés à l'avenir, car nous allons activer les blocs try/catch dans le compilateur d'optimisation. Vous pouvez examiner comment le compilateur d'optimisation gère les fonctions en utilisant l'option "--trace-opt" avec d8 comme ci-dessus. Vous obtiendrez ainsi plus d'informations sur les fonctions qui ont été abandonnées :

d8 --trace-opt primes.js

Désoptimisation

Enfin, l'optimisation effectuée par ce compilateur est spéculative. Parfois, elle ne fonctionne pas et nous reculons. Le processus de "dé-optimisation" jette le code optimisé et reprend l'exécution au bon endroit dans le code du compilateur "complet". La réoptimisation peut être déclenchée à nouveau plus tard, mais à court terme, l'exécution est ralentie. En particulier, si vous modifiez les classes de variables masquées après l'optimisation des fonctions, cette dé-optimisation se produira.

Par conséquent,

  • Éviter les modifications de classe masquées dans les fonctions après leur optimisation

Comme pour d'autres optimisations, vous pouvez obtenir un journal des fonctions que V8 a dû désoptimiser à l'aide d'un indicateur de journalisation:

d8 --trace-deopt primes.js

Autres outils V8

À ce propos, vous pouvez également transmettre les options de traçage V8 à Chrome au démarrage:

"/Applications/Google Chrome.app/Contents/MacOS/Google Chrome" --js-flags="--trace-opt --trace-deopt"```

En plus du profilage des outils de développement, vous pouvez utiliser d8 pour le profilage:

% out/ia32.release/d8 primes.js --prof

Il utilise le profileur d'échantillonnage intégré, qui prend un échantillon toutes les millisecondes et écrit v8.log.

En résumé

Il est important d'identifier et de comprendre comment le moteur V8 fonctionne avec votre code pour vous préparer à créer du code JavaScript performant. Encore une fois, le conseil de base est le suivant :

  • Préparez-vous avant de rencontrer (ou de remarquer) un problème
  • Ensuite, identifiez et comprenez le nœud de votre problème
  • Enfin, corrigez ce qui compte

Vous devez donc vous assurer que le problème provient de votre code JavaScript, en utilisant d'abord d'autres outils comme PageSpeed, en réduisant éventuellement le code à JavaScript pur (pas de DOM) avant de collecter des métriques, puis en utilisant ces métriques pour localiser les goulots d'étranglement et éliminer les plus importants. Nous espérons que la présentation de Daniel (et cet article) vous aideront à mieux comprendre comment V8 exécute JavaScript, mais n'oubliez pas de vous concentrer également sur l'optimisation de vos propres algorithmes !

Références