Pisanie wysokowydajnego kodu JavaScript bez dezoptymalizacji

10

Pisząc wrażliwy na wydajność kod w Javascript, który działa na dużych tablicach numerycznych (pomyśl o pakiecie algebry liniowej, działającym na liczbach całkowitych lub liczbach zmiennoprzecinkowych), zawsze chce się, aby JIT pomógł w jak największym stopniu. Z grubsza oznacza to:

  1. Zawsze chcemy, aby nasze tablice były spakowane SMI (małe liczby całkowite) lub spakowane Podwójne, w zależności od tego, czy wykonujemy obliczenia liczb całkowitych czy zmiennoprzecinkowych.
  2. Zawsze chcemy przekazywać takie same funkcje do funkcji, aby nie zostały oznaczone jako „megamorficzne” i nie zostały zoptymalizowane. Na przykład zawsze chcemy dzwonić vec.add(x, y)z obydwoma xi ybyć zapakowanymi tablicami SMI lub obydwoma zapakowanymi podwójnymi tablicami.
  3. Chcemy, aby funkcje były w jak największym stopniu wbudowane.

Gdy ktoś odejdzie poza te przypadki, nastąpi nagły i drastyczny spadek wydajności. Może się to zdarzyć z różnych nieszkodliwych powodów:

  1. Możesz zamienić spakowaną tablicę SMI w spakowaną tablicę Double poprzez pozornie nieszkodliwą operację, taką jak odpowiednik myArray.map(x => -x). Jest to właściwie „najlepszy” zły przypadek, ponieważ spakowane podwójne tablice są nadal bardzo szybkie.
  2. Możesz zamienić spakowaną tablicę w ogólną tablicę pudełkową, na przykład poprzez odwzorowanie tablicy na funkcję, która (nieoczekiwanie) zwróciła nulllub undefined. Ten zły przypadek jest dość łatwy do uniknięcia.
  3. Możesz dezoptymalizować całą funkcję, na przykład vec.add()przekazując zbyt wiele rodzajów rzeczy i zmieniając ją w megamorficzną. Może się to zdarzyć, jeśli chcesz wykonać „programowanie ogólne”, które vec.add()jest używane zarówno w przypadkach, w których nie jesteś ostrożny z typami (więc widzi wiele typów), a także w przypadkach, w których chcesz uzyskać maksymalną wydajność (na przykład powinien zawsze otrzymywać podwójne kopie).

Moje pytanie jest bardziej delikatnym pytaniem o to, jak napisać wysokowydajny kod JavaScript w świetle powyższych rozważań, jednocześnie zachowując kod miły i czytelny. Kilka szczegółowych pytań cząstkowych, abyś wiedział, do jakiego rodzaju odpowiedzi dążę:

  • Czy jest gdzieś zbiór wskazówek, jak programować, pozostając w świecie zapakowanych tablic SMI (na przykład)?
  • Czy można wykonywać ogólne, wysokowydajne programowanie w Javascripcie bez użycia systemu makr do wprowadzania elementów takich jak vec.add()w callites?
  • Jak modularnie kodować wysokowydajny kod do bibliotek w świetle takich rzeczy, jak megamorficzne witryny wywołujące i dezoptymalizacje? Na przykład, jeśli z radością korzystam z pakietu Algebry Liniowej Az dużą prędkością, a następnie importuję pakiet, Bktóry zależy od niego A, ale Bwywołuje go z innymi typami i dezoptymalizuje go, nagle (bez zmiany kodu) mój kod działa wolniej.
  • Czy są jakieś dobre, łatwe w użyciu narzędzia pomiarowe do sprawdzania, co silnik JavaScript robi wewnętrznie z typami?
