Leistungstipps für JavaScript in V8

Chris Wilson
Chris Wilson

Einleitung

Daniel Clifford hielt auf der Google I/O einen hervorragenden Vortrag über Tipps und Tricks zur Verbesserung der JavaScript-Leistung in V8. Daniel hat uns ermutigt, „Schnellere Nachfrage“ zu entwickeln, um die Leistungsunterschiede zwischen C++ und JavaScript sorgfältig zu analysieren und auf die Funktionsweise von JavaScript zu achten. Dieser Artikel fasst die wichtigsten Punkte von Daniels Vortrag zusammen. Wir aktualisieren diesen Artikel auch, wenn sich die Richtlinien zur Leistung ändern.

Der wichtigste Rat

Es ist wichtig, jede Leistungsempfehlung in einen Kontext zu bringen. Leistungsberatung macht süchtig und manchmal kann die Konzentration auf tiefgehende Ratschläge von den eigentlichen Problemen ablenken. Sie müssen sich die Leistung Ihrer Webanwendung ganzheitlich ansehen. Bevor Sie sich auf diesen Leistungstipp konzentrieren, sollten Sie Ihren Code wahrscheinlich mit Tools wie PageSpeed analysieren und Ihre Bewertung verbessern. So vermeiden Sie eine vorzeitige Optimierung.

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

  • Seien Sie vorbereitet, bevor Sie ein Problem haben (oder bemerken).
  • Identifizieren und verstehen Sie dann den Kern des Problems.
  • Schließlich, was wichtig ist

Für diese Schritte ist es wichtig zu verstehen, wie V8 JS optimiert, damit Sie Code unter Berücksichtigung des JS-Laufzeitdesigns schreiben können. Es ist auch wichtig, mehr über die verfügbaren Tools und deren Nutzen zu erfahren. In seinem Vortrag erklärt Daniel die Verwendung der Entwicklertools genauer. In diesem Dokument werden nur einige der wichtigsten Punkte des V8-Engine-Designs zusammengefasst.

Kommen wir zu den Tipps zu V8!

Ausgeblendete Klassen

JavaScript verfügt nur über begrenzte Informationen zum Typ der Kompilierungszeit: Typen können während der Laufzeit geändert werden. Daher ist es normalerweise kostspielig, JS-Typen bei der Kompilierung zu berücksichtigen. Dies könnte Sie fragen, ob die JavaScript-Leistung jemals an C++ herankommen könnte. V8 hat jedoch intern für Objekte zur Laufzeit erstellt. 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 zur Objektinstanz p2 das zusätzliche Element ".z" hinzugefügt wurde, haben p1 und p2 intern dieselbe versteckte Klasse, sodass V8 eine einzelne Version der optimierten Assembly für JavaScript-Code generieren kann, der entweder p1 oder p2 manipuliert. Je mehr Sie die Abweichung von versteckten Klassen vermeiden können, desto bessere Leistungen erhalten Sie.

Daher

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

Numbers

In V8 wird Tagging verwendet, um Werte effizient darzustellen, wenn sich Typen ändern können. V8 leitet aus den Werten ab, die Sie für welchen Zahlentyp verwenden. Sobald V8 diese Inferenz erstellt hat, werden die Werte durch Tagging effizient dargestellt, da sich diese Typen dynamisch ändern können. Allerdings fallen manchmal Kosten für das Ändern dieser Typ-Tags an. Aus diesem Grund ist es am besten, Zahlentypen konsistent zu verwenden. Im Allgemeinen ist es am besten, gegebenenfalls 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

  • Ich bevorzuge numerische Werte, die als vorzeichenbehaftete 31-Bit-Ganzzahlen dargestellt werden können.

Arrays

Zur Verarbeitung großer und dünnbesetzter Arrays gibt es intern zwei Arten von Arrayspeicher:

  • Fast Elements: linearer Speicher für kompakte Schlüsselsätze
  • Wörterbuchelemente: sonst Speicher für Hash-Tabelle

Es ist nicht empfehlenswert, dass der Array-Speicher von einem Typ in einen anderen wechselt.

Daher

  • Fortlaufende Schlüssel für Arrays verwenden, beginnend bei 0
  • Weisen Sie große Arrays (z. B. mehr als 64.000 Elemente) nicht vorab ihrer maximalen Größe zu, sondern wachsen Sie sie mit der Zeit weiter.
  • Elemente in Arrays, insbesondere numerische Arrays, sollten nicht gelöscht werden
  • 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 mit Double-Werten schneller: Die ausgeblendete Klasse des Arrays verfolgt Elementtypen und Arrays, die nur Doubles enthalten, werden entpackt, was zu einer verborgenen Klassenänderung führt. Die unvorsichtige Manipulation von Arrays kann jedoch durch Boxen und Unboxing zusätzlichen Aufwand 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];

da im ersten Beispiel die einzelnen Zuweisungen nacheinander ausgeführt werden und die Zuweisung von a[2] dazu führt, dass das Array in ein Array mit nicht umrandeten Doubles konvertiert wird. Die Zuweisung von a[3] führt jedoch 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 festgelegt werden.

  • Mit Array-Literalen für kleine Arrays mit fester Größe initialisieren
  • Weisen Sie kleine Arrays (< 64.000) der richtigen Größe vorab zu, bevor Sie sie verwenden.
  • Speichern Sie keine nicht numerischen Werte (Objekte) in numerischen Arrays
  • Achten Sie darauf, keine Rekonvertierung von kleinen Arrays zu verursachen, wenn Sie sie ohne Literale initialisieren.

JavaScript-Kompilierung

