RenderingNG im Detail: LayoutNG

Ian Kilpatrick
Ian Kilpatrick
Koji Ishi
Koji Ishi

Ich bin Ian Kilpatrick und leite zusammen mit Koji Ishii das Engineering-Team des Blink-Layouts. Bevor ich im Blink-Team arbeitete, war ich Frontend-Entwickler (damals gab es bei Google noch keine Position als „Frontend-Entwickler“). Ich habe Funktionen in Google Docs, Drive und Gmail entwickelt. Nach etwa fünf Jahren in dieser Position wagte ich ein großes Risiko und wechselte zum Blink-Team. Dort lernte ich C++ im Job und versuchte, mich mit der extrem komplexen Blink-Codebasis vertraut zu machen. Selbst heute verstehe ich nur einen relativ kleinen Teil davon. Ich bin Ihnen für die Zeit, die Sie mir in dieser Zeit geben, sehr dankbar. Ich fand es beruhigend, dass viele „rekonvaleszente Frontend-Entwickler“ vor mir den Wechsel zu „Browser-Entwicklern“ vollzogen hatten.

Meine bisherigen Erfahrungen haben mich persönlich im Blink-Team geleitet. Als Frontend-Entwickler stieß ich ständig auf Browserinkonsistenzen, Leistungsprobleme, Rendering-Fehler und fehlende Funktionen. LayoutNG war für mich eine Gelegenheit, diese Probleme im Layoutsystem von Blink systematisch zu beheben. Es ist die Summe der Bemühungen vieler Entwickler im Laufe der Jahre.

In diesem Beitrag erkläre ich, wie eine solche große Architekturänderung verschiedene Arten von Fehlern und Leistungsproblemen reduzieren und abmildern kann.

Ein allgemeiner Überblick über Layout-Engine-Architekturen

Bisher war das Layout-Baum von Blink ein sogenannter „veränderlicher Baum“.

Der Baum wird wie im folgenden Text beschrieben angezeigt.

Jedes Objekt im Layoutbaum enthielt Eingabeinformationen, z. B. die von einem übergeordneten Element auferlegte verfügbare Größe, die Position aller Floating-Elemente und Ausgabeinformationen, z. B. die endgültige Breite und Höhe des Objekts oder seine X- und Y-Position.

Diese Objekte wurden zwischen den Rendern beibehalten. Wenn eine Stiländerung auftrat, haben wir dieses Objekt und alle übergeordneten Elemente im Baum als schmutzig markiert. Wenn die Layoutphase der Rendering-Pipeline ausgeführt wurde, haben wir den Baum bereinigt, alle schmutzigen Objekte durchgegangen und dann das Layout ausgeführt, um sie in einen sauberen Zustand zu versetzen.

Wir haben festgestellt, dass diese Architektur zu vielen Arten von Problemen geführt hat, die wir unten beschreiben. Aber zuerst wollen wir uns die Eingaben und Ausgaben des Layouts ansehen.

Wenn das Layout für einen Knoten in diesem Baum ausgeführt wird, werden konzeptionell „Stil und DOM“ sowie alle übergeordneten Einschränkungen aus dem übergeordneten Layoutsystem (Raster, Block oder Flex) verwendet, der Algorithmus für Layouteinschränkungen wird ausgeführt und ein Ergebnis wird ausgegeben.

Das zuvor beschriebene Konzeptmodell.

Unsere neue Architektur formalisiert dieses konzeptionelle Modell. Wir haben den Layout-Baum weiterhin, verwenden ihn aber hauptsächlich, um die Eingaben und Ausgaben des Layouts zu speichern. Für die Ausgabe generieren wir ein völlig neues, unveränderliches Objekt namens Fragmentbaum.

Der Fragmentbaum.

Ich habe bereits den unveränderlichen Fragmentbaum beschrieben, der so konzipiert ist, dass große Teile des vorherigen Baums für inkrementelle Layouts wiederverwendet werden.

Außerdem speichern wir das übergeordnete Objekt mit den Einschränkungen, aus dem dieses Fragment generiert wurde. Wir verwenden dies als Cache-Schlüssel, auf den wir weiter unten noch näher eingehen.

Der Algorithmus für das Inline-Layout (Text) wurde ebenfalls neu geschrieben, um der neuen unveränderlichen Architektur zu entsprechen. Es erzeugt nicht nur die unveränderliche flache Listendarstellung für das Inline-Layout, sondern bietet auch Caching auf Absatzebene für ein schnelleres Neulayout, eine Form pro Absatz, um Schriftschnitte auf Elemente und Wörter anzuwenden, einen neuen Unicode-bidi-Algorithmus mit ICU, viele Korrekturen und vieles mehr.

