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

John McCutchan
John McCutchan

Einleitung

In den letzten Jahren wurden Webanwendungen erheblich beschleunigt. Viele Anwendungen werden inzwischen so schnell ausgeführt, dass sich manche Entwickler oft gefragt haben: „Ist das Web schnell genug?“. Für einige Anwendungen mag das der Fall sein, aber für Entwickler, die an Hochleistungsanwendungen arbeiten, wissen wir, dass es nicht schnell genug ist. Trotz der erstaunlichen Fortschritte bei der JavaScript-VM-Technologie hat eine aktuelle Studie gezeigt, dass Google-Anwendungen zwischen 50 und 70 % ihrer Zeit in V8 verbringen. Ihre Anwendung hat einen begrenzten Zeitraum. Durch Rasierzyklen kann ein anderes System mehr erledigen. Denken Sie daran: Anwendungen, die mit 60 fps ausgeführt werden, haben nur 16 ms pro Frame oder eine Verzögerung. Lesen Sie weiter, um mehr über die Optimierung von JavaScript-Anwendungen und die Profilerstellung für JavaScript-Anwendungen zu erfahren. Lesen Sie in Find Your Way to Oz (Der Weg nach Oz) ein schwindelerregendes Leistungsproblem.

Google I/O Session 2013

Dieses Material habe ich auf der Google I/O 2013 vorgestellt. Sehen Sie sich das folgende Video an:

Warum ist Leistung wichtig?

CPU-Zyklen sind ein Null-Sum-Spiel. Wenn Sie einen Teil Ihres Systems weniger nutzen, können Sie in einem anderen mehr verbrauchen oder insgesamt reibungsloser laufen. Ein schnellerer Betrieb und eine höhere Ausführungsrate sind oftmals konkurrierende Ziele. Nutzer wünschen sich neue Funktionen und erwarten zugleich, dass Ihre Anwendung reibungsloser läuft. Virtuelle JavaScript-Maschinen werden immer schneller. Das ist jedoch kein Grund dafür, Leistungsprobleme zu ignorieren, die Sie heute beheben können, wie die vielen Entwickler, die mit Leistungsproblemen in ihren Webanwendungen zu tun haben, bereits wissen. Bei Echtzeit und hoher Framerate ist der Druck, Unterbrechungen zu vermeiden, am wichtigsten. Eine Studie von Insomniac Games ergab, dass eine solide, anhaltende Framerate wichtig für den Erfolg eines Spiels ist: „Eine solide Framerate ist immer noch ein Zeichen für professionelles, gut gemachtes Produkt.“ Webentwickler:

Lösen von Leistungsproblemen

Die Lösung eines Leistungsproblems ist wie die Aufklärung eines Verbrechens. Sie müssen die Beweise sorgfältig prüfen, mögliche Ursachen prüfen und mit verschiedenen Lösungen experimentieren. Währenddessen musst du deine Messungen dokumentieren, damit du sicher sein kannst, dass du das Problem tatsächlich behoben hast. Es gibt kaum einen Unterschied zwischen dieser Methode und der Art, wie Kriminelle einen Fall lösen. Detektive untersuchen Beweise, verfragen Verdächtige und führen Experimente durch, um die rauchende Waffe zu finden.

V8 CSI: Oz

Die faszinierenden Zauberer, die den Bau von Find Your Way to Oz erschaffen, griff das V8-Team auf ein Leistungsproblem zu, das es nicht alleine lösen konnte. Gelegentlich frierte Oz ein, was zu Verzögerungen führte. Die Oz-Entwickler hatten erste Untersuchungen über den Bereich „Zeitachse“ in den Chrome-Entwicklertools durchgeführt. Bei der Arbeitsspeichernutzung haben sie die gefürchtete Sägezahngrafik entdeckt. Einmal pro Sekunde sammelte die automatische Speicherbereinigung 10 MB an Speicher und die Pausen der automatischen Speicherbereinigung entsprachen der Verzögerung. Ähnlich wie der folgende Screenshot aus der Zeitachse in den Chrome-Entwicklertools:

Zeitachse der Entwicklertools

Die V8-Detectives, Jakob und Yang, nahmen den Fall auf. Es fand ein langes Hin- und Herwechseln zwischen Jakob und Yang vom V8-Team und dem Oz-Team statt. Ich habe diese Unterhaltung auf die wichtigen Ereignisse reduziert, die zur Aufdeckung des Problems beigetragen haben.

Belege

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

Um welche Art von Anwendung geht es?

Die Oz-Demo ist eine interaktive 3D-Anwendung. Aus diesem Grund ist sie sehr empfindlich auf Pausen, die durch automatische Speicherbereinigung verursacht werden. Denken Sie daran, dass eine interaktive Anwendung, die mit 60 fps ausgeführt wird, 16 ms für die gesamte JavaScript-Arbeit zur Verfügung steht und etwas Zeit einräumen muss, damit Chrome die Grafikaufrufe verarbeitet und den Bildschirm zeichnen kann.

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

