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

Chris Wilson
Chris Wilson

Wstęp

Daniel Clifford wygłosił świetną prezentację na konferencji Google I/O, by poznać porady i wskazówki dotyczące zwiększania wydajności JavaScriptu w języku V8. Daniel zachęca nas do „szybszego wysyłania żądań” – to dokładne analizowanie różnic w wydajności między C++ i JavaScriptu oraz pisanie odpowiedniego kodu. W tym artykule znajdziesz podsumowanie najważniejszych punktów wypowiedzi Daniela. Będziemy też na bieżąco aktualizować ten artykuł w miarę zmiany wskazówek dotyczących skuteczności.

Najważniejsza rada

Ważne jest, aby wszystkie porady na temat skuteczności należy podać w kontekście. Porady dotyczące wyników są uzależniające i czasem skupienie się na dogłębnej wskazówce może odwrócić uwagę od rzeczywistych problemów. Musisz spojrzeć na skuteczność swojej aplikacji internetowej w całości – zanim skupisz się na tych wskazówkach, warto przeanalizować kod za pomocą takich narzędzi jak PageSpeed i poprawić wynik. Pozwoli to uniknąć przedwczesnej optymalizacji.

Najlepsza wskazówka, jak uzyskać dobrą wydajność aplikacji internetowych, to:

  • Przygotowanie się, zanim wystąpi (lub zauważy) problem
  • Następnie określ i zrozumiej sedno problemu.
  • Na koniec popraw to, co ważne

Aby wykonać te kroki, warto wiedzieć, jak V8 optymalizuje JS, który jest w stanie pisać kod z uwzględnieniem struktury środowiska wykonawczego JS. Warto też poznać dostępne narzędzia i sposób, w jaki mogą Ci one pomóc. Daniel przedstawia więcej szczegółów dotyczących korzystania z narzędzi dla programistów w swoim przemówieniu. W tym dokumencie uwzględniono tylko niektóre najważniejsze punkty w konstrukcji silnika V8.

Przejdźmy do wskazówek dotyczących V8.

Ukryte klasy

JavaScript ma ograniczone informacje o typach kodu JS w czasie kompilacji: typy mogą być zmieniane w czasie działania aplikacji, dlatego naturalne jest, że rozpatrywanie problemów związanych z typami JS podczas kompilacji jest kosztowne. Może to prowadzić do wątpliwości, jak wydajność JavaScriptu może zbliżyć się do C++. Jednak w V8 są ukryte typy utworzone dla obiektów w czasie działania; obiekty z tą samą ukrytą klasą mogą korzystać z 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 instancja obiektu p2 nie ma dodatkowego elementu „.z”, p1 i p2 mają wewnętrznie tę samą ukrytą klasę, więc V8 może wygenerować pojedynczą wersję zoptymalizowanego zestawu dla kodu JavaScript, który obsługuje zarówno p1, jak i p2. Im bardziej uda Ci się tego uniknąć, tym lepsze wyniki uzyskasz.

Dlatego

  • Inicjuj wszystkie elementy obiektu w funkcjach konstruktora (aby instancje nie zmieniły później typu)
  • Zawsze inicjuj elementy obiektów w tej samej kolejności

Numbers

Wersja 8 używa tagowania do efektywnego prezentowania wartości w sytuacjach, gdy typy się zmieniają. V8 określa na podstawie używanych przez Ciebie wartości typu liczby, z którą pracujesz. Gdy V8 wykona wnioskowanie, używa tagowania do efektywnego przedstawienia wartości, ponieważ te typy mogą się dynamicznie zmieniać. Czasami jednak zmiana tagów tego typu wiąże się z kosztami, najlepiej więc korzystać z typów liczb w spójny sposób i ogólnie najlepiej używać 31-bitowych liczb całkowitych ze znakiem w odpowiednich miejscach.

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 mogą być przedstawione jako 31-bitowe liczby całkowite ze znakiem.

Tablice

