Conseils pour optimiser les performances de JavaScript dans V8

Chris Wilson
Chris Wilson

Introduction

Daniel Clifford a donné une excellente conférence lors de la conférence Google I/O afin de partager des conseils et astuces pour améliorer les performances de JavaScript dans V8. Daniel nous a encouragés à "accélérer la demande" afin d'analyser attentivement les différences de performances entre C++ et JavaScript, et d'écrire du code en tenant compte du fonctionnement de JavaScript. Cet article récapitule les points les plus importants de l'intervention de Daniel. Il sera également tenu à jour à mesure que les conseils sur les performances évoluent.

Conseils les plus importants

Il est important de contextualiser les conseils d'amélioration des performances. Les conseils sur les performances sont addictifs. Parfois, se concentrer d'abord sur des conseils détaillés peut détourner l'attention des vrais problèmes. Vous devez avoir une vue globale des performances de votre application Web. Avant de vous concentrer sur ces conseils, nous vous conseillons d'analyser votre code à l'aide d'outils tels que PageSpeed, puis d'améliorer votre score. Cela vous permettra d'éviter toute optimisation prématurée.

Le meilleur conseil de base pour obtenir de bonnes performances dans les applications Web est le suivant:

  • Se préparer avant de rencontrer (ou de remarquer) un problème
  • Ensuite, identifiez et comprenez le nœud du problème
  • Enfin, corrigez ce qui compte le plus

Pour réaliser ces étapes, il peut être important de comprendre comment V8 optimise JS, afin que vous puissiez écrire votre code en tenant compte de la conception de l'environnement d'exécution JavaScript. Il est également important de connaître les outils disponibles et comment ils peuvent vous aider. Daniel explique plus en détail comment utiliser les outils pour les développeurs dans son discours ; ce document ne fait que résumer certains des points les plus importants de la conception du moteur V8.

Passons maintenant aux conseils sur V8 !

Classes masquées

JavaScript dispose d'informations limitées sur le type au moment de la compilation: les types peuvent être modifiés au moment de l'exécution. Il est donc naturel de s'attendre à ce qu'il soit coûteux de traiter les types JS au moment de la compilation. Cela peut vous amener à vous demander comment les performances JavaScript pourraient s'approcher de C++. Cependant, V8 comporte des types masqués créés en interne pour les objets lors de l'exécution. Les objets ayant la même classe caché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é de membre ".z", p1 et p2 ont en interne la même classe masquée. V8 peut donc générer une version unique de l'assemblage optimisé pour le code JavaScript qui manipule p1 ou p2. Plus vous éviterez de provoquer la divergence des classes masquées, meilleures seront les performances.

Par conséquent,

  • Initialiser tous les membres d'objet dans les fonctions de constructeur (afin que les instances ne changent pas de type par la suite)
  • Toujours initialiser les membres de l'objet dans le même ordre

Numéros

V8 utilise le taggage pour représenter efficacement les valeurs lorsque les types peuvent changer. V8 déduit des valeurs que vous utilisez le type de nombre concerné. Une fois que V8 a effectué cette inférence, il utilise le taggage pour représenter efficacement les valeurs, car ces types peuvent changer de façon dynamique. Cependant, la modification de ces tags de type a parfois un coût. Il est donc préférable d'utiliser les types de nombres de manière cohérente et, en général, il est préférable d'utiliser des entiers signés de 31 bits le cas échéant.

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,

  • Préférez les valeurs numériques pouvant être représentées par des entiers signés de 31 bits.

Tableaux

Pour gérer des tableaux creux et volumineux, il existe deux types de stockage interne pour les tableaux:

  • Fast Elements: stockage linéaire pour les ensembles de clés compacts
  • Éléments de dictionnaire: stockage de table de hachage dans les autres cas

Il est préférable de ne pas changer le type de stockage de la matrice.

Par conséquent,

  • Utiliser des clés contiguës commençant à 0 pour les tableaux
  • Ne préallouez pas les grands tableaux (plus de 64 000 éléments, par exemple) à leur taille maximale, mais agrandissez l'ensemble au fur et à mesure.
  • Ne pas supprimer les éléments des tableaux, en particulier les 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 cachée du tableau suit les types d'éléments, et les tableaux ne contenant que des doubles sont déballés (ce qui entraîne un changement de classe cachée). Cependant, une manipulation insouciante des tableaux peut entraîner un travail supplémentaire en raison du boxing et du déballage (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];