Joppy
źródło
1
To bardzo interesujący temat i bardzo dobrze napisany post, który pokazuje, że poprawnie wykonałeś swoją część badań. Jednak obawiam się, że pytanie (-a) jest zbyt szerokie dla formatu SO, a także, że nieuchronnie przyciągnie więcej opinii niż faktów. Optymalizacja kodu jest bardzo skomplikowaną sprawą, a dwie wersje silnika mogą nie zachowywać się tak samo. Myślę, że jest jedna z osób odpowiedzialnych za V8 JIT, która czasem się kręci, więc może mogą udzielić właściwej odpowiedzi dla swojego silnika, ale nawet dla nich, myślę, że byłby to zbyt szeroki temat na jedno pytanie / odpowiedź .
Kaiido
„Moje pytanie jest bardziej miękkim pytaniem o to, jak pisze się kod JavaScript o wysokiej wydajności ...” Na marginesie, zauważmy, że javascript zapewnia odradzanie procesów w tle (robotów sieciowych), a istnieją również biblioteki, które korzystają z oferta GPU (tensorflow.js i gpu.js) oznacza środki inne niż poleganie wyłącznie na kompilacji w celu zwiększenia przepustowości obliczeniowej aplikacji opartej na javascript ...
Jon Trent
@JonTrent Właściwie skłamałem trochę w swoim poście, nie dbam tak bardzo o klasyczne zastosowania algebry liniowej, ale bardziej o algebrę komputerową nad liczbami całkowitymi. Oznacza to, że wiele istniejących pakietów numerycznych jest natychmiast wykluczonych, ponieważ (na przykład) podczas zmniejszania wierszy macierzy mogą one dzielić przez 2, co jest „niedozwolone” na świecie, w którym pracuję od (1/2) nie jest liczbą całkowitą. Zastanawiałem się nad robotami sieciowymi (szczególnie w przypadku kilku długotrwałych obliczeń, które chcę anulować), ale problemem, który tu rozwiązuję, jest wystarczające zmniejszenie opóźnienia, aby móc reagować na interakcje.
Joppy
W przypadku arytmetyki liczb całkowitych w JavaScript prawdopodobnie patrzysz na kod w stylu asm.js, z grubsza „stawiając |0za każdą operację”. To nie jest ładne, ale najlepsze, co możesz zrobić w języku, który nie ma odpowiednich liczb całkowitych. Możesz także użyć BigInts, ale na dzień dzisiejszy nie są one bardzo szybkie w żadnym z popularnych silników (głównie z powodu braku popytu).
jmrk

Odpowiedzi:

8

Deweloper V8 tutaj. Biorąc pod uwagę zainteresowanie tym pytaniem i brak innych odpowiedzi, mogę spróbować; Obawiam się, że nie będzie to odpowiedź, na którą liczyłeś.

Czy jest gdzieś zbiór wskazówek, jak programować, pozostając w świecie zapakowanych tablic SMI (na przykład)?

Krótka odpowiedź: to właśnie tu: const guidelines = ["keep your integers small enough"].

Dłuższa odpowiedź: udzielenie kompleksowego zestawu wytycznych jest trudne z różnych powodów. Ogólnie rzecz biorąc, naszym zdaniem jest to, że programiści JavaScript powinni pisać kod, który ma dla nich sens i ich zastosowanie, a programiści silnika JavaScript powinni dowiedzieć się, jak szybko uruchomić ten kod na swoich silnikach. Z drugiej strony, istnieją oczywiście pewne ograniczenia tego ideału, w tym sensie, że niektóre wzorce kodowania zawsze będą miały wyższe koszty wydajności niż inne, niezależnie od wyborów implementacji silnika i działań optymalizacyjnych.

Kiedy mówimy o zaleceniach dotyczących wydajności, staramy się o tym pamiętać i dokładnie oszacować, które rekomendacje mają wysokie prawdopodobieństwo zachowania ważności przez wiele silników i wiele lat, a także są w miarę idiomatyczne / nieinwazyjne.

