Szczegółowa analiza renderowania: układNG

Ian Kilpatrick
Ian Kilpatrick
Ishi koji
Koji Ishi

Jestem Ian Kilpatrick i kierownik zespołu technicznego w zespole Blink. Przed rozpoczęciem pracy w zespole Blink byłem inżynierem interfejsu (zanim Google pełnił rolę „inżyniera interfejsu użytkownika”), tworząc funkcje w Dokumentach Google, Dysku i Gmailu. Po około 5 latach pełniłam tę rolę i przeniosłam się do zespołu Blink, skutecznie nauczyłam się C++ w pracy i próbowałam lepiej poznać niesamowicie złożoną bazę kodu Blink. Do dzisiaj wiem tylko niewielką część. Dziękuję za poświęcony mi czas. Pocieszył mnie fakt, że wielu „inżynierów zajmujących się przywracaniem danych” przeszło na „inżyniera przeglądarki” przed mną.

Moje wcześniejsze doświadczenie pomogło mi osobiście w zespole Blink. Jako inżynier frontendu stale napotykałem(-am) niespójności w przeglądarce, problemy z wydajnością, błędy renderowania i brakujące funkcje. Układ LayoutNG dał mi możliwość systematycznego rozwiązywania takich problemów w systemie układu Blink. Jest to efekt sumy wysiłków wielu inżynierów na przestrzeni lat.

W tym poście wyjaśnię, jak duża zmiana architektury może ograniczyć i wyeliminować różnego rodzaju błędy oraz problemy z wydajnością.

Widok z 30 tys. metrów na architektury układów

Wcześniej drzewo układu Blink było „zmiennym drzewem”.

Wyświetla drzewo zgodnie z opisem poniżej.

Każdy obiekt w drzewie układu zawierał informacje wejściowe, np. dostępny rozmiar nałożony przez obiekt nadrzędny, pozycję każdej liczby zmiennoprzecinkowej oraz informacje output, na przykład końcową szerokość i wysokość obiektu lub jego pozycję na osi x i y.

Obiekty te były przechowywane pomiędzy renderowaniem. Po zmianie stylu oznaczaliśmy obiekt jako brudny, a także wszystkie jego elementy nadrzędne w drzewie. Po uruchomieniu fazy układu potoku renderowania oczyściliśmy drzewo, przejęliśmy wszystkie zanieczyszczone obiekty, a następnie uruchomiliśmy układ, aby przywrócić je do stanu czystego.

Zauważyliśmy, że taka architektura prowadzi do wielu klas problemów, które omówimy poniżej. Najpierw jednak cofnijmy się i zastanówmy, jakie są dane wejściowe i wyjściowe układu.

Uruchomienie układu w węźle w tym drzewie powoduje, że koncepcyjnie korzysta się ze stylu „Styl plus DOM” i wszelkich ograniczeń nadrzędnych z nadrzędnego systemu układu (siatka, blok lub elastyczny). Uruchamia algorytm ograniczeń układu i otrzymuje wynik.

Opisany wcześniej model koncepcyjny.

Nasza nowa architektura nadaje 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.

Omówiliśmy już niezmienne drzewo fragmentów, w którym opisaliśmy, jak powinno wykorzystywać duże fragmenty poprzedniego drzewa na potrzeby układów przyrostowych.

Dodatkowo przechowujemy nadrzędny obiekt ograniczeń, który wygenerował ten fragment. Używamy go jako klucza pamięci podręcznej, który omówimy bardziej szczegółowo poniżej.

Algorytm układu wbudowanego (tekstowego) także zostaje przepisany, aby pasował do nowej, stałej architektury. Nie tylko zapewnia stałą reprezentację płaskiej listy dla układu wbudowanego, ale udostępnia też buforowanie na poziomie akapitu, które przyspiesza przekazywanie układu, uszczególnienie na poziomie akapitu, aby stosować cechy czcionek do różnych elementów i słów, nowy dwukierunkowy algorytm Unicode korzystający z ICU, dużo poprawek poprawek i nie tylko.

Rodzaje błędów układu

Ogólnie błędy w układach można podzielić na 4 kategorie, z których każda ma inne przyczyny.

Poprawność

Gdy myślimy o błędach w systemie renderowania, zwykle zwracamy uwagę na poprawność, np. „Przeglądarka A działa X, a przeglądarka B – działa Y”, lub „Przeglądarki A i B działają nieprawidłowo”. Wcześniej poświęcaliśmy na to wiele czasu, ale cały czas walczyliśmy z systemem. Częstym błędem było wprowadzenie bardzo ukierunkowanej poprawki w jednym błędzie, ale kilka tygodni później powodowało regresję w innej (pozornie niepowiązanej) części systemu.

Jak pisaliśmy w poprzednich postach, jest to oznaka bardzo łamliwego systemu. Szczególnie jeśli chodzi o układ, nie mieliśmy ujednoliconej umowy między klasami, co sprawiało, że inżynierowie przeglądarek musieli polegać na stanie, który nie powinien, lub błędnie zinterpretować jakąś wartość z innej części systemu.