Arten von Layoutfehlern

Layoutfehler lassen sich grob in vier verschiedene Kategorien unterteilen, die jeweils unterschiedliche Ursachen haben.

Richtigkeit

Wenn wir an Fehler im Rendering-System denken, denken wir in der Regel an die Korrektheit, z. B.: „Browser A hat Verhalten X, während Browser B Verhalten Y hat“ oder „Browser A und B sind beide fehlerhaft“. Früher haben wir viel Zeit dafür aufgewendet und dabei ständig mit dem System gekämpft. Ein häufiger Fehler war, dass wir eine sehr gezielte Fehlerbehebung für einen Fehler angewendet haben, aber Wochen später feststellen mussten, dass wir eine Regression in einem anderen (scheinbar nicht zusammenhängenden) Teil des Systems verursacht hatten.

Wie in vorherigen Beiträgen beschrieben, ist dies ein Zeichen für ein sehr brüchiges System. Insbesondere für das Layout gab es keine klare Vereinbarung zwischen den Klassen. Das führte dazu, dass Browserentwickler auf einen Status angewiesen waren, der nicht erforderlich war, oder einen Wert aus einem anderen Teil des Systems falsch interpretierten.

Beispielsweise gab es einmal eine Kette von etwa zehn Fehlern im Zusammenhang mit dem Flex-Layout, die sich über mehr als ein Jahr erstreckte. Jede Korrektur führte entweder zu einem Korrektheits- oder Leistungsproblem in einem Teil des Systems, was wiederum zu einem weiteren Fehler führte.

Da LayoutNG die Anforderungen an alle Komponenten im Layoutsystem klar definiert, können wir Änderungen jetzt mit viel größerer Sicherheit vornehmen. Wir profitieren auch sehr vom hervorragenden Web Platform Tests (WPT)-Projekt, mit dem mehrere Parteien zu einer gemeinsamen Web-Testsuite beitragen können.

Heute stellen wir fest, dass wir bei einer echten Regression auf unserem stabilen Kanal in der Regel keine zugehörigen Tests im WPT-Repository finden und dass sie nicht auf einem Missverständnis der Komponentenverträge beruht. Außerdem fügen wir im Rahmen unserer Richtlinie zur Fehlerbehebung immer einen neuen WPT-Test hinzu, um dafür zu sorgen, dass kein Browser denselben Fehler noch einmal macht.

Unter „Außerkraftsetzung“

Wenn Sie schon einmal einen mysteriösen Fehler hatten, bei dem der Fehler durch Ändern der Größe des Browserfensters oder Umschalten einer CSS-Eigenschaft wie von Zauberhand verschwindet, haben Sie ein Problem mit unzureichender Invalidation. Ein Teil des mutable-Baums wurde als sauber eingestuft, aber aufgrund einer Änderung an den übergeordneten Einschränkungen entsprach er nicht der richtigen Ausgabe.

Das ist bei den unten beschriebenen Layoutmodi mit zwei Durchläufen (der Layoutbaum wird zweimal durchlaufen, um den endgültigen Layoutstatus zu ermitteln) sehr häufig der Fall. Bisher sah unser Code so aus:

if (/* some very complicated statement */) {
  child->ForceLayout();
}

Eine Lösung für diese Art von Fehler wäre in der Regel:

if (/* some very complicated statement */ ||
    /* another very complicated statement */) {
  child->ForceLayout();
}

Eine Behebung dieses Problems führte in der Regel zu einer erheblichen Leistungsverschlechterung (siehe unten „Übermäßige Invalidation“) und war sehr schwierig.

Derzeit (wie oben beschrieben) haben wir ein unveränderliches übergeordnetes Objekt mit Einschränkungen, das alle Eingaben vom übergeordneten Layout an das untergeordnete Layout beschreibt. Dieser wird zusammen mit dem resultierenden unveränderlichen Fragment gespeichert. Aus diesem Grund haben wir einen zentralen Ort, an dem wir diese beiden Eingaben diff, um festzustellen, ob für das untergeordnete Element ein weiterer Layout-Durchlauf ausgeführt werden muss. Diese Differenzierungslogik ist kompliziert, aber gut strukturiert. Die Behebung dieser Art von Problemen mit unzureichender Invalidation erfordert in der Regel eine manuelle Prüfung der beiden Eingaben und die Entscheidung, was sich an der Eingabe geändert hat, sodass ein weiterer Layout-Durchlauf erforderlich ist.

