Une histoire sur V8, React et une baisse des performances. partie 1

L'article, dont nous publions aujourd'hui la première partie de la traduction, expliquera comment le moteur JavaScript V8 sélectionne les meilleures façons de représenter diverses valeurs JS en mémoire, et comment cela affecte les mécanismes internes de V8 concernant l'utilisation de formulaires dits objets (forme). Tout cela nous aidera à trier l'essence du récent problème de performances de React .



Types de données JavaScript


Chaque valeur JavaScript ne peut avoir qu'un seul des huit types de données existants: Number , String , Symbol , BigInt , Boolean , Undefined , Null et Object .


Types de données JavaScript

Le type de valeur peut être déterminé à l'aide de l'opérateur typeof , mais il existe une exception importante:

 typeof 42; // 'number' typeof 'foo'; // 'string' typeof Symbol('bar'); // 'symbol' typeof 42n; // 'bigint' typeof true; // 'boolean' typeof undefined; // 'undefined' typeof null; // 'object' -   ,     typeof { x: 42 }; // 'object' 

Comme vous pouvez le voir, la commande typeof null renvoie 'object' , pas 'null' , malgré le fait que null ait son propre type - Null . Afin de comprendre la raison de ce typeof comportement, nous prenons en compte le fait que l'ensemble de tous les types JavaScript peut être divisé en deux groupes:

  • Objets (c'est-à-dire, tapez Object ).
  • Valeurs primitives (c'est-à-dire toutes les valeurs non objectives).

À la lumière de ces connaissances, il s'avère que null signifie «aucune valeur d'objet», tandis undefined signifie «aucune valeur».


Valeurs primitives, objets, nuls et non définis

Suite à ces réflexions dans l'esprit de Java, Brendan Eich a conçu JavaScript pour que l'opérateur typeof renvoie 'object' pour les valeurs de ces types qui se trouvent dans la figure précédente à droite. Toutes les valeurs d'objet et null arrivent ici. C'est pourquoi l'expression typeof null === 'object' est vraie, bien qu'il existe un type distinct Null dans la spécification du langage.


L'expression typeof v === 'object' est vraie

Représentation des valeurs


Les moteurs JavaScript doivent pouvoir représenter toutes les valeurs JavaScript en mémoire. Cependant, il est important de noter que les types de valeurs en JavaScript sont distincts de la façon dont les moteurs JS les représentent en mémoire.

Par exemple, une valeur de 42 en JavaScript est de type number .

 typeof 42; // 'number' 

Il existe plusieurs façons de représenter des entiers comme 42 en mémoire:
Soumission
Bits
8 bits, en plus de deux
0010 1010
32 bits, avec jusqu'à deux ajouts
0000 0000 0000 0000 0000 0000 0010 1010
Décimal codé binaire (BCD)
0100 0010
32 bits, nombre à virgule flottante IEEE-754
0100 0010 0010 1000 0000 0000 0000 0000
64 bits, nombre à virgule flottante IEEE-754
0100 0000 0100 0101 0000 0000 0000 0000 0000 0000 0000 0000 0000 0000 0000 0000

Selon la norme ECMAScript, les nombres sont des valeurs à virgule flottante 64 bits, appelées nombres à virgule flottante double précision (Float64). Cependant, cela ne signifie pas que les moteurs JavaScript stockent toujours des nombres dans une vue Float64. Ce serait très, très inefficace! Les moteurs peuvent utiliser d'autres représentations internes des nombres - tant que le comportement des valeurs correspond exactement au comportement des nombres Float64.

Il s'est avéré que la plupart des nombres dans les applications JS réelles sont des index de tableau ECMAScript valides. C'est-à-dire des entiers compris entre 0 et 2 32 -2.

 array[0]; //      . array[42]; array[2**32-2]; //      . 

Les moteurs JavaScript peuvent choisir le format optimal pour représenter ces valeurs en mémoire. Ceci est fait afin d'optimiser le code qui fonctionne avec les éléments du tableau à l'aide d'index. Un processeur qui effectue des opérations d'accès à la mémoire a besoin que les indices de la matrice soient disponibles sous forme de nombres stockés dans une vue avec une addition de deux . Si, à la place, nous représentons les index des tableaux sous la forme de valeurs Float64, cela signifierait un gaspillage de ressources système, car le moteur aurait alors besoin de convertir les nombres Float64 dans un format avec addition de deux et vice versa chaque fois que quelqu'un accède à un élément de tableau.

La représentation des nombres de 32 bits avec l'ajout de jusqu'à deux est utile non seulement pour optimiser le travail avec les tableaux. En général, on peut noter que le processeur effectue des opérations entières beaucoup plus rapidement que les opérations qui utilisent des valeurs à virgule flottante. C'est pourquoi dans l'exemple suivant, le premier cycle sans problème est deux fois plus rapide que le deuxième cycle.

 for (let i = 0; i < 1000; ++i) {  //  } for (let i = 0.1; i < 1000.1; ++i) {  //  } 

Il en va de même pour les calculs utilisant des opérateurs mathématiques.

Par exemple, la performance de l'opérateur de prendre le reste de la division du fragment de code suivant dépend des nombres impliqués dans les calculs.

 const remainder = value % divisor; //  -  `value`  `divisor`   , //    . 

Si les deux opérandes sont représentés par des entiers, le processeur peut calculer le résultat très efficacement. Il existe une optimisation supplémentaire dans V8 pour les cas où l'opérande divisor est représenté par un nombre qui est une puissance de deux. Pour les valeurs représentées par des nombres à virgule flottante, les calculs sont beaucoup plus compliqués et prennent beaucoup plus de temps.

Étant donné que les opérations entières sont généralement effectuées beaucoup plus rapidement que les opérations sur des valeurs à virgule flottante, il peut sembler que les moteurs peuvent simplement toujours stocker tous les entiers et tous les résultats des opérations entières dans un format avec une addition de deux. Malheureusement, une telle approche violerait la spécification ECMAScript. Comme déjà mentionné, la norme prévoit la représentation des nombres au format Float64, et certaines opérations avec des entiers peuvent conduire à l'apparition de résultats sous forme de nombres à virgule flottante. Il est important que dans de telles situations, les moteurs JS produisent des résultats corrects.

 //  Float64   53-  . //         . 2**53 === 2**53+1; // true // Float64   ,   -1 * 0   -0,  //           . -1*0 === -0; // true // Float64   Infinity,   , //     . 1/0 === Infinity; // true -1/0 === -Infinity; // true // Float64    NaN. 0/0 === NaN; 

Même si dans l'exemple précédent, tous les nombres sur le côté gauche des expressions sont des entiers, tous les nombres sur le côté droit des expressions sont des valeurs à virgule flottante. C'est pourquoi aucune des opérations précédentes ne peut être effectuée correctement en utilisant un format 32 bits avec un maximum de deux. Les moteurs JavaScript doivent accorder une attention particulière pour garantir que lorsque vous effectuez des opérations entières, vous obtenez les résultats Float64 corrects (quoique capables de paraître inhabituels - comme dans l'exemple précédent).

Dans le cas de petits entiers qui entrent dans la plage de la représentation à 31 bits des entiers signés, V8 utilise une représentation spéciale appelée Smi . Tout ce qui n'est pas une valeur Smi est représenté comme une valeur HeapObject , qui est l'adresse d'une entité en mémoire. Pour les nombres qui ne tombent pas dans la plage Smi , nous avons un type spécial de HeapObject - le soi-disant HeapNumber .

 -Infinity // HeapNumber -(2**30)-1 // HeapNumber  -(2**30) // Smi       -42 // Smi        -0 // HeapNumber         0 // Smi       4.2 // HeapNumber        42 // Smi   2**30-1 // Smi     2**30 // HeapNumber  Infinity // HeapNumber       NaN // HeapNumber 

Comme vous pouvez le voir dans l'exemple précédent, certains numéros JS sont représentés comme Smi , et certains comme HeapNumber . Le moteur V8 est optimisé en termes de traitement des nombres Smi . Le fait est que les petits entiers sont très courants dans les vrais programmes JS. Lorsque vous travaillez avec des valeurs Smi , il n'est pas nécessaire d'allouer de la mémoire aux entités individuelles. De plus, leur utilisation vous permet d'effectuer des opérations rapides avec des nombres entiers.

Comparaison de Smi, HeapNumber et MutableHeapNumber


Parlons de ce à quoi ressemble la structure interne de ces mécanismes. Supposons que nous ayons l'objet suivant:

 const o = {  x: 42, // Smi  y: 4.2, // HeapNumber }; 

La valeur 42 de la propriété de l'objet x est codée en Smi . Cela signifie qu'il peut être stocké à l'intérieur de l'objet lui-même. Pour stocker la valeur 4.2, en revanche, vous devrez créer une entité distincte. Dans l'objet, il y aura un lien vers cette entité.


Stockage de diverses valeurs

Supposons que nous exécutons le morceau de code JavaScript suivant:

 ox += 10; // ox   52 oy += 1; // oy   5.2 

Dans ce cas, la valeur de la propriété x peut être mise à jour à son emplacement de stockage. Le fait est que la nouvelle valeur de x est 52, et ce nombre se situe dans la plage de Smi .


La nouvelle valeur de la propriété x est stockée là où la valeur précédente a été stockée.

Cependant, la nouvelle valeur de y , 5,2, ne rentre pas dans la gamme de Smi , et elle diffère en outre de la valeur précédente de y - 4,2. Par conséquent, V8 doit allouer de la mémoire à la nouvelle entité HeapNumber et la référencer à partir de l'objet déjà.


Nouvelle entité HeapNumber pour stocker la nouvelle valeur y

HeapNumber entités HeapNumber sont immuables. Cela vous permet d'implémenter certaines optimisations. Supposons que nous voulons définir la propriété de l'objet x valeur de la propriété y :

 ox = oy; // ox   5.2 

Lorsque vous effectuez cette opération, nous pouvons simplement faire référence à la même entité HeapNumber et ne pas allouer de mémoire supplémentaire pour stocker la même valeur.

L'un des inconvénients de l'immunité des entités HeapNuber est que la mise à jour fréquente des champs avec des valeurs en dehors de la plage Smi est lente. Cela est démontré dans l'exemple suivant:

 //   `HeapNumber`. const o = { x: 0.1 }; for (let i = 0; i < 5; ++i) {  //    `HeapNumber`.  ox += 1; } 

Lors du traitement de la première ligne, une instance de HeapNumber est créée, dont la valeur initiale est 0,1. Dans le corps du cycle, cette valeur passe à 1.1, 2.1, 3.1, 4.1 et enfin à 5.1. Par conséquent, au cours de l'exécution de ce code, 6 instances de HeapNumber , dont cinq seront soumises à des opérations de récupération de place après la fin de la boucle.


Entités HeapNumber

Afin d'éviter ce problème, V8 dispose d'une optimisation, qui est un mécanisme de mise à jour des champs numériques dont les valeurs ne correspondent pas à la plage Smi aux mêmes endroits où ils sont déjà stockés. Si un champ numérique stocke des valeurs pour lesquelles l'entité Smi ne convient pas au stockage, alors V8, sous la forme d'un objet, marque ce champ comme Double et alloue de la mémoire à l'entité MutableHeapNumber , qui stocke la valeur réelle représentée au format Float64.


Utilisation des entités MutableHeapNumber

Par conséquent, après la modification de la valeur du champ, V8 n'a plus besoin d'allouer de mémoire à la nouvelle entité HeapNumber . Au lieu de cela, écrivez simplement la nouvelle valeur dans une entité MutableHeapNumber existante.


Écriture d'une nouvelle valeur dans MutableHeapNumber

Cependant, cette approche a ses inconvénients. À savoir, puisque les valeurs de MutableHeapNumber peuvent changer, il est important de s'assurer que le système fonctionne de telle manière que ces valeurs se comportent comme indiqué dans la spécification du langage.


Inconvénients de MutableHeapNumber

Par exemple, si vous affectez la valeur de ox une autre variable y , vous devez vous assurer que la valeur de y ne change pas avec un changement ultérieur de ox . Ce serait une violation de la spécification JavaScript! Par conséquent, lors de l'accès à ox , le numéro doit être reconditionné à la valeur HeapNumber habituelle avant d'être affecté y .

Dans le cas des nombres à virgule flottante, V8 effectue les opérations d'emballage ci-dessus en utilisant ses mécanismes internes. Mais dans le cas de petits entiers, l'utilisation de MutableHeapNumber serait une perte de temps car Smi est un moyen plus efficace de représenter de tels nombres.

 const object = { x: 1 }; // ""  `x`    object.x += 1; //   `x`   

Afin d'éviter une utilisation inefficace des ressources système, tout ce que nous devons faire pour travailler avec de petits entiers est de marquer les champs correspondants dans les formes d'objets comme Smi . Par conséquent, les valeurs de ces champs, tant qu'elles correspondent à la plage Smi , peuvent être mises à jour directement à l'intérieur des objets.


Travailler avec des entiers dont les valeurs se situent dans la plage de Smi

À suivre ...

Chers lecteurs! Avez-vous rencontré des problèmes de performances JavaScript causés par les fonctionnalités du moteur JS?

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


All Articles