Perché le aggiunte elementally sono molto più veloci in loop separati che in un loop combinato?


2247

Supponiamo a1, b1, c1, e d1scegliere la memoria heap e il codice numerico ha il seguente ciclo di nucleo.

const int n = 100000;

for (int j = 0; j < n; j++) {
    a1[j] += b1[j];
    c1[j] += d1[j];
}

Questo loop viene eseguito 10.000 volte tramite un altro forloop esterno . Per accelerarlo, ho modificato il codice in:

for (int j = 0; j < n; j++) {
    a1[j] += b1[j];
}

for (int j = 0; j < n; j++) {
    c1[j] += d1[j];
}

Compilato su MS Visual C ++ 10.0 con ottimizzazione completa e SSE2 abilitato per 32 bit su un Intel Core 2 Duo (x64), il primo esempio richiede 5,5 secondi e l'esempio a doppio loop richiede solo 1,9 secondi. La mia domanda è: (Fare riferimento alla mia domanda riformulata in fondo)

PS: Non sono sicuro, se questo aiuta:

Il disassemblaggio per il primo ciclo assomiglia sostanzialmente a questo (questo blocco viene ripetuto circa cinque volte nell'intero programma):

movsd       xmm0,mmword ptr [edx+18h]
addsd       xmm0,mmword ptr [ecx+20h]
movsd       mmword ptr [ecx+20h],xmm0
movsd       xmm0,mmword ptr [esi+10h]
addsd       xmm0,mmword ptr [eax+30h]
movsd       mmword ptr [eax+30h],xmm0
movsd       xmm0,mmword ptr [edx+20h]
addsd       xmm0,mmword ptr [ecx+28h]
movsd       mmword ptr [ecx+28h],xmm0
movsd       xmm0,mmword ptr [esi+18h]
addsd       xmm0,mmword ptr [eax+38h]

Ogni loop dell'esempio a doppio loop produce questo codice (il blocco seguente viene ripetuto circa tre volte):

addsd       xmm0,mmword ptr [eax+28h]
movsd       mmword ptr [eax+28h],xmm0
movsd       xmm0,mmword ptr [ecx+20h]
addsd       xmm0,mmword ptr [eax+30h]
movsd       mmword ptr [eax+30h],xmm0
movsd       xmm0,mmword ptr [ecx+28h]
addsd       xmm0,mmword ptr [eax+38h]
movsd       mmword ptr [eax+38h],xmm0
movsd       xmm0,mmword ptr [ecx+30h]
addsd       xmm0,mmword ptr [eax+40h]
movsd       mmword ptr [eax+40h],xmm0

La domanda si è rivelata irrilevante, in quanto 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.

PPS: ecco il codice completo. Utilizza TBB Tick_Count per tempi di risoluzione più elevati, che possono essere disabilitati non definendo la TBB_TIMINGMacro:

#include <iostream>
#include <iomanip>
#include <cmath>
#include <string>

//#define TBB_TIMING

#ifdef TBB_TIMING   
#include <tbb/tick_count.h>
using tbb::tick_count;
#else
#include <time.h>
#endif

using namespace std;

//#define preallocate_memory new_cont

enum { new_cont, new_sep };

double *a1, *b1, *c1, *d1;


void allo(int cont, int n)
{
    switch(cont) {
      case new_cont:
        a1 = new double[n*4];
        b1 = a1 + n;
        c1 = b1 + n;
        d1 = c1 + n;
        break;
      case new_sep:
        a1 = new double[n];
        b1 = new double[n];
        c1 = new double[n];
        d1 = new double[n];
        break;
    }

    for (int i = 0; i < n; i++) {
        a1[i] = 1.0;
        d1[i] = 1.0;
        c1[i] = 1.0;
        b1[i] = 1.0;
    }
}

void ff(int cont)
{
    switch(cont){
      case new_sep:
        delete[] b1;
        delete[] c1;
        delete[] d1;
      case new_cont:
        delete[] a1;
    }
}

double plain(int n, int m, int cont, int loops)
{
#ifndef preallocate_memory
    allo(cont,n);
#endif

#ifdef TBB_TIMING   
    tick_count t0 = tick_count::now();
#else
    clock_t start = clock();
#endif

    if (loops == 1) {
        for (int i = 0; i < m; i++) {
            for (int j = 0; j < n; j++){
                a1[j] += b1[j];
                c1[j] += d1[j];
            }
        }
    } else {
        for (int i = 0; i < m; i++) {
            for (int j = 0; j < n; j++) {
                a1[j] += b1[j];
            }
            for (int j = 0; j < n; j++) {
                c1[j] += d1[j];
            }
        }
    }
    double ret;

#ifdef TBB_TIMING   
    tick_count t1 = tick_count::now();
    ret = 2.0*double(n)*double(m)/(t1-t0).seconds();
#else
    clock_t end = clock();
    ret = 2.0*double(n)*double(m)/(double)(end - start) *double(CLOCKS_PER_SEC);
#endif

#ifndef preallocate_memory
    ff(cont);
#endif

    return ret;
}


void main()
{   
    freopen("C:\\test.csv", "w", stdout);

    char *s = " ";

    string na[2] ={"new_cont", "new_sep"};

    cout << "n";

    for (int j = 0; j < 2; j++)
        for (int i = 1; i <= 2; i++)
#ifdef preallocate_memory
            cout << s << i << "_loops_" << na[preallocate_memory];
#else
            cout << s << i << "_loops_" << na[j];
#endif

    cout << endl;

    long long nmax = 1000000;

#ifdef preallocate_memory
    allo(preallocate_memory, nmax);
#endif

    for (long long n = 1L; n < nmax; n = max(n+1, long long(n*1.2)))
    {
        const long long m = 10000000/n;
        cout << n;

        for (int j = 0; j < 2; j++)
            for (int i = 1; i <= 2; i++)
                cout << s << plain(n, m, j, i);
        cout << endl;
    }
}

(Mostra FLOP / s per diversi valori di n.)

inserisci qui la descrizione dell'immagine


4
Potrebbe essere il sistema operativo che rallenta durante la ricerca nella memoria fisica ogni volta che si accede ad esso e ha qualcosa di simile alla cache in caso di accesso secondario allo stesso memblock.
AlexTheo,

7
Stai compilando con ottimizzazioni? Sembra un sacco di codice asm per O2 ...
Luchian Grigore,

1
Ho fatto quella che sembra essere una domanda simile qualche tempo fa. Esso o le risposte potrebbero avere informazioni di interesse.
Mark Wilkins,

61
Per essere pignoli, questi due frammenti di codice non sono equivalenti a causa di puntatori potenzialmente sovrapposti. C99 ha la restrictparola chiave per tali situazioni. Non so se MSVC abbia qualcosa di simile. Naturalmente, se questo fosse il problema, il codice SSE non sarebbe corretto.
user510306

8
Questo potrebbe avere a che fare con l'aliasing della memoria. Con un loop, d1[j]può anche essere con a1[j], quindi il compilatore potrebbe ritirarsi dall'ottimizzazione della memoria. Ciò non accade se si separano gli scritti in memoria in due cicli.
rturrado,

Risposte:


1691

Dopo un'ulteriore analisi di ciò, credo che ciò sia (almeno in parte) causato dall'allineamento dei dati dei quattro puntatori. Ciò causerà un certo livello di conflitti di cache bank / way.

Se ho indovinato correttamente su come stai allocando i tuoi array, loro è probabile che siano allineate alla linea di pagina .

Ciò significa che tutti i tuoi accessi in ciascun ciclo cadranno nello stesso modo cache. Tuttavia, i processori Intel hanno da un po 'di tempo associatività cache L1 a 8 vie. Ma in realtà, la performance non è del tutto uniforme. L'accesso a 4 vie è ancora più lento di quello a 2 vie.

EDIT: sembra infatti che stiate allocando tutti gli array separatamente. Di solito quando sono richieste allocazioni così grandi, l'allocatore richiederà nuove pagine dal sistema operativo. Pertanto, esiste un'alta probabilità che allocazioni di grandi dimensioni vengano visualizzate con lo stesso offset rispetto a un limite di pagina.

Ecco il codice di prova:

int main(){
    const int n = 100000;

#ifdef ALLOCATE_SEPERATE
    double *a1 = (double*)malloc(n * sizeof(double));
    double *b1 = (double*)malloc(n * sizeof(double));
    double *c1 = (double*)malloc(n * sizeof(double));
    double *d1 = (double*)malloc(n * sizeof(double));
#else
    double *a1 = (double*)malloc(n * sizeof(double) * 4);
    double *b1 = a1 + n;
    double *c1 = b1 + n;
    double *d1 = c1 + n;
#endif

    //  Zero the data to prevent any chance of denormals.
    memset(a1,0,n * sizeof(double));
    memset(b1,0,n * sizeof(double));
    memset(c1,0,n * sizeof(double));
    memset(d1,0,n * sizeof(double));

    //  Print the addresses
    cout << a1 << endl;
    cout << b1 << endl;
    cout << c1 << endl;
    cout << d1 << endl;

    clock_t start = clock();

    int c = 0;
    while (c++ < 10000){

#if ONE_LOOP
        for(int j=0;j<n;j++){
            a1[j] += b1[j];
            c1[j] += d1[j];
        }
#else
        for(int j=0;j<n;j++){
            a1[j] += b1[j];
        }
        for(int j=0;j<n;j++){
            c1[j] += d1[j];
        }
#endif

    }

    clock_t end = clock();
    cout << "seconds = " << (double)(end - start) / CLOCKS_PER_SEC << endl;

    system("pause");
    return 0;
}

Risultati benchmark:

EDIT: risultati su un effettivo macchina per architettura Core 2:

2 x Intel Xeon X5482 Harpertown a 3,2 GHz:

#define ALLOCATE_SEPERATE
#define ONE_LOOP
00600020
006D0020
007A0020
00870020
seconds = 6.206

#define ALLOCATE_SEPERATE
//#define ONE_LOOP
005E0020
006B0020
00780020
00850020
seconds = 2.116

//#define ALLOCATE_SEPERATE
#define ONE_LOOP
00570020
00633520
006F6A20
007B9F20
seconds = 1.894

//#define ALLOCATE_SEPERATE
//#define ONE_LOOP
008C0020
00983520
00A46A20
00B09F20
seconds = 1.993

osservazioni:

  • 6,206 secondi con un loop e 2,116 secondi con due loop. Questo riproduce esattamente i risultati dell'OP.

  • Nei primi due test, gli array sono assegnati separatamente. Noterai che hanno tutti lo stesso allineamento rispetto alla pagina.

  • Nelle seconde due prove, gli array sono raggruppati per interrompere tale allineamento. Qui noterai che entrambi i loop sono più veloci. Inoltre, il secondo (doppio) loop è ora quello più lento come ci si aspetterebbe normalmente.

Come sottolinea @Stephen Cannon nei commenti, è molto probabile che questo allineamento causi un falso aliasing nelle unità di caricamento / archiviazione o nella cache. Ho cercato su Google per questo e ho scoperto che Intel ha effettivamente un contatore hardware per l' aliasing dell'indirizzo parziale bancarelle di :

http://software.intel.com/sites/products/documentation/doclib/stdxe/2013/~amplifierxe/pmw_dp/events/partial_address_alias.html


5 Regioni - Spiegazioni

Regione 1:

Questo è facile. Il set di dati è così piccolo che le prestazioni sono dominate da sovraccarico come loop e branch.

Regione 2:

Qui, all'aumentare delle dimensioni dei dati, la quantità di sovraccarico relativo diminuisce e le prestazioni "si saturano". Qui due loop sono più lenti perché hanno il doppio di loop e branching overhead.

Non sono sicuro esattamente cosa stia succedendo qui ... L'allineamento potrebbe ancora avere un effetto poiché la nebbia di Agner menziona i conflitti della banca cache . (Tale collegamento riguarda Sandy Bridge, ma l'idea dovrebbe essere ancora applicabile al Core 2.)

