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 Profiler-Tool, mit dem sich die Leistung von Webanwendungen analysieren und optimieren lässt. 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 kennen, können Sie Muster, Engpässe und Leistungsspitzen erkennen, die Sie zur Leistungssteigerung nutzen können.

Im folgenden Beispiel wird die Verwendung des Bereichs Leistung veranschaulicht.

Profiling-Szenario einrichten und neu erstellen

Vor Kurzem haben wir uns zum Ziel gesetzt, den Bereich Leistung leistungsfähiger zu machen. Insbesondere wollten wir, dass große Mengen an Leistungsdaten schneller geladen werden. Dies ist beispielsweise der Fall, wenn Sie Profile für lang andauernde oder komplexe Prozesse erstellen oder hochgradig detaillierte Daten erfassen. Dazu musste zuerst verstanden werden, wie die Anwendung funktionierte und warum sie so funktionierte. Dies wurde mithilfe eines Profiler-Tools erreicht.

Wie Sie vielleicht wissen, sind die DevTools selbst eine Webanwendung. Daher kann es über das Steuerfeld Leistung profiliert werden. Wenn Sie dieses Steuerfeld selbst erfassen möchten, können Sie die Entwicklertools öffnen und dann eine weitere Entwicklertools-Instanz öffnen, die mit diesem Steuerfeld verknüpft ist. Bei Google wird diese Konfiguration als DevTools-in-DevTools bezeichnet.

Sobald die Einrichtung abgeschlossen ist, muss das zu profilierende Szenario 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.

Screenshot einer DevTools-Instanz, in der die Elemente in den DevTools selbst geprüft werden
DevTools-on-DevTools: Entwicklertools mit den Entwicklertools prüfen.

In der zweiten DevTools-Instanz wird im Bereich Leistung, der ab jetzt als Leistungsbereich bezeichnet wird, die erste DevTools-Instanz beobachtet, um das Szenario nachzubilden, bei dem ein Profil geladen wird.

In der zweiten DevTools-Instanz wird eine Liveaufzeichnung gestartet, während in der ersten Instanz ein Profil aus einer Datei auf dem Laufwerk geladen wird. Eine große Datei wird geladen, um die Leistung der Verarbeitung großer Eingaben genau zu erfassen. Wenn beide Instanzen geladen sind, werden die Daten für das Leistungsprofil (häufig als Trace bezeichnet) in der zweiten DevTools-Instanz des Bereichs „Leistung“ angezeigt, in dem ein Profil geladen wird.

Der Anfangszustand: Verbesserungsmöglichkeiten identifizieren

Nach dem Laden wurde in unserem zweiten Leistungsbereich im nächsten Screenshot Folgendes beobachtet. Konzentrieren Sie sich auf die Aktivität des Hauptthreads, die unter dem Track mit der Bezeichnung Haupt zu sehen ist. Im Flammendiagramm sind fünf große Aktivitätsgruppen zu sehen. Dazu gehören die Aufgaben, bei denen das Laden am längsten dauert. Die Gesamtzeit für diese Aufgaben betrug etwa 10 Sekunden. Im folgenden Screenshot wird der Bereich „Leistung“ verwendet, um sich auf jede dieser Aktivitätsgruppen zu konzentrieren und zu sehen, was sich finden lässt.

Screenshot des Bereichs „Leistung“ in den Entwicklertools, in dem das Laden eines Leistungs-Traces im Bereich „Leistung“ einer anderen DevTools-Instanz untersucht wird Das Laden des Profils dauert etwa 10 Sekunden. Diese Zeit wird hauptsächlich auf fünf Hauptgruppen von Aktivitäten verteilt.

Erste Aktivitätsgruppe: Unnötige Arbeit

Es wurde deutlich, dass die erste Aktivitätsgruppe Legacy-Code war, der zwar noch ausgeführt wurde, aber nicht benötigt wurde. Im Grunde war alles unter dem grünen Block mit der Beschriftung processThreadEvents reine Zeitverschwendung. Das war ein schneller Sieg. Durch das Entfernen dieses Funktionsaufrufs wurden etwa 1,5 Sekunden eingespart. Nicht schlecht, oder?

