V8 Engine: Comment ça marche?

V8 est un moteur Javascript créé au centre de développement de Google, en Allemagne. Le moteur est open source et écrit en C++. Il est utilisé autant pour le côté client (Google Chrome) que pour le côté serveur (node.js), dans l’écosystème des applications JavaScript.
athletes qui courent : V8 by js republic
V8 a été premièrement conceptualisé pour augmenter la performance d’exécution de JavaScript dans les navigateurs. Pour gagner en rapidité, V8 traduit le code JavaScript en langage machine performant au lieu d’utiliser un interpréteur. Le moteur compile le code Javascript en assembleur lors de l’exécution en implémentant un compilateur JIT (Just-In-Time) comme la plupart des moteurs Javascript modernes (comme SpiderMonkey ou Rhino (Mozilla)) le font. La différence principale avec V8 est qu’il ne produit pas de bytecode/code intermédiaire.
Le but de cet article est de vous montrer et vous faire comprendre comment le moteur V8 marche de façon à produire du code optimisé pour le côté client et le côté serveur de vos applications. Si vous vous posez déjà la question “Devrais-je être préoccupé par la performance du JavaScript”, alors je vous réponds avec une citation de Daniel Clifford (tech lead et manager de l’équipe V8):
En français:

“Ce n’est pas juste pour rendre votre application actuelle plus rapide, c’est pour rendre possible certaines choses qui ne l’étaient pas avant”

En anglais:

“It’s not just about making your current application run faster, it’s about enabling things that you have never been able to do in the past”.

Classe cachée

Le JavaScript est un prototype-based: Il n’y a pas de classe et les objets sont créés en utilisant un processus de clonage. Le JS est aussi un langage dynamiquement typé: les types et leurs informations ne sont pas explicites et des propriétés peuvent être ajoutées et même supprimées des objets à la volée. Accéder efficacement aux types et propriétés est un premier grand défi pour V8. Au lieu d’utiliser une structure ressemblante à un dictionnaire pour garder les propriétés des objets, et faire un lookup dynamique pour trouver la localisation d’une propriété (comme la plupart des moteurs Javascript font), V8 crée des classes cachées, en runtime, de façon à avoir une représentation interne du système de type et améliorer les temps d’accès au propriétés.
Prenons comme exemple une fonction Point et la création de deux objets Point:

Si les layouts sont les mêmes, ce qui est le cas, p et q font partie de la même classe cachée crée par V8. Ceci démontre un autre avantage de l’utilisation des classes cachées: Elles permettent à V8 de grouper les objets qui ont les mêmes propriétés. Ici; p et q utilisent le même code optimisé.
Assumons maintenant que nous voulons ajouter une propriété z à notre objet q, juste après sa déclaration (ce qui est absolument normal avec un langage dynamiquement typé).
Comment V8 arrive-t-il à gérer ce scénario ? En réalité, V8 crée une nouvelle classe cachée à chaque fois que la fonction constructeur déclare une propriété et il vérifie les changement de la classe cachée. Pourquoi ? Car si deux objets sont créés (p et q) et si un membre est ajouté au deuxième objet (q) après sa création, V8 a besoin de maintenir la dernière classe cachée créée (pour le premier objet p) et le moteur a besoin d’en créer une nouvelle avec (pour le deuxième objet q) le nouveau membre.
Example of how V8 creates hidden classes for the constructor functions
À chaque fois qu’une nouvelle classe cachée est créée, la précédente est mise à jour avec une transition de classe qui indique quelle est la classe cachée à utiliser au lieu de la précédente.

Optimisation de code

Comme V8 crée une nouvelle classe cachée pour chaque propriété, leurs créations doivent être minimisées. Pour ça, il faut essayer de ne pas ajouter des propriétés après la création de l’objet et toujours initialiser les membres de l’objet dans le même ordre (pour éviter des différents arbres de classes cachées).
Une autre astuce : les opérations monomorphiques sont celles qui fonctionnent dans des objets qui partagent la même classe cachée. V8 crée une classe cachée quand on appelle une fonction. Si l’on appelle la même fonction mais avec des paramètres de types différents, V8 a besoin de créer une autre classe cachée : Préférez le code monomorphique au code polymorphique.

