Wprowadzenie
Daniel Clifford wygłosił na konferencji Google I/O doskonałą prelekcję na temat porad i wskazówek dotyczących poprawy wydajności JavaScriptu w V8. Daniel zachęcił nas do „wymagania większej szybkości” – do dokładnego analizowania różnic w wydajności między C++ i JavaScriptem oraz do pisania kodu z uwzględnieniem sposobu działania JavaScriptu. W tym artykule znajdziesz podsumowanie najważniejszych punktów wystąpienia Daniela. Będziemy go też aktualizować, gdy zmienią się wskazówki dotyczące skuteczności.
Najważniejsza rada
Ważne jest, aby każdą poradę dotyczącą skuteczności umieścić w odpowiednim kontekście. Porady dotyczące wydajności są uzależniające, a czasami skupianie się na szczegółowych poradach może odwracać uwagę od rzeczywistych problemów. Musisz spojrzeć na wydajność aplikacji internetowej w sposób całościowy. Zanim skupisz się na tych wskazówkach dotyczących wydajności, przeanalizuj kod za pomocą narzędzi takich jak PageSpeed i popraw wynik. Pomoże Ci to uniknąć przedwczesnej optymalizacji.
Najlepsza podstawowa rada dotycząca uzyskiwania dobrej wydajności w aplikacjach internetowych to:
- Przygotuj się, zanim pojawi się problem
- Następnie określ i zrozum istotę problemu.
- Na koniec napraw to, co ważne
Aby wykonać te czynności, warto zrozumieć, jak V8 optymalizuje JS, dzięki czemu można pisać kod z uwzględnieniem projektu środowiska wykonawczego JS. Warto też dowiedzieć się, jakie narzędzia są dostępne i jak mogą Ci pomóc. Daniel w swojej prezentacji wyjaśnia, jak korzystać z narzędzi dla programistów. Ten dokument zawiera tylko niektóre z najważniejszych informacji o konstrukcji silnika V8.
Przejdźmy do wskazówek dotyczących V8.
Ukryte zajęcia
JavaScript ma ograniczone informacje o typach w czasie kompilacji: typy mogą być zmieniane w czasie działania, więc można się spodziewać, że wnioskowanie o typach JS w czasie kompilacji jest kosztowne. Możesz się zastanawiać, jak wydajność JavaScriptu może być zbliżona do C++. V8 ma jednak ukryte typy tworzone wewnętrznie dla obiektów w czasie działania. Obiekty z tą samą ukrytą klasą mogą następnie używać tego samego zoptymalizowanego wygenerowanego kodu.
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 do instancji obiektu p2 nie zostanie dodany dodatkowy element „.z”, p1 i p2 mają wewnętrznie tę samą ukrytą klasę, dzięki czemu V8 może wygenerować jedną wersję zoptymalizowanego kodu maszynowego dla kodu JavaScript, który manipuluje p1 lub p2. Im bardziej uda Ci się uniknąć rozbieżności między ukrytymi klasami, tym lepszą wydajność uzyskasz.
Dlatego
- Inicjuj 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 wnioskuje na podstawie używanych wartości, z jakim typem liczby masz do czynienia. Gdy V8 wyciągnie ten wniosek, używa tagowania, aby efektywnie reprezentować wartości, ponieważ te typy mogą się dynamicznie zmieniać. Czasami jednak zmiana tych tagów typu może wiązać się z kosztami, dlatego najlepiej jest używać typów liczb w sposób spójny. Ogólnie rzecz biorąc, najbardziej optymalne jest używanie 31-bitowych liczb całkowitych ze znakiem, gdy jest to właściwe.
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 rozproszone tablice, wewnętrznie dostępne są 2 typy pamięci masowej tablic:
- Fast Elements: pamięć liniowa dla kompaktowych zestawów kluczy
- Elementy słownika: w przeciwnym razie pamięć tabeli haszującej
Najlepiej nie zmieniać typu pamięci masowej tablicy.
Dlatego
- W przypadku tablic używaj ciągłych kluczy zaczynających się od 0.
- Nie przydzielaj wstępnie dużych tablic (np. > 64 tys. elementów) do ich maksymalnego rozmiaru, ale zwiększaj go w miarę potrzeb.
- Nie usuwaj elementów z tablic, 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.
}
Tablice liczb zmiennoprzecinkowych podwójnej precyzji są też szybsze – ukryta klasa tablicy śledzi typy elementów, a tablice zawierające tylko liczby zmiennoprzecinkowe podwójnej precyzji są rozpakowywane (co powoduje zmianę ukrytej klasy). Jednak nieostrożne manipulowanie tablicami może spowodować dodatkową pracę z powodu pakowania i rozpakowywania, 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 a[2] powoduje przekonwertowanie tablicy na tablicę nieopakowanych liczb zmiennoprzecinkowych, ale następnie przypisanie a[3] powoduje ponowne przekonwertowanie jej na tablicę, która może zawierać dowolne wartości (liczby lub obiekty). W drugim przypadku kompilator zna typy wszystkich elementów w literale, a ukrytą klasę można określić z góry.
- Inicjowanie przy użyciu literałów tablicowych w przypadku małych tablic o stałym rozmiarze
- Przed użyciem małych tablic (< 64 kB) wstępnie przydziel im prawidłowy rozmiar.
- Nie przechowuj wartości nienumerycznych (obiektów) w tablicach numerycznych.
- Jeśli inicjujesz bez literałów, uważaj, aby nie spowodować ponownej konwersji małych tablic.
Kompilacja JavaScriptu
Chociaż JavaScript jest bardzo dynamicznym językiem, a jego oryginalne implementacje były interpreterami, nowoczesne silniki środowiska wykonawczego JavaScriptu używają kompilacji. V8 (JavaScript w Chrome) ma 2 różne kompilatory JIT (Just-In-Time):
- Kompilator „Full”, który może wygenerować dobry kod dla dowolnego kodu JavaScript.
- Kompilator optymalizujący, który generuje świetny kod dla większości kodu JavaScript, ale kompilacja trwa dłużej.
Pełny kompilator
W V8 kompilator Full działa na całym kodzie i rozpoczyna wykonywanie kodu tak szybko, jak to możliwe, szybko generując dobry, ale nie doskonały kod. Ten kompilator nie zakłada prawie niczego o typach w czasie kompilacji – oczekuje, że typy zmiennych mogą się zmieniać i będą się zmieniać w czasie działania. Kod generowany przez kompilator Full wykorzystuje wbudowane pamięci podręczne (Inline Caches, IC) do uściślania wiedzy o typach podczas działania programu, co na bieżąco zwiększa wydajność.
Celem wbudowanych pamięci podręcznych jest wydajne obsługiwanie typów przez buforowanie kodu zależnego od typu na potrzeby operacji. Gdy kod jest uruchamiany, najpierw sprawdza założenia dotyczące typu, a następnie używa wbudowanej pamięci podręcznej, aby skrócić operację. Oznacza to jednak, że operacje, które akceptują wiele typów, będą mniej wydajne.
Dlatego
- Preferowane jest monomorficzne użycie operacji zamiast operacji polimorficznych.
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 drugie 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 pełnym kompilatorem V8 ponownie kompiluje „gorące” funkcje (czyli funkcje, które są uruchamiane wiele razy) za pomocą kompilatora optymalizującego. Ten kompilator wykorzystuje informacje o typach, aby przyspieszyć skompilowany kod. W rzeczywistości używa typów pobranych z pamięci podręcznych IC, o których właśnie mówiliśmy.
W kompilatorze optymalizującym operacje są wstawiane spekulatywnie (umieszczane bezpośrednio tam, gdzie są wywoływane). Przyspiesza to wykonywanie (kosztem wykorzystania pamięci), ale umożliwia też inne optymalizacje. Funkcje i konstruktory monomorficzne można w pełni wstawiać (to kolejny powód, dla którego monomorfizm jest dobrym pomysłem w V8).
Możesz rejestrować, co jest optymalizowane, korzystając z samodzielnej wersji „d8” silnika V8:
d8 --trace-opt primes.js
(powoduje to zapisanie nazw zoptymalizowanych funkcji w stdout).
Nie wszystkie funkcje można jednak zoptymalizować – niektóre z nich uniemożliwiają uruchomienie kompilatora optymalizującego w przypadku danej funkcji („bail-out”). W szczególności kompilator optymalizujący obecnie nie obsługuje funkcji z blokami try {} catch {}.
Dlatego
- Jeśli masz bloki try {} catch {}, umieść kod, który ma wpływ na wydajność, w funkcji zagnieżdżonej: ```js function perf_sensitive() { // Do performance-sensitive work here }
try { perf_sensitive() } catch (e) { // Handle exceptions here } ```
Te wskazówki prawdopodobnie zmienią się w przyszłości, gdy włączymy bloki try/catch w kompilatorze optymalizującym. Możesz sprawdzić, jak kompilator optymalizujący rezygnuje z funkcji, używając opcji „--trace-opt” z d8, jak powyżej. Dzięki temu uzyskasz więcej informacji o tym, z których funkcji zrezygnowano:
d8 --trace-opt primes.js
Deoptymalizacja
Wreszcie, optymalizacja wykonywana przez ten kompilator jest spekulacyjna – czasem się nie udaje i musimy się wycofać. Proces „deoptymalizacji” odrzuca zoptymalizowany kod i wznawia wykonywanie w odpowiednim miejscu w „pełnym” kodzie kompilatora. Ponowna optymalizacja może zostać uruchomiona później, ale w krótkiej perspektywie czasowej wykonanie kodu zwalnia. W szczególności deoptymalizacja nastąpi, gdy po optymalizacji funkcji zostaną wprowadzone zmiany w ukrytych klasach zmiennych.
Dlatego
- Unikaj zmian ukrytych klas w funkcjach po ich zoptymalizowaniu.
Podobnie jak w przypadku innych optymalizacji, możesz uzyskać dziennik funkcji, które V8 musiał zdeoptymalizować, za pomocą flagi logowania:
d8 --trace-deopt primes.js
Inne narzędzia V8
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 za pomocą narzędzi dla programistów możesz też używać d8:
% out/ia32.release/d8 primes.js --prof
Używa wbudowanego profilera próbkowania, który pobiera próbkę co milisekundę i zapisuje plik v8.log.
W skrócie
Aby przygotować się do tworzenia wydajnego kodu JavaScript, musisz wiedzieć, jak silnik V8 współpracuje z Twoim kodem. Podstawowa rada brzmi:
- Przygotuj się, zanim pojawi się problem
- Następnie określ i zrozum istotę problemu.
- Na koniec napraw to, co ważne
Oznacza to, że najpierw musisz się upewnić, że problem występuje w kodzie JavaScript, używając innych narzędzi, takich jak PageSpeed. Możesz też ograniczyć kod do czystego JavaScriptu (bez DOM) przed zebraniem danych, a następnie użyć tych danych do zlokalizowania wąskich gardeł i wyeliminowania tych najważniejszych. Mamy nadzieję, że prezentacja Daniela (i ten artykuł) pomogą Ci lepiej zrozumieć, jak V8 uruchamia JavaScript. Pamiętaj jednak, aby skupić się też na optymalizacji własnych algorytmów.