Ein um 400% schnelleres Leistungspanel durch bessere Performance

Andrés Olivares
Andrés Olivares
Nancy Li
Nancy Li

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.

Screenshot einer Entwicklertools-Instanz, in der die Elemente in den Entwicklertools selbst untersucht werden.
Entwicklertools in Entwicklertools: Entwicklertools mit den Entwicklertools untersuchen.

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.

Ein Screenshot des Leistungsbereichs in den Entwicklertools, in dem das Laden eines Leistungstraces im Leistungsbereich einer anderen Entwicklertools-Instanz untersucht wird. Das Laden des Profils dauert etwa 10 Sekunden. Diese Zeit ist hauptsächlich auf fünf Hauptaktivitätsgruppen aufgeteilt.

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.

Ein Screenshot des Leistungsbereichs in den Entwicklertools, in dem eine andere Instanz des Leistungsbereichs untersucht wird. Eine Aufgabe, die mit der Funktion „buildProfileCalls“ verknüpft ist, dauert etwa 0,5 Sekunden.

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.

Screenshot des Speicherprofilers in den Entwicklertools, der den Speicherverbrauch des Leistungsbereichs bewertet. Der Inspector weist darauf hin, dass die Funktion „buildProfileCalls“ für ein Speicherleck verantwortlich ist.

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.

Ein Screenshot des Ausgangszustands des Memory Profiler. Die Option „Zuweisungs-Sampling“ ist rot umrandet. Sie ist am besten für das Profiling des JavaScript-Arbeitsspeichers geeignet.

Der folgende Screenshot zeigt den erfassten Heap-Snapshot.

Screenshot des Memory Profiler mit einem speicherintensiven Set-basierten Vorgang

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.

Ein Screenshot des Memory Profiler. Der zuvor speicherintensive Set-basierte Vorgang wurde so geändert, dass er ein einfaches Array verwendet. Dadurch wurden die Speicherkosten erheblich gesenkt.

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

Screenshot des Leistungsbereichs, bevor Optimierungen an der Funktion „appendEventAtLevel“ vorgenommen wurden. Die Gesamtdauer für die Ausführung der Funktion betrug 1.372,51 Millisekunden.

Nachher

Ein Screenshot des Leistungsbereichs nach Optimierungen an der Funktion „appendEventAtLevel“. Die Gesamtlaufzeit der Funktion betrug 207,2 Millisekunden.

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.

Screenshot des Leistungsbereichs mit mehreren sich wiederholenden Aufgaben, die auch dann ausgeführt werden, wenn sie nicht erforderlich sind. Diese Aufgaben könnten auf Abruf statt im Voraus ausgeführt werden.

Wir haben zwei Probleme mit diesem Bild festgestellt:

  1. 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.
  2. 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.

Ein Screenshot des Leistungsbereichs mit sechs separaten Funktionsaufrufen zum Generieren derselben Trace-Minimap, die jeweils tiefe Aufrufstacks haben.

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:

Ein Screenshot des Leistungsbereichs, in dem die sechs separaten Funktionsaufrufe zum Generieren derselben Trace-Minimap auf nur zwei reduziert wurden.

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

Ein Screenshot des Leistungsbereichs, der das Laden von Traces vor der Optimierung zeigt. Der Vorgang dauerte etwa zehn Sekunden.

Nachher

Screenshot des Leistungsbereichs mit dem Laden von Traces nach Optimierungen. Der Vorgang dauert jetzt etwa zwei Sekunden.

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.