Mithilfe von Forensik- und Detektivarbeit Geheimnisse der JavaScript-Leistung lösen

John McCutchan
John McCutchan

Einführung

In den letzten Jahren wurden Webanwendungen erheblich beschleunigt. Viele Anwendungen laufen jetzt so schnell, dass einige Entwickler laut gefragt haben: „Ist das Web schnell genug?“ Für einige Anwendungen mag das zutreffen, aber für Entwickler, die an Hochleistungsanwendungen arbeiten, ist sie nicht schnell genug. Trotz der beeindruckenden Fortschritte bei der JavaScript-Virtual-Machine-Technologie hat eine neuere Studie gezeigt, dass Google-Anwendungen zwischen 50% und 70% ihrer Zeit in V8 verbringen. Ihre Anwendung hat eine begrenzte Zeit. Wenn Sie Zyklen von einem System abziehen, kann ein anderes System mehr leisten. Denken Sie daran, dass Anwendungen mit 60 fps nur 16 ms pro Frame haben, andernfalls kommt es zu Rucklern. Im Folgenden erfahren Sie mehr über die Optimierung von JavaScript und das Erstellen von JavaScript-Profilen. Dabei wird die Geschichte der Leistungsexperten des V8-Teams erzählt, die ein schwer zu fassendes Leistungsproblem in Find Your Way to Oz aufgedeckt haben.

Google I/O 2013-Sitzung

Ich habe diese Inhalte auf der Google I/O 2013 vorgestellt. Sehen Sie sich das Video unten an:

Warum ist die Leistung wichtig?

CPU-Zyklen sind ein Nullsummenspiel. Wenn Sie einen Teil Ihres Systems weniger nutzen, können Sie einen anderen Teil mehr nutzen oder das System insgesamt flüssiger laufen lassen. Schneller und mehr zu leisten sind oft konkurrierende Ziele. Nutzer fordern neue Funktionen, erwarten aber gleichzeitig, dass Ihre Anwendung reibungsloser funktioniert. JavaScript-virtuelle Maschinen werden immer schneller, aber das ist kein Grund, Leistungsprobleme zu ignorieren, die Sie heute beheben können. Das wissen viele Entwickler, die mit Leistungsproblemen in ihren Webanwendungen zu kämpfen haben. Bei Echtzeitanwendungen mit hoher Framerate ist es von entscheidender Bedeutung, dass keine Ruckler auftreten. Insomniac Games hat eine Studie durchgeführt, aus der hervorgeht, dass eine stabile, konstante Framerate für den Erfolg eines Spiels wichtig ist: „Eine stabile Framerate ist nach wie vor ein Zeichen für ein professionelles, gut gemachtes Produkt.“ Webentwickler aufgepasst!

Leistungsprobleme beheben

Die Lösung eines Leistungsproblems ist mit der Aufklärung eines Verbrechens vergleichbar. Sie müssen die Beweise sorgfältig prüfen, die vermuteten Ursachen überprüfen und verschiedene Lösungen ausprobieren. Dokumentieren Sie Ihre Messungen, damit Sie sicher sein können, dass Sie das Problem tatsächlich behoben haben. Diese Methode unterscheidet sich nur unwesentlich davon, wie Kriminalbeamte einen Fall lösen. Detektive prüfen Beweise, verhören Verdächtige und führen Tests durch, in der Hoffnung, den entscheidenden Hinweis zu finden.

V8 CSI: Oz

Die fantastischen Zauberer, die Find Your Way to Oz entwickeln, haben sich an das V8-Team gewandt, weil sie ein Leistungsproblem nicht selbst lösen konnten. Gelegentlich friert Oz ein, was zu Rucklern führt. Die Oz-Entwickler hatten bereits einige erste Untersuchungen mit dem Zeitleistenbereich in den Chrome DevTools durchgeführt. Bei der Analyse der Arbeitsspeichernutzung stieß er auf das gefürchtete Sägezahndiagramm. Einmal pro Sekunde sammelte der Garbage Collector 10 MB an Datenmüll und die Pausen der Speicherbereinigung entsprachen den Rucklern. Ähnlich wie im folgenden Screenshot der Zeitachse in den Chrome-Entwicklertools:

Zeitachse für Devtools

