Dans les entrailles de JavaScript - Partie 1

le 07/11/2017 par Franck Romano
Tags: Software Engineering

Au fil des discussions avec des collègues ou des amis, je me suis rendu compte que certaines questions revenaient souvent :

  • "JavaScript, c’est juste interprété ?"
  • "Ah mais c’est comme Java, tu peux faire de l’héritage !"
  • "Je ne comprends pas, comment ce truc peut être asynchrone ?"
  • "Et la gestion de la mémoire dans tout ça ?"

Cette série d’articles a pour vocation de démystifier ces interrogations en essayant d’y répondre de façon simple et concise.

Dans un premier temps, nous aborderons une question essentielle : JavaScript est-il un langage interprété ou compilé ? Nous verrons que la réponse n’est pas binaire.

Compilé ou interprété ? That’s the question

JavaScript est défini par la MDN comme un langage interprété mais aussi comme compilé à la volée (JIT-Compiled).

La confusion est d’autant plus renforcée par le manque d’informations dans la spécification même de JavaScript.

Lorsque du JavaScript est exécuté, le moteur JavaScript du navigateur (V8 sur Chrome, SpiderMonkey sur Firefox) ou du serveur (V8 avec Node.js) doit convertir le code source en un format compréhensible par l’ordinateur.

Deux moyens d’y arriver : l’interprétation ou la compilation du code source.

Interprétation : Le programme est traduit en langage machine lors de son exécution (runtime). Chaque ligne est traitée à la volée sans attendre une phase préalable de traitement.

Par contre, lorsqu’une ligne de code est exécutée N fois (une boucle par exemple), l’interpréteur se doit de refaire N fois la traduction en langage machine.

Compilation : Nécessite une phase préalable de traduction en langage machine afin de pouvoir être exécuté. Tout le code est compilé et optimisé au préalable par le compilateur afin que le programme puisse être exécuté. C’est une contrepartie permettant de pallier le soucis lié aux interpréteurs évoqué précédemment.

Petit historique

Apparu au milieu des années 90, JavaScript était alors peu utilisé, nous retrouvions quelques instructions dans la balise <header> et des appels à des fonctions comme onclick ou alert.

L'interprétation était beaucoup plus simple et fournissait des performances parfaitement adéquates pour les cas d'utilisation de l’époque.

Dans les années 2000, une course à la performance des navigateurs Web fait rage, si bien qu’elle est appelée guerre des navigateurs (Browsers war). C’est une période pendant laquelle les principaux acteurs du Web (Mozilla, Google) essaient d’optimiser leur navigateur au maximum.

Ainsi, Firefox dévoile SpiderMonkey, le tout premier compilateur JIT qui a permis un gain de performance de l’ordre de 20 à 40% comparé à l’ancienne version du moteur JavaScript.

Dans la foulée, Google met à disposition le navigateur Chrome et son moteur d’exécution V8, incluant une phase de compilation. Cette dernière est bien souvent connue comme “Juste à temps” ou JIT.

Les autres navigateurs (Edge, etc.) ont eux aussi fait évoluer leur moteur JavaScript en y ajoutant une phase de compilation. Cette évolution a amélioré la rapidité de JavaScript de fois 10 et a agi tel un levier quant à son utilisation sur des plateformes jusqu’alors inenvisageables comme la programmation orientée serveur (Node.js).

“Juste à temps” et compilation

Un compilateur JIT allie la rapidité d’exécution d’un interpréteur et l’optimisation apportée par un compilateur.

Les moteurs JavaScript incluant un JIT sont bien souvent composés de 4 briques essentielles:

  1. Un moniteur (profiler) qui joue le rôle de chef d’orchestre
  2. Un interpréteur, qui exécute le code source (runtime)
  3. Un compilateur dit "de base" qui réalise quelques optimisations basiques
  4. Un compilateur dit "optimisateur" qui se charge de faire des optimisations plus poussées si nécessaire.

Cela peut se représenter de la façon suivante:

Illustration simplifiée du pipeline de compilation

Le processus (simplifié) d’exécution est le suivant :

  1. Le moniteur fait appel à l’interpréteur pour exécuter le code. Il analyse au fil de l’eau les appels de fonction réalisés.
  2. Si l’interpréteur réalise plusieurs appels à une même fonction, le moniteur va la "taguer" en tant que fonction warm.
  3. Dès qu’une fonction est warm, le moniteur communique avec le baseline compilateur pour lui indiquer que la fonction est souvent utilisée et qu’elle doit être compilée.
  4. Le baseline compilateur fait quelques optimisations en générant une version optimisée (stub) de chaque instruction.
  5. Si une fonction devient de plus en plus warm, elle est "taguée" comme hot: elle doit être encore plus optimisée.
  6. Le moniteur décide alors d’envoyer cette fonction au compilateur-optimiseur qui va produire une version encore plus optimisée que celle générée par le baseline compilateur.
  7. Ce compilateur émet plusieurs hypothèses sur cette fonction comme par exemple, le type des variables. Sachant que JavaScript possède un typage dynamique, la probabilité d’erreur n’est pas nulle. Le compilateur a la possibilité de supprimer son optimisation et de se référer uniquement à la version compilée lors de l’étape 4: c’est une opération de désoptimisation.
  8. L’interpréteur, qui au lieu de devoir re-traduire en langage machine la fonction, se servira directement de sa version compilée ou de sa version optimisée afin d'accélérer le traitement.

L’étape numéro 7 est sujette à des problèmes de performance. En effet la désoptimisation peut ralentir le processus dans le cas où, le compilateur passe son temps à optimiser puis dé-optimiser le code. C’est pour cela que certain moteur JavaScript ont un seuil limite de cycle d’optimisation.

