Czytam slajdy Breaking the Javascript Speed Limit with V8 i jest przykład podobny do kodu poniżej. Nie wiem, dlaczego <=
jest wolniejszy niż <
w tym przypadku, czy ktoś może to wyjaśnić? Wszelkie uwagi są mile widziane.
Powolny:
this.isPrimeDivisible = function(candidate) {
for (var i = 1; i <= this.prime_count; ++i) {
if (candidate % this.primes[i] == 0) return true;
}
return false;
}
(Wskazówka: liczby pierwsze to tablica długości liczba_pierwsza)
Szybciej:
this.isPrimeDivisible = function(candidate) {
for (var i = 1; i < this.prime_count; ++i) {
if (candidate % this.primes[i] == 0) return true;
}
return false;
}
[Więcej informacji] poprawa szybkości jest znacząca, w moim teście w środowisku lokalnym wyniki są następujące:
V8 version 7.3.0 (candidate)
Powolny:
time d8 prime.js
287107
12.71 user
0.05 system
0:12.84 elapsed
Szybciej:
time d8 prime.js
287107
1.82 user
0.01 system
0:01.84 elapsed
javascript
v8
Leonardo Physh
źródło
źródło
<=
i<
jest identyczna, zarówno w teorii, jak i w rzeczywistej implementacji we wszystkich nowoczesnych procesorach (i interpreterach).main
kod, który wywołuje tę funkcję w pętli, która działa25000
razy, więc ogólnie wykonujesz dużo mniej iteracji. Ponadto, jeśli tablica ma długość 5, próba uzyskaniaarray[5]
wyjdzie poza jego limit, podającundefined
wartość, ponieważ tablice zaczynają indeksować0
.<=
ale poza tym działa identycznie jak<
wersja, wykonująci <= this.prime_count - 1
. To rozwiązuje zarówno problem „dodatkowej iteracji”, jak i „jeden za końcem tablicy”.Odpowiedzi:
Pracuję nad V8 w Google i chciałem zapewnić dodatkowe informacje na temat istniejących odpowiedzi i komentarzy.
Dla porównania, oto przykład pełnego kodu ze slajdów :
Przede wszystkim różnica w wydajności nie ma bezpośredniego związku z operatorami
<
i<=
. Więc proszę, nie przeskakuj przez obręcze tylko po to, aby uniknąć<=
w kodzie, ponieważ przeczytałeś na Stack Overflow, że jest wolny - tak nie jest!Po drugie, ludzie zauważyli, że tablica jest „dziurawa”. Nie było to jasne z fragmentu kodu w poście OP, ale jest to jasne, gdy spojrzysz na kod, który się inicjalizuje
this.primes
:Prowadzi to do tablicy z ciągu
HOLEY
elementów rodzaj w V8, nawet jeśli tablica kończy się całkowicie wypełniona / zapakowane / przyległe. Ogólnie rzecz biorąc, operacje na tablicach dziurawych są wolniejsze niż operacje na tablicach upakowanych, ale w tym przypadku różnica jest znikoma: wynosi 1 dodatkową kontrolę Smi ( mała liczba całkowita ) (w celu ochrony przed dziurami) za każdym razem, gdy trafimythis.primes[i]
w pętlę wewnątrzisPrimeDivisible
. Nie ma sprawy!TL; DR Istnienie macierzy nie
HOLEY
jest tutaj problemem.Inni wskazywali, że kod odczytuje poza zakresem. Generalnie zaleca się unikanie czytania poza długością tablic iw tym przypadku rzeczywiście pozwoliłoby to uniknąć ogromnego spadku wydajności. Ale dlaczego? Wersja 8 może obsłużyć niektóre z tych scenariuszy poza zakresem przy niewielkim wpływie na wydajność. Co zatem jest takiego specjalnego w tym konkretnym przypadku?
Odczyt poza zakresem powoduje,
this.primes[i]
że znajdujesz sięundefined
w tej linii:I to prowadzi nas do prawdziwego problemu :
%
operator jest teraz używany z operandami niecałkowitymi!integer % someOtherInteger
można obliczyć bardzo wydajnie; Silniki JavaScript mogą w tym przypadku generować wysoce zoptymalizowany kod maszynowy.integer % undefined
z drugiej strony jest o wiele mniej wydajneFloat64Mod
, ponieważundefined
jest reprezentowane jako podwójne.Fragment kodu można rzeczywiście ulepszyć, zmieniając na
<=
w<
tym wierszu:... nie dlatego, że
<=
jest jakimś lepszym operatorem niż<
, ale tylko dlatego, że pozwala to uniknąć odczytywania poza zakresem w tym konkretnym przypadku.źródło
Inne odpowiedzi i komentarze wspominają, że różnica między dwiema pętlami polega na tym, że pierwsza wykonuje o jedną iterację więcej niż druga. To prawda, ale w tablicy, która rozrasta się do 25 000 elementów, jedna iteracja mniej więcej spowodowałaby niewielką różnicę. Przypuszczam, że jeśli przyjmiemy, że średnia długość rośnie 12 500, wówczas różnica, której możemy się spodziewać, powinna wynosić około 1/12 500, czyli tylko 0,008%.
Różnica w wydajności jest tutaj znacznie większa, niż można by to wytłumaczyć jedną dodatkową iteracją, a problem został wyjaśniony pod koniec prezentacji.
this.primes
jest ciągłą tablicą (każdy element ma wartość), a wszystkie elementy są liczbami.Silnik JavaScript może zoptymalizować taką tablicę, aby była prostą tablicą rzeczywistych liczb, zamiast tablicy obiektów, które tak się składa, że zawierają liczby, ale mogą zawierać inne wartości lub nie zawierać żadnej wartości. Pierwszy format jest znacznie szybszy w dostępie: zajmuje mniej kodu, a tablica jest znacznie mniejsza, więc będzie lepiej pasować do pamięci podręcznej. Ale są pewne warunki, które mogą uniemożliwić użycie tego zoptymalizowanego formatu.
Jednym z warunków byłby brak niektórych elementów tablicy. Na przykład:
Jaka jest teraz wartość
a[1]
? Nie ma wartości . (Nie jest nawet poprawne stwierdzenie, że ma wartośćundefined
- element tablicy zawierającyundefined
wartość różni się od elementu tablicy, którego całkowicie brakuje).Nie ma sposobu, aby przedstawić to za pomocą samych liczb, więc silnik JavaScript jest zmuszony do używania mniej zoptymalizowanego formatu. Gdyby
a[1]
zawierała wartość liczbową, podobnie jak pozostałe dwa elementy, tablica mogłaby zostać zoptymalizowana do postaci tablicy tylko liczb.Innym powodem, dla którego tablica ma zostać wymuszona w niezoptymalizowanym formacie, może być próba uzyskania dostępu do elementu poza granicami tablicy, jak omówiono w prezentacji.
Pierwsza pętla z
<=
próbami odczytania elementu znajdującego się poza końcem tablicy. Algorytm nadal działa poprawnie, ponieważ w ostatniej dodatkowej iteracji:this.primes[i]
zwraca się,undefined
ponieważi
znajduje się poza końcem tablicy.candidate % undefined
(dla dowolnej wartościcandidate
) przyjmuje wartośćNaN
.NaN == 0
ocenia dofalse
.return true
nie jest wykonywany.Więc to tak, jakby dodatkowa iteracja nigdy się nie wydarzyła - nie ma wpływu na resztę logiki. Kod daje ten sam wynik, co bez dodatkowej iteracji.
Ale żeby się tam dostać, próbował odczytać nieistniejący element poza końcem tablicy. To zmusza tablicę do wycofania się z optymalizacji - a przynajmniej tak było w czasie tej rozmowy.
Druga pętla
<
odczytuje tylko elementy, które istnieją w tablicy, więc umożliwia zoptymalizowanie tablicy i kodu.Problem jest opisany na stronach 90-91 wykładu, z powiązaną dyskusją na stronach przed i po nim.
Zdarzyło mi się uczestniczyć w tej prezentacji Google I / O i później rozmawiałem z mówcą (jednym z autorów V8). W swoim własnym kodzie używałem techniki, która polegała na czytaniu poza końcem tablicy jako błędnej (z perspektywy czasu) próbie optymalizacji jednej konkretnej sytuacji. Potwierdził, że jeśli spróbujesz odczytać nawet poza końcem tablicy, uniemożliwi to użycie prostego zoptymalizowanego formatu.
Jeśli to, co powiedział autor V8, nadal jest prawdą, odczytanie poza koniec tablicy uniemożliwiłoby jej optymalizację i musiałoby wrócić do wolniejszego formatu.
Możliwe, że w międzyczasie V8 został ulepszony, aby efektywnie obsłużyć ten przypadek, lub że inne silniki JavaScript obsługują to inaczej. Nie wiem, czy tak czy inaczej, ale o tej deoptimizacji mówiła prezentacja.
źródło
undefined
zamiast liczby, co prowadzi do innego obliczenia.Object
), ponieważ musi obsługiwać dowolną mieszankę typów danych w tablicy. Jak wspomniałem powyżej, kod w podawanej pętliundefined
nie wpływa na poprawność algorytmu - w ogóle nie zmienia obliczeń (to tak, jakby dodatkowa iteracja nigdy się nie wydarzyła).Value
obiektów, która może zawierać odniesienia do wartości dowolnego typu. (Wymyśliłem nazwęValue
, ale chodzi o to, że elementy tablicy to nie tylko proste liczby, ale są to obiekty, które zawijają liczby lub inne typy.)HOLEY
ponieważ została utworzona przy użyciunew Array(n)
(chociaż ta część kodu nie była widoczna w OP).HOLEY
tablice pozostają naHOLEY
zawsze w wersji 8 , nawet jeśli zostaną później wypełnione. To powiedziawszy, dziurawość tablicy nie jest przyczyną problemu z perfekcją w tym przypadku; oznacza to po prostu, że musimy wykonać dodatkowe sprawdzenie Smi w każdej iteracji (w celu ochrony przed dziurami), co nie jest wielkim problemem.TL; DR Wolniejsza pętla wynika z dostępu do tablicy `` poza granicami '', co zmusza silnik do ponownej kompilacji funkcji z mniejszą lub nawet żadną optymalizacją LUB nie kompiluje funkcji z żadną z tych optymalizacji na początku ( jeśli (JIT-) Compiler wykrył / podejrzewał ten stan przed pierwszą wersją kompilacji), przeczytaj poniżej dlaczego;
Ktoś musi tylko powiedzieć (całkowicie zdumiony, że nikt jeszcze tego nie zrobił):
Kiedyś fragment kodu OP był de facto przykładem w książce programistycznej dla początkujących, mającej na celu zarysowanie / podkreślenie, że `` tablice '' w javascript są indeksowane począwszy od na 0, a nie 1, i jako taki może być użyty jako przykład typowego „błędu początkującego” (czy nie podoba ci się sposób, w jaki uniknąłem wyrażenia „błąd programowania”
;)
): dostęp do tablicy poza granicami .Przykład 1:
a
Dense Array
(ciągły (oznacza brak przerw między indeksami) ORAZ w rzeczywistości element w każdym indeksie) z 5 elementów przy użyciu indeksowania w oparciu o 0 (zawsze w ES262).Dlatego tak naprawdę nie mówimy o różnicy w wydajności między
<
vs<=
(lub „jedną dodatkową iteracją”), ale mówimy:„dlaczego poprawny fragment (b) działa szybciej niż błędny fragment (a)”?
Odpowiedź jest podwójna (chociaż z perspektywy implementującego język ES262 obie są formami optymalizacji):
Punkt 1 jest wystarczająco (i poprawnie IMHO) wyjaśniony przyjętą odpowiedzią , ale w pozycji 2: kompilacja zajmuje tylko 2 słowa („kod”) .
Dokładniej: JIT-Compilation i co ważniejsze JIT- RE -Compilation!
Specyfikacja języka to po prostu opis zestawu algorytmów („czynności, jakie należy wykonać, aby osiągnąć określony wynik końcowy”). Co, jak się okazuje, jest bardzo pięknym sposobem opisania języka. I pozostawia rzeczywistą metodę, której silnik używa do osiągnięcia określonych wyników, otwartą dla wdrażających, dając szerokie możliwości wymyślenia bardziej wydajnych sposobów uzyskania zdefiniowanych wyników. Silnik zgodny ze specyfikacją powinien dawać wyniki zgodne ze specyfikacją dla dowolnego zdefiniowanego wejścia.
Teraz, gdy kod / biblioteki / użycie javascript rośnie i zapamiętuje, ile zasobów (czasu / pamięci / itp.) Wykorzystuje `` prawdziwy '' kompilator, jasne jest, że nie możemy zmusić użytkowników odwiedzających stronę internetową do czekania tak długo (i wymagają ich mieć tyle dostępnych zasobów).
Wyobraź sobie następującą prostą funkcję:
Całkowicie jasne, prawda? Nie wymaga ŻADNEGO dodatkowego wyjaśnienia, prawda? Typ zwrotny to
Number
, prawda?Cóż ... nie, nie i nie ... To zależy od tego, jaki argument przekażesz do nazwanego parametru funkcji
arr
...Widzisz problem? W takim razie zastanów się, że to tylko ledwie skrobanie ogromnych możliwych permutacji ... Nie wiemy nawet, jakiego rodzaju TYPE funkcja RETURN jest dopiero, gdy skończymy ...
Teraz wyobraź sobie ten sam kod funkcji, który jest faktycznie używany na różnych typach lub nawet odmianach danych wejściowych, zarówno całkowicie dosłownie (w kodzie źródłowym) opisanym, jak i dynamicznie generowanych w programie „tablicach”.
Tak więc, jeśli miałbyś skompilować funkcję
sum
TYLKO RAZ, to jedyny sposób, który zawsze zwraca wynik zdefiniowany przez specyfikację dla dowolnego i wszystkich typów danych wejściowych, to oczywiście tylko przez wykonanie WSZYSTKICH określonych przez specyfikację głównych kroków I podrzędnych może zagwarantować wyniki zgodne ze specyfikacją (jak nienazwana przeglądarka w wersji starszej niż Y2K). Brak optymalizacji (ponieważ nie ma założeń) i martwy, wolno interpretowany język skryptowy.Kompilacja JIT (JIT jak w Just In Time) jest obecnie popularnym rozwiązaniem.
Tak więc zaczynasz kompilować funkcję, korzystając z założeń dotyczących tego, co robi, zwraca i akceptuje.
wymyślasz testy tak proste, jak to tylko możliwe, aby wykryć, czy funkcja może zacząć zwracać wyniki niezgodne ze specyfikacją (np. ponieważ otrzymuje nieoczekiwane dane wejściowe). Następnie odrzuć poprzednio skompilowany wynik i ponownie skompiluj do czegoś bardziej złożonego, zdecyduj, co zrobić z wynikiem częściowym, który już masz (czy można zaufać, czy ponownie obliczyć, aby mieć pewność), przywiąż funkcję z powrotem do programu i Spróbuj ponownie. Ostatecznie powrót do krokowej interpretacji skryptu, jak w specyfikacji.
Wszystko to wymaga czasu!
Wszystkie przeglądarki działają na swoich silnikach, dla każdej podwersji zobaczysz poprawę i regres. Łańcuchy były w pewnym momencie w historii naprawdę niezmiennymi łańcuchami (stąd array.join był szybszy niż konkatenacja ciągów), teraz używamy lin (lub podobnych), które rozwiązują problem. Oba zwracają wyniki zgodne ze specyfikacją i to się liczy!
Krótko mówiąc: tylko dlatego, że semantyka języka javascript często nam pomagała (jak w przypadku tego cichego błędu w przykładzie OP), nie oznacza, że „głupie” błędy zwiększają nasze szanse na wyplucie przez kompilator szybkiego kodu maszynowego. Zakłada się, że napisaliśmy `` zwykle '' poprawne instrukcje: aktualna mantra, którą my `` użytkownicy '' (języka programowania) musimy mieć: pomóc kompilatorowi, opisać, czego chcemy, faworyzować popularne idiomy (weź wskazówki z asm.js dla podstawowego zrozumienia jakie przeglądarki mogą próbować optymalizować i dlaczego).
Z tego powodu mówienie o osiągach jest zarówno ważne, ALE TAKŻE pole minowe (iz powodu tego pola minowego naprawdę chcę zakończyć wskazaniem (i zacytowaniem) jakiegoś istotnego materiału:
Źródło:
„JITProf: Pinpointing JIT-unfriendly JavaScript Code”
publikacja Berkeley, 2014, autorstwa Liang Gong, Michael Pradel, Koushik Sen.
http://software-lab.org/publications/jitprof_tr_aug3_2014.pdf
ASM.JS (również nie lubi dostępu do tablicy poza granicami):
http://asmjs.org/spec/latest/
i wreszcie https://blogs.windows.com/msedgedev/2015/05/07/bringing-asm-js-to-chakra-microsoft-edge/
gdzie jest mała podsekcja na temat wewnętrznych ulepszeń wydajności silnika podczas usuwania ograniczeń- check (po prostu podnosząc granicę - check poza pętlą miał już poprawę o 40%).
EDYCJA:
zauważ, że wiele źródeł mówi o różnych poziomach rekompilacji JIT, aż do interpretacji.
Teoretyczny przykład oparty na powyższych informacjach, dotyczący fragmentu PO:
Stąd czas był następujący:
pierwsze uruchomienie (zakończone niepowodzeniem) + wykonywanie całej pracy od nowa przy użyciu wolniejszego kodu maszynowego dla każdej iteracji + rekompilacja itp. Wyraźnie trwa> 2 razy dłużej w tym teoretycznym przykładzie !
EDYCJA 2: (zrzeczenie się: przypuszczenie oparte na faktach poniżej)
Im więcej o tym myślę, tym bardziej myślę, że ta odpowiedź może faktycznie wyjaśniać bardziej dominujący powód tej `` kary '' za błędny fragment a (lub premię za wydajność za fragment b , w zależności od tego, jak o tym myślisz), właśnie dlaczego uważam, że to (fragment a) błąd programistyczny:
Dość kuszące jest założenie, że
this.primes
jest to „gęsta tablica” czysto numeryczna, która też byłanew Array(/*size value*/)
) w rosnącej kolejności sekwencyjnej (kolejny znany od dawna kandydat na tablicę „prawdziwą”).Wiemy również, że
primes
długość tablicy jest buforowana jakoprime_count
! (wskazując na zamiar i stały rozmiar).Wiemy również, że większość silników początkowo przekazuje tablice jako kopiowanie przy modyfikacji (w razie potrzeby), co znacznie przyspiesza ich obsługę (jeśli ich nie zmienisz).
Dlatego rozsądnie jest założyć, że Array
primes
najprawdopodobniej jest już wewnętrznie zoptymalizowaną tablicą, która nie zmienia się po utworzeniu (łatwe do rozpoznania dla kompilatora, jeśli nie ma kodu modyfikującego tablicę po utworzeniu) i dlatego już jest (jeśli ma zastosowanie do silnik) przechowywany w zoptymalizowany sposób, prawie tak, jakby był plikiemTyped Array
.Jak próbowałem wyjaśnić w moim
sum
przykładzie funkcji, argument (y), które są przekazywane, mają duży wpływ na to, co faktycznie musi się wydarzyć i jako takie, jak ten konkretny kod jest kompilowany do kodu maszynowego. PrzekazanieString
dosum
funkcji nie powinno zmieniać ciągu znaków, ale zmieniać sposób kompilacji funkcji w JIT! Przekazanie tablicy dosum
powinno skompilować inną (być może nawet dodatkową dla tego typu lub „kształt”, jak to nazywają, przekazanego obiektu) wersję kodu maszynowego.Ponieważ wydaje się nieco bonkus, aby przekonwertować tablicę typu
primes
Array podobną do Typed_Array w locie na coś_else, podczas gdy kompilator wie, że ta funkcja nawet jej nie zmodyfikuje!Przy tych założeniach pozostawia 2 opcje:
Teraz naprawdę zastanawiam się, który z tych 2 to jest!
źródło
Aby dodać do tego trochę naukowego charakteru, oto plik jsperf
https://jsperf.com/ints-values-in-out-of-array-bounds
Testuje przypadek kontrolny tablicy wypełnionej intami i zapętleniem wykonując arytmetykę modularną, pozostając w granicach. Posiada 5 przypadków testowych:
new Array()
Pokazuje, że pierwsze 4 przypadki są naprawdę złe dla wydajności. Pętla poza granicami jest nieco lepsza niż pozostałe 3, ale wszystkie 4 są o około 98% wolniejsze niż w najlepszym przypadku.
Obudowa
new Array()
jest prawie tak dobra jak tablica surowa, tylko kilka procent wolniejsza.źródło