Leistungstipps für JavaScript in V8

Chris Wilson
Chris Wilson

Einführung

Daniel Clifford hat einen hervorragenden Vortrag auf der Google I/O gehalten, in dem er Tipps und Tricks zur Verbesserung der JavaScript-Leistung in V8 geteilt hat. Daniel ermutigte uns, „schneller zu fordern“ – die Leistungsunterschiede zwischen C++ und JavaScript sorgfältig zu analysieren und Code mit Blick auf die Funktionsweise von JavaScript zu schreiben. In diesem Artikel finden Sie eine Zusammenfassung der wichtigsten Punkte aus Daniels Vortrag. Wir aktualisieren diesen Artikel auch, wenn sich die Leistungsempfehlungen ändern.

Der wichtigste Rat

Es ist wichtig, Leistungstipps in einen Kontext zu setzen. Leistungstipps machen süchtig und manchmal kann es sehr ablenkend sein, sich zuerst auf detaillierte Tipps zu konzentrieren, anstatt sich auf die wirklichen Probleme zu konzentrieren. Sie müssen sich einen umfassenden Überblick über die Leistung Ihrer Webanwendung verschaffen. Bevor Sie sich auf diesen Leistungstipp konzentrieren, sollten Sie Ihren Code am besten mit Tools wie PageSpeed analysieren und eine Verbesserung erzielen. So können Sie eine vorzeitige Optimierung vermeiden.

Die beste grundlegende Empfehlung für eine gute Leistung in Webanwendungen lautet:

  • Vorbereiten, bevor ein Problem auftritt (oder bemerkt wird)
  • Identifizieren und verstehen Sie dann den Kern Ihres Problems.
  • Beheben Sie schließlich die Probleme, die Ihnen wichtig sind.

Um diese Schritte ausführen zu können, ist es wichtig zu verstehen, wie V8 JavaScript optimiert, damit Sie Code schreiben können, der auf die JS-Laufzeit abgestimmt ist. Es ist auch wichtig, sich über die verfügbaren Tools zu informieren und zu erfahren, wie sie Ihnen helfen können. Daniel erklärt in seinem Vortrag ausführlicher, wie die Entwicklertools verwendet werden. In diesem Dokument werden nur einige der wichtigsten Punkte des V8-Engine-Designs beschrieben.

Jetzt geht es weiter mit den V8-Tipps.

Ausgeblendete Kurse

JavaScript verfügt über begrenzte Informationen zur Kompilierungszeit: Typen können während der Laufzeit geändert werden, daher ist es normal, zu erwarten, dass die Erläuterung von JS-Typen zum Zeitpunkt der Kompilierung kostspielig ist. Das könnte Sie dazu bringen, sich zu fragen, wie die JavaScript-Leistung überhaupt annähernd an C++ herankommen könnte. V8 hat jedoch 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!```

Solange der Objektinstanz p2 kein zusätzliches Mitglied „.z“ hinzugefügt wird, haben p1 und p2 intern dieselbe ausgeblendete Klasse. V8 kann also eine einzelne Version optimierten Assemblercodes für JavaScript-Code generieren, der entweder p1 oder p2 manipuliert. Je weniger Sie die verborgenen Klassen auseinanderdriften lassen, desto besser ist die Leistung.

Daher

  • Alle Objektmitglieder in Konstruktorfunktionen initialisieren, damit die Instanzen später nicht den Typ ändern
  • Objektmitglieder immer in der gleichen Reihenfolge initialisieren

iWork Numbers

V8 verwendet Tagging, um Werte effizient darzustellen, wenn sich die Typen ändern können. V8 leitet aus den von Ihnen verwendeten Werten ab, um welchen Zahlentyp es sich handelt. Nachdem V8 diese Inferenz vorgenommen 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 sollten Sie die Zahlentypen einheitlich verwenden. Im Allgemeinen ist es am besten, nach Bedarf 31-Bit-ganze Zahlen mit Vorzeichen 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```

