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:
- 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.
- 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 obydwomax
iy
być zapakowanymi tablicami SMI lub obydwoma zapakowanymi podwójnymi tablicami. - 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:
- 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. - 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
null
lubundefined
. Ten zły przypadek jest dość łatwy do uniknięcia. - 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órevec.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
A
z dużą prędkością, a następnie importuję pakiet,B
który zależy od niegoA
, aleB
wywoł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?
javascript
v8
jit
spidermonkey
Joppy
źródło
źródło
|0
za 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).Odpowiedzi:
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ś.
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.
„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
x
liczbą całkowitą? Jeśli tak, weź tę ścieżkę kodu. Czyx
ł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), żex
zawsze 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ć
eval
do 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).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ścisznull
w tablicy lub funkcji, która powinna zawierać / operować tylko na liczbach. Oczywiście nadal wymagana jest dyscyplina: pojedynczynumber_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.
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.
źródło
Array.sort()
? Chciałbym przeczytać o tym trochę więcej.