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

Lesezeit: 8 Minuten

Marks Benutzeravatar
Markieren

Unten sind zwei Programme, die fast identisch sind, außer dass ich das umgeschaltet habe i und j Variablen herum. Beide laufen in unterschiedlichen Zeiträumen. Könnte jemand erklären, warum das 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; }
   }
}

  • de.wikipedia.org/wiki/…

    – Brendan Lange

    30. März 2012 um 2:21 Uhr

  • Können Sie einige Benchmark-Ergebnisse hinzufügen?

    – nichts101

    30. März 2012 um 3:25 Uhr

  • Siehe auch: stackoverflow.com/questions/9888154/…

    – Thomas Padron-McCarthy

    30. März 2012 um 3:37 Uhr


  • @naught101 Die Benchmarks zeigen einen Leistungsunterschied zwischen dem 3- und 10-fachen. Das ist einfaches C/C++, ich bin völlig ratlos, wie das so viele Stimmen bekommen hat …

    – TC1

    30. März 2012 um 9:12 Uhr

  • @TC1: Ich glaube nicht, dass es so einfach ist; vielleicht intermediär. Aber es sollte keine Überraschung sein, dass die „einfachen“ Dinge tendenziell für mehr Menschen nützlich sind, daher die vielen Upvotes. Darüber hinaus ist dies eine Frage, die schwer zu googeln ist, selbst wenn sie “einfach” ist.

    – LarsH

    30. März 2012 um 18:50 Uhr

Benutzeravatar von Robert Martin
Robert Martin

Wie andere gesagt haben, ist das Problem das Speichern an der Speicherstelle im Array: x[i][j]. Hier ist 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 also 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 2. Beispiel greifen Sie auf das Array zu, indem Sie zuerst die 2. Zahl durchlaufen, dh:

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

Das bedeutet, dass Sie sie alle der Reihe nach treffen. Betrachten Sie nun die 1. Version. 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 2-D-Array im Speicher angelegt hat, bitten Sie es, überall hin zu springen. Aber jetzt zum Kicker: Warum ist das wichtig? Alle Speicherzugriffe sind gleich, richtig?

Nein: wegen Caches. Daten aus Ihrem Arbeitsspeicher werden in kleinen Blöcken (als „Cache-Zeilen“ bezeichnet), typischerweise 64 Bytes, zur CPU übertragen. Wenn Sie 4-Byte-Ganzzahlen haben, bedeutet das, dass Sie 16 aufeinanderfolgende Ganzzahlen in einem hübschen kleinen Bündel erhalten. Es ist eigentlich ziemlich langsam, diese Speicherblöcke abzurufen; Ihre CPU kann viel Arbeit in der Zeit erledigen, die zum Laden einer einzelnen Cache-Zeile benötigt wird.

Schauen Sie sich nun die Reihenfolge der Zugriffe an: Das zweite Beispiel ist (1) ein Stück von 16 Ints zu greifen, (2) alle zu modifizieren, (3) 4000 * 4000/16 Mal zu wiederholen. Das ist schön schnell und die CPU hat immer etwas zu tun.

Das erste Beispiel ist (1) einen Block von 16 Ints nehmen, (2) nur einen davon ändern, (3) 4000*4000 Mal wiederholen. Das wird die 16-fache Anzahl von “Fetches” aus dem Speicher erfordern. Ihre CPU muss tatsächlich Zeit damit verbringen, herumzusitzen und darauf zu warten, dass dieser Speicher auftaucht, und während sie herumsitzt, verschwenden Sie wertvolle Zeit.

Wichtiger Hinweis:

Nun, da Sie die Antwort haben, hier ein interessanter Hinweis: Es gibt keinen inhärenten Grund dafür, dass Ihr zweites Beispiel das schnelle sein muss. Beispielsweise wäre in Fortran das erste Beispiel schnell und das zweite langsam. Das liegt daran, dass Fortran, anstatt Dinge in konzeptionelle “Zeilen” zu erweitern, wie es C tut, in “Spalten” erweitert, 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 „row-major“ und das von Fortran „column-major“. Wie Sie sehen, ist es sehr wichtig zu wissen, ob Ihre Programmiersprache zeilen- oder spaltenorientiert ist! Hier ist ein Link für weitere Informationen: http://en.wikipedia.org/wiki/Row-major_order

  • Sie haben die “erste” und “zweite” Version falsch herum; Das erste Beispiel variiert die Erste index in der inneren Schleife und wird das langsamer ausgeführte Beispiel sein.

    – Café

    30. März 2012 um 5:39 Uhr

  • Gute Antwort. Wenn Mark mehr über solche Feinheiten lesen möchte, würde ich ein Buch wie „Write Great Code“ empfehlen.

    – Wo

    30. März 2012 um 13:59 Uhr

  • Bonuspunkte für den Hinweis, dass C die Zeilenreihenfolge von Fortran geändert hat. Für wissenschaftliches Rechnen ist die Größe des L2-Cache alles, denn wenn alle Ihre Arrays in L2 passen, kann die Berechnung abgeschlossen werden, ohne in den Hauptspeicher zu gehen.

    – Michael Shopsin

    30. März 2012 um 15:26 Uhr

  • @birryree: Die frei verfügbare Was jeder Programmierer über Speicher wissen sollte ist auch gut zu lesen.

    – Café

    30. März 2012 um 22:38 Uhr

  • Tolle Antwort, aber ich stelle mir das Array tatsächlich als 0,0 1,0 2,0 vor. Warum sagen Sie 0,0 1,0 2,0?

    – Koray Tugay

    14. Oktober 2013 um 19:07 Uhr