Korrekturen an diesem Code zum Vergleichen sind in der Regel einfach und können aufgrund der einfachen Erstellung dieser unabhängigen Objekte leicht mithilfe von Unit-Tests getestet werden.

Vergleich eines Bildes mit fester Breite und eines Bildes mit prozentualer Breite
Bei einem Element mit fester Breite/Höhe spielt es keine Rolle, ob die angegebene verfügbare Größe zunimmt. Bei einem Element mit prozentualer Breite/Höhe ist das jedoch der Fall. available-size ist im Objekt Parent Constraints enthalten und wird im Rahmen des Diff-Algorithmus für diese Optimierung verwendet.

Der Diff-Code für das obige Beispiel lautet:

if (width.IsPercent()) {
  if (old_constraints.WidthPercentageSize() 
    != new_constraints.WidthPercentageSize())
   return kNeedsLayout;
}
if (height.IsPercent()) {
  if (old_constraints.HeightPercentageSize() 
    != new_constraints.HeightPercentageSize())
   return kNeedsLayout;
}

Hysterese

Diese Art von Fehlern ähnelt der Unter-Ungültigmachung. Im vorherigen System war es im Grunde sehr schwierig, dafür zu sorgen, dass das Layout idempotent war, d. h., dass das erneute Ausführen des Layouts mit denselben Eingaben zur selben Ausgabe führte.

Im folgenden Beispiel wechseln wir einfach zwischen zwei Werten einer CSS-Property hin und her. Dies führt jedoch zu einem „unendlich wachsenden“ Rechteck.

Im Video und in der Demo ist ein Hysteresefehler in Chrome 92 und niedriger zu sehen. In Chrome 93 wurde das Problem behoben.

Mit unserem vorherigen veränderbaren Baum war es unglaublich einfach, solche Fehler zu verursachen. Wenn der Code die Größe oder Position eines Objekts zum falschen Zeitpunkt oder in der falschen Phase liest (z. B. weil wir die vorherige Größe oder Position nicht „gelöscht“ haben), fügen wir sofort einen subtilen Hysteresefehler hinzu. Diese Fehler treten in der Regel nicht bei Tests auf, da sich die meisten Tests auf ein einzelnes Layout und Rendering konzentrieren. Noch besorgniserregender war, dass ein Teil dieser Hysterese erforderlich war, damit einige Layoutmodi richtig funktionierten. Es gab Fehler, bei denen wir eine Optimierung durchgeführt haben, um einen Layout-Pass zu entfernen, aber einen „Fehler“ eingeführt haben, da der Layout-Modus zwei Pässe erforderte, um die richtige Ausgabe zu erhalten.

Ein Baum, der die im vorherigen Text beschriebenen Probleme veranschaulicht.
Je nach den Informationen aus dem vorherigen Layoutergebnis kann dies zu nicht idempotenten Layouts führen.

Da wir bei LayoutNG explizite Eingabe- und Ausgabedatenstrukturen haben und der Zugriff auf den vorherigen Zustand nicht zulässig ist, konnten wir diese Art von Fehler im Layoutsystem weitgehend beheben.

Übermäßige Invalidation und Leistung

Dies ist das genaue Gegenteil der Klasse von Fehlern, bei denen zu wenig Daten ungültig gemacht werden. Oft haben wir bei der Behebung eines Fehlers, bei dem zu wenige Einträge ungültig gemacht wurden, eine Leistungseinbußen ausgelöst.

Oft mussten wir schwierige Entscheidungen treffen, bei denen wir die Korrektheit der Leistung vorrangig betrachtet haben. Im nächsten Abschnitt erfahren Sie, wie wir diese Art von Leistungsproblemen behoben haben.

Die Ära der Layouts mit zwei Durchläufen und Leistungseinbrüche

Flex- und Rasterlayouts haben die Ausdruckskraft von Layouts im Web verändert. Diese Algorithmen unterschieden sich jedoch grundlegend vom Algorithmus für das Blocklayout, der ihnen vorausging.

Beim Block-Layout muss die Engine in fast allen Fällen das Layout für alle untergeordneten Elemente nur einmal ausführen. Das ist zwar gut für die Leistung, aber nicht so ausdrucksstark, wie es Webentwickler wünschen.

