Warum wirkt sich die Reihenfolge der Schleifen auf die Leistung aus, wenn über ein 2D-Array iteriert wird?

360

Unten sind zwei Programme aufgeführt, die fast identisch sind, außer dass ich die Variablen iund umgeschaltet habe j. Sie laufen beide in unterschiedlicher Zeit. Könnte jemand erklären, warum dies passiert?

Version 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; }
  }
}

Version 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; }
   }
}
Kennzeichen
quelle
26
en.wikipedia.org/wiki/…
Brendan Long
7
Können Sie einige Benchmark-Ergebnisse hinzufügen?
naught101
3
Siehe auch
Thomas Padron-McCarthy
14
@ naught101 Die Benchmarks zeigen einen Leistungsunterschied zwischen 3 und 10 Mal. Dies ist einfaches C / C ++, ich bin völlig ratlos darüber, wie das so viele Stimmen bekommen hat ...
TC1
12
@ TC1: Ich denke nicht, dass es so einfach ist; vielleicht mittelschwer. Aber es sollte keine Überraschung sein, dass das "grundlegende" Zeug dazu neigt, mehr Menschen nützlich zu sein, daher die vielen positiven Stimmen. Darüber hinaus ist diese Frage schwer zu googeln, auch wenn sie "einfach" ist.
LarsH

Antworten:

595

Wie andere gesagt haben, ist das Problem das Speichern des Speicherorts im Array : x[i][j]. Hier ein kleiner Einblick, warum:

Sie haben ein zweidimensionales Array, aber der Speicher im Computer ist von Natur aus eindimensional. Während Sie sich Ihr Array so vorstellen:

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

Ihr Computer speichert es als einzelne Zeile im Speicher:

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

Im zweiten Beispiel greifen Sie auf das Array zu, indem Sie zuerst die zweite Nummer durchlaufen, dh:

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

Das bedeutet, dass Sie sie alle in der richtigen Reihenfolge treffen. Schauen Sie sich jetzt die 1. Version an. Sie gehen:

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

Aufgrund der Art und Weise, wie C das 2D-Array im Speicher angeordnet hat, bitten Sie es, überall hin zu springen. Aber jetzt zum Kicker: Warum ist das wichtig? Alle Speicherzugriffe sind gleich, oder?

Nein, wegen Caches. Daten aus Ihrem Speicher werden in kleinen Blöcken (als "Cache-Zeilen" bezeichnet), normalerweise 64 Byte, an die CPU übertragen. Wenn Sie 4-Byte-Ganzzahlen haben, bedeutet dies, dass Sie 16 aufeinanderfolgende Ganzzahlen in einem ordentlichen kleinen Bündel erhalten. Es ist eigentlich ziemlich langsam, diese Erinnerungsstücke abzurufen. Ihre CPU kann in der Zeit, die zum Laden einer einzelnen Cache-Zeile benötigt wird, viel Arbeit leisten.

Schauen Sie sich jetzt die Reihenfolge der Zugriffe noch einmal an: Das zweite Beispiel besteht darin, (1) einen Teil von 16 Zoll zu greifen, (2) alle zu ändern, (3) 4000 * 4000/16 Mal zu wiederholen. Das ist schön und schnell und die CPU hat immer etwas zu arbeiten.

Das erste Beispiel ist (1) einen Teil von 16 Zoll nehmen, (2) nur einen davon modifizieren, (3) 4000 * 4000 Mal wiederholen. Das erfordert die 16-fache Anzahl von "Abrufen" aus dem Speicher. Ihre CPU muss tatsächlich Zeit damit verbringen, herumzusitzen und darauf zu warten, dass dieser Speicher angezeigt wird, und während sie herumsteht, verschwenden Sie wertvolle Zeit.

Wichtige Notiz:

Nachdem Sie die Antwort erhalten haben, ist hier ein interessanter Hinweis: Es gibt keinen inhärenten Grund, warum Ihr zweites Beispiel das schnelle sein muss. In Fortran wäre beispielsweise das erste Beispiel schnell und das zweite langsam. Das liegt daran, dass Fortran die Dinge nicht wie C in konzeptionelle "Zeilen" erweitert, sondern in "Spalten", dh:

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

Das Layout von C heißt 'Zeilenmajor' und Fortrans heißt 'Spaltenmajor'. Wie Sie sehen, ist es sehr wichtig zu wissen, ob Ihre Programmiersprache Zeilen- oder Spaltenmajor ist! Hier ist ein Link für weitere Informationen: http://en.wikipedia.org/wiki/Row-major_order

Robert Martin
quelle
14
Dies ist eine ziemlich gründliche Antwort; Es ist das, was mir im Umgang mit Cache-Fehlern und Speicherverwaltung beigebracht wurde.
Makoto
7
Sie haben die "erste" und "zweite" Version falsch herum; Das erste Beispiel variiert den ersten Index in der inneren Schleife und ist das langsamere Ausführungsbeispiel.
Café
Gute Antwort. Wenn Mark mehr über solche Kleinigkeiten lesen möchte, würde ich ein Buch wie Write Great Code empfehlen.
wkl
8
Bonuspunkte für den Hinweis, dass C die Zeilenreihenfolge von Fortran geändert hat. Für das wissenschaftliche Rechnen ist die L2-Cache-Größe alles, denn wenn alle Ihre Arrays in L2 passen, kann die Berechnung abgeschlossen werden, ohne in den Hauptspeicher zu wechseln.
Michael Shopsin
4
@birryree: Das frei verfügbare Was jeder Programmierer über Speicher wissen sollte, ist auch eine gute Lektüre.
Café
68

