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

Benedikt Meurer
Benedikt Meurer

Webentwickler erwarten bei der Fehlerbehebung in ihrem Code kaum oder gar keine Auswirkungen auf die Leistung. Diese Erwartung ist jedoch keineswegs universell. Ein C++-Entwickler würde nie erwarten, dass ein Debug-Build seiner Anwendung die Produktionsleistung erreicht, und in den frühen Jahren von Chrome hatte das Öffnen der Entwicklertools erheblichen Einfluss auf die Leistung der Seite.

Dass diese Leistungseinbußen nicht mehr zu spüren sind, ist das Ergebnis der jahrelangen Investitionen in die Debugging-Funktionen der DevTools und V8. Trotzdem wird es nie möglich sein, den Leistungsaufwand der Entwicklertools auf null zu reduzieren. Das Festlegen von Haltepunkten, das Durchlaufen des Codes, das Erfassen von Stacktraces, das Erfassen eines Leistungs-Trace usw. – all das wirkt sich unterschiedlich stark auf die Ausführungsgeschwindigkeit aus. Schließlich wird es durch Beobachten verändert.

Aber natürlich sollte der Aufwand für die Entwicklertools – wie jeder Debugger auch – angemessen sein. In letzter Zeit haben wir einen deutlichen Anstieg bei der Anzahl der Berichte festgestellt, die besagen, dass die Entwicklertools die Anwendung in bestimmten Fällen so stark verlangsamen würden, dass sie nicht mehr nutzbar ist. Unten sehen Sie einen Vergleich aus dem Bericht chromium:1069425. Er veranschaulicht den Leistungsaufwand, der entsteht, wenn die Entwicklertools geöffnet sind.

Wie Sie dem Video entnehmen können, hat die Verlangsamung eine Größenordnung von 5–10x, was eindeutig nicht akzeptabel ist. Der erste Schritt bestand darin, zu verstehen, wohin die ganze Zeit geht und was diese massive Verlangsamung verursacht hat, während die Entwicklertools geöffnet waren. Beim Einsatz von Linux perf für den Chrome-Renderer-Prozess ergab sich die folgende Verteilung der Gesamtausführungszeit des Renderers:

Chrome Renderer-Ausführungszeit

Wir hatten zwar etwas im Zusammenhang mit dem Erfassen von Stacktraces erwartet, hätten aber nicht erwartet, dass etwa 90% der gesamten Ausführungszeit auf die Symbolisierung von Stackframes entfallen. Die Symbolisierung bezieht sich hier auf die Auflösung von Funktionsnamen und konkreten Quellpositionen – Zeilen- und Spaltennummern in Skripts – aus Roh-Stackframes.

Inferenz des Methodennamens

Noch überraschender war, dass fast immer die Funktion JSStackFrame::GetMethodName() in V8 verwendet wird. Aus früheren Untersuchungen wussten wir jedoch, dass JSStackFrame::GetMethodName() in Sachen Leistungsprobleme kein Fremdwort ist. Diese Funktion versucht, den Namen der Methode für Frames zu berechnen, die als Methodenaufrufe betrachtet werden (Frames, die Funktionsaufrufe im Format obj.func() statt func() darstellen). Ein kurzer Blick auf den Code zeigte, dass es funktioniert. Dazu wird das Objekt und seine Prototypkette vollständig durchlaufen und

  1. Daten-Properties, deren value die func-Schließung ist, oder
  2. Accessor-Eigenschaften, bei denen get oder set der func-Schließung entspricht.

Das klingt zwar nicht besonders günstig, aber es klingt nicht so, als würde es diesen schrecklichen Abschwung erklären. Also haben wir uns das in chromium:1069425 gemeldete Beispiel genauer angesehen. Dabei haben wir festgestellt, dass die Stacktraces für asynchrone Aufgaben sowie für Logeinträge von classes.js – einer JavaScript-Datei mit einer Größe von 10 MiB – erfasst wurden. Bei einer genaueren Betrachtung stellte sich heraus, dass dies im Grunde eine Java-Laufzeit plus Anwendungscode war, die in JavaScript kompiliert wurde. Die Stacktraces enthielten mehrere Frames mit Methoden, die auf einem Objekt A aufgerufen wurden. Daher dachten wir, es lohnt sich, zu verstehen, mit welcher Art von Objekt wir es zu tun haben.

