O 400% szybszy panel Wydajność dzięki wykorzystaniu AI

Andrés Olivares
Andrés Olivares
Nancy Li
Nancy Li

Niezależnie od tego, jaki typ aplikacji tworzysz, optymalizacja jej wydajności oraz szybkie wczytywanie i zapewnianie płynnych interakcji ma kluczowe znaczenie dla wygody użytkowników i sukcesu aplikacji. Jednym ze sposobów na to jest sprawdzenie aktywności aplikacji za pomocą narzędzi do profilowania w celu sprawdzenia, co dzieje się pod maską w trakcie działania aplikacji w określonym przedziale czasu. Panel Wydajność w Narzędziach deweloperskich to świetne narzędzie do profilowania, które służy do analizowania i optymalizowania wydajności aplikacji internetowych. Jeśli Twoja aplikacja działa w Chrome, zawiera szczegółowy obraz tego, co robi przeglądarka w trakcie wykonywania aplikacji. Wiedza na temat tej aktywności pomoże Ci zidentyfikować wzorce, wąskie gardła i najważniejsze punkty wydajności, które możesz wykorzystać, aby poprawić wydajność.

Z przykładu poniżej dowiesz się, jak korzystać z panelu Skuteczność.

Konfiguruję i odtwarzam scenariusz profilowania

Niedawno ustawiliśmy cel, aby panel Skuteczność był bardziej skuteczny. Chodziło w niej o szybsze wczytywanie dużych ilości danych o skuteczności. Dotyczy to na przykład profilowania długo trwających lub złożonych procesów albo rejestrowania bardzo szczegółowych danych. W tym celu najpierw trzeba było zrozumieć, jak działa aplikacja i dlaczego działa ona w taki sposób. Uzyskano je za pomocą narzędzia do profilowania.

Jak pewnie wiesz, Narzędzia deweloperskie są też aplikacją internetową. Dzięki temu można je profilować za pomocą panelu Skuteczność. Aby profilować ten panel, możesz otworzyć Narzędzia deweloperskie, a potem otworzyć podłączoną do niego instancję Narzędzi deweloperskich. W Google ta konfiguracja nosi nazwę DevTools-on-DevTools.

Po zakończeniu konfiguracji należy odtworzyć i zarejestrować scenariusz do profilowania. Aby uniknąć nieporozumień, oryginalne okno z Narzędziami deweloperskimi będzie nazywane „pierwszą instancją DevTools”, a okno, które sprawdza pierwszą instancję, będzie nazywane „drugą instancją narzędzi deweloperskich”.

Zrzut ekranu z instancją Narzędzi deweloperskich, która sprawdza elementy w samych narzędziach deweloperskich.
DevTools-on-DevTools: sprawdzanie narzędzi deweloperskich za pomocą Narzędzi deweloperskich.

W drugiej instancji Narzędzi deweloperskich panel Performance (od tej pory nazywany panelem wydajności) obserwuje pierwsze wystąpienie narzędzi deweloperskich, aby odtworzyć scenariusz, który spowoduje wczytanie profilu.

W drugiej instancji Narzędzi deweloperskich rozpoczyna się nagrywanie na żywo, a w pierwszej instancji wczytywany jest profil z pliku znajdującego się na dysku. Wczytywany jest duży plik, aby dokładnie profilować wydajność przetwarzania dużych danych wejściowych. Po zakończeniu wczytywania obu instancji dane profilowania wydajności (nazywane potocznie trace) są widoczne w drugim wystąpieniu Narzędzi deweloperskich w panelu wydajności, który wczytuje profil.

Stan początkowy: identyfikacja możliwości poprawy

Po zakończeniu wczytywania na następnym zrzucie ekranu widać to w drugim wystąpieniu panelu wydajności. Skup się na aktywności w wątku głównym, która jest widoczna pod ścieżką o nazwie Główny. Jak widać, na wykresie płomieniowym znajduje się 5 dużych grup aktywności. Są to zadania, których wczytywanie zajmuje najwięcej czasu. Łączny czas wykonywania tych zadań wynosił około 10 sekund. Na poniższym zrzucie ekranu panel skuteczności przedstawia każdą z tych grup aktywności i pokazuje, co można znaleźć.

