Wprowadzenie
Daniel Clifford wygłosił na konferencji Google I/O wyśmienite wystąpienie, w którym przedstawił wskazówki i porady dotyczące zwiększania wydajności JavaScriptu w V8. Daniel zachęcał nas do „wymagania szybszego działania” – dokładnego analizowania różnic w wydajności między C++ a JavaScriptem oraz pisania kodu z uwzględnieniem sposobu działania JavaScriptu. W tym artykule znajdziesz podsumowanie najważniejszych punktów wystąpienia Daniela. Będziemy też aktualizować ten artykuł w miarę wprowadzania zmian w wskazówkach dotyczących skuteczności.
Najważniejsze porady
Ważne jest, aby każdą poradę dotyczącą skuteczności rozpatrywać w kontekście. Porady dotyczące wydajności są uzależniające, a czasami skupianie się na nich może odwracać uwagę od rzeczywistych problemów. Musisz mieć całościowy wgląd w wyniki aplikacji internetowej – zanim skupisz się na tych wskazówkach dotyczących wydajności, powinieneś przeanalizować kod za pomocą narzędzi takich jak PageSpeed i podnieść wynik. Pomoże Ci to uniknąć przedwczesnej optymalizacji.
Oto podstawowe wskazówki, które pomogą Ci uzyskać dobrą wydajność aplikacji internetowych:
- Bądź przygotowany, zanim pojawi się problem
- Następnie zidentyfikuj i zrozumiej sedno problemu.
- Na koniec popraw to, co ważne
Aby wykonać te czynności, warto zrozumieć, jak V8 optymalizuje JS, aby móc pisać kod z uwzględnieniem środowiska uruchomieniowego JS. Warto też poznać dostępne narzędzia i sprawdzić, jak mogą Ci pomóc. Daniel omawia w swoim wystąpieniu, jak korzystać z narzędzi dla deweloperów. W tym dokumencie omówiono najważniejsze kwestie związane z projektowaniem silnika V8.
Przejdźmy do wskazówek dotyczących V8!
Ukryte zajęcia
Kod JavaScript ma ograniczone informacje o typie podczas kompilacji: typy mogą być zmieniane w czasie działania, więc naturalne, że podczas kompilowania analiza typów JS jest kosztowna. Możesz się zastanawiać, jak wydajność JavaScriptu może kiedykolwiek zbliżyć się do C++. Jednak V8 ma ukryte typy tworzone wewnętrznie dla obiektów w czasie wykonywania. Obiekty z tą samą ukrytą klasą mogą wtedy korzystać z tego samego wygenerowanego kodu zoptymalizowanego.
Na przykład:
function Point(x, y) {
this.x = x;
this.y = y;
}
var p1 = new Point(11, 22);
var p2 = new Point(33, 44);
// At this point, p1 and p2 have a shared hidden class
p2.z = 55;
// warning! p1 and p2 now have different hidden classes!```
Dopóki instancja obiektu p2 nie ma dodatkowego elementu „.z”, p1 i p2 mają wewnętrznie tę samą ukrytą klasę. Dzięki temu V8 może wygenerować jedną wersję zoptymalizowanego kodu asemblera dla kodu JavaScript, który manipuluje obiektem p1 lub p2. Im bardziej uda Ci się uniknąć rozbieżności w przypadku ukrytych klas, tym lepsze wyniki uzyskasz.
Dlatego
- Zainicjuj wszystkie elementy obiektu w funkcjach konstruktora (aby instancje nie zmieniały później typu)
- Zawsze inicjuj elementy obiektu w tej samej kolejności
Numbers
V8 używa tagowania, aby efektywnie reprezentować wartości, gdy typy mogą się zmieniać. V8 określa typ liczby na podstawie używanych wartości. Gdy V8 wykona to wnioskowanie, wykorzystuje tagowanie do efektywnego przedstawiania wartości, ponieważ typy te mogą się dynamicznie zmieniać. Zmiana tych tagów typu może jednak wiązać się z kosztami, dlatego najlepiej jest konsekwentnie używać typów liczbowych. W odpowiednich przypadkach najlepiej jest używać 31-bitowych liczb całkowitych ze znakiem.
Na przykład:
var i = 42; // this is a 31-bit signed integer
var j = 4.2; // this is a double-precision floating point number```
Dlatego
- Preferuj wartości liczbowe, które można przedstawić jako 31-bitowe liczby całkowite ze znakiem.
Tablice
Aby obsługiwać duże i rzadkie tablice, wewnętrznie dostępne są 2 typy pamięci tablic:
- Szybkie elementy: pamięć liniowa dla kompaktowych zestawów kluczy
- Elementy słownika: przechowywanie tabeli haszującej w innym przypadku
Najlepiej nie powodować przełączania się typu pamięci tablicowej z jednego na inny.
Dlatego
- Używaj ciągłych kluczy zaczynających się od 0 w przypadku tablic.
- Nie przydzielaj z góry dużych tablic (np. większych niż 64 K elementów) do ich maksymalnego rozmiaru, lecz zwiększaj je w miarę potrzeby
- Nie usuwaj elementów w tablicach, zwłaszcza tablic liczbowych.
- Nie wczytuj niezainicjowanych lub usuniętych elementów:
for (var b = 0; b < 10; b++) {
a[0] |= b; // Oh no!
}
//vs.
a = new Array();
a[0] = 0;
for (var b = 0; b < 10; b++) {
a[0] |= b; // Much better! 2x faster.
}
Ponadto tablice podwójnych są szybsze – ukryta klasa tablicy śledzi typy elementów, a tablice zawierające tylko podwójne są rozpakowane (co powoduje zmianę ukrytej klasy). Nieostrożna manipulacja tablicami może jednak powodować dodatkowe obciążenie związane z pakowaniem i rozpakowywaniem, np.
var a = new Array();
a[0] = 77; // Allocates
a[1] = 88;
a[2] = 0.5; // Allocates, converts
a[3] = true; // Allocates, converts```
jest mniej wydajny niż:
var a = [77, 88, 0.5, true];
ponieważ w pierwszym przykładzie poszczególne przypisania są wykonywane jedno po drugim, a przypisanie wartości a[2]
powoduje, że tablica jest konwertowana na tablicę niespakowanych podwójnych liczb rzeczywistych, ale przypisanie wartości a[3]
powoduje, że jest ona ponownie konwertowana z powrotem na tablicę, która może zawierać dowolne wartości (liczby lub obiekty). W drugim przypadku kompilator zna typy wszystkich elementów w literału, a klasę ukrytą można określić od razu.
- Inicjowanie za pomocą literałów tablic w przypadku małych tablic o stałym rozmiarze
- Wstępnie przydzielaj małe tablice (< 64 KB) o odpowiednim rozmiarze przed ich użyciem.
- Nie przechowuj nieliczbowych wartości (obiektów) w tablicach liczbowych
- Jeśli inicjujesz tablice bez literałów, uważaj, aby nie spowodować ponownej konwersji małych tablic.
Kompilacja JavaScript
Chociaż JavaScript jest bardzo dynamicznym językiem, a pierwotne jego implementacje były interpreterami, nowoczesne środowisko uruchomieniowe JavaScript korzysta z kompilacji. V8 (mechanizm JavaScript w Chrome) ma 2 różne kompilatory JIT:
- kompilator „Pełny”, który może generować dobry kod dla dowolnego kodu JavaScript;
- Kompilator optymalizujący, który generuje świetny kod dla większości kodu JavaScript, ale jego kompilacja trwa dłużej.
Pełny kompilator
W V8 kompilator pełny działa na całym kodzie i rozpoczyna jego wykonywanie tak szybko, jak to możliwe, szybko generując dobry, ale nie najlepszy kod. Kompilator ten nie zakłada prawie niczego o typach w momencie kompilacji – zakłada, że typy zmiennych mogą i będą się zmieniać w czasie wykonywania. Kod wygenerowany przez kompilator pełny korzysta z pamięci podręcznej w kodzie (IC) do dokładnego określania typów podczas działania programu, co pozwala na bieżąco zwiększać wydajność.
Celem pamięci podręcznej w kodzie jest wydajne obsługiwanie typów przez przechowywanie w pamięci podręcznej kodu zależnego od typu na potrzeby operacji. Gdy kod jest uruchamiany, najpierw sprawdza założenia dotyczące typu, a potem używa pamięci podręcznej w kodziku do skrótu operacji. Oznacza to jednak, że operacje, które akceptują wiele typów, będą mniej wydajne.
Dlatego
- W przypadku operacji polimorficznych preferowane jest zastosowanie monoomorficzne
Operacje są monomorficzne, jeśli ukryte klasy danych wejściowych są zawsze takie same. W przeciwnym razie są polimorficzne, co oznacza, że niektóre argumenty mogą zmieniać typ w różnych wywołaniach operacji. Na przykład drugi wywołanie add() w tym przykładzie powoduje polimorfizm:
function add(x, y) {
return x + y;
}
add(1, 2); // + in add is monomorphic
add("a", "b"); // + in add becomes polymorphic```
Kompilator optymalizujący
Równolegle z kompilacją pełną kompilator V8 ponownie kompiluje „gorące” funkcje (czyli funkcje, które są wykonywane wiele razy) za pomocą kompilatora optymalizacyjnego. Ten kompilator wykorzystuje informacje zwrotne o typie, aby szybciej skompilować skompilowany kod – w rzeczywistości wykorzystuje typy pobrane z objętych przed chwilą wskazówek.
W kompilatorze optymalizacyjnym operacje są umieszczane w ramach spekulatywnego wstawiania (bezpośrednio w miejscu wywołania). Przyspiesza to wykonywanie (kosztem zajmowanej pamięci), ale umożliwia też inne optymalizacje. Funkcje i konstruktory monoomorficzne mogą być w pełni wbudowane (to kolejny powód, dla którego monomorfizm jest dobrym pomysłem w przypadku V8).
Możesz rejestrować, co jest optymalizowane za pomocą samodzielnej wersji „d8” silnika V8:
d8 --trace-opt primes.js
(zapisuje on nazwy zoptymalizowanych funkcji w stdout).
Nie wszystkie funkcje da się jednak zoptymalizować – niektóre z nich uniemożliwiają działanie kompilatora optymalizacji w przypadku danej funkcji (tzw. rezygnacja). W szczególności kompilator optymalizujący obecnie rezygnuje z funkcji z blokami try {} catch {}.
Dlatego
- Umieść kod wrażliwy na działanie perf do zagnieżdżonej funkcji, jeśli wypróbujesz bloki {}cat {}: ```js function perf_sensitive() { // W tym miejscu stosuj bloki z uwzględnieniem wydajności }
try { perf_sensitive() } kick (e) { // Tutaj obsłuż wyjątki } ```
Te wskazówki prawdopodobnie ulegną zmianie w przyszłości, ponieważ włączymy bloki try/catch w kompilatorze optymalizacyjnym. Aby zobaczyć, jak kompilator optymalizacyjny rezygnuje z funkcji, użyj opcji „--trace-opt” z parametrem d8 zgodnie z opisem powyżej. Znajdziesz tam więcej informacji o tym, które funkcje zostały wykluczone:
d8 --trace-opt primes.js
Deoptymalizacja
Optymalizacja wykonywana przez ten kompilator jest spekulatywna – czasami nie działa i musimy się wycofać. Proces „deoptymalizacji” odrzuca zoptymalizowany kod i wznacznie wznawia jego wykonywanie w odpowiednim miejscu w „pełnym” kodzie kompilatora. Reoptymalizacja może zostać ponownie uruchomiona później, ale na krótką metę jej wykonywanie zostaje spowolnione. W szczególności przyczyną tej deoptymalizacji jest wywoływanie zmian w ukrytych klasach zmiennych po zoptymalizowaniu funkcji.
Dlatego
- Unikaj ukrytych zmian klasy w funkcjach po ich optymalizacji
Podobnie jak w przypadku innych optymalizacji, możesz uzyskać dziennik funkcji, które V8 musiało zdeoptymalizować za pomocą flagi rejestrowania:
d8 --trace-deopt primes.js
Inne narzędzia V8
Przy okazji możesz też przekazać opcje śledzenia V8 do Chrome podczas uruchamiania:
"/Applications/Google Chrome.app/Contents/MacOS/Google Chrome" --js-flags="--trace-opt --trace-deopt"```
Oprócz profilowania w narzędziach dla programistów możesz też korzystać z narzędzia d8 do profilowania:
% out/ia32.release/d8 primes.js --prof
Ta metoda korzysta z wbudowanego profilera próbkowania, który co milisekundę pobiera próbkę i zapisuje ją w pliku v8.log.
W podsumowaniu
Aby przygotować się do tworzenia wydajnego kodu JavaScript, musisz określić, jak silnik V8 współpracuje z Twoim kodem. Ponownie podajemy podstawową radę:
- Przygotuj się, zanim zobaczysz (lub zauważysz) problem
- Następnie zidentyfikuj i zrozumiej sedno problemu.
- Na koniec popraw to, co ważne
Oznacza to, że musisz się upewnić, że problem leży w Twoim kodzie JavaScript. Aby to sprawdzić, użyj najpierw innych narzędzi, takich jak PageSpeed. Możesz też ograniczyć się do samego kodu JavaScript (bez DOM) przed zebraniem danych. Następnie użyj tych danych, aby zlokalizować wąskie gardła i je wyeliminować. Mamy nadzieję, że wykład Daniela (oraz ten artykuł) pomoże Ci lepiej zrozumieć, jak V8 uruchamia JavaScript, ale pamiętaj, by skupić się też na optymalizacji własnych algorytmów.