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

John McCutchan
John McCutchan

Wprowadzenie

W ostatnich latach aplikacje internetowe znacznie przyspieszyły. Wiele aplikacji działa teraz tak szybko, że niektórzy deweloperzy zastanawiają się, czy internet jest wystarczająco szybki. W przypadku niektórych aplikacji może to być wystarczające, ale wiemy, że deweloperzy pracujący nad aplikacjami o wysokiej wydajności potrzebują szybszego rozwiązania. Pomimo niesamowitych postępów w technologii maszyn wirtualnych JavaScript niedawne badania wykazały, że aplikacje Google spędzają od 50% do 70% czasu w obrębie V8. Twoja aplikacja ma ograniczony czas działania, a zmniejszenie liczby cykli w jednym systemie oznacza, że inny system może wykonać więcej. Pamiętaj, że aplikacje działające z częstotliwością 60 kl./s mają tylko 16 ms na klatkę, a w przeciwnym razie jank. Aby dowiedzieć się więcej o optymalizacji kodu JavaScript i profilowaniu aplikacji JavaScript, przeczytaj artykuł Find Your Way to Oz, w którym zespół V8 opisuje, jak ścigaliśmy niejasny problem z wydajnością.

Sesja z Google I/O 2013

Materiał ten został zaprezentowany na konferencji Google I/O 2013. Obejrzyj film poniżej:

Dlaczego wydajność ma znaczenie?

Cykle procesora to gra o sumie zero. Dzięki temu, że jedna część systemu zużywa mniej zasobów, możesz wykorzystać więcej zasobów w innej części lub ogólnie zwiększyć wydajność. Szybsze działanie i większe możliwości to często rywalizujące ze sobą cele. Użytkownicy oczekują nowych funkcji, ale jednocześnie oczekują też płynniejszego działania aplikacji. Maszyny wirtualne JavaScript stają się coraz szybsze, ale nie jest to powód, dla którego można ignorować problemy z wydajnością, które można rozwiązać już dziś. Wiedzą o tym już wielu programistów, którzy mają do czynienia z problemami z wydajnością w swoich aplikacjach internetowych. W przypadku aplikacji działających w czasie rzeczywistym i wykazujących wysoką liczbę klatek na sekundę najważniejsze jest, aby nie było żadnych zacięć. Firma Insomniac Games przeprowadziła badanie, które wykazało, że stabilna, utrzymująca się częstotliwość generowania klatek na sekundę ma znaczenie dla sukcesu gry: „Stabilna częstotliwość generowania klatek na sekundę to nadal znak profesjonalnego, dobrze wykonanego produktu”. Uwaga dla programistów stron internetowych.

Rozwiązywanie problemów z wydajnością

Rozwiązywanie problemów ze skutecznością przypomina rozwiązywanie przestępstwa. Musisz dokładnie przeanalizować dowody, sprawdzić podejrzane przyczyny i eksperymentować z różnymi rozwiązaniami. Na każdym etapie musisz dokumentować pomiary, aby mieć pewność, że problem został rozwiązany. Ta metoda niewiele różni się od metody, jaką posługują się detektywi, aby rozwiązać sprawę. Detektywi badają dowody, przesłuchują podejrzanych i przeprowadzają eksperymenty, aby znaleźć dowody winy.

V8 CSI: Australia

Niesamowici czarodzieje, którzy tworzą Find Your Way to Oz, zgłosili się do zespołu V8 z problemem dotyczącym wydajności, którego nie udało im się rozwiązać. Czasami Oz się zawieszał, co powodowało zacinanie. Deweloperzy Oz przeprowadzili wstępne dochodzenie, korzystając z panelu osi czasuNarzędziach deweloperskich w Chrome. Przyglądając się wykorzystaniu pamięci, natknęli się na niesławny wykres piłowany. Raz na sekundę zbieracz śmieci zbierał 10 MB śmieci, a przerwy w zbieraniu śmieci odpowiadały niestabilności. Podobnie jak na tym zrzucie ekranu z osi czasu w Narzędziach deweloperskich w Chrome:

Oś czasu w Narzędziach deweloperskich

Detektywi z V8, Jakob i Yang, zajęli się sprawą. Między Jakobem i Yangiem z zespołu V8 oraz zespołem Oz trwała długa wymiana zdań. W tym podsumowaniu rozmowy uwzględniłem tylko ważne zdarzenia, które pomogły w znalezieniu problemu.

Dowody

Pierwszym krokiem jest zebranie i przeanalizowanie dowodów.

Jakiego typu aplikacja nas interesuje?

