Optimisation du travail avec des prototypes dans les moteurs JavaScript

Le matériel, dont nous publions la traduction aujourd'hui, a été préparé par Matthias Binens et Benedict Meirer. Ils travaillent sur le moteur V8 JS de Google. Cet article est consacré à certains mécanismes de base qui sont caractéristiques non seulement pour le V8, mais aussi pour d'autres moteurs. La connaissance de la structure interne de ces mécanismes permet aux personnes impliquées dans le développement de JavaScript de mieux naviguer les problèmes de performances du code. En particulier, nous parlerons ici des caractéristiques des pipelines d'optimisation du moteur et de la façon d'accélérer l'accès aux propriétés des prototypes d'objets.



Niveaux d'optimisation de code et compromis


Le processus de conversion des textes des programmes écrits en JavaScript en code approprié pour l'exécution est approximativement le même dans différents moteurs.
Le processus de conversion du code source JS en code exécutable

Les détails peuvent être trouvés ici . En outre, il convient de noter que bien que, à un niveau élevé, les pipelines de conversion du code source en exécutable soient très similaires pour différents moteurs, leurs systèmes d'optimisation de code diffèrent souvent. Pourquoi en est-il ainsi? Pourquoi certains moteurs ont-ils plus de niveaux d'optimisation que d'autres? Il s'avère que les moteurs doivent compromettre d'une manière ou d'une autre, ce qui consiste en ce qu'ils peuvent soit générer rapidement du code qui n'est pas le plus efficace mais adapté à l'exécution, ou passer plus de temps à créer un tel code, mais pour cette raison, atteindre des performances optimales.
Préparation rapide du code pour l'exécution et code optimisé qui prend plus de temps mais s'exécute plus rapidement

L'interpréteur est capable de générer rapidement du bytecode, mais un tel code n'est généralement pas très efficace. Le compilateur d'optimisation, en revanche, a besoin de plus de temps pour générer le code, mais au final, il obtient un code machine optimisé et plus rapide.

C'est ce modèle de préparation du code pour l'exécution qui est utilisé dans la V8. L'interpréteur V8 s'appelle Ignition, c'est le plus rapide des interprètes existants (en termes d'exécution du bytecode source). Le compilateur d'optimisation V8 est appelé TurboFan, qui est responsable de la création de code machine hautement optimisé.
Interpréteur d'allumage et compilateur d'optimisation TurboFan

