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

Chris Wilson
Chris Wilson

Wprowadzenie

Daniel Clifford znakomicie wygłosił na konferencji Google I/O znakomite przemówienie na temat porad i wskazówek, jak zwiększyć wydajność JavaScriptu w V8. Daniel zachęcił nas do „szybszego żądania” – dokładne analizowanie różnic pod względem wydajności między C++ a JavaScriptem oraz umiejętność pisania kodu o działaniu JavaScriptu. Podsumowanie najważniejszych punktów przemówienia Daniela znajduje się w tym artykule. Będziemy go na bieżąco aktualizować wraz ze zmianami wskazówek dotyczących skuteczności.

Najważniejsza porada

Istotne jest, aby każda porada dotycząca skuteczności miała kontekst. Porady na temat skuteczności są uzależniające, a skupienie się najpierw na szczegółowych poradach może znacznie odwracać uwagę od rzeczywistych problemów. Musisz spojrzeć na wydajność swojej aplikacji w całościowy widok. Zanim skupisz się na tych wskazówkach, powinieneś przeanalizować kod za pomocą takich narzędzi jak PageSpeed, aby poprawić swój wynik. Pomoże Ci to uniknąć przedwczesnej optymalizacji.

Podstawowe wskazówki pozwalające uzyskać dobrą wydajność w aplikacjach internetowych:

  • Przygotuj się, zanim zobaczysz (lub zauważysz) problem
  • Następnie określ i zrozumiej sedno problemu
  • Na koniec popraw to, co ważne

Aby wykonać te kroki, warto wiedzieć, w jaki sposób V8 optymalizuje JavaScript, co pozwoli Ci pisać kod z uwzględnieniem projektu środowiska wykonawczego JS. Warto również zapoznać się z dostępnymi narzędziami i dowiedzieć się, jak mogą Ci pomóc. Daniel dokładniej omawia korzystanie z narzędzi dla programistów w swojej prezentacji: W tym dokumencie uchwycono jedynie najważniejsze kwestie dotyczące konstrukcji 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. Być może zastanawiasz się, czy wydajność JavaScriptu zbliży się do C++. Jednak V8 ma ukryte typy utworzone wewnętrznie dla obiektów w czasie działania. obiekty z tą samą klasą ukrytą mogą 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 instancja obiektu p2 nie będzie miała dodatkowego elementu „.z” dodane p1 i p2 mają wewnętrznie tę samą ukrytą klasę, więc V8 może wygenerować jedną wersję zoptymalizowanego zestawu dla kodu JavaScript, który manipuluje p1 lub p2. Im bardziej uda Ci się uniknąć rozbieżności ukrytych klas, tym lepszą wydajność 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 wykorzystuje tagowanie do efektywnego przedstawiania 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ć. Czasami zmiana tego typu tagów może być jednak płatna, dlatego najlepiej jest stosować go w spójny sposób. Ogólnie najlepszym rozwiązaniem jest używanie 31-bitowych liczb całkowitych ze znakiem w odpowiednich przypadkach.

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 można było obsługiwać duże i rozproszone tablice, istnieją 2 typy pamięci tablicowej wewnętrznie:

  • Szybkie elementy: liniowa pamięć masowa dla kompaktowych zestawów kluczy
  • Elementy słownikowe: w innym przypadku przechowywanie tabel haszujących

Najlepiej nie doprowadzać do zmiany typu pamięci tablicowej.

Dlatego

  • W przypadku tablic używaj kluczy sąsiednich, zaczynając od 0.
  • Nie przydzielaj wstępnie dużych tablic (np.64 tys. elementów) do ich maksymalnego rozmiaru, zamiast tego powiększać je z upływem czasu
  • 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.
}

Dodatkowo tablice z podwójnymi wartościami są szybsze – ukryta klasa tablicy śledzi typy elementów, a tablice zawierające tylko liczby zmiennoprzecinkowe są rozpakowywane (co powoduje zmianę klasy ukrytej). Jednak beztroskie manipulacje tablicami mogą powodować dodatkową pracę z powodu ich 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];

ponieważ w pierwszym przykładzie poszczególne przypisania są wykonywane jeden po drugim, a przypisanie a[2] powoduje, że tablica jest konwertowana na tablicę z podwójnymi niepudełkami, a przypisanie klasy a[3] powoduje przekonwertowanie jej 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.

  • Inicjuj z użyciem literałów tablicowych w przypadku małych tablic o stałym rozmiarze
  • Przed użyciem małych tablic (<64 KB) przydziel wstępnie do prawidłowego rozmiaru
  • Nie przechowuj nieliczbowych wartości (obiektów) w tablicach liczbowych
  • Pamiętaj, aby nie spowodować ponownej konwersji małych tablic w przypadku inicjowania bez literałów.

Kompilacja JavaScriptu