Prezentacja Oz to interaktywna aplikacja 3D. Z tego powodu jest ona bardzo wrażliwa na przerwy spowodowane przez zbieranie elementów do usunięcia. Pamiętaj, że interaktywna aplikacja działająca z 60 FPS ma 16 ms na wykonanie wszystkich operacji w JavaScript, a część tego czasu musi pozostawić Chrome na przetworzenie wywołań grafiki i wyświetlenie ekranu.

Oz wykonuje wiele obliczeń arytmetycznych na wartościach podwójnych i często wywołuje WebAudio i WebGL.

Jaki problem ze skutecznością występuje?

Zauważyliśmy przerwy, czyli spadki liczby klatek, czyli zacięcia. Te przerwy są powiązane z cyklami usuwania elementów z pamięci podręcznej.

Czy deweloperzy stosują sprawdzone metody?

Tak, deweloperzy Oz znają się na wydajności i optymalizacji maszyn wirtualnych JavaScript. Warto zauważyć, że deweloperzy Oz używali języka źródłowego CoffeeScript i tworzyli kod JavaScriptu za pomocą kompilatora CoffeeScript. Sprawiło to, że część analizy była trudniejsza, ponieważ kod pisany przez programistów Oz nie pasował do kodu używanego przez V8. Narzędzia deweloperskie w Chrome obsługują teraz mapy źródeł, które ułatwiłyby to zadanie.

Dlaczego działa zbieracz pamięci?

Pamięć w JavaScript jest zarządzana automatycznie przez maszynę wirtualną. V8 używa wspólnego systemu zbierania pamięci, w którym pamięć jest dzielona na 2 (lub więcej) pokolenia. Młoda generacja przechowuje obiekty, które zostały niedawno przydzielone. Jeśli obiekt przetrwa wystarczająco długo, zostanie przeniesiony do starszej generacji.

Dane o młodych użytkownikach są zbierane znacznie częściej niż dane o starszych użytkownikach. Jest to celowe działanie, ponieważ zbieranie danych z młodszych generacji jest znacznie tańsze. Często można założyć, że częste przerwy w GC są spowodowane zbieraniem danych o młodych generacjach.

W V8 nowa przestrzeń pamięci jest podzielona na 2 ciągłe bloki pamięci o takim samym rozmiarze. W danym momencie używany jest tylko jeden z tych dwóch bloków pamięci i nazywa się go przestrzenią to. Przy wystarczającej ilości pamięci przydzielenie nowego obiektu jest tanie. Kursor w polu do jest przesuwany do przodu o liczbę bajtów potrzebną do nowego obiektu. Ten proces będzie trwać, dopóki nie wyczerpie się miejsce. W tym momencie program jest zatrzymany i rozpoczyna się zbieranie danych.

Młoda pamięć V8

W tym momencie miejsca „From” i „To” są zamieniane. Dane z pliku to są skanowane od początku do końca, a wszystkie obiekty, które są nadal aktywne, są kopiowane do pliku to lub promowane do stosu starszej generacji. Jeśli chcesz uzyskać więcej informacji, przeczytaj artykuł Algorytm Cheneya.

Zasadniczo należy rozumieć, że za każdym razem, gdy obiekt jest przydzielany domyślnie lub jawnie (za pomocą wywołania new, [], lub {}), aplikacja zbliża się do zbioru elementów do usunięcia i do opóźnienia jej działania.

Czy w przypadku tej aplikacji można się spodziewać 10 MB/s danych nieużytecznych?

Krótko mówiąc, nie. Deweloperzy nie robią nic, aby generować 10 MB/s śmieci.

Podejrzani

Następnym etapem dochodzenia jest ustalenie potencjalnych podejrzanych, a potem ich wyeliminowanie.

Podejrzany 1

wywołanie nowego podczas ramki. Pamiętaj, że każdy przydzielony obiekt przybliża Cię do pauzy GC. Aplikacje działające z dużą liczbą klatek powinny dążyć do zerowej liczby alokacji na klatkę. Zwykle wymaga to starannie przemyślonego systemu recyklingu obiektów, który jest dostosowany do danej aplikacji. Detektywi V8 skontaktowali się z zespołem Oz i okazało się, że nie było nowych wywołań. Zespół Oz wiedział już o tym wymaganiu i powiedział, że „to byłoby krępujące”. To już za nami.

Podejrzany 2

Modyfikowanie „kształtu” obiektu poza konstruktorem. Dzieje się tak, gdy nowa właściwość jest dodawana do obiektu poza konstruktorem. Spowoduje to utworzenie nowej ukrytej klasy dla obiektu. Gdy zoptymalizowany kod wykryje tę nową ukrytą klasę, zostanie uruchomione odoptymalizowanie. Niezoptymalizowany kod będzie się wykonywać, dopóki kod nie zostanie ponownie sklasyfikowany jako aktywny i zoptymalizowany. Ta zmiana z optymalizacji na optymalizację spowoduje niestabilność,ale nie będzie ściśle powiązana z nadmiernym generowaniem śmieci. Po dokładnym sprawdzeniu kodu potwierdzono, że kształty obiektów są statyczne, więc podejrzany 2 został wykluczony.