Na przykład przez ponad rok mieliśmy łańcuch około 10 błędów związanych z elastycznym układem. Każda z nich powodowała problem z poprawnością lub wydajnością części systemu, co prowadziło do kolejnego błędu.

Teraz gdy w LayoutNG zostanie sprecyzowany umowa między wszystkimi komponentami systemu, ustaliliśmy, że zmiany można wprowadzać z większą pewnością. Bardzo cenimy sobie również znakomity projekt Web Platform Tests (WPT), który umożliwia udział wielu stronom we wspólnym zestawie testów internetowych.

Obecnie okazuje się, że jeśli wprowadzimy prawdziwą regresję na kanale stabilnym, zwykle nie ma ona powiązanych testów w repozytorium WPT i nie wynika z niewłaściwego zrozumienia składowych umów. Ponadto w ramach zasad dotyczących naprawiania błędów zawsze dodajemy nowy test WPT, dzięki czemu żadna przeglądarka nie powtarza tego samego błędu.

Zbyt duża liczba unieważnień

Jeśli kiedykolwiek zdarzyło Ci się napotkać tajemniczy błąd polegający na tym, że zmiana rozmiaru okna przeglądarki lub magiczne przełączenie właściwości CSS powoduje jego usunięcie, oznacza to, że mamy problem z nieprawidłową unieważnieniem. W efekcie część zmiennego drzewa została uznana za oczyszczoną, ale ze względu na pewną zmianę w ograniczeniach nadrzędnych nie było to prawidłowe dane wyjściowe.

Jest to bardzo częste w przypadku dwuprzebiegowych trybów układu (dwukrotnego przejścia po drzewie układu w celu określenia ostatecznego stanu układu) opisanych poniżej. Wcześniej nasz kod wyglądałby tak:

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

Rozwiązaniem tego typu błędu zwykle jest:

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

Rozwiązanie tego typu problemów zwykle powodowało poważną regresję wydajności (patrz informacje o nadmiarowych unieważnieniach poniżej), a jej naprawienie było bardzo trudne.

Obecnie (jak opisano powyżej) mamy stały obiekt ograniczeń nadrzędnych, który opisuje wszystkie dane wejściowe z układu nadrzędnego do jednostki podrzędnej. Zapisujemy to w wyniku niezmiennym fragmentu. Z tego względu znajdujemy się w jednym miejscu, w którym różnicujemy te 2 dane wejściowe, aby określić, czy dziecko potrzebuje innego układu. Ta odmienna logika jest skomplikowana, ale dobrze kontrolowana. Debugowanie tej klasy problemów z niedostatecznie unieważnianiem skutkuje zwykle ręczną kontrolą 2 wejść i decydowaniem, co się w nich zmieniło, tak aby konieczne było ponowne przekazywanie układu.

Poprawki w tym różniącym się kodzie są zwykle proste i dzięki łatwemu tworzeniu tych niezależnych obiektów można łatwo testować jednostki.

Porównanie obrazu o stałej i procentowej szerokości.
W przypadku elementu o stałej szerokości lub wysokości nie ma znaczenia, czy podany rozmiar zwiększa się, ale wysokość elementu wskazywanego przez wartości procentowe ma znaczenie. Wartość dostępnego rozmiaru jest reprezentowana w obiekcie Ograniczenia nadrzędne i w ramach algorytmu różnicowania tę optymalizację przeprowadza.

Różniący się kod w tym przykładzie to:

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

Histereza

Ta klasa błędów przypomina niedostateczne unieważnianie. Zasadniczo w poprzednim systemie bardzo trudno było zadbać o idempotentność układu. Oznacza to, że ponowne uruchomienie układu z tymi samymi danymi wejściowymi dało taki sam wynik.

W poniższym przykładzie po prostu przełączamy między dwiema wartościami właściwość CSS. Da to jednak prostokąt „nieskończenie rosnący”.

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

Przy poprzednim zmiennym drzewie wprowadzenie takich błędów było niezwykle proste. Jeśli kod błędnie odczyta rozmiar lub położenie obiektu w niewłaściwym momencie lub etapie (np. nie „wyczyściliśmy” poprzedniego rozmiaru lub położenia), natychmiast dodamy subtelny błąd histerezy. Takie błędy zwykle nie pojawiają się podczas testowania, ponieważ większość testów skupia się na pojedynczym układzie i renderowaniu. Co bardziej niepokojące, zdaliśmy sobie sprawę, że ta histeza jest potrzebna do prawidłowego działania niektórych trybów układu. Mieliśmy błędy, które wymagały optymalizacji, by usunąć kartę układu, ale wprowadzenie błędu, ponieważ tryb układu wymagał dwukrotnego przejścia w celu uzyskania poprawnych danych wyjściowych.

Drzewo przedstawiające problemy opisane we wcześniejszym tekście.
W zależności od poprzednich informacji o wyniku układu powstaną układy nieidempotyczne

Ze względu na to, że mamy jawne struktury danych wejściowych i wyjściowych, a dostęp do poprzedniego stanu jest niedozwolony, znacznie ograniczyliśmy tę klasę błędu z systemu układów.

