Wykorzystuj analizy kryminalistyczne i detektywistyczne do rozwiązywania zagadek związanych z wydajnością JavaScriptu

John McCutchan
John McCutchan

Wstęp

W ostatnich latach liczba aplikacji internetowych znacznie przyspieszyła. Wiele aplikacji działa obecnie na tyle szybko, że niektórzy programiści zastanawiają się na głos, czy internet jest wystarczająco szybki. W przypadku niektórych aplikacji może być tak, ale deweloperzy pracujący nad aplikacjami o wysokiej wydajności wiemy, że nie są one wystarczająco szybkie. Pomimo niezwykłych postępów w technologii maszyn wirtualnych JavaScript, niedawne badanie wykazało, że aplikacje Google poświęcają około 50–70% czasu w V8. Twoja aplikacja ma ograniczony czas, a gotowanie cykli pracy jednego systemu oznacza, że inny system może zrobić więcej. Pamiętaj, że aplikacje działające z szybkością 60 kl./s mają tylko 16 ms na klatkę, a w przeciwnym razie – zacinanie. Czytaj dalej, aby dowiedzieć się, jak zoptymalizować aplikacje JavaScript i profilowe aplikacje JavaScript, z opowieści detektywów z zespołu V8, którzy śledzą niejasny problem z wydajnością w filmie Znajdź drogę do Oz.

Sesja Google I/O 2013

Prezentowałem ten materiał na Google I/O 2013. Obejrzyj ten film:

Dlaczego skuteczność ma znaczenie?

Cykle procesora to gra, w której zero sum. Zmniejszenie wykorzystania jednej części systemu umożliwia jej większe wykorzystanie w innej części lub ogólnie szybsze działanie. Szybsze bieganie i większa wydajność często wiążą się z konkurencyjnymi celami. Użytkownicy oczekują nowych funkcji, oczekując jednocześnie płynniejszego działania aplikacji. Wirtualne maszyny wirtualne w języku JavaScript działają coraz szybciej, ale nie jest to powód do ignorowania problemów z wydajnością, które można rozwiązać już dziś, ponieważ wielu programistów boryka się z problemami z wydajnością swoich aplikacji internetowych. W czasie rzeczywistym, przy dużej liczbie klatek, nacisk na to, żeby aplikacje nie zacinał się, ma kluczowe znaczenie. W ramach badania Insomniac Games przeprowadzono badanie, które wykazało, że stabilna, stała liczba klatek ma duże znaczenie dla powodzenia gry: „Stała liczba klatek na sekundę to nadal oznaka profesjonalnego, dobrze wykonanego produktu”. Zauważają to twórcy stron internetowych.

Rozwiązywanie problemów ze skutecznością

Rozwiązanie problemu dotyczącego wydajności jest jak rozwiązywanie przestępstwa. Musisz dokładnie zbadać dowody, sprawdzić potencjalne przyczyny i eksperymentować z różnymi rozwiązaniami. Cały czas musisz dokumentować pomiary, aby mieć pewność, że problem został rzeczywiście rozwiązany. Jest bardzo niewielka różnica między tą metodą a sposób postępowania przez kryminalistów. Detektywi sprawdzają dowody, przesłuchują podejrzanych i przeprowadzają eksperymenty z nadzieją na znalezienie tylnej broni palnej.

V8 CSI: Oz

Niesamowici magowie tworzący aplikację Znajdź drogę do Oz zgłosili zespołowi V8 problem z wydajnością, który nie byłby w stanie samodzielnie rozwiązać. Czasem Oz się zawieszał i zacinał. Programiści Oz przeprowadzili wstępne analizy, używając panelu osi czasu w Narzędziach deweloperskich w Chrome. Sprawdzając wykorzystanie pamięci, napotkał przerażający wykres ząbka piły. Raz na sekundę zbierał 10 MB śmieci, a wstrzymanie tej operacji korespondowało z tym zacinaniem. Wygląda to podobnie do tego zrzutu ekranu z osi czasu w Narzędziach deweloperskich w Chrome:

Oś czasu Devtools

Detektyw z V8, Jakb i Yang, zajęli się sprawą. Rozmowa z Jakobem i Yangiem z zespołu V8 i Oz trwała długo i z powrotem. Podzielę tę rozmowę do najważniejszych zdarzeń, które pomogły mi prześledzić ten problem.

