Wprowadzenie
W ostatnich latach aplikacje internetowe znacznie przyspieszyły. Wiele aplikacji działa teraz na tyle 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 dla deweloperów pracujących nad aplikacjami o wysokiej wydajności to za mało. Pomimo niesamowitych postępów w technologii maszyn wirtualnych JavaScript niedawne badanie wykazało, że aplikacje Google spędzają od 50% do 70% czasu w V8. Aplikacja ma ograniczony czas, więc skrócenie cykli 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ę, inaczej dochodzi do zacinania się. Przeczytaj ten artykuł, aby dowiedzieć się, jak optymalizować kod JavaScript i profilować aplikacje w JavaScripcie. Poznasz historię detektywów wydajności z zespołu V8, którzy tropili niejasny problem z wydajnością w Find Your Way to Oz.
Sesja Google I/O 2013
Przedstawiłem ten materiał na konferencji Google I/O 2013. Obejrzyj film poniżej:
Dlaczego wydajność ma znaczenie?
Cykle procesora to gra o sumie zerowej. Jeśli jedna część systemu będzie zużywać mniej zasobów, będzie można wykorzystać ich więcej w innej części lub zapewnić płynniejsze działanie całości. Szybsze działanie i większa liczba funkcji to często sprzeczne cele. Użytkownicy oczekują nowych funkcji, ale chcą też, aby aplikacja działała płynniej. Maszyny wirtualne JavaScriptu są coraz szybsze, ale nie jest to powód, aby ignorować problemy z wydajnością, które można rozwiązać już dziś, o czym wie wielu programistów borykających się z problemami z wydajnością w swoich aplikacjach internetowych. W aplikacjach działających w czasie rzeczywistym z dużą liczbą klatek na sekundę najważniejsze jest, aby nie występowały zacięcia. Firma Insomniac Games przeprowadziła badanie, które wykazało, że stabilna, utrzymująca się liczba klatek jest ważna dla sukcesu gry: „Stabilna liczba klatek jest nadal oznaką profesjonalnego, dobrze wykonanego produktu”. Uwaga dla programistów stron internetowych.
Rozwiązywanie problemów z wydajnością
Rozwiązywanie problemu z wydajnością jest jak rozwiązywanie zagadki kryminalnej. Musisz dokładnie przeanalizować dowody, sprawdzić podejrzewane przyczyny i wypróbować różne rozwiązania. Przez cały czas musisz dokumentować pomiary, aby mieć pewność, że problem został rozwiązany. Ta metoda niewiele różni się od sposobu, w jaki detektywi rozwiązują sprawy kryminalne. Detektywi badają dowody, przesłuchują podejrzanych i przeprowadzają eksperymenty, aby znaleźć niepodważalny dowód.
V8 CSI: Oz
Niesamowici czarodzieje, którzy stworzyli Find Your Way to Oz, zwrócili się do zespołu V8 z problemem dotyczącym wydajności, którego nie mogli rozwiązać samodzielnie. Czasami Oz się zawieszał, co powodowało zacinanie się. Programiści Oz przeprowadzili wstępne badanie za pomocą panelu osi czasu w Narzędziach deweloperskich w Chrome. Analizując wykorzystanie pamięci, natknęli się na przerażający wykres piłokształtny. Raz na sekundę moduł odśmiecania pamięci zbierał 10 MB śmieci, a przerwy w odśmiecaniu pamięci odpowiadały zacinaniu się. Podobnie jak na tym zrzucie ekranu z osi czasu w Narzędziach deweloperskich w Chrome:
Detektywi V8, Jakob i Yang, zajęli się tą sprawą. Jakob i Yang z zespołu V8 oraz zespół Oz długo wymieniali się informacjami. Wybraliśmy z tej rozmowy najważniejsze wydarzenia, które pomogły nam rozwiązać ten problem.
Dowody
Pierwszym krokiem jest zebranie i przeanalizowanie wstępnych dowodów.
O jakim typie aplikacji mówimy?
Wersja demonstracyjna Oz to interaktywna aplikacja 3D. Z tego powodu jest bardzo wrażliwy na przerwy spowodowane przez procesy czyszczenia 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 pozostawić część tego czasu na przetworzenie przez Chrome wywołań graficznych i wyświetlenie ekranu.
Oz wykonuje wiele obliczeń arytmetycznych na wartościach podwójnych i często wywołuje WebAudio i WebGL.
Na czym polega problem z wydajnością?
Widzimy przerwy, czyli gubienie klatek, czyli zacinanie się. Te przerwy są powiązane z uruchomieniami czyszczenia pamięci.
Czy deweloperzy stosują sprawdzone metody?
Tak, deweloperzy Oz dobrze znają techniki optymalizacji i wydajności maszyn wirtualnych JavaScriptu. Warto zauważyć, że deweloperzy Oz używali CoffeeScript jako języka źródłowego i generowali kod JavaScript za pomocą kompilatora CoffeeScript. Utrudniło to niektóre aspekty analizy, ponieważ kod pisany przez deweloperów Oz różnił się od kodu używanego przez V8. Narzędzia deweloperskie w Chrome obsługują teraz mapy źródeł, co ułatwiłoby to zadanie.
Dlaczego uruchamia się proces odzyskiwania pamięci?
Pamięcią w JavaScript automatycznie zarządza maszyna wirtualna. V8 używa wspólnego systemu czyszczenia pamięci, w którym pamięć jest podzielona na 2 (lub więcej) generacje. Młoda generacja zawiera obiekty, które zostały niedawno przydzielone. Jeśli obiekt przetrwa wystarczająco długo, zostanie przeniesiony do starej generacji.
Młoda generacja jest zbierana znacznie częściej niż stara. Jest to celowe, ponieważ zbieranie młodego pokolenia jest znacznie tańsze. Często można bezpiecznie założyć, że częste przerwy w GC są spowodowane zbieraniem młodej generacji.
W V8 młoda przestrzeń pamięci jest podzielona na 2 równe, ciągłe bloki pamięci. W danym momencie używany jest tylko jeden z tych 2 bloków pamięci, który jest nazywany przestrzenią docelową. Dopóki w przestrzeni docelowej jest wolna pamięć, przydzielanie nowego obiektu jest tanie. Kursor w przestrzeni docelowej jest przesuwany do przodu o liczbę bajtów potrzebną dla nowego obiektu. Ten proces będzie trwać, dopóki nie skończy się miejsce w przestrzeni „do”. W tym momencie program zostanie zatrzymany i rozpocznie się zbieranie danych.
W tym momencie zamieniane są miejsca „z” i „do”. Obszar, który był obszarem docelowym, a teraz jest obszarem źródłowym, jest skanowany od początku do końca, a wszystkie obiekty, które nadal są aktywne, są kopiowane do obszaru docelowego lub przenoszone do sterty starej generacji. Jeśli chcesz poznać szczegóły, przeczytaj o algorytmie Cheneya.
Intuicyjnie powinno być jasne, że za każdym razem, gdy obiekt jest przydzielany w sposób domyślny lub jawny (przez wywołanie new, [] lub {}), aplikacja zbliża się do czyszczenia pamięci i przerażającego wstrzymania działania.
Czy w przypadku tej aplikacji oczekiwane jest 10 MB/s niepotrzebnych danych?
Krótko mówiąc: nie. Deweloper nie robi niczego, co mogłoby spowodować generowanie 10 MB/s niepotrzebnych danych.
Podejrzani
Kolejnym etapem dochodzenia jest ustalenie potencjalnych podejrzanych, a następnie zawężenie ich listy.
Podejrzany 1
Wywołanie operatora new w ramce. Pamiętaj, że każdy przydzielony obiekt przybliża Cię do przerwy na GC. Aplikacje działające z dużą liczbą klatek na sekundę powinny dążyć do zerowej liczby alokacji na klatkę. Zwykle wymaga to starannie przemyślanego, dostosowanego do aplikacji systemu recyklingu obiektów. Detektywi V8 skontaktowali się z zespołem Oz, który potwierdził, że nie wywołuje metody `new`. Zespół Oz był już świadomy tego wymogu i stwierdził: „To byłoby żenujące”. Możesz skreślić to zadanie z listy.
Podejrzany nr 2
modyfikowanie „kształtu” obiektu poza konstruktorem; Dzieje się tak, gdy do obiektu poza konstruktorem dodawana jest nowa właściwość. Spowoduje to utworzenie nowej ukrytej klasy dla obiektu. Gdy zoptymalizowany kod zobaczy tę nową ukrytą klasę, zostanie wywołana deoptymalizacja, a niezoptymalizowany kod będzie wykonywany, dopóki nie zostanie sklasyfikowany jako często używany i ponownie zoptymalizowany. Ten proces deoptymalizacji i ponownej optymalizacji powoduje zacinanie się,ale nie jest ściśle związany z tworzeniem nadmiernej ilości niepotrzebnych danych. Po dokładnej analizie kodu potwierdzono, że kształty obiektów były statyczne, co wykluczyło podejrzanego nr 2.
Podejrzany nr 3
Arytmetyka 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 powstaje 5 obiektów HeapNumber. Pierwsze 3 dotyczą zmiennych a, b i c. Czwarta dotyczy wartości anonimowej (a * b), a piąta pochodzi z #4 * c. Piąta jest ostatecznie przypisana do point.x.
Oz wykonuje tysiące takich operacji na klatkę. Jeśli którekolwiek z tych obliczeń występują w funkcjach, które nigdy nie są optymalizowane, mogą być przyczyną powstawania niepotrzebnych danych. Ponieważ obliczenia w niezoptymalizowanym kodzie przydzielają pamięć nawet dla wyników tymczasowych.
Podejrzany nr 4
Zapisywanie liczby o podwójnej precyzji we właściwości. Aby zapisać liczbę, należy utworzyć obiekt HeapNumber, a właściwość musi zostać zmieniona tak, aby wskazywała na ten nowy obiekt. Zmiana właściwości, aby wskazywała na HeapNumber, nie spowoduje powstania niepotrzebnych danych. Możliwe jest jednak, że jako właściwości obiektów przechowywanych jest wiele liczb o podwójnej precyzji. Kod zawiera wiele instrukcji takich jak ta:
sprite.position.x += 0.5 * (dt);
W zoptymalizowanym kodzie za każdym razem, gdy zmiennej x przypisywana jest nowo obliczona wartość, co jest pozornie nieszkodliwą instrukcją, niejawnie przydzielany jest nowy obiekt HeapNumber, co przybliża nas do przerwy na odśmiecanie pamięci.
Pamiętaj, że używając tablicy typowanej (lub zwykłej tablicy, która zawiera tylko liczby zmiennoprzecinkowe podwójnej precyzji), możesz całkowicie uniknąć tego problemu, ponieważ pamięć na liczbę zmiennoprzecinkową podwójnej precyzji jest przydzielana tylko raz, a wielokrotna zmiana wartości nie wymaga przydzielania nowej pamięci.
Podejrzany 4 jest możliwy.
Kryminalistyka
W tym momencie detektywi mają 2 podejrzanych: przechowywanie liczb na stercie 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 podejrzany jest winny. UWAGA: w tej sekcji użyję reprodukcji problemu znalezionego w rzeczywistym kodzie źródłowym Oz. Ten kod jest o wiele mniejszy niż oryginalny, więc łatwiej go przeanalizować.
Eksperyment 1
Sprawdzanie pod kątem podejrzanego 3 (obliczenia arytmetyczne w niezoptymalizowanych funkcjach). Silnik JavaScript V8 ma wbudowany system logowania, który może dostarczyć wielu informacji o tym, co dzieje się w tle.
Jeśli Chrome nie jest uruchomiona, uruchom ją z flagami:
--no-sandbox --js-flags="--prof --noprof-lazy --log-timer-events"
a następnie całkowite zamknięcie Chrome spowoduje utworzenie pliku dziennika v8.log w bieżącym katalogu.
Aby zinterpretować zawartość pliku v8.log, musisz pobrać tę samą wersję V8, której używa Chrome (sprawdź about:version), i ją skompilować.
Po pomyślnym utworzeniu V8 możesz przetworzyć dziennik za pomocą procesora tick:
$ tools/linux-tick-processor /path/to/v8.log
(Zastąp linux przez mac lub windows w zależności od platformy). (To narzędzie musi być uruchamiane z katalogu źródłowego najwyższego poziomu w V8).
Procesor ticków wyświetla tekstową tabelę funkcji JavaScriptu, które miały najwięcej tickó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
Możesz zobaczyć, że demo.js ma 3 funkcje: opt, unopt i main. Zoptymalizowane funkcje mają obok nazwy gwiazdkę (*). Zauważ, że funkcja opt jest zoptymalizowana, a unopt nie jest zoptymalizowana.
Innym ważnym narzędziem w zestawie 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 powinien wyświetlić się taki widok:
Oprócz wykresu u dołu dane są wyświetlane w wierszach. Oś X to czas (ms). Po lewej stronie znajdują się etykiety każdego wiersza:
Wiersz V8.Execute ma narysowaną czarną pionową linię w każdym punkcie profilu, w którym V8 wykonywał kod JavaScript. V8.GCScavenger ma narysowaną niebieską pionową linię na każdym takcie profilu, w którym V8 wykonywał zbieranie nowej generacji. Podobnie jest w przypadku pozostałych stanów V8.
Jednym z najważniejszych wierszy jest „code kind being executed” (rodzaj wykonywanego kodu). Będzie świecić na zielono, gdy wykonywany będzie zoptymalizowany kod, i na czerwono-niebiesko, gdy wykonywany będzie niezoptymalizowany kod. Zrzut ekranu poniżej pokazuje przejście od kodu zoptymalizowanego do niezoptymalizowanego, a następnie z powrotem do zoptymalizowanego:
W idealnym przypadku (ale nigdy od razu) ta linia będzie ciągła i zielona. Oznacza to, że program przeszedł w zoptymalizowany stan stabilny. Niezoptymalizowany kod zawsze będzie działać wolniej niż zoptymalizowany.
Jeśli udało Ci się to zrobić, warto zauważyć, że możesz pracować znacznie szybciej, refaktoryzując aplikację, aby mogła działać w powłoce debugowania V8: d8. Użycie d8 zapewnia szybsze iteracje dzięki narzędziom tick-processor i plot-timer-event. Innym efektem ubocznym używania d8 jest to, że łatwiej jest wyizolować rzeczywisty problem, co zmniejsza ilość szumu w danych.
Analiza wykresu zdarzeń licznika czasu z kodu źródłowego Oz wykazała przejście od kodu zoptymalizowanego do niezoptymalizowanego. Podczas wykonywania kodu niezoptymalizowanego uruchomionych zostało wiele kolekcji nowej generacji, podobnie jak na tym zrzucie ekranu (uwaga: czas został usunięty w środku):
Jeśli przyjrzysz się uważnie, zobaczysz, że czarne linie wskazujące, kiedy V8 wykonuje kod JavaScript, nie występują dokładnie w tych samych momentach co kolekcje nowej generacji (niebieskie linie). To wyraźnie pokazuje, że podczas usuwania nieużytków skrypt jest wstrzymany.
Jeśli spojrzymy na dane wyjściowe procesora tick z kodu źródłowego Oz, funkcja najwyższego poziomu (updateSprites) nie została zoptymalizowana. Innymi słowy, funkcja, w której program spędził najwięcej czasu, również była niezoptymalizowana. To wyraźnie wskazuje, że podejrzany nr 3 jest sprawcą. Ź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.
}
}
Znając V8 tak dobrze, od razu zauważyli, że konstrukcja pętli for-i-in czasami nie jest optymalizowana przez V8. Innymi słowy, jeśli funkcja zawiera konstrukcję pętli for-i-in, może nie zostać zoptymalizowana. Jest to obecnie szczególny przypadek, który prawdopodobnie zmieni się w przyszłości, tzn. V8 może kiedyś zoptymalizować tę konstrukcję pętli. Nie jesteśmy detektywami V8 i nie znamy tego silnika jak własnej kieszeni, więc jak możemy ustalić, dlaczego funkcja 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 optymalizacji i deoptymalizacji. Przeszukując dane pod kątem updateSprites, znajdujemy:
[wyłączono optymalizację dla updateSprites, powód: ForInStatement nie jest szybkim przypadkiem]
Zgodnie z hipotezą detektywów przyczyną była konstrukcja pętli for-i-in.
Sprawa zamknięta
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 znacznie mniej obiektów HeapNumber, a w konsekwencji – rzadsze przerwy na czyszczenie pamięci. Możesz to łatwo potwierdzić, przeprowadzając te same eksperymenty z nowym kodem. Uważny czytelnik zauważy, że liczby zmiennoprzecinkowe podwójnej precyzji są nadal przechowywane jako właściwości. Jeśli profilowanie wskazuje, że warto to zrobić, zmiana pozycji na tablicę liczb zmiennoprzecinkowych podwójnej precyzji lub tablicę danych z określonym typem 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 piekle deoptymalizacji, i przekształcić kod obliczeniowy w funkcje liści, które zostały zoptymalizowane, co pozwoliło uzyskać jeszcze lepszą wydajność.
Ruszaj więc i zacznij rozwiązywać problemy z wydajnością.