Zrzut ekranu z panelem wydajności w Narzędziach deweloperskich, z którego widać wczytywanie śladu wydajności w panelu wydajności innej instancji Narzędzi deweloperskich. Załadowanie profilu zajmuje około 10 sekund. Czas ten dzieli się zwykle na 5 głównych grup aktywności.

Pierwsza grupa działań: niepotrzebna praca

Okazało się, że pierwszą grupą aktywności był starszy kod, który wciąż działał, ale tak naprawdę nie był potrzebny. Ogólnie rzecz biorąc, wszystko w zielonym bloku o nazwie processThreadEvents jest niepotrzebne. To była szybka wygrana. Usunięcie tego wywołania funkcji zaoszczędziło około 1,5 sekundy. Super!

Druga grupa aktywności

W drugiej grupie działań rozwiązanie nie było tak proste jak pierwsza. Zadanie buildProfileCalls trwało około 0, 5 sekundy i nie można było go uniknąć.

Zrzut ekranu przedstawiający panel wydajności w Narzędziach deweloperskich, który sprawdza inną instancję panelu wydajności. Zadanie powiązane z funkcją buildProfileCalls trwa około 0,5 sekundy.

Aby dokładniej zbadać problem, w panelu wydajności włączyliśmy opcję Pamięć i stwierdziliśmy, że aktywność buildProfileCalls również zużywała dużo pamięci. Tutaj możesz zobaczyć, jak niebieski wykres liniowy nagle skacze w czasie uruchamiania elementu buildProfileCalls, co sugeruje, że wystąpił potencjalny wyciek pamięci.

Zrzut ekranu przedstawiający narzędzie do profilowania pamięci w Narzędziach deweloperskich, które ocenia wykorzystanie pamięci przez panel wydajności. Inspektor sugeruje, że funkcja buildProfileCalls jest odpowiedzialna za wyciek pamięci.

W odpowiedzi na to podejrzenie skorzystaliśmy z panelu Memory (inny panel w Narzędziach deweloperskich, inny niż panel Pamięć w panelu wydajności). W panelu Pamięć wybrano typ profilowania „Alokacja próbkowanie”, które zarejestrował zrzut sterty dla panelu wydajności ładującego profil procesora.

Zrzut ekranu początkowego stanu programu profilującego pamięć. Opcja „Próbkowanie alokacji” jest wyróżniona czerwoną ramką i wskazuje, że ta opcja najlepiej sprawdza się w przypadku profilowania pamięci JavaScript.

Poniższy zrzut ekranu przedstawia zebraną migawkę stosu.

Zrzut ekranu pokazujący program profilujący pamięć z wybraną operacjami o dużej ilości pamięci i wykorzystaniem zestawu.

Na podstawie tego zrzutu sterty zaobserwowaliśmy, że klasa Set zużywała dużo pamięci. Po sprawdzeniu punktów wywołania okazało się, że niepotrzebnie przypisywaliśmy właściwości typu Set do obiektów utworzonych w dużych ilościach. Koszty się kumulowały i zużywały dużo pamięci, aż do momentu, w którym aplikacja ulegała awarii przy dużych danych wejściowych.

Zbiory są przydatne do przechowywania unikalnych elementów i udostępniania operacji wykorzystujących unikalność ich treści – na przykład do duplikowania zbiorów danych i zwiększania skuteczności wyszukiwania. Funkcje te nie były jednak konieczne, ponieważ przechowywane dane miały pewność, że pochodzą z niepowtarzalnych źródeł. W związku z tym zestawy nie były od początku potrzebne. Aby poprawić alokację pamięci, typ właściwości został zmieniony z Set na tablicę zwykłą. Po zastosowaniu tej zmiany wykonano kolejny zrzut stosu i zaobserwowano zmniejszoną alokację pamięci. Mimo że po tej zmianie nie nastąpiła znaczna poprawa szybkości, drugorzędną korzyścią jest rzadsza częstotliwość awarii aplikacji.

