Prestazioni di memoria / cache durante il lavoro con gli array in C

2

Ho giocato con alcuni esempi di array in C. Voglio capire di più su concetti di memoria, allineamento e cache. Soprattutto su grandi matrici su heap. A volte lavoro su immagini grandi (estremamente grandi), ecco perché è importante per me, ma non importa per quello che sto chiedendo ora.

Ho scritto un esempio seguente:

#include <stdio.h>
#include <stdlib.h>
#include <inttypes.h>
#include <GLFW/glfw3.h>

#define ARRAY_NUM 1000000 * 1000 // GIG
int main(int argc, char *argv[]) {

    if(!glfwInit()) {
        exit(EXIT_FAILURE);
    }

    uint64_t *array = malloc(sizeof(uint64_t) * ARRAY_NUM);

    double time_start = glfwGetTime();
    for(uint64_t i = 0; i < ARRAY_NUM; ++i) {
        array[i] = 0xff;
    }
    double time_end = glfwGetTime();

    free(array);
    glfwTerminate();

    double performance = ((ARRAY_NUM * sizeof(uint64_t))/1000000) / (time_end - time_start);
    printf("Done in %f\n", (time_end - time_start));
    printf("Performance: %f MB/s\n", performance);

    exit(EXIT_SUCCESS);
}

Con 1 concerto assegnato ricevo:

Done in 36.126213
Performance: 221.445854 MB/s

300 meg I get:

Done in 7.564931
Performance: 317.253391 MB/s

200 meg I get:

Done in 1.279391
Performance: 1250.594728 MB/s

100 meg I get:

Done in 0.355763
Performance: 2248.685313 MB/s

10 meg I get:

Done in 0.036575
Performance: 2187.315761 MB/s

e 1 meg ho:

Done in 0.004050
Performance: 1975.160383 MB/s

Naturalmente, i tempi variano tra il 5-10% tra le corse.

Ora, quello che mi chiedo qui è che cosa può e deve essere fatto per avere un throughput massimo costante che si possa ottenere per attraversare gli array sull'heap - specialmente quelli grandi. Qualcosa mi dice che anche i numeri più alti di 2+ GB / s non sono i numeri migliori qui, e quello che mi interessa è il risultato di 1 meg vs 10 e 100 - sembra che la cache si scaldi solo dopo un certo tempo.

Sto usando C99 (gcc5) e la mia comprensione è che con malloc e uint64_t dovrei avere già una memoria allineata. Se no, c'è forse un modo sicuro per farlo? Se questo è il colpevole del tutto, naturalmente. Forse sto colpendo male le linee della cache, o colpendo le pagine VM del sistema operativo che non le piace? Dove si potrebbe iniziare a ottimizzare l'accesso lineare all'interno di un array di grandi dimensioni?

dettagli:

  • L'ho fatto su un MacbookAir4,2 (I5-2557M) del 2011 con 4 GB di RAM e SSD (forse questo è il problema - una quantità bassa di RAM e malloc non sono in grado di ottenere il blocco in una volta sola? Devo testare questo sulla mia workstation quando ci arrivo)
  • gcc 5.2.0 (homebrew) con flag: -pedantic -std=c99 -Wall -Werror -Wextra -Wno-unused -O9 (yeah, niner ... Io uso 3 altrimenti, ma non si sa mai) con addizionali e flag di libreria e flag di framework per usare il timer glfw che tendo ad usare. Potrei averlo fatto senza, non importa.

Modifica

Dopo alcuni pensieri con cui Erik mi ha impiantato, ho fatto un altro test. Mostrando alcune delle cose relative alla pre-raccolta e, si spera, anche alla cache.

#include <stdio.h>
#include <stdlib.h>
#include <string.h> // memcpy
#include <inttypes.h>
#include <GLFW/glfw3.h>