Dowody

Pierwszym krokiem jest zebranie i przeanalizowanie wstępnych dowodów.

Jakiego typu aplikacji bierzemy pod uwagę?

Wersja demonstracyjna Oz to interaktywna aplikacja 3D. Z tego powodu jest bardzo wyczuwalna na wstrzymania powodowane przez odśmiecające zbiory pamięci. Pamiętaj, że interaktywna aplikacja działająca z szybkością 60 kl./s ma 16 ms na wykonanie wszystkich zadań JavaScriptu i musi zostawić trochę czasu na przetworzenie przez Chrome wywołań graficznych i wyświetlanie ekranu.

Oz wykonuje duże obliczenia arytmetyczne na podwójnych wartościach i często wykonuje wywołania WebAudio i WebGL.

Na czym polega problem z wydajnością?

Występują przerwy, czyli zacinanie się klatek. Te wstrzymania są powiązane z uruchomieniami czyszczenia pamięci.

Czy deweloperzy stosują sprawdzone metody?

Tak. Deweloperzy Oz dobrze znają się na wydajności maszyn wirtualnych JavaScript i technikach optymalizacji. Warto zauważyć, że programiści Oz używali CoffeeScript jako języka źródłowego i tworzyli kod JavaScript za pomocą kompilatora CoffeeScript. To sprawiło, że dochodzenie okazało się bardziej skomplikowane ze względu na brak związku między kodem napisanym przez programistów z Oz a używanym przez V8 kodem. Narzędzia deweloperskie w Chrome obsługują teraz mapy źródłowe, co ułatwiałoby to zadanie.

Dlaczego działa ładowanie śmieci?

Pamięć w JavaScript jest automatycznie zarządzana przez programistę przez maszynę wirtualną. W V8 używany jest typowy system czyszczenia pamięci, w którym pamięć jest dzielona na co najmniej 2 generations. Młode pokolenie trzyma w sobie obiekty, które zostały ostatnio przydzielone. Jeśli obiekt przeżyje wystarczająco długo, zostanie przeniesiony do starej generacji.

Młode pokolenie jest gromadzone znacznie częściej niż stare pokolenie. To celowo, ponieważ kolekcja młodych pokoleń jest znacznie tańsza. Często można przyjąć, że częste przerwy w zbieraniu danych są spowodowane zbieraniem danych przez młode pokolenie.

W wersji 8 przestrzeń pamięci dla młodych jest podzielona na 2 przylegające do siebie bloki pamięci o jednakowym rozmiarze. W danym momencie używany jest tylko jeden z tych 2 bloków pamięci, który jest nazywany przestrzenią kosmiczną. Chociaż w przestrzeni jest niezbędna ilość pamięci, przydzielanie nowego obiektu jest niedrogie. Kursor w obszarze zostanie przesunięty o liczbę bajtów niezbędnych do utworzenia nowego obiektu. Działa to aż do wyczerpania miejsca na dane. W tym momencie program zostaje zatrzymany i rozpoczyna się zbieranie danych.

Młoda pamięć V8

W tym momencie następuje zamiana między kosmosem a kosmosem. To, co było w kosmos, a teraz, z kosmosu, jest skanowane od początku do końca, a wszystkie wciąż żywe obiekty są kopiowane do przestrzeni kosmicznej lub przenoszone na stertę starej generacji. Jeśli chcesz dowiedzieć się więcej, przeczytaj artykuł o algorytmie Cheneya.

Musisz intuicyjnie rozumieć, że za każdym razem, gdy obiekt jest przydzielany niejawnie lub jawnie (przez wywołanie nowego, [] lub {}), aplikacja zbliża się do odśmiecania pamięci i wstrzymania aplikacji drejusz.

Czy w tej aplikacji spodziewane jest 10 MB/s zużycia danych?

Krótko mówiąc: nie. Deweloper nie oczekuje 10 MB/s odpadów.

Podejrzane

Kolejny etap to identyfikacja potencjalnych podejrzanych i sprecyzowanie ich.

Podejrzany nr 1