Wracając do przykładu: wewnętrzne użycie Smis powinno być szczegółem implementacyjnym, o którym kod użytkownika nie musi wiedzieć. Sprawi, że niektóre przypadki będą bardziej wydajne i nie powinny zaszkodzić w innych przypadkach. Nie wszystkie silniki używają Smis (na przykład AFAIK Firefox / Spidermonkey historycznie tego nie zrobił; słyszałem, że w niektórych przypadkach używają Smis; ale nie znam żadnych szczegółów i nie mogę rozmawiać z żadnym autorytetem w sprawie materia). W wersji 8 rozmiar Smis jest wewnętrznym szczegółem i faktycznie zmieniał się wraz z upływem czasu i wersjami. Na platformach 32-bitowych, które były najczęściej używane, Smis zawsze był 31-bitowymi liczbami całkowitymi; na platformach 64-bitowych były to 32-bitowe liczby całkowite ze znakiem, co ostatnio wydawało się najczęstszym przypadkiem, dopóki w Chrome 80 nie wprowadziliśmy „kompresji wskaźnika” dla architektur 64-bitowych, które wymagały zmniejszenia rozmiaru Smi do 31 bitów znanych z platform 32-bitowych. Jeśli zdarzyło Ci się, że oparłeś implementację na założeniu, że Smis mają zwykle 32 bity, dostaniesz niefortunne sytuacje takie jaktego .

Na szczęście, jak zauważyłeś, podwójne tablice wciąż są bardzo szybkie. W przypadku kodu obciążonego numerycznie prawdopodobnie sensowne jest przyjęcie / targetowanie podwójnych tablic. Biorąc pod uwagę częstość podwójności w JavaScript, można założyć, że wszystkie silniki mają dobre wsparcie dla podwójnych i podwójnych tablic.

Czy można wykonywać ogólne, wysokowydajne programowanie w JavaScript bez użycia czegoś takiego jak system makr, aby wstawić takie rzeczy jak vec.add () do stron wywoławczych?

„ogólny” jest generalnie sprzeczny z „wysokowydajnym”. Nie ma to związku z JavaScriptem ani z konkretnymi implementacjami silnika.

Kod „ogólny” oznacza, że ​​decyzje muszą być podejmowane w czasie wykonywania. Za każdym razem, gdy wykonujesz funkcję, kod musi być uruchamiany, aby określić, powiedzmy: „jest xliczbą całkowitą? Jeśli tak, weź tę ścieżkę kodu. Czy xłańcuch? Następnie przeskocz tutaj. Czy to obiekt? Czy on ma .valueOf? Nie? może .toString()? Może w swoim łańcuchu prototypów? Zadzwoń i zacznij od początku z wynikiem ". Zoptymalizowany kod „wysokiej wydajności” jest zasadniczo oparty na pomiarze rezygnacji z tych wszystkich testów dynamicznych; jest to możliwe tylko wtedy, gdy silnik / kompilator ma jakiś sposób na wcześniejsze wnioskowanie o typach: jeśli może udowodnić (lub założyć z wystarczającym prawdopodobieństwem), że xzawsze będzie liczbą całkowitą, musi wygenerować kod tylko dla tego przypadku ( pilnowany przez kontrolę typu, jeśli w grę wchodziły niepotwierdzone założenia).

Inlining jest do tego wszystkiego ortogonalny. Funkcję „ogólną” można jeszcze wprowadzić. W niektórych przypadkach kompilator może być w stanie propagować informacje o typie do funkcji wbudowanej, aby zmniejszyć tam polimorfizm.

(Dla porównania: C ++, ponieważ jest językiem kompilowanym statycznie, ma szablony do rozwiązania pokrewnego problemu. Krótko mówiąc, pozwalają programiście wyraźnie poinstruować kompilator o utworzeniu specjalistycznych kopii funkcji (lub całych klas) sparametryzowanych na danych typach. To jest fajne rozwiązanie w niektórych przypadkach, ale nie bez własnego zestawu wad, na przykład długich czasów kompilacji i dużych plików binarnych. JavaScript oczywiście nie ma czegoś takiego jak szablony. Możesz użyć evaldo zbudowania systemu, który jest nieco podobny, ale wtedy miałby podobne wady: musiałbyś wykonać ekwiwalent pracy kompilatora C ++ w czasie wykonywania i musiałbyś się martwić o ilość generowanego kodu).

