Perché l'ordine dei loop influenza le prestazioni durante l'iterazione su un array 2D?


360

Di seguito sono riportati due programmi quasi identici, tranne per il fatto che ho cambiato le variabili ie j. Entrambi funzionano in periodi di tempo diversi. Qualcuno potrebbe spiegare perché questo accade?

Versione 1

#include <stdio.h>
#include <stdlib.h>

main () {
  int i,j;
  static int x[4000][4000];
  for (i = 0; i < 4000; i++) {
    for (j = 0; j < 4000; j++) {
      x[j][i] = i + j; }
  }
}

Versione 2

#include <stdio.h>
#include <stdlib.h>

main () {
  int i,j;
  static int x[4000][4000];
  for (j = 0; j < 4000; j++) {
     for (i = 0; i < 4000; i++) {
       x[j][i] = i + j; }
   }
}


7
Puoi aggiungere alcuni risultati di benchmark?
niente101


14
@ naught101 I benchmark mostreranno una differenza di prestazioni tra 3 e 10 volte. Questo è C / C ++ di base, sono completamente perplesso su come questo abbia ottenuto così tanti voti ...
TC1

12
@ TC1: non penso sia così semplice; forse intermedio. Ma non dovrebbe sorprendere il fatto che le cose "di base" tendano ad essere utili a più persone, quindi ai molti voti. Inoltre, questa è una domanda difficile da google, anche se è "di base".
LarsH

Risposte:


595

Come altri hanno già detto, il problema è il negozio alla locazione di memoria nella matrice: x[i][j]. Ecco un po 'di informazioni sul perché:

Hai un array bidimensionale, ma la memoria nel computer è intrinsecamente monodimensionale. Quindi, mentre immagini il tuo array in questo modo:

0,0 | 0,1 | 0,2 | 0,3
----+-----+-----+----
1,0 | 1,1 | 1,2 | 1,3
----+-----+-----+----
2,0 | 2,1 | 2,2 | 2,3

Il computer lo memorizza in memoria come un'unica riga:

0,0 | 0,1 | 0,2 | 0,3 | 1,0 | 1,1 | 1,2 | 1,3 | 2,0 | 2,1 | 2,2 | 2,3

Nel secondo esempio, si accede alla matrice eseguendo prima il ciclo sul secondo numero, ovvero:

x[0][0] 
        x[0][1]
                x[0][2]
                        x[0][3]
                                x[1][0] etc...

Significa che li stai colpendo tutti in ordine. Ora guarda la prima versione. Stai facendo:

x[0][0]
                                x[1][0]
                                                                x[2][0]
        x[0][1]
                                        x[1][1] etc...

A causa del modo in cui C ha disposto l'array 2-d in memoria, gli stai chiedendo di saltare dappertutto. Ma ora per il calciatore: perché è importante? Tutti gli accessi alla memoria sono uguali, giusto?

No: a causa delle cache. I dati della tua memoria vengono trasferiti alla CPU in piccoli blocchi (chiamati "linee cache"), in genere 64 byte. Se hai numeri interi a 4 byte, ciò significa che otterrai 16 numeri interi consecutivi in ​​un piccolo pacchetto ordinato. In realtà è abbastanza lento recuperare questi blocchi di memoria; la tua CPU può fare molto lavoro nel tempo impiegato per il caricamento di una singola riga della cache.

Ora guarda indietro all'ordine degli accessi: il secondo esempio è (1) afferrare un pezzo di 16 pollici, (2) modificarli tutti, (3) ripetere 4000 * 4000/16 volte. È bello e veloce e la CPU ha sempre qualcosa su cui lavorare.

Il primo esempio è (1) afferrare un pezzo di 16 pollici, (2) modificarne solo uno, (3) ripetere 4000 * 4000 volte. Richiederà 16 volte il numero di "recuperi" dalla memoria. La tua CPU dovrà effettivamente passare il tempo in attesa che venga visualizzata quella memoria, e mentre è seduto in giro stai perdendo tempo prezioso.

Nota importante:

Ora che hai la risposta, ecco una nota interessante: non c'è motivo intrinseco che il tuo secondo esempio debba essere quello veloce. Ad esempio, in Fortran, il primo esempio sarebbe veloce e il secondo lento. Questo perché invece di espandere le cose in "file" concettuali come fa C, Fortran si espande in "colonne", ovvero:

0,0 | 1,0 | 2,0 | 0,1 | 1,1 | 2,1 | 0,2 | 1,2 | 2,2 | 0,3 | 1,3 | 2,3

Il layout di C si chiama 'row-major' e quello di Fortran si chiama 'column-major'. Come puoi vedere, è molto importante sapere se il tuo linguaggio di programmazione è row-major o column-major! Ecco un link per maggiori informazioni: http://en.wikipedia.org/wiki/Row-major_order


14
Questa è una risposta abbastanza approfondita; è ciò che mi è stato insegnato quando ho a che fare con problemi di cache e gestione della memoria.
Makoto,

7
Hai la "prima" e la "seconda" versione nel modo sbagliato; il primo esempio varia il primo indice nel ciclo interno e sarà l'esempio di esecuzione più lenta.
Caf