Wywołuję nowy w klatce. Pamiętaj, że każdy przypisany obiekt przybliża Cię do wstrzymania GC. Szczególnie aplikacje działające z dużą liczbą klatek powinny mieć zerową liczbę przydziałów na klatkę. Zwykle wymaga to starannie przemyślanego, dostosowanego do zastosowania systemu recyklingu przedmiotów. Detektywi V8 skontaktowali się z zespołem Oz i nie dzwonili pod nowy. Członkowie zespołu już znali ten wymóg i stwierdzili: „To byłoby krępujące”. Zdrap to z listy.

Podejrzany nr 2

Zmiana „kształtu” obiektu poza konstruktorem. Dzieje się tak za każdym razem, gdy do obiektu zostanie dodana nowa właściwość poza konstruktorem. Spowoduje to utworzenie dla obiektu nowej ukrytej klasy. Gdy zoptymalizowany kod zauważy tę nową ukrytą klasę, uaktywni się jej usunięcie, a niezoptymalizowany kod zostanie wykonany, dopóki nie zostanie sklasyfikowany jako gorący i zoptymalizowany. Rezygnacja z optymalizacji i ponownej optymalizacji spowoduje zawinięcie,ale nie jest ściśle powiązana z nadmiernym tworzeniem śmieci. Po dokładnym sprawdzeniu kodu potwierdzono, że kształty obiektów były statyczne, więc podejrzewamy, że wykluczono także kształty obiektów nr 2.

Podejrzany nr 3

Arytmetyczne w niezoptymalizowanym kodzie. W niezoptymalizowanym kodzie wszystkie obliczenia powodują przydzielanie obiektów. Na przykład ten fragment kodu:

var a = p * d;
var b = c + 3;
var c = 3.3 * dt;
point.x = a * b * c;

Powoduje to utworzenie 5 obiektów HeapNumber. Pierwsze trzy to zmienne a, b i c. Czwarty krok to wartość anonimowa (a * b), a 5 to wartość nr 4 * c. Piąta jest ostatecznie przypisana do punktu.x.

Oz wykonuje tysiące operacji na klatkę. Jeśli któreś z tych obliczeń wystąpią w funkcjach, które nigdy nie są zoptymalizowane, mogą być przyczyną śmieci. Ponieważ obliczenia w niezoptymalizowanym trybie przydzielają pamięć nawet na potrzeby wyników tymczasowych.

Podejrzany nr 4

Zapisywanie we właściwości podwójnej liczby precyzji. Aby przechowywać liczbę, musisz utworzyć obiekt HeapNumber, a jego właściwość zmieni się w taki sposób, by wskazywała nowy obiekt. Zmiana właściwości w taki sposób, aby wskazywała na wartość HeapNumber, nie spowoduje zanieczyszczenia. Może się jednak zdarzyć, że jako właściwości obiektów będzie zapisanych wiele podwójnych liczb dokładności. Kod jest pełen instrukcji, takich jak:

sprite.position.x += 0.5 * (dt);

W zoptymalizowanym kodzie za każdym razem, gdy do x zostanie przypisana świeżo obliczona wartość, czyli pozornie nieszkodliwa instrukcja, domyślnie przydzielany jest nowy obiekt HeapNumber, co zbliża się do wstrzymania odśmiecania.

Pamiętaj, że korzystając z tablicy wpisanej (lub tablicy zwykłej, która zawiera tylko liczby zmiennoprzecinkowe), możesz całkowicie uniknąć tego problemu, ponieważ miejsce na podwójną liczbę precyzji jest przydzielane tylko raz, a wielokrotne zmienianie wartości nie wymaga przydzielenia nowej pamięci.

Podejrzenie nr 4 jest możliwe.

Kryminalistyka

W tym momencie detektywi mogą mieć dwie potencjalne zagrożenia: przechowywanie liczb sterty jako właściwości obiektów oraz obliczenia arytmetyczne w niezoptymalizowanych funkcjach. Pora udać się do laboratorium i dokładnie ustalić, która osoba jest winna. UWAGA: w tej sekcji odtworzymy problem znaleziony w rzeczywistym kodzie źródłowym Oz. Taka reprodukcja jest rzędem wielkości mniejszym niż oryginalny kod, więc jest łatwiejsza do przeanalizowania.

Eksperyment 1