Zrzut ekranu pokazujący program profilujący pamięć. Do tej pory wymagającej dużej ilości pamięci operacja oparta na zbiorze danych została zmieniona na prostą tablicę, co znacznie obniżyło koszt pamięci.

Trzecia grupa działań: omówienie kompromisów związanych ze strukturą danych

Trzecia sekcja jest osobliwa: na wykresie płomieniowym widać, że składa się ona z wąskich, lecz wysokich kolumn, które oznaczają głębokie wywołania funkcji, a w tym przypadku głębokie rekurencje. Łącznie ta sekcja trwała około 1, 4 sekundy. Patrząc na dół tej sekcji, było jasne, że szerokość tych kolumn zależy od czasu trwania jednej funkcji: appendEventAtLevel, co sugerowało, że może to być wąskie gardło.

Implementacja funkcji appendEventAtLevel zwróciła uwagę na jedną rzecz. W przypadku każdej pozycji danych wejściowej (czyli „zdarzenia” w kodzie) dodawany jest do mapy element, który śledził pionową pozycję wpisów na osi czasu. Było to problematyczne, ponieważ liczba przechowywanych elementów była bardzo duża. Mapy są szybkie w przypadku wyszukiwania za pomocą klucza, ale ta zaleta nie jest bezpłatna. Gdy mapa rośnie, dodawanie do niej danych może być kosztowne np. z powodu ponownego haszowania. Koszt ten staje się widoczny, gdy stopniowo do mapy dodawane są duże ilości elementów.

/**
 * Adds an event to the flame chart data at a defined vertical level.
 */
function appendEventAtLevel (event, level) {
  // ...

  const index = data.length;
  data.push(event);
  this.indexForEventMap.set(event, index);

  // ...
}

Eksperymentowaliśmy z innym podejściem, które nie wymagało dodawania elementu na mapie w przypadku każdego wpisu na wykresie płomieniowym. Poprawa była znacząca, potwierdzając, że wąskie gardło rzeczywiście było związane z kosztami poniesionymi w związku z dodaniem wszystkich danych do mapy. Skrócił się czas grupy aktywności z około 1,4 sekundy do około 200 milisekund.

Przed:

Zrzut ekranu z panelem wydajności przed wprowadzeniem optymalizacji w funkcji addEventAtLevel. Łączny czas działania tej funkcji wyniósł 1372,51 milisekundy.

Po:

Zrzut ekranu z panelem wydajności po wprowadzeniu optymalizacji w funkcji addEventAtLevel. Łączny czas działania tej funkcji wyniósł 207,2 milisekundy.

Czwarta grupa działań: odkładanie na później danych o charakterze niekrytycznym oraz przechowywanie danych w pamięci podręcznej w celu uniknięcia powielania pracy

Powiększając okno, widać, że znajdują się tam 2 niemal identyczne bloki wywołań funkcji. Na podstawie nazw wywołanych funkcji możesz wnioskować, że bloki te składają się z kodu składającego się z drzew składowych (na przykład o nazwach takich jak refreshTree lub buildChildren). W rzeczywistości powiązany kod tworzy widoki drzewa w dolnej szufladzie panelu. Co ciekawe, te widoki drzewa nie są widoczne od razu po wczytaniu. Zamiast tego musi wybrać widok drzewa (karty „Od dołu”, „Drzewo połączeń” i „Dziennik zdarzeń” w panelu), aby je zobaczyć. Ponadto, jak widać na zrzucie ekranu, proces tworzenia drzewa został wykonany dwukrotnie.

Zrzut ekranu panelu wydajności z kilkoma powtarzającymi się zadaniami, które są wykonywane nawet wtedy, gdy nie są potrzebne Te zadania można odroczyć do wykonania na żądanie, a nie z wyprzedzeniem.

Znaleźliśmy 2 problemy z tym zdjęciem:

  1. Zadanie niekrytyczne spowalniało czas wczytywania. Użytkownicy nie zawsze potrzebują danych wyjściowych. Z tego względu zadanie nie ma krytycznego znaczenia przy wczytywaniu profilu.
  2. Wynik tych zadań nie został zapisany w pamięci podręcznej. Dlatego dane drzewa zostały wyliczone dwukrotnie, mimo iż dane się nie zmieniają.

