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 sur les performances sont addictifs, et parfois, se concentrer d'abord sur des conseils détaillés peut détourner l'attention des véritables 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. Cela vous aidera à éviter 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
- Identifiez ensuite le cœur du problème et comprenez-le.
- 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 aux conseils 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 naturel de s'attendre à ce que la réflexion sur les types JS au moment de la compilation soit coûteuse. 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 masquée. V8 peut donc générer une seule version d'assembly 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'objet dans le même ordre
Numbers
V8 utilise le taggage 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 commençant à 0 pour les tableaux
- N'allouez pas de manière préemptive de grands tableaux (par exemple, plus de 64 ko d'éléments) à leur taille maximale.Augmentez-les 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 ni 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 masqué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 provoquer de reconversion 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) différents:
- Le compilateur "Full", qui peut générer du code de qualité pour n'importe quel code JavaScript
- Le compilateur optimisant, qui produit un code de qualité pour la plupart des scripts JavaScript, mais dont la compilation est plus longue.
Le compilateur complet
Dans V8, le compilateur complet s'exécute sur tout le code et commence à l'exécuter dès que possible, générant rapidement du code correct, mais pas excellent. Ce compilateur ne fait presque aucune hypothèse sur les types au moment de la compilation. Il s'attend à ce que les types de variables puissent et vont changer 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 masquées sont toujours les mêmes. Sinon, elles sont polymorphes, ce qui signifie que certains des arguments peuvent changer de type entre les 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```
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 d'optimisation. 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 IC 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érez 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 les autres optimisations, vous pouvez obtenir un journal des fonctions que V8 a dû dé-optimiser avec un indicateur de journalisation:
d8 --trace-deopt primes.js
Autres outils V8
Par ailleurs, 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
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
- Identifiez ensuite le cœur du problème et comprenez-le.
- Enfin, corrigez ce qui compte
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. Vous pouvez éventuellement réduire le code à du code JavaScript pur (sans DOM) avant de collecter des métriques, puis utiliser ces métriques pour localiser les goulots d'étranglement et les éliminer. Nous espérons que la conférence de Daniel (et cet article) vous aideront à mieux comprendre comment V8 exécute JavaScript. N'oubliez pas d'optimiser vos propres algorithmes !