Suralimenter V8 avec des nombres sur le tas mutables
Chez V8, nous nous efforçons constamment d'améliorer les performances de JavaScript. Dans le cadre de cet effort, nous avons récemment revisité la suite de tests JetStream2 pour éliminer les goulets d'étranglement de performance. Cet article détaille une optimisation spécifique que nous avons réalisée et qui a permis une amélioration significative de 2.5x
dans le test async-fs
, contribuant ainsi à une augmentation notable du score global. L'optimisation a été inspirée par le test, mais de tels motifs apparaissent également dans le code du monde réel.
Le test async-fs
, comme son nom l'indique, est une implémentation de système de fichiers en JavaScript, axée sur les opérations asynchrones. Cependant, un goulot d'étranglement de performance surprenant existe : l'implémentation de Math.random
. Elle utilise une implémentation personnalisée et déterministe de Math.random
pour des résultats cohérents entre les exécutions. L'implémentation est la suivante :
let seed;
Math.random = (function() {
return function () {
seed = ((seed + 0x7ed55d16) + (seed << 12)) & 0xffffffff;
seed = ((seed ^ 0xc761c23c) ^ (seed >>> 19)) & 0xffffffff;
seed = ((seed + 0x165667b1) + (seed << 5)) & 0xffffffff;
seed = ((seed + 0xd3a2646c) ^ (seed << 9)) & 0xffffffff;
seed = ((seed + 0xfd7046c5) + (seed << 3)) & 0xffffffff;
seed = ((seed ^ 0xb55a4f09) ^ (seed >>> 16)) & 0xffffffff;
return (seed & 0xfffffff) / 0x10000000;
};
})();
La variable clé ici est seed
. Elle est mise à jour à chaque appel de Math.random
, générant ainsi la séquence pseudo-aléatoire. De manière cruciale, ici, seed
est stockée dans un ScriptContext
.
Un ScriptContext
sert de lieu de stockage pour les valeurs accessibles dans un script particulier. En interne, ce contexte est représenté comme un tableau de valeurs marquées de V8. Dans la configuration par défaut de V8 pour les systèmes 64 bits, chacune de ces valeurs marquées occupe 32 bits. Le bit le moins significatif de chaque valeur agit comme un tag. Un 0
indique un petit entier sur 31 bits (SMI
). La valeur entière réelle est stockée directement, décalée d'un bit vers la gauche. Un 1
indique un pointeur compressé vers un objet sur le tas, où la valeur du pointeur compressé est incrémentée de un.
Cette étiquetage différencie la façon dont les nombres sont stockés. Les SMI
résident directement dans le ScriptContext
. Les nombres plus grands ou ceux avec des parties décimales sont stockés indirectement sous forme d'objets immuables HeapNumber
sur le tas (un double 64-bits), avec le ScriptContext
contenant un pointeur compressé vers eux. Cette approche gère efficacement divers types numériques tout en optimisant pour le cas courant des SMI
.
Le goulot d'étranglement
La tentative d'analyse avec Math.random
a révélé deux principaux problèmes de performance :
-
Allocation de
HeapNumber
: L'emplacement dédié à la variableseed
dans le script context pointe vers unHeapNumber
standard immuable. Chaque fois que la fonctionMath.random
met à jourseed
, un nouvel objetHeapNumber
doit être alloué sur le tas, ce qui entraîne une pression importante sur l'allocation et la collecte des déchets. -
Arithmétique en virgule flottante : Bien que les calculs dans
Math.random
soient fondamentalement des opérations sur les entiers (utilisant des décalages bit à bit et des additions), le compilateur ne peut pas tirer pleinement parti de cela. Commeseed
est stockée sous forme deHeapNumber
générique, le code généré utilise des instructions en virgule flottante plus lentes. Le compilateur ne peut pas prouver queseed
contiendra toujours une valeur représentable sous forme d'entier. Bien que le compilateur puisse potentiellement spéculer sur des intervalles d'entiers 32 bits, V8 se concentre principalement sur lesSMI
. Même avec une spéculation d'entiers 32 bits, une conversion coûteuse potentielle de flottant 64 bits vers entier 32 bits, ainsi qu'une vérification sans perte, seraient toujours nécessaires.
La solution
Pour résoudre ces problèmes, nous avons mis en œuvre une optimisation en deux parties :
-
Suivi des types d'emplacements / emplacements mutables pour
HeapNumber
: Nous avons étendu le suivi des valeurs constantes du contexte de script (variableslet
initialisées mais jamais modifiées) pour inclure des informations sur le type. Nous suivons si la valeur de cet emplacement est constante, unSMI
, unHeapNumber
ou une valeur étiquetée générique. Nous avons également introduit le concept d'emplacements mutables pourHeapNumber
dans les contextes de script, similaire aux champs mutables pourHeapNumber
pour lesJSObjects
. Au lieu de pointer vers unHeapNumber
immuable, l'emplacement de contexte de script possède leHeapNumber
, et son adresse ne doit pas fuiter. Cela élimine la nécessité d'allouer un nouveauHeapNumber
à chaque mise à jour pour le code optimisé. LeHeapNumber
possédé est lui-même modifié directement. -
Int32
mutable dans le tas (Heap
): Nous améliorons les types d'emplacements de contexte de script pour suivre si une valeur numérique se situe dans la plageInt32
. Si c'est le cas, leHeapNumber
mutable stocke la valeur en tant queInt32
brut. Si nécessaire, la transition vers undouble
offre l'avantage de ne pas nécessiter une réallocation deHeapNumber
. Dans le cas deMath.random
, le compilateur peut désormais observer queseed
est constamment mis à jour avec des opérations sur des entiers et marquer l'emplacement comme contenant unInt32
mutable.
Il est important de noter que ces optimisations introduisent une dépendance du code au type de la valeur stockée dans l'emplacement de contexte. Le code optimisé généré par le compilateur JIT repose sur le fait que l'emplacement contient un type spécifique (ici, un Int32
). Si un code écrit une valeur dans l'emplacement seed
qui change son type (par exemple, écrire un nombre à virgule flottante ou une chaîne), le code optimisé devra être désoptimisé. Cette désoptimisation est nécessaire pour garantir la correction. Par conséquent, la stabilité du type stocké dans l'emplacement est cruciale pour maintenir des performances optimales. Dans le cas de Math.random
, le masquage de bits dans l'algorithme garantit que la variable seed
contient toujours une valeur Int32
.
Les résultats
Ces changements accélèrent considérablement la fonction particulière Math.random
:
-
Pas d'allocation / mises à jour rapides sur place : La valeur de
seed
est mise à jour directement dans son emplacement mutable du contexte de script. Aucun nouvel objet n'est alloué pendant l'exécution deMath.random
. -
Opérations sur des entiers : Le compilateur, sachant que l'emplacement contient un
Int32
, peut générer des instructions sur les entiers hautement optimisées (décalages, additions, etc.). Cela évite les surcoûts des calculs en virgule flottante.
L'effet combiné de ces optimisations offre une amélioration remarquable d'environ ~2,5x
sur le benchmark async-fs
. Cela contribue à une amélioration d'environ ~1,6 %
du score global de JetStream2. Cela démontre que du code apparemment simple peut créer des goulots d'étranglement inattendus en termes de performances, et que des optimisations petites et ciblées peuvent avoir un impact important, pas seulement pour le benchmark.