So haben wir die Stacktraces der Chrome-Entwicklertools um das Zehnfache beschleunigt

Benedikt Meurer
Benedikt Meurer

Webentwickler erwarten kaum oder gar keine Leistungseinbußen beim Debuggen ihres Codes. Diese Erwartung ist jedoch keineswegs universell. Ein C++-Entwickler würde nie erwarten, dass ein Debug-Build seiner Anwendung die Produktionsleistung erreicht. In den frühen Jahren von Chrome hatte das Öffnen von DevTools bereits einen erheblichen Einfluss auf die Leistung der Seite.

Dass diese Leistungseinbußen nicht mehr spürbar sind, ist das Ergebnis jahrelanger Investitionen in die Debugging-Funktionen von DevTools und V8. Trotzdem werden wir den Leistungsoverhead von DevTools nie auf null reduzieren können. Das Festlegen von Haltepunkten, das Durcharbeiten des Codes, das Erfassen von Stacktraces und das Erfassen eines Leistungs-Traces wirken sich unterschiedlich auf die Ausführungsgeschwindigkeit aus. Schließlich ändert sich die Beobachtung von etwas.

Aber natürlich sollte der Aufwand für die Entwicklertools, wie bei jedem Debugger, angemessen sein. In letzter Zeit haben wir eine deutliche Zunahme der Meldungen erhalten, dass DevTools die Anwendung in bestimmten Fällen so stark verlangsamt, dass sie nicht mehr verwendet werden kann. Unten sehen Sie einen direkten Vergleich aus dem Bericht chromium:1069425, der den Leistungsoverhead zeigt, der durch das bloße Öffnen der DevTools entsteht.

Wie Sie im Video sehen können, ist die Verzögerung 5- bis 10-mal höher als normal, was natürlich nicht akzeptabel ist. Der erste Schritt bestand darin, herauszufinden, wo die Zeit verloren geht und was diese massive Verlangsamung beim Öffnen der DevTools verursacht. Mit Linux perf für den Chrome-Renderer-Prozess wurde die folgende Verteilung der Gesamtausführungszeit des Renderers ermittelt:

Ausführungszeit des Chrome-Renderers

Wir hatten zwar erwartet, dass etwas mit dem Erfassen von Stack-Traces zusammenhängt, aber nicht, dass etwa 90 % der gesamten Ausführungszeit für die Symbolisierung von Stack-Frames aufgewendet werden. Die Symbolisierung bezieht sich hier auf die Auflösung von Funktionsnamen und konkreten Quellpositionen (Zeilen- und Spaltennummern in Scripts) aus Roh-Stack-Frames.

Methodennamen-Inferenz

Noch überraschender war, dass fast die gesamte Zeit auf die JSStackFrame::GetMethodName()-Funktion in V8 entfiel. Wir wussten jedoch aus vorherigen Untersuchungen, dass JSStackFrame::GetMethodName() kein Unbekannter in der Welt der Leistungsprobleme ist. Mit dieser Funktion wird versucht, den Namen der Methode für Frames zu berechnen, die als Methodeaufrufe betrachtet werden (Frames, die Funktionsaufrufe vom Typ obj.func() statt func() darstellen). Ein kurzer Blick in den Code hat ergeben, dass das Objekt und seine Prototypkette vollständig durchlaufen und nach

  1. Dateneigenschaften, deren value die func-Schließung sind, oder
  2. Zugriffseigenschaften, bei denen entweder get oder set der func-Schließung entspricht.

Das klingt zwar an sich nicht besonders billig, aber es klingt auch nicht so, als würde es diese schreckliche Verlangsamung erklären. Deshalb haben wir uns das in chromium:1069425 gemeldete Beispiel genauer angesehen und festgestellt, dass die Stacktraces sowohl für asynchrone Aufgaben als auch für Lognachrichten von classes.js erfasst wurden – einer 10 MiB großen JavaScript-Datei. Bei genauerer Betrachtung zeigte sich, dass es sich im Grunde um eine Java-Laufzeit plus Anwendungscode handelt, der in JavaScript kompiliert wurde. Die Stack-Traces enthielten mehrere Frames mit Methoden, die auf ein Objekt A aufgerufen wurden. Daher wollten wir herausfinden, um welche Art von Objekt es sich handelt.

