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 die Optimierung ihrer Leistung, die schnelle Ladegeschwindigkeit und reibungslose Interaktionen entscheidend für die User Experience und den Erfolg der Anwendung. Eine Möglichkeit, dies zu tun, besteht darin, die Aktivität einer Anwendung mithilfe von Profilerstellungstools zu überprüfen, um zu sehen, was im Hintergrund während eines Zeitfensters passiert. Der Bereich Leistung in den Entwicklertools ist ein hervorragendes Profilerstellungstool, mit dem Sie die Leistung von Webanwendungen analysieren und optimieren können. 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 verstehen, können Sie Muster, Engpässe und Leistungsschwerpunkte erkennen, auf die Sie reagieren können, um die Leistung zu verbessern.

Im folgenden Beispiel wird die Verwendung des Steuerfelds Leistung Schritt für Schritt beschrieben.

Profilerstellungsszenario einrichten und neu erstellen

Wir haben uns vor Kurzem das Ziel gesetzt, die Leistung des Steuerfelds Leistung zu verbessern. Insbesondere wollten wir große Mengen an Leistungsdaten schneller laden. Dies ist beispielsweise der Fall, wenn Sie Profile für lang andauernde oder komplexe Prozesse erstellen oder hochgradig detaillierte Daten erfassen. Dazu mussten wir zuerst ein Verständnis davon haben, wie die Anwendung leistet und warum sie auf diese Weise funktionierte. Dafür wurde ein Profilerstellungstool eingesetzt.

Wie Sie vielleicht wissen, handelt es sich bei DevTools selbst um eine Webanwendung. Daher kann es über das Steuerfeld Leistung erstellt werden. Wenn Sie ein Profil für diesen Bereich erstellen möchten, können Sie die Entwicklertools öffnen und dann eine weitere zugehörige Entwicklertools-Instanz öffnen. Bei Google heißt diese Einrichtung DevTools-on-DevTools.

Wenn die Einrichtung bereit ist, muss das Szenario, für das ein Profil erstellt werden soll, neu erstellt und aufgezeichnet werden. Zur Vermeidung von Verwechslungen wird das ursprüngliche Entwicklertools-Fenster als erste Entwicklertools-Instanz und das Fenster, in dem die erste Instanz geprüft wird, als zweite Entwicklertools-Instanz bezeichnet.

<ph type="x-smartling-placeholder">
</ph> Screenshot einer Entwicklertools-Instanz, bei der die Elemente in den Entwicklertools selbst geprüft werden <ph type="x-smartling-placeholder">
</ph> DevTools-on-DevTools: Entwicklertools mit DevTools prüfen

Auf der zweiten Entwicklertools-Instanz wird im Bereich Leistung, der ab jetzt als Performance-Bereich bezeichnet wird, die erste Entwicklertools-Instanz beobachtet. Dabei wird das Szenario neu erstellt, wodurch ein Profil geladen wird.

<ph type="x-smartling-placeholder">

Auf der zweiten Entwicklertools-Instanz wird eine Live-Aufzeichnung gestartet, während auf der ersten Instanz ein Profil aus einer Datei auf dem Laufwerk geladen wird. Es wird eine große Datei geladen, um ein genaues Profil der Leistung bei der Verarbeitung großer Eingaben zu erstellen. Wenn beide Instanzen fertig geladen sind, werden die Daten zur Leistungsprofilerstellung – auch als Trace bezeichnet – in der zweiten Entwicklertools-Instanz des Perf-Steuerfelds zum Laden eines Profils angezeigt.

Der Ausgangszustand: Verbesserungsmöglichkeiten identifizieren

Wenn der Ladevorgang abgeschlossen ist, sehen Sie im nächsten Screenshot bei unserer zweiten Perf-Panel-Instanz Folgendes: Fokussierung auf die Aktivität des Hauptthreads, die unter dem Track mit der Bezeichnung Main sichtbar ist Im Flame-Diagramm gibt es fünf große Gruppen von Aktivitäten. Dies sind die Aufgaben, bei denen das Laden am meisten Zeit in Anspruch nimmt. Die Gesamtdauer dieser Aufgaben betrug ungefähr 10 Sekunden. Im folgenden Screenshot sehen Sie im Leistungssteuerfeld, welche dieser Aktivitätsgruppen vorhanden sind.