Welche Art von Leistungsproblem sehen wir?

Wir sehen Pausen, also Frame-Drops, also Verzögerungen. Diese Pausen stehen in Zusammenhang mit Ausführungen der automatischen Speicherbereinigung.

Befolgen die Entwickler Best Practices?

Ja, die Oz-Entwickler sind bestens mit JavaScript-VM-Leistung und -Optimierungstechniken vertraut. Die Oz-Entwickler haben CoffeeScript als Ausgangssprache verwendet und mit dem CoffeeScript-Compiler JavaScript-Code erstellt. Dies machte die Untersuchung etwas komplizierter, da die Verbindung zwischen dem von den Oz-Entwicklern geschriebenen Code und dem Code, der von V8 verarbeitet wurde, nicht funktionierte. Die Chrome-Entwicklertools unterstützen jetzt Quellzuordnungen, was den Vorgang vereinfacht hätte.

Warum wird die automatische Speicherbereinigung ausgeführt?

Der Arbeitsspeicher in JavaScript wird automatisch von der VM für den Entwickler verwaltet. V8 verwendet ein gemeinsames System für die automatische Speicherbereinigung, bei dem der Arbeitsspeicher in zwei (oder mehr) generations unterteilt ist. Die junge Generation hält Objekte, die kürzlich zugewiesen wurden. Wenn ein Objekt lange genug überlebt, wird es in die alte Generation verschoben.

Die junge Generation wird viel häufiger geholt als die alte. Das liegt daran, dass die Sammlung von jungen Generationen viel günstiger ist. Es kann davon ausgegangen werden, dass häufige GC-Pausen durch die Erfassung junger Generationen verursacht werden.

In V8 ist der Bereich des jungen Arbeitsspeichers in zwei gleich große zusammenhängende Speicherblöcke unterteilt. Es wird immer nur einer dieser beiden Speicherblöcke verwendet. Er wird als Raum bezeichnet. Es ist zwar noch genügend Arbeitsspeicher vorhanden, aber die Zuweisung eines neuen Objekts ist kostengünstig. Ein Cursor in den Bereich in den Bereich wird die für das neue Objekt erforderliche Anzahl von Byte nach vorne verschoben. Dies wird so lange fortgesetzt, bis der Zielraum erschöpft ist. An diesem Punkt wird das Programm beendet und die Erfassung beginnt.

V8 – junge Erinnerung

An dieser Stelle werden die Elemente vom Raum in den Bereich verschoben. Was war das Weltraumforschungsprojekt (und nun das vom Weltall) wird von Anfang bis Ende gescannt und Objekte, die noch am Leben sind, werden in den Weltraum kopiert oder werden auf den Heap der alten Generation übertragen. Weitere Informationen finden Sie unter Cheney-Algorithmus.

Intuitiv sollten Sie verstehen, dass sich Ihre Anwendung jedes Mal, wenn ein Objekt implizit oder explizit (über einen Aufruf von new, [] oder {}) zugewiesen wird, der Speicherbereinigung und der gefürchteten Unterbrechung der Anwendung immer näher annähert.

Sind 10 MB/s Speicherkapazität für diese Anwendung erwartet?

Kurz gesagt, nein. Der Entwickler tut nichts, um 10 MB/s an Speicherkapazität zu erwarten.

Verdächtige

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

Verdächtiger Nr. 1

Im Frame wird eine neue Person angerufen. Denken Sie daran, dass Sie durch jedes zugewiesene Objekt einer GC-Pause immer näher kommen. Insbesondere bei Anwendungen, die mit hohen Framerates ausgeführt werden, sollte keine Zuweisung pro Frame anstreben. Dazu ist in der Regel ein sorgfältig durchdachtes, anwendungsspezifisches Recyclingsystem für Objekte erforderlich. Die V8-Detectives haben sich mit dem Oz-Team unterhalten und es wurde nicht als neu bezeichnet. Das Oz-Team war sich dieser Anforderung bereits bewusst und sagte: „Das wäre peinlich.“ Rubbeln Sie diese Frage von der Liste ab.

Verdächtiger Nr. 2

Die „Form“ eines Objekts außerhalb des Konstruktors ändern Dies geschieht immer dann, wenn einem Objekt außerhalb des Konstruktors eine neue Eigenschaft hinzugefügt wird. Dadurch wird eine neue versteckte Klasse für das Objekt erstellt. Wenn der optimierte Code diese neue versteckte Klasse erkennt, wird eine Deaktivierung ausgelöst. Nicht optimierter Code wird ausgeführt, bis der Code wieder als Hot und optimiert klassifiziert wird. Diese Abwanderung durch De-Optimierung und erneute Optimierung führt zu Verzögerungen,hängt aber nicht direkt mit einer übermäßigen Speicherbereinigung zusammen. Nach einer sorgfältigen Prüfung des Codes stellte sich heraus, dass die Objektformen statisch waren, sodass Verdächtiger Nr. 2 ausgeschlossen wurde.