Podejrzany 3

Obliczenia w niezoptymalizowanym kodzie. W niezoptymalizowanym kodzie wszystkie obliczenia powodują przydzielenie rzeczywistych 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;

W rezultacie zostanie utworzonych 5 obiektów HeapNumber. Pierwsze 3 z nich to zmienne a, b i c. Czwarta to wartość anonimowa (a * b), a piąta to wartość z #4 * c. Ta ostatnia jest przypisana do punktu.x.

Oz wykonuje tysiące takich operacji na każdy obraz. Jeśli któreś z tych obliczeń występuje w funkcjach, które nigdy nie są optymalizowane, może to być przyczyną występowania danych nieużytecznych. Ponieważ obliczenia w niezoptymalizowanym przydzielają pamięć nawet na potrzeby tymczasowych wyników.

Podejrzany 4

przechowywanie liczby zmiennoprzecinkowej podwójnej precyzji w właściwości; Musisz utworzyć obiekt HeapNumber, aby przechowywać liczbę i zmienić właściwość, aby wskazywała na ten nowy obiekt. Zmiana właściwości na HeapNumber nie spowoduje powstania śmieci. Możliwe jednak, że jako właściwości obiektów przechowywane są liczne liczby zmiennopozycyjne podwójnej precyzji. Kod jest pełen instrukcji takich jak:

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

W optymalnym kodzie za każdym razem, gdy zmiennej x przypisuje się nowo obliczoną wartość, czyli pozornie nieszkodliwe wyrażenie, przypisuje się nowy obiekt HeapNumber, co zbliża nas do przerwy w zbieraniu elementów.

Pamiętaj, że korzystanie z tablicy typowanej (lub zwykłej tablicy zawierającej tylko podwójne podwójne precyzje) pozwala całkowicie uniknąć tego problemu, ponieważ pamięć dla podwójnej precyzji jest przydzielana tylko raz, a wielokrotne zmienianie wartości nie wymaga przydzielania nowej pamięci.

Podejrzany 4 to prawdopodobna opcja.

Kryminalistyka

W tej chwili detektywi mają 2 możliwych podejrzanych: przechowywanie liczb stosu jako właściwości obiektów i obliczenia arytmetyczne wykonywane w niezoptymalizowanych funkcjach. Nadszedł czas, aby udać się do laboratorium i ostatecznie ustalić, który z podejrzanych jest winny. UWAGA: w tej sekcji pokażę, jak rozwiązać problem występujący w rzeczywistym kodzie źródłowym Oz. Ta reprodukcja jest o wiele mniejsza niż oryginalny kod, dzięki czemu łatwiej ją analizować.

Eksperyment 1

Sprawdzanie podejrzanego elementu 3 (obliczenia arytmetyczne w niezoptymalizowanych funkcjach). Mechanizm JavaScript V8 ma wbudowany system rejestrowania, który może dostarczyć wielu informacji o tym, co dzieje się w tle.

Chrome nie jest wcale uruchomiony, uruchom Chrome z flagami:

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

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

Aby interpretować zawartość pliku v8.log, musisz pobrać tę samą wersję v8, której używa Chrome (sprawdź about:version), i skompilować ją.

Po pomyślnym skompilowaniu v8 możesz przetworzyć dziennik za pomocą procesora tick:

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

(w zależności od platformy zastąp linux hasłem mac lub windows). (to narzędzie musi być uruchamiane z katalogu źródłowego najwyższego poziomu w V8).

Przetwarzacz znaczników wyświetla tekstową tabelę funkcji JavaScript, które miały najwięcej 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

Plik demo.js zawierał 3 funkcje: opt, unopt i main. Obok nazw zoptymalizowanych funkcji widnieje gwiazdka (*). Zwróć uwagę, że funkcja opt jest zoptymalizowana, a unopt nie jest zoptymalizowana.

Kolejnym ważnym narzędziem w arsenalu detektywa V8 jest plot-timer-event. Można go wykonać w ten sposób:

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

Po uruchomieniu w bieżącym katalogu pojawi się plik png o nazwie timer-events.png. Po otwarciu strony zobaczysz coś takiego:

Zdarzenia licznika czasu

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

Oś Y zdarzeń licznika czasu

W wierszu V8.Execute znajduje się czarna pionowa linia na każdym znaczniku profilu, w którym V8 wykonywało kod JavaScript. V8.GCScavenger ma niebieską linię pionową narysowaną na każdym znaczniku profilu, w którym V8 wykonywało kolekcję nowej generacji. Podobnie w przypadku pozostałych stanów V8.

