Niezależnie od tego, jaki typ aplikacji rozwijasz, optymalizacja jej wydajności i zapewnienie szybkiego wczytywania oraz płynnych interakcji jest kluczowe 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. Wiedza o tej aktywności może Ci pomóc w wykrywaniu wzorców, wąskich gardeł i najciekawszych obszarów aktywności, które można poprawić, aby poprawić 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. Dzieje się tak na przykład wtedy, gdy profilujesz długotrwałe lub złożone procesy albo rejestrujesz dane o wysokiej szczegółowoś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”.
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źć.
Pierwsza grupa aktywności: niepotrzebna 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ła szybka wygrana. Usunięcie tego wywołania funkcji pozwoliło 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. buildProfileCalls
zajęło około 0, 5 sekundy i nie można było tego uniknąć.
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 widać, jak wykres niebieskiej linii nagle skacze w okresie, w którym działa funkcja buildProfileCalls
. Może to wskazywać na potencjalny wyciek pamięci.
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.
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. Te koszty się sumowały i spożywały dużo pamięci, aż do tego stopnia, że aplikacja często się zawieszała przy dużych 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, zmieniliśmy 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 ta zmiana nie przyniosła znacznej poprawy szybkości, miała ona dodatkową zaletę: aplikacja rzadziej się zawieszała.
Trzecia grupa aktywności: ważenie korzyści i wad struktury 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 ten fragment trwał około 1, 4 s. Spod koniec tej sekcji widać, że szerokość tych kolumn była określana przez czas trwania jednej funkcji: appendEventAtLevel
, co sugerowało, ż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ż przechowywana liczba elementów była bardzo duża. 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);
// ...
}
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 wykonania grupy aktywności skrócił się z 1,4 sekundy do około 200 milisekund.
Przed:
Po:
Czwarta grupa aktywności: odroczenie nieistotnych zadań i danych w pamięci podręcznej, aby zapobiec powielaniu pracy
Po powiększeniu tego okna można zobaczyć, że są w nim 2 niemal identyczne bloki wywołań funkcji. Z nazwy wywoływanych funkcji można wywnioskować, że te bloki składają się z kodu, który tworzy drzewa (np. o nazwach refreshTree
lub buildChildren
). W istocie powiązany kod to ten, który tworzy widoki drzewa w dolnej szufladzie panelu. Co ciekawe, widoki drzew nie są wyświetlane zaraz 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 tworzenia drzewa został wykonany dwukrotnie.
Na tym zdjęciu zidentyfikowaliśmy 2 problemy:
- Zadanie niekrytyczne spowalniało czas wczytywania. Użytkownicy nie zawsze potrzebują jego danych wyjściowych. Dlatego to zadanie nie jest kluczowe dla wczytania profilu.
- 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 badamy też kwestię buforowania tego typu zadań.
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.
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 zostać uruchomiony tylko raz.
Podczas zbadania problemu okazało się, że powiązany kod został wywołany w ramach kilku części ładowania, które bezpośrednio lub pośrednio wywołują funkcję obliczającą 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. Nie ma szybkiego rozwiązania tego problemu. Sposób rozwiązania problemu zależy od architektury kodu źródłowego. 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:
Pamiętaj, że renderowanie minimapy jest wykonywane dwukrotnie, a nie raz. Dzieje się tak, ponieważ dla każdego profilu są rysowane 2 mapy mini: jedna dla podglądu u góry panelu, a druga dla menu, które wybiera aktualnie widoczny profil z historii (każdy element w tym menu zawiera podglą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.
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:
Po:
Czas wczytywania po wprowadzeniu ulepszeń wynosił 2 sekundy, co oznacza, że poprawa o około 80% została osiągnięta przy stosunkowo niewielkim nakładzie pracy, ponieważ większość zmian stanowiły szybkie poprawki. Oczywiście kluczowe było prawidłowe określenie co należy zrobić na początku, a panel wydajności był odpowiednim narzędziem do tego celu.
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ż 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. Korzystaj z narzędzi profilowania, aby identyfikować wzorce wydajności w czasie wykonywania.
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ś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. Jest też teraz znacznie szybszy. 😉
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 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 wprowadzić regresję wydajności, a trudno jest zrozumieć, dlaczego kod działa w taki sposób, co utrudnia wprowadzanie ulepszeń.
3. Identyfikowanie niepotrzebnej pracy
Starzejące się 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. Odpowiednie 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 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. Opóźnianie pracy, która nie ma kluczowego znaczenia
Jeśli dane wyjściowe zadania nie są potrzebne od razu, a jego wykonanie wydłuża ścieżkę krytyczną, rozważ odroczenie jego wykonania przez wywołanie leniwie, gdy dane wyjściowe są rzeczywiście 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. 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 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.