Einführung
Daniel Clifford hat auf der Google I/O einen ausgezeichneten Vortrag mit Tipps und Tricks zur Verbesserung der JavaScript-Leistung in V8 gehalten. Daniel hat uns ermutigt, „schneller zu fordern“ – die Leistungsunterschiede zwischen C++ und JavaScript sorgfältig zu analysieren und Code so zu schreiben, dass die Funktionsweise von JavaScript berücksichtigt wird. In diesem Artikel werden die wichtigsten Punkte von Daniels Vortrag zusammengefasst. Außerdem wird er aktualisiert, wenn sich die Empfehlungen zur Leistung ändern.
Der wichtigste Rat
Es ist wichtig, alle Leistungsempfehlungen in den richtigen Kontext zu setzen. Leistungstipps machen süchtig und manchmal kann es ziemlich ablenkend sein, sich zuerst auf detaillierte Tipps zu konzentrieren, anstatt sich mit den eigentlichen Problemen zu befassen. Sie müssen die Leistung Ihrer Webanwendung ganzheitlich betrachten. Bevor Sie sich auf diese Leistungstipps konzentrieren, sollten Sie Ihren Code mit Tools wie PageSpeed analysieren und Ihre Punktzahl verbessern. So vermeiden Sie eine vorzeitige Optimierung.
Der beste grundlegende Rat für eine gute Leistung in Webanwendungen lautet:
- Seien Sie vorbereitet, bevor ein Problem auftritt (oder Sie es bemerken)
- Identifizieren und verstehen Sie dann den Kern des Problems.
- Beheben Sie schließlich die wichtigsten Probleme.
Um diese Schritte auszuführen, ist es wichtig zu verstehen, wie V8 JS optimiert, damit Sie Code unter Berücksichtigung des JS-Laufzeitdesigns schreiben können. Außerdem ist es wichtig, sich über die verfügbaren Tools zu informieren und zu erfahren, wie sie Ihnen helfen können. Daniel geht in seinem Vortrag näher auf die Verwendung der Entwicklertools ein. In diesem Dokument werden nur einige der wichtigsten Punkte des V8-Engine-Designs behandelt.
Kommen wir also zu den V8-Tipps.
Ausgeblendete Kurse
JavaScript hat nur begrenzte Typinformationen zur Kompilierzeit: Typen können zur Laufzeit geändert werden. Es ist also natürlich, dass es teuer ist, zur Kompilierzeit über JS-Typen nachzudenken. Das könnte Sie zu der Frage veranlassen, wie die JavaScript-Leistung jemals auch nur annähernd an die von C++ herankommen könnte. V8 verfügt jedoch über versteckte Typen, die intern für Objekte zur Laufzeit erstellt werden. Objekte mit derselben versteckten Klasse können dann denselben optimierten generierten Code verwenden.
Beispiel:
function Point(x, y) {
this.x = x;
this.y = y;
}
var p1 = new Point(11, 22);
var p2 = new Point(33, 44);
// At this point, p1 and p2 have a shared hidden class
p2.z = 55;
// warning! p1 and p2 now have different hidden classes!```
Bis die Objektinstanz p2 ein zusätzliches Mitglied „.z“ hat, haben p1 und p2 intern dieselbe versteckte Klasse. V8 kann also eine einzelne Version von optimiertem Assembly für JavaScript-Code generieren, der entweder p1 oder p2 manipuliert. Je besser Sie verhindern können, dass die versteckten Klassen voneinander abweichen, desto besser ist die Leistung.
Daher gilt:
- Initialisieren Sie alle Objektmitglieder in Konstruktorfunktionen, damit die Instanzen später nicht den Typ ändern.
- Objektmitglieder immer in derselben Reihenfolge initialisieren
iWork Numbers
V8 verwendet Tagging, um Werte effizient darzustellen, wenn sich Typen ändern können. V8 leitet aus den von Ihnen verwendeten Werten ab, um welchen Zahlentyp es sich handelt. Sobald V8 diese Schlussfolgerung gezogen hat, werden Werte mithilfe von Tagging effizient dargestellt, da sich diese Typen dynamisch ändern können. Das Ändern dieser Typ-Tags ist jedoch manchmal mit Kosten verbunden. Daher ist es am besten, Zahlentypen einheitlich zu verwenden. Im Allgemeinen ist es am besten, wenn möglich vorzeichenbehaftete 31-Bit-Ganzzahlen zu verwenden.
Beispiel:
var i = 42; // this is a 31-bit signed integer
var j = 4.2; // this is a double-precision floating point number```
Daher gilt:
- Bevorzugen Sie numerische Werte, die als vorzeichenbehaftete 31-Bit-Ganzzahlen dargestellt werden können.
Arrays
Für die Verarbeitung großer und dünnbesetzter Arrays gibt es intern zwei Arten von Array-Speichern:
- Fast Elements: linearer Speicher für kompakte Schlüsselsätze
- Wörterbuchelemente: andernfalls Hashtabellen-Speicher
Es ist am besten, wenn der Array-Speicher nicht von einem Typ in einen anderen wechselt.
Daher gilt:
- Verwenden Sie für Arrays zusammenhängende Schlüssel, die bei 0 beginnen.
- Weisen Sie großen Arrays (z. B. > 64.000 Elemente) nicht vorab ihre maximale Größe zu, sondern lassen Sie sie nach Bedarf wachsen.
- Löschen Sie keine Elemente in Arrays, insbesondere in numerischen Arrays.
- Laden Sie keine nicht initialisierten oder gelöschten Elemente:
for (var b = 0; b < 10; b++) {
a[0] |= b; // Oh no!
}
//vs.
a = new Array();
a[0] = 0;
for (var b = 0; b < 10; b++) {
a[0] |= b; // Much better! 2x faster.
}
Außerdem sind Arrays von Doubles schneller – die verborgene Klasse des Arrays verfolgt Elementtypen und Arrays, die nur Doubles enthalten, werden entpackt (was zu einer Änderung der verborgenen Klasse führt).Eine unvorsichtige Bearbeitung von Arrays kann jedoch aufgrund von Boxing und Unboxing zu zusätzlichem Aufwand führen, z.B.
var a = new Array();
a[0] = 77; // Allocates
a[1] = 88;
a[2] = 0.5; // Allocates, converts
a[3] = true; // Allocates, converts```
ist weniger effizient als:
var a = [77, 88, 0.5, true];
Im ersten Beispiel werden die einzelnen Zuweisungen nacheinander ausgeführt. Die Zuweisung von a[2] führt dazu, dass das Array in ein Array von Unboxed-Double-Werten konvertiert wird. Die Zuweisung von a[3] führt dann dazu, dass es wieder in ein Array konvertiert wird, das beliebige Werte (Zahlen oder Objekte) enthalten kann. Im zweiten Fall kennt der Compiler die Typen aller Elemente im Literal und die versteckte Klasse kann im Voraus bestimmt werden.
- Mit Array-Literalen für kleine Arrays mit fester Größe initialisieren
- Kleine Arrays (< 64 KB) vor der Verwendung auf die richtige Größe vorab zuweisen
- Keine nicht numerischen Werte (Objekte) in numerischen Arrays speichern
- Achten Sie darauf, dass kleine Arrays nicht neu konvertiert werden, wenn Sie sie ohne Literale initialisieren.
JavaScript-Kompilierung
Obwohl JavaScript eine sehr dynamische Sprache ist und die ursprünglichen Implementierungen Interpreter waren, verwenden moderne JavaScript-Laufzeit-Engines die Kompilierung. V8 (die JavaScript-Engine von Chrome) hat zwei verschiedene JIT-Compiler (Just-In-Time):
- Der „Full“-Compiler, der guten Code für jedes JavaScript generieren kann
- Der optimierende Compiler, der für die meisten JavaScript-Codes hervorragenden Code erzeugt, aber länger zum Kompilieren braucht.
Der vollständige Compiler
In V8 wird der vollständige Compiler für den gesamten Code ausgeführt und beginnt so schnell wie möglich mit der Ausführung des Codes. Dabei wird schnell guter, aber nicht hervorragender Code generiert. Dieser Compiler geht bei der Kompilierung von fast keinen Annahmen über Typen aus. Er erwartet, dass sich die Typen von Variablen zur Laufzeit ändern können und werden. Der vom Full-Compiler generierte Code verwendet Inline-Caches (ICs), um das Wissen über Typen während der Programmausführung zu verfeinern und die Effizienz im laufenden Betrieb zu verbessern.
Das Ziel von Inline-Caches ist es, Typen effizient zu verarbeiten, indem typabhängiger Code für Vorgänge im Cache gespeichert wird. Wenn der Code ausgeführt wird, werden zuerst Typannahmen validiert und dann wird der Inline-Cache verwendet, um den Vorgang abzukürzen. Das bedeutet jedoch, dass Vorgänge, die mehrere Typen akzeptieren, weniger leistungsfähig sind.
Daher gilt:
- Die monomorphe Verwendung von Vorgängen wird gegenüber polymorphen Vorgängen bevorzugt.
Vorgänge sind monomorph, wenn die verborgenen Klassen der Eingaben immer gleich sind. Andernfalls sind sie polymorph, d. h., einige der Argumente können bei verschiedenen Aufrufen des Vorgangs den Typ ändern. Beispielsweise führt der zweite add()-Aufruf in diesem Beispiel zu Polymorphismus:
function add(x, y) {
return x + y;
}
add(1, 2); // + in add is monomorphic
add("a", "b"); // + in add becomes polymorphic```
Optimierungs-Compiler
Parallel zum vollständigen Compiler kompiliert V8 „heiße“ Funktionen (also Funktionen, die oft ausgeführt werden) mit einem optimierenden Compiler neu. Dieser Compiler verwendet Typ-Feedback, um den kompilierten Code schneller zu machen. Tatsächlich verwendet er die Typen aus den ICs, über die wir gerade gesprochen haben.
Im optimierenden Compiler werden Vorgänge spekulativ inline eingefügt (direkt dort platziert, wo sie aufgerufen werden). Dies beschleunigt die Ausführung (auf Kosten des Speicherbedarfs), ermöglicht aber auch andere Optimierungen. Monomorphe Funktionen und Konstruktoren können vollständig inline eingefügt werden. Das ist ein weiterer Grund, warum Monomorphismus in V8 eine gute Idee ist.
Mit der eigenständigen d8-Version der V8-Engine können Sie protokollieren, was optimiert wird:
d8 --trace-opt primes.js
(Dadurch werden die Namen der optimierten Funktionen in stdout protokolliert.)
Nicht alle Funktionen können jedoch optimiert werden. Einige Funktionen verhindern, dass der Optimierungs-Compiler für eine bestimmte Funktion ausgeführt wird (ein „Bail-out“). Insbesondere bricht der Optimierungs-Compiler derzeit bei Funktionen mit try {} catch {}-Blöcken ab!
Daher gilt:
- Fügen Sie leistungsempfindlichen Code in eine verschachtelte Funktion ein, wenn Sie „try {} catch {}“-Blöcke haben: ```js function perf_sensitive() { // Do performance-sensitive work here }
try { perf_sensitive() } catch (e) { // Handle exceptions here } ```
Diese Anleitung wird sich in Zukunft wahrscheinlich ändern, da wir try/catch-Blöcke im optimierenden Compiler aktivieren. Sie können untersuchen, wie der optimierende Compiler Funktionen abbricht, indem Sie die Option „--trace-opt“ mit d8 wie oben verwenden. Dadurch erhalten Sie weitere Informationen darüber, welche Funktionen abgebrochen wurden:
d8 --trace-opt primes.js
Deoptimierung
Schließlich ist die von diesem Compiler durchgeführte Optimierung spekulativ – manchmal funktioniert sie nicht und wir brechen ab. Bei der „Deoptimierung“ wird optimierter Code verworfen und die Ausführung an der richtigen Stelle im „vollständigen“ Compiler-Code fortgesetzt. Die Neuoptimierung wird möglicherweise später noch einmal ausgelöst, aber kurzfristig verlangsamt sich die Ausführung. Insbesondere wenn nach der Optimierung der Funktionen Änderungen an den versteckten Klassen von Variablen vorgenommen werden, wird diese Deoptimierung ausgelöst.
Daher gilt:
- Änderungen an ausgeblendeten Klassen in Funktionen nach der Optimierung vermeiden
Wie bei anderen Optimierungen können Sie mit einem Logging-Flag ein Log der Funktionen abrufen, die V8 deoptimieren musste:
d8 --trace-deopt primes.js
Weitere V8-Tools
Übrigens können Sie beim Start von Chrome auch V8-Tracing-Optionen übergeben:
"/Applications/Google Chrome.app/Contents/MacOS/Google Chrome" --js-flags="--trace-opt --trace-deopt"```
Zusätzlich zur Profilerstellung mit den Entwicklertools können Sie auch d8 für die Profilerstellung verwenden:
% out/ia32.release/d8 primes.js --prof
Dabei wird der integrierte Sampling-Profiler verwendet, der jede Millisekunde eine Stichprobe erstellt und in v8.log schreibt.
Zusammenfassung
Es ist wichtig, zu verstehen, wie die V8-Engine mit Ihrem Code funktioniert, um leistungsstarkes JavaScript zu erstellen. Noch einmal der grundlegende Rat:
- Seien Sie vorbereitet, bevor ein Problem auftritt (oder Sie es bemerken)
- Identifizieren und verstehen Sie dann den Kern des Problems.
- Beheben Sie schließlich die wichtigsten Probleme.
Sie sollten also zuerst mit anderen Tools wie PageSpeed prüfen, ob das Problem in Ihrem JavaScript liegt. Reduzieren Sie den Code vor dem Erfassen von Messwerten möglicherweise auf reines JavaScript (kein DOM) und verwenden Sie diese Messwerte dann, um Engpässe zu finden und die wichtigsten zu beseitigen. Wir hoffen, dass Daniels Vortrag (und dieser Artikel) Ihnen geholfen haben, besser zu verstehen, wie V8 JavaScript ausführt. Vergessen Sie aber nicht, auch Ihre eigenen Algorithmen zu optimieren.