Dementsprechend

  • Verwenden Sie vorzugsweise numerische Werte, die als 31-Bit-Ganzzahlen mit Vorzeichen dargestellt werden können.

Arrays

Um große und dünnbesetzte Arrays zu verarbeiten, gibt es intern zwei Arten von Arrayspeichern:

  • Fast Elements: Linearer Speicher für kompakte Schlüsselsätze
  • Wörterbuchelemente: Andernfalls Hashtabellenspeicher

Es ist am besten, den Array-Speicher nicht von einem Typ in einen anderen umzuwandeln.

Dementsprechend

  • Für Arrays fortlaufende Schlüssel mit 0 beginnend verwenden
  • Weisen Sie große Arrays (z. B. mit mehr als 64.000 Elementen) nicht vorab ihrer maximalen Größe zu, sondern wachsen Sie im Laufe der Zeit.
  • Elemente in Arrays nicht löschen, insbesondere keine 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 Doppeln schneller: Die versteckte Klasse des Arrays überwacht die Elementtypen und Arrays, die nur Doppel enthalten, werden entschachtelt (was zu einer Änderung der versteckten Klasse führt).Eine unvorsichtige Manipulation von Arrays kann jedoch aufgrund von Boxing und Unboxing zusätzliche Arbeit verursachen, 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];

weil im ersten Beispiel die einzelnen Zuweisungen nacheinander ausgeführt werden und durch die Zuweisung von a[2] das Array in ein Array mit nicht geboxten Double-Werten umgewandelt wird, durch die Zuweisung von a[3] aber wieder in ein Array umgewandelt wird, das beliebige Werte (Zahlen oder Objekte) enthalten kann. Im zweiten Fall kennt der Compiler die Typen aller Elemente im Literal und die ausgeblendete Klasse kann vorab bestimmt werden.

  • Arrays mit fester Größe mit Arrayliteralen initialisieren
  • Kleine Arrays (< 64 kB) vor der Verwendung in der richtigen Größe vorallocaten
  • Nicht numerische Werte (Objekte) nicht in numerischen Arrays speichern
  • Achten Sie darauf, dass kleine Arrays nicht neu konvertiert werden, wenn 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):

  • Den "Full"-Compiler, der guten Code für jedes JavaScript generieren kann
  • Der Optimierer-Compiler, der für die meisten JavaScript-Code hervorragenden Code generiert, aber länger zum Kompilieren braucht.

Der vollständige Compiler

In V8 wird der Full-Compiler auf allen Code ausgeführt und beginnt so schnell wie möglich mit der Ausführung des Codes. So wird schnell guter, aber nicht hervorragender Code generiert. Dieser Compiler geht bei der Kompilierung fast gar nicht von Typen aus. Er geht davon aus, dass sich die Variablentypen 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 so die Effizienz im laufenden Betrieb zu verbessern.

Das Ziel von Inline-Caches besteht darin, Typen effizient zu verarbeiten, indem typabhängiger Code für Vorgänge im Cache gespeichert wird. Wenn der Code ausgeführt wird, werden zuerst die Typannahmen validiert und dann der Vorgang mithilfe des Inline-Caches umgangen. Das bedeutet jedoch, dass Vorgänge, die mehrere Typen akzeptieren, weniger leistungsfähig sind.

Daher

  • Die monomorphe Verwendung von Vorgängen wird polymorphen Vorgängen vorgezogen.

Vorgänge sind monomorph, wenn die versteckten 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. Der zweite add()-Aufruf in diesem Beispiel führt beispielsweise zu Polymorphismus:

function add(x, y) {
  return x + y;
}

add(1, 2);      // + in add is monomorphic
add("a", "b");  // + in add becomes polymorphic```

Der optimierende Compiler