Bella risposta. Se Mark vuole saperne di più su una cosa così grintosa, consiglierei un libro come Write Great Code.
wkl,

8
Punti bonus per indicare che C ha cambiato l'ordine delle righe da Fortran. Per il calcolo scientifico la dimensione della cache L2 è tutto perché se tutti gli array si adattano a L2, il calcolo può essere completato senza andare nella memoria principale.
Michael Shopsin

4
@birryree: anche ciò che ogni programmatore dovrebbe sapere sulla memoria è una buona lettura.
Caf

68

Niente a che fare con il montaggio. Ciò è dovuto a mancati cache .

Le matrici multidimensionali C vengono archiviate con l'ultima dimensione come la più veloce. Quindi la prima versione mancherà la cache su ogni iterazione, mentre la seconda versione no. Quindi la seconda versione dovrebbe essere sostanzialmente più veloce.

Vedi anche: http://en.wikipedia.org/wiki/Loop_interchange .


23

La versione 2 funzionerà molto più velocemente perché utilizza la cache del tuo computer meglio della versione 1. Se ci pensi, le matrici sono solo aree contigue di memoria. Quando richiedi un elemento in un array, il tuo sistema operativo porterà probabilmente una pagina di memoria nella cache che contiene quell'elemento. Tuttavia, poiché i prossimi elementi si trovano anche su quella pagina (perché sono contigui), l'accesso successivo sarà già nella cache! Questo è ciò che sta facendo la versione 2 per accelerare.

La versione 1, invece, accede agli elementi colonna saggia e non riga saggia. Questo tipo di accesso non è contiguo a livello di memoria, quindi il programma non può sfruttare al massimo la cache del sistema operativo.


Con queste dimensioni di array, probabilmente è responsabile il gestore della cache nella CPU anziché nel sistema operativo.
krlmlr

12

Il motivo è l'accesso ai dati nella cache locale. Nel secondo programma esegui una scansione lineare della memoria che beneficia della memorizzazione nella cache e del prefetching. Il modello di utilizzo della memoria del tuo primo programma è molto più esteso e quindi ha un comportamento della cache peggiore.


11

Oltre alle altre eccellenti risposte sugli accessi alla cache, esiste anche una possibile differenza di ottimizzazione. È probabile che il tuo secondo loop sia ottimizzato dal compilatore in qualcosa di equivalente a:

  for (j=0; j<4000; j++) {
    int *p = x[j];
    for (i=0; i<4000; i++) {
      *p++ = i+j;
    }
  }

Questo è meno probabile per il primo ciclo, perché dovrebbe incrementare il puntatore "p" di 4000 ogni volta.

EDIT: p++ e persino *p++ = ..può essere compilato in una singola istruzione CPU nella maggior parte delle CPU. *p = ..; p += 4000impossibile, quindi ci sono meno benefici nell'ottimizzarlo. È anche più difficile, perché il compilatore deve conoscere e utilizzare le dimensioni dell'array interno. E non accade che spesso nel ciclo interno nel codice normale (si verifica solo per le matrici multidimensionali, in cui l'ultimo indice viene mantenuto costante nel ciclo e il secondo all'ultimo viene fatto un passo), quindi l'ottimizzazione è meno prioritaria .


Non capisco cosa 'perché dovrebbe saltare il puntatore "p" con 4000 ogni volta ".
Veedrac,

@Veedrac Il puntatore dovrebbe essere incrementato di 4000 all'interno del ciclo interno: p += 4000isop++
fishinear del

Perché il compilatore dovrebbe trovare questo problema? iè già incrementato di un valore non unitario, dato che è un incremento del puntatore.
Veedrac,

Ho aggiunto ulteriori spiegazioni
pesce nelle vicinanze del

Prova a digitare int *f(int *p) { *p++ = 10; return p; } int *g(int *p) { *p = 10; p += 4000; return p; }in gcc.godbolt.org . I due sembrano compilare sostanzialmente lo stesso.
Veedrac,

7

Questa linea è il colpevole:

x[j][i]=i+j;

La seconda versione utilizza la memoria continua, quindi sarà notevolmente più veloce.

Ci ho provato

x[50000][50000];

e il tempo di esecuzione è di 13 secondi per la versione 1 contro 0.6 per la versione 2.


4

Provo a dare una risposta generica.

Perché i[y][x]è una scorciatoia per *(i + y*array_width + x)in C (prova l'elegante int P[3]; 0[P] = 0xBEEF;).

Mentre ripeti y , si passa a pezzi di dimensioni array_width * sizeof(array_element). Se lo hai nel tuo ciclo interno, allora avrai array_width * array_heightiterazioni su quei blocchi.

Lanciando l'ordine, avrai solo array_height iterazioni di chunk e tra qualsiasi iterazione di chunk avrai array_widthsolo iterazioni sizeof(array_element).

Mentre su CPU x86 molto vecchie questo non contava molto, al giorno d'oggi l'x86 esegue molto il prefetching e la memorizzazione nella cache dei dati. Probabilmente produci molti errori di cache nel tuo ordine di iterazione più lento.

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.