Wskazówki dotyczące wydajności JavaScript w V8

Chris Wilson
Chris Wilson

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.

Oto kilka wskazówek dotyczących V8.

Ukryte zajęcia

JavaScript ma ograniczone informacje o typach w czasie kompilacji: typy można zmieniać w czasie działania, więc naturalnie można oczekiwać, że tworzenie typów JS w czasie kompilacji jest kosztowne. 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 używać 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 unikniesz rozbieżności ukrytych klas, tym lepsze wyniki 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 etykietowania, aby efektywnie reprezentować wartości, gdy typy mogą się zmieniać. V8 wnioskuje na podstawie wartości, z jakiego typu liczby korzystasz. Gdy V8 dokona tego rozumowania, będzie używać tagowania, aby efektywnie reprezentować wartości, ponieważ te typy mogą się zmieniać dynamicznie. 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 miejscu

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 elementów nieinicjowanych ani usuniętych:
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 literale, a ukrytą klasę można określić z wyprzedzeniem.

  • 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 wartości nienumerycznych (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) w celu dokładnego określenia 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

  • Preferowane jest użycie monomorficznych operacji zamiast polimorficznych operacji

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. Kompilator ten używa informacji zwrotnej typu, aby przyspieszyć kompilowany kod. W rzeczy samej korzysta z typów pochodzących z interfejsów IC, o których właśnie mówiliśmy.

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 monomorficzne mogą być wstawiane w ramach całego kodu (to kolejny powód, dla którego monomorfizm jest dobrym pomysłem w 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 można jednak zoptymalizować – niektóre funkcje uniemożliwiają kompilatorowi optymalizującemu działanie na danej funkcji (tzw. „wyjście awaryjne”). W szczególności kompilator optymalizujący obecnie rezygnuje z funkcji z blokami try {} catch {}.

Dlatego

  • Jeśli masz bloki try {} catch {}, umieść kod, który wpływa na wydajność, w funkcji zagnieżdżonej:```js function perf_sensitive() { // Tutaj wykonuj czynności, które wpływają na wydajność }

try { perf_sensitive() } catch (e) { // Handle exceptions here } ```

Te wskazówki prawdopodobnie ulegną zmianie w przyszłości, ponieważ włączymy bloki try/catch w kompilatorze optymalizacyjnym. Aby sprawdzić, jak kompilator optymalizujący rezygnuje z funkcji, użyj opcji „--trace-opt” z d8, jak opisano powyżej. Dzięki temu uzyskasz więcej informacji o tym, z których funkcji rezygnowano:

d8 --trace-opt primes.js

De-optymalizacja

Optymalizacja wykonywana przez ten kompilator jest spekulacyjna – 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, wprowadzanie zmian w ukrytych klasach zmiennych po optymalizacji funkcji spowoduje deoptymalizację.

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 za pomocą narzędzi dla deweloperów możesz też użyć 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.

Podsumowanie

Aby przygotować się do tworzenia wydajnego kodu JavaScript, musisz określić, jak silnik V8 współpracuje z Twoim kodem. Ponownie podajemy podstawową radę:

  • Bądź przygotowany, zanim pojawi się 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, np. 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 (i ten artykuł) pomoże Ci lepiej zrozumieć, jak V8 uruchamia JavaScript. Pamiętaj jednak, aby skupić się też na optymalizacji własnych algorytmów.

Pliki referencyjne