Unabhängig davon, welche Art von Anwendung Sie entwickeln, ist es entscheidend für die Nutzerfreundlichkeit und den Erfolg der Anwendung, ihre Leistung zu optimieren und dafür zu sorgen, dass sie schnell geladen wird und reibungslose Interaktionen ermöglicht. Eine Möglichkeit dazu ist, die Aktivität einer Anwendung mit Profiling-Tools zu untersuchen, um zu sehen, was im Hintergrund passiert, während sie in einem bestimmten Zeitraum ausgeführt wird. Der Bereich „Leistung“ in den Entwicklertools ist ein hervorragendes Profiling-Tool zum Analysieren und Optimieren der Leistung von Webanwendungen. Wenn Ihre App in Chrome ausgeführt wird, erhalten Sie einen detaillierten visuellen Überblick darüber, was der Browser während der Ausführung Ihrer Anwendung tut. Wenn Sie diese Aktivität nachvollziehen, können Sie Muster, Engpässe und Leistungsschwerpunkte identifizieren, auf die Sie reagieren können, um die Leistung zu verbessern.
Im folgenden Beispiel wird die Verwendung des Bereichs Leistung beschrieben.
Profilierungsszenario einrichten und nachbilden
Vor Kurzem haben wir uns zum Ziel gesetzt, den Bereich Leistung zu optimieren. Insbesondere sollte es große Mengen an Leistungsdaten schneller laden. Das ist beispielsweise der Fall, wenn Sie Prozesse mit langer Laufzeit oder komplexe Prozesse profilieren oder Daten mit hoher Granularität erfassen. Dazu war es zuerst erforderlich, zu verstehen, wie die Anwendung funktioniert hat und warum sie so funktioniert hat. Dies wurde mithilfe eines Profiling-Tools erreicht.
Wie Sie vielleicht wissen, sind die Entwicklertools selbst eine Webanwendung. Daher kann sie mit dem Leistungssteuerfeld profiliert werden. Wenn Sie dieses Steuerfeld selbst analysieren möchten, können Sie die Entwicklertools öffnen und dann eine weitere Instanz der Entwicklertools öffnen, die daran angehängt ist. Bei Google wird diese Einrichtung als DevTools-on-DevTools bezeichnet.
Nachdem die Einrichtung abgeschlossen ist, muss das zu profilierende Szenario nachgebildet und aufgezeichnet werden. Um Verwirrung zu vermeiden, wird das ursprüngliche DevTools-Fenster als die erste DevTools-Instanz bezeichnet und das Fenster, in dem die erste Instanz untersucht wird, als die zweite DevTools-Instanz.

In der zweiten DevTools-Instanz wird der Bereich Leistung (im Folgenden als Leistungsbereich bezeichnet) verwendet, um das Szenario in der ersten DevTools-Instanz nachzubilden und ein Profil zu laden.
In der zweiten DevTools-Instanz wird eine Liveaufzeichnung gestartet, während in der ersten Instanz ein Profil aus einer Datei auf der Festplatte geladen wird. Eine große Datei wird geladen, um die Leistung bei der Verarbeitung großer Eingaben genau zu analysieren. Wenn beide Instanzen geladen sind, werden die Daten für das Leistungsprofiling – in der Regel als Trace bezeichnet – in der zweiten DevTools-Instanz des Leistungsbereichs angezeigt, in der ein Profil geladen wird.
Ausgangssituation: Verbesserungsmöglichkeiten ermitteln
Nachdem das Laden abgeschlossen ist, wird im nächsten Screenshot Folgendes in unserer zweiten Leistungsübersicht beobachtet. Konzentrieren Sie sich auf die Aktivität des Haupt-Threads, die unter dem Track mit der Bezeichnung Main (Haupt) zu sehen ist. Im Flammenchart sind fünf große Aktivitätsgruppen zu sehen. Sie bestehen aus den Aufgaben, bei denen das Laden am längsten dauert. Die Gesamtzeit für diese Aufgaben betrug etwa 10 Sekunden. Im folgenden Screenshot wird das Leistungsfeld verwendet, um sich auf jede dieser Aktivitätsgruppen zu konzentrieren und zu sehen, was sich darin finden lässt.

Erste Aktivitätsgruppe: unnötige Arbeit
Es stellte sich heraus, dass die erste Gruppe von Aktivitäten Legacy-Code war, der immer noch ausgeführt wurde, aber nicht wirklich benötigt wurde. Im Grunde war alles unter dem grünen Block mit der Bezeichnung processThreadEvents
verschwendete Mühe. Das war ein schneller Erfolg. Durch das Entfernen dieses Funktionsaufrufs konnten etwa 1,5 Sekunden eingespart werden. Nicht schlecht, oder?
Zweite Aktivitätsgruppe
In der zweiten Aktivitätsgruppe war die Lösung nicht so einfach wie in der ersten. Die buildProfileCalls
hat etwa 0, 5 Sekunden gedauert und konnte nicht vermieden werden.