Zweite Aktivitätsgruppe

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

Screenshot des Bereichs „Leistung“ in den Entwicklertools, in dem eine andere Instanz des Bereichs „Leistung“ geprüft wird Eine der Funktion buildProfileCalls zugeordnete Aufgabe dauert etwa 0,5 Sekunden.

Aus Neugier haben wir die Option Speicher im Bereich „Leistung“ aktiviert, um die Angelegenheit genauer zu untersuchen. Dabei haben wir festgestellt, dass auch die buildProfileCalls-Aktivität viel Arbeitsspeicher belegt. 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. Der Prüfer schlägt vor, dass die Funktion „buildProfileCalls“ für ein Speicherleck verantwortlich ist.

Um diesen Verdacht zu überprüfen, haben wir den Bereich „Arbeitsspeicher“ (ein weiterer Bereich in den DevTools, der sich vom Bereich „Arbeitsspeicher“ im Bereich „Leistung“ unterscheidet) verwendet. Im Bereich „Arbeitsspeicher“ wurde der Profilerstellungstyp „Allocation sampling“ ausgewählt, mit dem der Heap-Snapshot für das Leistungsbereich zum Laden des CPU-Profils aufgezeichnet wurde.

Ein Screenshot des ursprünglichen Zustands des Speicherprofils. Die Option „Stichprobenzuweisung“ ist durch ein rotes Feld markiert. Es gibt an, dass diese Option für die JavaScript-Speicherprofilerstellung am besten geeignet ist.

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

Screenshot des Memory Profilers mit einem arbeitsspeicherintensiven setbasierten Vorgang

Anhand dieses Heap-Snapshots wurde festgestellt, dass die Set-Klasse viel Arbeitsspeicher beansprucht. Bei der Prüfung der Aufrufpunkte wurde festgestellt, dass wir Objekten, die in großen Mengen erstellt wurden, unnötig Eigenschaften vom Typ Set zugewiesen haben. Diese Kosten summierten sich und es wurde viel Arbeitsspeicher verbraucht, sodass die Anwendung bei großen Eingaben häufig abstürzte.

Sets eignen sich zum Speichern eindeutiger Elemente und bieten Funktionen, die die Einzigartigkeit ihrer Inhalte nutzen, z. B. die Deduplizierung von Datensätzen und effizientere Suchanfragen. Diese Funktionen waren jedoch nicht erforderlich, da die gespeicherten Daten garantiert eindeutig aus der Quelle waren. Daher waren Sets von vornherein nicht erforderlich. Um die Speicherzuweisung zu verbessern, wurde der Property-Typ von einem Set in ein einfaches Array geändert. Nach der Anwendung dieser Änderung wurde ein weiterer Heap-Snapshot erstellt und eine geringere Arbeitsspeicherzuweisung festgestellt. Obwohl mit dieser Änderung keine erheblichen Geschwindigkeitsverbesserungen erzielt wurden, war der sekundäre Vorteil, dass die Anwendung seltener abstürzte.

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

Dritte Aktivitätsgruppe: Abwägung der Vor- und Nachteile von Datenstrukturen

Der dritte Abschnitt ist seltsam: Wie Sie im Flammendiagramm sehen, besteht er aus schmalen, aber hohen Säulen, die in diesem Fall tiefe Funktionsaufrufe und tiefe Rekursionen darstellen. Insgesamt dauerte dieser Abschnitt etwa 1,4 Sekunden. Unten in diesem Abschnitt war zu sehen, dass die Breite dieser Spalten durch die Dauer einer Funktion bestimmt wurde: appendEventAtLevel. Das könnte auf ein Engpass hindeuten.

