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 für die Nutzerfreundlichkeit und den Erfolg der Anwendung entscheidend, ihre Leistung zu optimieren, dafür zu sorgen, dass sie schnell geladen wird und reibungslose Interaktionen ermöglicht. 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 bestimmten Zeitfensters passiert. Der Bereich Leistung in den Entwicklertools ist ein hervorragendes Tool zur Profilerstellung, um die Leistung von Webanwendungen zu analysieren und zu optimieren. Wenn Ihre App in Chrome ausgeführt wird, erhalten Sie einen detaillierten Überblick darüber, was der Browser beim Ausführen Ihrer Anwendung tut. Wenn Sie diese Aktivität verstehen, können Sie Muster, Engpässe und Leistungsengpässe erkennen, auf die Sie reagieren können, um die Leistung zu verbessern.

Im folgenden Beispiel wird die Verwendung des Steuerfelds Leistung erläutert.

Unser Profilerstellungsszenario einrichten und neu erstellen

Wir haben uns das Ziel gesetzt, die Leistung des Bereichs Leistung zu verbessern. Insbesondere wollten wir damit große Mengen an Leistungsdaten schneller laden. Dies ist beispielsweise der Fall, wenn Sie Profile für lang andauernde oder komplexe Prozesse erstellen oder Daten mit hohem Detaillierungsgrad erfassen. Dazu musste zuerst die Leistung der Anwendung und die Gründe für diese Art der Leistung klar werden. Dafür wurde ein Profilerstellungstool verwendet.

Wie du vielleicht weißt, ist die Entwicklertools selbst eine Webanwendung. Daher kann über das Steuerfeld Leistung ein Profil erstellt werden. Wenn du für diesen Bereich ein Profil erstellen möchtest, öffne die Entwicklertools und öffne dann eine weitere zugehörige Entwicklertools-Instanz. Bei Google wird diese Konfiguration als Entwicklertools-on-Entwicklertools bezeichnet.

Sobald die Einrichtung abgeschlossen ist, muss das Szenario, für das ein Profil erstellt werden soll, neu erstellt und aufgezeichnet werden. Um Verwirrung zu vermeiden, 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.

Screenshot einer DevTools-Instanz, die die Elemente in den Entwicklertools selbst überprüft.
DevTools-on-DevTools: Prüfung der Entwicklertools mit den Entwicklertools.

Auf der zweiten Entwicklertools-Instanz wird im Bereich Leistung, der ab jetzt Leistungsbereich genannt wird, die erste Entwicklertools-Instanz beobachtet, um das Szenario neu zu erstellen. Dabei wird ein Profil geladen.

Auf der zweiten Entwicklertools-Instanz wird eine Live-Aufzeichnung gestartet, während in 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 – allgemein als trace bezeichnet – in der zweiten Entwicklertools-Instanz des Leistungsbereichs angezeigt, in dem ein Profil geladen wird.

Ausgangszustand: Identifizieren von Verbesserungsmöglichkeiten

Nachdem der Ladevorgang abgeschlossen ist, wurde im nächsten Screenshot Folgendes bei unserer zweiten Performance Panel-Instanz beobachtet. Fokus auf die Aktivität des Hauptthreads, sichtbar unter dem Track Main. Im Flammendiagramm sind fünf große Aktivitätsgruppen zu sehen. Dies sind die Aufgaben, bei denen das Laden die meiste Zeit in Anspruch nimmt. Die Gesamtzeit dieser Aufgaben betrug ungefähr 10 Sekunden. Im folgenden Screenshot wird der Bereich „Leistung“ verwendet, um sich auf die einzelnen Aktivitätsgruppen zu konzentrieren und die gefundenen Daten zu sehen.

Screenshot des Bereichs „Leistung“ in den Entwicklertools. Hier wird das Laden eines Leistungs-Trace im Leistungsbereich einer anderen Entwicklertools-Instanz geprüft. Das Laden des Profils dauert etwa 10 Sekunden. Diese Zeit ist in fünf Hauptaktivitätsgruppen aufgeteilt.

Erste Aktivitätsgruppe: unnötige Arbeit