Do obsługi dużych i rozproszonych macierzy są wewnętrzne 2 typy pamięci masowej:

  • Szybkie elementy: pamięć liniowa dla kompaktowych zestawów kluczy
  • Elementy słownika: przechowywanie tabeli haszującej w innym przypadku

Najlepiej nie powodować przewracania pamięci tablicowej z jednego typu na drugi.

Dlatego

  • W przypadku tablic używaj sąsiadujących kluczy, zaczynając od 0
  • Nie przydzielaj wstępnie dużych tablic (np. ponad 64 tys. elementów) do ich maksymalnego rozmiaru, tylko zwiększaj je z biegiem czasu.
  • Nie usuwaj elementów w tablicach, zwłaszcza w tablicach liczbowych.
  • Nie wczytuj niezainicjowanych ani 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 z podwójnymi są też szybsze – klasa ukrytych w tablicy śledzi typy elementów, a tablice zawierające tylko liczby zmiennoprzecinkowe są rozpakowywane (co powoduje zmianę klasy). Jednak bez ostrożne manipulowanie tablicami może powodować 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 efektywne niż:

var a = [77, 88, 0.5, true];

W pierwszym przykładzie poszczególne przypisania są wykonywane jeden po drugim, a przypisanie a[2] powoduje, że tablica jest konwertowana na tablicę z rozpakowanymi elementami, ale przypisanie a[3] powoduje ponowne przekształcenie jej z powrotem w 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.

  • Inicjuj z użyciem literałów tablicy w przypadku małych tablic o stałym rozmiarze
  • Zanim użyjesz małych tablic (<64 tys.), aby poprawić ich rozmiar, przydziel je wstępnie.
  • Nie przechowuj wartości nieliczbowych (obiektów) w tablicach liczbowych
  • Uważaj, aby nie powodować ponownej konwersji małych tablic, jeśli inicjujesz bez literałów.

Kompilacja JavaScript

Chociaż JavaScript jest językiem bardzo dynamicznym, a jego oryginalne implementacje były interpreterami, nowoczesne mechanizmy wykonawcze JavaScriptu korzystają z kompilacji. V8 (JavaScript w Chrome) ma dwa różne kompilatory Just-In-Time (JIT):

  • kompilator „Full”, który może generować dobry kod dla każdego kodu JavaScript;
  • kompilator Optymalizuj, który tworzy świetny kod dla większości JavaScriptu, ale jego kompilacja trwa dłużej;

Pełna kompilacja

W wersji V8 kompilator pełnego kodu działa na całym kodzie i zaczyna go uruchamiać tak szybko, jak to możliwe, szybko generując dobry, ale niewystarczający kod. Ten kompilator nie zakłada w czasie kompilacji prawie żadnych informacji o typach – oczekuje, że typy zmiennych mogą się zmieniać w czasie działania. Kod wygenerowany przez kompilator typu Full używa wbudowanych pamięci podręcznej (IC) do poszerzania wiedzy na temat typów plików podczas działania programu, co zwiększa wydajność na bieżąco.

Celem wbudowanych pamięci podręcznej jest wydajna obsługa typów przez buforowanie kodu zależnego od typu operacji. Po uruchomieniu kodu najpierw sprawdzane są założenia dotyczące typu, a następnie użycie wbudowanej pamięci podręcznej w celu skrótu do operacji. Oznacza to jednak, że operacje, które akceptują wiele typów, będą mniej wydajne.

Dlatego

  • Zamiast operacji polimorficznych preferujemy użycie monomorficzne 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 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 optymalizacji

Równolegle z pełnym kompilatorem V8 ponownie kompiluje „gorące” funkcje (czyli funkcje, które są uruchamiane wiele razy) z kompilatorem optymalizującym. Ten kompilator korzysta z opinii o typach, aby przyspieszyć kompilowanie kodu – faktycznie korzysta z typów pobranych z przedstawionych przed chwilą układów scalonych.