Stacktraces eines Objekts

Offensichtlich hat der Java-zu-JavaScript-Compiler ein einzelnes Objekt mit beeindruckenden 82.203 Funktionen generiert. Das wurde offensichtlich interessant. Als Nächstes kehrten wir zu JSStackFrame::GetMethodName() von V8 zurück, um zu prüfen, ob wir dort etwas ungewolltes Obst pflücken können.

  1. Dabei wird zuerst die "name" der Funktion als Eigenschaft des Objekts gesucht. Wenn sie gefunden wird, wird geprüft, ob der Eigenschaftswert mit der Funktion übereinstimmt.
  2. Wenn die Funktion keinen Namen hat oder das Objekt keine passende Eigenschaft hat, wird auf einen umgekehrten Lookup zurückgegriffen, indem alle Eigenschaften des Objekts und seiner Prototypen durchsucht werden.

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

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

Die erste Erkenntnis war, dass der umgekehrte Lookup in zwei Schritte aufgeteilt wurde (für das Objekt selbst und jedes Objekt in seiner Prototyp-Kette durchgeführt):

  1. Extrahieren Sie die Namen aller aufzählbaren Attribute und
  2. Führen Sie einen generischen Property-Lookup für jeden Namen durch und testen Sie, ob der sich daraus ergebende Eigenschaftswert mit dem gesuchten Abschluss übereinstimmt.

Das sah nach einer ziemlich niedrig hängenden Frücht aus, da zum Extrahieren der Namen bereits alle Eigenschaften durchgegangen werden müssen. Anstatt die beiden Durchgänge – O(N) für die Namensextraktion und O(N log(N)) für die Tests, können wir alles in einem einzigen Durchlauf durchführen und die Attributwerte direkt prüfen. Dadurch wurde die gesamte Funktion etwa 2- bis 10-mal schneller.

Die zweite Erkenntnis war sogar noch interessanter. Die Funktionen waren zwar technisch gesehen anonyme Funktionen, die V8-Engine hatte jedoch einen sogenannten abgeleiteten Namen für sie aufgezeichnet. Bei Funktionsliteralen, die auf der rechten Seite von Zuweisungen im Format obj.foo = function() {...} angezeigt werden, speichert der V8-Parser "obj.foo" als abgeleiteten Namen für das Funktionsliteral. In unserem Fall bedeutet dies, dass wir zwar nicht den Eigennamen hatten, den wir einfach nachschlagen konnten, aber wir hatten etwas nahe genug: Für das A.SDV = function() {...}-Beispiel oben hatten wir den "A.SDV" als abgeleiteten Namen und wir konnten den Eigenschaftsnamen aus dem abgeleiteten Namen ableiten, indem wir nach dem letzten Punkt suchen und dann nach der Eigenschaft "SDV" auf dem Objekt suchen. So ließ sich in fast allen Fällen ein kostspieliger vollständiger Durchlauf mit einer einzelnen Property-Suche ersetzen. Diese beiden Verbesserungen landeten in dieser CL. Dadurch wurde die Verlangsamung für das Beispiel in chromium:1069425 deutlich reduziert.

Error.stack

Wir hätten es hier geschafft. Aber es gab etwas Verdächtiges, da die Entwicklertools den Methodennamen nie für Stack-Frames verwenden. Die Klasse v8::StackFrame in der C++ API bietet keine Möglichkeit, zum Methodennamen zu gelangen. Daher schien es falsch, dass wir überhaupt JSStackFrame::GetMethodName() anrufen. Stattdessen verwenden und veröffentlichen wir den Methodennamen nur in der JavaScript-Stacktrace API. Sehen Sie sich das folgende einfache error-methodname.js-Beispiel an, um diese Verwendung zu verstehen:

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

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

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

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

Hier sehen Sie die Methodennamensuche im Spiel: Der oberste Stackframe zeigt, wie er die Funktion foo in einer Instanz von Object über die Methode bar aufruft. Bei der nicht standardmäßigen error.stack-Property wird JSStackFrame::GetMethodName() intensiv genutzt. Tatsächlich haben unsere Leistungstests aber auch ergeben, dass durch unsere Änderungen der Vorgang erheblich beschleunigt wurde.