Jak modularnie kodować wysokowydajny kod do bibliotek w świetle takich rzeczy, jak megamorficzne witryny wywołujące i dezoptymalizacje? Na przykład, jeśli szczęśliwie używam pakietu A Algebry Liniowej z dużą prędkością, a następnie importuję pakiet B zależny od A, ale B wywołuje go z innymi typami i dezoptymalizuje go, nagle (bez zmiany kodu) mój kod działa wolniej .

Tak, to jest ogólny problem z JavaScript. Wersja 8 służyła do wewnętrznego implementowania niektórych wbudowanych funkcji (takich jak Array.sort) w JavaScript, a ten problem (który nazywamy „zanieczyszczeniem sprzężeniem zwrotnym typu”) był jednym z głównych powodów, dla których całkowicie odeszliśmy od tej techniki.

To powiedziawszy, dla kodu numerycznego nie ma tak wielu typów (tylko Smis i podwójne), a jak zauważyłeś, powinny one mieć podobną wydajność w praktyce, więc chociaż zanieczyszczenie sprzężeniem zwrotnym typu jest rzeczywiście teoretycznym problemem, aw niektórych przypadkach może mają znaczący wpływ, jest również całkiem prawdopodobne, że w scenariuszach algebry liniowej nie zobaczysz mierzalnej różnicy.

Ponadto w silniku występuje znacznie więcej sytuacji niż „jeden typ == szybki” i „więcej niż jeden typ == wolny”. Jeśli dana operacja widziała zarówno Smis, jak i dublet, to w porządku. Ładowanie elementów z dwóch rodzajów tablic również jest w porządku. Używamy terminu „megamorficzny” w przypadku, gdy obciążenie zobaczyło tak wiele różnych typów, że rezygnuje z indywidualnego śledzenia, a zamiast tego używa bardziej ogólnego mechanizmu, który lepiej skaluje się do dużej liczby typów - funkcja zawierająca takie obciążenia może wciąż się optymalizuje. „Dezoptymalizacja” to bardzo konkretny czyn polegający na wyrzucaniu zoptymalizowanego kodu dla funkcji, ponieważ widzi się nowy typ, którego wcześniej nie widziano, i dlatego zoptymalizowany kod nie jest przystosowany do obsługi. Ale nawet to jest w porządku: po prostu wróć do niezoptymalizowanego kodu, aby zebrać więcej informacji zwrotnych na temat typu i zoptymalizuj później. Jeśli zdarzy się to kilka razy, nie ma się czym martwić; staje się problemem tylko w patologicznie złych przypadkach.

Podsumowując, to wszystko: nie martw się o to . Wystarczy napisać rozsądny kod, niech silnik sobie z tym poradzi. Mówiąc „rozsądny”, mam na myśli: to, co ma sens w twoim przypadku użycia, jest czytelne, łatwe w utrzymaniu, wykorzystuje wydajne algorytmy, nie zawiera błędów takich jak czytanie poza długością tablic. Idealnie, to wszystko, co tam jest, i nie musisz robić nic więcej. Jeśli to sprawia, że czujesz się lepiej zrobić coś , i / lub jeśli rzeczywiście obserwując problemy z wydajnością, mogę zaoferować dwa pomysły:

Korzystanie z TypeScript może pomóc. Ostrzeżenie o dużym tłuszczu: typy TypeScript mają na celu produktywność programistów, a nie wydajność wykonywania (i jak się okazuje, te dwie perspektywy mają bardzo odmienne wymagania od systemu typów). To powiedziawszy, istnieje pewne nakładanie się: np. Jeśli konsekwentnie adnotujesz rzeczy jako number, wtedy kompilator TS ostrzeże cię, jeśli przypadkowo umieścisz nullw tablicy lub funkcji, która powinna zawierać / operować tylko na liczbach. Oczywiście nadal wymagana jest dyscyplina: pojedynczy number_func(random_object as number)właz ratunkowy może po cichu podważyć wszystko, ponieważ poprawność adnotacji typu nie jest nigdzie egzekwowana.