Screenshot des Bereichs „Leistung“ in den Entwicklertools, in dem das Laden eines Leistungs-Traces im Bereich „Leistung“ einer anderen Entwicklertools-Instanz untersucht wird. Das Laden des Profils dauert etwa 10 Sekunden. Diese Zeit ist größtenteils auf fünf Hauptaktivitätsgruppen verteilt.

Erste Aktivitätsgruppe: unnötige Arbeit

Es wurde klar, dass die erste Aktivitätsgruppe Legacy-Code war, der zwar noch ausgeführt wurde, aber nicht benötigt wurde. Alles unter dem grünen Block mit der Beschriftung processThreadEvents war reine Zeitverschwendung. Das war ein schneller Sieg. Durch das Entfernen dieses Funktionsaufrufs sparte man etwa 1,5 Sekunden Zeit. Cool!

Zweite Aktivitätsgruppe

Bei der zweiten Aktivitätsgruppe war die Lösung nicht so einfach wie bei der ersten. Die buildProfileCalls dauerte ungefähr 0, 5 Sekunden und diese Aufgabe konnte nicht vermieden werden.

Screenshot des Leistungsbereichs in den Entwicklertools, auf dem eine weitere Instanz des Leistungssteuerfelds untersucht wird. Eine der Funktion buildProfileCalls zugeordnete Aufgabe dauert etwa 0,5 Sekunden.

Aus Neugier haben wir im PERF-Panel die Option Memory (Arbeitsspeicher) aktiviert, um das Problem weiter zu untersuchen, und stellten fest, dass auch die buildProfileCalls-Aktivität viel Arbeitsspeicher verbraucht hat. Hier sehen Sie, wie das blaue Liniendiagramm plötzlich um die Zeit springt, in der buildProfileCalls ausgeführt wird. Dies deutet auf ein potenzielles Speicherleck hin.

Screenshot des Arbeitsspeicher-Profilers in den Entwicklertools, der den Arbeitsspeicherverbrauch des Leistungsbereichs bewertet. Das Prüftool deutet darauf hin, dass die Funktion buildProfileCalls für ein Speicherleck verantwortlich ist.

Wir haben diesen Verdacht weiter untersucht. Das ist ein weiterer Bereich in den Entwicklertools, der sich vom Bereich „Memory“ im PERF-Steuerfeld unterscheidet. Im Bereich „Arbeitsspeicher“ sehen Sie Profiling-Typ ausgewählt, mit dem der Heap-Snapshot für das Performance-Steuerfeld beim Laden des CPU-Profils aufgezeichnet wurde.

Screenshot des Anfangszustands des Memory Profiler. „Allocation Sampling“ (Zuordnungsstichproben) wird durch ein rotes Feld hervorgehoben. Dies bedeutet, dass diese Option am besten für die Erstellung von JavaScript-Speicherprofilen geeignet ist.

Der folgende Screenshot zeigt den Heap-Snapshot, der erfasst wurde.

<ph type="x-smartling-placeholder">
Screenshot des Arbeitsspeicher-Profilers, auf dem ein speicherintensiver satzbasierter Vorgang ausgewählt ist.

Anhand dieses Heap-Snapshots wurde festgestellt, dass die Set-Klasse viel Arbeitsspeicher verbraucht hat. Beim Überprüfen der Aufrufpunkte wurde festgestellt, dass wir Objekten, die in großen Mengen erstellt wurden, unnötigerweise Eigenschaften vom Typ Set zugewiesen haben. Diese Kosten summierten sich und es wurde viel Arbeitsspeicher verbraucht, bis die Anwendung bei großen Eingaben häufig abstürzte.

Sätze sind nützlich, um eindeutige Elemente zu speichern und Vorgänge bereitzustellen, die die Eindeutigkeit ihres Inhalts verwenden, z. B. das Deduplizieren von Datasets und das Bereitstellen effizienterer Suchen. Diese Funktionen waren jedoch nicht erforderlich, da die gespeicherten Daten garantiert eindeutig von der Quelle waren. Daher waren Sets von vornherein nicht erforderlich. Zur Verbesserung der Arbeitsspeicherzuweisung wurde der Attributtyp von Set in ein einfaches Array geändert. Nach Anwendung dieser Änderung wurde ein weiterer Heap-Snapshot erstellt und es wurde eine reduzierte Arbeitsspeicherzuweisung festgestellt. Obwohl durch diese Änderung keine nennenswerten Verbesserungen der Geschwindigkeit erzielt wurden, bestand der zusätzliche Vorteil darin, dass die App seltener abgestürzt ist.

