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 jest sprawdzenie aktywności aplikacji za pomocą narzędzi do profilowania, aby dowiedzieć się, co dzieje się w jej wnętrzu podczas działania w określonym przedziale czasu. Panel Wydajność 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. Dzięki temu możesz wykrywać wzorce, wąskie gardła i punkty krytyczne, które mogą mieć wpływ na wydajność.
W tym przykładzie pokazujemy, jak korzystać z panelu Skuteczność.
Konfigurowanie i powtarzanie scenariusza profilowania
Niedawno postawiliśmy sobie za cel zwiększenie skuteczności panelu Skuteczność. Chcieliśmy przede wszystkim, aby szybciej wczytywał duże ilości danych o wydajności. 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. Aby to osiągnąć, trzeba było najpierw zrozumieć, jak aplikacja działała i dlaczego działała w taki sposób. Udało się to osiągnąć za pomocą narzędzia 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 nosi nazwę 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 drugim wystąpieniu DevTools rozpoczyna się nagrywanie na żywo, a w pierwszym wczytuje się 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 w naszym drugim panelu wydajności zaobserwowaliśmy na zrzucie ekranu poniżej. Skoncentruj się na aktywności głównego wątku, który jest widoczny na ścieżce o nazwie Główny. 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 tym zrzucie ekranu panel skuteczności służy do skupienia się na poszczególnych grupach aktywności, aby sprawdzić, co można znaleźć.
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 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 „Dobór próbki alokacji”, który zarejestrował migawkę stosu dla panelu wydajności wczytującego profil procesora.
Na zrzucie ekranu poniżej widać zebrany zrzut 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. 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 w ogóle 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.
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 ten fragment trwał około 1, 4 sekund. 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.
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ż 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 ze wzrostem rozmiaru mapy dodawanie do niej danych może stać się kosztowne z powodu ponownego mieszania. 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 elementów na mapie dla każdego wpisu na wykresie płomienistym. Poprawa była znacząca, co potwierdza, że wąskie gardło było rzeczywiście związane z nadmiernym obciążeniem wynikającym z dodawania wszystkich danych do mapy. Czas wykonywania przez grupę 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 zbliżeniu tego okna widać, że są 2 bloki wywołań funkcji, które są prawie identyczne. 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 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 tworzenia drzewa został wykonany dwukrotnie.
Zidentyfikowaliśmy 2 problemy z tym zdjęciem:
- Nieistotne zadanie utrudniało skrócenie czasu 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 słupkowego, 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 nie musiało się to zdarzać 6 razy. W rzeczywistości kod powinien pozostać aktualny, jeśli nie zostanie załadowany żaden inny profil. Teoretycznie kod powinien być wykonywany 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). 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 rezultacie 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 czego należy dokonać na początku, a panel wydajności był odpowiednim narzędziem do tego celu.
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. 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ść 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. 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 połączeń
Jeśli to możliwe, unikaj zbytniego komplikowania wykresu połączeń. 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 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 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 przetwarzania 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 zajmujemy się tą kategorią, ale jej znaczenie trudno przecenić.
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.