Bei der Implementierung der appendEventAtLevel-Funktion fiel mir etwas auf. 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. Das war problematisch, da die Anzahl der gespeicherten Elemente sehr groß war. Karten sind für schlüsselbasierte Suchanfragen schnell, aber dieser Vorteil ist nicht kostenlos. Wenn eine Karte größer wird, kann das Hinzufügen von Daten beispielsweise aufgrund von Neuhash-Vorgängen teuer werden. Diese Kosten werden deutlich, 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 mit einem anderen Ansatz experimentiert, bei dem wir nicht für jeden Eintrag im Flammendiagramm ein Element in einer Karte hinzufügen mussten. Die Verbesserung war beträchtlich, was bestätigte, dass das Nadelöhr tatsächlich mit dem Overhead zusammenhing, der durch das Hinzufügen aller Daten zur Karte verursacht wurde. Die Zeit, die die Aktivitätsgruppe benötigt hat, schrumpfte von etwa 1,4 Sekunden auf etwa 200 Millisekunden.

Vorher

Screenshot des Bereichs „Leistung“ vor der Optimierung der Funktion „appendEventAtLevel“ 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 Gesamtlaufzeit der Funktion betrug 207,2 Millisekunden.

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

Wenn Sie in dieses Fenster hineinzoomen, sehen Sie zwei fast identische Funktionsaufrufblöcke. 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). Tatsächlich ist der zugehörige Code der Code, mit dem die Baumansicht in der unteren Leiste des Steuerfelds erstellt wird. Interessant ist, dass diese Baumansichten nicht direkt nach dem Laden angezeigt werden. Stattdessen muss der Nutzer eine Baumansicht auswählen (die Tabs „Bottom-Up“, „Anrufabfolge“ und „Ereignisprotokoll“ im Navigationsbereich), damit die Bäume angezeigt werden. Wie Sie auf dem Screenshot sehen können, wurde der Baum zweimal erstellt.

Screenshot des Bereichs „Leistung“ mit mehreren sich wiederholenden Aufgaben, die auch ausgeführt werden, wenn sie nicht erforderlich sind Diese Aufgaben können verschoben werden, damit sie bei Bedarf und nicht im Voraus ausgeführt werden.

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

  1. Eine nicht kritische Aufgabe beeinträchtigte die Ladezeit. 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.

Wir haben damit begonnen, die Baumstruktur erst zu berechnen, wenn der Nutzer die Baumansicht manuell geöffnet hat. Nur dann lohnt es sich, den Preis für die Erstellung dieser Bäume zu zahlen. Die Gesamtzeit für die Ausführung betrug etwa 3,4 Sekunden. Die Verzögerung hatte also einen erheblichen Einfluss auf die Ladezeit. 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.

Ein Screenshot des Bereichs „Leistung“ mit sechs separaten Funktionsaufrufen zum Generieren derselben Minimap für den Trace, die jeweils tiefe Aufrufstacks haben.

Der Code, der mehrmals aufgerufen wird, verarbeitet die Daten, die in der Minikarte gerendert werden sollen (die Übersicht über die Zeitachsenaktivität oben im Steuerfeld). Es war nicht klar, warum das Problem mehrmals auftrat, aber es hätte nicht sechsmal passieren müssen. Die Ausgabe des Codes sollte in der Tat aktuell bleiben, wenn kein anderes Profil geladen wird. Theoretisch sollte der Code nur einmal ausgeführt werden.

Bei der Untersuchung wurde festgestellt, dass der entsprechende Code aufgerufen wurde, weil mehrere Teile in der Ladepipeline die Funktion, die die Minimap berechnet, direkt oder indirekt aufrufen. Das liegt daran, dass sich die Komplexität des Aufrufgraphs des Programms im Laufe der Zeit entwickelt hat und unwissentlich weitere Abhängigkeiten zu diesem Code hinzugefügt wurden. Es gibt keine schnelle Lösung für dieses Problem. Die 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 sah die Zeitachse so aus:

Screenshot des Bereichs „Leistung“ mit den sechs separaten Funktionsaufrufen zum Generieren derselben Minimap für den Trace, die auf nur zwei reduziert wurden

Das Minimap-Rendering wird nicht nur einmal, sondern zweimal ausgeführt. Das liegt daran, dass für jedes Profil zwei Minikarten gezeichnet werden: eine für die Übersicht oben im Steuerfeld und eine für das Drop-down-Menü, über das 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 genau denselben Inhalt, sodass sie füreinander wiederverwendet werden können.

