Szczegółowa analiza renderowania: układNG

Ian Kilpatrick
Ian Kilpatrick
Koji Ishi
Koji Ishi

Nazywam się Ian Kilpatrick i razem z Koji Ishii kieruję zespołem programistów odpowiedzialnym za układ Blink. Zanim dołączyłam do zespołu Blink, byłam inżynierem front-endu (przed pojawieniem się w Google stanowiska „inżynier front-endu”), który tworzy funkcje w Dokumentach Google, Dysku i Gmailu. Po około 5 latach pracy w tym dziale zdecydowałem się na ryzykowny krok i dołączyłem do zespołu Blink. W ramach tej pracy nauczyłem się C++, a także zacząłem wdrażać bardzo złożoną bazę kodu Blink. Nawet dzisiaj rozumiem tylko niewielką część. Dziękuję za poświęcony czas. Pocieszył mnie fakt, że wielu „byłych front-endowych inżynierów” przedo mnie przeszło na stanowisko „inżynierów zajmujących się przeglądarkami”.

Moje wcześniejsze doświadczenie w zespole Blink pomogło mi osobiście. Jako inżynier front-endowy często natrafiałem na niespójności w przeglądarce, problemy z wydajnością, błędy renderowania i brakujące funkcje. Dzięki LayoutNG udało mi się systematycznie rozwiązywać te problemy w systemie układu Blink. Jest to suma wysiłków wielu inżynierów na przestrzeni lat.

W tym poście wyjaśnię, jak duża zmiana architektury może ograniczać występowanie różnych typów błędów i problemów z wydajnością.

Architektura silnika układu na 9000 stóp

Wcześniej drzewo układu w Blink było tym, co nazywam „drzewem zmiennym”.

Pokazuje drzewo zgodnie z opisem w tekście poniżej.

Każdy obiekt w drzewie układu zawierał informacje wejściowe, takie jak dostępny rozmiar narzucony przez obiekt nadrzędny, położenie dowolnych elementów zmiennoprzecinkowych oraz informacje wyjściowe, np. ostateczną szerokość i wysokość obiektu lub jego położenie w kierunku osi X i Y.

Te obiekty były przechowywane między renderowaniami. Gdy nastąpiła zmiana stylu, oznaczyliśmy ten obiekt jako zmieniony, a także wszystkie jego nadrzędne w drzewie. Po zakończeniu fazy układu w pipeline renderowania czyściliśmy drzewo, sprawdzaliśmy wszystkie brudne obiekty, a następnie wykonywaliśmy układ, aby je wyczyścić.

Okazało się, że ta architektura spowodowała wiele klas problemów, które opisujemy poniżej. Najpierw jednak zastanów się, jakie są dane wejściowe i wyjściowe układu.

Uruchomienie układu na węźle w tym drzewie polega na koncepcyjnym pobraniu „stylu plus DOM” oraz wszelkich ograniczeń nadrzędnych z systemu układu nadrzędnego (siatka, blok lub flex), a następnie na uruchomieniu algorytmu ograniczenia układu i wygenerowaniu wyniku.

Model koncepcyjny opisany wcześniej.

Nasza nowa architektura formalizuje ten model koncepcyjny. Nadal mamy drzewo układu, ale używamy go głównie do przechowywania danych wejściowych i wyjściowych układu. Na potrzeby danych wyjściowych generujemy zupełnie nowy, niezmienny obiekt o nazwie drzewo fragmentów.

Drzewo fragmentów.

W poprzednim artykule omawialiśmy niezmienną strukturę fragmentów i opisywaliśmy, jak została ona zaprojektowana, aby można było ponownie używać dużych fragmentów poprzedniej struktury w przypadku układów przyrostowych.

Dodatkowo przechowujemy obiekt ograniczeń nadrzędnych, który wygenerował ten fragment. Używamy go jako klucza pamięci podręcznej, o którym więcej powiemy poniżej.