Die V8-Ermittler Jakob und Yang übernahmen den Fall. Es gab viele Rückfragen zwischen Jakob und Yang vom V8-Team und dem Oz-Team. Ich habe diese Unterhaltung auf die wichtigsten Ereignisse reduziert, die zur Behebung dieses Problems beigetragen haben.

Belege

Der erste Schritt besteht darin, die ersten Beweise zu erfassen und zu untersuchen.

Um welche Art von Anwendung handelt es sich?

Die Oz-Demo ist eine interaktive 3D-Anwendung. Aus diesem Grund ist es sehr empfindlich gegenüber Pausen, die durch Garbage Collection verursacht werden. Denken Sie daran, dass eine interaktive Anwendung, die mit 60 fps ausgeführt wird, 16 ms Zeit für alle JavaScript-Vorgänge hat und einen Teil dieser Zeit für Chrome reservieren muss, um die Grafikaufrufe zu verarbeiten und den Bildschirm zu zeichnen.

Oz führt viele arithmetische Berechnungen mit Doppelwerten durch und ruft häufig WebAudio und WebGL auf.

Welche Art von Leistungsproblem tritt auf?

Es gibt Pausen, also Frame-Drops, also Ruckler. Diese Pausen stehen in Zusammenhang mit der automatischen Speicherbereinigung.

Folgen die Entwickler den Best Practices?

Ja, die Oz-Entwickler sind mit der Leistung und den Optimierungstechniken von JavaScript-VMs vertraut. Die Oz-Entwickler verwendeten CoffeeScript als Quellsprache und generierten JavaScript-Code über den CoffeeScript-Compiler. Dies machte die Untersuchung etwas schwieriger, da der Code, der von den Oz-Entwicklern geschrieben wurde, nicht mit dem Code übereinstimmte, der von V8 verwendet wurde. Die Chrome-Entwicklertools unterstützen jetzt Quellzuordnungen, was dies einfacher gemacht hätte.

Warum wird der Garbage Collector ausgeführt?

Der Arbeitsspeicher in JavaScript wird für den Entwickler automatisch von der VM verwaltet. V8 verwendet ein gängiges System zur automatischen Bereinigung von Speicher, bei dem der Speicher in zwei (oder mehr) Generationen unterteilt wird. Die Young Generation enthält Objekte, die vor Kurzem zugewiesen wurden. Wenn ein Objekt lange genug überlebt, wird es in die alte Generation verschoben.

Die Daten der jüngeren Generation werden viel häufiger erfasst als die der älteren Generation. Das ist beabsichtigt, da die Erhebung von Daten der jüngeren Generation viel günstiger ist. Häufig ist davon auszugehen, dass häufige GC-Pausen durch die Sammlung der jungen Generation verursacht werden.

In V8 ist der Young-Speicherbereich in zwei gleich große zusammenhängende Speicherblöcke unterteilt. Nur einer dieser beiden Speicherblöcke ist zu einem bestimmten Zeitpunkt belegt und wird als „To-Space“ bezeichnet. Solange noch Speicherplatz im Zielbereich vorhanden ist, ist die Zuweisung eines neuen Objekts kostengünstig. Ein Cursor im Zielbereich wird um die Anzahl der Byte verschoben, die für das neue Objekt erforderlich sind. Dies geschieht so lange, bis der Speicherplatz aufgebraucht ist. An diesem Punkt wird das Programm angehalten und die Erfassung beginnt.

V8-Young-Memory

An diesem Punkt werden der Ursprungs- und der Zielbereich vertauscht. Was vorher der Zielbereich war und jetzt der Ursprungsbereich ist, wird von Anfang bis Ende gescannt. Alle noch aktiven Objekte werden in den Zielbereich kopiert oder in den Heap der alten Generation verschoben. Weitere Informationen finden Sie im Artikel Cheney-Algorithmus.

Sie sollten intuitiv verstehen, dass jedes Mal, wenn ein Objekt entweder implizit oder explizit (über einen Aufruf von new, [], oder {}) zugewiesen wird, Ihre Anwendung immer näher an einer Garbage Collection und der gefürchteten Anwendungspause rückt.

Sind 10 MB/s an Junk-Daten für diese Anwendung normal?

Kurz gesagt: Nein. Der Entwickler tut nichts, um 10 MB/s an Junk-Daten zu erwarten.

Verdächtige

In der nächsten Phase der Untersuchung werden potenzielle Verdächtige ermittelt und dann eingegrenzt.

Verdächtiger 1

