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 na to jest 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 Twoja aplikacja działa w Chrome, możesz zobaczyć szczegółowe omówienie działania przeglądarki w trakcie jej wykonywania. Wiedza o tej aktywności może Ci pomóc zidentyfikować wzorce, wąskie gardła i najciekawsze obszary związane z wydajnością, które możesz poprawić.
Z przykładu poniżej dowiesz się, jak korzystać z panelu Skuteczność.
Konfigurowanie i odtwarzanie scenariusza profilowania
Niedawno określiliśmy cel, który ma zwiększyć skuteczność panelu Skuteczność. Chcieliśmy w szczególności przyspieszyć wczytywanie dużych ilości danych o skuteczności. Może tak być na przykład podczas 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 ją profilować w panelu Skuteczność. Aby profilować ten panel, możesz otworzyć Narzędzia deweloperskie, a następnie otworzyć inną instancję powiązaną z tym panelem. W Google ta konfiguracja jest nazywana DevTools-on-DevTools.
Po przygotowaniu konfiguracji należy odtworzyć i zarejestrować scenariusz, który ma zostać profilowany. Aby uniknąć nieporozumień, pierwotne okno Narzędzi deweloperskich będzie nazywane „pierwszym wystąpieniem Narzędzi deweloperskich”, a okno, w którym sprawdza się pierwsze wystąpienie, będzie określane jako „druga instancja Narzędzi deweloperskich”.
W drugiej instancji Narzędzi deweloperskich panel Wydajność – od tego momentu nazywany panelem wydajności – rejestruje pierwszą instancję Narzędzi deweloperskich, 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. Wczytywany jest duży plik, aby można było dokładnie profilować wydajność przetwarzania dużych ilości danych wejściowych. Gdy obie instancje zostaną wczytane, dane profilowania wydajności – nazywane potocznie śledzeniem – pojawią się w drugim instancji Narzędzi deweloperskich panelu wydajności wczytującego profil.
Stan początkowy: identyfikacja możliwości poprawy
Po zakończeniu wczytywania zaobserwowano na następnym zrzucie ekranu: Skup się na aktywności w wątku głównym, który jest widoczny pod ścieżką Główny. Na wykresie płomieniowym jest pięć dużych grup aktywności. Są to zadania, których wczytywanie trwa najdłużej. Łączny czas 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źć.
Pierwsza grupa aktywności: niepotrzebna praca
Okazało się, że pierwszą grupą działań był starszy kod, który nadal działał, ale tak naprawdę nie był potrzebny. Zasadniczo wszystko pod zielonym blokiem oznaczonym etykietą processThreadEvents
było stracone. To była szybka wygrana. Usunięcie tego wywołania funkcji zaoszczędzi około 1,5 sekundy. Super!
Druga grupa aktywności
W drugiej grupie działań 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.
Z ciekawości włączyliśmy opcję Pamięć w panelu Perf, aby dokładniej zbadać problem i zauważyliśmy, ż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.
Aby odpowiedzieć na to podejrzenie, użyliśmy panelu Pamięć (inny panel w Narzędziach deweloperskich, inny niż panel Pamięć w panelu Perf). W panelu Pamięć w kolumnie „Próbkowanie przydziałów” Wybrano typ profilowania, co spowodowało zarejestrowanie zrzutu stosu dla panelu wydajności, który wczytuje profil procesora.
Na zrzucie ekranu poniżej widać zrzut stosu, który został zebrane.
Na podstawie tego zrzutu stosu zauważono, że klasa Set
zużywała dużo pamięci. Sprawdzając punkty wywoływania, stwierdziliśmy, że niepotrzebnie przypisywaliśmy właściwości typu Set
do obiektów utworzonych w dużych ilościach. 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.
Zbiory są przydatne do przechowywania unikalnych elementów i udostępniają operacje wykorzystujące niepowtarzalność ich treści, takie jak usuwanie duplikatów zbiorów danych i usprawnianie wyszukiwania. Te funkcje nie były jednak konieczne, ponieważ zagwarantowano, że przechowywane dane będą unikalne od źródła. 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 zastosowaniu tej zmiany wykonano kolejny zrzut stosu i zaobserwowano zmniejszenie przydziału pamięci. Pomimo że zmiana nie przyniosła znaczącej poprawy szybkości działania, drugorzędną korzyścią było rzadsze awarie aplikacji.
Trzecia grupa działań: kompromisy w strukturze danych
Trzecia sekcja jest nietypowa: na wykresie płomieniowym widać, że składa się on z wąskich, ale wysokich kolumn oznaczających głębokie wywołania funkcji i głębokie rekurencje w tym przypadku. Łą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.
Podczas implementacji funkcji appendEventAtLevel
wyróżniono jedną rzecz. Na każdy wpis danych wejściowy (nazywany w kodzie „zdarzeniem”) do mapy został dodany element śledzący pionowe położenie wpisów na osi czasu. Było to problematyczne, ponieważ przechowywano bardzo dużo elementów. Mapy Google obsługują szybkie wyszukiwanie przy użyciu klucza, ale ta funkcja nie jest dostępna bezpłatnie. Wraz z powiększaniem się mapy dodawanie danych do niej może, na przykład, zwiększyć koszty związane z ponownym szyfrowaniem. Koszt ten staje się zauważalny przy stopniowym dodaniu do mapy dużej 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);
// ...
}
Wypróbowaliśmy inną metodę, która nie wymagała od nas dodawania elementu do każdego wpisu na wykresie płomieniowym. 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:
Po:
Czwarta grupa aktywności: odraczanie niekrytycznych zadań i przechowywanie danych w pamięci podręcznej, aby zapobiec ich duplikowaniu
Po powiększeniu tego okna można zobaczyć, że są w nim 2 niemal identyczne bloki wywołań funkcji. Patrząc na nazwy wywoływanych funkcji, możesz wywnioskować, że te bloki składają się z kodu tworzącego drzewa (np. o nazwach w rodzaju refreshTree
lub buildChildren
). W rzeczywistości ten kod tworzy widoki drzewa w dolnej szufladzie panelu. Co ciekawe, widoki drzew nie są wyświetlane zaraz po załadowaniu. Zamiast tego użytkownik musi wybrać widok drzewa (karty „Od dołu do góry”, „Drzewo wywołań” i „Rejestr zdarzeń” w szufladzie). Jak widać na zrzucie ekranu, proces budowania drzew został przeprowadzony dwukrotnie.
Na tym zdjęciu zidentyfikowaliśmy 2 problemy:
- Zadanie niekrytyczne spowalniało czas wczytywania. Użytkownicy nie zawsze potrzebują tych danych. W związku z tym nie jest to niezbędne do wczytania profilu.
- Wynik tych zadań nie został zapisany w pamięci podręcznej. Dlatego drzewa zostały obliczone dwukrotnie, chociaż dane się nie zmieniły.
Rozpoczęliśmy od obliczania drzew do momentu, gdy użytkownik ręcznie otworzy widok drzewa. Dopiero wtedy warto zapłacić za stworzenie tych drzew. Łączny czas dwukrotnego uruchomienia wyniósł około 3,4 sekundy, więc odroczenie miało znaczny wpływ na czas wczytywania. Nadal pracujemy nad umieszczaniem tego typu zadań w pamięci podręcznej.
Piąta grupa działań: w miarę możliwości unikaj złożonych hierarchii wywołań
Po przyjrzeniu się tej grupie dało się zauważyć, że określony łańcuch wywołań jest wywoływany wielokrotnie. Ten sam wzór pojawił się 6 razy w różnych miejscach na wykresie płomieniowym, a łączny czas trwania tego okna wyniósł około 2,4 sekundy!
Powiązany kod wielokrotnie wywoływany to część, która przetwarza dane na potrzeby renderowania na „minimapie” (przegląd aktywności na osi czasu u góry panelu). Nie było jasne, dlaczego tak się stało kilka razy, ale na pewno nie było to aż 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 kilku części w potoku wczytywania, bezpośrednio lub pośrednio przez wywołanie funkcji obliczającej minimapę. Wynika to z faktu, że złożoność wykresu wywołań programu zmieniała się z czasem i nieoczekiwanie dodano do tego kodu kolejne zależności. 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 zmniejszyć złożoność hierarchii wywołań i dodać kontrolę, aby zapobiec wykonaniu kodu, jeśli dane wejściowe pozostaną niezmienione. Potem wygląda to tak:
Pamiętaj, że renderowanie minimapy odbywa się 2 razy, 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). Obydwa materiały zawierają jednak dokładnie te same treści, więc jeden z nich powinien być możliwy do ponownego wykorzystania w drugiej.
Te minimapy to obrazy narysowane na obszarze roboczym, dlatego trzeba było skorzystać z narzędzia kanw drawImage
, a następnie uruchomić kod tylko raz, by zaoszczędzić czas. 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 poprawek) zmiana na osi czasu wczytywania profilu wyglądała następująco:
Przed:
Po:
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ść działań wymagała szybkich poprawek. Oczywiście kluczowe było określenie, co zrobić na początku. Panel wydajności okazał się odpowiednim narzędziem.
Warto również podkreślić, że liczby te odnoszą się do profilu używanego jako przedmiot badań. Profil był dla nas interesujący, ponieważ był wyjątkowo duży. Ponieważ potok przetwarzania jest taki sam w przypadku każdego profilu, osiągnięta znacząca poprawa dotyczy każdego profilu wczytanego w panelu wydajności.
Wnioski
Z tych wyników możesz wyciągnąć kilka wniosków, jeśli chodzi o optymalizację 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ą niezmiernie przydatne do zrozumienia, co dzieje się w aplikacji, gdy jest ona uruchomiona, zwłaszcza w celu identyfikowania możliwości poprawy wydajności. Panel wydajności w Narzędziach deweloperskich w Chrome doskonale sprawdza się w przypadku aplikacji internetowych, ponieważ jest to natywne narzędzie do profilowania sieci w przeglądarce, które aktywnie aktualizuje się z najnowszymi funkcjami platformy internetowej. Usługa działa też znacznie szybciej. 😉
Używaj próbek, które mogą być używane jako reprezentatywne zbiory zadań, i zobacz, co uda Ci się znaleźć.
2. Unikaj złożonych hierarchii wywołań
W miarę możliwości staraj się, by wykres wywołań był zbyt skomplikowany. 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. Rozpoznawanie niepotrzebnych zadań
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 tego owocu to najniżej wiszący owoc.
4. Właściwe korzystanie ze struktur danych
Wykorzystuj struktury danych do optymalizacji skuteczności, ale przy podejmowaniu decyzji, której z nich użyć, uwzględnij też koszty i wady związane z każdym typem struktury danych. Liczy się nie tylko złożoność przestrzeniowa samej struktury danych, ale także złożoność czasowa odpowiednich operacji.
5. Zapisywanie wyników w pamięci podręcznej w celu uniknięcia powielania pracy w przypadku złożonych lub powtarzalnych operacji
Jeśli wykonanie tej operacji jest kosztowne, warto zapisać jej wyniki na przyszłość. Warto to zrobić również w przypadku tych operacji, które są wykonywane wiele razy, nawet jeśli każda sesja nie jest szczególnie kosztowna.
6. Odłóż zadania, które nie są krytyczne
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, gdy dane wyjściowe faktycznie są potrzebne.
7. Używaj wydajnych algorytmów w przypadku dużych danych wejściowych
W przypadku dużych ilości danych algorytmy optymalnej złożoności czasowej stają się bardzo ważne. Nie uwzględniliśmy tej kategorii w tym przykładzie, ale nie można przesądzić o jej znaczeniu.
8. Dodatkowo: sprawdź swoje potoki
Aby mieć pewność, że Twój kod zmienia się szybko, warto monitorować jego działanie i porównywać go ze standardami. Dzięki temu możesz aktywnie wykrywać regresje i zwiększać ogólną niezawodność, aby zapewnić długoterminowy sukces.