Screenshot des Memory Profiler Der zuvor arbeitsspeicherintensive Set-basierte Vorgang wurde geändert, um ein einfaches Array zu verwenden, wodurch die Speicherkosten erheblich reduziert wurden.

Dritte Aktivitätsgruppe: Vor- und Nachteile der Datenstruktur abwägen

Der dritte Abschnitt ist ungewöhnlich: Sie können im Flame-Diagramm sehen, dass es aus schmalen, aber hohen Spalten besteht, die tiefe Funktionsaufrufe und in diesem Fall tiefe Rekursionen darstellen. Insgesamt dauerte dieser Abschnitt etwa 1, 4 Sekunden. Am Ende dieses Abschnitts wurde deutlich, dass die Breite dieser Spalten von der Dauer einer Funktion bestimmt wurde: appendEventAtLevel, was darauf hindeutet, dass es sich um einen Engpass handeln könnte

Bei der Implementierung der appendEventAtLevel-Funktion ist eine Sache besonders aufgefallen. Für jeden einzelnen Dateneintrag in der Eingabe (im Code als „Ereignis“ bezeichnet) wurde einer Karte ein Element hinzugefügt, das die vertikale Position der Zeitachseneinträge verfolgt. Dies war problematisch, da die Menge der gespeicherten Elemente sehr groß war. Karten sind für schlüsselbasierte Suchvorgänge schnell, aber dieser Vorteil ist nicht kostenlos. Wenn eine Karte größer wird, kann das Hinzufügen von Daten beispielsweise teuer werden, weil sie erneut gehasht wird. Dieser Preis macht sich deutlich, wenn der Karte nach und nach große Mengen von Elementen hinzugefügt werden.

