Einführung
In den letzten Jahren wurden Webanwendungen erheblich beschleunigt. Viele Anwendungen laufen mittlerweile so schnell, dass ich einige Entwickler laut fragen gehört habe: „Ist das Web schnell genug?“ Für einige Anwendungen mag das ausreichen, aber für Entwickler, die an Hochleistungsanwendungen arbeiten, ist das nicht schnell genug. Trotz der erstaunlichen Fortschritte bei der Technologie für virtuelle JavaScript-Maschinen hat eine aktuelle Studie gezeigt, dass Google-Anwendungen 50 % bis 70 % ihrer Zeit in V8 verbringen. Ihre Anwendung hat eine begrenzte Zeit. Wenn Sie Zyklen von einem System einsparen, kann ein anderes System mehr tun. Denken Sie daran, dass Anwendungen, die mit 60 fps ausgeführt werden, nur 16 ms pro Frame haben, da es sonst zu Rucklern kommt. In diesem Artikel erfahren Sie, wie Sie JavaScript optimieren und JavaScript-Anwendungen profilieren. Es handelt sich um eine Geschichte aus der Praxis, in der die Performance-Detektive des V8-Teams ein obskures Leistungsproblem in Find Your Way to Oz aufspüren.
Google I/O 2013-Sitzung
Ich habe dieses Material 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 ein Teil deines Systems weniger Ressourcen verbraucht, kannst du mehr Ressourcen für einen anderen Teil verwenden oder das System insgesamt flüssiger ausführen. Schneller arbeiten und mehr erledigen sind oft konkurrierende Ziele. Nutzer verlangen neue Funktionen, erwarten aber auch, dass Ihre Anwendung reibungsloser läuft. Virtuelle JavaScript-Maschinen werden immer schneller, aber das ist kein Grund, Leistungsprobleme zu ignorieren, die Sie heute beheben können. Das wissen die vielen Entwickler, die sich mit Leistungsproblemen in ihren Webanwendungen befassen, bereits. Bei Echtzeitanwendungen mit hoher Framerate ist es besonders wichtig, dass keine Ruckler auftreten. Insomniac Games hat eine Studie veröffentlicht, die zeigt, dass eine solide, anhaltende Framerate für den Erfolg eines Spiels wichtig ist: „Eine solide Framerate ist nach wie vor ein Zeichen für ein professionelles, gut gemachtes Produkt.“ Webentwickler aufgepasst!
Leistungsprobleme beheben
Das Lösen eines Leistungsproblems ist wie das Lösen eines Kriminalfalls. Sie müssen die Belege sorgfältig prüfen, vermutete Ursachen untersuchen und verschiedene Lösungen ausprobieren. Sie müssen Ihre Messungen während des gesamten Prozesses dokumentieren, damit Sie sicher sein können, dass Sie das Problem tatsächlich behoben haben. Diese Methode unterscheidet sich kaum von der, mit der Kriminalbeamte einen Fall lösen. Die Ermittler untersuchen Beweise, verhören Verdächtige und führen Experimente durch, um den entscheidenden Beweis zu finden.
V8 CSI: Oz
Die Entwickler von Find Your Way to Oz haben sich mit einem Leistungsproblem an das V8-Team gewandt, das sie nicht selbst lösen konnten. Gelegentlich ist Oz eingefroren, was zu Rucklern geführt hat. Die Oz-Entwickler hatten bereits erste Untersuchungen mit dem Bereich „Zeitachse“ in den Chrome-Entwicklertools durchgeführt. Bei der Analyse der Speichernutzung stießen sie auf das gefürchtete Sägezahndiagramm. Einmal pro Sekunde wurden 10 MB an Daten bereinigt und die Pausen für die Speicherbereinigung entsprachen dem Ruckeln. Ähnlich wie im folgenden Screenshot der Zeitachse in den Chrome-Entwicklertools:
Die V8-Detektive Jakob und Yang nahmen sich des Falls an. Es folgte ein langes Hin und Her zwischen Jakob und Yang vom V8-Team und dem Oz-Team. Ich habe diese Unterhaltung auf die wichtigen Ereignisse reduziert, die bei der Suche nach dem Problem geholfen haben.
Belege
Der erste Schritt besteht darin, die ersten Beweise zu sammeln und zu untersuchen.
Um welche Art von Anwendung handelt es sich?
Die Oz-Demo ist eine interaktive 3D-Anwendung. Daher reagiert er sehr empfindlich auf Pausen, die durch die automatische Speicherbereinigung verursacht werden. Denken Sie daran, dass eine interaktive Anwendung, die mit 60 fps ausgeführt wird, 16 ms Zeit hat, um alle JavaScript-Aufgaben zu erledigen. Ein Teil dieser Zeit muss für Chrome übrig bleiben, um die Grafikaufrufe zu verarbeiten und den Bildschirm zu zeichnen.
Oz führt viele arithmetische Berechnungen mit Double-Werten durch und ruft häufig WebAudio und WebGL auf.
Welche Art von Leistungsproblem tritt auf?
Wir sehen Pausen, auch bekannt als Frame-Drops oder Ruckeln. Diese Pausen korrelieren mit den Ausführungen der automatischen Speicherbereinigung.
Befolgen die Entwickler die Best Practices?
Ja, die Oz-Entwickler sind mit JavaScript-VM-Leistungs- und Optimierungstechniken vertraut. Die Oz-Entwickler haben CoffeeScript als Quellsprache verwendet und JavaScript-Code über den CoffeeScript-Compiler erstellt. Das machte die Untersuchung etwas schwieriger, da der von den Oz-Entwicklern geschriebene Code nicht mit dem von V8 verwendeten Code übereinstimmte. Die Chrome-Entwicklertools unterstützen jetzt Quellzuordnungen, was die Sache vereinfacht hätte.
Warum wird die Speicherbereinigung ausgeführt?
Der Arbeitsspeicher in JavaScript wird für den Entwickler automatisch von der VM verwaltet. V8 verwendet ein gängiges System zur automatischen Speicherbereinigung, bei dem der Arbeitsspeicher in zwei (oder mehr) Generationen unterteilt wird. Die junge Generation enthält Objekte, die vor Kurzem zugewiesen wurden. Wenn ein Objekt lange genug überlebt, wird es in die alte Generation verschoben.
Die junge Generation wird viel häufiger erfasst als die alte Generation. Dies ist beabsichtigt, da die Erfassung der jungen Generation viel günstiger ist. Es ist oft sicher, davon auszugehen, dass häufige GC-Pausen durch die Erfassung der jungen Generation verursacht werden.
In V8 ist der junge Speicherbereich in zwei gleich große zusammenhängende Speicherblöcke unterteilt. Es wird immer nur einer dieser beiden Speicherblöcke verwendet. Er wird als „to space“ bezeichnet. Solange im Zielbereich noch Speicherplatz vorhanden ist, ist das Zuweisen eines neuen Objekts kostengünstig. Ein Cursor im Zielbereich wird um die Anzahl der Bytes, die für das neue Objekt erforderlich sind, nach vorn verschoben. Dies wird fortgesetzt, bis der Zielbereich erschöpft ist. An diesem Punkt wird das Programm beendet und die Erfassung beginnt.
An diesem Punkt werden der Quell- und der Zielbereich vertauscht. Der Bereich, der der Zielbereich war und jetzt der Quellbereich ist, wird von Anfang bis Ende gescannt. Alle Objekte, die noch aktiv sind, werden in den Zielbereich kopiert oder in den Heap der alten Generation verschoben. Weitere Informationen finden Sie im Artikel zum Cheney-Algorithmus.
Sie sollten intuitiv verstehen, dass Ihre Anwendung jedes Mal, wenn ein Objekt entweder implizit oder explizit (über einen Aufruf von „new“, „[]“ oder „{}“) zugewiesen wird, einer automatischen Speicherbereinigung und der gefürchteten Anwendungspause näher kommt.
Sind 10 MB/s Garbage für diese Anwendung zu erwarten?
Kurz gesagt: Nein. Der Entwickler tut nichts, was 10 MB/s an Garbage erwarten lässt.
Verdächtige
In der nächsten Phase der Untersuchung werden potenzielle Verdächtige ermittelt und dann eingegrenzt.
Verdächtiger 1
Aufruf von „new“ während des Frames. Denken Sie daran, dass jedes zugewiesene Objekt Sie einer GC-Pause näher bringt. Insbesondere bei Anwendungen, die mit hohen Frameraten ausgeführt werden, sollte die Anzahl der Zuweisungen pro Frame null sein. Dazu ist in der Regel ein sorgfältig durchdachtes, anwendungsspezifisches Objektrecyclingsystem erforderlich. Die V8-Detektive haben beim Oz-Team nachgefragt und erfahren, dass „new“ nicht aufgerufen wurde. Tatsächlich war sich das Oz-Team dieser Anforderung bereits bewusst und sagte: „Das wäre peinlich.“ Diesen Punkt kannst du von deiner Liste streichen.
Verdächtiger 2
Ändern der „Form“ eines Objekts außerhalb des Konstruktors. 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 optimierter Code diese neue versteckte Klasse sieht, wird eine Deoptimierung ausgelöst. Nicht optimierter Code wird ausgeführt, bis der Code als „hot“ eingestuft und wieder optimiert wird. Diese Deoptimierung und erneute Optimierung führt zu Rucklern,korreliert aber nicht unbedingt mit übermäßiger Garbage-Erstellung. Nach einer sorgfältigen Prüfung des Codes wurde bestätigt, dass die Objektformen statisch waren. Verdächtiger Nr. 2 wurde also ausgeschlossen.
Verdächtiger Nr. 3
Arithmetische Operationen in nicht optimiertem Code. In nicht optimiertem Code führt jede Berechnung 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;
Es werden 5 HeapNumber-Objekte erstellt. Die ersten drei sind für die Variablen a, b und c. Der vierte ist für den anonymen Wert (a * b) und der fünfte ist von #4 * c. Der fünfte wird schließlich point.x zugewiesen.
Oz führt Tausende dieser Vorgänge pro Frame aus. Wenn eine dieser Berechnungen in Funktionen erfolgt, die nie optimiert werden, könnte dies die Ursache für den Garbage sein. Denn bei Berechnungen in nicht optimierten Abfragen wird auch für temporäre Ergebnisse Speicher zugewiesen.
Verdächtiger Nr. 4
Speichern einer Zahl mit doppelter Genauigkeit in einer Property. 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 Garbage erzeugt. Es ist jedoch möglich, dass viele Zahlen mit doppelter Genauigkeit als Objektattribute gespeichert werden. Der Code ist voller Anweisungen wie der folgenden:
sprite.position.x += 0.5 * (dt);
In optimiertem Code wird jedes Mal, wenn x ein neu berechneter Wert zugewiesen wird, implizit ein neues HeapNumber-Objekt zugewiesen. Das ist eine scheinbar harmlose Anweisung, die uns aber einer Garbage-Collection-Pause näher bringt.
Wenn Sie ein typisiertes Array (oder ein reguläres Array, das nur Double-Werte enthält) verwenden, können Sie dieses Problem vollständig vermeiden, da der Speicher für die Double-Precision-Zahl nur einmal zugewiesen wird und das wiederholte Ändern des Werts keine neue Speicherzuweisung erfordert.
Verdächtiger 4 ist eine Möglichkeit.
Forensik
An diesem Punkt haben die Detektive 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, welcher Verdächtige schuldig 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
Prüfung auf Verdächtigen Nr. 3 (arithmetische Berechnung innerhalb nicht optimierter Funktionen). Die V8-JavaScript-Engine verfügt über ein integriertes Logging-System, das einen guten Einblick in die Vorgänge im Hintergrund bietet.
Chrome wird nicht ausgeführt und mit den folgenden Flags gestartet:
--no-sandbox --js-flags="--prof --noprof-lazy --log-timer-events"
und dann Chrome vollständig beenden, wird im aktuellen Verzeichnis eine v8.log-Datei erstellt.
Um den Inhalt von v8.log zu interpretieren, müssen Sie die gleiche Version von v8 herunterladen, die Ihr Chrome verwendet (prüfen Sie 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 im Quellverzeichnis der obersten Ebene in v8 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
„demo.js“ hatte drei Funktionen: „opt“, „unopt“ und „main“. Optimierte Funktionen sind mit einem Sternchen (*) gekennzeichnet. Beachten Sie, dass die Funktion „opt“ optimiert und „unopt“ nicht optimiert ist.
Ein weiteres wichtiges Tool im Werkzeugkasten des V8-Detektivs ist „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 mit dem Namen „timer-events.png“. Wenn Sie sie öffnen, sollte sie in etwa so aussehen:
Abgesehen von der Grafik am unteren Rand werden die Daten in Zeilen angezeigt. Die X-Achse ist die Zeit (ms). Auf der linken Seite finden Sie Labels für jede Zeile:
In der Zeile „V8.Execute“ ist bei jedem Profil-Tick, bei dem V8 JavaScript-Code ausgeführt hat, eine schwarze vertikale Linie zu sehen. V8.GCScavenger hat bei jedem Profil-Tick, bei dem V8 eine neue Generation-Sammlung durchgeführt hat, eine blaue vertikale Linie. Ähnlich verhält es sich mit den anderen V8-Zuständen.
Eine der wichtigsten Zeilen ist „code kind being executed“ (Art des ausgeführten Codes). Sie ist grün, wenn optimierter Code ausgeführt wird, und rot und 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:
Im Idealfall ist diese Linie durchgehend grün, aber nie sofort. 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 gegangen sind, sollten Sie wissen, dass Sie viel schneller arbeiten können, wenn Sie Ihre Anwendung so refaktorieren, dass sie in der v8-Debug-Shell d8 ausgeführt werden kann. Mit d8 können Sie die Iterationszeiten mit den Tools „tick-processor“ und „plot-timer-event“ verkürzen. Ein weiterer Nebeneffekt der Verwendung von d8 ist, dass es einfacher wird, das eigentliche Problem zu isolieren, wodurch die Menge an Rauschen in den Daten reduziert wird.
Ein Blick auf das Diagramm der Timer-Ereignisse aus dem Oz-Quellcode zeigte einen Übergang von optimiertem zu nicht optimiertem Code. Während der Ausführung des nicht optimierten Codes wurden viele Sammlungen der neuen Generation ausgelöst, ähnlich wie im folgenden Screenshot (die Zeit in der Mitte wurde entfernt):
Wenn Sie genau hinsehen, erkennen Sie, dass die schwarzen Linien, die angeben, wann V8 JavaScript-Code ausführt, genau zu denselben Profil-Tick-Zeiten fehlen wie die Sammlungen der neuen Generation (blaue Linien). Daran ist deutlich zu erkennen, dass das Script während der Speicherbereinigung pausiert wird.
Wenn wir uns die Ausgabe des Tick-Prozessors aus dem Oz-Quellcode ansehen, sehen wir, dass die oberste Funktion (updateSprites) nicht optimiert wurde. Mit anderen Worten: Die Funktion, in der das Programm die meiste Zeit verbracht hat, war auch nicht optimiert. Das ist ein starker Hinweis darauf, 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 so gut kennen, haben sie sofort erkannt, dass das for-i-in-Schleifenkonstrukt von V8 manchmal nicht optimiert wird. Wenn eine Funktion ein „for-i-in“-Schleifenkonstrukt enthält, wird sie möglicherweise nicht optimiert. Dies ist heute ein Sonderfall und wird sich in Zukunft wahrscheinlich ändern. Das heißt, V8 könnte dieses Schleifenkonstrukt eines Tages optimieren. Da wir keine V8-Detektive sind und V8 nicht wie unsere Westentasche kennen, wie können wir feststellen, warum „updateSprites“ nicht optimiert wurde?
Test 2
Chrome mit diesem Flag ausführen:
--js-flags="--trace-deopt --trace-opt-verbose"
zeigt ein ausführliches Protokoll der Optimierungs- und Deoptimierungsdaten an. Wenn wir die Daten nach „updateSprites“ durchsuchen, finden wir Folgendes:
[disabled optimization for updateSprites, reason: ForInStatement is not fast case]
Wie die Detektive vermutet hatten, war die for-i-in-Schleife die Ursache.
Fall abgeschlossen
Nachdem der Grund für die fehlende Optimierung von updateSprites gefunden wurde, war die Lösung einfach: Die Berechnung musste nur in eine eigene Funktion verschoben werden, also:
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 damit zu selteneren Pausen bei der automatischen Speicherbereinigung führt. Sie können dies ganz einfach bestätigen, indem Sie dieselben Tests mit neuem Code durchführen. Aufmerksame Leser werden feststellen, dass Double-Zahlen weiterhin als Properties gespeichert werden. Wenn das Profiling ergibt, dass es sich lohnt, kann die Position in ein Array von Doubles oder ein typisiertes Datenarray geändert werden, um die Anzahl der erstellten Objekte weiter zu reduzieren.
Epilog
Die Oz-Entwickler haben sich damit aber nicht zufriedengegeben. Mit den Tools und Techniken, die sie von den V8-Detektiven erhalten hatten, konnten sie einige andere Funktionen finden, die in der Deoptimierungshölle feststeckten, und den Berechnungscode in optimierte Blattfunktionen auslagern, was zu einer noch besseren Leistung führte.
Also los, machen Sie sich auf die Suche nach Leistungsproblemen!