Mimo że JavaScript jest bardzo dynamicznym językiem, a jego oryginalne implementacje były tłumaczami, współczesne silniki środowiska wykonawczego JavaScript korzystają z kompilacji. V8 (JavaScript w Chrome) ma 2 różne kompilatory Just-In-Time (JIT):

  • Opcja „Full” kompilatora, który generuje dobry kod dla każdego JavaScriptu
  • Kompilator optymalizacji, który tworzy świetny kod dla większości języków JavaScript, ale jego kompilacja trwa dłużej.

Pełny kompilator

W wersji 8 pełny kompilator uruchamia się na całym kodzie i od razu rozpoczyna jego wykonywanie, szybko generując dobry, ale niezbyt dobry kod. Ten kompilator zakłada prawie nic na temat typów zmiennych w czasie kompilacji – oczekuje, że typy zmiennych mogą i będą się zmieniać w czasie działania. Kod wygenerowany przez kompilator typu Full korzysta z wbudowanych pamięci podręcznych (IC), aby doprecyzowywać wiedzę o typach podczas działania programu, co poprawia wydajność na bieżąco.

Celem wbudowanych pamięci podręcznych jest wydajna obsługa typów przez buforowanie zależnego od typu kodu na potrzeby operacji. podczas uruchamiania kodu zweryfikuje najpierw założenia dotyczące typu, a potem użyje wbudowanej pamięci podręcznej 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 stosowanie jednoomorficznych 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 zależności od różnych wywołań 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 kod „got” (czyli funkcji uruchamianych 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 optymalizacji operacje są spekulacyjne (umieszczane bezpośrednio tam, gdzie są wywoływane). Przyspiesza to wykonywanie działania (ogranicza wykorzystanie 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 zarejestrować to, co zostanie zoptymalizowane, używając samodzielnej wersji „d8”. wersja silnika V8:

d8 --trace-opt primes.js

(to powoduje zarejestrowanie nazw zoptymalizowanych funkcji do standardowego protokołu).

Nie wszystkie funkcje da się jednak zoptymalizować – niektóre z nich uniemożliwiają działanie kompilatora optymalizacyjnego w przypadku danej funkcji (tzw. rezygnacja). W szczególności obecnie kompilator optymalizujący wycofuje funkcje z blokami try {} „allow {}”.

Dlatego

  • Jeśli wypróbujesz bloki {}catch {}, umieść w zagnieżdżonej funkcji kod z danymi perf: ```js function perf_sensitive() { // W tym miejscu wykonaj czynności z uwzględnieniem wydajności

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

Te wskazówki prawdopodobnie ulegną zmianie w przyszłości, ponieważ w kompilatorze optymalizacyjnym włączamy bloki try/catch. Za pomocą parametru „--trace-opt” możesz sprawdzić, w jaki sposób kompilator optymalizacyjny umniejsza funkcje z kropką d8 jak powyżej. Daje ona więcej informacji o tym, które funkcje zostały pominięte:

d8 --trace-opt primes.js

Deoptymalizacja

Pamiętaj też, że optymalizacja przeprowadzana przez ten kompilator jest spekulacyjna – czasem nie wszystko działa i wracamy do tematu. Proces „dezoptymalizacji” odrzuca zoptymalizowany kod i wznawia wykonywanie kodu od razu w „pełnym” miejscu kompilatora. Ponowna optymalizacja może zostać aktywowana ponownie później, ale przez krótki czas wykonywanie zadań spowalnia pracę. W szczególności przyczyną tej deoptymalizacji jest wywoływanie zmian w ukrytych klasach zmiennych po zoptymalizowaniu funkcji.

Dlatego

  • Unikaj ukrytych zmian klas w funkcjach po ich zoptymalizowaniu

Podobnie jak w przypadku innych optymalizacji, za pomocą flagi logowania można uzyskać dziennik funkcji, które były deoptymalizowane przez V8:

d8 --trace-deopt primes.js

Inne narzędzia V8

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

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

Oprócz korzystania z profilowania narzędzi dla programistów możesz też korzystać z narzędzia d8 do profilowania:

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

Korzysta z wbudowanego narzędzia do profilowania próbkowania, które pobiera próbkę co milisekundę i zapisuje wersję v8.log.

W podsumowaniu

Warto wiedzieć, jak działa mechanizm V8 z kodem, by przygotować wydajny kod JavaScript. Powtórzmy podstawową radę:

  • Przygotuj się, zanim zobaczysz (lub zauważysz) problem
  • Następnie określ i zrozumiej sedno problemu
  • Na koniec popraw to, co ważne

Sprawdź, czy problem leży w kodzie JavaScript, poprzez użycie innych narzędzi, takich jak PageSpeed. Sprowadzać się do czystego JavaScriptu (bez DOM) przed zbieraniem wskaźników, a potem wykorzystać te dane, aby znaleźć wąskie gardła i wyeliminować te najważniejsze. Mamy nadzieję, że wykład Daniela (i ten artykuł) pomoże Ci lepiej zrozumieć, jak V8 uruchamia JavaScript, ale pamiętaj, by skupić się też na optymalizacji własnych algorytmów.

Pliki referencyjne