Nichts mit Montage zu tun. Das ist wegen Cache-Fehlschläge.

C Mehrdimensionale Arrays werden mit der letzten Dimension als der schnellsten gespeichert. Die erste Version wird also den Cache bei jeder Iteration 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.

Version 2 läuft viel schneller, da sie den Cache Ihres Computers besser nutzt als Version 1. 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 paar Elemente jedoch auch auf dieser Seite befinden (weil sie zusammenhängend sind), wird der nächste Zugriff bereits im Cache sein! Dies ist, was Version 2 tut, um seine 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 OS-Caching nicht so stark nutzen kann.

  • Bei diesen Array-Größen ist hier wahrscheinlich eher der Cache-Manager in der CPU als im OS verantwortlich.

    – krlmlr

    30. März 2012 um 8:59 Uhr

Der Grund ist der Cache-lokale Datenzugriff. Im zweiten Programm scannen Sie den Speicher linear, was von Caching und Prefetching profitiert. Das Speichernutzungsmuster Ihres ersten Programms ist viel weiter verbreitet und hat daher ein schlechteres Cache-Verhalten.

Benutzeravatar von fishinear
fischnah

Neben den anderen hervorragenden Antworten zu Cache-Treffern gibt es auch einen möglichen Optimierungsunterschied. Ihre zweite Schleife wird wahrscheinlich vom Compiler in etwas Äquivalentes optimiert:

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 sie den Zeiger “p” jedes Mal um 4000 erhöhen müsste.

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

  • Ich verstehe nicht, was “weil der Zeiger “p” jedes Mal mit 4000 springen müsste” bedeutet.

    – Veedrac

    6. März 2016 um 20:57 Uhr


  • @Veedrac Der Zeiger müsste innerhalb der inneren Schleife um 4000 erhöht werden: p += 4000 iso p++

    – fischnah

    7. März 2016 um 8:46 Uhr


  • Warum sollte der Compiler das als Problem empfinden? i bereits um einen Nicht-Einheitswert inkrementiert, da es sich um ein Zeigerinkrement handelt.

    – Veedrac

    7. März 2016 um 11:16 Uhr

  • Ich habe weitere Erklärungen hinzugefügt

    – fischnah

    7. März 2016 um 14:55 Uhr

  • Versuchen Sie es mit der Eingabe int *f(int *p) { *p++ = 10; return p; } int *g(int *p) { *p = 10; p += 4000; return p; } hinein gcc.godbolt.org. Die beiden scheinen im Grunde gleich zu kompilieren.

    – Veedrac

    7. März 2016 um 17:13 Uhr

Benutzeravatar von Nicolas Modrzyk
Nikolaus Modrzyk

Diese Zeile der Übeltäter:

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

Die zweite Version verwendet fortlaufenden Speicher und wird daher wesentlich schneller sein.

Ich habe es mit versucht

x[50000][50000];

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

  • Ich verstehe nicht, was “weil der Zeiger “p” jedes Mal mit 4000 springen müsste” bedeutet.

    – Veedrac

    6. März 2016 um 20:57 Uhr


  • @Veedrac Der Zeiger müsste innerhalb der inneren Schleife um 4000 erhöht werden: p += 4000 iso p++

    – fischnah

    7. März 2016 um 8:46 Uhr


  • Warum sollte der Compiler das als Problem empfinden? i bereits um einen Nicht-Einheitswert inkrementiert, da es sich um ein Zeigerinkrement handelt.

    – Veedrac

    7. März 2016 um 11:16 Uhr

  • Ich habe weitere Erklärungen hinzugefügt

    – fischnah

    7. März 2016 um 14:55 Uhr

  • Versuchen Sie es mit der Eingabe int *f(int *p) { *p++ = 10; return p; } int *g(int *p) { *p = 10; p += 4000; return p; } hinein gcc.godbolt.org. Die beiden scheinen im Grunde gleich zu kompilieren.

    – Veedrac

    7. März 2016 um 17:13 Uhr

Benutzeravatar von Sebastian Mach
Sebastian Mach

Ich versuche eine allgemeine Antwort zu geben.

Da i[y][x] ist eine Abkürzung für *(i + y*array_width + x) in C (probieren Sie die classy int P[3]; 0[P] = 0xBEEF;).

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

Wenn Sie die Bestellung umdrehen, haben Sie nur noch array_height Chunk-Iterationen und zwischen allen Chunk-Iterationen haben array_width Iterationen von nur sizeof(array_element).

Während dies auf wirklich alten x86-CPUs keine große Rolle spielte, übernehmen die heutigen x86er eine Menge Prefetching und Caching von Daten. Sie produzieren wahrscheinlich viele Cache-Fehlschläge in Ihrer langsameren Iterationsreihenfolge.

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

This website is using cookies to improve the user-friendliness. You agree by using the website further.

Privacy policy