D’autres exemples sur la manière dont V8 optimise le code JavaScript

Valeurs Taggées

Pour avoir une représentation efficace des nombres et des objets JavaScript, V8 les représente avec une valeur de 32 bit. Il utilise un bit pour savoir si c’est un objet (flag = 1) ou un entier (flag = 0) appelé de SMall Integer ou SMI à cause de ces 31 bits. Donc, si une valeur numérique est plus grande que 31 bits, V8 encapsulera le nombre, le transformant en double ou en créant un nouvel objet pour l’encapsuler.
Optimisation de Code: Utilisez les nombres signés de 31 bit le plus possible, pour éviter l’opération coûteuse d’encapsulation dans un objet JavaScript.

Tableaux

V8 utilise deux méthodes différentes pour traiter les tableaux:

  • Fast elements : Conçu pour des tableaux où l’ensemble des clés sont très compactes. Ils ont un buffer de stockage linéaire pour que l’accès soit très efficace.

  • Dictionary elements: Conçu pour les tableaux où les éléments sont éparpillés. Ils sont en fait des tables d’hachages, qui est plus coûteux d’accès que les “Fast Elements”.

Optimisation de Code: Être certain que V8 utilise la méthode “Fast Element” pour traiter les tableaux, autrement dit, éviter les tableaux avec des éléments éparpillés où les clés ne sont pas ordonnées de façon incrémentale. Éviter aussi les pre-allocs de tableaux de grandes dimensions. Il est préférable qu’ils grandissent au fur et à mesure. Enfin, ne pas effacer d’éléments dans les tableaux : l’ensemble des clés seraient alors dispersées.

Comment V8 compile le code JavaScript ?

V8 a deux compilateurs !

  • Un compilateur “Full” qui peut générer du bon code pour n’importe quel JavaScript : un bon code mais, ce n’est pas du code JIT génial. Le but de ce compilateur est de générer du code rapidement. Pour atteindre cet objectif, le compilateur ne fait aucune analyse de type et ne sait rien à propos des types. Au lieu de ça, le compilateur utilise une stratégie de cache inline ou “IC” pour raffiner la connaissance sur les types en même temps que le programme est exécuté. IC est très efficace et augmente la rapidité (d’un ordre x20).

  • Un compilateur de optimisation qui lui produit du code génial pour la majorité du langage JavaScript. Ce compilateur arrive plus tard et re-compile les fonctions qui sont utilisées plusieurs fois (hot functions). Ce compilateur prend les types du cache inline et décide comment mieux optimiser le code. Cependant, quelques parties du langage ne sont pas encore supportées comme, par exemple, les blocs try/catch. (L’astuce pour les blocs try/catch est d’écrire le code “non stable” dans une fonction et l’appeler dans le bloc try).

Optimisation de Code: V8 supporte aussi la de-optimisation: le compilateur d’optimisation fait des hypothèses optimistes à partir du cache inline sur les différents types, la de-optimisation arrive si ces hypothèses sont invalides. Par exemple, si une classe cachée a été créée et qu’elle n’était pas prévue, V8 jette le code optimisé et retourne au compilateur “Full” pour récupérer les types du cache inline. Ce processus est lent et doit être évité, ce qui est possible en évitant de changer les fonctions après qu’elles aient été optimisées.

Ressources

  • Google I/O 2012 “Breaking the JavaScript Speed Limit with V8” with Daniel Clifford, tech lead and manager of the V8 team: video and slides.

  • V8: an open source JavaScript engine: video of Lars Bak, V8 core engineer.

  • Nikkei Electronics Asia blog post: Why Is the New Google V8 Engine So Fast ?

Ressources supplémentaires : monomorphique vs polymorphique
Source : How the V8 engine works?, Thibault Laurens
Traduction réalisée par Yoan Ribeiro