Beschleunigung der StackTrace-Mikro-Benchmarks

Aber zurück zu den Chrome-Entwicklertools: Die Tatsache, dass der Methodenname berechnet wird, obwohl error.stack nicht verwendet wird, scheint nicht richtig zu sein. Aus der Geschichte kann uns helfen: Früher verfügte V8 über zwei verschiedene Mechanismen, um einen Stacktrace für die beiden oben beschriebenen APIs zu erfassen und darzustellen: die C++ v8::StackFrame API und die JavaScript-Stacktrace API. Zwei verschiedene Möglichkeiten zu haben, war (ungefähr) fehleranfällig und führte häufig zu Inkonsistenzen und Fehlern. Daher haben wir Ende 2018 ein Projekt gestartet, bei dem wir einen einzelnen Engpass für die Erfassung von Stacktraces beheben.

Dieses Projekt war ein großer Erfolg und hat die Anzahl der Probleme im Zusammenhang mit der Erfassung von Stacktraces drastisch reduziert. Die meisten Informationen, die über das nicht standardmäßige error.stack-Attribut bereitgestellt wurden, wurden ebenfalls verzögert berechnet, und zwar nur dann, wenn sie wirklich benötigt wurden. Im Rahmen der Refaktorierung haben wir den gleichen Trick auf v8::StackFrame-Objekte angewendet. Alle Informationen über den Stack-Frame werden berechnet, wenn zum ersten Mal eine Methode für den Stack-Frame aufgerufen wird.

Dadurch wird im Allgemeinen die Leistung verbessert. Leider stellte sich dadurch heraus, dass die Verwendung dieser C++ API-Objekte in Chromium und den Entwicklertools etwas entgegengesetzt wurde. Seit wir eine neue v8::internal::StackFrameInfo-Klasse eingeführt haben, die alle Informationen zu einem Stackframe enthielt, der entweder über v8::StackFrame oder über error.stack verfügbar war, wurde immer die Obermenge der von beiden APIs bereitgestellten Informationen berechnet. Das bedeutet, dass wir bei Verwendung von v8::StackFrame (insbesondere für Entwicklertools) auch den Methodennamen berechnen, sobald Informationen zu einem Stackframe angefordert wurden. Es stellt sich heraus, dass die Entwicklertools immer sofort Quell- und Skriptinformationen anfordern.

Basierend auf dieser Erkenntnis konnten wir die Darstellung von Stack-Frames refaktorieren und drastisch vereinfachen und sie noch fauler machen, sodass die Nutzung in V8 und Chromium jetzt nur noch die Kosten für die Berechnung der angeforderten Informationen zahlt. Dies führte zu einer enormen Leistungssteigerung für die Entwicklertools und andere Chromium-Anwendungsfälle, für die nur ein Bruchteil der Informationen über Stack-Frames benötigt wurde (im Wesentlichen nur der Skriptname und der Quellort in Form eines Zeilen- und Spaltenversatzes), und eröffnete den Weg für weitere Leistungsverbesserungen.

Funktionsnamen

Da die oben erwähnten Refaktorierungen außer Acht gelassen wurden, reduzierte sich der Aufwand für die Symbolisierung (die in v8_inspector::V8Debugger::symbolize aufgewendete Zeit) auf etwa 15% der gesamten Ausführungszeit. Wir konnten deutlicher erkennen, wo V8 Zeit verbrachte, wenn sie Stack-Frames für die Nutzung in den Entwicklertools erfasst und symbolisiert hatten.

Kosten für Symbolisierung

Das Erste, was auffiel, waren die kumulativen Kosten für die Berechnung der Zeilen- und Spaltennummer. Der teure Teil hier ist die Berechnung des Zeichen-Offsets im Skript (basierend auf dem Bytecode-Offset, das wir von V8 erhalten), und es stellte sich heraus, dass wir aufgrund unserer Refaktorierung oben zweimal getan haben, einmal bei der Berechnung der Zeilennummer und ein weiteres Mal bei der Berechnung der Spaltennummer. Das Caching der Quellposition für v8::internal::StackFrameInfo-Instanzen hat dazu beigetragen, dies schnell zu beheben, und v8::internal::StackFrameInfo::GetColumnNumber aus allen Profilen vollständig entfernt.