Le compromis entre le retard dans le démarrage du programme et la vitesse d'exécution est la raison pour laquelle certains moteurs JS ont des niveaux d'optimisation supplémentaires. Par exemple, dans SpiderMonkey, entre l'interpréteur et le compilateur d'optimisation IonMonkey, il existe un niveau intermédiaire représenté par le compilateur de base (il est appelé «The Baseline Compiler» dans la documentation de Mozilla, mais «baseline» n'est pas un nom propre).
Niveaux d'optimisation du code SpiderMonkey

L'interpréteur génère rapidement du bytecode, mais ce code est exécuté relativement lentement. Le compilateur de base prend plus de temps pour générer le code, mais ce code est déjà plus rapide. Enfin, l'optimisation du compilateur IonMonkey prend le plus de temps pour générer du code machine, mais ce code peut être exécuté très efficacement.

Jetons un coup d'œil à un exemple spécifique et examinons comment les pipelines de divers moteurs gèrent le code. Dans l'exemple présenté ici, il y a une boucle «chaude» contenant du code qui se répète tant de fois.

let result = 0; for (let i = 0; i < 4242424242; ++i) {    result += i; } console.log(result); 

V8 commence à exécuter le bytecode dans l'interpréteur Ignition. À un moment donné, le moteur découvre que le code est «chaud» et lance le frontend TurboFan, qui fait partie de TurboFan travaillant avec les données de profilage et créant une représentation de base du code machine. Les données sont ensuite transmises à l'optimiseur TurboFan, fonctionnant dans un flux séparé, pour de nouvelles améliorations.
Optimisation de code à chaud dans V8

Pendant l'optimisation, V8 continue d'exécuter le bytecode dans Ignition. Lorsque l'optimiseur est terminé, nous avons un code machine exécutable qui peut être utilisé à l'avenir.

Le moteur SpiderMonkey commence également à exécuter le bytecode dans l'interpréteur. Mais il a un niveau supplémentaire représenté par le compilateur de base, ce qui conduit au fait que le code "à chaud" arrive d'abord à ce compilateur. Il génère le code de base dans le thread principal, la transition vers l'exécution de ce code se fait lorsqu'il est prêt.
Optimisation de code à chaud dans SpiderMonkey

Si le code de base fonctionne suffisamment longtemps, SpiderMonkey lance finalement le frontend et l'optimiseur IonMonkey, qui est très similaire à ce qui se passe dans la V8. Le code de base continue de s'exécuter dans le cadre du processus d'optimisation de code effectué par IonMonkey. Par conséquent, lorsque l'optimisation est terminée, le code optimisé est exécuté à la place du code de base.

L'architecture du moteur Chakra est très similaire à l'architecture de SpiderMonkey, mais Chakra s'efforce d'obtenir un niveau de concurrence plus élevé afin d'éviter de bloquer le thread principal. Au lieu de résoudre toutes les tâches de compilation dans le thread principal, Chakra copie et envoie le bytecode et les données de profilage dont le compilateur est susceptible d'avoir besoin dans un processus de compilation séparé.
Optimisation de code chaud dans Chakra

Lorsque le code généré préparé par SimpleJIT est prêt, le moteur l'exécute au lieu de bytecode. Ce processus est répété pour procéder à l'exécution du code préparé par FullJIT. L'avantage de cette approche est que les pauses associées à la copie des données sont généralement beaucoup plus courtes que celles provoquées par le fonctionnement d'un compilateur à part entière (frontal). Cependant, le inconvénient de cette approche est le fait que les algorithmes de copie heuristique peuvent manquer certaines informations qui peuvent être utiles pour une sorte d'optimisation. Nous voyons ici un exemple de compromis entre la qualité du code reçu et les retards.

Dans JavaScriptCore, toutes les tâches d'optimisation de la compilation sont effectuées en parallèle avec le thread principal responsable de l'exécution du code JavaScript. Cependant, il n'y a pas d'étape de copie. Au lieu de cela, le thread principal appelle simplement les tâches de compilation dans un autre thread. Le compilateur utilise ensuite un schéma de verrouillage complexe pour accéder aux données de profilage à partir du thread principal.
Optimisation du code "à chaud" dans JavaScriptCore

L'avantage de cette approche est qu'elle réduit le blocage forcé du thread principal provoqué par le fait qu'elle effectue des tâches d'optimisation de code. Les inconvénients de cette architecture sont que sa mise en œuvre nécessite la solution de tâches complexes de traitement de données multi-thread, et qu'au cours du travail, pour effectuer diverses opérations, il faut recourir à des verrous.

Nous venons de discuter des compromis que les moteurs sont obligés de faire, en choisissant entre la génération de code rapide à l'aide d'interprètes et la création de code rapide à l'aide de compilateurs d'optimisation. Cependant, ce sont loin de tous les problèmes auxquels sont confrontés les moteurs. La mémoire est une autre ressource système lors de son utilisation et vous devez recourir à des solutions de compromis. Pour le démontrer, considérez un programme JS simple qui ajoute des nombres.

 function add(x, y) {   return x + y; } add(1, 2); 

Voici le bytecode de la fonction d' add générée par l'interpréteur Ignition dans la V8:

 StackCheck Ldar a1 Add a0, [0] Return 

Vous ne pouvez pas entrer dans le sens de ce bytecode, en fait, son contenu ne nous intéresse pas particulièrement. L'essentiel ici est qu'il n'a que quatre instructions.

Lorsqu'un tel morceau de code est «chaud», TurboFan est utilisé, ce qui génère le code machine hautement optimisé suivant:

 leaq rcx,[rip+0x0] movq rcx,[rcx-0x37] testb [rcx+0xf],0x1 jnz CompileLazyDeoptimizedCode push rbp movq rbp,rsp push rsi push rdi cmpq rsp,[r13+0xe88] jna StackOverflow movq rax,[rbp+0x18] test al,0x1 jnz Deoptimize movq rbx,[rbp+0x10] testb rbx,0x1 jnz Deoptimize movq rdx,rbx shrq rdx, 32 movq rcx,rax shrq rcx, 32 addl rdx,rcx jo Deoptimize shlq rdx, 32 movq rax,rdx movq rsp,rbp pop rbp ret 0x18 

Comme vous pouvez le voir, le volume de code, par rapport à l'exemple ci-dessus de quatre instructions, est très important. Typiquement, le bytecode est beaucoup plus compact que le code machine, et en particulier le code machine optimisé. D'un autre côté, un interpréteur est nécessaire pour exécuter le bytecode, et le code optimisé peut être exécuté directement sur le processeur.
C'est l'une des principales raisons pour lesquelles les moteurs JavaScript n'optimisent pas absolument tout le code. Comme nous l'avons vu précédemment, la création de code machine optimisé prend beaucoup de temps, et de plus, comme nous venons de le découvrir, il faut plus de mémoire pour stocker du code machine optimisé.
Utilisation de la mémoire et niveau d'optimisation

En conséquence, nous pouvons dire que la raison pour laquelle les moteurs JS ont différents niveaux d'optimisation est le problème fondamental de choisir entre la génération de code rapide, par exemple, en utilisant un interpréteur, et la génération de code rapide, qui sont exécutées au moyen du compilateur d'optimisation. Si nous parlons des niveaux d'optimisation de code utilisés dans les moteurs, alors plus il y en a, plus les optimisations sont subtiles auxquelles le code peut être soumis, mais cela est dû à la complexité des moteurs et à la charge supplémentaire sur le système. De plus, il ne faut pas oublier ici que le niveau d'optimisation du code affecte la quantité de mémoire que ce code occupe. C'est pourquoi les moteurs JS essaient d'optimiser uniquement les fonctions "à chaud".

Optimisation de l'accès aux propriétés du prototype d'objet


Les moteurs JavaScript optimisent l'accès aux propriétés des objets grâce à l'utilisation de formes d'objets (Shape) et de caches en ligne (Inline Cache, IC). Les détails à ce sujet peuvent être lus dans ce document, mais pour le dire en bref, nous pouvons dire que le moteur stocke la forme de l'objet séparément des valeurs de l'objet.
Objets de même forme

L'utilisation de formes d'objets permet d'effectuer une optimisation appelée mise en cache en ligne. L'utilisation conjointe de formulaires d'objets et de caches en ligne vous permet d'accélérer les opérations répétées d'accès aux propriétés des objets, effectuées au même endroit dans le code.
Accélérer l'accès à une propriété d'objet

Classes et prototypes


Maintenant que nous savons comment accélérer l'accès aux propriétés des objets en JavaScript, jetez un œil à l'une des récentes innovations JavaScript - les classes. Voici à quoi ressemble la déclaration de classe:

 class Bar {   constructor(x) {       this.x = x;   }   getX() {       return this.x;   } } 

Bien que cela puisse ressembler à l'apparition dans JS d'un concept complètement nouveau, les classes ne sont en fait que du sucre syntaxique pour le système prototype de construction d'objets, qui a toujours été présent dans JavaScript:

 function Bar(x) {   this.x = x; } Bar.prototype.getX = function getX() {   return this.x; }; 

Ici, nous écrivons la fonction dans la propriété getX de l'objet getX . Cette opération fonctionne exactement de la même manière que lors de la création des propriétés de tout autre objet, car les prototypes en JavaScript sont des objets. Dans les langages basés sur l'utilisation de prototypes, tels que JavaScript, les méthodes qui peuvent être partagées par tous les objets d'un certain type sont stockées dans des prototypes, et les champs des objets individuels sont stockés dans leurs instances.

Voyons ce qui se passe, pour ainsi dire, dans les coulisses lorsque nous créons une nouvelle instance de l'objet Bar , en l'affectant au foo constant.

 const foo = new Bar(true); 

Après avoir exécuté un tel code, l'instance de l'objet créé ici aura un formulaire contenant une seule propriété x . Le prototype de l'objet foo est Bar.prototype , qui appartient à la classe Bar .
Objet et son prototype

Bar.prototype a sa propre forme contenant une seule propriété getX dont la valeur est une fonction qui, lorsqu'elle est appelée, renvoie la valeur de this.x Le prototype prototype Bar.prototype est Object.prototype , qui fait partie du langage. Object.prototype est l'élément racine de l'arborescence du prototype, donc son prototype est null .

Voyons maintenant ce qui se passe si vous créez un autre objet de type Bar .
Plusieurs objets du même type

Comme vous pouvez le voir, l'objet foo et l'objet qux , qui sont des instances de la classe Bar , comme nous l'avons déjà dit, utilisent la même forme de l'objet. Les deux utilisent le même prototype - l'objet Bar.prototype .

Accéder aux propriétés du prototype


Alors maintenant, nous savons ce qui se passe lorsque nous déclarons une nouvelle classe et l'instancions. Et qu'en est-il de l'appel à la méthode de l'objet? Considérez l'extrait de code suivant:

 class Bar {   constructor(x) { this.x = x; }   getX() { return this.x; } } const foo = new Bar(true); const x = foo.getX(); //        ^^^^^^^^^^ 

Un appel de méthode peut être compris comme une opération composée de deux étapes:

 const x = foo.getX(); //         : const $getX = foo.getX; const x = $getX.call(foo); 

À la première étape, la méthode est chargée, qui n'est qu'une propriété du prototype (dont la valeur est la fonction). Dans la deuxième étape, une fonction est appelée avec this ensemble. Considérez la première étape du chargement de la méthode getX partir de l'objet foo :
Chargement de la méthode getX à partir de l'objet foo

Le moteur analyse l'objet foo et découvre qu'il n'y a pas de propriété getX sous la forme de l'objet foo . Cela signifie que le moteur doit examiner la chaîne prototype de l'objet afin de trouver cette méthode. Le moteur accède au prototype Bar.prototype et examine la forme de l'objet de ce prototype. Là, il trouve la propriété souhaitée à l'offset 0. Ensuite, la valeur stockée à cet offset dans Bar.prototype , JSFunction getX est détectée - et c'est exactement ce que nous recherchons. Ceci termine la recherche de la méthode.

La flexibilité de JavaScript permet de changer les chaînes de prototypes. Par exemple, comme ceci:

 const foo = new Bar(true); foo.getX(); // true Object.setPrototypeOf(foo, null); foo.getX(); // Uncaught TypeError: foo.getX is not a function 

Dans cet exemple, nous appelons la méthode foo.getX() deux fois, mais chacun de ces appels a une signification et un résultat complètement différents. C'est pourquoi, bien que les prototypes JavaScript ne soient que des objets, l'accélération de l'accès aux propriétés du prototype est encore plus difficile pour les moteurs JS que l'accélération de l'accès à leurs propres propriétés d'objets ordinaires.

Si nous regardons des programmes réels, il s'avère que le chargement des propriétés d'un prototype est une opération très courante. Il est exécuté chaque fois qu'une méthode est appelée.

 class Bar {   constructor(x) { this.x = x; }   getX() { return this.x; } } const foo = new Bar(true); const x = foo.getX(); //        ^^^^^^^^^^ 

Un peu plus tôt, nous avons expliqué comment les moteurs optimisent le chargement des propriétés régulières et personnalisées des objets grâce à l'utilisation de formulaires d'objet et de caches en ligne. Comment optimiser le chargement répété des propriétés du prototype pour des objets de même forme? Ci-dessus, nous avons vu comment les propriétés sont chargées.
Chargement de la méthode getX à partir de l'objet foo

Afin d'accélérer l'accès à la méthode avec des appels répétés, dans notre cas, vous devez connaître les éléments suivants:

  1. La forme de l'objet foo ne contient pas la méthode getX et ne change pas. Cela signifie que l'objet foo n'est pas modifié en y ajoutant des propriétés ou en les supprimant ou en modifiant les attributs des propriétés.
  2. Le prototype foo est toujours le Bar.prototype original. Cela signifie que le prototype foo ne change pas à l'aide de la méthode Object.setPrototypeOf() ou en affectant un nouveau prototype à la propriété spéciale _proto_ .
  3. Le formulaire Bar.prototype contient getX et ne change pas. Autrement dit, Bar.prototype pas modifié en supprimant des propriétés, en les ajoutant ou en modifiant leurs attributs.

Dans le cas général, cela signifie que nous devons effectuer 1 vérification de l'objet lui-même et 2 vérifications pour chaque prototype jusqu'au prototype qui stocke la propriété que nous recherchons. Autrement dit, vous devez effectuer des vérifications 1 + 2N (où N est le nombre de prototypes testés), ce qui dans ce cas ne semble pas si mauvais, car la chaîne de prototypes est assez courte. Cependant, les moteurs doivent souvent fonctionner avec des chaînes de prototypes beaucoup plus longues. Ceci, par exemple, est typique des éléments DOM ordinaires. Voici un exemple:

 const anchor = document.createElement('a'); // HTMLAnchorElement const title = anchor.getAttribute('title'); 

Ici, nous avons HTMLAnchorElement et nous appelons sa méthode getAttribute() . La chaîne de prototypes de cet élément simple représentant un lien HTML comprend 6 prototypes! La plupart des méthodes DOM intéressantes ne sont pas dans leur propre prototype HTMLAnchorElement . Ils sont dans des prototypes situés plus bas dans la chaîne.
Chaîne prototype

La méthode getAttribute() se trouve dans Element.prototype . Cela signifie qu'à chaque fois que la méthode anchor.getAttribute() est anchor.getAttribute() , le moteur est obligé d'effectuer les actions suivantes:

  1. Vérifie l'objet d' anchor lui-même pour getAttribute .
  2. Vérification que le prototype direct de l'objet est HTMLAnchorElement.prototype .
  3. HTMLAnchorElement.prototype que HTMLAnchorElement.prototype n'a pas de méthode getAttribute .
  4. Vérification que le prochain prototype est HTMLElement.prototype .
  5. Découvrir qu'il n'y a pas de méthode nécessaire ici.
  6. Enfin, découvrir que le prochain prototype est Element.prototype .
  7. getAttribute qu'il existe une méthode getAttribute .

Comme vous pouvez le voir, 7 contrôles sont effectués ici. Comme ce code est très courant dans la programmation Web, les moteurs utilisent des optimisations pour réduire le nombre de vérifications nécessaires pour charger les propriétés du prototype.

Si nous revenons à l'un des exemples précédents, nous pouvons nous rappeler que lorsque nous appelons la méthode getX de l'objet getX , nous effectuons 3 vérifications:

 class Bar {   constructor(x) { this.x = x; }   getX() { return this.x; } } const foo = new Bar(true); const $getX = foo.getX; 

Pour chaque objet qui se trouve dans la chaîne de prototype, jusqu'à celui qui contient la propriété souhaitée, nous devons vérifier la forme de l'objet uniquement pour découvrir l'absence de ce que nous recherchons. Ce serait bien si nous pouvions réduire le nombre de vérifications en réduisant la vérification du prototype à la vérification de la présence ou de l'absence de ce que nous recherchons. C'est ce que fait le moteur avec un simple mouvement: au lieu de stocker le lien prototype dans l'instance elle-même, le moteur le stocke sous la forme d'un objet.
Stockage de référence de prototype

Chaque formulaire a un lien vers un prototype. Cela signifie également que chaque fois que le prototype change, le moteur se déplace vers la nouvelle forme de l'objet. Il nous suffit maintenant de vérifier la forme de l'objet pour la présence d'une propriété et de prendre soin de protéger le lien prototype.

Grâce à cette approche, nous pouvons réduire le nombre de contrôles de 1 + 2N à 1 + N, ce qui accélérera l'accès aux propriétés des prototypes. Cependant, de telles opérations nécessitent encore beaucoup de ressources, car il existe une relation linéaire entre leur nombre et la longueur de la chaîne prototype. Les moteurs ont mis en place différents mécanismes visant à garantir que le nombre de contrôles ne dépende pas de la longueur de la chaîne prototype, exprimée en constante. Cela est particulièrement vrai dans les situations où le chargement de la même propriété est effectué plusieurs fois.

Propriété ValidityCell


V8 fait référence aux formes de prototypes spécifiquement pour le but ci-dessus. Chaque prototype a une forme unique qui n'est pas partagée avec d'autres objets (en particulier, avec d'autres prototypes), et chacune des formes d'objet prototype a une propriété ValidityCell qui leur est associée.
Propriété ValidityCell

Cette propriété est déclarée invalide lors de la modification du prototype associé au formulaire ou de tout prototype sus-jacent. Considérez ce mécanisme plus en détail.

Afin d'accélérer les opérations séquentielles de chargement des propriétés à partir des prototypes, V8 utilise un cache en ligne contenant quatre champs: ValidityCell , Prototype , Shape , Offset .
Champs de cache en ligne

Lors de l'échauffement du cache en ligne lors de la première exécution du code, V8 se souvient du décalage auquel la propriété a été trouvée dans le prototype, du prototype dans lequel la propriété a été trouvée (dans cet exemple, Bar.prototype ), de la forme de l'objet ( foo dans ce cas) et, en outre, un lien vers le paramètre ValidityCell actuel du prototype immédiat, un lien vers lequel se présente sous la forme d'un objet (dans ce cas, il s'agit également de Bar.prototype ).

La prochaine fois que vous accéderez au cache en ligne, le moteur devra vérifier la forme de l'objet et de ValidityCell . Si ValidityCell est toujours valide, le moteur peut profiter directement du décalage précédemment enregistré dans le prototype sans effectuer d'opérations de recherche supplémentaires.

Lorsque le prototype change, un nouveau formulaire est créé et la propriété ValidityCell précédente est déclarée non valide. Par conséquent, la prochaine fois que vous tenterez d'accéder au cache en ligne, cela n'apportera aucun avantage, ce qui entraînera de mauvaises performances.
Les conséquences du changement de prototype

Si nous revenons à l'exemple avec l'élément DOM, cela signifie que toute modification, par exemple, dans le prototype d' Object.prototype , conduira non seulement à invalider le cache en ligne pour Object.prototype lui-même, mais aussi pour tous les prototypes situés en dessous dans la chaîne de prototypes y compris EventTarget.prototype , Node.prototype , Element.prototype , etc., jusqu'à HTMLAnchorElement.prototype .
Implications de la modification du prototype Object.prototype

En fait, modifier Object.prototype pendant l'exécution de code signifie nuire gravement aux performances. Ne fais pas ça.

Nous étudions ce qui précède avec un exemple. Supposons que nous ayons la classe Bar et la fonction loadX , qui appelle la méthode des objets créés à partir de la classe Bar . Nous appelons la fonction loadX plusieurs fois, en lui passant des instances de la même classe.

 function loadX(bar) {   return bar.getX(); // IC  'getX'   `Bar`. } loadX(new Bar(true)); loadX(new Bar(false)); // IC  `loadX`    `ValidityCell`  // `Bar.prototype`. Object.prototype.newMethod = y => y; // `ValidityCell`  IC `loadX`   //    `Object.prototype`  . 

Le cache en loadX dans loadX pointe désormais vers ValidityCell pour Bar.prototype . , , Object.prototype — JavaScript, ValidityCell , - , .

Object.prototype — , - , . , :

 Object.prototype.foo = function() { /* … */ }; //    : someObject.foo(); //     . delete Object.prototype.foo; 

Object.prototype , - , . , . - , . , « », , .

, , . . Object.prototype , , - .

, — , JS- - , . . , , . , , , .

Résumé


, JS- , , , -, ValidityCell , . JavaScript, , ( , , , ).

Chers lecteurs! , - , JS, ?

Source: https://habr.com/ru/post/fr422321/


All Articles