Aus Neugier haben wir die Option Arbeitsspeicher im Leistungsbereich aktiviert, um das Problem genauer zu untersuchen. Dabei haben wir festgestellt, dass auch die buildProfileCalls
-Aktivität viel Arbeitsspeicher verwendet. Hier sehen Sie, wie die blaue Linie im Diagramm um den Zeitpunkt der Ausführung von buildProfileCalls
herum plötzlich ansteigt. Das deutet auf ein potenzielles Speicherleck hin.

Um diesem Verdacht nachzugehen, haben wir das Memory-Panel (ein weiteres Panel in DevTools, das sich vom Memory-Drawer im Leistungs-Panel unterscheidet) verwendet. Im Bereich „Arbeitsspeicher“ wurde der Profiltyp „Zuweisungs-Sampling“ ausgewählt, mit dem der Heap-Snapshot für das Laden des CPU-Profils im Leistungsbereich aufgezeichnet wurde.

Der folgende Screenshot zeigt den erfassten Heap-Snapshot.

Aus diesem Heap-Snapshot geht hervor, dass die Klasse Set
viel Speicher belegt. Bei der Überprüfung der Aufrufstellen wurde festgestellt, dass wir unnötigerweise Eigenschaften vom Typ Set
Objekten zugewiesen haben, die in großen Mengen erstellt wurden. Diese Kosten summierten sich und es wurde viel Arbeitsspeicher verbraucht, sodass die Anwendung bei großen Eingaben häufig abstürzte.
Sets sind nützlich, um eindeutige Elemente zu speichern. Sie bieten Vorgänge, die die Eindeutigkeit ihrer Inhalte nutzen, z. B. zum Deduplizieren von Datasets und für effizientere Suchvorgänge. Diese Funktionen waren jedoch nicht erforderlich, da die gespeicherten Daten garantiert eindeutig waren. Daher waren Sets von Anfang an nicht erforderlich. Zur Verbesserung der Speicherzuweisung wurde der Attributtyp von Set
in ein einfaches Array geändert. Nachdem diese Änderung angewendet wurde, wurde ein weiterer Heap-Snapshot erstellt und es wurde eine geringere Speicherzuweisung beobachtet. Obwohl diese Änderung keine wesentlichen Geschwindigkeitsverbesserungen brachte, war der sekundäre Vorteil, dass die Anwendung seltener abstürzte.

Dritte Aktivitätsgruppe: Abwägen von Kompromissen bei der Datenstruktur
Der dritte Abschnitt ist ungewöhnlich: Im Flame-Diagramm sehen Sie schmale, aber hohe Spalten, die tiefe Funktionsaufrufe und in diesem Fall tiefe Rekursionen darstellen. Insgesamt dauerte dieser Abschnitt etwa 1, 4 Sekunden. Am unteren Rand dieses Abschnitts war zu sehen, dass die Breite dieser Spalten durch die Dauer einer Funktion bestimmt wurde: appendEventAtLevel
. Das deutete darauf hin, dass es sich um einen Engpass handeln könnte.
Bei der Implementierung der appendEventAtLevel
-Funktion ist uns etwas aufgefallen. Für jeden einzelnen Dateneintrag in der Eingabe (im Code als „event“ bezeichnet) wurde ein Element in eine Karte eingefügt, in der die vertikale Position der Zeitachseneinträge erfasst wurde. Das war problematisch, weil die Anzahl der gespeicherten Elemente sehr groß war. Karten sind schnell für schlüsselbasierte Suchvorgänge, aber dieser Vorteil ist nicht kostenlos. Wenn eine Karte größer wird, kann das Hinzufügen von Daten beispielsweise aufgrund von Rehashing teuer werden. Diese Kosten machen sich bemerkbar, wenn der Karte nacheinander eine große Anzahl von Elementen hinzugefügt wird.
/**
* Adds an event to the flame chart data at a defined vertical level.
*/
function appendEventAtLevel (event, level) {
// ...
const index = data.length;
data.push(event);
this.indexForEventMap.set(event, index);
// ...
}
Wir haben einen anderen Ansatz ausprobiert, bei dem wir nicht für jeden Eintrag im Flammenchart ein Element auf einer Karte hinzufügen mussten. Die Verbesserung war erheblich, was bestätigt, dass der Engpass tatsächlich mit dem Overhead zusammenhing, der durch das Hinzufügen aller Daten zur Karte entstanden ist. Die Dauer der Aktivitätsgruppe sank von etwa 1,4 Sekunden auf etwa 200 Millisekunden.
Vorher

Nachher