Regione 3:

A questo punto, i dati non rientrano più nella cache L1. Quindi le prestazioni sono limitate dalla larghezza di banda della cache L1 <-> L2.

Regione 4:

Il calo delle prestazioni nel single-loop è ciò che stiamo osservando. E come detto, ciò è dovuto all'allineamento che (molto probabilmente) provoca falsi aliasing nelle unità di caricamento / memorizzazione del processore.

Tuttavia, affinché si verifichi un falso alias, è necessario che vi sia un passo abbastanza grande tra i set di dati. Ecco perché non lo vedi nella regione 3.

Regione 5:

A questo punto, nulla si adatta alla cache. Quindi sei legato dalla larghezza di banda della memoria.


2 x Intel X5482 Harpertown a 3,2 GHz Intel Core i7 870 a 2,8 GHz Intel Core i7 2600K a 4,4 GHz


162
+1: penso che questa sia la risposta. Contrariamente a quanto dicono tutte le altre risposte, non si tratta della variante a ciclo singolo che ha intrinsecamente più mancanze della cache, si tratta del particolare allineamento delle matrici che causano le mancate cache.
Oliver Charlesworth,

30
Questo; una falsa aliasing è la spiegazione più probabile.
Stephen Canon,

7
@VictorT. Ho usato il codice a cui l'OP era collegato. Genera un file .css che posso aprire in Excel e trarne un grafico.
Mistico il