new während des Frames aufrufen Denken Sie daran, dass Sie mit jedem zugewiesenen Objekt der GC-Pause immer näher kommen. Bei Anwendungen mit hoher Framerate sollte es möglichst keine Zuweisungen pro Frame geben. Normalerweise erfordert dies ein sorgfältig durchdachtes, anwendungsspezifisches Objektrecyclingsystem. Die V8-Ermittler haben beim Oz-Team nachgefragt und es wurde bestätigt, dass es sich nicht um einen neuen Fall handelt. Das Oz-Team war sich dieser Anforderung bereits bewusst und sagte: „Das wäre peinlich.“ Dieser Punkt kann von der Liste gestrichen werden.

Verdächtiger 2

Die „Form“ eines Objekts außerhalb des Konstruktors ändern. Das passiert immer, wenn einem Objekt außerhalb des Konstruktors eine neue Property hinzugefügt wird. Dadurch wird eine neue ausgeblendete Klasse für das Objekt erstellt. Wenn optimierter Code diese neue ausgeblendete Klasse erkennt, wird eine Deoptimierung ausgelöst. Nicht optimierter Code wird ausgeführt, bis der Code als heiß eingestuft und noch einmal optimiert wird. Dieser Optimierungs- und Deoptimierungs-Tausch führt zu Rucklern,steht aber nicht in direktem Zusammenhang mit der übermäßigen Erstellung von Garbage. Nach einer sorgfältigen Prüfung des Codes wurde bestätigt, dass die Objektformen statisch waren. Verdacht Nr. 2 wurde daher ausgeschlossen.

Verdächtiger 3

Arithmetik in nicht optimiertem Code Bei nicht optimiertem Code führen alle Berechnungen dazu, dass tatsächliche Objekte zugewiesen werden. Beispiel:

var a = p * d;
var b = c + 3;
var c = 3.3 * dt;
point.x = a * b * c;

Dadurch werden fünf HeapNumber-Objekte erstellt. Die ersten drei sind für die Variablen a, b und c. Die 4. ist für den anonymen Wert (a * b) und die 5. ist aus #4 * c. Die 5. wird letztendlich point.x zugewiesen.

Oz führt Tausende dieser Vorgänge pro Frame aus. Wenn eine dieser Berechnungen in Funktionen erfolgt, die nie optimiert werden, kann dies die Ursache für den Junk-Code sein. Denn bei nicht optimierten Berechnungen wird Arbeitsspeicher auch für temporäre Ergebnisse zugewiesen.

Verdächtiger 4

Eine Zahl mit doppelter Genauigkeit in einer Property speichern Es muss ein HeapNumber-Objekt erstellt werden, um die Zahl zu speichern, und die Eigenschaft muss so geändert werden, dass sie auf dieses neue Objekt verweist. Wenn Sie die Property so ändern, dass sie auf die HeapNumber verweist, wird kein Junk-Code erzeugt. Es ist jedoch möglich, dass viele Gleitkommazahlen mit doppelter Genauigkeit als Objekteigenschaften gespeichert werden. Der Code enthält viele Anweisungen wie diese:

sprite.position.x += 0.5 * (dt);

In optimiertem Code wird jedes Mal, wenn x ein neu berechneter Wert zugewiesen wird, also eine scheinbar harmlose Anweisung, implizit ein neues HeapNumber-Objekt zugewiesen, was die Pause der Garbage Collection näher bringt.

Wenn Sie ein typisiertes Array (oder ein normales Array, das nur Doppelwerte enthält) verwenden, können Sie dieses Problem vollständig vermeiden, da der Speicherplatz für die Gleitkommazahl mit doppelter Genauigkeit nur einmal zugewiesen wird und für die wiederholte Änderung des Werts kein neuer Speicherplatz zugewiesen werden muss.

Verdächtiger 4 ist eine Möglichkeit.

Forensik

Die Ermittler haben jetzt zwei mögliche Verdächtige: das Speichern von Heap-Zahlen als Objekteigenschaften und arithmetische Berechnungen in nicht optimierten Funktionen. Es war an der Zeit, ins Labor zu gehen und endgültig festzustellen, wer der Schuldige war. HINWEIS: In diesem Abschnitt verwende ich eine Reproduktion des Problems, das im tatsächlichen Oz-Quellcode gefunden wurde. Diese Reproduktion ist um Größenordnungen kleiner als der ursprüngliche Code und daher leichter zu verstehen.