Użycie TypedArrays może również pomóc. Mają nieco więcej narzutu (zużycie pamięci i szybkość alokacji) na tablicę w porównaniu ze zwykłymi tablicami JavaScript (więc jeśli potrzebujesz wielu małych tablic, wówczas tablice zwykłe są prawdopodobnie bardziej wydajne) i są mniej elastyczne, ponieważ nie mogą się rozwijać lub zmniejszają się po alokacji, ale dają gwarancję, że wszystkie elementy mają dokładnie jeden typ.

Czy są jakieś dobre, łatwe w użyciu narzędzia pomiarowe do sprawdzania, co silnik JavaScript robi wewnętrznie z typami?

Nie, i to celowo. Jak wyjaśniono powyżej, nie chcemy, abyś specjalnie dostosowywał swój kod do wszelkich wzorców, które V8 może dziś szczególnie dobrze zoptymalizować, i nie uważamy, że tak naprawdę chcesz to zrobić. Ten zestaw rzeczy może się zmieniać w obu kierunkach: jeśli istnieje wzór, który chciałbyś zastosować, możemy go zoptymalizować w przyszłej wersji (wcześniej bawiliśmy się pomysłem przechowywania nieskartowanych 32-bitowych liczb całkowitych jako elementów tablicy .. ... ale prace nad tym jeszcze się nie rozpoczęły, więc nie ma obietnic); a czasami, jeśli w przeszłości stosowaliśmy pewien wzorzec optymalizacji, możemy zdecydować się go porzucić, jeśli przeszkadza to w innych, ważniejszych / wpływających na optymalizację. Ponadto rzeczy takie jak inline heurystyka są notorycznie trudne do poprawienia, więc podejmowanie właściwych decyzji we właściwym czasie jest obszarem ciągłych badań i odpowiednich zmian w zachowaniu silnika / kompilatora; co sprawia, że ​​jest to kolejny przypadek, w którym byłoby to niefortunne dla wszystkich (ciebiei nas), jeśli spędziłeś dużo czasu na ulepszaniu kodu, dopóki jakiś zestaw bieżących wersji przeglądarek nie podejmie w przybliżeniu decyzji, które według ciebie (lub wiesz?) są najlepsze, tylko po to, aby wrócić pół roku później, aby zdać sobie sprawę, że obecne przeglądarki zmieniły swoją heurystykę.

Oczywiście zawsze możesz zmierzyć wydajność całej aplikacji - to jest to, co ostatecznie się liczy, a nie to, jakie wybory konkretnie dokonał silnik. Uważaj na mikrobenchmarki, ponieważ są one mylące: jeśli wyodrębnisz tylko dwa wiersze kodu i porównasz je, istnieje szansa, że ​​scenariusz będzie wystarczająco inny (np. Informacje zwrotne innego typu), że silnik podejmie bardzo różne decyzje.

jmrk
źródło
2
Dzięki za to doskonałą odpowiedź, potwierdza wielu moich podejrzeń o tym, jak to wszystko działa, i co najważniejsze, jak są one przeznaczone do pracy. Nawiasem mówiąc, czy są jakieś posty na blogu itp. Na temat problemu „typu feedback”, o którym wspominałeś Array.sort()? Chciałbym przeczytać o tym trochę więcej.
Joppy
Nie sądzę, że blogowaliśmy o tym konkretnym aspekcie. Zasadniczo jest to, co sam opisałeś w swoim pytaniu: kiedy wbudowane elementy są zaimplementowane w JavaScript, są one „jak biblioteka” w tym sensie, że jeśli różne fragmenty kodu wywołują je z różnymi typami, wydajność może pogorszyć - czasami tylko trochę, czasami więcej. Nie był to jedyny i prawdopodobnie nawet największy problem z tą techniką; Przede wszystkim chciałem tylko powiedzieć, że znam ogólny problem.
jmrk