La domanda originale
Perché un loop è molto più lento di due loop?
Conclusione:
Caso 1 è un classico problema di interpolazione che sembra essere inefficiente. Penso anche che questo sia stato uno dei motivi principali per cui molte architetture e sviluppatori di macchine hanno finito per costruire e progettare sistemi multi-core con la capacità di fare applicazioni multi-thread e programmazione parallela.
Osservandolo da questo tipo di approccio senza coinvolgere il modo in cui Hardware, SO e Compiler (s) lavorano insieme per fare allocazioni di heap che implicano lavorare con RAM, Cache, File di Pagina, ecc .; la matematica che sta alla base di questi algoritmi ci mostra quale di questi due è la soluzione migliore.
Possiamo usare un'analogia di un Boss
essere Summation
che rappresenterà un For Loop
che deve viaggiare tra lavoratori A
e B
.
Possiamo facilmente vedere che il caso 2 è almeno la metà più veloce se non un po 'più del caso 1 a causa della differenza nella distanza necessaria per viaggiare e del tempo impiegato tra i lavoratori. Questa matematica si allinea quasi virtualmente e perfettamente sia con il BenchMark Times sia con il numero di differenze nelle Istruzioni di assemblaggio.
Comincerò ora a spiegare come funziona tutto questo di seguito.
Valutare il problema
Il codice del PO:
const int n=100000;
for(int j=0;j<n;j++){
a1[j] += b1[j];
c1[j] += d1[j];
}
E
for(int j=0;j<n;j++){
a1[j] += b1[j];
}
for(int j=0;j<n;j++){
c1[j] += d1[j];
}
La considerazione
Considerando la domanda originale del PO circa le 2 varianti del ciclo for e la sua domanda modificata sul comportamento delle cache insieme a molte altre eccellenti risposte e commenti utili; Mi piacerebbe provare a fare qualcosa di diverso qui adottando un approccio diverso su questa situazione e problema.
L'approccio
Considerando i due loop e tutte le discussioni sulla cache e l'archiviazione di pagine, vorrei adottare un altro approccio per guardarlo da una prospettiva diversa. Uno che non coinvolge i file di cache e di pagina né le esecuzioni per allocare memoria, infatti, questo approccio non riguarda nemmeno l'hardware o il software.
La prospettiva
Dopo aver esaminato il codice per un po ', è diventato evidente quale sia il problema e cosa lo stia generando. Dividiamolo in un problema algoritmico e guardiamolo dalla prospettiva dell'uso delle notazioni matematiche, quindi applichiamo un'analogia ai problemi matematici e agli algoritmi.
Cosa sappiamo
Sappiamo che questo ciclo verrà eseguito 100.000 volte. Sappiamo anche che a1
, b1
, c1
ed1
sono indicazioni su un'architettura a 64-bit. All'interno di C ++ su una macchina a 32 bit, tutti i puntatori sono 4 byte e su una macchina a 64 bit, hanno una dimensione di 8 byte poiché i puntatori hanno una lunghezza fissa.
Sappiamo che abbiamo 32 byte in cui allocare in entrambi i casi. L'unica differenza è che stiamo allocando 32 byte o 2 set di 2-8 byte su ogni iterazione in cui il 2o caso stiamo allocando 16 byte per ogni iterazione per entrambi i loop indipendenti.
Entrambi i loop equivalgono ancora a 32 byte nelle allocazioni totali. Con queste informazioni ora andiamo avanti e mostriamo la matematica generale, gli algoritmi e l'analogia di questi concetti.
Conosciamo il numero di volte in cui lo stesso insieme o gruppo di operazioni dovrà essere eseguito in entrambi i casi. Conosciamo la quantità di memoria che deve essere allocata in entrambi i casi. Possiamo valutare che il carico di lavoro complessivo delle allocazioni tra i due casi sarà approssimativamente lo stesso.
Quello che non sappiamo
Non sappiamo quanto tempo ci vorrà per ogni caso a meno che non impostiamo un contatore ed eseguiamo un test di riferimento. Tuttavia, i parametri di riferimento erano già stati inclusi nella domanda originale e anche in alcune delle risposte e dei commenti; e possiamo vedere una differenza significativa tra i due e questo è l'intero ragionamento per questa proposta a questo problema.
Investigiamo
È già evidente che molti l'hanno già fatto osservando le allocazioni di heap, i test di benchmark, esaminando RAM, cache e file di paging. Sono stati inclusi anche punti dati specifici e indici di iterazione specifici e le varie conversazioni su questo problema specifico hanno molte persone che iniziano a mettere in discussione altre cose correlate su di esso. Come possiamo iniziare a considerare questo problema usando algoritmi matematici e applicando un'analogia ad esso? Iniziamo facendo un paio di affermazioni! Quindi costruiamo il nostro algoritmo da lì.
Le nostre affermazioni:
- Lasceremo che il nostro ciclo e le sue iterazioni siano una somma che inizia a 1 e termina a 100000 invece di iniziare con 0 come nei loop perché non dobbiamo preoccuparci dello schema di indicizzazione 0 dell'indirizzamento della memoria poiché ci interessa solo l'algoritmo stesso.
- In entrambi i casi abbiamo 4 funzioni con cui lavorare e 2 chiamate di funzione con 2 operazioni da eseguire su ciascuna chiamata di funzione. Noi impostare questi in su come funzioni e le chiamate a funzioni come il seguente:
F1()
, F2()
, f(a)
, f(b)
, f(c)
e f(d)
.
Gli algoritmi:
1o caso: - Solo una somma ma due chiamate di funzione indipendenti.
Sum n=1 : [1,100000] = F1(), F2();
F1() = { f(a) = f(a) + f(b); }
F2() = { f(c) = f(c) + f(d); }
Secondo caso: - Due sommazioni ma ognuna ha una propria chiamata di funzione.
Sum1 n=1 : [1,100000] = F1();
F1() = { f(a) = f(a) + f(b); }
Sum2 n=1 : [1,100000] = F1();
F1() = { f(c) = f(c) + f(d); }
Se hai notato F2()
esiste solo Sum
da Case1
dove F1()
è contenuto Sum
da Case1
e in entrambi Sum1
e Sum2
da Case2
. Ciò sarà evidente in seguito quando inizieremo a concludere che esiste un'ottimizzazione nel secondo algoritmo.
Le iterazioni attraverso il primo caso Sum
chiamano f(a)
che si aggiungeranno a se stesse, f(b)
quindi chiamano f(c)
che faranno lo stesso ma si aggiungono f(d)
a sé per ogni 100000
iterazione. Nel secondo caso, abbiamo Sum1
e Sum2
che entrambi agiscono come se fossero la stessa funzione chiamata due volte di seguito.
In questo caso possiamo trattare Sum1
e Sum2
come semplicemente vecchi Sum
dove Sum
in questo caso assomiglia a questo: Sum n=1 : [1,100000] { f(a) = f(a) + f(b); }
e ora sembra un'ottimizzazione in cui possiamo semplicemente considerarla come la stessa funzione.
Riepilogo con analogia
Con quello che abbiamo visto nel secondo caso sembra quasi che ci sia ottimizzazione poiché entrambi per i loop hanno la stessa firma esatta, ma questo non è il vero problema. Il problema non è il lavoro che è stato fatto da f(a)
, f(b)
, f(c)
, e f(d)
. In entrambi i casi e il confronto tra i due, è la differenza nella distanza che la somma deve percorrere in ciascun caso che ti dà la differenza nel tempo di esecuzione.
Pensate del For Loops
come il Summations
che fa le iterazioni come una Boss
che sta dando ordini a due persone A
e B
e che i loro posti di lavoro sono a base di carne C
e D
, rispettivamente, e per raccogliere qualche pacchetto da loro e restituirlo. In questa analogia, i cicli for o le iterazioni di sommatoria e i controlli delle condizioni stessi in realtà non rappresentano il Boss
. Ciò che rappresenta effettivamente Boss
non proviene direttamente dagli algoritmi matematici effettivi, ma dal concetto reale Scope
e Code Block
all'interno di una routine o subroutine, metodo, funzione, unità di traduzione, ecc. Il primo algoritmo ha 1 ambito in cui il 2o algoritmo ha 2 ambiti consecutivi.
Nel primo caso su ogni distinta di chiamata, il Boss
va a A
e dà l'ordine e A
va a prendere il B's
pacchetto, poi Boss
va a C
e dà agli ordini di fare lo stesso e ricevere il pacchetto da D
ogni iterazione.
Nel secondo caso, Boss
funziona direttamente con A
go and fetch B's
package fino a quando non vengono ricevuti tutti i pacchetti. Quindi Boss
funziona con C
lo stesso per ottenere tutti i D's
pacchetti.
Poiché stiamo lavorando con un puntatore a 8 byte e gestiamo l'allocazione dell'heap, consideriamo il seguente problema. Diciamo che Boss
è a 100 piedi da A
e che A
è a 500 piedi da C
. Non dobbiamo preoccuparci di quanto Boss
sia inizialmente distante a C
causa dell'ordine delle esecuzioni. In entrambi i casi, Boss
inizialmente viaggia dal A
primo poi al B
. Questa analogia non vuol dire che questa distanza sia esatta; è solo uno scenario di test utile per mostrare il funzionamento degli algoritmi.
In molti casi quando si eseguono allocazioni di heap e si lavora con la cache e i file di paging, queste distanze tra le posizioni degli indirizzi possono non variare molto o possono variare in modo significativo a seconda della natura dei tipi di dati e delle dimensioni dell'array.
I casi di test:
Primo caso: alla prima iterazione,Boss
inizialmente deve percorrere 100 piedi per far scivolare l'ordineA
e se neA
va e fa le sue cose, ma poiBoss
deve percorrere 500 piediC
per dargli il suo ordine. Quindi alla successiva iterazione e ogni altra iterazione dopo laBoss
deve andare avanti e indietro per 500 piedi tra i due.
Secondo caso: LaBoss
deve viaggiare 100 piedi alla prima iterazione aA
, ma dopo che, lui è già lì e solo aspettaA
di tornare fino a quando tutti gli slittamenti sono pieni. QuindiBoss
deve percorrere 500 piedi sulla prima iterazioneC
perchéC
è a 500 piedi daA
. Dal momento che questoBoss( Summation, For Loop )
viene chiamato subito dopo aver lavorato conA
lui, allora aspetta lì come ha fattoA
finoaquando non sono state completate tutte le richiesteC's
.
La differenza nelle distanze percorse
const n = 100000
distTraveledOfFirst = (100 + 500) + ((n-1)*(500 + 500);
// Simplify
distTraveledOfFirst = 600 + (99999*100);
distTraveledOfFirst = 600 + 9999900;
distTraveledOfFirst = 10000500;
// Distance Traveled On First Algorithm = 10,000,500ft
distTraveledOfSecond = 100 + 500 = 600;
// Distance Traveled On Second Algorithm = 600ft;
Il confronto dei valori arbitrari
Possiamo facilmente vedere che 600 è molto meno di 10 milioni. Ora, questo non è esatto, perché non conosciamo l'effettiva differenza di distanza tra quale indirizzo di RAM o da quale cache o file di paging ogni chiamata su ogni iterazione sarà dovuta a molte altre variabili invisibili. Questa è solo una valutazione della situazione di cui tenere conto e guardarla dallo scenario peggiore.
Da questi numeri sembrerebbe quasi che l'algoritmo Uno sia 99%
più lento dell'algoritmo due; Tuttavia, questa è solo la Boss's
parte o la responsabilità degli algoritmi e non tiene conto per i lavoratori attuali A
, B
, C
, & D
e che cosa hanno a che fare su ogni iterazione del ciclo. Quindi il lavoro del capo rappresenta solo circa il 15 - 40% del lavoro totale svolto. La maggior parte del lavoro svolto attraverso i lavoratori ha un impatto leggermente maggiore nel mantenere il rapporto tra le differenze di velocità a circa il 50-70%
The Observation: - Le differenze tra i due algoritmi
In questa situazione, è la struttura del processo del lavoro svolto. Ciò dimostra che il caso 2 è più efficiente sia per l'ottimizzazione parziale di avere una dichiarazione di funzione simile sia per definizione in cui sono solo le variabili che differiscono per nome e distanza percorsa.
Vediamo anche che la distanza totale percorsa nel caso 1 è molto più lontana rispetto al caso 2 e possiamo considerare che questa distanza ha percorso il nostro fattore tempo tra i due algoritmi. Il caso 1 ha molto più lavoro da fare rispetto al caso 2 .
Ciò è osservabile dalle prove delle ASM
istruzioni mostrate in entrambi i casi. Insieme a quanto già affermato in questi casi, ciò non tiene conto del fatto che nel caso 1 il boss dovrà attendere entrambi A
e C
tornare prima di poter tornare A
nuovamente per ogni iterazione. Inoltre non tiene conto del fatto che se A
o B
sta impiegando un tempo estremamente lungo, sia l'uno Boss
che l'altro lavoratore sono inattivi in attesa di essere eseguiti.
Nel Caso 2 l'unico a rimanere inattivo è Boss
fino a quando il lavoratore non torna. Quindi anche questo ha un impatto sull'algoritmo.
I PO Domande modificate
EDIT: la domanda si è rivelata irrilevante, poiché il comportamento dipende fortemente dalle dimensioni degli array (n) e dalla cache della CPU. Quindi, se c'è ulteriore interesse, riformulo la domanda:
Potresti fornire una solida comprensione dei dettagli che portano ai diversi comportamenti della cache, come illustrato dalle cinque aree nel seguente grafico?
Potrebbe anche essere interessante sottolineare le differenze tra architetture CPU / cache, fornendo un grafico simile per queste CPU.
Per quanto riguarda queste domande
Come ho dimostrato senza dubbio, c'è un problema di fondo ancor prima che vengano coinvolti l'hardware e il software.
Ora per quanto riguarda la gestione della memoria e la memorizzazione nella cache insieme ai file di paging, ecc. Che funzionano tutti insieme in un set integrato di sistemi tra i seguenti:
The Architecture
{Hardware, firmware, alcuni driver integrati, kernel e set di istruzioni ASM}.
The OS
{Sistemi di gestione di file e memoria, driver e registro}.
The Compiler
{Unità di traduzione e ottimizzazioni del codice sorgente}.
- E anche lo
Source Code
stesso con i suoi set di algoritmi distintivi.
Possiamo già vedere che c'è un collo di bottiglia che sta accadendo all'interno del primo algoritmo prima ancora applicare a qualsiasi macchina con un qualsiasi arbitrario Architecture
, OS
e Programmable Language
rispetto al secondo algoritmo. Esisteva già un problema prima di coinvolgere i concetti intrinseci di un computer moderno.
I risultati finali
Però; non vuol dire che queste nuove domande non sono importanti perché sono esse stesse e hanno un ruolo dopo tutto. Hanno un impatto sulle procedure e sulle prestazioni complessive e questo è evidente con i vari grafici e le valutazioni di molti che hanno dato le loro risposte e commenti.
Se hai prestato attenzione all'analogia dei Boss
due lavoratori A
e B
chi ha dovuto andare a recuperare i pacchetti da C
e D
rispettivamente e considerando le notazioni matematiche dei due algoritmi in questione; puoi vedere senza il coinvolgimento dell'hardware e del software del computer Case 2
è circa 60%
più veloce di Case 1
.
Quando guardi i grafici e i grafici dopo che questi algoritmi sono stati applicati a un codice sorgente, compilati, ottimizzati ed eseguiti attraverso il sistema operativo per eseguire le loro operazioni su un determinato componente hardware, puoi persino vedere un po 'più di degrado tra le differenze in questi algoritmi.
Se il Data
set è abbastanza piccolo, all'inizio potrebbe non sembrare una differenza. Tuttavia, poiché Case 1
è più 60 - 70%
lento di quanto Case 2
possiamo vedere la crescita di questa funzione in termini di differenze nelle esecuzioni temporali:
DeltaTimeDifference approximately = Loop1(time) - Loop2(time)
//where
Loop1(time) = Loop2(time) + (Loop2(time)*[0.6,0.7]) // approximately
// So when we substitute this back into the difference equation we end up with
DeltaTimeDifference approximately = (Loop2(time) + (Loop2(time)*[0.6,0.7])) - Loop2(time)
// And finally we can simplify this to
DeltaTimeDifference approximately = [0.6,0.7]*Loop2(time)
Questa approssimazione è la differenza media tra questi due loop sia algoritmicamente che operazioni della macchina che comportano ottimizzazioni software e istruzioni della macchina.
Quando il set di dati cresce in modo lineare, aumenta anche la differenza temporale tra i due. L'algoritmo 1 ha più recuperi dell'algoritmo 2 che è evidente quando Boss
deve percorrere avanti e indietro la massima distanza tra A
e C
per ogni iterazione dopo la prima iterazione mentre l'algoritmo 2 Boss
deve viaggiare A
una volta e poi dopo aver terminato A
deve viaggiare una distanza massima solo una volta quando si passa da A
a C
.
Cercare di Boss
concentrarsi sul fare due cose simili contemporaneamente e destreggiarle avanti e indietro invece di concentrarsi su compiti consecutivi simili lo farà arrabbiare abbastanza alla fine della giornata poiché ha dovuto viaggiare e lavorare il doppio. Pertanto non perdere la portata della situazione lasciando che il tuo capo entri in un collo di bottiglia interpolato perché il coniuge del capo e i figli non lo apprezzerebbero.
Modifica: principi di progettazione dell'ingegneria del software
- La differenza tra Local Stack
e i Heap Allocated
calcoli all'interno dell'iterativo per i loop e la differenza tra i loro usi, le loro efficienze ed efficacia -
L'algoritmo matematico che ho proposto sopra si applica principalmente ai loop che eseguono operazioni sui dati allocati nell'heap.
- Operazioni consecutive sullo stack:
- Se i loop eseguono operazioni sui dati localmente all'interno di un singolo blocco di codice o ambito che si trova all'interno del frame dello stack, si applicherà comunque, ma le posizioni di memoria sono molto più vicine al punto in cui sono in genere sequenziali e la differenza nella distanza percorsa o nel tempo di esecuzione è quasi trascurabile. Dal momento che non ci sono allocazioni all'interno dell'heap, la memoria non viene dispersa e la memoria non viene recuperata attraverso ram. La memoria è in genere sequenziale e relativa al frame dello stack e al puntatore dello stack.
- Quando vengono eseguite operazioni consecutive nello stack, un moderno processore memorizzerà nella cache valori e indirizzi ripetitivi conservando tali valori all'interno dei registri della cache locale. Il tempo delle operazioni o delle istruzioni qui è dell'ordine dei nano-secondi.
- Operazioni assegnate di heap consecutivi:
- Quando si inizia ad applicare allocazioni di heap e il processore deve recuperare gli indirizzi di memoria per chiamate consecutive, a seconda dell'architettura della CPU, del controller del bus e dei moduli Ram, il tempo delle operazioni o dell'esecuzione può essere nell'ordine di micro millisecondi. Rispetto alle operazioni dello stack memorizzate nella cache, queste sono piuttosto lente.
- La CPU dovrà recuperare l'indirizzo di memoria da Ram e in genere qualsiasi cosa nel bus di sistema è lenta rispetto ai percorsi dati interni o ai bus dati all'interno della CPU stessa.
Pertanto, quando si lavora con dati che devono essere presenti nell'heap e si attraversano attraverso di essi in loop, è più efficiente mantenere ciascun set di dati e i relativi algoritmi all'interno del proprio loop singolo. Otterrai migliori ottimizzazioni rispetto al tentativo di fattorizzare loop consecutivi inserendo più operazioni di diversi set di dati che si trovano nell'heap in un singolo loop.
Va bene farlo con i dati che si trovano nello stack poiché sono spesso memorizzati nella cache, ma non per i dati a cui deve essere richiesto il proprio indirizzo di memoria per ogni iterazione.
È qui che entra in gioco l'ingegneria del software e il design dell'architettura del software. È la capacità di sapere come organizzare i dati, sapere quando memorizzare i dati nella cache, sapere quando allocare i dati sull'heap, sapere come progettare e implementare i propri algoritmi e sapere quando e dove chiamarli.
Potresti avere lo stesso algoritmo che appartiene allo stesso set di dati, ma potresti volere un progetto di implementazione per la sua variante di stack e un altro per la sua variante allocata in heap solo a causa del problema di cui sopra che si vede dalla sua O(n)
complessità dell'algoritmo quando funziona con il mucchio.
Da quello che ho notato nel corso degli anni, molte persone non prendono in considerazione questo fatto. Tenderanno a progettare un algoritmo che funziona su un determinato set di dati e lo useranno indipendentemente dal set di dati che viene memorizzato nella cache locale nello stack o se è stato allocato nell'heap.
Se si desidera una vera ottimizzazione, sì, potrebbe sembrare una duplicazione del codice, ma per generalizzare sarebbe più efficiente avere due varianti dello stesso algoritmo. Uno per le operazioni di stack e l'altro per le operazioni di heap eseguite in loop iterativi!
Ecco uno pseudo esempio: due semplici strutture, un algoritmo.
struct A {
int data;
A() : data{0}{}
A(int a) : data{a}{}
};
struct B {
int data;
B() : data{0}{}
A(int b) : data{b}{}
}
template<typename T>
void Foo( T& t ) {
// do something with t
}
// some looping operation: first stack then heap.
// stack data:
A dataSetA[10] = {};
B dataSetB[10] = {};
// For stack operations this is okay and efficient
for (int i = 0; i < 10; i++ ) {
Foo(dataSetA[i]);
Foo(dataSetB[i]);
}
// If the above two were on the heap then performing
// the same algorithm to both within the same loop
// will create that bottleneck
A* dataSetA = new [] A();
B* dataSetB = new [] B();
for ( int i = 0; i < 10; i++ ) {
Foo(dataSetA[i]); // dataSetA is on the heap here
Foo(dataSetB[i]); // dataSetB is on the heap here
} // this will be inefficient.
// To improve the efficiency above, put them into separate loops...
for (int i = 0; i < 10; i++ ) {
Foo(dataSetA[i]);
}
for (int i = 0; i < 10; i++ ) {
Foo(dataSetB[i]);
}
// This will be much more efficient than above.
// The code isn't perfect syntax, it's only psuedo code
// to illustrate a point.
Questo è ciò a cui mi riferivo avendo implementazioni separate per varianti di stack rispetto a varianti di heap. Gli algoritmi stessi non contano troppo, sono le strutture in loop che li userete in questo.