Vierte Aktivitätsgruppe: Nicht kritische Aufgaben aufschieben und Daten im Cache speichern, um doppelte Arbeit zu vermeiden
Wenn wir uns dieses Fenster genauer ansehen, erkennen wir zwei fast identische Blöcke mit Funktionsaufrufen. Anhand der Namen der aufgerufenen Funktionen lässt sich ableiten, dass diese Blöcke aus Code bestehen, mit dem Bäume erstellt werden (z. B. mit Namen wie refreshTree
oder buildChildren
). Tatsächlich wird mit dem zugehörigen Code die Baumansicht im unteren Bereich des Felds erstellt. Interessant ist, dass diese Baumansichten nicht direkt nach dem Laden angezeigt werden. Stattdessen muss der Nutzer eine Baumansicht auswählen (die Tabs „Bottom-up“, „Aufrufbaum“ und „Ereignisprotokoll“ in der Seitenleiste), damit die Bäume angezeigt werden. Außerdem wurde der Prozess zum Erstellen des Baums, wie im Screenshot zu sehen ist, zweimal ausgeführt.

Wir haben zwei Probleme mit diesem Bild festgestellt:
- Eine nicht kritische Aufgabe hat die Leistung der Ladezeit beeinträchtigt. Nutzer benötigen die Ausgabe nicht immer. Daher ist die Aufgabe nicht kritisch für das Laden des Profils.
- Das Ergebnis dieser Aufgaben wurde nicht im Cache gespeichert. Deshalb wurden die Bäume zweimal berechnet, obwohl sich die Daten nicht geändert haben.
Zuerst haben wir die Berechnung des Baums auf den Zeitpunkt verschoben, an dem der Nutzer die Baumansicht manuell geöffnet hat. Erst dann lohnt es sich, den Aufwand für das Erstellen dieser Bäume in Kauf zu nehmen. Die Gesamtdauer für die beiden Ausführungen betrug etwa 3, 4 Sekunden.Durch das Aufschieben konnte die Ladezeit also deutlich verkürzt werden. Wir prüfen derzeit, ob wir auch diese Arten von Aufgaben zwischenspeichern können.
Fünfte Aktivitätsgruppe: Vermeiden Sie nach Möglichkeit komplexe Anrufhierarchien.
Bei genauerer Betrachtung dieser Gruppe stellte sich heraus, dass eine bestimmte Aufrufsequenz wiederholt aufgerufen wurde. Dasselbe Muster trat sechsmal an verschiedenen Stellen im Flame-Diagramm auf und die Gesamtdauer dieses Zeitfensters betrug etwa 2, 4 Sekunden.

Der zugehörige Code, der mehrmals aufgerufen wird, ist der Teil, der die Daten verarbeitet, die in der „Minimap“ (der Übersicht der Zeitachsenaktivität oben im Bereich) gerendert werden sollen. Es war nicht klar, warum das mehrmals passiert ist, aber es hätte sicherlich nicht sechsmal passieren müssen. Die Ausgabe des Codes sollte aktuell bleiben, wenn kein anderes Profil geladen wird. Theoretisch sollte der Code nur einmal ausgeführt werden.
Bei der Untersuchung wurde festgestellt, dass der zugehörige Code aufgerufen wurde, weil mehrere Teile der Ladepipeline die Funktion, die die Minimap berechnet, direkt oder indirekt aufgerufen haben. Das liegt daran, dass die Komplexität des Aufrufgraphen des Programms im Laufe der Zeit zugenommen hat und unwissentlich weitere Abhängigkeiten zu diesem Code hinzugefügt wurden. Für dieses Problem gibt es keine schnelle Lösung. Die Lösung hängt von der Architektur des betreffenden Quellcodes ab. In unserem Fall mussten wir die Komplexität der Aufrufhierarchie etwas reduzieren und eine Prüfung hinzufügen, um die Ausführung des Codes zu verhindern, wenn die Eingabedaten unverändert blieben. Nach der Implementierung sieht die Zeitachse so aus:

Die Ausführung des Minimap-Renderings erfolgt zweimal, nicht nur einmal. Das liegt daran, dass für jedes Profil zwei Minimaps gezeichnet werden: eine für die Übersicht oben im Bereich und eine für das Drop-down-Menü, in dem das aktuell sichtbare Profil aus dem Verlauf ausgewählt wird. Jeder Eintrag in diesem Menü enthält eine Übersicht des Profils, das er auswählt. Trotzdem haben beide genau denselben Inhalt, sodass einer für den anderen wiederverwendet werden kann.
Da es sich bei diesen Minimaps um Bilder handelt, die auf einem Canvas gezeichnet werden, musste nur das drawImage
Canvas-Utility verwendet und der Code nur einmal ausgeführt werden, um Zeit zu sparen. Dadurch konnte die Dauer der Gruppe von 2, 4 Sekunden auf 140 Millisekunden reduziert werden.
Fazit
Nachdem wir alle diese Korrekturen (und einige kleinere hier und da) vorgenommen hatten, sah die Änderung der Zeitachse für das Laden des Profils so aus:
Vorher