Parallel zum vollständigen Compiler kompiliert V8 "hot"-Funktionen (d. h. Funktionen, die häufig ausgeführt werden) mit einem Optimierungs-Compiler neu. Dieser Compiler verwendet Typrückmeldungen, um den kompilierten Code schneller zu machen. Tatsächlich werden die Typen aus den ICs verwendet, ü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 Arbeitsspeichers), ermöglicht aber auch andere Optimierungen. Monomorphe Funktionen und Konstruktoren können vollständig inline eingefügt werden (dies 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 der Optimierungs-Compiler greift derzeit auf Funktionen mit dem Konstruktion {} Catch {}-Blöcken aus!

Daher

  • Fügen Sie sicherheitsrelevanten Code in eine verschachtelte Funktion ein, wenn Sie es mit {}fang {} Blöcken versuchen: ```js function perf_sensitive() { // Hier leistungsabhängige Arbeiten ausführen }

try { perf_sensitive() } catch (e) { // Ausnahmen hier behandeln } ```

Diese Empfehlung wird sich wahrscheinlich in Zukunft ändern, da wir Try/Catch-Blöcke im optimierten Compiler aktivieren. Sie können prüfen, wie der optimierte Compiler bei Funktionen abbricht, indem Sie die Option „--trace-opt“ mit d8 wie oben verwenden. Dadurch erhalten Sie weitere Informationen dazu, bei welchen Funktionen ein Abbruch erfolgt:

d8 --trace-opt primes.js

De-Optimierung

Die Optimierung durch diesen Compiler ist spekulativ. Manchmal funktioniert sie nicht und wir kehren zur ursprünglichen Ausführung zurück. Bei der „Deoptimierung“ wird optimierter Code verworfen und die Ausführung wird an der richtigen Stelle im „vollständigen“ Compilercode fortgesetzt. Die Neuoptimierung kann später noch einmal ausgelöst werden, aber kurzfristig wird die Ausführung verlangsamt. Insbesondere Änderungen an den ausgeblendeten Variablenklassen nach der Optimierung der Funktionen führen zu dieser Deoptimierung.

Daher

  • Versteckte Klassenänderungen in Funktionen nach der Optimierung vermeiden

Wie bei anderen Optimierungen können Sie mit einem Logging-Flag ein Log der Funktionen abrufen, die von V8 entfernt werden mussten:

d8 --trace-deopt primes.js

Andere V8-Tools

Sie können V8-Tracing-Optionen auch beim Starten an Chrome übergeben:

"/Applications/Google Chrome.app/Contents/MacOS/Google Chrome" --js-flags="--trace-opt --trace-deopt"```

Neben dem Profiling mit den Entwicklertools können Sie auch d8 verwenden:

% out/ia32.release/d8 primes.js --prof

Dazu wird der integrierte Stichproben-Profiler verwendet, der alle Millisekunden eine Stichprobe nimmt und v8.log schreibt.

Zusammenfassung

Es ist wichtig, dass Sie genau wissen, wie die V8-Engine mit Ihrem Code arbeitet, um die Erstellung von leistungsstarkem JavaScript vorzubereiten. Noch einmal:

  • Vorbereiten, bevor ein Problem auftritt
  • Identifizieren und verstehen Sie dann den Kern Ihres Problems.
  • Beheben Sie schließlich die Probleme, die Ihnen wichtig sind.

Sie sollten also zuerst mit anderen Tools wie PageSpeed prüfen, ob das Problem in Ihrem JavaScript liegt. Reduzieren Sie es gegebenenfalls auf reines JavaScript (ohne DOM), bevor Sie Messwerte erfassen. Verwenden Sie diese Messwerte dann, um Engpässe zu finden und die wichtigsten zu beseitigen. Wir hoffen, dass Daniel's Vortrag (und dieser Artikel) Ihnen dabei helfen, besser zu verstehen, wie V8 JavaScript ausführt. Konzentrieren Sie sich aber auch darauf, Ihre eigenen Algorithmen zu optimieren.

Verweise