Interessanter war für uns, dass der Wert von v8::StackFrame::GetFunctionName in allen Profilen, die wir uns angesehen haben, überraschend hoch war. Bei genauerer Betrachtung stellten wir fest, dass es unnötig teuer war, den Namen zu berechnen, den wir für die Funktion im Stack-Frame in den Entwicklertools anzeigen würden.

  1. Wenn wir zuerst nach der nicht standardmäßigen "displayName"-Property suchen. Wenn diese eine Daten-Property mit einem Stringwert ergibt, würden wir diese verwenden.
  2. Andernfalls wird nach der Standard-"name"-Property gesucht und geprüft, ob diese eine Daten-Property liefert, deren Wert ein String ist.
  3. und schließlich auf einen internen Debug-Namen zurück, der vom V8-Parser abgeleitet und im Funktionsliteral gespeichert wird.

Das Attribut "displayName" wurde als Behelfslösung für die Eigenschaft "name" auf Function-Instanzen hinzugefügt, die in JavaScript schreibgeschützt und nicht konfigurierbar sind.Es wurde jedoch nie standardisiert und nicht weitverbreitet, da die Entwicklertools des Browsers Funktionsnamen hinzugefügt haben, die in 99,9% der Fälle funktionieren. Darüber hinaus wurde in ES2015 das Attribut "name" für Function Instanzen konfigurierbar, sodass keine spezielle "displayName"-Eigenschaft mehr erforderlich ist. Da die negative Suche nach "displayName" kostspielig und nicht wirklich notwendig ist (ES2015 wurde vor über fünf Jahren veröffentlicht), haben wir beschlossen, die Unterstützung für die nicht standardmäßige fn.displayName-Property aus V8 (und den Entwicklertools) zu entfernen.

Da die negative Suche nach "displayName" nicht funktionierte, entfiel die Hälfte der Kosten für v8::StackFrame::GetFunctionName. Die andere Hälfte wird für die generische "name"-Property-Suche verwendet. Glücklicherweise hatten wir bereits eine Logik, um ein kostspieliges Lookup von "name"-Attributen bei (unberührten) Function-Instanzen zu vermeiden, die wir vor einiger Zeit in V8 eingeführt haben, um Function.prototype.bind() selbst schneller zu machen. Wir haben die erforderlichen Prüfungen portiert, mit denen wir die kostspielige generische Suche von vornherein überspringen können. Dadurch wurde v8::StackFrame::GetFunctionName in keinem Profil mehr angezeigt, das wir in Betracht gezogen haben.

Fazit

Mit den oben genannten Verbesserungen haben wir den Aufwand der Entwicklertools in Bezug auf Stacktraces erheblich reduziert.

Es gibt immer noch verschiedene mögliche Verbesserungen. Beispielsweise ist der Aufwand bei der Verwendung von MutationObservers immer noch bemerkbar, wie in chromium:1077657 gemeldet. In der Zwischenzeit haben wir die größten Problempunkte angegangen und werden möglicherweise in Zukunft die Leistung der Fehlerbehebung weiter optimieren.

Vorschaukanäle herunterladen

Du kannst Chrome Canary, Dev oder Beta als Standardbrowser für die Entwicklung verwenden. Mit diesen Vorschaukanälen erhalten Sie Zugriff auf die neuesten Funktionen der Entwicklertools, können bahnbrechende Webplattform-APIs testen und Probleme auf Ihrer Website erkennen, noch bevor Ihre Nutzer dies tun.

Chrome-Entwicklertools-Team kontaktieren

Verwende die folgenden Optionen, um die neuen Funktionen und Änderungen im Beitrag oder andere Themen im Zusammenhang mit den Entwicklertools zu besprechen.

  • Sende uns über crbug.com einen Vorschlag oder Feedback.
  • Wenn du ein Problem mit den Entwicklertools melden möchtest, klicke in den Entwicklertools auf Weitere Optionen   Mehr   > Hilfe > Probleme mit Entwicklertools melden.
  • Senden Sie einen Tweet an @ChromeDevTools.
  • Hinterlasse Kommentare unter YouTube-Videos oder YouTube-Videos mit Tipps zu DevTools.