Algorytm układu wstawionego tekstu został też przepisany, aby pasował do nowej niezmiennej architektury. Nie tylko generuje niezmienną reprezentację listy płaskiej na potrzeby układu wstawianego, ale też korzysta z pamięci podręcznej na poziomie akapitu, aby przyspieszyć ponowne układanie, z kształtów na poziomie akapitu, aby stosować funkcje czcionek w elementach i słowach, z nowego algorytmu dwukierunkowego Unicode z wykorzystaniem ICU, z wielu poprawek związanych z poprawnością i z wielu innych funkcji.

Typy błędów układu

Błędy układu można ogólnie podzielić na 4 kategorie, z różnymi przyczynami.

Poprawność

Gdy mówimy o błędach w systemie renderowania, mamy na myśli poprawność. Przykładowo: „Przeglądarka A ma zachowanie X, a Przeglądarka B – zachowanie Y” lub „Przeglądarki A i B są uszkodzone”. Wcześniej poświęcaliśmy na to dużo czasu, ponieważ ciągle walczyliśmy z systemem. Typowym błędem było zastosowanie bardzo ukierunkowanej poprawki dotyczącej jednego błędu, ale po kilku tygodniach okazało się, że spowodowaliśmy regresję w innej (pozornie niezwiązanej) części systemu.

Jak pisaliśmy w poprzednich postach, jest to oznaka bardzo niestabilnego systemu. W przypadku układu nie mieliśmy czystego powiązania między klasami, co powodowało, że inżynierowie przeglądarek byli zależni od stanu, na którym nie powinni polegać, lub niewłaściwie interpretowali niektóre wartości z innej części systemu.

Na przykład w ciągu ponad roku mieliśmy około 10 błędów związanych z flex layoutem. Każda poprawka powodowała problemy z poprawnością lub wydajnością w części systemu, co prowadziło do kolejnych błędów.

Teraz, gdy LayoutNG wyraźnie określa relacje między wszystkimi komponentami systemu układu, możemy wprowadzać zmiany z większym poczuciem pewności. Korzystamy też z wyników projektu Web Platform Tests (WPT), który pozwala wielu stronom tworzyć wspólne testy internetowe.

Obecnie stwierdziliśmy, że jeśli wprowadzimy prawdziwą regresję na stabilnym kanale, zwykle nie ma ona powiązanych testów w repozytorium WPT i nie jest wynikiem niezrozumienia umów dotyczących komponentów. Ponadto zgodnie z naszą polityką dotyczącą poprawek zawsze dodajemy nowy test WPT, aby żadna przeglądarka nie popełniła tego samego błędu ponownie.

Unieważnienie

Jeśli kiedykolwiek spotkałeś się z tajemniczym błędem, który znikał po zmianie rozmiaru okna przeglądarki lub przełączeniu właściwości CSS, prawdopodobnie był to problem z niepełnym unieważnianiem. Część drzewa z możliwością zmiany została uznana za czystą, ale ze względu na zmiany w ograniczeniach nadrzędnych nie stanowiła prawidłowego wyjścia.

Jest to bardzo częste w przypadku trybów układu z 2 przechodami (przejście po drzewie układu 2 razy w celu określenia ostatecznego stanu układu), opisanych poniżej. Wcześniej nasz kod wyglądał tak:

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

W przypadku tego typu błędów zwykle należy:

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

Rozwiązanie tego typu problemu zwykle powodowało poważny spadek wydajności (patrz poniżej w sekcji „Zbyt duża nieważność”) i było bardzo trudne do naprawienia.

Obecnie (jak opisano powyżej) mamy niezmienny obiekt ograniczeń rodzica, który opisuje wszystkie dane wejściowe od układu nadrzędnego do podrzędnego. Przechowujemy go razem z wynikiem niezmiennego fragmentu. Dlatego mamy centralne miejsce, w którym diff te 2 parametry, aby określić, czy dziecko musi przejść kolejną iterację układu. Ta logika porównywania jest skomplikowana, ale dobrze zorganizowana. Debugowanie tej klasy problemów z niepełnym unieważnianiem zwykle wymaga ręcznego sprawdzenia obu danych wejściowych i ustalenia, co się w nich zmieniło, że wymagane jest ponowne wykonanie passu układu.