#define ARRAY_NUM 1000000 * 125 // GIG
int main(int argc, char *argv[]) {

    double performance = 0.0;

    if(!glfwInit()) {
        exit(EXIT_FAILURE);
    }

    uint64_t *array = malloc(sizeof(uint64_t) * ARRAY_NUM);
    uint64_t *array_copy = malloc(sizeof(uint64_t) * ARRAY_NUM);

    double time_start = glfwGetTime();
    for(uint64_t i = 0; i < ARRAY_NUM; ++i) {
        array[i] = 0xff;
    }
    double time_end = glfwGetTime();

    performance = ((ARRAY_NUM * sizeof(uint64_t))/1000000) / (time_end - time_start);
    printf("Init done in %f - size of array: %lu MBs (x2)\n", (time_end - time_start), (ARRAY_NUM*sizeof(uint64_t)/1000000));
    printf("Performance: %f MB/s\n\n", performance);

    performance = 0;
    time_start = glfwGetTime();
    for(uint64_t i = 0; i < ARRAY_NUM; ++i) {
        array_copy[i] = array[i];
    }
    time_end = glfwGetTime();

    performance = ((ARRAY_NUM * sizeof(uint64_t))/1000000) / (time_end - time_start);
    printf("Copying (linear) done in %f\n", (time_end - time_start));
    printf("Performance: %f MB/s\n\n", performance);

    performance = 0;
    time_start = glfwGetTime();
    for(uint64_t i = 0; i < ARRAY_NUM; i=i+8) {
        array_copy[i] = array[i];
        if(i < (ARRAY_NUM-8)) {
            array_copy[i+1] = array[i+1];
            array_copy[i+2] = array[i+2];
            array_copy[i+3] = array[i+3];
            array_copy[i+4] = array[i+4];
            array_copy[i+5] = array[i+5];
            array_copy[i+6] = array[i+6];
            array_copy[i+7] = array[i+7];
        }
    }
    time_end = glfwGetTime();

    performance = ((ARRAY_NUM * sizeof(uint64_t))/1000000) / (time_end - time_start);
    printf("Copying (stride 8) done in %f\n", (time_end - time_start));
    printf("Performance: %f MB/s\n\n", performance);



    const int imax = 100;
    double performance_average = 0.0;
    for(int j = 0; j < imax; ++j) {
        uint64_t tmp = 0;
        performance = 0;
        time_start = glfwGetTime();
        for(uint64_t i = 0; i < ARRAY_NUM; i=i+8) {
            tmp = array[i];
            if(i < (ARRAY_NUM-8)) {
                tmp = array[i+1];
                tmp = array[i+2];
                tmp = array[i+3];
                tmp = array[i+4];
                tmp = array[i+5];
                tmp = array[i+6];
                tmp = array[i+7];
            }
        }
        time_end = glfwGetTime();

        performance = ((ARRAY_NUM * sizeof(uint64_t))/1000000) / (time_end - time_start);
        performance_average += performance;
        printf("[%d/%d] Performance stride 8: %f MB/s\r", j+1, imax, performance);
        fflush(stdout);
    }
    performance_average = performance_average / imax;
    printf("\nAverage: %f MB/s\n\n", performance_average);

    performance_average = 0.0;
    for(int j = 0; j < imax; ++j) {
        uint64_t tmp = 0;
        performance = 0;
        time_start = glfwGetTime();
        for(uint64_t i = 0; i < ARRAY_NUM; ++i) {
            tmp = array[i];
        }
        time_end = glfwGetTime();

        performance = ((ARRAY_NUM * sizeof(uint64_t))/1000000) / (time_end - time_start);
        performance_average += performance;
        printf("[%d/%d] Performance dumb: %f MB/s\r", j+1, imax, performance);
        fflush(stdout);
    }
    performance_average = performance_average / imax;
    printf("\nAverage: %f MB/s\n\n", performance_average);

    performance = 0;
    time_start = glfwGetTime();
    memcpy(array_copy, array, ARRAY_NUM*sizeof(uint64_t));
    time_end = glfwGetTime();

    performance = ((ARRAY_NUM * sizeof(uint64_t))/1000000) / (time_end - time_start);
    printf("Copying (memcpy) done in %f\n", (time_end - time_start));
    printf("Performance: %f MB/s\n", performance);

    free(array);
    free(array_copy);
    glfwTerminate();

    exit(EXIT_SUCCESS);
}

Se non vuoi usare il timer di glfw, sostituiscilo con il tuo. Lo compilo con -pedantic -std=c99 -Wall -Werror -Wextra -Wno-unused -O0 e -fprefetch-loop-arrays non sembra avere effetti, quindi l'ho lasciato fuori.

Ora posso vedere gli effetti del pre-recupero in gioco. Ecco un esempio di output:

Init done in 0.784799 - size of array: 1000 MBs (x2)
Performance: 1274.211087 MB/s

