L'histoire d'une chute de performance V8 dans React
Précédemment, nous avons discuté de la manière dont les moteurs JavaScript optimisent l'accès aux objets et aux tableaux grâce à l'utilisation de Shapes et d'Inline Caches, et nous avons exploré comment les moteurs accélèrent l'accès aux propriétés de prototype en particulier. Cet article décrit comment V8 choisit les représentations en mémoire optimales pour diverses valeurs JavaScript, et comment cela impacte les mécanismes de formes — tout cela aide à expliquer une récente chute de performance V8 dans le cœur de React.
Note : Si vous préférez regarder une présentation plutôt que lire des articles, profitez de la vidéo ci-dessous ! Sinon, ignorez la vidéo et continuez à lire.
Types de JavaScript
Chaque valeur JavaScript possède exactement un des huit types différents (actuellement) : Number
, String
, Symbol
, BigInt
, Boolean
, Undefined
, Null
et Object
.
Avec une exception notable, ces types sont observables en JavaScript grâce à l'opérateur typeof
:
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'
typeof null
renvoie 'object'
, et non 'null'
, malgré que Null
soit un type en soi. Pour comprendre pourquoi, considérez que l'ensemble de tous les types JavaScript est divisé en deux groupes :
- objets (c.-à-d. le type
Object
) - primitifs (c.-à-d. toute valeur qui n'est pas un objet)
Ainsi, null
signifie « pas de valeur d'objet », tandis que undefined
signifie « pas de valeur ».
En suivant cette logique, Brendan Eich a conçu JavaScript pour que typeof
renvoie 'object'
pour toutes les valeurs à droite, c'est-à-dire tous les objets et les valeurs nulles, dans l'esprit de Java. C'est pourquoi typeof null === 'object'
malgré que la spécification ait un type Null
séparé.
Représentation des valeurs
Les moteurs JavaScript doivent pouvoir représenter des valeurs JavaScript arbitraires en mémoire. Cependant, il est important de noter que le type JavaScript d'une valeur est distinct de la manière dont les moteurs JavaScript représentent cette valeur en mémoire.
La valeur 42
, par exemple, a pour type number
en JavaScript.
typeof 42;
// → 'number'
Il existe plusieurs façons de représenter un nombre entier comme 42
en mémoire :
représentation | bits |
---|---|
complément à deux sur 8 bits | 0010 1010 |
complément à deux sur 32 bits | 0000 0000 0000 0000 0000 0000 0010 1010 |
décimal codé binaire économique (BCD) | 0100 0010 |
flottant IEEE-754 sur 32 bits | 0100 0010 0010 1000 0000 0000 0000 0000 |
flottant IEEE-754 sur 64 bits | 0100 0000 0100 0101 0000 0000 0000 0000 0000 0000 0000 0000 0000 0000 0000 0000 |
La norme ECMAScript standardise les nombres comme des valeurs en virgule flottante de 64 bits, également connues sous le nom de flottant en double précision ou Float64. Cependant, cela ne signifie pas que les moteurs JavaScript stockent les nombres en représentation Float64 tout le temps — ce serait terriblement inefficace ! Les moteurs peuvent choisir d'autres représentations internes, tant que le comportement observable correspond exactement à celui de Float64.
La plupart des nombres dans les applications JavaScript du monde réel se trouvent être des indices de tableau ECMAScript valides, c'est-à-dire des valeurs entières comprises dans la plage de 0 à 2³²−2.
array[0]; // Indice de tableau le plus petit possible.
array[42];
array[2**32-2]; // Indice de tableau le plus grand possible.
Les moteurs JavaScript peuvent choisir une représentation en mémoire optimale pour ces nombres afin d'optimiser le code qui accède aux éléments du tableau par leur indice. Pour que le processeur effectue l'opération d'accès mémoire, l'indice de tableau doit être disponible en complément à deux. Représenter les indices de tableau en tant que Float64 serait une perte, car le moteur devrait alors effectuer des conversions aller-retour entre Float64 et le complément à deux à chaque accès à un élément de tableau.
La représentation en complément à deux sur 32 bits ne se limite pas uniquement aux opérations de tableau. En général, les processeurs exécutent les opérations sur les entiers beaucoup plus rapidement que les opérations en virgule flottante. C'est pourquoi dans l'exemple suivant, la première boucle est facilement deux fois plus rapide que la seconde boucle.
for (let i = 0; i < 1000; ++i) {
// rapide 🚀
}
for (let i = 0.1; i < 1000.1; ++i) {
// lent 🐌
}
Cela vaut également pour les opérations. Les performances de l'opérateur modulo dans le morceau de code suivant dépendent du fait que vous travaillez avec des entiers ou non.
const remainder = value % divisor;
// Rapide 🚀 si `value` et `divisor` sont représentés comme des entiers,
// lent 🐌 sinon.
Si les deux opérandes sont représentés comme des entiers, le CPU peut calculer le résultat très efficacement. V8 possède des chemins rapides supplémentaires dans les cas où le divisor
est une puissance de deux. Pour les valeurs représentées comme des flottants, le calcul est beaucoup plus complexe et prend beaucoup plus de temps.
Étant donné que les opérations sur les entiers s'exécutent généralement beaucoup plus rapidement que celles en virgule flottante, il semblerait que les moteurs puissent simplement utiliser toujours le complément à deux pour tous les entiers et tous les résultats des opérations sur les entiers. Malheureusement, cela serait une violation de la spécification ECMAScript ! ECMAScript se standardise sur Float64, et donc certaines opérations sur les entiers produisent en réalité des flottants. Il est important que les moteurs JavaScript produisent les résultats corrects dans ces cas.
// Float64 a une plage d'entiers sûrs de 53 bits. Au-delà de cette plage,
// vous devez perdre la précision.
2**53 === 2**53+1;
// → true
// Float64 prend en charge les zéros négatifs, donc -1 * 0 doit être -0, mais
// il n'y a aucune façon de représenter le zéro négatif dans le complément à deux.
-1*0 === -0;
// → true
// Float64 a des infinis qui peuvent être produits par une division
// par zéro.
1/0 === Infinity;
// → true
-1/0 === -Infinity;
// → true
// Float64 contient également des NaN.
0/0 === NaN;
Même si les valeurs du côté gauche sont des entiers, toutes les valeurs du côté droit sont des flottants. C'est pourquoi aucune des opérations ci-dessus ne peut être réalisée correctement en utilisant le complément à deux sur 32 bits. Les moteurs JavaScript doivent prendre des précautions particulières pour s'assurer que les opérations sur les entiers basculent correctement pour produire les résultats sophistiqués Float64.
Pour les petits entiers dans la plage des entiers signés sur 31 bits, V8 utilise une représentation spéciale appelée Smi
. Tout ce qui n'est pas un Smi
est représenté en tant que HeapObject
, qui est l'adresse d'une entité en mémoire. Pour les nombres, nous utilisons un type spécial de HeapObject
, le soi-disant HeapNumber
, pour représenter les nombres qui ne sont pas dans la plage de Smi
.
-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 le montre l'exemple ci-dessus, certains nombres JavaScript sont représentés sous forme de Smi
, et d'autres sous forme de HeapNumber
. V8 est spécifiquement optimisé pour les Smi
, car les petits entiers sont si courants dans les programmes JavaScript du monde réel. Les Smi
n'ont pas besoin d'être alloués en tant qu'entités dédiées en mémoire et permettent des opérations rapides sur les entiers en général.
Le point important à retenir ici est que même les valeurs ayant le même type JavaScript peuvent être représentées de manière complètement différente en coulisses, en tant qu'optimisation.
Smi
vs. HeapNumber
vs. MutableHeapNumber
Voici comment cela fonctionne sous le capot. Supposons que vous avez l'objet suivant :
const o = {
x: 42, // Smi
y: 4.2, // HeapNumber
};
La valeur 42
pour x
peut être encodée en tant que Smi
, de sorte qu'elle peut être stockée à l'intérieur de l'objet lui-même. La valeur 4.2
, en revanche, nécessite une entité distincte pour contenir la valeur, et l'objet pointe vers cette entité.
Maintenant, supposons que nous exécutons le morceau de code JavaScript suivant :
o.x += 10;
// → o.x est maintenant 52
o.y += 1;
// → o.y est maintenant 5.2
Dans ce cas, la valeur de x
peut être mise à jour directement, puisque la nouvelle valeur 52
entre également dans la plage Smi
.
Cependant, la nouvelle valeur de y=5.2
ne correspond pas à un Smi
et est également différente de la valeur précédente 4.2
, donc V8 doit allouer une nouvelle entité HeapNumber
pour l'affectation à y
.
Les HeapNumber
s ne sont pas modifiables, ce qui permet certaines optimisations. Par exemple, si nous affectons la valeur de y
à x
:
o.x = o.y;
// → o.x est maintenant 5.2
…nous pouvons maintenant simplement lier au même HeapNumber
au lieu d'en allouer un nouveau pour la même valeur.
Un inconvénient des HeapNumber
s immuables est que la mise à jour des champs avec des valeurs en dehors de la plage Smi
serait lente, comme dans l'exemple suivant :
// Créez une instance de `HeapNumber`.
const o = { x: 0.1 };
for (let i = 0; i < 5; ++i) {
// Créez une instance supplémentaire de `HeapNumber`.
o.x += 1;
}
La première ligne créerait une instance de HeapNumber
avec la valeur initiale 0.1
. Le corps de la boucle change cette valeur en 1.1
, 2.1
, 3.1
, 4.1
, et finalement 5.1
, créant un total de six instances de HeapNumber
au passage, dont cinq deviennent des déchets une fois que la boucle est terminée.
Pour éviter ce problème, V8 offre un moyen de mettre à jour sur place les champs de nombres non Smi
, également en tant qu'optimisation. Lorsque un champ numérique contient des valeurs en dehors de la plage Smi
, V8 marque ce champ comme un champ Double
sur la forme et alloue un MutableHeapNumber
qui contient la valeur réelle codée en Float64.
Lorsque la valeur d'un champ change, V8 n'a plus besoin d'allouer un nouveau HeapNumber
, mais peut simplement mettre à jour le MutableHeapNumber
sur place.
Cependant, cette approche présente également un inconvénient. Étant donné que la valeur d'un MutableHeapNumber
peut changer, il est important que ces valeurs ne soient pas diffusées.
Par exemple, si vous affectez o.x
à une autre variable y
, vous ne voudriez pas que la valeur de y
change la prochaine fois que o.x
change — cela constituerait une violation de la spécification JavaScript ! Ainsi, lorsque o.x
est accédé, le nombre doit être re-boxé dans un HeapNumber
ordinaire avant de l’affecter à y
.
Pour les flottants, V8 effectue toutes les magies de “boxing” ci-dessus en arrière-plan. Mais pour les petits entiers, il serait inefficace d'utiliser l'approche MutableHeapNumber
, car Smi
est une représentation plus efficace.
const object = { x: 1 };
// → pas de “boxing” pour `x` dans l'objet
object.x += 1;
// → mise à jour directe de la valeur de `x` dans l'objet
Pour éviter l'inefficacité, tout ce que nous avons à faire pour de petits entiers est de marquer le champ sur la forme comme une représentation Smi
, et simplement mettre à jour la valeur numérique sur place tant qu'elle reste dans la plage des petits entiers.
Dépréciations et migrations de formes
Alors, que se passe-t-il si un champ contient initialement un Smi
, mais contient ensuite un nombre en dehors de la plage des petits entiers ? Comme dans ce cas, avec deux objets partageant la même forme où x
est initialement représenté comme un Smi
:
const a = { x: 1 };
const b = { x: 2 };
// → les objets ont maintenant `x` en tant que champ `Smi`
b.x = 0.2;
// → `b.x` est maintenant représenté comme un `Double`
y = a.x;
Cela commence par deux objets pointant vers la même forme, où x
est marqué comme une représentation Smi
:
Lorsque b.x
passe à une représentation Double
, V8 alloue une nouvelle forme où x
est attribué à la représentation Double
, et qui pointe vers la forme vide. V8 alloue également un MutableHeapNumber
pour conserver la nouvelle valeur 0.2
pour la propriété x
. Ensuite, nous mettons à jour l'objet b
pour qu'il pointe vers cette nouvelle forme et changeons l'emplacement dans l'objet pour pointer vers le MutableHeapNumber
précédemment alloué à l'offset 0. Enfin, nous marquons l'ancienne forme comme dépréciée et la désolidarisons de l'arbre de transition. Cela s'effectue en créant une nouvelle transition pour 'x'
de la forme vide vers la forme nouvellement créée.
Nous ne pouvons pas complètement supprimer l'ancienne forme à ce stade, car elle est encore utilisée par a
, et il serait beaucoup trop coûteux de parcourir la mémoire pour trouver tous les objets pointant vers l'ancienne forme et les mettre à jour immédiatement. À la place, V8 fait cela paresseusement : tout accès ou assignation de propriété à a
le migre d'abord vers la nouvelle forme. L'idée est de rendre progressivement la forme obsolète inaccessible et de laisser le collecteur de déchets l'éliminer.
Un cas plus délicat se produit si le champ qui change de représentation n'est pas le dernier de la chaîne :
const o = {
x: 1,
y: 2,
z: 3,
};
o.y = 0.1;
Dans ce cas, V8 doit trouver la forme dite fractionnée, qui est la dernière forme dans la chaîne avant que la propriété concernée ne soit introduite. Ici, nous modifions y
, donc nous devons trouver la dernière forme qui n'a pas y
, ce qui, dans notre exemple, est la forme qui a introduit x
.
À partir de la forme divisée, nous créons une nouvelle chaîne de transition pour y
qui rejoue toutes les transitions précédentes, mais avec 'y'
marqué comme représentation Double
. Et nous utilisons cette nouvelle chaîne de transition pour y
, marquant l'ancien sous-arbre comme obsolète. Dans la dernière étape, nous migrons l'instance o
vers la nouvelle forme, en utilisant un MutableHeapNumber
pour contenir la valeur de y
maintenant. De cette manière, les nouveaux objets ne suivent pas l'ancien chemin, et une fois que toutes les références à l'ancienne forme ont disparu, la partie obsolète de la forme de l'arbre disparaît.
Transitions d'extensibilité et de niveau d'intégrité
Object.preventExtensions()
empêche de nouveaux attributs d'être ajoutés à un objet. Si vous essayez, cela génère une exception. (Si vous n'êtes pas en mode strict, cela ne génère pas une erreur mais ne fait silencieusement rien.)
const object = { x: 1 };
Object.preventExtensions(object);
object.y = 2;
// TypeError: Impossible d'ajouter la propriété y;
// l'objet n'est pas extensible
Object.seal
fait la même chose que Object.preventExtensions
, mais il marque également tous les attributs comme non configurables, ce qui signifie que vous ne pouvez pas les supprimer, ni changer leur énumérabilité, leur configurabilité ou leur capacité à être modifiés.
const object = { x: 1 };
Object.seal(object);
object.y = 2;
// TypeError: Impossible d'ajouter la propriété y;
// l'objet n'est pas extensible
delete object.x;
// TypeError: Impossible de supprimer la propriété x
Object.freeze
fait la même chose que Object.seal
, mais il empêche également la modification des valeurs des attributs existants en les marquant comme non modifiables.
const object = { x: 1 };
Object.freeze(object);
object.y = 2;
// TypeError: Impossible d'ajouter la propriété y;
// l'objet n'est pas extensible
delete object.x;
// TypeError: Impossible de supprimer la propriété x
object.x = 3;
// TypeError: Impossible d'assigner à une propriété en lecture seule x
Prenons cet exemple concret, avec deux objets qui ont chacun un seul attribut x
, et où nous empêchons ensuite toute extension du deuxième objet.
const a = { x: 1 };
const b = { x: 2 };
Object.preventExtensions(b);
Cela commence comme nous le savons déjà, avec une transition de la forme vide vers une nouvelle forme contenant l'attribut 'x'
(représenté comme Smi
). Lorsque nous empêchons les extensions à b
, nous effectuons une transition spéciale vers une nouvelle forme marquée comme non extensible. Cette transition spéciale n'introduit aucun nouvel attribut — c'est vraiment juste un marqueur.
Notez que nous ne pouvons pas simplement mettre à jour la forme avec x
sur place, car elle est nécessaire pour l'autre objet a
, qui est toujours extensible.
Le problème de performance dans React
Rassemblons tout et utilisons ce que nous avons appris pour comprendre le problème récent dans React #14365. Lorsque l'équipe React a analysé une application réelle, elle a remarqué une chute de performance étrange dans V8 qui affectait le cœur de React. Voici une version simplifiée du bug :
const o = { x: 1, y: 2 };
Object.preventExtensions(o);
o.y = 0.2;
Nous avons un objet avec deux champs ayant la représentation Smi
. Nous empêchons toute extension de l'objet et finalement forçons le deuxième champ à adopter la représentation Double
.
Comme nous l'avons appris auparavant, cela crée approximativement cette configuration :
Les deux attributs sont marqués comme ayant une représentation Smi
, et la transition finale est la transition d'extensibilité pour marquer la forme comme non extensible.
Nous devons maintenant changer y
pour une représentation Double
, ce qui signifie que nous devons de nouveau commencer par trouver la forme divisée. Dans ce cas, c'est la forme qui a introduit x
. Mais V8 s'est alors emmêlé, car la forme divisée était extensible alors que la forme actuelle était marquée comme non extensible. Et V8 ne savait pas vraiment comment rejouer correctement les transitions dans ce cas. V8 a donc essentiellement abandonné les efforts pour rendre cela compréhensible et a créé une forme séparée qui n'est pas connectée à l'arbre des formes existantes et qui n'est pas partagée avec d'autres objets. Pensez à cela comme une forme orpheline:
Vous pouvez imaginer que c'est assez mauvais si cela arrive à beaucoup d'objets, car cela rend tout le système de formes inutilisable.
Dans le cas de React, voici ce qui s'est passé : chaque FiberNode
a quelques champs qui sont censés contenir des horodatages lorsque le profilage est activé.
class FiberNode {
constructor() {
this.actualStartTime = 0;
Object.preventExtensions(this);
}
}
const node1 = new FiberNode();
const node2 = new FiberNode();
Ces champs (comme actualStartTime
) sont initialisés à 0
ou -1
et ont donc au départ la représentation Smi
. Mais plus tard, les horodatages en virgule flottante réels de performance.now()
sont stockés dans ces champs, les poussant à adopter la représentation Double
, car ils ne rentrent pas dans un Smi
. En plus de cela, React empêche également les extensions aux instances de FiberNode
.
Initialement, l'exemple simplifié ci-dessus ressemblait à ceci :
Il existe deux instances partageant un arbre de formes, fonctionnant toutes comme prévu. Mais ensuite, lorsque vous stockez le véritable horodatage, V8 est confus pour trouver la forme divisée :
V8 attribue une nouvelle forme orpheline à node1
, et la même chose se produit pour node2
peu de temps après, ce qui entraîne deux îlots orphelins, chacun avec leurs propres formes disjointes. De nombreuses applications React réelles n'ont pas seulement deux, mais plutôt des dizaines de milliers de ces FiberNode
. Comme vous pouvez l'imaginer, cette situation n'était pas particulièrement idéale pour les performances de V8.
Heureusement, nous avons corrigé cette chute de performance dans V8 v7.4, et nous cherchons à rendre les modifications de représentation des champs moins coûteuses pour supprimer toutes les chutes de performance restantes. Avec la correction, V8 fait désormais les choses correctement:
Les deux instances de FiberNode
pointent vers la forme non extensible où 'actualStartTime'
est un champ Smi
. Lorsque la première affectation à node1.actualStartTime
a lieu, une nouvelle chaîne de transition est créée et l'ancienne chaîne est marquée comme dépréciée:
Notez comment la transition d'extensibilité est désormais correctement rejouée dans la nouvelle chaîne.
Après l'affectation à node2.actualStartTime
, les deux nœuds se réfèrent à la nouvelle forme, et la partie dépréciée de l'arbre de transition peut être nettoyée par le ramasse-miettes.
Remarque : Vous pourriez penser que toute cette dépréciation/migration de formes est complexe, et vous auriez raison. En fait, nous avons une suspicion que sur des sites Web réels, cela cause plus de problèmes (en termes de performances, d'utilisation de mémoire et de complexité) que cela n'en résout, particulièrement car avec la compression des pointeurs, nous ne pourrons plus l'utiliser pour stocker les champs de type double en ligne dans l'objet. Par conséquent, nous espérons supprimer complètement le mécanisme de dépréciation des formes de V8. Vous pourriez dire que c'est *met ses lunettes de soleil* en cours de dépréciation. YEEEAAAHHH… ::
L'équipe React a atténué le problème de son côté en s'assurant que tous les champs de temps et de durée sur les FiberNode
commencent avec une représentation Double
:
class FiberNode {
constructor() {
// Forcer la représentation `Double` dès le départ.
this.actualStartTime = Number.NaN;
// Plus tard, vous pouvez toujours initialiser à la valeur souhaitée :
this.actualStartTime = 0;
Object.preventExtensions(this);
}
}
const node1 = new FiberNode();
const node2 = new FiberNode();
Au lieu de Number.NaN
, toute valeur en virgule flottante qui ne correspond pas à la plage Smi
pourrait être utilisée. Voici quelques exemples : 0.000001
, Number.MIN_VALUE
, -0
, et Infinity
.
Il convient de noter que le bogue React concret était spécifique à V8 et qu'en général, les développeurs ne devraient pas optimiser pour une version spécifique d'un moteur JavaScript. Cependant, il est utile d'avoir une solution lorsque les choses ne fonctionnent pas.
Gardez à l'esprit que le moteur JavaScript fait de la magie sous le capot, et vous pouvez l'aider en évitant de mélanger les types si possible. Par exemple, n'initialisez pas vos champs numériques avec null
, car cela désactive tous les avantages du suivi de représentation des champs et cela rend votre code plus lisible :
// Ne faites pas cela !
class Point {
x = null;
y = null;
}
const p = new Point();
p.x = 0.1;
p.y = 402;
En d'autres termes, écrivez un code lisible, et les performances suivront !
Points à retenir
Nous avons abordé les points suivants dans cette analyse en profondeur :
- JavaScript distingue les “primitives” des “objets”, et
typeof
est un menteur. - Même les valeurs ayant le même type JavaScript peuvent avoir des représentations différentes en coulisses.
- V8 tente de trouver la représentation optimale pour chaque propriété dans vos programmes JavaScript.
- Nous avons discuté de la manière dont V8 gère les dépréciations et les migrations des formes, y compris les transitions d'extensibilité.
Sur la base de ces connaissances, nous avons identifié quelques conseils pratiques de codage en JavaScript qui peuvent aider à améliorer les performances :
- Initialisez toujours vos objets de la même manière, afin que les formes puissent être efficaces.
- Choisissez des valeurs initiales sensées pour vos champs afin d'aider les moteurs JavaScript dans la sélection des représentations.