Introduction
Daniel Clifford a donné une excellente présentation lors de Google I/O sur les conseils et astuces pour améliorer les performances JavaScript dans V8. Daniel nous a encouragés à "exiger plus de rapidité", 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 résume les points les plus importants de la présentation de Daniel. Nous le mettrons à jour à mesure que les conseils sur les performances évolueront.
Le conseil le plus important
Il est important de replacer les conseils d'optimisation des performances dans leur contexte. Les conseils d'optimisation des performances sont addictifs, et se concentrer d'abord sur des conseils approfondis peut parfois détourner l'attention des vrais problèmes. Vous devez adopter une vue d'ensemble des performances de votre application Web. Avant de vous concentrer sur ces conseils d'optimisation, vous devriez probablement analyser votre code avec des outils tels que PageSpeed et améliorer votre score. Cela vous aidera à éviter une optimisation prématurée.
Le meilleur conseil de base pour obtenir de bonnes performances dans les applications Web est le suivant :
- Préparez-vous avant de rencontrer (ou de remarquer) un problème
- Ensuite, identifiez et comprenez le cœur de votre problème.
- Enfin, corrigez ce qui compte
Pour ce faire, il peut être important de comprendre comment V8 optimise le code JS, afin de pouvoir é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 reprend que certains des points les plus importants de la conception du moteur V8.
Passons maintenant aux conseils pour V8.
Cours masqués
JavaScript dispose d'informations limitées sur les types au moment de la compilation : les types peuvent être modifiés au moment de l'exécution. Il est donc normal de s'attendre à ce qu'il soit coûteux de raisonner sur les types JS au moment de la compilation. Vous vous demandez peut-être comment les performances de JavaScript peuvent se rapprocher de celles de C++. Cependant, V8 dispose de types masqués créé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 de membre ".z" supplémentaire, p1 et p2 ont en interne la même classe cachée. V8 peut donc générer une seule version d'assemblage optimisé pour le code JavaScript qui manipule p1 ou p2. Plus vous éviterez de faire diverger les classes cachées, meilleures seront les performances que vous obtiendrez.
Par conséquent
- Initialisez tous les membres d'objet dans les fonctions de constructeur (afin que les instances ne changent pas de type ultérieurement).
- Toujours initialiser les membres d'un objet dans le même ordre
Numbers
V8 utilise le tagging pour représenter efficacement les valeurs lorsque les types peuvent changer. V8 déduit des valeurs que vous utilisez le type de nombre auquel vous avez affaire. 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 manière dynamique. Cependant, la modification de ces tags de type peut parfois avoir 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 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
- Privilégiez les valeurs numériques pouvant être représentées sous forme d'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 les ensembles de clés compacts
- Éléments de dictionnaire : stockage de 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 commençant par 0 pour les tableaux
- Ne préallouez pas de grands tableaux (par exemple, > 64 000 éléments) à leur taille maximale.Augmentez plutôt leur taille au fur et à mesure.
- Ne supprimez pas d'éléments dans les 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 masquée du tableau suit les types d'éléments, et les tableaux ne contenant que des doubles sont unboxés (ce qui entraîne un changement de classe masquée). Cependant, une manipulation imprudente des tableaux peut entraîner un travail supplémentaire en raison du boxing et de l'unboxing, 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 un tableau de doubles non encapsulés, mais l'attribution de a[3] entraîne sa reconversion 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 masqué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 les petits tableaux (< 64 ko) à la bonne taille avant de les utiliser.
- Ne pas stocker de valeurs non numériques (objets) dans des tableaux numériques
- Veillez à ne pas provoquer de reconversion de petits tableaux si vous effectuez l'initialisation sans littéraux.
Compilation JavaScript
Bien que JavaScript soit un langage très dynamique et que ses implémentations d'origine aient été des interpréteurs, les moteurs d'exécution JavaScript modernes utilisent la compilation. En fait, V8 (le moteur JavaScript de Chrome) dispose de deux compilateurs JIT (Just-In-Time) 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 produit un code de qualité pour la plupart des scripts JavaScript, mais qui prend plus de temps à compiler.
Le compilateur complet
Dans V8, le compilateur complet s'exécute sur l'ensemble du code et commence à l'exécuter dès que possible, en générant rapidement un code de bonne qualité, mais pas optimal. Ce compilateur ne fait presque aucune hypothèse sur les types au temps de compilation. Il s'attend à ce que les types de variables puissent changer et changent au moment de l'exécution. Le code généré par le compilateur Full 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 monomorphe des opérations est préférable aux opérations polymorphes
Les opérations sont monomorphes si les classes cachées des entrées sont toujours les mêmes. Sinon, elles sont polymorphes, ce qui signifie que certains arguments peuvent changer de type d'un appel à l'autre. Par exemple, le deuxième appel add() dans 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```
Le compilateur d'optimisation
En parallèle du compilateur complet, V8 recompile les fonctions "hot" (c'est-à-dire les fonctions exécutées de nombreuses fois) avec un compilateur d'optimisation. Ce compilateur utilise le feedback de type pour accélérer le code compilé. En fait, il utilise les types extraits des IC dont nous venons de parler.
Dans le compilateur d'optimisation, les opérations sont intégrées de manière spéculative (placées directement là 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 consigne 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 abandonne actuellement les fonctions avec des blocs try {} catch {} !
Par conséquent
- Placez le code sensible aux performances dans une fonction imbriquée si vous avez des blocs try {} catch {} : ```js function perf_sensitive() { // Do performance-sensitive work here }
try { perf_sensitive() } catch (e) { // Handle exceptions here } ```
Ces conseils changeront probablement à l'avenir, car 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, ce qui vous donne 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 revenons en arrière. Le processus de "désoptimisation" supprime 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 ralentit. En particulier, la modification des classes cachées de variables après l'optimisation des fonctions entraînera cette désoptimisation.
Par conséquent
- Éviter les modifications de classes 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 flag de journalisation :
d8 --trace-deopt primes.js
Autres outils V8
Vous pouvez également transmettre des 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 d'utiliser le profilage des outils pour les développeurs, vous pouvez également utiliser d8 pour effectuer le profilage :
% out/ia32.release/d8 primes.js --prof
Cette commande utilise le profileur d'échantillonnage intégré, qui effectue un échantillonnage 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 cœur de votre problème.
- Enfin, corrigez ce qui compte
Cela signifie que vous devez vous assurer que le problème se situe dans votre code JavaScript, en utilisant d'abord d'autres outils comme PageSpeed, en réduisant éventuellement le code à du JavaScript pur (sans 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. Toutefois, n'oubliez pas de vous concentrer également sur l'optimisation de vos propres algorithmes.