Une fois ce seuil atteint, le compilateur arrêtera de tenter une optimisation et se référera à la version du code source sous sa forme "brute" (celle de l’étape 1) ou à un stub produit lors de l'étape 4.

Niveau performance et sans rentrer dans les détails de chaque moteur JavaScript pour cause de simplicité, on peut voir que ça dépote : V8 est toujours en tête et affiche des performances intéressantes.

Exemple d’optimisation réalisée par le compilateur

Le typage dynamique de JavaScript requiert du compilateur une certaine rigueur et par dessus tout, une capacité à inférer sur les instructions à compiler.

Prenons par exemple cette (simple) fonction:

function computeSum (array) {

Son rôle est simple: calculer la somme des éléments d’un tableau d’entiers donné (avec l'aide la fonction reduce). Içi, nous avons remplacé l'opérateur somme (+) par l'opérateur d'affectation après addition (+=) afin de mieux illustrer nos propos.

Son processus de d'exécution et de compilation ne devrait pas être trop compliquée. Pourtant, il n'en est pas moins trivial.

Une fois que la fonction sera exécutée, celle-ci deviendra warm et sera envoyée au compilateur "basique" (baseline compiler).

Ainsi, chaque ligne de la fonction aura un version compilée associée (stub) et le compilateur se servira de celle-ci dès que possible. Par exemple, la ligne contenant l'instruction += aura un et un seul stub qui effectuera rapidement l'opération arithmétique entre deux entiers.

Dans ce cas, on dit que le fragment de code est "monomorphique": le type est le même pour chaque éléments du tableau.

Or, avec le typage dynamique de JavaScript, rien ne nous assure que tous les éléments d’un tableau donné soient des entiers. En effet, sur un tableau de taille N, il se peut que ce dernier contienne un ou plusieurs éléments de type String à une position donnée.

Dans cas, le fragment de code est dit polymorphe.

L’instruction acc += current nécessite alors que le compilateur puisse faire la différence entre une opération arithmétique et une concaténation.

Le compilateur JIT devrait alors produire une version optimisée (stub) de chaque alternative (entier et string), et ce n'est pas envisageable.

Ce cas de figure est trop complexe car le nombre de stub mis à disposition serait important et représente une charge lourde (overhead) pour le compilateur JIT car il doit se "poser les mêmes questions" à chaque itération de boucle : "Est-ce que la variable array est un tableau ?", "Est-ce que acc est un entier ?" avant de choisir le stub correspondant.

Stubs correspondant à l'opérateur '+='

C'est au compilateur "optimisateur" de gérer ces cas de figure.

Au lieu de compiler instruction par instruction les lignes de la fonction, il la compile en entier et effectue la tâche de type checking en avance de phase, en inférant au préalable sur le type des variables avant de rentrer dans l’exécution de la boucle.

Illustration d'une compilation totale

Il suppose certain faits "vrais" afin d'accélérer le traitement comme par exemple: si l'élément courant du tableau est un entier, le suivant en sera un. Et si par le plus grand des hasard, une de ces suppositions s'avère être fausse, le moniteur (lors de l'exécution) demandera la suppression de cette version sur-optimisée et se référera aux stubs fournis par le compilateur basique ou à la version native de la fonction.

Interprété ou Compilé ?

Maintenant que nous avons une idée générale du processus d'exécution, nous pouvons nous risquer à étiqueter JavaScript comme interprété ou compilé.

Les compilateurs JIT traduisent le code source (en langage machine) à la volée, et ce uniquement dans un but d’amélioration des performances, ce n’est pas une initiative propre à ECMA TC39 ou encore à JavaScript.

JavaScript est donc un langage interprété, qui, au fur et à mesure des usages et évolutions, a dû faire face à des enjeux de performance et a conduit à un mode en quelque sorte "hybride", mêlant interprétation et compilation.

Récemment, Mozilla a proposé une évolution de son moteur d’exécution (HolyJIT)  en utilisant le langage Rust, visant à améliorer les performances et la sécurité du processus de compilation.

Fin Février 2017, les 4 principaux navigateurs (Chrome, Safari, Edge et Firefox) ont annoncé que le MVP (Miminum Valuable Product) concernant WebAssembly (WASM) était finalisé.

Il s’agit d’un socle, qui associé aux compilateurs JIT, ouvre le champ des possibles concernant l’utilisation de langages autres que JavaScript (C++, Rust) au sein même du navigateur.

Par exemple, un module développé dans un langage tel que C ou Rust peut être embarqué et être utilisé dans une application JavaScript sans que cela impact le navigateur.

Tout cela n’est pas trivial et nécessite que le code soit compilé via un compilateur prévu à cet effet afin que, en sortie, un fichier .wasm soit produit.

Principe de WebAssembly

C’est une technologie intéressante et à suivre car les traitements lourds (calculs intensifs via CPU ou GPU) peuvent très bien être développés dans un langage adapté (C ou C++) et le reste de l’application développé en JavaScript.

Dans le prochain article, nous aborderons le concept d'héritage en JavaScript, maladroitement associé à celui des langages orientés objets et nous verrons qu’ils ne sont pas similaires.

Pour aller plus loin

Voici quelques articles intéressants, ils traitent le sujet plus en profondeur en se focalisant sur V8 :

http://www.jayconrod.com/posts/54/a-tour-of-v8-crankshaft-the-optimizing-compiler

https://v8project.blogspot.fr/2016/08/firing-up-ignition-interpreter.html

http://www.jayconrod.com/posts/51/a-tour-of-v8-full-compiler

https://ponyfoo.com/articles/an-introduction-to-speculative-optimization-in-v8

https://v8project.blogspot.fr/2017/11/csa.html