Warum ist die Gruppensummierung bei sortierten Gruppen langsamer als bei unsortierten Gruppen?

27

Ich habe 2 Spalten mit durch Tabulatoren getrennten Ganzzahlen, von denen die erste eine zufällige Ganzzahl und die zweite eine Ganzzahl ist, die die Gruppe identifiziert, die von diesem Programm generiert werden kann. ( generate_groups.cc)

#include <cstdlib>
#include <iostream>
#include <ctime>

int main(int argc, char* argv[]) {
  int num_values = atoi(argv[1]);
  int num_groups = atoi(argv[2]);

  int group_size = num_values / num_groups;
  int group = -1;

  std::srand(42);

  for (int i = 0; i < num_values; ++i) {
    if (i % group_size == 0) {
      ++group;
    }
    std::cout << std::rand() << '\t' << group << '\n';
  }

  return 0;
}

Ich benutze dann ein zweites Programm ( sum_groups.cc), um die Summen pro Gruppe zu berechnen.

#include <iostream>
#include <chrono>
#include <vector>

// This is the function whose performance I am interested in
void grouped_sum(int* p_x, int *p_g, int n, int* p_out) {
  for (size_t i = 0; i < n; ++i) {
    p_out[p_g[i]] += p_x[i];
  }
}

int main() {
  std::vector<int> values;
  std::vector<int> groups;
  std::vector<int> sums;

  int n_groups = 0;

  // Read in the values and calculate the max number of groups
  while(std::cin) {
    int value, group;
    std::cin >> value >> group;
    values.push_back(value);
    groups.push_back(group);
    if (group > n_groups) {
      n_groups = group;
    }
  }
  sums.resize(n_groups);

  // Time grouped sums
  std::chrono::system_clock::time_point start = std::chrono::system_clock::now();
  for (int i = 0; i < 10; ++i) {
    grouped_sum(values.data(), groups.data(), values.size(), sums.data());
  }
  std::chrono::system_clock::time_point end = std::chrono::system_clock::now();

  std::cout << (end - start).count() << std::endl;

  return 0;
}

Wenn ich diese Programme dann auf einem Datensatz einer bestimmten Größe ausführe und dann die Reihenfolge der Zeilen desselben Datensatzes mische, berechnen die gemischten Daten die Summen ~ 2x oder schneller als die geordneten Daten.

g++ -O3 generate_groups.cc -o generate_groups
g++ -O3 sum_groups.cc -o sum_groups
generate_groups 1000000 100 > groups
shuf groups > groups2
sum_groups < groups
sum_groups < groups2
sum_groups < groups2
sum_groups < groups
20784
8854
8220
21006

Ich hätte erwartet, dass die Originaldaten, die nach Gruppen sortiert sind, eine bessere Datenlokalität haben und schneller sind, aber ich beobachte das entgegengesetzte Verhalten. Ich habe mich gefragt, ob jemand den Grund vermuten kann.

Jim
quelle
1
Ich weiß es nicht, aber Sie schreiben in Elemente außerhalb des Bereichs des Summenvektors - wenn Sie das Normale getan und Verweise auf Vektoren anstelle von Zeigern auf die Datenelemente übergeben und dann verwendet haben .at()oder einen Debug-Modus operator[], der Grenzen setzt Überprüfen Sie, was Sie sehen würden.
Shawn
Haben Sie überprüft, ob die Datei "groups2" alle Ihre Daten enthält und ob alle eingelesen und verarbeitet werden? Gibt es vielleicht irgendwo einen EOF-Charakter in der Mitte?
1201ProgramAlarm
2
Das Programm hat ein undefiniertes Verhalten, da Sie die Größe nie ändern sum. Anstatt dass sums.reserve(n_groups);Sie anrufen müssen sums.resize(n_groups);- das hat @Shawn angedeutet.
Eugene
1
Beachten Sie (siehe z. B. hier oder hier ), dass sich ein Paarvektor anstelle von zwei Vektoren (Werte und Gruppe) wie erwartet verhält.
Bob__
1
Sie haben die Daten nach den Werten sortiert, oder? Aber das sortiert dann auch die Gruppen, und das wirkt sich auf den Ausdruck aus p_out[p_g[i]] += p_x[i];. Möglicherweise weisen die Gruppen in der ursprünglichen verschlüsselten Reihenfolge tatsächlich eine gute Clusterbildung hinsichtlich des Zugriffs auf das p_outArray auf. Das Sortieren der Werte kann zu einem schlechten gruppenindizierten Zugriffsmuster führen p_out.
Kaz