Szukam podejrzanych nr 3 (obliczenia arytmetyczne w niezoptymalizowanych funkcjach). Mechanizm JavaScript V8 ma wbudowany system logowania, który może zapewniać doskonały wgląd w zaistniałe okoliczności.

Przeglądarka Chrome w ogóle nie działa, a następnie uruchamia się z flagami:

--no-sandbox --js-flags="--prof --noprof-lazy --log-timer-events"

a następnie całkowite zamknięcie Chrome spowoduje utworzenie pliku v8.log w bieżącym katalogu.

Aby zinterpretować zawartość pliku v8.log, musisz pobrać tę samą wersję v8 co Chrome (sprawdź informacje o:wersji) i ją utworzyć.

Po utworzeniu wersji 8 możesz przetworzyć dziennik za pomocą procesora znaczników:

$ tools/linux-tick-processor /path/to/v8.log

W zależności od platformy zastąp system Mac lub Windows zamiast systemu Linux. (to narzędzie musi być uruchamiane z katalogu źródłowego najwyższego poziomu w wersji 8).

Procesor znaczników wyświetla opartą na tekście tabelę funkcji JavaScript z największą liczbą znaczników:

[JavaScript]:
ticks  total  nonlib   name
167   61.2%   61.2%  LazyCompile: *opt demo.js:12
 40   14.7%   14.7%  LazyCompile: unopt demo.js:20
 15    5.5%    5.5%  Stub: KeyedLoadElementStub
 13    4.8%    4.8%  Stub: BinaryOpStub_MUL_Alloc_Number+Smi
  6    2.2%    2.2%  Stub: BinaryOpStub_ADD_OverwriteRight_Number+Number
  4    1.5%    1.5%  Stub: KeyedStoreElementStub
  4    1.5%    1.5%  KeyedLoadIC:  {12}
  2    0.7%    0.7%  KeyedStoreIC:  {13}
  1    0.4%    0.4%  LazyCompile: ~main demo.js:30

Jak widać, plik demo.js miał 3 funkcje: opt, unopt i main. Przy nazwach funkcji zoptymalizowanych jest gwiazdka (*). Zwróć uwagę, że opcja optymalizacji funkcji jest zoptymalizowana, a jej brak jest niezoptymalizowany.

Innym ważnym narzędziem w torbie z narzędziami w V8 jest licznik czasu. Można go wykonać w ten sposób:

$ tools/plot-timer-event /path/to/v8.log

Po uruchomieniu w bieżącym katalogu znajduje się plik PNG o nazwie timer-events.png. Po jego otwarciu powinno wyświetlić się mniej więcej coś takiego:

Zdarzenia licznika czasu

Poza wykresem u dołu dane są wyświetlane w wierszach. Oś X przedstawia czas (ms). Po lewej stronie znajdują się etykiety poszczególnych wierszy:

Oś Y zdarzeń licznika czasu

Wiersz V8.Execute zawiera narysowaną czarną pionową linię w każdym znaczniku profilu, gdzie V8 wykonywał kod JavaScript. Na każdym pasku profilu w V8.GCScavenger rysowana jest niebieska pionowa linia, w której narzędzie V8 wykonywało zbiór nowej generacji. Analogicznie w przypadku pozostałych stanów V8.

Jednym z najważniejszych wierszy jest „wykonywany rodzaj kodu”. Ma kolor zielony przy każdym uruchomieniu zoptymalizowanym kodu, a czerwony i niebieski podczas wykonywania niezoptymalizowanego kodu. Zrzut ekranu poniżej pokazuje przejście ze zoptymalizowanego do niezoptymalizowanego, a następnie z powrotem do kodu zoptymalizowanego:

Rodzaj wykonywanego kodu

Idealnie, ale nigdy od razu, ta linia będzie zawsze zielona. Oznacza to, że Twój program znalazł się w zoptymalizowanym stanie. Niezoptymalizowany kod zawsze będzie działać wolniej niż kod zoptymalizowany.

Jeśli udało Ci się zaliczyć taką długość, warto zauważyć, że możesz pracować znacznie szybciej dzięki refaktoryzacji aplikacji, aby mogła ona działać w powłoce debugowania v8: d8. Użycie kodu d8 pozwala szybciej przeprowadzić iterację przy użyciu narzędzi tick-processor i plot-timer-event. Innym skutkiem ubocznym korzystania z d8 jest łatwiejsze wyizolowanie rzeczywistego problemu, co zmniejsza ilość szumu występującego w danych.