Poprawki w tym kodzie porównywania są zwykle proste i łatwe do testowania jednostkowego ze względu na prostotę tworzenia tych niezależnych obiektów.

Porównanie obrazu o stałej szerokości i obrazu o szerokości wyrażonej w procentach.
Element o stałyej szerokości/wysokości nie reaguje na zwiększenie dostępnego rozmiaru, ale element o szerokości/wysokości określonej w procentach już tak. Dostępny rozmiar jest reprezentowany w obiekcie Ograniczenia nadrzędne i w ramach algorytmu porównywania będzie wykonywać tę optymalizację.

Kod porównywania w przykładzie powyżej:

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

Hysteresis

Ta klasa błędów jest podobna do niepotwierdzenia. W poprzednim systemie bardzo trudno było zapewnić idempotentność układu, czyli powtórne uruchomienie układu z tymi samymi danymi wejściowymi, które prowadziło do uzyskania tego samego wyniku.

W przykładzie poniżej po prostu przełączamy właściwość CSS między 2 wartościami. Spowoduje to jednak „nieskończone” powiększanie prostokąta.

Film i prezentacja pokazują błąd histerezy w Chrome 92 i starszych. Ten problem został rozwiązany w Chrome 93.

W poprzednim drzewie z możliwością zmiany było bardzo łatwo wprowadzić takie błędy. Jeśli kod popełnił błąd i odczytał rozmiar lub położenie obiektu w niewłaściwym momencie lub na nieodpowiednim etapie (ponieważ nie „wyczyściliśmy” poprzedniego rozmiaru lub położenia), natychmiast dodamy subtelny błąd histerezy. Te błędy zwykle nie pojawiają się podczas testowania, ponieważ większość testów koncentruje się na jednym układzie i renderowaniu. Co więcej, wiedzieliśmy, że pewna histereza jest potrzebna do prawidłowego działania niektórych trybów układu. Wystąpiły błędy, w których przypadku optymalizacja polegała na usunięciu przejścia układu, ale wprowadzała „błąd”, ponieważ tryb układu wymagał 2 przejść, aby uzyskać prawidłowe dane wyjściowe.

Drzewo przedstawiające problemy opisane w poprzednim tekście.
Zależnie od informacji o wynikach z poprzedniego układu może to skutkować nieidempotentnymi układami.

W LayoutNG mamy wyraźne struktury danych wejściowych i wyjściowych, a dostęp do poprzedniego stanu nie jest dozwolony, dzięki czemu udało nam się w dużej mierze wyeliminować tę klasę błędów z systemu układu.

Nadmierne unieważniania i wydajność

Jest to przeciwieństwo klasy błędów związanych z niepełnym unieważnianiem. Często podczas naprawiania błędu związanego z niepełnym unieważnieniem powodowaliśmy spadek wydajności.

Często musieliśmy podejmować trudne decyzje, wybierając poprawność kosztem wydajności. W następnej sekcji omówimy szczegółowo, jak rozwiązaliśmy te problemy.

Układy z 2 przechodami i spadki skuteczności

Układy flex i siatka to zmiana w wyrazistości układów w internecie. Jednak te algorytmy różniły się zasadniczo od wcześniejszego algorytmu układu bloków.

Blok układu (w prawie wszystkich przypadkach) wymaga od silnika wykonania układu wszystkich jego elementów dokładnie raz. To świetne rozwiązanie pod względem wydajności, ale nie jest tak efektywne, jak chcieliby to programiści.

Często chcesz na przykład, aby rozmiar wszystkich elementów potomnych był taki sam jak rozmiar największego z nich. Aby to umożliwić, układ nadrzędny (elastyczny lub siatkowany) wykona pomiar, aby określić rozmiar każdego elementu podrzędnego, a następnie wykona pomiar układu, aby rozciągnąć wszystkie elementy podrzędne do tego rozmiaru. To zachowanie jest domyślne zarówno w przypadku układu elastycznego, jak i siatki.