Dabei stellte sich heraus, dass es sich bei der ersten Gruppe um Legacy-Code handelte, der zwar weiterhin ausgeführt wurde, aber nicht wirklich gebraucht wurde. Im Grunde war alles unter dem grünen Block mit der Beschriftung processThreadEvents verschwendeter Aufwand. Das war ein schneller Sieg. Durch das Entfernen dieses Funktionsaufrufs konnten ca.1,5 Sekunden Zeit gespart werden. Nicht schlecht, oder?

Zweite Aktivitätsgruppe

Bei der zweiten Aktivitätsgruppe war die Lösung nicht so einfach wie bei der ersten. Der buildProfileCalls dauerte etwa 0,5 Sekunden, und diese Aufgabe ließ sich nicht vermeiden.

Screenshot des Bereichs „Leistung“ in den Entwicklertools, der eine andere Instanz des Leistungsbereichs prüft. Eine Aufgabe, die mit der Funktion buildProfileCalls verknüpft ist, dauert etwa 0,5 Sekunden.

Aus Neugier haben wir die Option Arbeitsspeicher im Leistungspanel aktiviert, um die Sache genauer zu untersuchen. Dabei stellten wir fest, dass auch die buildProfileCalls-Aktivität viel Arbeitsspeicher verbraucht hat. Hier können Sie sehen, wie sich das blaue Liniendiagramm während der Ausführung von buildProfileCalls plötzlich springt, was auf ein potenzielles Speicherleck hinweist.

Screenshot des Arbeitsspeicher-Profilers in den Entwicklertools zur Bewertung des Arbeitsspeicherverbrauchs des Steuerfelds „Leistung“. Das Inspektor legt nahe, dass die Funktion buildProfileCalls für ein Speicherleck verantwortlich ist.

Um auf diesen Verdacht zurückzukommen, haben wir den Bereich „Memory“ (ein weiterer Bereich in den Entwicklertools, der sich vom Arbeitsspeicher-Bereich im Leistungsbereich unterscheidet) verwendet. Im Bereich „Arbeitsspeicher“ wurde der Profiltyp „Allocation sampling“ (Stichprobenzuweisung) ausgewählt, mit dem der Heap-Snapshot für das Leistungsfeld aufgezeichnet wurde, das das CPU-Profil lädt.

Screenshot des Anfangszustands des Arbeitsspeicher-Profilers. Die Option „Allocation sampling“ (Stichprobenzuweisung) ist mit einem roten Feld markiert. Dies gibt an, dass diese Option am besten für die Erstellung von JavaScript-Arbeitsspeicherprofilen geeignet ist.

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

Screenshot des Arbeitsspeicher-Profilers mit einem speicherintensiven Set-basierten Vorgang.

Bei diesem Heap-Snapshot wurde festgestellt, dass die Klasse Set viel Arbeitsspeicher verbraucht hat. Bei der Überprüfung der Aufrufpunkte wurde festgestellt, dass Objekten, die in großen Volumen erstellt wurden, unnötigerweise Attribute vom Typ Set zugewiesen wurden. Die Kosten zahlten sich zusammen und es wurde viel Arbeitsspeicher verbraucht, bis die Anwendung bei großen Eingaben häufig abstürzte.

Datasets sind nützlich, um eindeutige Elemente zu speichern, und bieten Operationen, die die Eindeutigkeit ihres Inhalts nutzen, wie das Deduplizieren von Datasets und die Bereitstellung effizienterer Lookups. Diese Funktionen waren jedoch nicht erforderlich, da die gespeicherten Daten aus der Quelle garantiert eindeutig sind. Daher waren Sets von vornherein nicht notwendig. Zur Verbesserung der Arbeitsspeicherzuweisung wurde der Eigenschaftstyp von einem Set in ein einfaches Array geändert. Nach dieser Änderung wurde ein weiterer Heap-Snapshot erstellt und die Arbeitsspeicherzuweisung reduziert. Obwohl die Geschwindigkeit durch diese Änderung nicht wesentlich verbessert wurde, bestand der zweite Vorteil darin, dass die Anwendung seltener abstürzte.

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

Dritte Aktivitätsgruppe: Abwägung der Kompromisse bei der Datenstruktur

Der dritte Abschnitt ist eigenartig: Sie können im Flame-Diagramm sehen, dass er aus schmalen, aber hohen Säulen besteht, die tiefe Funktionsaufrufe und in diesem Fall tiefe Rekursionen anzeigen. 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 auf einen Engpass hindeuten könnte:

Innerhalb 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. Maps sind schnell für schlüsselbasierte Suchen, aber dieser Vorteil ist nicht kostenlos. Wenn eine Karte größer wird, kann das Hinzufügen von Daten beispielsweise aufgrund des erneuten Hashss kostspielig werden. Diese Kosten machen sich deutlich, wenn große Mengen von Elementen nacheinander zur Karte 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 experimentierten mit einem 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 belegt, dass der Engpass tatsächlich mit dem Aufwand zusammenhing, der durch das Hinzufügen aller Daten zur Karte entstanden ist. Die Zeit, die für die Aktivitätsgruppe benötigt wurde, sank von 1,4 Sekunden auf etwa 200 Millisekunden.

Vorher

Screenshot des Leistungsbereichs vor Optimierungen an der Funktion „attachEventAtLevel“. Die Gesamtzeit für die Ausführung der Funktion betrug 1.372,51 Millisekunden.

Nachher

Screenshot des Leistungsbereichs, nachdem Optimierungen an der Funktion „attachEventAtLevel“ vorgenommen wurden. Die Gesamtzeit für die Ausführung der Funktion betrug 207,2 Millisekunden.

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

Beim Heranzoomen dieses Fensters sehen Sie, dass es zwei nahezu identische Blöcke von Funktionsaufrufen gibt. Aus den Namen der aufgerufenen Funktionen können Sie schließen, dass diese Blöcke aus Code bestehen, der Bäume erstellt (z. B. mit 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 (Tabs „Bottom-up“, „Aufrufbaum“ und „Ereignisprotokoll“ in der Leiste), damit die Bäume angezeigt werden. Darüber hinaus, wie Sie an dem Screenshot erkennen können, wurde der Baumerstellungsprozess zweimal ausgeführt.

Screenshot des Leistungsbereichs mit mehreren sich wiederholenden Aufgaben, die auch dann ausgeführt werden, wenn sie nicht benötigt werden Diese Aufgaben können so verschoben werden, dass sie bei Bedarf und nicht im Voraus ausgeführt werden.

Wir haben bei diesem Bild zwei Probleme identifiziert:

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

Wir haben mit der Baumberechnung begonnen, bis der Nutzer die Baumansicht manuell geöffnet hat. Nur dann lohnt es sich, den Preis für diese Bäume zu zahlen. Die doppelte Ausführung betrug insgesamt etwa 3,4 Sekunden, sodass sich die Ladezeit erheblich veränderte. Auch das Caching solcher Aufgaben wird geprüft.

Fünfte Aktivitätsgruppe: Komplexe Aufrufhierarchien vermeiden

Bei genauerer Betrachtung dieser Gruppe wurde klar, dass eine bestimmte Anrufkette wiederholt aufgerufen wurde. Dasselbe Muster erschien sechsmal an verschiedenen Stellen im Flame-Diagramm und die Gesamtdauer dieses Fensters betrug ungefähr 2, 4 Sekunden.

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, verarbeitet die Daten, die auf der „Minimap“ gerendert werden sollen. Das ist die Übersicht über die Zeitachsenaktivität oben im Steuerfeld. Es war nicht klar, warum es mehrfach passiert ist, aber es muss auch nicht sechsmal vorkommen! 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 als Folge mehrerer Teile in der Ladepipeline direkt oder indirekt der Funktion zur Berechnung der Minimap aufgerufen wurde. Dies liegt daran, dass sich die Komplexität der Aufrufgrafik des Programms im Laufe der Zeit weiterentwickelt 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 des Problems 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, falls die Eingabedaten unverändert blieben. Nach der Implementierung kam der Zeitplan so vor:

Screenshot des Leistungsbereichs mit den sechs separaten Funktionsaufrufen zum Generieren derselben Trace-Minimap, die auf das Zweifache reduziert wurde.

Das Rendern der Minikarte erfolgt zweimal, nicht nur einmal. Das liegt daran, dass für jedes Profil zwei Minikarten gezeichnet werden: eine für die Übersicht im oberen Bereich und eine weitere für das Drop-down-Menü, mit dem das aktuell sichtbare Profil aus dem Verlauf ausgewählt wird (jeder Eintrag in diesem Menü enthält eine Übersicht über das ausgewählte Profil). Trotzdem haben diese beiden Inhalte exakt den gleichen Inhalt, sodass ein Inhalt auch für die andere wiederverwendet werden kann.

Da es sich bei beiden Minikarten um Bilder handelt, die auf einer Leinwand gezeichnet wurden, mussten Sie das Canvas-Dienstprogramm drawImage verwenden und den Code anschließend nur einmal ausführen, um zusätzliche Zeit zu sparen. Dadurch konnte die Dauer der Gruppe von 2, 4 Sekunden auf 140 Millisekunden reduziert werden.

Fazit

Nachdem alle diese Korrekturen (sowie ein paar kleinere hier und da) vorgenommen wurden, sah die Änderung des Zeitplans für das Laden des Profils so aus:

Vorher

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

Nachher

Screenshot des Leistungsbereichs, der das Laden des Trace nach der Optimierung zeigt Der Vorgang dauert jetzt etwa zwei Sekunden.

Die Ladezeit nach den Verbesserungen betrug 2 Sekunden. Das bedeutet, dass sich eine Verbesserung von etwa 80% mit relativ geringem Aufwand erreichen lässt, da der Großteil der Behebung aus schnellen Korrekturen besteht. Natürlich war es wichtig, zu Beginn richtig zu bestimmen, was du tun musst, und das Leistungspanel war dafür das richtige Tool.

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

Takeaways

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

1. Profilerstellungstools verwenden, um Muster der Laufzeitleistung zu identifizieren

Profilerstellungstools sind unglaublich nützlich, um zu verstehen, was in Ihrer Anwendung während der Ausführung geschieht, insbesondere um Möglichkeiten zur Leistungssteigerung zu identifizieren. Der Bereich „Leistung“ in den Chrome-Entwicklertools ist eine hervorragende Option für Webanwendungen, da er das native Webprofilerstellungstool im Browser ist und aktiv gepflegt wird, um immer die neuesten Funktionen der Webplattform zu nutzen. Außerdem geht es jetzt deutlich schneller! 😉

Verwenden Sie Beispiele, die als repräsentative Arbeitslasten verwendet werden können, und schauen Sie, was Sie finden können!

2. Komplexe Aufrufhierarchien vermeiden

Vermeiden Sie es nach Möglichkeit, die Aufrufgrafik zu kompliziert zu gestalten. Bei komplexen Aufrufhierarchien ist es einfach, Leistungsabfälle einzuführen. Außerdem ist es schwer zu verstehen, warum Ihr Code so funktioniert, wie er ist, was es schwierig macht, Verbesserungen vorzunehmen.

3. Unnötige Arbeit identifizieren

Im Alter von Codebasen ist es üblich, dass sie Code enthalten, der nicht mehr benötigt wird. In unserem Fall haben veralteter und unnötiger Code einen erheblichen Teil der gesamten Ladezeit in Anspruch genommen. Das war die am wenigsten hängende Frucht.

4. Datenstrukturen sinnvoll verwenden

Verwenden Sie Datenstrukturen, um die Leistung zu optimieren, aber auch zu verstehen, welche Kosten und Nachteile jede Art von Datenstruktur mit sich bringt, wenn Sie entscheiden, welche verwendet werden sollen. Dabei geht es nicht nur um die räumliche Komplexität der Datenstruktur selbst, sondern auch um die zeitliche Komplexität der anwendbaren 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 die nächste Zeit zu speichern. Es ist auch sinnvoll, dies zu tun, wenn der Vorgang viele Male 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 Aufgabenausführung den kritischen Pfad erweitert, sollten Sie sie aufschieben, indem Sie sie verzögert aufrufen, wenn ihre Ausgabe tatsächlich benötigt wird.

7. Bei großen Eingaben effiziente Algorithmen verwenden

Bei großen Eingaben sind optimale Zeitkomplexitätsalgorithmen entscheidend. Wir haben uns in diesem Beispiel nicht mit dieser Kategorie beschäftigt, aber ihre Bedeutung kann kaum überschätzt werden.

8. Bonus: Benchmarking Ihrer Pipelines

Damit Ihr Code in Entwicklung schnell bleibt, empfiehlt es sich, das Verhalten zu überwachen und mit Standards zu vergleichen. Auf diese Weise identifizieren Sie proaktiv Regressionen und verbessern die Gesamtzuverlässigkeit. So sind Sie für langfristigen Erfolg gerüstet.