5
@Nawaz Una pagina è in genere 4KB. Se guardi gli indirizzi esadecimali che stampo, i test assegnati separatamente hanno tutti lo stesso modulo 4096. (ovvero 32 byte dall'inizio di un confine 4KB) Forse GCC non ha questo comportamento. Questo potrebbe spiegare perché non vedi le differenze.
Mistico il


224

OK, la risposta giusta deve sicuramente fare qualcosa con la cache della CPU. Ma usare l'argomento cache può essere abbastanza difficile, specialmente senza dati.

Ci sono molte risposte, che hanno portato a molte discussioni, ma ammettiamolo: i problemi della cache possono essere molto complessi e non sono monodimensionali. Dipendono fortemente dalla dimensione dei dati, quindi la mia domanda era ingiusta: si è rivelato essere in un punto molto interessante nel grafico della cache.

La risposta di @ Mysticial ha convinto molte persone (incluso me), probabilmente perché era l'unico che sembrava basarsi sui fatti, ma era solo un "punto dati" della verità.

Ecco perché ho combinato il suo test (usando un'allocazione continua vs. separata) e il consiglio di risposta di @James.

I grafici sottostanti mostrano che la maggior parte delle risposte e in particolare la maggior parte dei commenti alla domanda e alle risposte possono essere considerate completamente errate o vere a seconda dell'esatto scenario e dei parametri utilizzati.

Nota che la mia domanda iniziale era a n = 100.000 . Questo punto (per caso) presenta un comportamento speciale:

  1. Possiede la più grande discrepanza tra la versione a uno e due loop (quasi un fattore tre)

  2. È l'unico punto in cui un loop (ovvero con allocazione continua) batte la versione a due loop. (Questo ha reso possibile la risposta di Mysticial.)

Il risultato utilizzando i dati inizializzati:

Inserisci qui la descrizione dell'immagine

Il risultato utilizzando dati non inizializzati (questo è ciò che Mysticial ha testato):

Inserisci qui la descrizione dell'immagine

E questo è difficile da spiegare: dati inizializzati, che vengono allocati una volta e riutilizzati per ogni caso di test successivo di dimensioni vettoriali diverse:

Inserisci qui la descrizione dell'immagine

Proposta

Ogni domanda di basso livello relativa alle prestazioni su Stack Overflow dovrebbe essere richiesta per fornire informazioni MFLOPS per l'intera gamma di dimensioni dei dati rilevanti della cache! È una perdita di tempo per tutti pensare alle risposte e soprattutto discuterne con gli altri senza queste informazioni.


18
+1 Bella analisi. Non intendevo lasciare i dati non inizializzati in primo luogo. È appena successo che l'allocatore li ha azzerati comunque. Quindi i dati inizializzati sono importanti. Ho appena modificato la mia risposta con i risultati su una vera macchina per architettura Core 2 e sono molto più vicini a ciò che stai osservando. Un'altra cosa è che ho testato una gamma di dimensioni ne mostra lo stesso gap prestazionale per n = 80000, n = 100000, n = 200000, ecc ...
Mysticial,

2
@Mysticial Penso che il sistema operativo implementa l'azzeramento delle pagine ogni volta che si danno nuove pagine a un processo per evitare possibili spionaggi tra processi.
v.

1
@ v.oddou: il comportamento dipende anche dal sistema operativo; IIRC, Windows ha un thread per mettere in background le pagine liberate a zero, e se una richiesta non può essere soddisfatta da pagine già azzerate, la VirtualAllocchiamata si blocca fino a quando può azzerare abbastanza per soddisfare la richiesta. Al contrario, Linux mappa semplicemente la pagina zero come copia su scrittura quanto basta e, in scrittura, copia i nuovi zeri in una nuova pagina prima di scrivere nei nuovi dati. Ad ogni modo, dal punto di vista del processo in modalità utente, le pagine vengono azzerate, ma il primo utilizzo di memoria non inizializzata sarà generalmente più costoso su Linux che su Windows.
ShadowRanger

81

Il secondo ciclo comporta molta meno attività della cache, quindi è più facile per il processore tenere il passo con le esigenze di memoria.


1
Stai dicendo che la seconda variante comporta un minor numero di errori nella cache? Perché?
Oliver Charlesworth,

2
@Oli: Nella prima variante, il processore deve accedere quattro linee di memoria con un tempo- a[i], b[i], c[i]e d[i]Nella seconda variante, ha bisogno solo due. Questo rende molto più fattibile riempire quelle righe durante l'aggiunta.
Puppy,

4
Ma finché le matrici non si scontrano nella cache, ogni variante richiede lo stesso numero esatto di letture e scritture da / nella memoria principale. Quindi la conclusione è (penso) che queste due matrici si scontrano continuamente.
Oliver Charlesworth,

3
Non seguo Per istruzione (cioè per istanza di x += y), ci sono due letture e una scrittura. Questo è vero per entrambe le varianti. Il requisito cache <-> larghezza di banda CPU è quindi lo stesso. Finché non ci sono conflitti, anche la cache <-> Requisiti di larghezza di banda RAM è la stessa ..
Oliver Charlesworth,

2
Come notato in stackoverflow.com/a/1742231/102916 , il prefetch hardware del Pentium M è in grado di tracciare 12 diversi flussi in avanti (e mi aspetterei che l'hardware successivo sia almeno altrettanto capace). Il loop 2 sta ancora leggendo solo quattro stream, quindi rientra ampiamente in questo limite.
Brooks Moses,

50

Immagina di lavorare su una macchina in cui n era giusto il valore giusto perché fosse possibile solo tenere in memoria due array alla volta, ma la memoria totale disponibile, tramite cache del disco, era ancora sufficiente per contenere tutti e quattro.

Supponendo una semplice politica di memorizzazione nella cache LIFO, questo codice:

for(int j=0;j<n;j++){
    a[j] += b[j];
}
for(int j=0;j<n;j++){
    c[j] += d[j];
}

causerebbe prima di tutto ae bdi essere caricato nella RAM e poi lavorato interamente nella RAM. Quando inizia il secondo loop, ced sarebbero quindi caricati dal disco nella RAM e operati.

l'altro loop

for(int j=0;j<n;j++){
    a[j] += b[j];
    c[j] += d[j];
}

sfogliare due array e sfogliare gli altri due ogni volta intorno al ciclo . Questo sarebbe ovviamente molto più lento.

Probabilmente non vedrai la memorizzazione nella cache del disco nei tuoi test, ma probabilmente stai vedendo gli effetti collaterali di un'altra forma di memorizzazione nella cache.


Sembra che ci sia un po 'di confusione / incomprensione qui, quindi cercherò di elaborare un po' usando un esempio.

n = 2e stiamo lavorando con i byte. Nel mio scenario abbiamo quindi solo 4 byte di RAM e il resto della nostra memoria è significativamente più lento (diciamo 100 volte più accesso).

Supponendo una politica di cache abbastanza stupida se il byte non è nella cache, mettilo lì e ottieni anche il seguente byte mentre ci siamo otterrai uno scenario simile a questo:

  • Con

    for(int j=0;j<n;j++){
     a[j] += b[j];
    }
    for(int j=0;j<n;j++){
     c[j] += d[j];
    }
  • cache a[0]e a[1]quindi b[0]e b[1]e impostato a[0] = a[0] + b[0]nella cache - ora ci sono quattro byte nella cache a[0], a[1]e b[0], b[1]. Costo = 100 + 100.

  • impostato a[1] = a[1] + b[1]nella cache. Costo = 1 + 1.
  • Ripetere l'operazione per ce d.
  • Costo totale = (100 + 100 + 1 + 1) * 2 = 404

  • Con

    for(int j=0;j<n;j++){
     a[j] += b[j];
     c[j] += d[j];
    }
  • cache a[0]e a[1]quindi b[0]e b[1]e impostato a[0] = a[0] + b[0]nella cache - ora ci sono quattro byte nella cache a[0], a[1]e b[0], b[1]. Costo = 100 + 100.

  • espellere a[0], a[1], b[0], b[1]da cache e cache c[0]e c[1]quindi d[0]e d[1]e impostarec[0] = c[0] + d[0] in cache. Costo = 100 + 100.
  • Sospetto che tu stia cominciando a vedere dove sto andando.
  • Costo totale = (100 + 100 + 100 + 100) * 2 = 800

Questo è un classico scenario thrash cache.


12
Questo non è corretto Un riferimento a un particolare elemento di un array non causa il paging dell'intero array dal disco (o dalla memoria non memorizzata nella cache); viene cercata solo la pagina o la riga della cache pertinente.
Brooks Moses,

1
@Brooks Moses - Se cammini attraverso l'intero array, come sta accadendo qui, allora lo farà.
OldCurmudgeon,

1
Bene, sì, ma è quello che succede durante l'intera operazione, non quello che succede ogni volta intorno al ciclo. Hai affermato che il secondo modulo "esploderà due array e sfoglierà negli altri due ogni volta intorno al ciclo", ed è quello a cui mi oppongo. Indipendentemente dalle dimensioni degli array complessivi, nel mezzo di questo loop la tua RAM manterrà una pagina da ciascuno dei quattro array, e nulla verrà eseguito il paging fino a quando il ciclo non avrà terminato.
Brooks Moses,

Nel caso particolare in cui n era il valore giusto per poter solo tenere in memoria due dei tuoi array alla volta, l' accesso a tutti gli elementi di quattro array in un loop deve sicuramente finire con il thrashing.
OldCurmudgeon,

1
Perché rimani in quel ciclo 2 pagine nella totalità a1e b1per il primo compito, piuttosto che solo nella prima pagina di ciascuna di esse? (Stai assumendo pagine a 5 byte, quindi una pagina è metà della tua RAM? Non è solo il ridimensionamento, è completamente diverso da un vero processore.)
Brooks Moses,

35

Non è a causa di un codice diverso, ma a causa della memorizzazione nella cache: la RAM è più lenta dei registri della CPU e una memoria cache è all'interno della CPU per evitare di scrivere la RAM ogni volta che cambia una variabile. Ma la cache non è grande come la RAM, quindi ne mappa solo una piccola parte.

Il primo codice modifica gli indirizzi di memoria distanti alternandoli ad ogni loop, richiedendo quindi continuamente di invalidare la cache.

Il secondo codice non si alterna: scorre semplicemente su indirizzi adiacenti due volte. Questo rende tutto il lavoro da completare nella cache, invalidandolo solo dopo l'avvio del secondo ciclo.


Perché ciò causerebbe l'invalidazione continua della cache?
Oliver Charlesworth,

1
@OliCharlesworth: pensa alla cache come una copia cartacea di un intervallo contiguo di indirizzi di memoria. Se fai finta di accedere a un indirizzo non parte di essi, devi ricaricare la cache. E se qualcosa nella cache è stato modificato, deve essere riscritto nella RAM, altrimenti andrà perso. Nel codice di esempio, 4 vettori di 100'000 numeri interi (400kByte) sono probabilmente più numerosi della capacità della cache L1 (128 o 256K).
Emilio Garavaglia,

5
La dimensione della cache non ha alcun impatto in questo scenario. Ogni elemento dell'array viene utilizzato solo una volta e successivamente non importa se viene sfrattato. La dimensione della cache è importante solo se si dispone di una località temporale (ovvero riutilizzerai gli stessi elementi in futuro).
Oliver Charlesworth,

2
@OliCharlesworth: se devo caricare un nuovo valore in una cache e c'è già un valore che è stato modificato, devo prima scriverlo e questo mi fa aspettare che avvenga la scrittura.
Emilio Garavaglia,

2
Ma in entrambe le varianti del codice OP, ogni valore viene modificato esattamente una volta. Si esegue lo stesso numero di riprese di valore in ciascuna variante.
Oliver Charlesworth il

22

Non riesco a replicare i risultati discussi qui.

Non so se la colpa è di un codice di riferimento scadente, o cosa, ma i due metodi sono entro il 10% l'uno dall'altro sulla mia macchina utilizzando il codice seguente e un ciclo di solito è leggermente più veloce di due - come faresti aspettarsi.

Le dimensioni dell'array variavano da 2 ^ 16 a 2 ^ 24, usando otto loop. Sono stato attento a inizializzare gli array di origine in modo che il +=compito non chiedesse alla FPU di aggiungere immondizia di memoria interpretata come doppia.

Ho suonato in giro con vari schemi, come mettere l'assegnazione di b[j], d[j]per InitToZero[j]all'interno dei cicli, e anche con l'utilizzo += b[j] = 1e+= d[j] = 1 , e ho ottenuto risultati abbastanza consistente.

Come ci si potrebbe aspettare, l'inizializzazione be l' dinterno del ciclo hanno InitToZero[j]dato un vantaggio all'approccio combinato, poiché sono stati eseguiti back-to-back prima delle assegnazioni a aec , ma comunque entro il 10%. Vai a capire.

L'hardware è Dell XPS 8500 con generazione 3 Core i7 a 3,4 GHz e 8 GB di memoria. Per 2 ^ 16 a 2 ^ 24, usando otto loop, il tempo cumulativo è stato rispettivamente di 44,987 e 40,965. Visual C ++ 2010, completamente ottimizzato.

PS: ho modificato i loop in modo da contare fino a zero e il metodo combinato è stato leggermente più veloce. Mi gratto la testa. Nota il nuovo dimensionamento dell'array e i conteggi dei cicli.

// MemBufferMystery.cpp : Defines the entry point for the console application.
//
#include "stdafx.h"
#include <iostream>
#include <cmath>
#include <string>
#include <time.h>

#define  dbl    double
#define  MAX_ARRAY_SZ    262145    //16777216    // AKA (2^24)
#define  STEP_SZ           1024    //   65536    // AKA (2^16)

int _tmain(int argc, _TCHAR* argv[]) {
    long i, j, ArraySz = 0,  LoopKnt = 1024;
    time_t start, Cumulative_Combined = 0, Cumulative_Separate = 0;
    dbl *a = NULL, *b = NULL, *c = NULL, *d = NULL, *InitToOnes = NULL;

    a = (dbl *)calloc( MAX_ARRAY_SZ, sizeof(dbl));
    b = (dbl *)calloc( MAX_ARRAY_SZ, sizeof(dbl));
    c = (dbl *)calloc( MAX_ARRAY_SZ, sizeof(dbl));
    d = (dbl *)calloc( MAX_ARRAY_SZ, sizeof(dbl));
    InitToOnes = (dbl *)calloc( MAX_ARRAY_SZ, sizeof(dbl));
    // Initialize array to 1.0 second.
    for(j = 0; j< MAX_ARRAY_SZ; j++) {
        InitToOnes[j] = 1.0;
    }

    // Increase size of arrays and time
    for(ArraySz = STEP_SZ; ArraySz<MAX_ARRAY_SZ; ArraySz += STEP_SZ) {
        a = (dbl *)realloc(a, ArraySz * sizeof(dbl));
        b = (dbl *)realloc(b, ArraySz * sizeof(dbl));
        c = (dbl *)realloc(c, ArraySz * sizeof(dbl));
        d = (dbl *)realloc(d, ArraySz * sizeof(dbl));
        // Outside the timing loop, initialize
        // b and d arrays to 1.0 sec for consistent += performance.
        memcpy((void *)b, (void *)InitToOnes, ArraySz * sizeof(dbl));
        memcpy((void *)d, (void *)InitToOnes, ArraySz * sizeof(dbl));

        start = clock();
        for(i = LoopKnt; i; i--) {
            for(j = ArraySz; j; j--) {
                a[j] += b[j];
                c[j] += d[j];
            }
        }
        Cumulative_Combined += (clock()-start);
        printf("\n %6i miliseconds for combined array sizes %i and %i loops",
                (int)(clock()-start), ArraySz, LoopKnt);
        start = clock();
        for(i = LoopKnt; i; i--) {
            for(j = ArraySz; j; j--) {
                a[j] += b[j];
            }
            for(j = ArraySz; j; j--) {
                c[j] += d[j];
            }
        }
        Cumulative_Separate += (clock()-start);
        printf("\n %6i miliseconds for separate array sizes %i and %i loops \n",
                (int)(clock()-start), ArraySz, LoopKnt);
    }
    printf("\n Cumulative combined array processing took %10.3f seconds",
            (dbl)(Cumulative_Combined/(dbl)CLOCKS_PER_SEC));
    printf("\n Cumulative seperate array processing took %10.3f seconds",
        (dbl)(Cumulative_Separate/(dbl)CLOCKS_PER_SEC));
    getchar();

    free(a); free(b); free(c); free(d); free(InitToOnes);
    return 0;
}

Non sono sicuro del motivo per cui è stato deciso che MFLOPS era una metrica rilevante. Pensavo che l'idea fosse quella di concentrarsi sugli accessi alla memoria, quindi ho cercato di ridurre al minimo il tempo di calcolo in virgola mobile. Ho lasciato il +=, ma non sono sicuro del perché.

Un'assegnazione diretta senza calcolo sarebbe un test più pulito del tempo di accesso alla memoria e creerebbe un test uniforme indipendentemente dal conteggio dei loop. Forse ho perso qualcosa nella conversazione, ma vale la pena pensarci due volte. Se il plus viene lasciato fuori dal compito, il tempo cumulativo è quasi identico a 31 secondi ciascuno.


1
La penalità di disallineamento menzionata qui è quando un singolo carico / negozio è disallineato (incluso il carico / magazzini SSE non allineati). Ma questo non è il caso qui poiché le prestazioni sono sensibili ai relativi allineamenti dei diversi array. Non ci sono disallineamenti a livello di istruzione. Ogni singolo carico / magazzino è correttamente allineato.
Mistico

18

È perché la CPU non ha così tanti errori di cache (dove deve attendere che i dati dell'array provengano dai chip RAM). Sarebbe interessante per te regolare le dimensioni degli array continuamente in modo da superare le dimensioni della cache di livello 1 (L1), quindi la cache di livello 2 (L2), della tua CPU e tracciare il tempo impiegato per il tuo codice eseguire contro le dimensioni delle matrici. Il grafico non dovrebbe essere una linea retta come ti aspetteresti.


2
Non credo ci sia alcuna interazione tra la dimensione della cache e la dimensione dell'array. Ogni elemento dell'array viene utilizzato una sola volta e può quindi essere sfrattato in modo sicuro. Tuttavia, potrebbe esserci un'interazione tra la dimensione della riga della cache e la dimensione dell'array, se ciò causa un conflitto tra i quattro array.
Oliver Charlesworth,

15

Il primo ciclo alterna la scrittura in ciascuna variabile. Il secondo e il terzo fanno solo piccoli salti di dimensioni degli elementi.

Prova a scrivere due linee parallele di 20 croci con una penna e un foglio separati da 20 cm. Prova una volta a finire una e poi l'altra riga e prova un'altra volta scrivendo una croce in ogni riga alternativamente.


Le analogie con le attività del mondo reale sono piene di pericoli, quando si pensa a cose come le istruzioni della CPU. Quello che stai illustrando è effettivamente il tempo di ricerca , che si applicherebbe se stessimo parlando della lettura / scrittura dei dati memorizzati su un disco rotante, ma non c'è tempo di ricerca nella cache della CPU (o nella RAM o su un SSD). Gli accessi a regioni disgiunte di memoria non comportano penalità rispetto ad accessi adiacenti.
FeRD

7

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 Bossessere Summationche rappresenterà un For Loopche deve viaggiare tra lavoratori Ae 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, c1ed1 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 Sumda Case1dove F1()è contenuto Sumda Case1e in entrambi Sum1e Sum2da Case2. Ciò sarà evidente in seguito quando inizieremo a concludere che esiste un'ottimizzazione nel secondo algoritmo.

Le iterazioni attraverso il primo caso Sumchiamano 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 100000iterazione. Nel secondo caso, abbiamo Sum1e Sum2che entrambi agiscono come se fossero la stessa funzione chiamata due volte di seguito.

In questo caso possiamo trattare Sum1e Sum2come semplicemente vecchi Sumdove Sumin 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 Loopscome il Summationsche fa le iterazioni come una Bossche sta dando ordini a due persone Ae Be che i loro posti di lavoro sono a base di carne Ce 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 Bossnon proviene direttamente dagli algoritmi matematici effettivi, ma dal concetto reale Scopee Code Blockall'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 Bossva a Ae dà l'ordine e Ava a prendere il B'spacchetto, poi Bossva a Ce dà agli ordini di fare lo stesso e ricevere il pacchetto da Dogni iterazione.

Nel secondo caso, Bossfunziona direttamente con Ago and fetch B'spackage fino a quando non vengono ricevuti tutti i pacchetti. Quindi Bossfunziona con Clo stesso per ottenere tutti i D'spacchetti.

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 Ae che Aè a 500 piedi da C. Non dobbiamo preoccuparci di quanto Bosssia inizialmente distante a Ccausa dell'ordine delle esecuzioni. In entrambi i casi, Bossinizialmente viaggia dal Aprimo 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,Bossinizialmente deve percorrere 100 piedi per far scivolare l'ordineAe se neAva e fa le sue cose, ma poiBossdeve percorrere 500 piediCper dargli il suo ordine. Quindi alla successiva iterazione e ogni altra iterazione dopo laBossdeve andare avanti e indietro per 500 piedi tra i due.

Secondo caso: LaBossdeve viaggiare 100 piedi alla prima iterazione aA, ma dopo che, lui è già lì e solo aspettaAdi tornare fino a quando tutti gli slittamenti sono pieni. QuindiBossdeve percorrere 500 piedi sulla prima iterazioneCperchéCè a 500 piedi daA. Dal momento che questoBoss( Summation, For Loop )viene chiamato subito dopo aver lavorato conAlui, allora aspetta lì come ha fattoAfinoaquando 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'sparte o la responsabilità degli algoritmi e non tiene conto per i lavoratori attuali A, B, C, & De 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 ASMistruzioni 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 Ae Ctornare prima di poter tornare Anuovamente per ogni iterazione. Inoltre non tiene conto del fatto che se Ao Bsta impiegando un tempo estremamente lungo, sia l'uno Bossche l'altro lavoratore sono inattivi in ​​attesa di essere eseguiti.

Nel Caso 2 l'unico a rimanere inattivo è Bossfino 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 Codestesso 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, OSe Programmable Languagerispetto 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 Bossdue lavoratori Ae Bchi ha dovuto andare a recuperare i pacchetti da Ce Drispettivamente 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 Dataset è abbastanza piccolo, all'inizio potrebbe non sembrare una differenza. Tuttavia, poiché Case 1è più 60 - 70%lento di quanto Case 2possiamo 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 Bossdeve percorrere avanti e indietro la massima distanza tra Ae Cper ogni iterazione dopo la prima iterazione mentre l'algoritmo 2 Bossdeve viaggiare Auna volta e poi dopo aver terminato Adeve viaggiare una distanza massima solo una volta quando si passa da Aa C.

Cercare di Bossconcentrarsi 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 Stacke i Heap Allocatedcalcoli 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.


È passato un po 'di tempo da quando ho pubblicato questa risposta, ma volevo anche aggiungere un breve commento che potrebbe anche aiutare a capirlo: nella mia analogia con il Boss come il ciclo for o le sommazioni o iterazioni attraverso un loop, potremmo anche considera questo boss come la combinazione tra Stack Frame & Stack Pointer che gestisce le variabili scope e stack e l'indirizzamento della memoria dei loop for.
Francis Cugler,

@PeterMortensen Ho preso in considerazione il tuo consiglio modificando leggermente la mia risposta originale. Credo che questo sia quello che stavi suggerendo.
Francis Cugler,

2

Potrebbe essere vecchio C ++ e ottimizzazioni. Sul mio computer ho ottenuto quasi la stessa velocità:

Un loop: 1.577 ms

Due loop: 1.507 ms

Corro Visual Studio 2015 su un processore E5-1620 da 3,5 GHz con 16 GB di RAM.

Utilizzando il nostro sito, riconosci di aver letto e compreso le nostre Informativa sui cookie e Informativa sulla privacy.
Licensed under cc by-sa 3.0 with attribution required.