Obwohl JavaScript eine sehr dynamische Sprache ist und ursprüngliche Implementierungen davon Interpreter waren, verwenden moderne JavaScript-Laufzeit-Engines die Kompilierung. V8 (das JavaScript von Chrome) hat zwei verschiedene JIT-Compiler (Just-In-Time):

  • Entspricht dem "Full"-Compiler, der guten Code für jegliches JavaScript generieren kann.
  • Entspricht dem Optimierungs-Compiler, der für die meisten JavaScript-Elemente hervorragenden Code erzeugt, dessen Kompilierung jedoch länger dauert.

Kompletter Compiler

In V8 wird der Compiler "Full" für den gesamten Code ausgeführt und beginnt so schnell wie möglich mit der Ausführung von Code. Dabei wird schnell guter, aber nicht einwandfreier Code generiert. Dieser Compiler geht bei der Kompilierung von so gut wie nichts über Typen aus. Er erwartet, dass sich Variablentypen während der 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, validiert er zuerst Typannahmen und verwendet dann den Inline-Cache, um den Vorgang zu verkürzen. Dies bedeutet jedoch, dass Vorgänge, die mehrere Typen akzeptieren, weniger leistungsfähig sind.

Daher

  • Die monomorphe Verwendung von Operationen wird gegenüber polymorphen Operationen bevorzugt.

Operationen sind monomorph, wenn die ausgeblendeten Klassen von Eingaben immer gleich sind. Andernfalls sind sie polymorph, was bedeutet, dass einige der Argumente den Typ über verschiedene Aufrufe des Vorgangs hinweg ändern können. Der zweite add()-Aufruf in diesem Beispiel verursacht Polymorphie:

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 "heiße" Funktionen (d. h. Funktionen, die mehrmals ausgeführt werden) mit einem optimierenden Compiler neu. Dieser Compiler verwendet Typfeedback, 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 platziert (direkt dort platziert, wo sie aufgerufen werden). Dies beschleunigt die Ausführung (auf Kosten des Arbeitsspeicherbedarfs), 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.

Sie können mit der eigenständigen „d8“-Version der V8-Engine protokollieren, was optimiert wird:

d8 --trace-opt primes.js

(Damit werden die Namen optimierter Funktionen in stdout protokolliert.)

Nicht alle Funktionen können jedoch optimiert werden - einige Funktionen verhindern, dass der optimierende Compiler für eine bestimmte Funktion ausgeführt wird (eine "Ausweichlösung"). Besonders der optimierende Compiler scheitert derzeit mit test {}cat {}-Blöcken gegen Funktionen!

Daher

  • Fügen Sie perf-sensitiven Code in eine verschachtelte Funktion ein, wenn Sie {}cat {}-Blöcke versucht haben: ```js function perf_sensitive() { // Hier leistungsabhängige Arbeit ausführen }

else { perf_sensitive() } Kontrolle (e) { // Hier Ausnahmen verarbeiten } ```

Diese Anleitung wird sich wahrscheinlich in Zukunft ändern, wenn wir im Optimierungs-Compiler versuche/Catch-Blöcke aktivieren. Sie können untersuchen, wie der optimierende Compiler Funktionen ausbricht, indem Sie wie oben die Option "--trace-opt" mit d8 verwenden. Diese gibt Ihnen mehr Informationen darüber, welche Funktionen ausgeschlossen wurden:

d8 --trace-opt primes.js

De-Optimierung

Zu guter Letzt ist die von diesem Compiler durchgeführte Optimierung spekulativ. Manchmal funktioniert sie nicht und wir gehen einen Schritt zurück. Der optimierte Code wird durch den Prozess der "Deoptimierung" verworfen und die Ausführung wird an der richtigen Stelle im "vollständigen" Compiler-Code fortgesetzt. Die Reoptimierung kann später noch einmal ausgelöst werden, aber kurzfristig verlangsamt sich die Ausführung. Diese Deoptimierung wird insbesondere durchgeführt, wenn Änderungen an den verborgenen Klassen von Variablen nach der Optimierung der Funktionen vorgenommen werden.

Daher

  • Verborgene 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 V8 deaktivieren musste:

d8 --trace-deopt primes.js

Andere V8-Tools

Übrigens können Sie beim Start auch V8-Tracing-Optionen an Chrome übergeben:

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

Neben der Profilerstellung in den Entwicklertools können Sie d8 auch für die Profilerstellung nutzen:

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

Dabei wird der integrierte Sampling-Profiler verwendet, der jede Millisekunde eine Stichprobe erstellt und v8.log schreibt.

Zusammenfassung

Es ist wichtig, dass Sie die Funktionsweise der V8-Engine mit Ihrem Code kennen und verstehen, um sich auf die Erstellung von leistungsstarkem JavaScript vorzubereiten. Hier noch einmal die grundlegende Empfehlung:

  • Seien Sie vorbereitet, bevor Sie ein Problem haben (oder bemerken).
  • Identifizieren und verstehen Sie dann den Kern des Problems.
  • Schließlich, was wichtig ist

Das bedeutet, dass Sie sicherstellen sollten, dass das Problem in Ihrem JavaScript liegt, indem Sie zuerst andere Tools wie PageSpeed verwenden. Möglicherweise auf reines JavaScript (kein DOM) reduzieren, bevor Sie Messwerte erfassen, und diese Messwerte dann verwenden, um Engpässe zu finden und die wichtigen zu beseitigen. Hoffentlich werden Daniels Vortrag (und dieser Artikel) Ihnen helfen, besser zu verstehen, wie JavaScript in V8 ausgeführt wird – aber achten Sie unbedingt auch darauf, Ihre eigenen Algorithmen zu optimieren!

Verweise