Test 1

Suche nach Verdächtigem 3 (Arithmetische Berechnung in nicht optimierten Funktionen). Die V8-JavaScript-Engine hat ein integriertes Protokollierungssystem, das Aufschluss über die Vorgänge im Hintergrund geben kann.

Wenn Chrome nicht gestartet wird, starten Sie Chrome mit den folgenden Flags:

--no-sandbox --js-flags="--prof --noprof-lazy --log-timer-events"

Wenn Sie Chrome dann vollständig schließen, wird im aktuellen Verzeichnis die Datei „v8.log“ erstellt.

Wenn Sie den Inhalt von „v8.log“ auswerten möchten, müssen Sie dieselbe Version von V8 herunterladen, die in Chrome verwendet wird (siehe „about:version“), und kompilieren.

Nachdem du Version 8 erfolgreich erstellt hast, kannst du das Protokoll mit dem Tick-Prozessor verarbeiten:

$ tools/linux-tick-processor /path/to/v8.log

Ersetzen Sie je nach Plattform „linux“ durch „mac“ oder „windows“. Dieses Tool muss in Version 8 im Quellverzeichnis der obersten Ebene ausgeführt werden.

Der Tick-Prozessor zeigt eine textbasierte Tabelle mit den JavaScript-Funktionen an, die die meisten Ticks hatten:

[JavaScript]:
ticks  total  nonlib   name
167   61.2%   61.2%  LazyCompile: *opt demo.js:12
 40   14.7%   14.7%  LazyCompile: unopt demo.js:20
 15    5.5%    5.5%  Stub: KeyedLoadElementStub
 13    4.8%    4.8%  Stub: BinaryOpStub_MUL_Alloc_Number+Smi
  6    2.2%    2.2%  Stub: BinaryOpStub_ADD_OverwriteRight_Number+Number
  4    1.5%    1.5%  Stub: KeyedStoreElementStub
  4    1.5%    1.5%  KeyedLoadIC:  {12}
  2    0.7%    0.7%  KeyedStoreIC:  {13}
  1    0.4%    0.4%  LazyCompile: ~main demo.js:30

Wie Sie sehen, hat demo.js drei Funktionen: opt, unopt und main. Optimierte Funktionen sind durch ein Sternchen (*) gekennzeichnet. Beachten Sie, dass die Funktion „opt“ optimiert und „unopt“ nicht optimiert ist.

Ein weiteres wichtiges Tool im V8-Detektivkoffer ist das plot-timer-event. Sie kann so ausgeführt werden:

$ tools/plot-timer-event /path/to/v8.log

Nach der Ausführung befindet sich im aktuellen Verzeichnis eine PNG-Datei namens timer-events.png. Wenn Sie es öffnen, sollte es in etwa so aussehen:

Timer-Ereignisse

Neben dem Diagramm unten werden die Daten in Zeilen angezeigt. Die X-Achse ist die Zeit (ms). Auf der linken Seite finden Sie Labels für die einzelnen Zeilen:

Y-Achse für Timer-Ereignisse

In der Zeile „V8.Execute“ ist bei jeder Profilmarkierung, an der V8 JavaScript-Code ausgeführt hat, eine schwarze vertikale Linie zu sehen. Bei V8.GCScavenger ist bei jeder Profilmarkierung, an der V8 eine neue Generation erfasst hat, eine blaue vertikale Linie zu sehen. Ähnlich verhält es sich mit den übrigen V8-Zuständen.

Eine der wichtigsten Zeilen ist „Ausgeführte Codeart“. Er ist grün, wenn optimierter Code ausgeführt wird, und rot-blau, wenn nicht optimierter Code ausgeführt wird. Der folgende Screenshot zeigt den Übergang von optimiertem zu nicht optimiertem und dann wieder zu optimiertem Code:

Ausgeführte Codeart

Im Idealfall sollte diese Linie durchgehend grün sein, was aber nicht sofort der Fall ist. Das bedeutet, dass Ihr Programm in einen optimierten stabilen Zustand übergegangen ist. Nicht optimierter Code wird immer langsamer ausgeführt als optimierter Code.

