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 stworzysz, optymalizacja jej wydajności oraz zapewnienie, że będzie ona szybko się ładowała i zapewniała płynne interakcje, mają kluczowe znaczenie dla wygody użytkowników i sukcesu aplikacji. Jednym ze sposobów może być zbadanie aktywności aplikacji za pomocą narzędzi do profilowania w celu sprawdzenia, co dzieje się w tle podczas jej działania w określonym przedziale czasu. Panel Skuteczność w Narzędziach deweloperskich to świetne narzędzie do profilowania, które pozwala analizować i optymalizować wydajność aplikacji internetowych. Jeśli aplikacja działa w Chrome, możesz uzyskać szczegółowy wizualny przegląd tego, co przeglądarka robi podczas jej wykonywania. Poznanie tego rodzaju aktywności może pomóc w identyfikowaniu wzorców, wąskich gardeł i punktów krytycznych, które mogą mieć wpływ na wydajność.

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

Konfigurowanie i powtarzanie scenariusza profilowania

Niedawno określiliśmy cel, który ma zwiększyć skuteczność panelu Skuteczność. Chcieliśmy przede wszystkim, aby wczytywał większe ilości danych o wydajności szybciej. Może tak być na przykład w przypadku profilowania długotrwałych lub złożonych procesów albo zbierania danych o dużej dokładności. W tym celu trzeba było najpierw dowiedzieć się, jak działanie aplikacji i dlaczego działało w ten sposób. W tym celu wykorzystano narzędzie do profilowania.

Jak pewnie wiesz, Narzędzia deweloperskie to aplikacja internetowa. Dlatego można go profilować za pomocą panelu Wydajność. Aby przeprowadzić profilowanie tego panelu, otwórz Narzędzia deweloperskie, a potem otwórz inny ich egzemplarz. W Google ta konfiguracja jest nazywana Narzędzia deweloperskie w Narzędziach deweloperskich.

Po przygotowaniu konfiguracji należy odtworzyć i nagrany scenariusz, który ma być profilowany. Aby uniknąć nieporozumień, oryginalne okno Narzędzi deweloperskich będziemy nazywać „pierwszym wystąpieniem Narzędzi deweloperskich”, a okno, które sprawdza pierwsze wystąpienie, będzie nazywane „drugim wystąpieniem Narzędzi deweloperskich”.

Zrzut ekranu pokazujący instancję Narzędzi deweloperskich podczas sprawdzania elementów w Narzędziach deweloperskich.
Narzędzia deweloperskie w Narzędziach deweloperskich: sprawdzanie Narzędzi deweloperskich za pomocą Narzędzi deweloperskich.

W drugim wystąpieniu DevTools panel Wydajność (odtąd nazywany panelem wydajności) obserwuje pierwsze wystąpienie DevTools, aby odtworzyć scenariusz, który wczytuje profil.

W drugiej instancji Narzędzi deweloperskich rozpoczyna się nagrywanie na żywo, a w pierwszej instancji – wczytywany jest profil z pliku na dysku. Ładowanie dużego pliku ma na celu dokładne określenie wydajności przetwarzania dużych danych wejściowych. Po zakończeniu wczytywania obu instancji dane z profilowania wydajności (zwykle nazywane śladem) są widoczne w panelu wydajności w drugim wystąpieniu Narzędzi deweloperskich.

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

Po zakończeniu wczytywania zaobserwowano na następnym zrzucie ekranu: Skoncentruj się na aktywności głównego wątku, który jest widoczny na ścieżce o nazwie Główna. Widać, że na wykresie płomienistym jest 5 dużych grup aktywności. Są to zadania, których wczytywanie zajmuje najwięcej czasu. Łączny czas wykonywania tych zadań wyniósł około 10 sekund. Na poniższym zrzucie ekranu panel skuteczności skupia się na każdej z tych grup aktywności i pokazuje, co można znaleźć.

Zrzut ekranu przedstawiający panel wydajności w Narzędziach deweloperskich sprawdzający wczytywanie logu czasu wydajności w panelu wydajności innej instancji Narzędzi deweloperskich. Załadowanie profilu zajmuje około 10 sekund. Czas ten jest w głównej mierze podzielony na 5 głównych grup aktywności.

Pierwsza grupa aktywności: zbędna praca

Okazało się, że pierwsza grupa aktywności to starszy kod, który nadal działał, ale nie był już potrzebny. Zasadniczo wszystko pod zielonym blokiem z etykietą processThreadEvents było stratą czasu. To było szybkie zwycięstwo. Usunięcie tego wywołania funkcji zaoszczędzi około 1,5 sekundy. Super!

Druga grupa aktywności