Da diese Minikarten beide Bilder sind, die auf einem Canvas gezeichnet werden, musste ich das drawImage Canvas-Dienstprogramm verwenden und den Code anschließend nur einmal ausführen, um Zeit zu sparen. Dadurch konnte die Dauer der Gruppe von 2, 4 Sekunden auf 140 Millisekunden reduziert werden.

Fazit

Nachdem wir all diese Fehlerkorrekturen (und einige kleinere hier und da) angewendet hatten, sah die 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

Screenshot des Bereichs „Leistung“ mit dem Ladevorgang eines Tracings nach den Optimierungen Der Vorgang dauert jetzt etwa zwei Sekunden.

Nach den Verbesserungen betrug die Ladezeit 2 Sekunden. Das bedeutet, dass mit relativ wenig Aufwand eine Verbesserung von etwa 80% erzielt wurde, da die meisten Maßnahmen aus schnellen Fehlerkorrekturen bestanden. Natürlich war es wichtig, was zu tun war, und das Leistungspanel war das richtige Tool dafür.

Außerdem ist es wichtig zu erwähnen, dass diese Zahlen für ein Profil gelten, das als Testobjekt 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 deutliche Verbesserung für jedes Profil, das im Leistungsbereich geladen wird.

Fazit

Diese Ergebnisse lassen sich in Bezug auf die Leistungsoptimierung Ihrer Anwendung so zusammenfassen:

1. Profiling-Tools verwenden, um Laufzeitleistungsmuster zu identifizieren

Profiling-Tools sind äußerst nützlich, um zu verstehen, was in Ihrer Anwendung während der Ausführung passiert, insbesondere um Möglichkeiten zur Leistungssteigerung zu ermitteln. Das Performance Panel in den Chrome-Entwicklertools ist eine gute Option für Webanwendungen, da es das native Web-Profilierungstool im Browser ist und aktiv gepflegt wird, um immer auf dem neuesten Stand der Webplattformfunktionen zu sein. Außerdem ist sie jetzt deutlich schneller. 😉

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

2. Komplexe Aufrufhierarchien vermeiden

Vermeiden Sie nach Möglichkeit, dass Ihr Aufrufgraph zu kompliziert wird. Bei komplexen Aufrufhierarchien ist es leicht, Leistungseinbrüche zu verursachen, und es ist schwierig zu verstehen, warum der Code so ausgeführt wird, wie er ausgeführt wird. Das erschwert die Verbesserungen.

3. Unnötige Arbeit identifizieren

Ältere Codebases enthalten häufig Code, der nicht mehr benötigt wird. In unserem Fall beanspruchte alter und unnötiger Code einen erheblichen Teil der gesamten Ladezeit. Das Entfernen war die einfachste Lösung.

4. Datenstrukturen effizient nutzen

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 Speicherkomplexität der Datenstruktur selbst, sondern auch um die Zeitkomplexität der anwendbaren Vorgänge.

5. Ergebnisse im Cache speichern, um bei komplexen oder sich wiederholenden Vorgängen doppelte Arbeit zu vermeiden

Wenn die Ausführung des Vorgangs aufwendig ist, ist es sinnvoll, die Ergebnisse für die nächste Verwendung zu speichern. Dies ist auch sinnvoll, wenn der Vorgang häufig ausgeführt wird, auch wenn er nicht besonders kostenintensiv ist.

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 verschieben, indem Sie sie nur dann aufrufen, wenn die 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 diese Kategorie nicht berücksichtigt, ihre Bedeutung kann jedoch kaum überschätzt werden.

8. Bonus: Pipelines benchmarken

Damit Ihr sich entwickelnder Code möglichst schnell bleibt, sollten Sie das Verhalten beobachten und mit Standards vergleichen. Auf diese Weise erkennen Sie proaktiv Regressionen und verbessern die Gesamtzuverlässigkeit, was Sie für langfristigen Erfolg gerüstet ist.