Dwa zestawy pudełek: pierwszy pokazuje rozmiar pudełek w przebiegu pomiaru, a drugi – w układzie o równej wysokości.

Te układy z 2 przelotami początkowo zapewniały akceptowalną wydajność, ponieważ użytkownicy zwykle nie umieszczali ich głęboko w hierarchii. Jednak wraz z pojawianiem się bardziej złożonych treści zaczęły się pojawiać poważne problemy z wydajnością. Jeśli nie zcacheujesz wyniku fazy pomiaru, drzewo układu będzie się przełączać między stanem pomiaru a ostatecznym stanem układu.

Objaśnienie układów z jednym, 2 i 3 przechodami
Na powyższym obrazie mamy 3 elementy <div>. Prosty układ z jednym przejściem (np. układ blokowy) odwiedzi 3 węzły układu (złożoność O(n)). Jednak w przypadku układu z 2 przelotami (np. elastycznego lub siatki) w tym przykładzie może to potencjalnie skutkować złożonością O(2n) wizyt.
Wykres przedstawiający wykładniczy wzrost czasu tworzenia układu.
Ten obraz i prezentacja pokazują układ wykładniczy z układem siatki. Ten problem został rozwiązany w Chrome 93 w wyniku przeniesienia Gridu do nowej architektury.

Wcześniej, aby rozwiązać ten problem, próbowaliśmy dodawać do układów elastycznych i siatek bardzo konkretne pamięci podręczne. To zadziałało (i doszliśmy bardzo daleko z Flex), ale ciągle walczyliśmy z błędami związanymi z niepełnym i zbyt dużym unieważnieniem.

Dzięki LayoutNG możemy tworzyć jawne struktury danych zarówno dla danych wejściowych, jak i wyjściowych układu. Oprócz tego zbudowaliśmy pamięć podręczną pomiarów i przesłań układu. Dzięki temu złożoność wraca do O(n), co daje przewidywalną, liniową wydajność dla programistów stron internetowych. Jeśli układ będzie wymagał 3 przebiegów, po prostu zapiszemy ten przebieg w pamięci podręcznej. Może to w przyszłości umożliwić bezpieczne wprowadzenie bardziej zaawansowanych trybów układu. Jest to przykład tego, jak RenderingNG zasadniczo umożliwia rozszerzalność. W niektórych przypadkach układ siatki może wymagać użycia 3 przebiegów, ale obecnie jest to bardzo rzadkie.

Zauważyliśmy, że gdy deweloperzy napotykają problemy z wydajnością układu, jest to zwykle spowodowane błędem związanym z wykładniczym czasem układania, a nie surową przepustowością etapu układania w przepływie danych. Jeśli mała zmiana (jeden element zmieniający jedną właściwość CSS) powoduje zmianę układu o 50–100 ms, prawdopodobnie jest to błąd układu o charakterze wykładniczym.

W skrócie

Układ jest bardzo złożoną kwestią, a my nie omawiamy wszystkich interesujących szczegółów, takich jak optymalizacja układu wstawianego (czyli całego podsystemu wstawianego i tekstowego). Nawet omawiane tu zagadnienia są tylko wierzchołkiem góry lodowej, a wiele szczegółów zostało pominięte. Mamy nadzieję, że udało nam się pokazać, jak systematyczne ulepszanie architektury systemu może prowadzić do niesamowitych długoterminowych zysków.

Wiemy jednak, że przed nami jeszcze dużo pracy. Zdajemy sobie sprawę z istnienia różnych klas problemów (zarówno związanych z wydajnością, jak i z poprawnością), nad którymi pracujemy. Cieszymy się też z nowych funkcji układu, które pojawią się w CSS. Uważamy, że architektura LayoutNG pozwala bezpiecznie i skutecznie rozwiązywać te problemy.

Jedno zdjęcie (wiesz, które!) autorstwa Una Kravets.