Nachher

Die Ladezeit nach den Verbesserungen betrug 2 Sekunden. Das bedeutet, dass eine Verbesserung von etwa 80% mit relativ geringem Aufwand erreicht wurde, da die meisten Änderungen aus schnellen Korrekturen bestanden. Natürlich war es wichtig, was zu tun war, richtig zu identifizieren. Das Leistungsfeld war das richtige Tool dafür.
Es ist auch wichtig zu betonen, dass diese Zahlen nur für ein Profil gelten, das als Studienobjekt verwendet wird. Das Profil war für uns interessant, weil es besonders groß war. Da die Verarbeitungspipeline jedoch für jedes Profil gleich ist, gilt die erhebliche Verbesserung für jedes Profil, das im Leistungsbereich geladen wird.
Fazit
Aus diesen Ergebnissen lassen sich einige Erkenntnisse zur Leistungsoptimierung Ihrer Anwendung ableiten:
1. Profilierungstools verwenden, um Laufzeitleistungsmuster zu erkennen
Profiling-Tools sind sehr nützlich, um zu verstehen, was in Ihrer Anwendung passiert, während sie ausgeführt wird. Das ist besonders wichtig, um Möglichkeiten zur Leistungssteigerung zu erkennen. Das Performance-Panel in den Chrome-Entwicklertools ist eine gute Option für Webanwendungen, da es sich um das native Webprofiling-Tool im Browser handelt und es aktiv gewartet wird, um mit den neuesten Webplattformfunktionen auf dem neuesten Stand zu sein. Außerdem ist die Funktion jetzt deutlich schneller. 😉
Verwenden Sie Beispiele, die als repräsentative Arbeitslasten verwendet werden können, und sehen Sie, was Sie finden.
2. Komplexe Anrufhierarchien vermeiden
Vermeiden Sie nach Möglichkeit, dass Ihr Aufrufdiagramm zu kompliziert wird. Bei komplexen Aufrufhierarchien lassen sich leicht Leistungsregressionen einführen. Außerdem ist es schwer nachzuvollziehen, warum Ihr Code so ausgeführt wird, wie er ausgeführt wird. Das macht es schwierig, Verbesserungen zu erzielen.
3. Unnötige Arbeit identifizieren
Es ist üblich, dass ältere Codebases Code enthalten, der nicht mehr benötigt wird. In unserem Fall nahm Legacy- und unnötiger Code einen erheblichen Teil der gesamten Ladezeit in Anspruch. Das Entfernen war die einfachste Lösung.
4. Datenstrukturen richtig verwenden
Verwenden Sie Datenstrukturen, um die Leistung zu optimieren. Berücksichtigen Sie aber auch die Kosten und Kompromisse, die mit den einzelnen Arten von Datenstrukturen verbunden sind, wenn Sie entscheiden, welche Sie verwenden möchten. Dabei geht es nicht nur um die Speicherkomplexität der Datenstruktur selbst, sondern auch um die Zeitkomplexität der anwendbaren Operationen.
5. Ergebnisse im Cache speichern, um doppelte Arbeit bei komplexen oder sich wiederholenden Vorgängen zu vermeiden
Wenn die Ausführung des Vorgangs kostspielig ist, empfiehlt es sich, die Ergebnisse für das nächste Mal zu speichern. Das ist auch sinnvoll, wenn der Vorgang oft ausgeführt wird, auch wenn die einzelnen Ausführungen nicht besonders kostspielig sind.
6. Nicht kritische Aufgaben verschieben
Wenn die Ausgabe einer Aufgabe nicht sofort benötigt wird und die Ausführung der Aufgabe den kritischen Pfad verlängert, sollten Sie sie verzögern, indem Sie sie erst aufrufen, wenn ihre Ausgabe tatsächlich benötigt wird.
7. Effiziente Algorithmen für große Eingaben verwenden
Bei großen Eingaben sind Algorithmen mit optimaler Zeitkomplexität entscheidend. In diesem Beispiel haben wir uns diese Kategorie nicht angesehen, aber ihre Bedeutung kann kaum überschätzt werden.
8. Bonus: Pipelines vergleichen
Damit Ihr sich entwickelnder Code schnell bleibt, ist es ratsam, das Verhalten zu beobachten und mit Standards zu vergleichen. So können Sie Regressionen proaktiv erkennen und die allgemeine Zuverlässigkeit verbessern, was Ihnen langfristig zum Erfolg verhilft.