TL; DR Il loop più lento è dovuto all'accesso ai "fuori campo" dell'array, che costringe il motore a ricompilare la funzione con ottimizzazioni minori o addirittura assenti O a non compilare la funzione con una di queste ottimizzazioni per iniziare ( se il compilatore (JIT-) ha rilevato / sospettato questa condizione prima della prima 'versione' della compilation), continua a leggere perché;
Qualcuno
deve solo dirlo (assolutamente stupito nessuno lo ha già fatto):
c'era un tempo in cui lo snippet dell'OP sarebbe stato di fatto un esempio in un libro di programmazione per principianti destinato a delineare / enfatizzare che gli 'array' in javascript sono indicizzati a partire da a 0, non 1, e come tale essere usato come esempio di un "errore per principianti" comune (non ti piace come ho evitato la frase "errore di programmazione"
;)
):
accesso all'array fuori limite .
Esempio 1:
a Dense Array
(essendo contigui (significa senza vuoti tra gli indici) E in realtà un elemento in ciascun indice) di 5 elementi usando l'indicizzazione basata su 0 (sempre in 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
Quindi non stiamo davvero parlando della differenza di prestazioni tra <
vs <=
(o "un'iterazione extra"), ma stiamo parlando:
"perché lo snippet corretto (b) viene eseguito più velocemente dello snippet errato (a)"?
La risposta è duplice (anche se dal punto di vista dell'implementazione del linguaggio ES262 entrambe sono forme di ottimizzazione):
- Rappresentazione dei dati: come rappresentare / archiviare l'array internamente in memoria (oggetto, hashmap, array numerico "reale", ecc.)
- Codice macchina funzionale: come compilare il codice che accede / gestisce (legge / modifica) questi "array"
L'articolo 1 è sufficientemente (e correttamente IMHO) spiegato dalla risposta accettata , ma ciò spende solo 2 parole ("il codice") nell'articolo 2: compilazione .
Più precisamente: JIT-Compilation e ancor più importante JIT- RE- Compilation!
Le specifiche del linguaggio sono fondamentalmente solo una descrizione di una serie di algoritmi ("passaggi da eseguire per ottenere un risultato finale definito"). Che, a quanto pare, è un modo molto bello per descrivere una lingua. E lascia il metodo effettivo che un motore utilizza per ottenere risultati specifici aperto agli implementatori, offrendo ampie opportunità di trovare modi più efficienti per produrre risultati definiti. Un motore conforme alle specifiche dovrebbe fornire risultati conformi alle specifiche per qualsiasi input definito.
Ora, con il codice javascript / librerie / utilizzo in aumento e ricordando quante risorse (tempo / memoria / ecc.) Utilizza un compilatore "reale", è chiaro che non possiamo far aspettare così tanto gli utenti che visitano una pagina Web (e li richiedono avere tante risorse disponibili).
Immagina la seguente semplice funzione:
function sum(arr){
var r=0, i=0;
for(;i<arr.length;) r+=arr[i++];
return r;
}
Perfettamente chiaro, vero? Non richiede NESSUN chiarimento aggiuntivo, giusto? Il tipo di ritorno è Number
, giusto?
Bene .. no, no & no ... Dipende dall'argomento che passi al parametro della funzione denominata 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)
Vedi il problema? Quindi considera che questo sta raschiando a malapena le enormi possibili permutazioni ... Non sappiamo nemmeno che tipo di TIPO la funzione TORNA finché non abbiamo finito ...
Ora immagina che questo stesso codice funzione sia effettivamente utilizzato su diversi tipi o persino variazioni di input, entrambi descritti letteralmente (nel codice sorgente) e generati dinamicamente "array" nel programma.
Pertanto, se si dovesse compilare la funzione sum
SOLO UNA VOLTA, l'unico modo che restituisce sempre il risultato definito dalle specifiche per qualsiasi e tutti i tipi di input, ovviamente, solo eseguendo TUTTI i passaggi E AND secondari prescritti dalle specifiche è possibile garantire risultati conformi alle specifiche (come un browser pre-y2k senza nome). Non ci sono ottimizzazioni (perché nessuna ipotesi) e il linguaggio di script interpretazione lenta è morto.
JIT-Compilation (JIT come in Just In Time) è l'attuale soluzione popolare.
Quindi, inizi a compilare la funzione usando i presupposti su ciò che fa, restituisce e accetta.
viene fornito un controllo il più semplice possibile per rilevare se la funzione potrebbe iniziare a restituire risultati non conformi alle specifiche (ad esempio perché riceve input imprevisti). Quindi, getta via il risultato compilato precedente e ricompila in qualcosa di più elaborato, decidi cosa fare con il risultato parziale che hai già (è valido fidarti o calcola di nuovo per essere sicuro), ricollega la funzione al programma e riprova. Alla fine, ricadendo sull'interpretazione graduale dello script come nelle specifiche.
Tutto ciò richiede tempo!
Tutti i browser funzionano sui loro motori, per ogni sub-versione vedrai che le cose miglioreranno e regrediranno. Le stringhe erano ad un certo punto della storia stringhe davvero immutabili (quindi array.join era più veloce della concatenazione di stringhe), ora usiamo le corde (o simili) che alleviano il problema. Entrambi restituiscono risultati conformi alle specifiche ed è quello che conta!
Per farla breve: solo perché la semantica del linguaggio javascript spesso ci ha restituito le spalle (come con questo bug silenzioso nell'esempio del PO) non significa che errori "stupidi" aumentino le nostre possibilità che il compilatore sputi codice macchina veloce. Presuppone che abbiamo scritto le istruzioni "di solito" corrette: l'attuale mantra che noi "utenti" (del linguaggio di programmazione) deve avere è: aiutare il compilatore, descrivere ciò che vogliamo, favorire idiomi comuni (prendere suggerimenti da asm.js per la comprensione di base quali browser possono provare a ottimizzare e perché).
Per questo motivo , parlare delle prestazioni è importante, MA ANCHE un campo minato (e a causa di tale campo minato voglio davvero finire con il puntare (e citare) del materiale pertinente:
L'accesso alle proprietà degli oggetti inesistenti e agli elementi dell'array fuori dai limiti restituisce il undefined
valore invece di sollevare un'eccezione. Queste caratteristiche dinamiche rendono conveniente la programmazione in JavaScript, ma rendono anche difficile la compilazione di JavaScript in un codice macchina efficiente.
...
Una premessa importante per un'ottimizzazione JIT efficace è che i programmatori utilizzano le funzionalità dinamiche di JavaScript in modo sistematico. Ad esempio, i compilatori JIT sfruttano il fatto che le proprietà degli oggetti vengono spesso aggiunte a un oggetto di un determinato tipo in un ordine specifico o che raramente si verificano accessi di array fuori limite. I compilatori JIT sfruttano questi presupposti di regolarità per generare un codice macchina efficiente in fase di esecuzione. Se un blocco di codice soddisfa i presupposti, il motore JavaScript esegue un codice macchina generato e efficiente. Altrimenti, il motore deve tornare al codice più lento o all'interpretazione del programma.
Fonte:
"JITProf: Individuazione del codice JavaScript non compatibile con JIT"
Pubblicazione Berkeley, 2014, di Liang Gong, Michael Pradel, Koushik Sen.
http://software-lab.org/publications/jitprof_tr_aug3_2014.pdf
ASM.JS (inoltre non piace l'accesso fuori array fuori limite):
Compilazione anticipata
Poiché asm.js è un sottoinsieme rigoroso di JavaScript, questa specifica definisce solo la logica di convalida: la semantica dell'esecuzione è semplicemente quella di JavaScript. Tuttavia, asm.js convalidato è suscettibile di compilazione anticipata (AOT). Inoltre, il codice generato da un compilatore AOT può essere abbastanza efficiente, caratterizzato da:
- rappresentazioni unboxed di numeri interi e numeri in virgola mobile;
- assenza di controlli del tipo di runtime;
- assenza di raccolta rifiuti; e
- carichi e negozi di heap efficienti (con strategie di implementazione che variano in base alla piattaforma).
Il codice che non riesce a convalidare deve tornare all'esecuzione con mezzi tradizionali, ad es. Interpretazione e / o compilazione just-in-time (JIT).
http://asmjs.org/spec/latest/
e infine https://blogs.windows.com/msedgedev/2015/05/07/bringing-asm-js-to-chakra-microsoft-edge/
dove c'è una piccola sottosezione sui miglioramenti delle prestazioni interne del motore quando si rimuovono i limiti- check (pur alzando il limite-check al di fuori del loop aveva già un miglioramento del 40%).
EDIT:
nota che più fonti parlano di diversi livelli di JIT-Compompilation fino all'interpretazione.
Esempio teorico basato sulle informazioni di cui sopra, relativamente allo snippet del PO:
- Chiama per isPrimeDivisible
- Compilare isPrimeDivisible utilizzando ipotesi generali (come nessun accesso fuori limite)
- Lavora
- BAM, improvvisamente la matrice accede fuori dai limiti (proprio alla fine).
- Merda, dice engine, ricompiliamo isPrimeDivisible usando diversi (meno) presupposti, e questo motore di esempio non cerca di capire se può riutilizzare il risultato parziale corrente, quindi
- Ricalcola tutto il lavoro usando la funzione più lenta (si spera che finisca, altrimenti ripeti e questa volta basta interpretare il codice).
- Risultato di ritorno
Quindi il tempo era:
prima esecuzione (fallito alla fine) + facendo tutto di nuovo tutto da capo usando un codice macchina più lento per ogni iterazione + la ricompilazione ecc. Chiaramente impiega> 2 volte di più in questo esempio teorico !
EDIT 2: (disclaimer: congettura basata sui fatti di seguito)
Più ci penso, più penso che questa risposta possa effettivamente spiegare la ragione più dominante di questa "penalità" su frammenti errati a (o bonus di prestazioni su frammenti b , a seconda di come lo pensi), proprio perché sono adorato nel chiamarlo (frammento a) un errore di programmazione:
È abbastanza allettante supporre che si this.primes
tratti di un puro numerico "denso array"
- Letterale hardcoded nel codice sorgente (noto candidato eccellente per diventare un array "reale" poiché tutto è già noto al compilatore prima del tempo di compilazione) OPPURE
- molto probabilmente generato usando una funzione numerica che riempie un pre-dimensionato (
new Array(/*size value*/)
) in ordine sequenziale crescente (un altro candidato noto da molto tempo a diventare un array 'reale').
Sappiamo anche che la primes
lunghezza dell'array è memorizzata nella cache come prime_count
! (indicando l'intento e la dimensione fissa).
Sappiamo anche che la maggior parte dei motori inizialmente passa gli array come copia su modifica (quando necessario), il che rende la loro gestione molto più veloce (se non li cambi).
È quindi ragionevole presumere che l'array primes
sia molto probabilmente già un array ottimizzato internamente che non viene modificato dopo la creazione (semplice da sapere per il compilatore se non esiste un codice che modifica l'array dopo la creazione) e quindi è già (se applicabile a il motore) memorizzato in modo ottimizzato, praticamente come se fosse un Typed Array
.
Come ho cercato di chiarire con il mio sum
esempio di funzione, gli argomenti che vengono superati influenzano fortemente ciò che deve realmente accadere e come tale viene compilato quel particolare codice in codice macchina. Passare String
a alla sum
funzione non dovrebbe cambiare la stringa ma cambiare il modo in cui la funzione è compilata da JIT! Passare un array a sum
dovrebbe compilare una versione diversa (forse anche aggiuntiva per questo tipo, o 'forma' come lo chiamano, dell'oggetto che è stato passato) di codice macchina.
Come sembra leggermente bonkus convertire l'array di tipo Typed_Array primes
al volo in qualcosa_else mentre il compilatore sa che questa funzione non lo modificherà nemmeno!
Sotto questi presupposti che lascia 2 opzioni:
- Compilare come numero-cruncher assumendo che non ci siano limiti al di fuori dei limiti, incorrere in un problema al di fuori dei limiti alla fine, ricompilare e ripetere il lavoro (come indicato nell'esempio teorico nella modifica 1 sopra)
- Il compilatore ha già rilevato (o sospettato?) Un accesso non previsto in anticipo e la funzione era compilata JIT come se l'argomento passato fosse un oggetto rado con conseguente rallentamento del codice macchina funzionale (poiché avrebbe più controlli / conversioni / coercizioni eccetera.). In altre parole: la funzione non è mai stata ammissibile per determinate ottimizzazioni, è stata compilata come se ricevesse un argomento "sparse array" (- like).
Ora mi chiedo davvero quale di questi 2 sia!
<=
ed<
è identica, sia in teoria che nell'attuazione effettiva in tutti i moderni processori (e interpreti).