Stacktraces eines Objekts

Offenbar hatte der Java-zu-JavaScript-Compiler ein einzelnes Objekt mit 82.203 Funktionen generiert. Das wurde langsam interessant. Als Nächstes haben wir uns die JSStackFrame::GetMethodName() von V8 noch einmal angesehen, um herauszufinden, ob es dort noch einfache Verbesserungsmöglichkeiten gibt.

  1. Dazu wird zuerst die "name" der Funktion als Attribut des Objekts ermittelt. Wenn sie gefunden wird, wird geprüft, ob der Attributwert mit der Funktion übereinstimmt.
  2. Wenn die Funktion keinen Namen hat oder das Objekt keine übereinstimmende Eigenschaft hat, wird eine umgekehrte Suche durchgeführt, bei der alle Eigenschaften des Objekts und seiner Prototypen durchlaufen werden.

In unserem Beispiel sind alle Funktionen anonym und haben leere "name"-Attribute.

A.SDV = function() {
   // ...
};

Die erste Erkenntnis war, dass die Rückwärtssuche in zwei Schritte unterteilt wurde (für das Objekt selbst und jedes Objekt in der Prototypkette ausgeführt):

  1. Extrahieren Sie die Namen aller aufzählbaren Properties und
  2. Führen Sie für jeden Namen eine allgemeine Property-Suche durch und prüfen Sie, ob der resultierende Property-Wert mit der gesuchten Schließung übereinstimmt.

Das schien eine relativ einfache Aufgabe zu sein, da zum Extrahieren der Namen alle Unterkünfte durchgegangen werden müssen. Anstatt zwei Durchläufe auszuführen – O(N) für die Namensextraktion und O(N log(N)) für die Tests – könnten wir alles in einem Durchlauf erledigen und die Property-Werte direkt prüfen. Dadurch war die gesamte Funktion um das 2- bis 10-Fache schneller.

Das zweite Ergebnis war noch interessanter. Obwohl es sich technisch gesehen um anonyme Funktionen handelt, hat die V8-Engine für sie einen sogenannten abgeleiteten Namen erfasst. Bei Funktionsliteralen, die rechts neben Zuweisungen in der Form obj.foo = function() {...} erscheinen, speichert der V8-Parser "obj.foo" als abgeleiteten Namen für das Funktionsliteral. In unserem Fall bedeutet das, dass wir zwar keinen korrekten Namen hatten, den wir einfach nachschlagen konnten, aber einen ziemlich ähnlichen: Im Beispiel A.SDV = function() {...} oben hatten wir "A.SDV" als abgeleiteten Namen. Wir konnten den Property-Namen aus dem abgeleiteten Namen ableiten, indem wir nach dem letzten Punkt suchten und dann nach der Property "SDV" auf dem Objekt suchten. Das hat in fast allen Fällen funktioniert und eine teure vollständige Durchsuchung durch eine einzelne Property-Suche ersetzt. Diese beiden Verbesserungen wurden im Rahmen dieser CL eingeführt und haben die Verlangsamung für das in chromium:1069425 beschriebene Beispiel erheblich reduziert.

Error.stack

Wir hätten hier aufhören können. Es gab jedoch etwas Fehlerhaftes, da DevTools den Methodennamen für Stack-Frames nie verwendet. Tatsächlich gibt es in der C++ API für die Klasse v8::StackFrame nicht einmal eine Möglichkeit, den Methodennamen abzurufen. Daher erschien es uns falsch, dass wir JSStackFrame::GetMethodName() überhaupt anrufen würden. Der Methodenname wird stattdessen nur in der JavaScript Stacktrace API verwendet (und sichtbar gemacht). Das folgende einfache Beispiel error-methodname.js veranschaulicht diese Verwendung:

function foo() {
    console.log((new Error).stack);
}

var object = {bar: foo};
object.bar();

Hier sehen wir eine Funktion foo, die unter dem Namen "bar" auf object installiert ist. Wenn Sie dieses Snippet in Chromium ausführen, erhalten Sie die folgende Ausgabe:

Error
    at Object.foo [as bar] (error-methodname.js:2)
    at error-methodname.js:6

Hier sehen wir die Suche nach dem Methodennamen: Der oberste Stack-Frame ruft die Funktion foo über die Methode bar auf einer Instanz von Object auf. Die nicht standardmäßige error.stack-Property nutzt also JSStackFrame::GetMethodName() intensiv. Unsere Leistungstests haben außerdem gezeigt, dass unsere Änderungen die Leistung deutlich verbessert haben.

Beschleunigung bei Mikro-Benchmarks für Stack-Traces

Zurück zu den Chrome-Entwicklertools: Der Name der Methode wird berechnet, obwohl error.stack nicht verwendet wird. Das ist nicht richtig. Hier ist ein wenig Hintergrundwissen hilfreich: Traditionell gab es in V8 zwei unterschiedliche Mechanismen zum Erfassen und Darstellen eines Stack-Traces für die beiden oben beschriebenen APIs (die C++-v8::StackFrame API und die JavaScript-Stack-Trace API). Zwei verschiedene Möglichkeiten, (in etwa) dasselbe zu tun, waren fehleranfällig und führten oft zu Inkonsistenzen und Fehlern. Deshalb haben wir Ende 2018 ein Projekt gestartet, um ein einziges Nadelöhr für die Erfassung von Stacktraces zu finden.

Dieses Projekt war ein großer Erfolg und die Anzahl der Probleme im Zusammenhang mit der Stack-Trace-Erfassung konnte drastisch reduziert werden. Die meisten Informationen, die über die nicht standardmäßige error.stack-Property bereitgestellt wurden, wurden ebenfalls nur bei Bedarf und verzögert berechnet. Im Rahmen des Refaktorings haben wir diesen Trick jedoch auch auf v8::StackFrame-Objekte angewendet. Alle Informationen zum Stackframe werden berechnet, wenn eine Methode zum ersten Mal darauf aufgerufen wird.

Dies verbessert in der Regel die Leistung, aber leider widerspricht es der Art und Weise, wie diese C++ API-Objekte in Chromium und DevTools verwendet werden. Insbesondere da wir eine neue v8::internal::StackFrameInfo-Klasse eingeführt hatten, die alle Informationen zu einem Stack-Frame enthielt, die entweder über v8::StackFrame oder über error.stack freigegeben wurden, berechneten wir immer die Übermenge der von beiden APIs bereitgestellten Informationen. Das bedeutete, dass bei der Verwendung von v8::StackFrame (und insbesondere in DevTools) auch der Methodenname berechnet wurde, sobald Informationen zu einem Stack-Frame angefordert wurden. Wie sich herausstellt, fordert die Entwicklertools immer sofort Quell- und Skriptinformationen an.

Basierend auf dieser Erkenntnis konnten wir die Darstellung von Stackframes erheblich vereinfachen und die Darstellung noch fauler machen, sodass bei der Nutzung in V8 und Chromium nur noch die Kosten für die Berechnung der angeforderten Informationen anfallen. Dies führte zu einer enormen Leistungssteigerung für die DevTools und andere Chromium-Anwendungsfälle, für die nur ein Bruchteil der Informationen zu Stackframes benötigt wird (im Wesentlichen nur der Scriptname und der Quellspeicherort in Form von Zeilen- und Spaltenoffset). Außerdem eröffnete dies die Möglichkeit für weitere Leistungsverbesserungen.

Funktionsnamen

Nachdem die oben genannten Refactorings abgeschlossen waren, wurde der Overhead der Symbolisierung (die Zeit, die in v8_inspector::V8Debugger::symbolize verbracht wurde) auf etwa 15 % der Gesamtausführungszeit reduziert. Außerdem konnten wir besser nachvollziehen, wo V8 Zeit beim Erfassen und Symbolisieren von Stackframes für die Verwendung in DevTools verbrauchte.

Kosten für die Symbolisierung

