URL: https://linuxfr.org/users/enjolras/journaux/javascript-performances-et-firefox Title: JavaScript, performances, et Firefox Authors: Enjolras Date: 2012年08月10日T17:11:35+02:00 License: CC By-SA Tags: javascript, firefox, mozilla et développement_web Score: 83 Le JavaScript prend de plus en plus d'importance dans le monde de l'informatique, et de ce fait, les navigateurs embarquent des interpréteurs qui doivent relever des défis techniques pour lui permettre de prendre encore plus d'importance. Je ne vais pas parler dans ce journal du langage JavaScript lui même, des ses fonctionnalités, ni même discuter de sa qualité. JavaScript existe dans son état actuel, et son importance est telle sur le web qu'il est invraisemblable d'envisager un navigateur entièrement fonctionnel qui ne permet pas de l’exécuter. Je m'intéresse ici aux défis qui se posent quand on tente d'en fournir une implémentation efficace, et plus particulièrement à l'implémentation utilisée dans Firefox. # Bref aperçu de JavaScript [JavaScript](http://fr.wikipedia.org/wiki/JavaScript "Définition Wikipédia") a été développé sous sa forme première en 1995 par [Brendan Eich](http://fr.wikipedia.org/wiki/Brendan Eich "Définition Wikipédia"), pour le navigateur [Netscape Communications](http://fr.wikipedia.org/wiki/Netscape Communications "Définition Wikipédia"). Il est devenu plus tard un standard [Ecma International](http://www.ecma-international.org/) sous le nom d'[ECMAScript](http://fr.wikipedia.org/wiki/ECMAScript "Définition Wikipédia"), dont JavaScript reste une implémentation majeure. Sans tomber dans un tutoriel complet de JavaScript (que je serais de toute manière incapable d'écrire), il y a quelques caractéristiques du langage qui jouent un rôle important dans les problèmes que peut représenter son implémentation. ## Typé dynamiquement Les variables n'ont pas de type associé, qui est connu statiquement. Les types sont associées aux valeurs, ce qui permet d'associer n'importe quel valeur à n'importe quelle variable. ```javascript var myVar; myVar = 2; myVar = "Hello world"; ``` Un type est associé à "2" qui est un nombre à virgule floatante double précision et à "Hello world" qui est une chaine de caractère. ## Fonctions de premier ordre Les fonctions sont traitées comme des valeurs. Elles peuvent être passée en argument, retourner comme résultat, assignée à une variable, etc. ```javascript var add42 = function(x) { return x + 42 ; }; ``` ## Orienté objet Contrairement à des langages comme Java, JavaScript est orienté objet par prototypage. Il n'y a pas de notion de classe, qui donne statiquement la forme d'un objet, et donc pas d'héritage. Un objet est fondamentalement une table de hachage qui fait correspondre une valeur identifiant, ainsi qu'un mot clé, **this**, qui pointe sur l'objet lui même. Chaque valeur peut être une fonction, qui est donc traitée comme une méthode, par le simple fait d'utiliser **this**. Les objets sont la plus part du temps crées par clonage d'un autre objet, c'est ce qu'on appelle le prototypage. Une fonction peut être utilisée comme constructeur pour créer de nouveaux objets, et les initialiser. # SpiderMonkey SpiderMonkey est l’interpréteur JavaScript de Firefox, il fournit toutes les fonctions nécessaire pour exécuter du JavaScript. Il est issu d'une ré-écriture du moteur JavaScript pour Netscape 4, même s'il a beaucoup évolué depuis. Notons qu'il fournit une API C, (qui est en train d'évoluer vers le C++) et qu'il est indépendant de Firefox, ce qui permet de l'embarquer dans d'autres logiciels, ce qui est le cas de Gnome-shell par exemple. ## Interpréteur Jusqu’à Firefox 3.5, le code JavaScript est entièrement interprété. La partie frontend de SpiderMonkey se charge de parser le code et de le vérifier, puis émet du bytecode, qui est une suite d'instructions "simples". La machine virtuelle JavaScript se charge ensuite d’exécuter ce bytecode. Les instructions sont lues, et, grâce à des énormes disjonctions de cas sur toutes les instructions, du code est appelé pour effectuer l'opération associée à l'instruction. Le gros problème de cette technique, c'est sa lenteur. Tant que JavaScript était utilisé pour des scripts mineurs, cela importait peu, mais l'ampleur qu'il a pris nécessite des performances dignes d'un langage conventionnel. ## Typage et efficacité Le typage dynamique simplifie peut être la vie du développeur, mais elle complique grandement celle de l'interpréteur. Prenons le cas des opérations polymorphes. ```javascript var foo; foo = x + y; ``` x et y n'ont pas de type statique. Cette expression peut avoir plusieurs sens : - ajout de deux entiers, - ajout de deux floatants, - concaténation de deux chaines,... L'interpréteur doit distinguer selon les cas pour décider quelle opération exécuter, et pour cela, il a besoin des types. Les valeurs doivent donc embarquer des informations sur leur type, encapsuler dans une structure de donner. Une valeur encapsuler contient donc sa valeur et son type. ``` { type : INT ; val : 12 } ``` Une variable ne peut contenir qu'une valeur encapsuler, pour permettre à l’interpréteur d'appeler les bonnes opérations sur leur valeur, mais pour effectuer l'opération elle même, il faut dé-encapsuler la valeur. Ainsi, pour effectuer une instruction, la machine virtuelle doit : 1. Lire l'instruction en mémoire 2. Lire les opérandes en mémoire 3. Lire leurs types, et choisir l'opération à effectuer 4. Dé-encapsuler les opérandes 5. Effectuer l'opération 6. Ré-encapsuler le résultat 7. Écrire le résultat en mémoire Beaucoup de travail pour pas grand chose... ## Compilation Just-In-Time L'idée de la compilation à la volée (Just-in-time) est de compiler le bytecode vers du code natif avant de l’exécuter. Cette opération permet de gagner du temps, en effet, supposons que le JIT compile le bytecode vers du code machine, avec il ne reste plus qu'a exécuter ce code qui va : 1. lire le type des opérandes 2. Choisir l'opération 3. Dé-encapsuler les opérandes 4. effectuer le calcul 5. Encapsuler le résultat Le reste est effectué par la machine physique, le processeur/le noyau. On gagne du temps sur l'opération, du moment que la compilation est assez rapide. De ce fait, on ne peut pas compiler toutes les opérations possibles à chaque fois : ce serait trop lent, il faut choisir. Et si l'on se trompe ? Il faut procéder prudemment, et mettre des garde-fous dans le code compilé. Si les choix optimistes réalisés s'avèrent erronés, il suffit d'invalider le code produit, et de retourner dans la machine virtuelle effectuer l'opération. ## Ah, si seulement on connaissait les types ! Comment choisir quelle opération compiler ? On aimerait bien n'en compiler qu'une, ou du moins les plus probables, et ne pas perdre du temps à compiler des opérations en trop, ou, pire, des opérations en moins (ce qui forcerait à retourner dans la machine virtuelle, chose qui est atrocement lente). Il y a deux réponse à cette question. La première est entièrement pragmatique, et c'est celle employée par la majorité des navigateurs. Le code JavaScript qui n'est exécute qu'une seule fois peut être lent, c'est triste, mais ce n'est pas dramatique. Par contre, il est utile d’optimiser du code important, qui est exécute plusieurs milliers de fois. Il suffit alors de laisser la machine virtuelle faire son travail les premières fois, et de regarder quels types prennent les variables, et de conserver ces types en mémoire. Si le code est appelé trop souvent, on va décider de le compiler, et on compilera en priorité. Seulement, même si cette technique à de grande chance de permettre de rester dans le code compilé, elle ne permet pas de supprimer les garde-fous. Si l'ont savait exactement quel ensemble de type peut prendre une variable locale, on pourrait optimiser au mieux le code ! Brian Hackett a donc écrit une [inférence de types pour JavaScript](http://rfrn.org/~shu/drafts/ti.pdf). En analysant le code, et en appliquant des règles de typages, on peut prouver que certains variables ont un type donné, ou du moins un ensemble de types possible ! Le gros problème de JavaScript, c'est que son typage n'est pas déterministe, c'est à dire qu'on ne peut pas prouver le type de n'importe quelle variable en ne regardant que le code. C'est surement le plus gros défaut et problème de JavaScript en ce qui concerne la performance. Si l'ont connait le type exact des variables locales, alors, le code compilé pour effectuer une opération effectue les étapes suivantes : 1. effectuer l'opération Il semble qu'on puisse faire difficilement mieux :D ! ## Un autre problème, les objets Le principe des objets par prototypage entraine un autre problème : l’accès aux propriétés d'un objet. Pour accéder à une propriété a, il faut chercher dans cet objet, puis potentiellement dans toute la chaine de ses prototypes. Encore quelque chose de lent... Une fois la propriété trouvée, il suffit d'en lire la valeur, ce qui est rapide. SpiderMonkey utilise donc un mécanisme d'inline caching (couramment appelé IC dans les bugs et le code). L'idée est simple : 1. la première fois qu'on accède à une propriété, on utilise la méthode lente 2. Mais on conserve la méthode utilisée pour y accéder 3. Qu'on compile et qu'on garde en cache pour l'avenir La forme des objets est gardée en cache, et ce cache contient des méthodes optimisées pour accéder aux propriétés connues. Cette méthode peut s'avérer plus lente quand la chaine des formes est assez longue et qu'on désire accéder à une propriété du prototype de base. Cela peut par exemple se produire dans les cas des clôtures imbriquées, ou un nouvel objet est généré pour chaque clôture. # Compilateur à la volée optimisant En utilisant les informations de type, on peut alors essayer de compiler du code machine optimisé pour les bouts de code importants. ## JaëgerMonkey JaëgerMonkey est le deuxième compilateur à la volée optimisant, après TraceMonkey, introduit dans Firefox 3.5 et supprimé depuis. Contrairement à TraceMonkey, qui avait une approche particulière, JaëgerMonkey est capable de travailler au niveau d'une fonction. Son but est de compiler le plus rapidement possible du code optimisé selon le type. ## IonMonkey IonMonkey est un nouveau compilateur à la volé, encore en développement dans une branche à part. Son but est de produire du code bien plus optimisé que celui produit par JaëgerMonkey. Pour cela, il est construit selon une architecture classique des compilateurs. L'idée actuelle du pipeline est la suivante : 1. Exécuter le code dans la machine virtuelle de SpiderMonkey 2. S'il est assez important (déterminé via une heuristique), le compiler avec JaegerMonkey 3. S'il est vraiment important (encore une heursistique), le recompiler avec IonMonkey Il ne faut pas oublier que le code compilé peut être invalidé pour diverses raisons. La compilation est en effet optimiste. Elle ne compile que les opérations les plus probables. IonMonkey est capable de travailler sans JaëgerMonkey, mais il est actuellement plus lent sur du code JavaScript courant, parce que l'opération de compilation est plus lourde. Il est donc plus intéressant de n'utiliser IonMonkey que quand il est bénéfique. ### MIR et SSA A partir du bytecode de SpiderMonkey, ainsi que des informations de l'inférences de types et du profilage effectué précédemment par Spidermonkey, la première passe de IonMonkey génère une représentation intermédiaire indépendante de la Machine (MIR). Je pense que ce journal est assez long comme ça pour que j'évite de rentrer encore dans les détails, mais vous pouvez vous référer à [ce wiki](https://wiki.mozilla.org/IonMonkey/MIR) ainsi qu'aux divers articles sur [SSA](http://fr.wikipedia.org/wiki/Static_single_assignment_form "Définition Wikipédia"). Les types sont spécialisés en fonction des informations de types, et on procéde aux optimisations du code. L'intérêt de cette représentation est qu'elle permet d'effectuer les opérations courantes d'optimisation, comme le [Déplacement_des_invariants_de_boucle](http://fr.wikipedia.org/wiki/Déplacement_des_invariants_de_boucle "Définition Wikipédia"). ### LIR et allocation de registre La MIR est traduite en une représentation dépendant de l'architecture, la LIR, et l'allocation de registre est effectuée. IonMonkey implémente un algorithme basé sur [le travail réalisé pour HotSpot](http://ssw.jku.at/Research/Papers/Wimmer04Master/Wimmer04Master.pdf), l'allocation de registre se faisant sur une représentation toujours en SSA. ### Génération de code Enfin, la LIR est transformée en code machine, puis exécutée. Des points de restaurations sont placés dans le code, qui permette de retourner de manière correcte dans la machine virtuelle en cas d'invalidation. ### Compilation en parallèle Des travaux en cours visent à permettre de compiler le code en parallèle de la VM, dans un autre thread, pour éviter que la compilation stoppe l’exécution du reste du code. # Ramasse miette L'autre problème fondamental de JavaScript est la gestion de la mémoire. Cette gestion n'est pas explicite dans le langage, elle doit être gérée par l’interpréteur. Quand un nouvel objet est créé sur le tas, de la mémoire est allouée pour le stoker. Tout le problème consiste à savoir quand libérer cette mémoire. Note : je fais ici quelques simplification, notament, je ne parle pas des Weak references, ni du fait que des objets JavaScript peuvent être liés à des objets C++ dans Firefox, ce qui complique encore les choses. ## Mark-and-sweep L'idée est de trouver les objets en mémoire qui ne sont plus référencées, pour pouvoir les supprimer. La première étape nommée conservative stack scanning, consiste à trouver tout les pointeurs stockés en pile. Pour cela, tout ce qui est en pile est considéré comme un pointeur si l’adresse contient un véritable objet. On remarque que trop d'objets sont considérés comme des pointeurs, mais cela n'est à première vue pas grave, sans compter que le problème de savoir si un élément est un pointeur ou pas est indécidable. A partir de ces pointeurs, appelés racines, on va marqué tout les objets qui sont atteignable. Les objets pointés le sont évidemment, ainsi que ceux vers lesquels ils pointent etc etc. L'algorithme procède récursivement jusqu’à terminer. Après la phase de marquage, on parcourt tout le tas, et on libère les objets n'étant pas marqués comme utilisés. Problème : pendant ces opérations, on ne peut pas exécuter d'autre code que l'algorithme lui même, sous peine d'invalider l'opération ! Des objets peuvent en effet être modifiés. C'est la source de beaucoup des fameux freezes de Firefox. L'UI étant écrite en javascript, elle ne peut plus s’exécuter pendant un GC, et si le cycle de GC est long, l'utilisateur n'est pas content. ## Comment rendre l'utilisateur content ? ### GC incrémental C'est le travail qui est arrivé dernièrement dans Firefox, et qui n'est pas encore entièrement dans la version stable. L'idée est simple : si le GC est trop long, on va le séparer en pleins de petites passes de quelques millisecondes. Ca sera toujours aussi lent, voire plus lent, mais l'utilisateur ne le verra pas. Le GC incrémental sépare donc les phases de marquage et de collection en plusieurs petites phases entre lesquelles s’intercale l’exécution du code du navigateur. La difficulté de cette approche consiste à gérer les modifications du tas qui ont lieu quand le GC est en pause. Il a fallut écrire toute un mécanisme de garde-fous qui surveille les écritures dans le tas et en informe le GC. ### GC générationnel C'est le mécanisme qu'utilise Chrome. Il repose sur un résultat empirique : les nouveaux objets ont plus de chance de disparaitre vite que les objets qui existent depuis longtemps. L'idée est alors de garder la trace des nouveaux objets. Le plus souvent, on n’effectue le GC que sur cet ensemble d'objets jeunes. Quand les objets sont restés suffisamment jeunes, ils passent dans l'ensemble des objets vieux, qui est collecté moins souvent. Ainsi, la plupart du temps, seul une petite partie du tas doit être parcourue. ### L'utilisateur n'est toujours pas content, Firefox consomme trop de mémoire ! D'énormes progrès ont été réalisés sur l'allocation mémoire réalisée par Firefox au cours de l'année passée. Pourtant, il y a un problème difficile à résoudre : la fragmentation mémoire. Ce problème survient quand beaucoup d'objets sont alloués en même temps, mais qu'ils n'ont pas la même durée de vie. Certains seront libérés, et d'autre nom. Cependant, si ces objets sont trop petits, l'espace libéré ne pourra pas être ré-utilisé, parce que les "trous" laissés sont trop petits et non contigus. Dans certains cas, presque 40% de la mémoire JavaScript peut être en réalité vide ! Les développeurs Firefox ont mis en place plusieurs heuristiques dans l'allocation mémoire pour tenter d'éviter au maximum ces cas, mais ça ne suffit pas à régler entièrement le problème. Une manière de le résoudre est d'implémenter un "Moving GC". C'est la dessus que porte une partie du travail actuel. L'idée est de permettre au GC de déplacer des objets dans le tas, en mettant à jour les références qui pointent vers eux. Il peut alors déplacer les objets non libérés et les réarranger de manière compacte, et ainsi optimiser la gestion de la mémoire. La difficulté d'un pareil GC est qu'un scanner de pile conservatif ne suffit plus : il faut connaitre les références à mettre à jour, sous peine de corrompre la pile ! # Conclusion L'implémentation d'un interpréteur JavaScript performant est donc loin d'être un problème trivial. Malgré les efforts des développeurs, la performance de JavaScript dépendant de beaucoup de chose, et la façon d'écrire un code peut influer beaucoup sur ses performances (beaucoup peut être un facteur 100 !). Si vous êtes développeurs JavaScript, il y a des bonnes pratiques à respecter, mais réjouissez vous, de nouveaux outils apparaissent pour vous aidez, comme l'extension [JITinspector](https://addons.mozilla.org/fr/firefox/addon/jit-inspector/) ou le [nouveau profiler](https://benoitgirard.wordpress.com/2012/07/27/javascript-profiling-with-the-gecko-profiler-and-js-anti-pattern/) Quant à moi, je finirai ce trolldi en affirmant que je me réjouis de ne pas avoir à développer en JavaScript !