Spójrzmy na wykres zdarzeń licznika z kodu źródłowego Oz. Widać, że nastąpiło przejście z kodu zoptymalizowanego do niezoptymalizowanego. Podczas wykonywania niezoptymalizowanego kodu uruchomiono wiele kolekcji nowej generacji, podobnie jak na zrzucie ekranu poniżej (na środku została usunięta notatka):

Wykres zdarzeń licznika czasu

Jeśli przyjrzeć się bliżej, można zauważyć, że czarne linie wskazujące, kiedy V8 wykonuje kod JavaScript, brakuje dokładnie w takich samych odstępach czasu, jak w kolekcjach nowej generacji (niebieskie linie). Wskazuje to wyraźnie, że w trakcie zbierania odpadów skrypt jest wstrzymywany.

W danych wyjściowych procesora znacznika z kodu źródłowego Oz widać, że górna funkcja (updateSprites) nie została zoptymalizowana. Innymi słowy, funkcja, w ramach której program spędził najwięcej czasu, również była niezoptymalizowana. To wyraźnie wskazuje, że sprawcą jest podejrzana nr 3. Źródło aktualizacji updateSprite zawierało takie pętle:

function updateSprites(dt) {
    for (var sprite in sprites) {
        sprite.position.x += 0.5 * dt;
        // 20 more lines of arithmetic computation.
    }
}

Wiedząc, jak działają V8, od razu zauważyli, że konstrukcja pętli „for-i-in” nie jest czasami optymalizowana przez wersję 8. Inaczej mówiąc, jeśli funkcja zawiera konstrukt pętli for-i-in, może nie być zoptymalizowana. Jest to wyjątkowy przypadek, który prawdopodobnie w przyszłości się zmieni. Oznacza to, że V8 może pewnego dnia zoptymalizować tę pętlę. Nie jesteśmy detektywami V8 i nie wiemy, jak V8 ma się w Twoim ręku. Jak możemy sprawdzić, dlaczego aplikacja updateSprites nie została zoptymalizowana?

Eksperyment 2

Uruchamianie Chrome z tą flagą:

--js-flags="--trace-deopt --trace-opt-verbose"

wyświetla szczegółowy dziennik danych dotyczących optymalizacji i deoptymalizacji. Przeszukując dane w poszukiwaniu obiektów updateSprite, znaleziono:

[wyłączona optymalizacja dla updateSprites, powód: ForInStatement nie działa szybko]

Zgodnie z hipotezą detektywów, powodem był konstrukcja pętli for-i-in.

Zgłoszenie zamknięte

Gdy odkryliśmy przyczynę braku optymalizacji komponentu updateSprite, można było łatwo rozwiązać ten problem. Wystarczy przenieść obliczenia do osobnej funkcji, czyli:

function updateSprite(sprite, dt) {
    sprite.position.x += 0.5 * dt;
    // 20 more lines of arithmetic computation.
}

function updateSprites(dt) {
    for (var sprite in sprites) {
        updateSprite(sprite, dt);
    }
}

Opcja updateSprite zostanie zoptymalizowana, co spowoduje zmniejszenie liczby obiektów HeapNumber, a w rezultacie rzadsze wstrzymania GC. Możesz to łatwo potwierdzić, przeprowadzając te same eksperymenty z nowym kodem. Uważny czytelnik zauważy, że liczby podwójne są nadal przechowywane jako właściwości. Jeśli profilowanie wskazuje, że warto to robić, zmiana pozycji na tablicę zmiennoprzecinkową lub tablicą danych typu, w którym jest wykonywana, jeszcze bardziej zmniejszy liczbę tworzonych obiektów.

Epilog

Deweloperzy Oz nie poprzestali na tym. Korzystając z narzędzi i technik udostępnionych im przez detektywów V8, udało im się odnaleźć kilka innych funkcji, które utknęły w chaosie deoptymalizacji i uwzględniły kod obliczeniowy w funkcjach liści, które zostały zoptymalizowane, co przyniosło jeszcze lepsze wyniki.

Wyjdź naprzeciw i rozwiązuj problemy związane z wydajnością.