Beispielsweise möchten Sie oft, dass sich die Größe aller untergeordneten Elemente auf die Größe des größten Elements ausdehnen soll. Dazu führt das übergeordnete Layout (Flex oder Grid) einen Messdurchlauf durch, um zu ermitteln, wie groß jedes der untergeordneten Elemente ist, und dann einen Layoutdurchlauf, um alle untergeordneten Elemente auf diese Größe zu ziehen. Dieses Verhalten ist sowohl für das Flex- als auch für das Grid-Layout standardmäßig aktiviert.

Zwei Gruppen von Feldern: Die erste zeigt die Eigengröße der Felder im Messdurchlauf, die zweite im Layout alle Felder mit gleicher Höhe.

Diese Layouts mit zwei Durchgängen waren anfangs leistungsmäßig akzeptabel, da sie in der Regel nicht tief verschachtelt waren. Mit komplexeren Inhalten traten jedoch erhebliche Leistungsprobleme auf. Wenn Sie das Ergebnis der Messphase nicht im Cache speichern, wechselt der Layout-Baum zwischen dem Mess-Status und dem endgültigen Layout-Status.

Die Layouts mit einem, zwei und drei Durchgängen werden in der Bildunterschrift erläutert.
Im obigen Bild sind drei <div>-Elemente zu sehen. Bei einem einfachen Layout mit nur einem Durchlauf (z. B. Blocklayout) werden drei Layoutknoten besucht (Komplexität O(n)). Bei einem Layout mit zwei Durchläufen (z. B. Flex oder Grid) kann dies jedoch zu einer Komplexität von O(2n) Besuchen für dieses Beispiel führen.
Diagramm, das die exponentielle Steigerung der Layoutzeit zeigt
Dieses Bild und die Demo zeigen ein exponentielles Layout mit einem Rasterlayout. In Chrome 93 wurde das Problem behoben, da Grid auf die neue Architektur umgestellt wurde.

Bisher haben wir versucht, dem Flex- und Rasterlayout sehr spezifische Caches hinzuzufügen, um diese Art von Leistungsabfall zu bekämpfen. Das funktionierte (und wir kamen mit Flex sehr weit), aber wir hatten ständig mit Fehlern zu kämpfen, bei denen die Daten zu oft oder zu selten ungültig gemacht wurden.

Mit LayoutNG können wir sowohl für die Eingabe als auch für die Ausgabe des Layouts explizite Datenstrukturen erstellen. Außerdem haben wir Caches für die Mess- und Layoutpässe erstellt. Dadurch wird die Komplexität wieder auf O(n) zurückgesetzt, was zu einer vorhersehbar linearen Leistung für Webentwickler führt. Wenn für ein Layout drei Durchläufe erforderlich sind, wird auch dieser Durchlauf im Cache gespeichert. Dies kann in Zukunft die Möglichkeit eröffnen, erweiterte Layoutmodi sicher einzuführen. Ein Beispiel dafür, wie RenderingNG die Erweiterbarkeit insgesamt grundlegend verbessert. In einigen Fällen kann ein Grid-Layout drei Durchläufe erfordern, was derzeit jedoch äußerst selten vorkommt.

Wenn Entwickler Leistungsprobleme speziell beim Layout feststellen, liegt das in der Regel an einem Fehler bei der exponentiellen Layoutzeit und nicht am Rohdurchsatz der Layoutphase der Pipeline. Wenn eine kleine inkrementelle Änderung (ein Element ändert eine einzelne CSS-Eigenschaft) zu einem Layout von 50–100 Millisekunden führt, ist das wahrscheinlich ein exponentieller Layoutfehler.

Zusammenfassung

Das Layout ist ein extrem komplexer Bereich. Wir haben nicht alle interessanten Details wie Inline-Layout-Optimierungen (d. h. die Funktionsweise des gesamten Inline- und Text-Subsystems) behandelt. Selbst die hier besprochenen Konzepte haben nur an der Oberfläche gekratzt und viele Details ausgespart. Wir hoffen jedoch, dass wir gezeigt haben, wie die systematische Verbesserung der Architektur eines Systems langfristig zu überdurchschnittlichen Gewinnen führen kann.

Uns ist aber bewusst, dass noch viel Arbeit vor uns liegt. Uns sind verschiedene Probleme bekannt, sowohl in Bezug auf die Leistung als auch auf die Korrektheit, an deren Lösung wir arbeiten. Wir freuen uns auch auf die neuen Layoutfunktionen, die in CSS eingeführt werden. Wir sind der Meinung, dass die Architektur von LayoutNG die Lösung dieser Probleme sicher und praktikabel macht.

Ein Bild (Sie wissen schon welches!) von Una Kravets