Turbocharging V8 mit veränderlichen Heap-Zahlen
Bei V8 streben wir ständig danach, die Leistung von JavaScript zu verbessern. Im Rahmen dieser Bemühungen haben wir kürzlich die JetStream2-Benchmark-Suite geprüft, um Leistungseinbrüche zu beseitigen. Dieser Beitrag beschreibt eine spezifische Optimierung, die eine signifikante Verbesserung von 2.5x
im async-fs
-Benchmark ergab und zu einem spürbaren Anstieg der Gesamtpunktzahl beitrug. Die Optimierung wurde durch den Benchmark inspiriert, aber solche Muster finden sich auch im echten Code.
Der async-fs
-Benchmark ist, wie der Name vermuten lässt, eine JavaScript-Dateisystemimplementierung, die sich auf asynchrone Operationen konzentriert. Jedoch gibt es eine überraschende Leistungsengstelle: die Implementierung von Math.random
. Es verwendet eine eigene, deterministische Implementierung von Math.random
für konsistente Ergebnisse über mehrere Durchläufe hinweg. Die Implementierung lautet:
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;
};
})();
Die entscheidende Variable hier ist seed
. Sie wird bei jedem Aufruf von Math.random
aktualisiert und erzeugt die pseudozufällige Sequenz. Wesentlich ist, dass seed
in einem ScriptContext
gespeichert wird.
Ein ScriptContext
dient als Speicherort für Werte, die innerhalb eines bestimmten Skripts zugänglich sind. Intern wird dieses Kontext als ein Array von V8's markierten Werten dargestellt. Für die Standardkonfiguration von V8 auf 64-Bit-Systemen belegt jeder dieser markierten Werte 32 Bit. Das am wenigsten signifikante Bit jedes Wertes dient als Markierung. Ein 0
zeigt ein 31-Bit Small Integer (SMI
) an. Der tatsächliche Integer-Wert wird direkt gespeichert, um ein Bit nach links verschoben. Ein 1
zeigt auf einen komprimierten Zeiger, der auf ein Heap-Objekt verweist, wobei der komprimierte Zeigerwert um eins erhöht wird.
Diese Markierung unterscheidet, wie Zahlen gespeichert werden. SMIs
befinden sich direkt im ScriptContext
. Größere Zahlen oder solche mit Dezimalbruchteilen werden indirekt als unveränderliche HeapNumber
-Objekte auf dem Heap gespeichert (ein 64-Bit-Double), wobei das ScriptContext
einen komprimierten Zeiger darauf hält. Dieser Ansatz behandelt verschiedene Zahlentypen effizient, während er für den häufigen SMI
-Fall optimiert ist.
Die Engstelle
Das Profiling von Math.random
hat zwei wesentliche Leistungsprobleme offenbart:
-
HeapNumber
-Zuweisung: Der Slot, der der Variablenseed
im Skriptkontext zugewiesen ist, zeigt auf eine Standard-HeapNumber, die unveränderlich ist. Jedes Mal, wenn die FunktionMath.random
seed
aktualisiert, muss ein neuesHeapNumber
-Objekt auf dem Heap zugewiesen werden, was zu erheblichem Druck bei der Zuweisung und Müllabfuhr führt. -
Gleitkommamathematik: Obwohl die Berechnungen innerhalb von
Math.random
grundsätzlich Integer-Operationen sind (unter Verwendung von bitweisen Verschiebungen und Additionen), kann der Compiler dies nicht vollständig ausnutzen. Daseed
als generischeHeapNumber
gespeichert ist, verwendet der generierte Code langsamere Gleitkommaoperationen. Der Compiler kann nicht beweisen, dassseed
immer einen Wert enthält, der als Integer darstellbar ist. Während der Compiler möglicherweise über 32-Bit-Integer-Bereiche spekulieren könnte, liegt der Fokus von V8 hauptsächlich aufSMIs
. Selbst bei 32-Bit-Integer-Spekulation wäre eine möglicherweise kostspielige Konvertierung von 64-Bit-Gleitkomma zu 32-Bit-Integer, zusammen mit einer verlustfreien Überprüfung, dennoch erforderlich.
Die Lösung
Um diese Probleme zu adressieren, haben wir eine zweistufige Optimierung implementiert:
-
Slottyp-Verfolgung / veränderbare Heap-Nummer-Slots: Wir haben Konstantwertverfolgung im Scriptkontext (let-Variablen, die initialisiert, aber nie geändert wurden) erweitert, um Typinformationen einzuschließen. Wir verfolgen, ob der Slotwert eine Konstante, ein
SMI
, einHeapNumber
oder ein allgemein markierter Wert ist. Außerdem haben wir das Konzept von veränderbaren Heap-Nummer-Slots innerhalb von Scriptkontexten eingeführt, ähnlich wie bei veränderbaren Heap-Nummernfeldern fürJSObjects
. Statt auf eine unveränderlicheHeapNumber
zu verweisen, gehört dieHeapNumber
im Slot des Scriptkontextes, und ihre Adresse sollte nicht ausgelaufen werden. Dies beseitigt die Notwendigkeit, bei jeder Aktualisierung für optimierten Code eine neueHeapNumber
zuzuweisen. Die besesseneHeapNumber
wird selbst an Ort und Stelle verändert. -
Veränderbare Heap
Int32
: Wir erweitern die Slottypen des Scriptkontextes, um zu verfolgen, ob ein numerischer Wert in denInt32
-Bereich fällt. Ist dies der Fall, speichert die veränderbareHeapNumber
den Wert als rohesInt32
. Falls notwendig, bietet der Übergang zu einemdouble
den zusätzlichen Vorteil, dass keine Neuzuweisung derHeapNumber
erforderlich ist. Im Fall vonMath.random
kann der Compiler nun beobachten, dassseed
konsequent mit Ganzzahloperationen aktualisiert wird und den Slot als einen veränderbarenInt32
kennzeichnen.
Es ist wichtig zu verstehen, dass diese Optimierungen eine Codeabhängigkeit vom Typ des im Kontextslot gespeicherten Wertes einführen. Der durch den JIT-Compiler generierte optimierte Code verlässt sich darauf, dass der Slot einen spezifischen Typ enthält (hier ein Int32
). Falls irgendein Code einen Wert in den seed
-Slot schreibt, der den Typ verändert (z. B. eine Gleitkommazahl oder einen String), muss der optimierte Code deoptimiert werden. Diese Deoptimierung ist notwendig, um die Korrektheit zu gewährleisten. Daher ist die Stabilität des im Slot gespeicherten Typs entscheidend, um die Spitzenleistung aufrechtzuerhalten. Im Fall von Math.random
stellt das Bitmaskieren im Algorithmus sicher, dass die Seed-Variable immer einen Int32
-Wert enthält.
Die Ergebnisse
Diese Änderungen beschleunigen die eigenartige Math.random
-Funktion erheblich:
-
Keine Zuweisung / schnelle In-Situ-Aktualisierungen: Der
seed
-Wert wird direkt innerhalb seines veränderbaren Slots im Scriptkontext aktualisiert. Während der Ausführung vonMath.random
werden keine neuen Objekte zugewiesen. -
Ganzzahloperationen: Der Compiler, der über das Wissen verfügt, dass der Slot einen
Int32
enthält, kann hochoptimierte Ganzzahlinstruktionen (Verschiebungen, Additionen usw.) generieren. Dadurch wird der Overhead der Gleitkommaarithmetik vermieden.
Die kombinierte Wirkung dieser Optimierungen führt zu einer bemerkenswerten ~2.5x
Beschleunigung im async-fs
Benchmark. Dies trägt wiederum zu einer ~1.6%
Verbesserung des gesamten JetStream2-Scores bei. Dies zeigt, dass scheinbar einfacher Code unerwartete Leistungsengpässe erzeugen kann und dass kleine, gezielte Optimierungen große Auswirkungen nicht nur auf den Benchmark haben können.