Nadmierne unieważnianie i skuteczność

To jest bezpośrednie przeciwieństwo błędów, które nie zawsze są w stanie nieprawidłowe. Naprawienie błędu polegającego na unieważnieniu treści często powodowało spadek wydajności.

Często musieliśmy dokonywać trudnych wyborów i zamiast skuteczności reklam musieliśmy stawiać na poprawność. W następnej sekcji dowiemy się, jak radziliśmy sobie z tego typu problemami z wydajnością.

Układ dwuprzejazdowy i klify związane z wydajnością

Elastyczność i układ siatki odzwierciedlają zmianę ekspresji układów w internecie. Algorytmy te różniły się jednak zasadniczo od stosowanego wcześniej algorytmu układu blokowego.

Układ blokowy (w prawie wszystkich przypadkach) wymaga, by wyszukiwarka wykonała układ na wszystkich jego elementach podrzędnych dokładnie raz. Jest to świetne rozwiązanie w zakresie wydajności, ale niestety nie musi być tak ekspresywne, jak tego oczekują programiści.

Na przykład często chcesz, aby rozmiar wszystkich elementów podrzędnych rozwijał się do największego rozmiaru. Aby to obsługiwać, układ nadrzędny (fleks lub siatka) przeprowadza przekazywanie pomiaru, aby określić, jak duże jest każde z elementów podrzędnych, a następnie przekazuje układ, aby rozciągnąć wszystkie elementy podrzędne do tego rozmiaru. To zachowanie jest domyślne w przypadku układu elastycznego i siatkowego.

Dwa zestawy pudełek. Pierwszy z nich pokazuje ich wewnętrzny rozmiar w ramach pomiaru, a drugi na tej samej wysokości.

Taki układ dwuprzebiegowy był początkowo akceptowalny pod względem wydajności, ponieważ klienci zwykle nie umieszczali ich głęboko. Jednak w miarę powstawania bardziej złożonych treści zauważyliśmy jednak poważne problemy z wydajnością. Jeśli nie zapiszesz w pamięci podręcznej wyniku fazy pomiaru, drzewo układu zacznie migać między stanem measure a końcowym stanem układu.

Układ 1, 2 lub 3-przebiegowy opisany w napisie.
Na obrazie powyżej widać 3 elementy <div>. Prosty układ jednoprzebiegowy (taki jak układ blokowy) odwiedza 3 węzły układu (złożoność O(n)). Jednak w przypadku układu dwuprzebiegowego (np. elastycznego lub siatki) w tym przykładzie może to zwiększyć złożoność O(2n) wizyt.
Wykres przedstawiający wykładniczy wzrost czasu korzystania z 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 siatki do nowej architektury.

Wcześniej staraliśmy się dodawać bardzo konkretne pamięci podręczne do elastycznego układu siatki, aby walczyć z tego typu klifami wydajności. To pomogło (i doszliśmy bardzo daleko z Flex), ale stale zmagaliśmy się z błędami dotyczącymi unieważniania.

LayoutNG pozwala nam tworzyć wyraźne struktury danych dla danych wejściowych i wyjściowych w układzie. Do tego mamy pamięci podręczne dla kart pomiarów i układów. W ten sposób złożoność wróci do O(n), co daje przewidywalną liniową wydajność programistów stron internetowych. Jeśli zdarzy się już, że układ ma układ trójprzebiegowy, zapisujemy go w pamięci podręcznej. Może to stworzyć możliwość bezpiecznego wprowadzenia bardziej zaawansowanych trybów układu w przyszłości. Jest to przykład tego, jak RenderingNG zasadniczo zwiększa rozszerzalność. W niektórych przypadkach układ siatki może wymagać układów trzyprzejściowych, ale obecnie robi się to niezwykle rzadko.

Odkrywamy, że problemy z wydajnością w układzie przez programistów mają zwykle związek z błędem związanym z wykładniczym czasem układu, a nie z pełnej przepustowości etapu układu potoku. Jeśli mała zmiana przyrostowa (jeden element zmienia jedną właściwość CSS) skutkuje układem o długości 50–100 ms, jest to prawdopodobnie błąd układu wykładniczego.

W skrócie

Układ to niezwykle złożony obszar, więc nie omówiliśmy m.in. optymalizacji układu w tekście (faktycznie jak działa cały podsystem w tekście i w tekście), a nawet omówione tu koncepcje to tylko drobiazg, a wiele szczegółów dominuje. Mamy jednak nadzieję, że pokazaliśmy, jak systematyczne ulepszanie architektury systemu może w dłuższej perspektywie prowadzić do nadzwyczajnych korzyści.

Zdajemy sobie jednak sprawę, że czeka nas jeszcze wiele pracy. Zdajemy sobie sprawę z klas problemów (zarówno dotyczących skuteczności, jak i poprawności), nad którymi pracujemy, i cieszymy się, że wprowadzimy nowe funkcje układu w CSS. Jesteśmy przekonani, że architektura LayoutNG pozwala na bezpieczne i łatwe rozwiązywanie tych problemów.

Jeden obraz (wicie, który!) autorstwa Una Kravets.