Copying (linear) done in 2.086404
Performance: 479.293545 MB/s

Copying (stride 8) done in 0.313592
Performance: 3188.856625 MB/s

[100/100] Performance stride 8: 6458.897164 MB/s
Average: 6393.163000 MB/s

[100/100] Performance dumb: 2597.816831 MB/s
Average: 2530.225830 MB/s

Copying (memcpy) done in 0.202581
Performance: 4936.303056 MB/s

Puoi vedere come la lettura di 8 valori per loop (valore arbitrario per ora) produce più del doppio della larghezza di banda! Ciò che mi ha sorpreso è che la copia era aumentata di +6 volte. Ho incluso memcpy come riferimento, che è ancora più veloce. Gioco interessante Mi piacerebbe sapere di più su cosa cercare e come affrontare la lettura / scrittura ottimale su array e, eventualmente, payloads non banali (ho scelto uint64_t in questo caso).

EDIT2:

con una falcata di 40 (non di più, non di meno - 320bytes) ho ottenuto letture di ~ 7800 MB / s. Che è più vicino al massimo di 10600 (1333 MHz DDR3).

    
posta Keyframe 12.11.2015 - 00:37
fonte

1 risposta

3

I tuoi test sono praticamente in loop direttamente nella memoria, quindi la cache funziona in modo approssimativo come segue:

Tocca un po 'di memoria non in una cache, che attiva un carico della linea cache dalla cache più vicina, L1, che passa attraverso le cache L2 e L3, fino a quando non viene raggiunta la memoria principale. Un chunk di dati della memoria principale di L3-cache-line viene recuperato per riempire l'L3. Quindi un chunk di dati di L3-cache-line di L3 viene recuperato per riempire la L2, quindi con L1 che riempie da L2.

Il tuo programma può ora riprendere l'elaborazione della posizione di memoria che ha provocato la mancanza. Le prossime iterazioni del ciclo procederanno con i dati dalla cache L1, poiché una dimensione della linea della cache è maggiore degli 8 byte che si toccano in ciascuna iterazione. Dopo un piccolo numero di iterazioni, viene raggiunta la fine della linea cache, e questo innesca un altro errore di cache. Può o meno tornare alla memoria principale di nuovo, a seconda che la dimensione della linea della cache L2 o L3 sia uguale o maggiore della dimensione della linea della cache L1.

Generalmente, il tuo ciclo beneficia della cache solo in base alle dimensioni della linea della cache, poiché l'hardware recupera più dati di quanti ne richiede l'iterazione.

Alcuni processori (Itanium) dispongono di un meccanismo che consente al software di informare l'hardware per tentare di eseguire in anticipo i carichi della linea cache. Questo può coprire parte del costo dei carichi di memoria principale.

Dato che il tuo loop non rivisita mai la memoria che tocca, non ottieni ulteriori benefici dalla cache. Se volessi sperimentare ulteriormente, potresti prendere in considerazione:

Scrivere un ciclo esterno che passi di una certa quantità di stepping fissa e alcuni loop interni che usano ripetutamente i dati nell'intervallo di stepping: Quindi, un loop annidato triplo con l'iterazione esterna della quantità di stepping fissa e il primo interno che fornisce una ripetizione semplice ( ad es. 100 iterazioni) e il ciclo più interno che accede all'intervallo dell'array secondo la quantità di step. In sostanza, accediamo a ciascuna posizione di memoria nell'array 100 volte, ma cambiamo l'ordine e il raggruppamento di come accediamo a tali posizioni, variando la dimensione di stepping in diverse esecuzioni. Questo dovrebbe mostrare sostanzialmente più variazioni (ordini di grandezza) tra quando le cache funzionano bene e non lo sono.

Con piccole dimensioni stepping le cache dovrebbero funzionare bene e le 100 iterazioni su ogni piccola gamma trarranno grande vantaggio dal riutilizzo della cache, e con grandi dimensioni di stepping, le prestazioni dovrebbero diminuire drasticamente, essere più vicini a ciò che stai osservando con la tua retta via attraverso la memoria.

(Tutto questo presuppone, ovviamente, che il compilatore non si pasticcia troppo con il tuo codice, a volte i benchmark troppo semplici vengono ottimizzati ...)

    
risposta data 12.11.2015 - 01:19
fonte

Leggi altre domande sui tag