Jednym z najważniejszych wierszy jest „Rodzaj wykonywanego kodu”. Gdy jest wykonywany zoptymalizowany kod, dioda będzie świecić na zielono, a gdy nieoptymalizowany – na czerwono i niebiesko. Na poniższym zrzucie ekranu widać przejście z kodu zoptymalizowanego na kod nieoptymalizowany i z powrotem na kod zoptymalizowany:

Rodzaj kodu wykonywanego

W idealnej sytuacji, ale nie zawsze od razu, ta linia będzie na zielono. Oznacza to, że Twój program przeszedł w stan stabilny po optymalizacji. Niezoptymalizowany kod zawsze będzie działał wolniej niż zoptymalizowany.

Jeśli dotarłeś(-aś) do tego etapu, warto wiedzieć, że możesz pracować znacznie szybciej, przeprowadzając refaktoryzację aplikacji, aby można było uruchomić ją w powłoce debugowania v8: d8. Użycie d8 pozwala skrócić czas iteracji dzięki narzędziom tick-processor i plot-timer-event. Innym efektem ubocznym użycia d8 jest to, że łatwiej jest wyodrębnić rzeczywisty problem, zmniejszając ilość szumu w danych.

Na wykresie zdarzeń zegara w źródle kodu Oz widać przejście z kodu zoptymalizowanego na kod nieoptymalizowany. Podczas wykonywania nieoptymalizowanego kodu zostało wywołanych wiele kolekcji nowej generacji, jak na tym zrzucie ekranu (uwaga: czas został usunięty w środku):

Wykres zdarzeń licznika czasu

Przyjrzyj się uważnie wykresowi. Czasy wykonywania kodu JavaScript przez V8 są puste w tych samych momentach, w których występują zbiory nowej generacji (niebieskie linie). To wyraźnie pokazuje, że podczas zbierania śmieci skrypt jest wstrzymywany.

Patrząc na dane wyjściowe procesora interfejsu w kodzie źródłowym Oz, widać, że główna funkcja (updateSprites) nie została zoptymalizowana. Innymi słowy, funkcja, na której program spędził najwięcej czasu, też nie została zoptymalizowana. To wyraźnie wskazuje, że podejrzany 3 jest winny. Źródło funkcji updateSprites zawierało pętle, które wyglądały tak:

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

Znajomość V8 doprowadziła ich do wniosku, że konstrukcja pętli for-i-in nie jest czasem optymalizowana przez V8. Inaczej mówiąc, jeśli funkcja zawiera konstrukcję pętli for-i-in, może nie zostać zoptymalizowana. Jest to szczególny przypadek, który prawdopodobnie ulegnie zmianie w przyszłości, ponieważ V8 może kiedyś zoptymalizować tę konstrukcję pętli. Nie jesteśmy ekspertami w V8 i nie znamy tej biblioteki od podszewki do podszewki. Jak więc możemy ustalić, dlaczego updateSprites nie został zoptymalizowany?

Eksperyment 2

Uruchom Chrome z tą flagą:

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

wyświetla szczegółowy dziennik danych optymalizacji i dezoptymalizacji. Szukanie w danych obiektów updateSprites kończy się znalezieniem:

[disabled optimization for updateSprites, reason: ForInStatement is not fast case]

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

Zgłoszenie zamknięte

Po odkryciu przyczyny, dla której funkcja updateSprites nie była zoptymalizowana, rozwiązanie było proste: wystarczyło 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);
    }
}

Funkcja updateSprite zostanie zoptymalizowana, co spowoduje, że będzie generować znacznie mniej obiektów HeapNumber, a w efekcie rzadziej będzie wstrzymywać GC. Możesz to łatwo sprawdzić, przeprowadzając te same eksperymenty z nowym kodem. Uważny czytelnik zauważy, że liczby zmiennoprzecinkowe są nadal przechowywane jako właściwości. Jeśli profilowanie wskaże, że warto to zrobić, zmiana pozycji na tablicę podwójnych wartości rzeczywistych lub tablicę danych typowanych pozwoli jeszcze bardziej zmniejszyć liczbę tworzonych obiektów.

Epilog

Deweloperzy Oz nie poprzestali na tym. Dzięki narzędziom i technikom udostępnionym przez detektywów V8 udało im się znaleźć kilka innych funkcji, które utknęły w otchłani deoptymalizacji, i przekształcić kod obliczeniowy w funkcje liściaste, które zostały zoptymalizowane, co zaowocowało jeszcze większą wydajnością.

Ruszaj i rozwiązuj problemy z wydajnością.