Als Erstes fielen mir die kumulativen Kosten für die Berechnung der Zeilen- und Spaltennummer auf. Der teure Teil hier ist die Berechnung des Zeichen-Offsets im Skript (basierend auf dem Bytecode-Offset, das wir von V8 erhalten). Es stellte sich heraus, dass wir dies aufgrund unserer Refaktorierung oben zweimal durchgeführt haben, einmal bei der Berechnung der Zeilennummer und einmal bei der Berechnung der Spaltennummer. Durch das Caching der Quellposition in v8::internal::StackFrameInfo-Instanzen konnte das Problem schnell behoben und v8::internal::StackFrameInfo::GetColumnNumber vollständig aus allen Profilen entfernt werden.

Interessanter war für uns, dass v8::StackFrame::GetFunctionName in allen Profilen, die wir uns angesehen haben, überraschend hoch lag. Bei der näheren Untersuchung haben wir festgestellt, dass es unnötig aufwendig war, den Namen zu berechnen, den wir für die Funktion im Stack Frame in DevTools anzeigen wollten.

  1. zuerst nach der nicht standardmäßigen "displayName"-Property suchen. Wenn diese eine Datenproperty mit einem Stringwert liefert, wird diese verwendet.
  2. andernfalls wird nach der Standard-"name"-Property gesucht und noch einmal geprüft, ob diese eine Datenproperty mit einem Stringwert liefert.
  3. und greift schließlich auf einen internen Debug-Namen zurück, der vom V8-Parser abgeleitet und im Funktionsliteral gespeichert wird.

Das Attribut "displayName" wurde als Problemumgehung für das Attribut "name" hinzugefügt, da Function-Instanzen in JavaScript nur lesbar und nicht konfigurierbar sind. Es wurde jedoch nie standardisiert und fand keine breite Verwendung, da die Browser-Entwicklertools eine Funktion zur Namensableitung enthalten, die in 99,9 % der Fälle funktioniert. Außerdem wurde in ES2015 die Property "name" in Function-Instanzen konfigurierbar, sodass keine spezielle "displayName"-Property mehr erforderlich ist. Da die negative Suche nach "displayName" recht kostspielig und nicht wirklich notwendig ist (ES2015 wurde vor über fünf Jahren veröffentlicht), haben wir beschlossen, den Support für die nicht standardmäßige Eigenschaft fn.displayName aus V8 (und den DevTools) zu entfernen.

Da die negative Suche nach "displayName" nicht mehr erforderlich ist, wurden die Hälfte der Kosten für v8::StackFrame::GetFunctionName entfernt. Die andere Hälfte wird an die allgemeine "name"-Property-Suche weitergeleitet. Glücklicherweise hatten wir bereits eine Logik implementiert, um kostspielige Suchanfragen der "name"-Eigenschaft in (unberührten) Function-Instanzen zu vermeiden. Diese Logik wurde vor einiger Zeit in V8 eingeführt, um Function.prototype.bind() selbst schneller zu machen. Wir haben die erforderlichen Prüfungen mitgenommen, sodass wir die kostspielige generische Suche erst einmal überspringen können, was dazu führt, dass v8::StackFrame::GetFunctionName in keinem der Profile, die wir in Betracht gezogen haben, mehr angezeigt wird.

Fazit

Mit den oben genannten Verbesserungen haben wir den Aufwand für die Entwicklertools in Bezug auf Stacktraces deutlich reduziert.

Wir wissen, dass es noch verschiedene Verbesserungsmöglichkeiten gibt. So ist beispielsweise der Overhead bei der Verwendung von MutationObservers immer noch spürbar, wie in chromium:1077657 beschrieben. Vorerst haben wir jedoch die wichtigsten Probleme behoben. Wir werden uns in Zukunft möglicherweise noch einmal damit befassen, um die Debugging-Leistung weiter zu optimieren.

Vorschaukanäle herunterladen

Verwenden Sie als Standard-Entwicklungsbrowser Chrome Canary, Chrome Dev oder Chrome Beta. Diese Vorabversionen bieten Zugriff auf die neuesten DevTools-Funktionen, ermöglichen den Test moderner Webplattform-APIs und helfen Ihnen, Probleme auf Ihrer Website zu finden, bevor Ihre Nutzer sie bemerken.

Chrome-Entwicklertools-Team kontaktieren

Mit den folgenden Optionen können Sie über neue Funktionen, Updates oder andere Themen im Zusammenhang mit den DevTools sprechen.