W przypadku drugiej grupy aktywności rozwiązanie nie było tak proste jak w przypadku pierwszej. Działanie buildProfileCalls zajęło około 0, 5 sekundy i nie można było uniknąć tego zadania.

Zrzut ekranu panelu wydajności w Narzędziach deweloperskich, w którym sprawdzamy inny panel wydajności Zadanie związane z funkcją buildProfileCalls trwa około 0,5 sekundy.

Z ciekawości włączyliśmy w panelu wydajności opcję Pamięć, aby dokładniej zbadać ten problem. Okazało się, że aktywność buildProfileCalls również zużywa dużo pamięci. Tutaj możesz zobaczyć, jak niebieski wykres liniowy przeskakuje w czasie uruchomienia funkcji buildProfileCalls, co sugeruje potencjalny wyciek pamięci.

Zrzut ekranu z profilowania pamięci w Narzędziach deweloperskich, które ocenia wykorzystanie pamięci przez panel wydajności. Inspektor sugeruje, że wyciek pamięci jest spowodowany przez funkcję buildProfileCalls.

Aby to sprawdzić, użyliśmy panelu Pamięć (to inny panel w DevTools, inny niż panel Pamięć w panelu wydajność). W panelu Pamięć wybrano typ profilowania „Do próbkowania alokacji”, który rejestruje migawkę stosu dla panelu wydajności wczytującego profil procesora.

Zrzut ekranu pokazujący początkowy stan profilatora pamięci. Opcja „Próbkowanie alokacji” jest podświetlona na czerwono i wskazuje, że najlepiej sprawdza się w przypadku profilowania pamięci JavaScript.

Na zrzucie ekranu poniżej widać zrzut stosu, który został zebrane.

Zrzut ekranu z profilowania pamięci z wybraną operacją na zbiorze, która wymaga dużej ilości pamięci.

Z tego zrzutu pamięci podręcznej wynika, że klasa Set zużywa dużo pamięci. Po sprawdzeniu punktów wywołania stwierdziliśmy, że niepotrzebnie przypisywaliśmy właściwości typu Set obiektom, które zostały utworzone w dużej ilości. Koszty się sumowały i wykorzystywały dużo pamięci do tego stopnia, że awarie aplikacji zdarzały się często przy dużych ilościach danych wejściowych.

Zestawy są przydatne do przechowywania unikalnych elementów i do wykonywania operacji, które wykorzystują unikalność ich zawartości, np. deduplikacji zbiorów danych i zapewniania bardziej wydajnych wyszukiwań. Te funkcje nie były jednak potrzebne, ponieważ zapisane dane były gwarantowane jako unikalne w źródle. W związku z tym zestawy nie były z góry potrzebne. Aby poprawić alokację pamięci, zmieniono typ właściwości z „Set” na „zwykłą tablicę”. Po wprowadzeniu tej zmiany wykonano kolejny zrzut pamięci i zaobserwowano zmniejszone przydzielanie pamięci. Pomimo że ta zmiana nie przyniosła znacznej poprawy szybkości, miała ona dodatkową zaletę: aplikacja rzadziej się zawieszała.

Zrzut ekranu z profilowaniem pamięci Wcześniej używające dużo pamięci operacja oparta na zestawie została zmieniona na prostą tablicę, co znacznie obniżyło koszty pamięci.

Trzecia grupa aktywności: ważenie korzyści i wad struktury danych

Trzecia sekcja jest osobliwa: widać na niej, że składa się z wąskich, ale wysokich kolumn, które w tym przypadku oznaczają głębokie wywołania funkcji i głęboką rekursję. Łącznie ta sekcja trwała około 1, 4 sekundy. Patrząc na dół tej sekcji, dało się zauważyć, że szerokość tych kolumn jest określona na podstawie czasu trwania jednej funkcji: appendEventAtLevel, co sugeruje, że może to być wąskie gardło.

W implementacji funkcji appendEventAtLevel jedna rzecz przykuła moją uwagę. Do każdego pojedynczego wpisu danych w danych wejściowych (który w kodzie jest nazywany „zdarzeniem”) dodano element do mapy, która śledzi pozycję pionową wpisów na osi czasu. Było to problematyczne, ponieważ przechowywano bardzo dużo elementów. Mapy są szybkie w przypadku wyszukiwania na podstawie klucza, ale ta zaleta nie jest bezpłatna. Wraz z powiększaniem się mapy dodawanie danych do niej może, na przykład, zwiększyć koszty związane z ponownym szyfrowaniem. Te koszty stają się zauważalne, gdy do mapy sukcesywnie 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 dla każdego wpisu na wykresie płomienia. Poprawa była istotna, co potwierdzało, że wąskie gardło rzeczywiście było związane z narzutami wynikającymi z dodania wszystkich danych do mapy. Czas trwania grupy aktywności skrócił się z około 1,4 sekundy do około 200 milisekund.