/**
 * 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 testeten einen anderen Ansatz, bei dem wir nicht für jeden Eintrag im Flame-Diagramm ein Element in einer Karte hinzufügen mussten. Die Verbesserung war signifikant und bestätigt, dass der Engpass tatsächlich mit dem Aufwand zusammenhängt, der durch das Hinzufügen aller Daten zur Karte entsteht. Die Zeit, die die Aktivitätsgruppe benötigt hat, schrumpfte von etwa 1,4 Sekunden auf etwa 200 Millisekunden.

Vorher

Screenshot des Leistungssteuerfelds, bevor die Funktion „appendEventAtLevel“ optimiert wurde Die Gesamtzeit für die Ausführung der Funktion betrug 1.372,51 Millisekunden.

Nachher

Screenshot des Leistungssteuerfelds, nachdem die Funktion „appendEventAtLevel“ optimiert wurde Die Gesamtzeit für die Ausführung der Funktion betrug 207,2 Millisekunden.

Vierte Aktivitätsgruppe: Nicht kritische Arbeiten aufschieben und Daten im Cache speichern, um doppelte Arbeiten zu vermeiden

Beim Heranzoomen dieses Fensters sehen Sie, dass es zwei nahezu identische Blöcke von Funktionsaufrufen gibt. Aus dem Namen der aufgerufenen Funktionen können Sie ableiten, dass diese Blöcke aus Code bestehen, mit dem Baumstrukturen erstellt werden (z. B. Namen wie refreshTree oder buildChildren). Mit dem zugehörigen Code werden die Baumansichten in der unteren Leiste des Steuerfelds erstellt. Interessant ist, dass diese Baumansichten nicht direkt nach dem Laden angezeigt werden. Stattdessen muss der Nutzer eine Baumansicht auswählen (die Registerkarten "Bottom-up", "Call Tree" und "Event Log" in der Leiste), damit die Bäume angezeigt werden. Darüber hinaus wurde der Baumerstellungsprozess zweimal ausgeführt, wie Sie dem Screenshot entnehmen können.

Screenshot des Leistungsbereichs mit mehreren, sich wiederholenden Aufgaben, die ausgeführt werden, auch wenn sie nicht benötigt werden. Diese Aufgaben können verschoben werden, um sie bei Bedarf statt im Voraus auszuführen.

Es gibt zwei Probleme, die wir bei diesem Bild erkannt haben:

  1. Eine nicht kritische Aufgabe hat die Leistung der Ladezeit behindert. Nutzer benötigen die Ausgabe nicht immer. Daher ist die Aufgabe für das Laden des Profils nicht entscheidend.
  2. Das Ergebnis dieser Aufgaben wurde nicht im Cache gespeichert. Daher wurden die Bäume zweimal berechnet, obwohl sich die Daten nicht veränderten.

Wir haben mit der Berechnung der Baumstruktur begonnen, als der Nutzer die Baumansicht manuell öffnete. Nur dann lohnt es sich, den Preis für die Erstellung dieser Bäume zu bezahlen. Die gesamte Zeit für die zweimalige Ausführung betrug ca.3,4 Sekunden, sodass sich die Ladezeit erheblich veränderte, wenn der Test verzögert wurde. Wir prüfen derzeit noch, ob wir diese Arten von Aufgaben im Cache speichern können.

Fünfte Aktivitätsgruppe: möglichst komplexe Aufrufhierarchien vermeiden

Ein genauer Blick auf diese Gruppe ergab, dass eine bestimmte Anrufkette wiederholt aufgerufen wurde. Dasselbe Muster erschien sechsmal an verschiedenen Stellen im Flame-Diagramm, und die Gesamtdauer dieses Fensters betrug etwa 2,4 Sekunden.

Screenshot des Steuerfelds „Leistung“ 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 auf der „Minimap“ gerendert werden sollen. (Übersicht über die Zeitachsenaktivität oben im Steuerfeld). Es war nicht klar, warum es mehrmals passiert, aber es musste auch nicht sechsmal passieren. Tatsächlich sollte die Ausgabe des Codes aktuell bleiben, wenn kein anderes Profil geladen wird. Theoretisch sollte der Code nur einmal ausgeführt werden.

Bei einer Untersuchung wurde festgestellt, dass der zugehörige Code als Folge mehrerer Teile in der Ladepipeline direkt oder indirekt die Funktion zum Berechnen der Minimap aufgerufen wurde. Dies liegt daran, dass sich die Komplexität der Aufrufgrafik des Programms im Laufe der Zeit verändert hat und unwissentlich weitere Abhängigkeiten zu diesem Code hinzugefügt wurden. Für dieses Problem gibt es keine schnelle Lösung. Die Art der Lösung hängt von der Architektur der betreffenden Codebasis 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 haben wir diesen Ausblick auf den Zeitplan erhalten:

Screenshot des Steuerfelds „Leistung“ mit sechs separaten Funktionsaufrufen zum Generieren derselben Trace-Minimap, die auf das Zweifache reduziert wurde.

Beachten Sie, dass die Minimap-Renderingausführung zweimal, nicht einmal ausgeführt wird. Das liegt daran, dass für jedes Profil zwei Minikarten gezeichnet werden: eine für die Übersicht über dem Steuerfeld und eine weitere für das Dropdown-Menü, das das aktuell sichtbare Profil aus dem Verlauf auswählt. Jedes Element in diesem Menü enthält eine Übersicht des ausgewählten Profils. Da diese beiden Inhalte jedoch denselben Inhalt haben, sollte das eine für die andere wiederverwendet werden können.

Da es sich bei diesen Minikarten um Bilder handelt, die auf einem Canvas gezeichnet wurden, musste das Canvas-Dienstprogramm drawImage verwendet und der Code anschließend nur einmal ausgeführt werden, um zusätzliche Zeit zu sparen. Aufgrund dieser Bemühungen wurde die Dauer der Gruppe von 2, 4 Sekunden auf 140 Millisekunden reduziert.

Fazit

Nachdem alle diese Korrekturen und einige andere kleinere Korrekturen vorgenommen wurden, sah die Änderung der Zeitachse für das Laden des Profils so aus:

Vorher

Screenshot des Leistungsbereichs mit dem Laden von Trace vor der Optimierung Der Vorgang dauerte etwa zehn Sekunden.

Nachher

<ph type="x-smartling-placeholder">
</ph> Screenshot des Leistungsbereichs mit dem Laden von Trace nach der Optimierung Dieser Vorgang dauert ungefähr zwei Sekunden.
<ph type="x-smartling-placeholder">

Die Ladezeit nach den Verbesserungen betrug 2 Sekunden, was bedeutet, dass eine Verbesserung von etwa 80% mit relativ geringem Aufwand erreicht wurde, da der Großteil aus schnellen Korrekturen bestand. Natürlich war es wichtig, zu Beginn klar zu erkennen, was zu tun war, und das Perf-Panel war dafür das richtige Tool.

Es ist auch wichtig zu betonen, dass diese Zahlen für ein Profil spezifisch sind, das als Studienobjekt verwendet wird. Das Profil war für uns interessant, da es besonders groß war. Da die Verarbeitungspipeline für jedes Profil gleich ist, gilt die erzielte deutliche Verbesserung jedoch für jedes Profil, das im Leistungsbereich geladen wird.

Fazit

Aus diesen Ergebnissen können Sie zur Leistungsoptimierung Ihrer Anwendung einige Erkenntnisse ziehen:

1. Profilerstellungstools zur Identifizierung von Laufzeitleistungsmustern verwenden

Profilerstellungstools sind äußerst nützlich, um zu verstehen, was in Ihrer Anwendung während der Ausführung geschieht, insbesondere um Möglichkeiten zur Verbesserung der Leistung zu identifizieren. Das Steuerfeld „Leistung“ in den Chrome-Entwicklertools ist eine gute Option für Webanwendungen, da es das native Webprofil-Tool im Browser ist und aktiv gepflegt wird, um die neuesten Webplattformfunktionen zu nutzen. Außerdem ist es jetzt deutlich schneller! 😉

Verwenden Sie Beispiele, die als repräsentative Arbeitslasten verwendet werden können, und sehen Sie sich an, was Sie finden können.

2. Komplexe Aufrufhierarchien vermeiden

Vermeiden Sie es nach Möglichkeit, die Aufrufgrafik zu kompliziert zu gestalten. Komplexe Aufrufhierarchien führen zu Leistungsabfällen und es ist schwer nachzuvollziehen, warum Ihr Code so ausgeführt wird, wie er ist. Verbesserungen lassen sich nur schwer umsetzen.

3. Unnötige Arbeiten identifizieren

Es ist üblich, dass veraltete Codebasen Code enthalten, der nicht mehr benötigt wird. In unserem Fall machte veralteter und unnötiger Code einen erheblichen Teil der gesamten Ladezeit aus. Sie zu entfernen war die am tiefsten hängende Frucht.

4. Datenstrukturen angemessen verwenden

Verwenden Sie Datenstrukturen, um die Leistung zu optimieren, und verstehen Sie auch die Kosten und Vor- und Nachteile der einzelnen Datenstrukturen, wenn Sie entscheiden, welche Sie verwenden möchten. Dabei geht es nicht nur um die räumliche Komplexität der Datenstruktur selbst, sondern auch um die Zeitkomplexität der betreffenden Vorgänge.

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, ist es sinnvoll, die Ergebnisse für den nächsten Bedarf zu speichern. Es ist auch sinnvoll, dies zu tun, wenn der Vorgang mehrmals durchgeführt wird, auch wenn jede einzelne Zeit nicht besonders kostspielig ist.

6. Nicht kritische Arbeit aufschieben

Wenn die Ausgabe einer Aufgabe nicht sofort benötigt wird und die Ausführung der Aufgabe den kritischen Pfad erweitert, können Sie sie aufschieben, indem Sie sie verzögert aufrufen, wenn die Ausgabe tatsächlich benötigt wird.

7. Effiziente Algorithmen für große Eingaben verwenden

Bei großen Datenmengen sind optimale Algorithmen für die Zeitkomplexität entscheidend. Wir haben uns diese Kategorie in diesem Beispiel nicht angesehen, aber ihre Bedeutung kann kaum hoch genug betont werden.

8. Bonus: Benchmarks für Ihre Pipelines

Um sicherzustellen, dass Ihr Code in der Entwicklung schnell bleibt, ist es ratsam, das Verhalten zu überwachen und mit den Standards zu vergleichen. Auf diese Weise erkennen Sie proaktiv Regressionen und verbessern die Gesamtzuverlässigkeit, was für langfristigen Erfolg gerüstet ist.