car dans le premier exemple, les attributions individuelles sont effectuées les unes après les autres, et l'attribution de a[2] entraîne la conversion du tableau en tableau de doubles déballés, mais l'attribution de a[3] le reconvertit en un 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 les petits tableaux de taille fixe
  • Pré-allonger les petits tableaux (<64 Ko) à la bonne taille avant de les utiliser
  • Ne stockez pas de valeurs non numériques (objets) dans les tableaux numériques
  • Veillez à ne pas entraîner de reconversion de petits tableaux si vous effectuez une initialisation sans littéraux.

Compilation JavaScript

Bien que JavaScript soit un langage très dynamique dont les implémentations originales étaient des interpréteurs, les moteurs d'exécution JavaScript modernes utilisent la compilation. V8 (le code JavaScript de Chrome) possède deux compilateurs Just-In-Time (JIT) différents:

  • Le compilateur "Full", qui peut générer un bon code pour n'importe quel JavaScript
  • Le compilateur d'optimisation, qui génère un excellent code 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 soient modifiés 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, améliorant ainsi son efficacité à la volée.

L'objectif des caches intégrés est de gérer efficacement les types, en mettant en cache le code dépendant du type 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 à l'utilisation polymorphe.

Les opérations sont monomorphes si les classes cachées des entré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 de l'opération. Par exemple, le deuxième appel add() de cet exemple entraîne un polymorphisme:

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

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

Compilateur d'optimisation

Parallèlement au compilateur complet, V8 recompile les fonctions "chaudes" (c'est-à-dire les fonctions exécutées plusieurs fois) avec un compilateur d'optimisation. Ce compilateur utilise le retour de 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 intégrées de manière spéculative (elles sont placées directement là où elles sont appelées). Cela accélère l'exécution (au prix de l'espace mémoire utilisé), mais permet également d'effectuer d'autres optimisations. Les fonctions et 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 stdout).

Cependant, toutes les fonctions ne peuvent pas être optimisées. Certaines d'entre elles empêchent le compilateur d'optimisation de s'exécuter sur une fonction donnée (une "bail-out"). En particulier, le compilateur d'optimisation annule actuellement les fonctions avec les blocs try {} catch {} !

Par conséquent,

  • Placez le code sensible aux performances dans une fonction imbriquée si vous essayez des blocs {} catch {} : ```js function perf_sensitive() { // Travaillez ici pour les performances sensibles }

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

Ces conseils seront probablement modifiés à l'avenir, lorsque nous activerons les blocs try/catch dans le compilateur d'optimisation. Vous pouvez examiner comment le compilateur d'optimisation abandonne les fonctions en utilisant l'option "--trace-opt" avec d8 comme ci-dessus, qui vous donne plus d'informations sur les fonctions qui ont été supprimé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 abandonnons. Le processus de désoptimisation élimine le code optimisé et reprend l'exécution au bon endroit avec le code de compilation "complet". La réoptimisation peut être déclenchée à nouveau plus tard, mais à court terme, l'exécution ralentit. En particulier, la modification des classes de variables masquées après l'optimisation des fonctions entraîne cette désoptimisation.

Par conséquent,

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

Comme pour les 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 pour les développeurs, vous pouvez utiliser d8 pour effectuer le profilage:

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

Cette fonction 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:

  • Se préparer avant de rencontrer (ou de remarquer) un problème
  • Ensuite, identifiez et comprenez le nœud du problème
  • Enfin, corrigez ce qui compte le plus

Vous devez donc vous assurer que le problème se trouve dans votre code JavaScript, en utilisant d'abord d'autres outils tels que PageSpeed ; avant de collecter les métriques, vous devez éventuellement réduire à JavaScript pur (sans DOM). Utilisez ensuite ces métriques pour localiser les goulots d'étranglement et éliminer les plus importants. J'espère que l'intervention de Daniel (et cet article) vous aidera à mieux comprendre comment V8 exécute JavaScript. Mais veillez également à vous concentrer sur l'optimisation de vos propres algorithmes !

Références