Zaczęliśmy od odroczenia obliczania drzewa do momentu, w którym użytkownik ręcznie otworzył widok drzewa. Dopiero wtedy warto zapłacić za utworzenie tych drzew. Łączny czas dwukrotnego uruchomienia tej funkcji wynosił około 3,4 sekundy, więc jego odroczenie miało znaczny wpływ na czas wczytywania. Nadal szukamy też możliwości buforowania tego typu zadań.

Piąta grupa aktywności: w miarę możliwości unikaj złożonych hierarchii wywołań

Po dokładnym przyjrzeniu się tej grupie było jasne, że określony łańcuch połączeń jest wywoływany wielokrotnie. Ten sam wzór pojawił się 6 razy w różnych miejscach na wykresie płomieniowym, a całkowity czas trwania tego okna wyniósł około 2,4 sekundy.

Zrzut ekranu panelu wydajności z 6 osobnymi wywołaniami funkcji służącymi do generowania tej samej minimapy logu czasu, z których każde zawiera głębokie stosy wywołań.

Powiązany kod wywoływany wielokrotnie to część, która przetwarza dane przeznaczone do renderowania na „minimapie” (przeglądzie działania na osi czasu u góry panelu). Nie było jasne, dlaczego tak się stało, ale na pewno nie doszło do 6 razy. Jeśli nie zostanie wczytany żaden inny profil, dane wyjściowe kodu powinny pozostać aktualne. Teoretycznie kod powinien zostać uruchomiony tylko raz.

Po zbadaniu sprawy stwierdziliśmy, że powiązany kod został wywołany w wyniku bezpośredniego lub pośredniego wywoływania funkcji, która oblicza minimapę, przez wiele części w potoku wczytywania. Wynika to z faktu, że złożoność grafu wywołań programu zmieniała się z biegiem czasu, a nieświadomie dodano do niego więcej zależności. Nie ma szybkiego rozwiązania tego problemu. Sposób rozwiązania tego problemu zależy od architektury bazy kodu. W naszym przypadku musieliśmy nieco zmniejszyć złożoność hierarchii wywołań i dodać kontrolę, aby zapobiec wykonaniu kodu, jeśli dane wejściowe pozostały niezmienione. Po wdrożeniu przedstawiliśmy następujący harmonogram:

Zrzut ekranu z panelem wydajności, na którym widać 6 osobnych wywołań funkcji służących do generowania tej samej minimapy logu czasu z wykorzystaniem tylko 2 razy.

Pamiętaj, że renderowanie minimapy odbywa się 2 razy, a nie raz. Dzieje się tak, ponieważ dla każdego profilu rysowane są dwie minimapy: jedna dla przeglądu u góry panelu, a druga dla menu rozwijanego umożliwiającego wybranie aktualnie widocznego profilu z historii (każda pozycja w tym menu zawiera przegląd wybranego profilu). Oba materiały mają jednakową treść, więc jedno z nich powinno być możliwe do ponownego wykorzystania w drugim.

Ponieważ te minimapy to obrazy rysowane na płótnie, konieczne było użycie narzędzia Canvas drawImage, a następnie uruchomienie kodu tylko raz, aby zaoszczędzić trochę czasu. W efekcie czas trwania grupy został skrócony z 2, 4 sekundy do 140 milisekund.

Podsumowanie

Po zastosowaniu wszystkich tych poprawek (oraz kilku innych mniejszych) zmiana w czasie wczytywania profilu wyglądała następująco:

Przed:

Zrzut ekranu panelu wydajności z widocznym wczytywaniem logu czasu przed optymalizacją. Proces trwał około 10 sekund.

Po:

Zrzut ekranu panelu wydajności z widocznym wczytywaniem logu czasu po optymalizacji. Ten proces trwa teraz około 2 sekund.