Nichts mit Montage zu tun. Dies ist auf Cache-Fehler zurückzuführen .

C mehrdimensionale Arrays werden mit der letzten Dimension als der schnellsten gespeichert. Die erste Version wird also bei jeder Iteration den Cache verpassen, während die zweite Version dies nicht tut. Die zweite Version sollte also wesentlich schneller sein.

Siehe auch: http://en.wikipedia.org/wiki/Loop_interchange .

Oliver Charlesworth
quelle
23

Version 2 wird viel schneller ausgeführt, da der Cache Ihres Computers besser als Version 1 verwendet wird. Wenn Sie darüber nachdenken, sind Arrays nur zusammenhängende Speicherbereiche. Wenn Sie ein Element in einem Array anfordern, bringt Ihr Betriebssystem wahrscheinlich eine Speicherseite in den Cache, die dieses Element enthält. Da sich die nächsten Elemente jedoch auch auf dieser Seite befinden (weil sie zusammenhängend sind), befindet sich der nächste Zugriff bereits im Cache! Dies ist, was Version 2 tut, um die Geschwindigkeit zu erhöhen.

Version 1 hingegen greift spaltenweise und nicht zeilenweise auf Elemente zu. Diese Art des Zugriffs ist auf Speicherebene nicht zusammenhängend, sodass das Programm das Caching des Betriebssystems nicht so stark nutzen kann.

Oleksi
quelle
Bei diesen Arraygrößen ist hier wahrscheinlich eher der Cache-Manager in der CPU als im Betriebssystem verantwortlich.
krlmlr
12

Der Grund ist der cache-lokale Datenzugriff. Im zweiten Programm scannen Sie linear durch den Speicher, was vom Caching und Prefetching profitiert. Das Speichernutzungsmuster Ihres ersten Programms ist weitaus weiter verteilt und weist daher ein schlechteres Cache-Verhalten auf.

Codierer mit variabler Länge
quelle
11

Neben den anderen hervorragenden Antworten auf Cache-Treffer gibt es auch einen möglichen Optimierungsunterschied. Ihre zweite Schleife wird wahrscheinlich vom Compiler in etwas optimiert, das Folgendes entspricht:

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

Dies ist für die erste Schleife weniger wahrscheinlich, da der Zeiger "p" jedes Mal um 4000 erhöht werden müsste.

EDIT: p++ und *p++ = ..kann in den meisten CPUs sogar zu einem einzelnen CPU-Befehl kompiliert werden. *p = ..; p += 4000kann nicht, daher ist es weniger vorteilhaft, es zu optimieren. Es ist auch schwieriger, weil der Compiler die Größe des inneren Arrays kennen und verwenden muss. Und es kommt nicht so häufig in der inneren Schleife im normalen Code vor (es tritt nur bei mehrdimensionalen Arrays auf, bei denen der letzte Index in der Schleife konstant gehalten wird und der vorletzte Schritt schrittweise ausgeführt wird), sodass die Optimierung weniger Priorität hat .

fishinear
quelle
Ich verstehe nicht, was "weil es den Zeiger" p "jedes Mal mit 4000 springen müsste" bedeutet.
Veedrac
@Veedrac Der Zeiger müsste mit 4000 in der inneren Schleife inkrementiert werden: p += 4000isop++
fishinear
Warum sollte der Compiler das als Problem empfinden? iwird bereits um einen Nicht-Einheitswert erhöht, da es sich um ein Zeigerinkrement handelt.
Veedrac
Ich habe weitere Erklärungen hinzugefügt
fishinear
Versuchen Sie, int *f(int *p) { *p++ = 10; return p; } int *g(int *p) { *p = 10; p += 4000; return p; }in gcc.godbolt.org einzugeben . Die beiden scheinen im Grunde das gleiche zu kompilieren.
Veedrac
7

Diese Linie der Schuldige:

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

Die zweite Version verwendet kontinuierlichen Speicher und ist daher wesentlich schneller.

Ich habe es mit versucht

x[50000][50000];

und die Ausführungszeit beträgt 13 Sekunden für Version 1 gegenüber 0,6 Sekunden für Version 2.

Nicolas Modrzyk
quelle
4

Ich versuche eine generische Antwort zu geben.

Weil i[y][x]es eine Abkürzung für *(i + y*array_width + x)in C ist (probieren Sie das Noble aus int P[3]; 0[P] = 0xBEEF;).

Während Sie iterieren y, iterieren Sie über Größenblöcke array_width * sizeof(array_element). Wenn Sie das in Ihrer inneren Schleife haben, dann haben Siearray_width * array_height Iterationen über diese Blöcke haben.

Wenn Sie die Reihenfolge umdrehen, haben Sie nur array_heightChunk-Iterationen, und zwischen jeder Chunk-Iteration haben Sie nur array_widthIterationen von sizeof(array_element).

Während dies auf wirklich alten x86-CPUs nicht viel bedeutete, führt x86 heutzutage viel Prefetching und Caching von Daten durch. Sie erzeugen wahrscheinlich viele Cache-Fehler in Ihrer langsameren Iterationsreihenfolge.

Sebastian Mach
quelle