Przed:

Zrzut ekranu panelu skuteczności przed wprowadzeniem optymalizacji do funkcji appendEventAtLevel. Łączny czas wykonania funkcji wyniósł 1372,51 ms.

Po:

Zrzut ekranu panelu wydajności po wprowadzeniu optymalizacji w funkcji includeEventAtLevel. Łączny czas wykonania funkcji wyniósł 207,2 ms.

Czwarta grupa aktywności: odroczenie nieistotnych zadań i danych w pamięci podręcznej, aby zapobiec powielaniu pracy

Po powiększeniu tego okna widać, że są 2 bloki wywołań funkcji, które są prawie identyczne. Patrząc na nazwy wywoływanych funkcji, możesz wywnioskować, że te bloki składają się z kodu, który tworzy drzewa (na przykład o nazwach typu refreshTree lub buildChildren). W rzeczywistości ten powiązany kod tworzy widoki drzewa w dolnej szufladzie panelu. Co ciekawe, widoki drzew nie są wyświetlane od razu po załadowaniu. Aby wyświetlić drzewa, użytkownik musi wybrać widok drzewa (karty „Od dołu do góry”, „Drzewo wywołań” i „Dziennik zdarzeń” w szufladzie). Jak widać na zrzucie ekranu, proces budowania drzew został przeprowadzony dwukrotnie.

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

Zidentyfikowaliśmy 2 problemy z tym zdjęciem:

  1. Zadanie niekrytyczne spowalniało czas wczytywania. Użytkownicy nie zawsze potrzebują tych danych. Dlatego to zadanie nie jest kluczowe dla wczytania profilu.
  2. Wynik tych zadań nie został zapisany w pamięci podręcznej. Dlatego drzewa zostały obliczone 2 razy, mimo że dane się nie zmieniły.

Na początku odłożyliśmy obliczenie drzewa na czas, gdy użytkownik ręcznie otworzy widok drzewa. Dopiero wtedy warto zapłacić za tworzenie tych drzew. Łączny czas wykonania tego kodu dwukrotnie wynosił około 3,4 sekund, więc odroczenie wykonania kodu znacznie skróciło czas wczytywania. Nadal pracujemy nad umieszczaniem tego typu zadań w pamięci podręcznej.

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

Po dokładnym przyjrzeniu się tej grupie okazało się, że określony łańcuch wywołań był wykonywany wielokrotnie. Ten sam wzór pojawił się 6 razy w różnych miejscach wykresu fali, a łączny czas trwania tego okna wyniósł około 2,4 sekundy.

Zrzut ekranu panelu skuteczności pokazujący 6 osobnych wywołań funkcji służących do wygenerowania tej samej minimapy śladu, z których każde ma głęboką listę wywołań.

Powiązany kod wywoływany wielokrotnie to część, która przetwarza dane do wyświetlenia na „minimapie” (przegląd aktywności na osi czasu u góry panelu). Nie było jasne, dlaczego tak się działo, ale na pewno nie musiało się to zdarzyć 6 razy. W rzeczywistości kod powinien generować aktualne dane, jeśli nie jest wczytywany żaden inny profil. Teoretycznie kod powinien działać tylko raz.

Po zbadaniu sprawy stwierdziliśmy, że powiązany kod został wywołany w wyniku kilku części w potoku wczytywania, bezpośrednio lub pośrednio przez wywołanie funkcji obliczającej minimapę. Dzieje się tak, ponieważ złożoność grafu wywołań programu zmieniała się z czasem, a więcej zależności od tego kodu zostało dodanych bez Twojej wiedzy. Szybkiego rozwiązania tego problemu nie można zastosować. Sposób rozwiązania tego problemu zależy od architektury bazy kodu. W naszym przypadku musieliśmy nieco uprościć hierarchię wywołań i dodać kontrolę, aby zapobiec wykonywaniu kodu, jeśli dane wejściowe pozostaną niezmienione. Po wdrożeniu tej zmiany linia czasu wyglądała tak:

Zrzut ekranu panelu skuteczności pokazujący 6 oddzielnych wywołań funkcji służących do wygenerowania tej samej minimapy śladu, które zostały zredukowane do zaledwie 2 razy.

Pamiętaj, że renderowanie minimapy jest wykonywane dwukrotnie, a nie raz. Dzieje się tak, ponieważ w przypadku każdego profilu rysowane są dwie minimapy: jedna dla przeglądu na górze panelu i druga dla menu wyboru aktualnie widocznego profilu z historii (każdy element w tym menu zawiera przegląd wybranego profilu). Mają jednak dokładnie te same treści, więc można je stosować naprzemiennie.