Czas wczytywania po wprowadzeniu ulepszeń wyniósł 2 sekundy, co oznacza, że przy stosunkowo niewielkim nakładzie pracy udało się uzyskać poprawę o około 80%, ponieważ większość zmian obejmowała szybkie poprawki. Oczywiście na początku kluczowe było określenie, co należy zrobić, ale panel wydajności okazał się odpowiednim narzędziem.

Ważne jest również, aby podkreślić, że liczby te są charakterystyczne dla profilu używanego jako przedmiot badań. Profil był dla nas interesujący, ponieważ był wyjątkowo duży. Ponieważ jednak potok przetwarzania jest taki sam dla każdego profilu, znacząca poprawa ma zastosowanie do każdego profilu wczytanego w panelu wydajności.

Odebranie krążka

Oto kilka wniosków z tych wyników w zakresie optymalizacji wydajności aplikacji:

1. Korzystaj z narzędzi do profilowania w celu identyfikowania wzorców wydajności środowiska wykonawczego

Narzędzia do profilowania są niezwykle przydatne w określaniu, co dzieje się w aplikacji, gdy jest ona uruchomiona. W szczególności pomaga znaleźć możliwości poprawy wydajności. Panel wydajności w Narzędziach deweloperskich w Chrome to świetna opcja w przypadku aplikacji internetowych, ponieważ jest to natywne narzędzie do profilowania witryn w przeglądarce i jest aktywnie aktualizowane, aby zawsze było aktualne o najnowsze funkcje platformy internetowej. Poza tym proces jest znacznie szybszy. 😉

Skorzystaj z przykładów, których można użyć jako reprezentatywnych zbiorów zadań, i zobacz, co uda Ci się znaleźć.

2. Unikaj złożonych hierarchii wywołań

W miarę możliwości unikaj zbyt skomplikowanego wykresu wywołań. Przy złożonych hierarchii wywołań łatwo jest wprowadzać regresje wydajności i trudno zrozumieć, dlaczego kod działa prawidłowo, co utrudnia wprowadzanie ulepszeń.

3. Rozpoznawanie niepotrzebnych zadań

Starzejące się bazy kodu często zawierają kod, który nie jest już potrzebny. W naszym przypadku starszy i niepotrzebny kod zajmowała znaczną część łącznego czasu wczytywania. Usunięcie owocu sprawiło, że był to najniżej wiszący owoc.

4. Właściwe korzystanie ze struktur danych

Korzystaj ze struktur danych, aby optymalizować wydajność, ale pamiętaj też o kosztach i ograniczeniach związanych z poszczególnymi typami struktur danych przy wyborze odpowiedniego rodzaju. Chodzi nie tylko o złożoność samej struktury danych, ale także o złożoność czasu poszczególnych operacji.

5. Buforuj wyniki, aby uniknąć duplikowania zadań przy skomplikowanych lub powtarzalnych operacjach

Jeśli wykonanie takiej operacji jest kosztowne, warto zapisać jej wyniki, by użyć jej w przyszłości. Ma to sens także wtedy, gdy operacja jest wykonywana wiele razy, nawet jeśli każdy etap nie jest szczególnie kosztowny.

6. Odłóż niekrytyczną pracę

Jeśli dane wyjściowe zadania nie są potrzebne natychmiast, a jego wykonanie powoduje rozszerzenie ścieżki krytycznej, rozważ odroczenie tego zadania przez leniwe wywoływanie go, gdy dane wyjściowe faktycznie są potrzebne.

7. Używaj wydajnych algorytmów na dużych danych wejściowych

W przypadku dużych ilości danych kluczowe znaczenie mają optymalne algorytmy złożoności czasowej. W tym przykładzie nie uwzględniliśmy tej kategorii, ale jej znaczenie nie jest dopuszczalne.

8. Dodatkowe informacje: porównaj swoje potoki

Aby mieć pewność, że Twój kod rozwija się szybko, warto monitorować jego działanie i porównywać go ze standardami. W ten sposób aktywnie wykrywasz regresje i zwiększasz ogólną niezawodność, co z kolei zapewnia długoterminowy sukces.