Conseils pour optimiser les performances de JavaScript dans V8

Chris Wilson
Chris Wilson

Introduction

Daniel Clifford a donné un excellent discours lors de la conférence Google I/O, avec des conseils et des astuces pour améliorer les performances de JavaScript dans V8. Daniel nous a encouragés à "accélérer la demande" pour analyser avec attention les différences de performances entre C++ et JavaScript, et écrire le code en tenant compte du fonctionnement de JavaScript. Cet article récapitule les points les plus importants de la présentation de Daniel. Nous le mettrons également à jour à mesure que les conseils sur les performances évoluent.

Conseils les plus importants

Il est important de mettre en contexte les conseils d'amélioration des 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 avoir une vision globale des performances de votre application Web. Avant de vous concentrer sur ces conseils sur les performances, nous vous conseillons d'analyser votre code à l'aide d'outils comme PageSpeed et d'améliorer votre score. Vous éviterez ainsi une optimisation prématurée.

Voici le meilleur conseil à suivre pour obtenir de bonnes performances dans vos applications Web:

  • Être préparé avant d’avoir (ou de remarquer) un problème
  • Ensuite, identifiez et comprenez le noyau de votre 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 d'écrire du code en tenant compte de la conception de l'environnement d'exécution JS. Il est également important de connaître les outils disponibles et de savoir 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 fait que capturer 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 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. Cela peut vous amener à vous demander comment les performances JavaScript pourraient se rapprocher de celles de C++. Cependant, V8 comporte des types cachés créés en interne pour les objets au moment de l'exécution. objets avec la même classe cachée peuvent ensuite 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!```

Jusqu'à ce que l'instance d'objet p2 dispose du membre supplémentaire ".z" p1 et p2 ont en interne la même classe cachée, de sorte que V8 peut générer une version unique d'assemblage optimisé pour le code JavaScript qui manipule p1 ou p2. Plus vous pouvez éviter de provoquer une divergence entre les classes masquées, meilleures seront vos performances.

Par conséquent,

  • Initialiser tous les membres d'objets dans les fonctions de constructeur (afin que les instances ne changent pas de type par la suite)
  • 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 des valeurs à partir desquelles vous utilisez le type de nombre auquel vous avez affaire. Une fois que V8 a effectué cette inférence, il utilise l'ajout de tags pour représenter efficacement les valeurs, car ces types peuvent changer de façon dynamique. Cependant, la modification de ces types de tags a parfois un coût. Il est donc préférable d'utiliser les types numériques de manière cohérente et, en général, il est plus optimal 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 les tableaux volumineux et creux, il existe deux types de stockage de tableaux en interne:

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

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 commençant à 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 supprimez pas 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 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é). Cependant, une manipulation improbable des tableaux peut entraîner un travail supplémentaire du fait de la mise en boîte 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 l'une après l'autre, et l'attribution de a[2] entraîne la conversion du tableau en tableau de doubles déballés, tandis que l'attribution de a[3] le reconvertit en un tableau pouvant contenir n'importe quelle valeur (nombres ou objets). Dans le deuxième 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éallouez de petits tableaux (< 64 Ko) pour corriger leur taille avant de les utiliser.
  • Ne stockez pas 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

Même si JavaScript est un langage très dynamique et que ses implémentations initiales étaient des interpréteurs, les moteurs d'exécution JavaScript modernes utilisent la compilation. V8 (JavaScript de Chrome) dispose en fait de deux compilateurs Just-In-Time (JIT) différents:

  • Le mode "Full" un compilateur capable de générer du code adapté à tout type de 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.

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 changent 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é à 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 d'opérations monomorphes est préférable à l'utilisation d'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 "à chaud" (c'est-à-dire des fonctions qui sont exécutées plusieurs fois) à l'aide d'un compilateur d'optimisation. Ce compilateur utilise des retours de type pour accélérer le code compilé. En fait, il utilise les types provenant 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 (directement à l'endroit où elles sont appelées). Cela accélère l'exécution (au détriment de l'espace mémoire utilisé), mais permet également 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 enregistre les noms des fonctions optimisées dans stdout).

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

Par conséquent,

  • Placez le code sensible aux perf dans une fonction imbriquée si vous essayez d'utiliser des blocs {} catch {} : ```js function perf_sensitive() { // Travailler ici en tenant compte des performances }

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

Ces conseils seront probablement modifiés à l'avenir, car nous activerons les blocs try/catch dans le compilateur d'optimisation. Pour observer comment le compilateur d'optimisation abandonne certaines fonctions, utilisez l'option "--trace-opt". avec d8 comme ci-dessus, qui vous donne plus d'informations sur les fonctions qui ont été ignoré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ésoptimisation" supprime le code optimisé et reprend l'exécution au bon endroit en mode "complet" code de compilation. La réoptimisation peut être déclenchée à nouveau plus tard, mais à court terme, l'exécution ralentit. En particulier, le fait de modifier les classes cachées des variables après l'optimisation des fonctions entraînera cette désoptimisation.

Par conséquent,

  • Éviter les modifications de classe caché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 de développement, vous pouvez utiliser d8 pour le profilage:

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

Elle utilise le profileur d'échantillonnage intégré, qui prend un échantillon chaque milliseconde 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:

  • Être préparé avant d’avoir (ou de remarquer) un problème
  • Ensuite, identifiez et comprenez le noyau de votre problème
  • Enfin, corrigez ce qui compte le plus

Cela signifie que vous devez vous assurer que le problème se trouve dans votre code JavaScript, en utilisant d'abord d'autres outils comme PageSpeed ; en se limitant éventuellement à du JavaScript pur (pas de DOM) avant de collecter des métriques, puis d'utiliser 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