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).
var arr_five_char=['a', 'b', 'c', 'd', 'e']; // arr_five_char.length === 5
// indexes are: 0 , 1 , 2 , 3 , 4 // there is NO index number 5
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):
- Reprezentacja danych: jak reprezentować / przechowywać tablicę wewnętrznie w pamięci (obiekt, hashmap, `` rzeczywista '' tablica numeryczna itp.)
- Funkcjonalny kod maszynowy: jak skompilować kod, który uzyskuje dostęp / obsługuje (odczytuje / modyfikuje) te „tablice”
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ę:
function sum(arr){
var r=0, i=0;
for(;i<arr.length;) r+=arr[i++];
return r;
}
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
...
sum('abcde'); // String('0abcde')
sum([1,2,3]); // Number(6)
sum([1,,3]); // Number(NaN)
sum(['1',,3]); // String('01undefined3')
sum([1,,'3']); // String('NaN3')
sum([1,2,{valueOf:function(){return this.val}, val:6}]); // Number(9)
var val=5; sum([1,2,{valueOf:function(){return val}}]); // Number(8)
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:
Dostęp do nieistniejących właściwości obiektu i elementów tablicy spoza granic zwraca undefined
wartość zamiast zgłaszania wyjątku. Te dynamiczne funkcje sprawiają, że programowanie w JavaScript jest wygodne, ale utrudniają też kompilację JavaScript w wydajny kod maszynowy.
...
Ważną przesłanką skutecznej optymalizacji JIT jest to, że programiści używają dynamicznych funkcji JavaScript w sposób systematyczny. Na przykład kompilatory JIT wykorzystują fakt, że właściwości obiektu są często dodawane do obiektu danego typu w określonej kolejności lub że dostęp do tablicy poza granicami występuje rzadko. Kompilatory JIT wykorzystują te założenia dotyczące regularności do generowania wydajnego kodu maszynowego w czasie wykonywania. Jeśli blok kodu spełnia założenia, silnik JavaScript wykonuje wydajny, wygenerowany kod maszynowy. W przeciwnym razie silnik musi wrócić do wolniejszego kodu lub zinterpretować program.
Ź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):
Kompilacja z wyprzedzeniem
Ponieważ asm.js jest ścisłym podzbiorem języka JavaScript, ta specyfikacja definiuje tylko logikę walidacji - semantyka wykonywania jest po prostu semantyka JavaScript. Jednak zweryfikowany plik asm.js jest podatny na kompilację z wyprzedzeniem (AOT). Co więcej, kod generowany przez kompilator AOT może być dość wydajny, oferując:
- reprezentacje liczb całkowitych i liczb zmiennoprzecinkowych bez ramki;
- brak kontroli typu w czasie wykonywania;
- brak zbierania śmieci; i
- wydajne ładowanie sterty i składowanie (ze strategiami wdrażania różniącymi się w zależności od platformy).
Kod, który nie przejdzie walidacji, musi wrócić do wykonania tradycyjnymi metodami, np. Interpretacją i / lub kompilacją just-in-time (JIT).
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:
- Wywołanie isPrimeDivisible
- Skompiluj isPrimeDivisible przy użyciu ogólnych założeń (np. Brak dostępu poza granicami)
- Wykonać pracę
- BAM, nagle tablica uzyskuje dostęp poza granicami (na samym końcu).
- Bzdura, mówi silnik, skompilujmy ponownie, że jest PrimeDivisible przy użyciu innych (mniej) założeń, a ten przykładowy silnik nie próbuje dowiedzieć się, czy może ponownie wykorzystać bieżący wynik częściowy, więc
- Przelicz ponownie całą pracę używając wolniejszej funkcji (miejmy nadzieję, że się zakończy, w przeciwnym razie powtórz i tym razem po prostu zinterpretuj kod).
- Wynik zwrotu
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ła
- Zakodowane w dosłowny kodu źródłowego (znany excelent kandydatem do stania się „prawdziwe” array jak wszystko jest już znany kompilator przed kompilacją) LUB
- najprawdopodobniej wygenerowane przy użyciu funkcji numerycznej wypełniającej pre-size (
new 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 jako prime_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ł plikiem Typed 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. Przekazanie String
do sum
funkcji nie powinno zmieniać ciągu znaków, ale zmieniać sposób kompilacji funkcji w JIT! Przekazanie tablicy do sum
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:
- Kompiluj jako analizator liczb, zakładając, że nie ma poza zakresem, napotkaj problem z poza granicami na końcu, przekompiluj i ponów pracę (jak opisano w przykładzie teoretycznym w edycji 1 powyżej)
- Kompilator już wykrył (lub podejrzewał?) Dostęp z góry poza granicami, a funkcja została skompilowana w JIT tak, jakby przekazany argument był rzadkim obiektem, co skutkowało wolniejszym funkcjonalnym kodem maszynowym (ponieważ miałoby więcej sprawdzeń / konwersji / wymuszeń itp.). Innymi słowy: funkcja nigdy nie kwalifikowała się do pewnych optymalizacji, została skompilowana tak, jakby otrzymywała argument „rzadka tablica” (- jak).
Teraz naprawdę zastanawiam się, który z tych 2 to jest!
<=
i<
jest identyczna, zarówno w teorii, jak i w rzeczywistej implementacji we wszystkich nowoczesnych procesorach (i interpreterach).