Verdächtiger Nr. 3

Arithmetik in nicht optimierter Code In nicht optimiertem Code werden bei allen Berechnungen tatsächliche Objekte zugewiesen. Beispiel:

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

Führt dazu, dass 5 HeapNumber-Objekte erstellt werden. Die ersten drei sind für die Variablen a, b und c. Der vierte Wert steht für den anonymen Wert (a * b) und der fünfte stammt aus Nr. 4 * c; der fünfte wird schließlich Punkt.x zugewiesen.

Oz führt Tausende dieser Vorgänge pro Frame aus. Wenn eine dieser Berechnungen in Funktionen erfolgt, die nie optimiert wurden, könnte dies die Ursache des Speichers sein. Weil Berechnungen in nicht optimiertem Arbeitsspeicher auch für temporäre Ergebnisse Arbeitsspeicher zuweisen.

Verdächtiger Nr. 4

Eine Zahl mit doppelter Genauigkeit für eine Eigenschaft speichern Es muss ein HeapNumber-Objekt erstellt werden, um die Nummer und die Eigenschaft zu speichern, die auf dieses neue Objekt verweist. Wenn Sie das Attribut so ändern, dass es auf die HeapNumber verweist, wird kein Speicher gelöscht. Es ist jedoch möglich, dass viele Zahlen mit doppelter Genauigkeit als Objekteigenschaften gespeichert werden. Der Code enthält folgende Anweisungen:

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

Im optimierten Code wird jedes Mal, wenn x ein neu berechneter Wert – eine scheinbar harmlose Anweisung – zugewiesen wird, implizit ein neues HeapNumber-Objekt zugewiesen, wodurch wir einer Pause bei der automatischen Speicherbereinigung näher kommen.

Wenn Sie ein typisiertes Array (oder ein reguläres Array, das nur doppelte Werte enthält) verwenden, können Sie dieses Problem ganz vermeiden, da der Speicher für die Zahl mit doppelter Genauigkeit nur einmal zugewiesen wird und bei wiederholten Änderungen des Werts kein neuer Speicher zugewiesen werden muss.

Verdächtiger Nr. 4 ist eine Möglichkeit.

Forensik

An dieser Stelle haben die Detektive zwei mögliche Verdächtige: das Speichern von Heap-Zahlen als Objekteigenschaften und die arithmetische Berechnung innerhalb nicht optimierter Funktionen. Es war an der Zeit, ins Labor zu gehen und eindeutig festzustellen, welcher Verdächtiger schuldig war. HINWEIS: In diesem Abschnitt werde ich eine Reproduktion des Problems aus dem eigentlichen Oz-Quellcode verwenden. Diese Reproduktion ist um Größenordnungen kleiner als der ursprüngliche Code und daher leichter verständlich.

Test 1

Überprüfung auf verdächtige Person Nr. 3 (arithmetische Berechnung in nicht optimierten Funktionen). Die V8 JavaScript-Engine verfügt über ein integriertes Protokollierungssystem, das einen guten Einblick in die Abläufe im Hintergrund bietet.

Wenn Chrome überhaupt nicht ausgeführt wird, starten Sie Chrome mit folgenden Flags:

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

Wenn Sie Chrome vollständig beenden, wird im aktuellen Verzeichnis die Datei v8.log erstellt.

Damit der Inhalt von v8.log interpretiert werden kann, müssen Sie die Version von v8, die auch in Chrome verwendet wird, herunterladen (überprüfen about:version) und sie erstellen.

Nachdem Sie v8 erfolgreich erstellt haben, können Sie das Protokoll mit dem Tick-Prozessor verarbeiten:

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