Wenn Sie so weit gekommen sind, sollten Sie wissen, dass Sie viel schneller arbeiten können, wenn Sie Ihre Anwendung so umstrukturieren, dass sie in der V8-Debug-Shell d8 ausgeführt werden kann. Mit d8 können Sie mit den Tools „Tick-Prozessor“ und „Plot-Timer-Ereignis“ schneller iterieren. Ein weiterer Nebeneffekt der Verwendung von d8 ist, dass sich das eigentliche Problem leichter isolieren lässt, wodurch die Menge an Rauschen in den Daten reduziert wird.

Im Diagramm der Timer-Ereignisse aus dem Oz-Quellcode ist ein Übergang von optimiertem zu nicht optimiertem Code zu sehen. Bei der Ausführung nicht optimierten Codes wurden viele neue Sammlungen der nächsten Generation ausgelöst, ähnlich wie im folgenden Screenshot (die Zeit wurde in der Mitte entfernt):

Diagramm für Timer-Ereignisse

Bei genauerem Hinsehen sehen Sie, dass die schwarzen Linien, die angeben, wann V8 JavaScript-Code ausführt, genau zu denselben Profil-Taktzeiten fehlen wie die Sammlungen der neuen Generation (blaue Linien). Das zeigt deutlich, dass das Script während der Garbage Collection pausiert wird.

Die Ausgabe des Tick-Prozessors aus dem Oz-Quellcode zeigt, dass die oberste Funktion (updateSprites) nicht optimiert wurde. Mit anderen Worten: Die Funktion, auf die das Programm am meisten Zeit verwendet hat, war ebenfalls nicht optimiert. Dies deutet stark darauf hin, dass Verdächtiger 3 der Täter ist. Die Quelle für updateSprites enthielt Schleifen, die so aussahen:

function updateSprites(dt) {
    for (var sprite in sprites) {
        sprite.position.x += 0.5 * dt;
        // 20 more lines of arithmetic computation.
    }
}

Da sie V8 gut kennen, erkannten sie sofort, dass das for-i-in-Loop-Konstrukt manchmal nicht von V8 optimiert wird. Wenn eine Funktion also ein for-i-in-Loop-Konstrukt enthält, wird sie möglicherweise nicht optimiert. Dies ist derzeit ein Sonderfall, der sich in Zukunft wahrscheinlich ändern wird. Das heißt, V8 wird dieses Schleifenkonstrukt möglicherweise eines Tages optimieren. Da wir keine V8-Experten sind und V8 nicht aus dem Effeff kennen, wie können wir herausfinden, warum updateSprites nicht optimiert wurde?

Test 2

Chrome mit diesem Flag ausführen:

--js-flags="--trace-deopt --trace-opt-verbose"

ein ausführliches Protokoll mit Daten zur Optimierung und Deaktivierung von Daten enthält. Bei der Suche nach „updateSprites“ in den Daten finden wir Folgendes:

[Optimierung für updateSprites deaktiviert, Grund: ForInStatement ist kein schneller Fall]

Wie die Detektive vermutet hatten, war das for-i-in-Loop-Konstrukt der Grund.

Fall abgeschlossen

Nachdem ich die Ursache für die nicht optimierte Funktion „updateSprites“ gefunden hatte, war die Lösung einfach: Ich habe die Berechnung in eine eigene Funktion verschoben:

function updateSprite(sprite, dt) {
    sprite.position.x += 0.5 * dt;
    // 20 more lines of arithmetic computation.
}

function updateSprites(dt) {
    for (var sprite in sprites) {
        updateSprite(sprite, dt);
    }
}

updateSprite wird optimiert, was zu deutlich weniger HeapNumber-Objekten und damit zu selteneren GC-Pausen führt. Sie können dies ganz einfach überprüfen, indem Sie dieselben Tests mit neuem Code ausführen. Der aufmerksame Leser wird feststellen, dass doppelte Zahlen weiterhin als Properties gespeichert werden. Wenn das Profilern ergibt, dass sich das lohnt, kann die Anzahl der erstellten Objekte durch Ändern von „position“ in ein Array von Doppelwerten oder ein typisiertes Datenarray weiter reduziert werden.

Epilog

Die Oz-Entwickler haben aber nicht nur das getan. Mit den Tools und Techniken, die ihnen die V8-Detektive zur Verfügung gestellt hatten, konnten sie einige weitere Funktionen finden, die in der Deoptimierungshölle feststeckten, und den Berechnungscode in untergeordnete Funktionen einbinden, die optimiert wurden. Dies führte zu einer noch besseren Leistung.

Also, leg los und löse einige Leistungsverbrechen!