Ponieważ obie minimapy to obrazy namalowane na płótnie, wystarczyło użyć drawImage narzędzia do obsługi płótna, a potem uruchomić kod tylko raz, aby zaoszczędzić trochę czasu. W efekcie czas trwania grupy został skrócony z 2,4 sekund do 140 milisekund.

Podsumowanie

Po zastosowaniu wszystkich tych poprawek (oraz kilku innych, mniejszych) czas wczytywania profilu wyglądał tak:

Przed:

Zrzut ekranu z panelem skuteczności pokazujący wczytywanie śladu przed optymalizacją Proces trwał około 10 sekund.

Po:

Zrzut ekranu panelu wydajności pokazujący wczytywanie śledzenia po optymalizacji Proces ten trwa teraz około 2 sekund.

Czas wczytywania po wprowadzeniu ulepszeń wyniósł 2 sekundy, co oznacza, że poprawę o około 80% uzyskano przy stosunkowo niewielkim nakładzie pracy, ponieważ większość z nich wymagała szybkich poprawek. Oczywiście kluczowe było określenie, co zrobić na początku. Panel wydajności okazał się odpowiednim narzędziem.

Należy też pamiętać, że te liczby dotyczą profilu używanego jako przedmiot badań. Profil był dla nas interesujący, ponieważ był wyjątkowo duży. Ponieważ jednak strumień przetwarzania jest taki sam dla każdego profilu, znaczna poprawa dotyczy wszystkich profili załadowanych w panelu wydajności.

Wnioski

Z tych wyników można wyciągnąć wnioski dotyczące optymalizacji wydajności aplikacji:

1. Korzystanie z narzędzi do profilowania w celu określenia wzorców wydajności środowiska wykonawczego

Narzędzia do profilowania są bardzo przydatne do sprawdzania, co dzieje się w aplikacji podczas jej działania, zwłaszcza do znajdowania możliwości poprawy wydajności. Panel Wydajność w Narzędziach deweloperskich Chrome to świetne rozwiązanie dla aplikacji internetowych, ponieważ jest to natywne narzędzie do profilowania stron internetowych w przeglądarce. Jest ono aktywnie aktualizowane, aby uwzględniać najnowsze funkcje platformy internetowej. Usługa działa też znacznie szybciej. 😉

Użyj próbek, które mogą służyć jako reprezentatywne obciążenia, i sprawdź, co możesz znaleźć.

2. Unikaj złożonych hierarchii połączeń

Jeśli to możliwe, unikaj zbyt skomplikowanego wykresu połączeń. W przypadku złożonych hierarchii wywołań łatwo jest wprowadzać regresje wydajności i trudno zrozumieć, dlaczego kod działa tak, jak jest, co utrudnia wprowadzanie ulepszeń.

3. Identyfikowanie niepotrzebnej pracy

Stare bazy kodu często zawierają kod, który nie jest już potrzebny. W naszym przypadku starszy i niepotrzebny kod zajmował znaczną część łącznego czasu wczytywania. Usunięcie go było najprostszym rozwiązaniem.

4. Właściwe korzystanie ze struktur danych

Korzystaj ze struktur danych, aby optymalizować skuteczność, ale pamiętaj też, że każdy typ struktury danych wiąże się z pewnymi kosztami i ustępstwami. Nie chodzi tu tylko o złożoność przestrzenną samej struktury danych, ale także o złożoność czasową odpowiednich operacji.

5. wyniki w pamięci podręcznej, aby uniknąć duplikowania pracy w przypadku złożonych lub powtarzających się operacji;

Jeśli wykonanie operacji jest kosztowne, warto przechowywać jej wyniki na potrzeby kolejnego użycia. Ma to też sens, jeśli operacja jest wykonywana wiele razy, nawet jeśli poszczególne wywołania nie są szczególnie kosztowne.

6. Opóźnianie pracy, która nie ma kluczowego znaczenia

Jeśli dane wyjściowe zadania nie są potrzebne natychmiast, a wykonanie zadania przedłuża ścieżkę krytyczną, rozważ jej odroczenie przez leniwe wywoływanie tej ścieżki, gdy jej dane wyjściowe faktycznie są potrzebne.

7. Używanie wydajnych algorytmów do obsługi dużych danych wejściowych

W przypadku dużych danych kluczowe znaczenie mają algorytmy o optymalnej złożoności czasowej. W tym przykładzie nie analizujemy tej kategorii, ale trudno przecenić jej znaczenia.

8. Bonus: porównywanie potoków

Aby mieć pewność, że rozwijany kod pozostaje szybki, warto monitorować jego działanie i porównywać je ze standardami. Dzięki temu możesz aktywnie wykrywać regresje i zwiększać ogólną niezawodność, co zapewni Ci długoterminowy sukces.