(Ersetzen Sie Linux je nach Plattform 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 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 mit einem Sternchen (*) versehen. Beachten Sie, dass die Funktion „opt“ optimiert und „unopt“ nicht optimiert ist.

Ein weiteres wichtiges Werkzeug in der V8-Detektivtasche ist das Plot-Timer-Ereignis. Dies 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 mit dem Namen Timer-events.png. Nach dem Öffnen sollte Folgendes angezeigt werden:

Timer-Ereignisse

Abgesehen vom Diagramm am unteren Rand werden die Daten in Zeilen angezeigt. Die X-Achse ist die Zeit (ms). Die linke Seite enthält Beschriftungen für jede Zeile:

Timer-Ereignisse – Y-Achse

In der Zeile „V8.Execute“ ist bei jedem Profilstrich, wo V8 JavaScript-Code ausgeführt hat, eine schwarze vertikale Linie gezeichnet. V8.GCScavenger hat eine blaue vertikale Linie an jeder Profilmarkierung, an der V8 eine Sammlung der neuen Generation durchgeführt hat. Das Gleiche gilt für die übrigen V8-Zustände.

Eine der wichtigsten Zeilen ist die „ausgeführte Codeart“. Er ist grün, wenn optimierter Code ausgeführt wird, und eine Mischung aus Rot und Blau, wenn nicht optimierter Code ausgeführt wird. Der folgende Screenshot zeigt den Übergang von optimiertem zu nicht optimiertem Code wieder zurück zum optimierten Code:

Ausgeführte Codeart

Idealerweise, aber niemals sofort, ist diese Linie durchgehend grün. 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 viel Zeit haben, sollten Sie beachten, dass Sie viel schneller arbeiten können, indem Sie Ihre Anwendung so refaktorieren, dass sie in der Debug-Shell v8 ausgeführt wird: d8. Mit dem Tick-Prozessor und den Plot-Timer-Ereignis-Tools von d8 profitieren Sie von kürzeren Iterationszeiten. Ein weiterer Nebeneffekt der Verwendung von d8 besteht darin, dass es einfacher ist, tatsächliche Probleme zu isolieren, da so das Rauschen in den Daten reduziert wird.

Im Diagramm mit den Timer-Ereignissen aus dem Oz-Quellcode wurde ein Übergang von optimiertem zu nicht optimiertem Code gezeigt. Beim Ausführen von nicht optimiertem Code wurden viele Sammlungen der neuen Generation ausgelöst, ähnlich wie im folgenden Screenshot (die Notizzeit wurde in der Mitte entfernt):

Diagramm mit Timer-Ereignissen

Wenn Sie genau hinschauen, sehen Sie, dass die schwarzen Linien, die anzeigen, dass V8 JavaScript-Code ausführt, zu genau den gleichen Profil-Tickzeiten wie die Sammlungen der neuen Generation fehlen (blaue Linien). Das zeigt deutlich, dass das Skript während der Bereinigung pausiert wird.

Bei der Betrachtung der Tick-Prozessor-Ausgabe aus dem Oz-Quellcode war die Top-Funktion (updateSprites) nicht optimiert. Mit anderen Worten: Die Funktion, mit der das Programm die meiste Zeit verbracht hat, war ebenfalls nicht optimiert. Das weist eindeutig darauf hin, dass Verdächtiger Nr. 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 so gut kennen wie sie, erkannten sie sofort, dass das for-i-in-Schleifenkonstrukt manchmal nicht von V8 optimiert wird. Mit anderen Worten: Wenn eine Funktion ein for-i-in-Schleifenkonstrukt enthält, wird sie möglicherweise nicht optimiert. Dies ist heute ein Sonderfall und wird sich wahrscheinlich in Zukunft ändern, d. h., V8 wird dieses Schleifenkonstrukt eines Tages optimieren. Wir sind keine V8-Detectives und kennen V8 nicht wie unsere Handrücken. Wie können wir herausfinden, warum updateSprites nicht optimiert war?

Test 2

Wenn Sie Chrome mit diesem Flag ausführen:

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

zeigt ein ausführliches Protokoll der Optimierungs- und Deoptimierungsdaten an. Bei der Suche nach updateSprites finden wir Folgendes:

[Optimierung für updateSprites deaktiviert, Grund: ForInStatement ist nicht schnell]

Genau wie die Detektive die Hypothese aufgestellt hatten, war das Konstrukt der For-i-in-Schleife der Grund.

Fall abgeschlossen

Nachdem wir herausgefunden haben, dass „updateSprites“ nicht optimiert war, war die Problembehebung einfach. Verschieben Sie die Berechnung einfach in eine eigene Funktion, nämlich:

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 weitaus weniger HeapNumber-Objekten und selteneren GC-Pausen führt. Dies sollte sich leicht überprüfen lassen, indem Sie dieselben Tests mit neuem Code durchführen. Wichtig ist vor allem, dass doppelte Zahlen weiterhin als Eigenschaften gespeichert werden. Wenn sich die Profilerstellung lohnt, würde die Anzahl der zu erstellenden Objekte weiter reduziert werden, wenn die Position in ein Array mit Double-Werten oder in ein typisiertes Datenarray geändert wird.

Epilog

Die Oz-Entwickler haben damit noch nicht aufgehört. Mit den Tools und Techniken, die die V8-Detectives mit ihnen geteilt hatten, konnten sie noch einige andere Funktionen finden, die in der Hölle stecken geblieben sind, und den Berechnungscode in Blattfunktionen einkalkuliert, die optimiert wurden, was zu einer noch besseren Leistung führte.

Dann mal los! Löse ein paar Leistungskriminalität!