Antworten:

33

Setup / mach es langsam

Erstens läuft das Programm ungefähr zur gleichen Zeit, unabhängig davon:

sumspeed$ time ./sum_groups < groups_shuffled 
11558358

real    0m0.705s
user    0m0.692s
sys 0m0.013s

sumspeed$ time ./sum_groups < groups_sorted
24986825

real    0m0.722s
user    0m0.711s
sys 0m0.012s

Die meiste Zeit wird in der Eingangsschleife verbracht. Aber da wir an der interessiert sind grouped_sum(), lassen Sie uns das ignorieren.

Wenn Sie die Benchmark-Schleife von 10 auf 1000 Iterationen ändern, wird grouped_sum()die Laufzeit dominiert:

sumspeed$ time ./sum_groups < groups_shuffled 
1131838420

real    0m1.828s
user    0m1.811s
sys 0m0.016s

sumspeed$ time ./sum_groups < groups_sorted
2494032110

real    0m3.189s
user    0m3.169s
sys 0m0.016s

perf diff

Jetzt können wir perfdie heißesten Stellen in unserem Programm finden.

sumspeed$ perf record ./sum_groups < groups_shuffled
1166805982
[ perf record: Woken up 1 times to write data ]
[kernel.kallsyms] with build id 3a2171019937a2070663f3b6419330223bd64e96 not found, continuing without symbols
Warning:
Processed 4636 samples and lost 6.95% samples!

[ perf record: Captured and wrote 0.176 MB perf.data (4314 samples) ]

sumspeed$ perf record ./sum_groups < groups_sorted
2571547832
[ perf record: Woken up 2 times to write data ]
[kernel.kallsyms] with build id 3a2171019937a2070663f3b6419330223bd64e96 not found, continuing without symbols
[ perf record: Captured and wrote 0.420 MB perf.data (10775 samples) ]

Und der Unterschied zwischen ihnen:

sumspeed$ perf diff
[...]
# Event 'cycles:uppp'
#
# Baseline  Delta Abs  Shared Object        Symbol                                                                  
# ........  .........  ...................  ........................................................................
#
    57.99%    +26.33%  sum_groups           [.] main
    12.10%     -7.41%  libc-2.23.so         [.] _IO_getc
     9.82%     -6.40%  libstdc++.so.6.0.21  [.] std::num_get<char, std::istreambuf_iterator<char, std::char_traits<c
     6.45%     -4.00%  libc-2.23.so         [.] _IO_ungetc
     2.40%     -1.32%  libc-2.23.so         [.] _IO_sputbackc
     1.65%     -1.21%  libstdc++.so.6.0.21  [.] 0x00000000000dc4a4
     1.57%     -1.20%  libc-2.23.so         [.] _IO_fflush
     1.71%     -1.07%  libstdc++.so.6.0.21  [.] std::istream::sentry::sentry
     1.22%     -0.77%  libstdc++.so.6.0.21  [.] std::istream::operator>>
     0.79%     -0.47%  libstdc++.so.6.0.21  [.] __gnu_cxx::stdio_sync_filebuf<char, std::char_traits<char> >::uflow
[...]

Mehr Zeit in main(), was sich wahrscheinlich grouped_sum()eingefügt hat. Großartig, vielen Dank, perf.

perf annotieren

Gibt es einen Unterschied, wo die Zeit drinnen verbracht wirdmain() ?

Gemischt:

sumspeed$ perf annotate -i perf.data.old
[...]
            // This is the function whose performance I am interested in
            void grouped_sum(int* p_x, int *p_g, int n, int* p_out) {
              for (size_t i = 0; i < n; ++i) {
       180:   xor    %eax,%eax
              test   %rdi,%rdi
             je     1a4
              nop
                p_out[p_g[i]] += p_x[i];
  6,88 190:   movslq (%r9,%rax,4),%rdx
 58,54        mov    (%r8,%rax,4),%esi
            #include <chrono>
            #include <vector>
       
            // This is the function whose performance I am interested in
            void grouped_sum(int* p_x, int *p_g, int n, int* p_out) {
              for (size_t i = 0; i < n; ++i) {
  3,86        add    $0x1,%rax
                p_out[p_g[i]] += p_x[i];
 29,61        add    %esi,(%rcx,%rdx,4)
[...]

Sortiert:

sumspeed$ perf annotate -i perf.data
[...]
            // This is the function whose performance I am interested in
            void grouped_sum(int* p_x, int *p_g, int n, int* p_out) {
              for (size_t i = 0; i < n; ++i) {
       180:   xor    %eax,%eax
              test   %rdi,%rdi
             je     1a4
              nop
                p_out[p_g[i]] += p_x[i];
  1,00 190:   movslq (%r9,%rax,4),%rdx
 55,12        mov    (%r8,%rax,4),%esi
            #include <chrono>
            #include <vector>
       
            // This is the function whose performance I am interested in
            void grouped_sum(int* p_x, int *p_g, int n, int* p_out) {
              for (size_t i = 0; i < n; ++i) {
  0,07        add    $0x1,%rax
                p_out[p_g[i]] += p_x[i];
 43,28        add    %esi,(%rcx,%rdx,4)
[...]

Nein, es dominieren die gleichen zwei Anweisungen. Sie dauern also in beiden Fällen lange, sind aber noch schlimmer, wenn die Daten sortiert werden.

perf stat

Okay. Wir sollten sie jedoch gleich oft ausführen, daher muss jede Anweisung aus irgendeinem Grund langsamer werden. Mal sehen, was perf statsagt.

sumspeed$ perf stat ./sum_groups < groups_shuffled 
1138880176

 Performance counter stats for './sum_groups':

       1826,232278      task-clock (msec)         #    0,999 CPUs utilized          
                72      context-switches          #    0,039 K/sec                  
                 1      cpu-migrations            #    0,001 K/sec                  
             4 076      page-faults               #    0,002 M/sec                  
     5 403 949 695      cycles                    #    2,959 GHz                    
       930 473 671      stalled-cycles-frontend   #   17,22% frontend cycles idle   
     9 827 685 690      instructions              #    1,82  insn per cycle         
                                                  #    0,09  stalled cycles per insn
     2 086 725 079      branches                  # 1142,639 M/sec                  
         2 069 655      branch-misses             #    0,10% of all branches        

       1,828334373 seconds time elapsed

sumspeed$ perf stat ./sum_groups < groups_sorted
2496546045

 Performance counter stats for './sum_groups':

       3186,100661      task-clock (msec)         #    1,000 CPUs utilized          
                 5      context-switches          #    0,002 K/sec                  
                 0      cpu-migrations            #    0,000 K/sec                  
             4 079      page-faults               #    0,001 M/sec                  
     9 424 565 623      cycles                    #    2,958 GHz                    
     4 955 937 177      stalled-cycles-frontend   #   52,59% frontend cycles idle   
     9 829 009 511      instructions              #    1,04  insn per cycle         
                                                  #    0,50  stalled cycles per insn
     2 086 942 109      branches                  #  655,014 M/sec                  
         2 078 204      branch-misses             #    0,10% of all branches        

       3,186768174 seconds time elapsed

Nur eines fällt auf: Stalled-Cycles-Frontend .

Okay, die Anweisungspipeline blockiert. Im Frontend. Was genau das bedeutet, variiert wahrscheinlich zwischen den Mikroarchitekturen.

Ich habe allerdings eine Vermutung. Wenn Sie großzügig sind, können Sie es sogar eine Hypothese nennen.

Hypothese

Durch Sortieren der Eingabe erhöhen Sie die Lokalität der Schreibvorgänge. In der Tat werden sie sehr lokal sein; Fast alle Ergänzungen, die Sie vornehmen, werden an dieselbe Stelle wie die vorherige geschrieben.

Das ist großartig für den Cache, aber nicht großartig für die Pipeline. Sie führen Datenabhängigkeiten ein und verhindern, dass die nächste Additionsanweisung fortgesetzt wird, bis die vorherige Addition abgeschlossen ist (oder das Ergebnis auf andere Weise für nachfolgende Anweisungen verfügbar gemacht hat ).

Das ist dein Problem.

Ich glaube.

Es reparieren

Mehrere Summenvektoren

Versuchen wir mal etwas. Was wäre, wenn wir mehrere Summenvektoren verwenden würden, bei jeder Addition zwischen ihnen wechseln und diese am Ende zusammenfassen würden? Es kostet uns ein wenig Lokalität, sollte aber die Datenabhängigkeiten beseitigen.

(Der Code ist nicht schön; verurteile mich nicht, Internet !!)

#include <iostream>
#include <chrono>
#include <vector>

#ifndef NSUMS
#define NSUMS (4) // must be power of 2 (for masking to work)
#endif

// This is the function whose performance I am interested in
void grouped_sum(int* p_x, int *p_g, int n, int** p_out) {
  for (size_t i = 0; i < n; ++i) {
    p_out[i & (NSUMS-1)][p_g[i]] += p_x[i];
  }
}

int main() {
  std::vector<int> values;
  std::vector<int> groups;
  std::vector<int> sums[NSUMS];

  int n_groups = 0;

  // Read in the values and calculate the max number of groups
  while(std::cin) {
    int value, group;
    std::cin >> value >> group;
    values.push_back(value);
    groups.push_back(group);
    if (group >= n_groups) {
      n_groups = group+1;
    }
  }
  for (int i=0; i<NSUMS; ++i) {
    sums[i].resize(n_groups);
  }

  // Time grouped sums
  std::chrono::system_clock::time_point start = std::chrono::system_clock::now();
  int* sumdata[NSUMS];
  for (int i = 0; i < NSUMS; ++i) {
    sumdata[i] = sums[i].data();
  }
  for (int i = 0; i < 1000; ++i) {
    grouped_sum(values.data(), groups.data(), values.size(), sumdata);
  }
  for (int i = 1; i < NSUMS; ++i) {
    for (int j = 0; j < n_groups; ++j) {
      sumdata[0][j] += sumdata[i][j];
    }
  }
  std::chrono::system_clock::time_point end = std::chrono::system_clock::now();

  std::cout << (end - start).count() << " with NSUMS=" << NSUMS << std::endl;

  return 0;
}

(Oh, und ich habe auch die Berechnung für n_groups korrigiert; sie war um eins verschoben.)

Ergebnisse

Nachdem ich mein Makefile so konfiguriert habe, dass -DNSUMS=...es dem Compiler ein Argument gibt, kann ich Folgendes tun:

sumspeed$ for n in 1 2 4 8 128; do make -s clean && make -s NSUMS=$n && (perf stat ./sum_groups < groups_shuffled && perf stat ./sum_groups < groups_sorted)  2>&1 | egrep '^[0-9]|frontend'; done
1134557008 with NSUMS=1
       924 611 882      stalled-cycles-frontend   #   17,13% frontend cycles idle   
2513696351 with NSUMS=1
     4 998 203 130      stalled-cycles-frontend   #   52,79% frontend cycles idle   
1116188582 with NSUMS=2
       899 339 154      stalled-cycles-frontend   #   16,83% frontend cycles idle   
1365673326 with NSUMS=2
     1 845 914 269      stalled-cycles-frontend   #   29,97% frontend cycles idle   
1127172852 with NSUMS=4
       902 964 410      stalled-cycles-frontend   #   16,79% frontend cycles idle   
1171849032 with NSUMS=4
     1 007 807 580      stalled-cycles-frontend   #   18,29% frontend cycles idle   
1118732934 with NSUMS=8
       881 371 176      stalled-cycles-frontend   #   16,46% frontend cycles idle   
1129842892 with NSUMS=8
       905 473 182      stalled-cycles-frontend   #   16,80% frontend cycles idle   
1497803734 with NSUMS=128
     1 982 652 954      stalled-cycles-frontend   #   30,63% frontend cycles idle   
1180742299 with NSUMS=128
     1 075 507 514      stalled-cycles-frontend   #   19,39% frontend cycles idle   

Die optimale Anzahl von Summenvektoren hängt wahrscheinlich von der Pipeline-Tiefe Ihrer CPU ab. Meine 7 Jahre alte Ultrabook-CPU kann die Pipeline wahrscheinlich mit weniger Vektoren maximieren, als eine neue schicke Desktop-CPU benötigen würde.

Mehr ist natürlich nicht unbedingt besser. Als ich mit 128 Summenvektoren verrückt wurde, litten wir mehr unter Cache-Fehlern - was sich daran zeigt, dass die gemischte Eingabe langsamer als sortiert wurde, wie Sie es ursprünglich erwartet hatten. Wir haben den Kreis geschlossen! :) :)

Summe pro Gruppe im Register

(Dies wurde in einer Bearbeitung hinzugefügt)

Agh, Nerd hat geschnippt ! Wenn Sie wissen, dass Ihre Eingabe sortiert wird und Sie nach noch mehr Leistung suchen, ist das folgende Umschreiben der Funktion (ohne zusätzliche Summenarrays) zumindest auf meinem Computer noch schneller.

// This is the function whose performance I am interested in
void grouped_sum(int* p_x, int *p_g, int n, int* p_out) {
  int i = n-1;
  while (i >= 0) {
    int g = p_g[i];
    int gsum = 0;
    do {
      gsum += p_x[i--];
    } while (i >= 0 && p_g[i] == g);
    p_out[g] += gsum;
  }
}

Der Trick in diesem Fall besteht darin, dass der Compiler die gsumVariable, die Summe der Gruppe, in einem Register speichern kann. Ich vermute (kann aber sehr falsch sein), dass dies schneller ist, da die Rückkopplungsschleife in der Pipeline hier kürzer sein kann und / oder weniger Speicherzugriffe. Ein guter Branchenprädiktor macht die zusätzliche Überprüfung der Gruppengleichheit billig.

Ergebnisse

Es ist schrecklich für gemischte Eingaben ...

sumspeed$ time ./sum_groups < groups_shuffled
2236354315

real    0m2.932s
user    0m2.923s
sys 0m0.009s

... ist aber rund 40% schneller als meine "viele Summen" -Lösung für sortierte Eingaben.

sumspeed$ time ./sum_groups < groups_sorted
809694018

real    0m1.501s
user    0m1.496s
sys 0m0.005s

Viele kleine Gruppen sind langsamer als einige große. Ob dies die schnellere Implementierung ist oder nicht, hängt also wirklich von Ihren Daten ab. Und wie immer auf Ihrem CPU-Modell.

Vektoren mit mehreren Summen, mit Versatz anstelle von Bitmaskierung

Sopel schlug vier abgewickelte Ergänzungen als Alternative zu meinem Bitmaskierungsansatz vor. Ich habe eine verallgemeinerte Version ihres Vorschlags implementiert, die unterschiedlich umgehen kann NSUMS. Ich zähle darauf, dass der Compiler die innere Schleife für uns abwickelt (was zumindest für ihn der Fall war NSUMS=4).

#include <iostream>
#include <chrono>
#include <vector>

#ifndef NSUMS
#define NSUMS (4) // must be power of 2 (for masking to work)
#endif

#ifndef INNER
#define INNER (0)
#endif
#if INNER
// This is the function whose performance I am interested in
void grouped_sum(int* p_x, int *p_g, int n, int** p_out) {
  size_t i = 0;
  int quadend = n & ~(NSUMS-1);
  for (; i < quadend; i += NSUMS) {
    for (int k=0; k<NSUMS; ++k) {
      p_out[k][p_g[i+k]] += p_x[i+k];
    }
  }
  for (; i < n; ++i) {
    p_out[0][p_g[i]] += p_x[i];
  }
}
#else
// This is the function whose performance I am interested in
void grouped_sum(int* p_x, int *p_g, int n, int** p_out) {
  for (size_t i = 0; i < n; ++i) {
    p_out[i & (NSUMS-1)][p_g[i]] += p_x[i];
  }
}
#endif


int main() {
  std::vector<int> values;
  std::vector<int> groups;
  std::vector<int> sums[NSUMS];

  int n_groups = 0;

  // Read in the values and calculate the max number of groups
  while(std::cin) {
    int value, group;
    std::cin >> value >> group;
    values.push_back(value);
    groups.push_back(group);
    if (group >= n_groups) {
      n_groups = group+1;
    }
  }
  for (int i=0; i<NSUMS; ++i) {
    sums[i].resize(n_groups);
  }

  // Time grouped sums
  std::chrono::system_clock::time_point start = std::chrono::system_clock::now();
  int* sumdata[NSUMS];
  for (int i = 0; i < NSUMS; ++i) {
    sumdata[i] = sums[i].data();
  }
  for (int i = 0; i < 1000; ++i) {
    grouped_sum(values.data(), groups.data(), values.size(), sumdata);
  }
  for (int i = 1; i < NSUMS; ++i) {
    for (int j = 0; j < n_groups; ++j) {
      sumdata[0][j] += sumdata[i][j];
    }
  }
  std::chrono::system_clock::time_point end = std::chrono::system_clock::now();

  std::cout << (end - start).count() << " with NSUMS=" << NSUMS << ", INNER=" << INNER << std::endl;

  return 0;
}

Ergebnisse

Zeit zu messen. Beachten Sie, dass ich, seit ich gestern in / tmp gearbeitet habe, nicht genau die gleichen Eingabedaten habe. Daher sind diese Ergebnisse nicht direkt mit den vorherigen vergleichbar (aber wahrscheinlich nahe genug).

sumspeed$ for n in 2 4 8 16; do for inner in 0 1; do make -s clean && make -s NSUMS=$n INNER=$inner && (perf stat ./sum_groups < groups_shuffled && perf stat ./sum_groups < groups_sorted)  2>&1 | egrep '^[0-9]|frontend'; done; done1130558787 with NSUMS=2, INNER=0
       915 158 411      stalled-cycles-frontend   #   16,96% frontend cycles idle   
1351420957 with NSUMS=2, INNER=0
     1 589 408 901      stalled-cycles-frontend   #   26,21% frontend cycles idle   
840071512 with NSUMS=2, INNER=1
     1 053 982 259      stalled-cycles-frontend   #   23,26% frontend cycles idle   
1391591981 with NSUMS=2, INNER=1
     2 830 348 854      stalled-cycles-frontend   #   45,35% frontend cycles idle   
1110302654 with NSUMS=4, INNER=0
       890 869 892      stalled-cycles-frontend   #   16,68% frontend cycles idle   
1145175062 with NSUMS=4, INNER=0
       948 879 882      stalled-cycles-frontend   #   17,40% frontend cycles idle   
822954895 with NSUMS=4, INNER=1
     1 253 110 503      stalled-cycles-frontend   #   28,01% frontend cycles idle   
929548505 with NSUMS=4, INNER=1
     1 422 753 793      stalled-cycles-frontend   #   30,32% frontend cycles idle   
1128735412 with NSUMS=8, INNER=0
       921 158 397      stalled-cycles-frontend   #   17,13% frontend cycles idle   
1120606464 with NSUMS=8, INNER=0
       891 960 711      stalled-cycles-frontend   #   16,59% frontend cycles idle   
800789776 with NSUMS=8, INNER=1
     1 204 516 303      stalled-cycles-frontend   #   27,25% frontend cycles idle   
805223528 with NSUMS=8, INNER=1
     1 222 383 317      stalled-cycles-frontend   #   27,52% frontend cycles idle   
1121644613 with NSUMS=16, INNER=0
       886 781 824      stalled-cycles-frontend   #   16,54% frontend cycles idle   
1108977946 with NSUMS=16, INNER=0
       860 600 975      stalled-cycles-frontend   #   16,13% frontend cycles idle   
911365998 with NSUMS=16, INNER=1
     1 494 671 476      stalled-cycles-frontend   #   31,54% frontend cycles idle   
898729229 with NSUMS=16, INNER=1
     1 474 745 548      stalled-cycles-frontend   #   31,24% frontend cycles idle   

Ja, die innere Schleife mit NSUMS=8ist die schnellste auf meinem Computer. Im Vergleich zu meinem "lokalen Gsum" -Ansatz hat es auch den zusätzlichen Vorteil, dass es für die gemischte Eingabe nicht schrecklich wird.

Interessant zu bemerken: NSUMS=16wird schlimmer als NSUMS=8. Dies kann daran liegen, dass immer mehr Cache-Fehler auftreten oder dass wir nicht über genügend Register verfügen, um die innere Schleife ordnungsgemäß abzuwickeln.

Snild Dolkow
quelle
5
Das hat Spaß gemacht. :)
Snild Dolkow
3
Es war fantastisch! Wusste nichts davon perf.
Tanveer Badar
1
Ich frage mich, ob bei Ihrem ersten Ansatz das manuelle Abrollen von 4x mit 4 verschiedenen Akkus zu einer besseren Leistung führen würde. So etwas wie godbolt.org/z/S-PhFm
Sopel
Danke für den Vorschlag. Ja, das hat die Leistung gesteigert, und ich habe es der Antwort hinzugefügt.
Snild Dolkow
Vielen Dank! Ich hatte gedacht, dass so etwas die Möglichkeit sein könnte, wusste aber nicht, wie ich es bestimmen sollte, danke für Ihre ausführliche Antwort!
Jim
3

Hier ist der Grund, warum sortierte Gruppen langsamer sind als nicht gespeicherte Gruppen.

Hier ist zunächst der Assembler-Code für die Summierungsschleife:

008512C3  mov         ecx,dword ptr [eax+ebx]
008512C6  lea         eax,[eax+4]
008512C9  lea         edx,[esi+ecx*4] // &sums[groups[i]]
008512CC  mov         ecx,dword ptr [eax-4] // values[i]
008512CF  add         dword ptr [edx],ecx // sums[groups[i]]+=values[i]
008512D1  sub         edi,1
008512D4  jne         main+163h (08512C3h)

Schauen wir uns die Add-Anweisung an, die der Hauptgrund für dieses Problem ist.

008512CF  add         dword ptr [edx],ecx // sums[groups[i]]+=values[i]

Wenn der Prozessor diesen Befehl zuerst ausführt, gibt er eine Speicherleseanforderung (Ladeanforderung) an die Adresse in edx aus, addiert dann den Wert von ecx und gibt dann eine Schreibanforderung (Speicheranforderung) für dieselbe Adresse aus.

Es gibt eine Funktion bei der Neuordnung des Prozessoraufrufspeichers

Um eine Leistungsoptimierung der Befehlsausführung zu ermöglichen, ermöglicht die IA-32-Architektur Abweichungen vom stark geordneten Modell, das als Prozessorreihenfolge bezeichnet wird, in Prozessoren der Pentium 4-, Intel Xeon- und P6-Familie. Diese Variationen der Prozessorreihenfolge (hier als Speicherordnungsmodell bezeichnet) ermöglichen leistungssteigernde Operationen, z. B. das Ermöglichen, dass Lesevorgänge gepufferten Schreibvorgängen vorausgehen. Das Ziel jeder dieser Variationen besteht darin, die Ausführungsgeschwindigkeit von Befehlen zu erhöhen und gleichzeitig die Speicherkohärenz auch in Systemen mit mehreren Prozessoren aufrechtzuerhalten.

und es gibt eine Regel

Lesevorgänge können mit älteren Schreibvorgängen an verschiedenen Speicherorten neu angeordnet werden, jedoch nicht mit älteren Schreibvorgängen an denselben Speicherort.

Wenn also die nächste Iteration die Add-Anweisung erreicht, bevor die Schreibanforderung abgeschlossen ist, wartet sie nicht, wenn sich die EDX-Adresse vom vorherigen Wert unterscheidet, und gibt die Leseanforderung aus. Sie wird über die ältere Schreibanforderung neu angeordnet, und die Add-Anweisung wird fortgesetzt. Wenn die Adresse jedoch dieselbe ist, wartet der Befehl add, bis der alte Schreibvorgang abgeschlossen ist.

Beachten Sie, dass die Schleife kurz ist und der Prozessor sie schneller ausführen kann, als der Speichercontroller die Anforderung zum Schreiben in den Speicher abgeschlossen hat.

Bei sortierten Gruppen lesen und schreiben Sie daher viele Male hintereinander von derselben Adresse, sodass die Leistungssteigerung durch Neuordnung des Speichers verloren geht. Wenn zufällige Gruppen verwendet werden, hat jede Iteration wahrscheinlich eine andere Adresse, sodass der Lesevorgang nicht auf älteres Schreiben wartet und vorbestellt wird. Die Anweisung zum Hinzufügen wartet nicht auf die vorherige Anweisung.

Ahmed Anter
quelle