W kompilatorze optymalizującym operacje są wbudowane spekulacyjnie (bezpośrednio tam, gdzie są wywoływane). Przyspiesza to wykonywanie kodu (obniżając ilość pamięci), ale umożliwia też inne optymalizacje. Funkcje i konstruktory monomorficzne mogą być wbudowane w całości (to kolejny powód, dla którego monomorfizm jest dobrym pomysłem w V8).

Możesz rejestrować optymalizację za pomocą samodzielnej wersji „d8” silnika V8:

d8 --trace-opt primes.js

(spowoduje to zapisanie nazw zoptymalizowanych funkcji na wyjściową).

Nie wszystkie funkcje można jednak zoptymalizować – niektóre z nich uniemożliwiają uruchomienie kompilatora optymalizacji w przypadku danej funkcji („bailout”). W szczególności kompilator optymalizujący obecnie blokuje funkcje z blokami try {} catch {}!

Dlatego

  • Jeśli użyjesz bloków typu catch-{}, umieść kod wrażliwy na perf w funkcji zagnieżdżonej: ```js function perf_sensitive() { // Wykonaj tu pracę zależną od wydajności }

try { perf_sensitive() } catch (e) { // Tutaj obsługujej wyjątki } ```

Te wskazówki prawdopodobnie się zmienią w przyszłości, gdy w kompilatorze optymalizującym wprowadzimy bloki try-catch. Możesz sprawdzić, jak kompilator optymalizujący wycofuje się z funkcji, używając jak powyżej opcji „--trace-opt” z d8. W ten sposób uzyskasz więcej informacji o tym, które funkcje zostały zablokowane:

d8 --trace-opt primes.js

Deoptymalizacja

Pamiętaj też, że optymalizacja przeprowadzana przez ten kompilator ma charakter spekulacyjny – czasami się nie sprawdza i wycofuje się. Proces „deoptymalizacji” odrzuca zoptymalizowany kod i wznawia wykonywanie we właściwym miejscu w „pełnym” kodzie kompilatora. Ponowna optymalizacja może zostać uruchomiona ponownie później, ale na krótki okres czas wykonywania jest wolniejszy. Deoptymalizacja jest w szczególności spowodowana zmianami w ukrytych klasach zmiennych po zoptymalizowaniu funkcji.

Dlatego

  • Unikaj ukrytych zmian klas w funkcjach po ich zoptymalizowaniu

Podobnie jak w przypadku innych optymalizacji możesz wygenerować dziennik funkcji, które V8 musiało przeprowadzić deoptymalizację, za pomocą flagi logowania:

d8 --trace-deopt primes.js

Inne narzędzia V8

Przy okazji możesz też przekazać do Chrome opcje śledzenia V8 podczas uruchamiania:

"/Applications/Google Chrome.app/Contents/MacOS/Google Chrome" --js-flags="--trace-opt --trace-deopt"```

Oprócz profilowania za pomocą narzędzi programistycznych możesz też profilować za pomocą narzędzia d8:

% out/ia32.release/d8 primes.js --prof

W tym celu używany jest wbudowany program do profilowania próbkowania, który pobiera próbkę co milisekundę i zapisuje dane v8.log.

W skrócie

Poznanie i zrozumienie działania mechanizmu V8 z kodem jest bardzo ważne, aby przygotować się do tworzenia wydajnego kodu JavaScript. Po trzecie, podstawowa rada jest taka:

  • Przygotowanie się, zanim wystąpi (lub zauważy) problem
  • Następnie określ i zrozumiej sedno problemu.
  • Na koniec popraw to, co ważne

Oznacza to, że musisz najpierw sprawdzić, czy problem tkwi w kodzie JavaScript, korzystając z innych narzędzi, takich jak PageSpeed. Przed rozpoczęciem gromadzenia danych najlepiej ograniczyć do czystego JavaScriptu (bez DOM), a potem wykorzystać te dane, aby znaleźć wąskie gardła i wyeliminować te najważniejsze. Mamy nadzieję, że prezentacja Daniela (oraz ten artykuł) pomoże Ci lepiej zrozumieć, jak V8 obsługuje JavaScript – ale skoncentruj się także na optymalizacji własnych algorytmów.

Źródła