Ein um 400% schnelleres Leistungspanel durch bessere Performance

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

Unabhängig von der Art der Anwendung, die Sie entwickeln, ist es für die Nutzerfreundlichkeit und den Erfolg der Anwendung entscheidend, die Leistung zu optimieren und dafür zu sorgen, dass sie schnell geladen wird und reibungslose Interaktionen bietet. Eine Möglichkeit besteht darin, die Aktivitäten einer Anwendung mithilfe von 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 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. Das ist beispielsweise der Fall, wenn Sie langlaufende oder komplexe Prozesse erfassen oder Daten mit hoher Granularität erfassen. Dazu musste zuerst verstanden werden, wie die Anwendung funktionierte und warum sie so funktionierte. Dies wurde mithilfe eines Profiling-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. Um Verwechslungen zu vermeiden, wird das ursprüngliche DevTools-Fenster als erste DevTools-Instanz und das Fenster, in dem die erste Instanz geprüft wird, als zweite DevTools-Instanz bezeichnet.

Screenshot einer DevTools-Instanz, in der die Elemente in den DevTools selbst geprüft werden
Entwicklertools in Entwicklertools: Entwicklertools mit 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 im Bereich „Leistung“ angezeigt, in dem ein Profil geladen wird.

Der Anfangszustand: Verbesserungsmöglichkeiten identifizieren

Nach dem Laden war in unserem zweiten Leistungsbereich Folgendes zu sehen (siehe Screenshot unten). Konzentrieren Sie sich auf die Aktivität des Hauptthreads, die unter dem Track mit der Bezeichnung Main 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 im Bereich „Leistung“ die Aufmerksamkeit auf jede dieser Aktivitätsgruppen gelenkt, um 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 stellte sich heraus, dass die erste Aktivitätsgruppe aus Altcode bestand, der zwar noch ausgeführt wurde, aber nicht wirklich benötigt wurde. Im Grunde war alles unter dem grünen Block mit der Beschriftung processThreadEvents reine Zeitverschwendung. Das war ein schneller Erfolg. 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 etwa 0, 5 Sekunden und konnte nicht vermieden werden.

Screenshot des Bereichs „Leistung“ in den Entwicklertools, in dem eine andere Instanz des Bereichs „Leistung“ geprüft wird Eine Aufgabe, die mit der Funktion „buildProfileCalls“ verknüpft ist, 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, dass die blaue Linie um die Zeit herum, zu der buildProfileCalls ausgeführt wird, plötzlich einen Sprung macht. Dies deutet auf ein potenzielles Speicherleck hin.

Screenshot des Speicherprofils in den DevTools, in dem der Speicherverbrauch des Bereichs „Leistung“ bewertet wird 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 anderer Bereich in DevTools, der sich vom Bereich „Arbeitsspeicher“ im Bereich „Leistung“ unterscheidet) verwendet. Im Bereich „Speicher“ wurde der Profiling-Typ „Zuweisungsstichprobe“ ausgewählt, wodurch der Heap-Snapshot für den Bereich „Leistung“ aufgezeichnet wurde, in dem das CPU-Profil geladen wird.

Ein Screenshot des ursprünglichen Zustands des Speicherprofils. Die Option „Zuweisungsstichprobe“ ist rot hervorgehoben. Sie ist die beste Option für das JavaScript-Speicherprofiling.

Der folgende Screenshot zeigt den erfassten Heap-Snapshot.

Screenshot des Memory Profilers mit einem arbeitsspeicherintensiven setbasierten Vorgang

Anhand dieses Heap-Snapshots wurde festgestellt, dass die Set-Klasse viel Arbeitsspeicher verbraucht. 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 gar 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 speicherintensive setbasierte Vorgang wurde durch ein einfaches Array ersetzt, wodurch die Speicherkosten erheblich gesenkt 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 verfolgte. 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 in Anspruch nahm, sank von etwa 1,4 Sekunden auf etwa 200 Millisekunden.

Vorher

Screenshot des Bereichs „Leistung“ vor der Optimierung der Funktion „appendEventAtLevel“ Die Gesamtlaufzeit der Funktion betrug 1.372,51 Millisekunden.

Nachher

Screenshot des Bereichs „Leistung“ nach Optimierungen an der Funktion „appendEventAtLevel“ 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. Anhand des Namens der aufgerufenen Funktionen können Sie erkennen, dass diese Blöcke aus Code bestehen, mit dem Bäume erstellt werden (z. B. mit Namen wie refreshTree oder buildChildren). Tatsächlich ist es der zugehörige Code, der die Baumansichten im unteren Bereich des Steuerfelds 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“, „Anrufabfolge“ und „Ereignisprotokoll“ im Navigationsbereich), damit die Bäume angezeigt werden. Wie Sie auf dem Screenshot sehen, 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.

Wir haben bei diesem Bild zwei Probleme festgestellt:

  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 arbeiten auch daran, diese Aufgaben zu cachen.

Fünfte Aktivitätsgruppe: Vermeiden Sie nach Möglichkeit komplexe Aufrufhierarchien

Bei genauerer Betrachtung dieser Gruppe wurde klar, dass eine bestimmte Aufrufabfolge wiederholt aufgerufen wurde. Dasselbe Muster trat sechsmal an verschiedenen Stellen im Flammendiagramm auf 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 es mehrmals passierte, 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 bleiben. 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 Bereichs „Leistung“ mit dem Trace-Ladevorgang vor den Optimierungen 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 zuerst zu tun ist, 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 sich um das native Web-Profilierungstool im Browser handelt. Es wird aktiv gepflegt, 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, Leistungseinbußen 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 der einfachste Schritt.

4. Datenstrukturen effizient nutzen

Verwenden Sie Datenstrukturen, um die Leistung zu optimieren. Berücksichtigen Sie aber 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. So können Sie Regressionen proaktiv erkennen und die Zuverlässigkeit insgesamt verbessern, um langfristig erfolgreich zu sein.