Beantwortung einer anderen Frage zum Stapelüberlauf ( dieser ) bin ich auf ein interessantes Unterproblem gestoßen. Was ist der schnellste Weg, um ein Array von 6 ganzen Zahlen zu sortieren?
Da die Frage sehr niedrig ist:
- Wir können nicht davon ausgehen, dass Bibliotheken verfügbar sind (und der Aufruf selbst hat seine Kosten), nur einfaches C.
- Um zu vermeiden, dass die Anweisungspipeline geleert wird (was sehr hohe Kosten verursacht), sollten wir wahrscheinlich Verzweigungen, Sprünge und jede andere Art von Unterbrechung des Kontrollflusses minimieren (wie die, die hinter Sequenzpunkten in
&&
oder versteckt sind||
). - Der Platz ist begrenzt und die Minimierung der Register und der Speichernutzung ist ein Problem. Idealerweise ist die Sortierung an Ort und Stelle wahrscheinlich am besten.
Wirklich ist diese Frage eine Art Golf, bei dem das Ziel nicht darin besteht, die Quelllänge, sondern die Ausführungszeit zu minimieren. Ich nenne es 'Zening'-Code, wie er im Titel des Buches Zen of Code Optimization von Michael Abrash und seinen Fortsetzungen verwendet wird .
Warum es interessant ist, gibt es mehrere Schichten:
- Das Beispiel ist einfach und leicht zu verstehen und zu messen, es sind nicht viele C-Kenntnisse erforderlich
- Es zeigt die Auswirkungen der Wahl eines guten Algorithmus für das Problem, aber auch die Auswirkungen des Compilers und der zugrunde liegenden Hardware.
Hier ist meine Referenzimplementierung (naiv, nicht optimiert) und mein Testset.
#include <stdio.h>
static __inline__ int sort6(int * d){
char j, i, imin;
int tmp;
for (j = 0 ; j < 5 ; j++){
imin = j;
for (i = j + 1; i < 6 ; i++){
if (d[i] < d[imin]){
imin = i;
}
}
tmp = d[j];
d[j] = d[imin];
d[imin] = tmp;
}
}
static __inline__ unsigned long long rdtsc(void)
{
unsigned long long int x;
__asm__ volatile (".byte 0x0f, 0x31" : "=A" (x));
return x;
}
int main(int argc, char ** argv){
int i;
int d[6][5] = {
{1, 2, 3, 4, 5, 6},
{6, 5, 4, 3, 2, 1},
{100, 2, 300, 4, 500, 6},
{100, 2, 3, 4, 500, 6},
{1, 200, 3, 4, 5, 600},
{1, 1, 2, 1, 2, 1}
};
unsigned long long cycles = rdtsc();
for (i = 0; i < 6 ; i++){
sort6(d[i]);
/*
* printf("d%d : %d %d %d %d %d %d\n", i,
* d[i][0], d[i][6], d[i][7],
* d[i][8], d[i][9], d[i][10]);
*/
}
cycles = rdtsc() - cycles;
printf("Time is %d\n", (unsigned)cycles);
}
Rohergebnisse
Da die Anzahl der Varianten immer größer wird, habe ich sie alle in einer Testsuite zusammengefasst, die gefunden werden kann ist . Die tatsächlich verwendeten Tests sind dank Kevin Stock etwas weniger naiv als die oben gezeigten. Sie können es in Ihrer eigenen Umgebung kompilieren und ausführen. Das Verhalten auf verschiedenen Zielarchitekturen / Compilern interessiert mich sehr. (OK Leute, geben Sie es in Antworten, ich werde +1 jeden Mitwirkenden einer neuen Ergebnismenge).
Ich habe Daniel Stutzbach (zum Golfen) vor einem Jahr die Antwort gegeben, da er zu dieser Zeit die Quelle der schnellsten Lösung war (Sortieren von Netzwerken).
Linux 64 Bit, gcc 4.6.1 64 Bit, Intel Core 2 Duo E8400, -O2
- Direkter Aufruf der qsort-Bibliotheksfunktion: 689.38
- Naive Implementierung (Einfügesortierung): 285,70
- Einfügungssortierung (Daniel Stutzbach): 142.12
- Einfügungssortierung Abgerollt: 125,47
- Rangfolge: 102,26
- Rangfolge mit Registern: 58.03
- Sorting Networks (Daniel Stutzbach): 111,68
- Sortieren von Netzwerken (Paul R): 66,36
- Sortieren von Netzwerken 12 mit schnellem Austausch: 58,86
- Sorting Networks 12 reordered Swap: 53.74
- Sorting Networks 12 neu angeordnet Simple Swap: 31.54
- Neu geordnetes Sortiernetzwerk mit schnellem Austausch: 31.54
- Neu geordnetes Sortiernetzwerk mit schnellem Tausch V2: 33.63
- Inlined Bubble Sort (Paolo Bonzini): 48,85
- Abgerollte Einfügungssortierung (Paolo Bonzini): 75,30
Linux 64 Bit, gcc 4.6.1 64 Bit, Intel Core 2 Duo E8400, -O1
- Direkter Aufruf der qsort-Bibliotheksfunktion: 705.93
- Naive Implementierung (Einfügesortierung): 135,60
- Einfügungssortierung (Daniel Stutzbach): 142.11
- Einfügungssortierung Abgerollt: 126,75
- Rangfolge: 46,42
- Rangfolge mit Registern: 43,58
- Sorting Networks (Daniel Stutzbach): 115,57
- Sortieren von Netzwerken (Paul R): 64,44
- Sortieren von Netzwerken 12 mit schnellem Austausch: 61,98
- Sorting Networks 12 reordered Swap: 54.67
- Sorting Networks 12 neu angeordnet Simple Swap: 31.54
- Neu geordnetes Sortiernetzwerk mit schnellem Austausch: 31.24
- Neu geordnetes Sortiernetzwerk mit schnellem Tausch V2: 33.07
- Inlined Bubble Sort (Paolo Bonzini): 45,79
- Abgerollte Einfügungssortierung (Paolo Bonzini): 80,15
Ich habe sowohl -O1- als auch -O2-Ergebnisse eingeschlossen, da O2 überraschenderweise für mehrere Programme weniger effizient ist als O1. Ich frage mich, welche spezifische Optimierung diesen Effekt hat.
Kommentare zu Lösungsvorschlägen
Einfügungssortierung (Daniel Stutzbach)
Wie erwartet ist es in der Tat eine gute Idee, Zweige zu minimieren.
Sortieren von Netzwerken (Daniel Stutzbach)
Besser als Einfügungssortierung. Ich fragte mich, ob der Haupteffekt nicht darin bestand, die externe Schleife zu umgehen. Ich habe es durch Abrollen der Einfügungssortierung versucht, um zu überprüfen, und tatsächlich erhalten wir ungefähr die gleichen Zahlen (Code ist hier ).
Netzwerke sortieren (Paul R)
Das beste bis jetzt. Der eigentliche Code, den ich zum Testen verwendet habe, ist hier . Ich weiß noch nicht, warum es fast doppelt so schnell ist wie die andere Implementierung des Sortiernetzwerks. Parameterübergabe? Schnelles Maximum?
Sortieren von Netzwerken 12 SWAP mit Fast Swap
Wie von Daniel Stutzbach vorgeschlagen, habe ich sein 12-Swap-Sortiernetzwerk mit einem branchless Fast Swap kombiniert (Code ist hier ). Es ist in der Tat schneller, das bisher beste mit einer kleinen Marge (ungefähr 5%), wie es mit 1 Swap weniger zu erwarten war.
Es ist auch interessant festzustellen, dass der branchless Swap viel (viermal) weniger effizient zu sein scheint als der einfache Swap, der in einer PPC-Architektur verwendet wird.
Aufrufen der Bibliothek qsort
Um einen weiteren Bezugspunkt zu geben, habe ich auch versucht, einfach die Bibliothek qsort aufzurufen (Code ist hier ). Wie erwartet ist es viel langsamer: 10 bis 30 Mal langsamer ... wie sich bei der neuen Testsuite herausstellte, scheint das Hauptproblem das anfängliche Laden der Bibliothek nach dem ersten Aufruf zu sein, und es ist nicht so schlecht mit anderen zu vergleichen Ausführung. Unter meinem Linux ist es nur drei- bis zwanzigmal langsamer. Bei einigen Architekturen, die von anderen für Tests verwendet werden, scheint sie sogar schneller zu sein (ich bin wirklich überrascht, da die Bibliothek qsort eine komplexere API verwendet).
Rangordnung
Rex Kerr schlug eine andere völlig andere Methode vor: Berechnen Sie für jedes Element des Arrays direkt seine endgültige Position. Dies ist effizient, da für die Berechnung der Rangfolge keine Verzweigung erforderlich ist. Der Nachteil dieser Methode besteht darin, dass das Dreifache des Speichers des Arrays benötigt wird (eine Kopie des Arrays und der Variablen zum Speichern von Rangfolgen). Die Leistungsergebnisse sind sehr überraschend (und interessant). In meiner Referenzarchitektur mit 32-Bit-Betriebssystem und Intel Core2 Quad E8300 lag die Zykluszahl leicht unter 1000 (wie beim Sortieren von Netzwerken mit Verzweigungs-Swap). Beim Kompilieren und Ausführen auf meiner 64-Bit-Box (Intel Core2 Duo) lief es jedoch viel besser: Es wurde das bisher schnellste. Ich habe endlich den wahren Grund herausgefunden. Meine 32-Bit-Box verwendet gcc 4.4.1 und meine 64-Bit-Box gcc 4.4.
Update :
Wie die oben veröffentlichten Zahlen zeigen, wurde dieser Effekt durch spätere Versionen von gcc noch verstärkt, und die Rangfolge wurde durchweg doppelt so schnell wie bei jeder anderen Alternative.
Sortieren von Netzwerken 12 mit neu angeordnetem Swap
Die erstaunliche Effizienz des Rex Kerr-Vorschlags mit gcc 4.4.3 hat mich gefragt: Wie kann ein Programm mit dreimal so viel Speicherauslastung schneller sein als verzweigungslose Sortiernetzwerke? Meine Hypothese war, dass es weniger Abhängigkeiten von der Art hatte, die nach dem Schreiben gelesen wurde, was eine bessere Verwendung des superskalaren Befehlsplaners des x86 ermöglichte. Das brachte mich auf die Idee: Swaps neu anordnen, um Lese- und Schreibabhängigkeiten zu minimieren. Einfacher ausgedrückt: Wenn Sie dies tun SWAP(1, 2); SWAP(0, 2);
, müssen Sie warten, bis der erste Austausch abgeschlossen ist, bevor Sie den zweiten ausführen, da beide auf eine gemeinsame Speicherzelle zugreifen. Wenn Sie dies tun, kann SWAP(1, 2); SWAP(4, 5);
der Prozessor beide parallel ausführen. Ich habe es versucht und es funktioniert wie erwartet, die Sortiernetzwerke laufen etwa 10% schneller.
Sortieren von Netzwerken 12 mit Simple Swap
Ein Jahr nach dem ursprünglichen Beitrag schlug Steinar H. Gunderson vor, den Compiler nicht zu überlisten und den Swap-Code einfach zu halten. Es ist in der Tat eine gute Idee, da der resultierende Code etwa 40% schneller ist! Er schlug auch einen von Hand optimierten Austausch unter Verwendung des x86-Inline-Assembly-Codes vor, der noch einige Zyklen ersparen kann. Das Überraschendste (es heißt Bände über die Psychologie des Programmierers) ist, dass vor einem Jahr keiner der Verwendeten diese Version des Austauschs ausprobiert hat. Der Code, den ich zum Testen verwendet habe, ist hier . Andere schlugen andere Möglichkeiten vor, einen C-Fast-Swap zu schreiben, aber er liefert die gleichen Leistungen wie der einfache mit einem anständigen Compiler.
Der "beste" Code lautet jetzt wie folgt:
static inline void sort6_sorting_network_simple_swap(int * d){
#define min(x, y) (x<y?x:y)
#define max(x, y) (x<y?y:x)
#define SWAP(x,y) { const int a = min(d[x], d[y]); \
const int b = max(d[x], d[y]); \
d[x] = a; d[y] = b; }
SWAP(1, 2);
SWAP(4, 5);
SWAP(0, 2);
SWAP(3, 5);
SWAP(0, 1);
SWAP(3, 4);
SWAP(1, 4);
SWAP(0, 3);
SWAP(2, 5);
SWAP(1, 3);
SWAP(2, 4);
SWAP(2, 3);
#undef SWAP
#undef min
#undef max
}
Wenn wir glauben, dass unser Testsatz (und ja, es ist ziemlich schlecht, es ist nur ein Vorteil, kurz, einfach und leicht zu verstehen, was wir messen), liegt die durchschnittliche Anzahl von Zyklen des resultierenden Codes für eine Sorte unter 40 Zyklen ( 6 Tests werden ausgeführt). Damit lag jeder Swap bei durchschnittlich 4 Zyklen. Ich nenne das erstaunlich schnell. Weitere Verbesserungen möglich?
__asm__ volatile (".byte 0x0f, 0x31; shlq $32, %%rdx; orq %%rdx, %0" : "=a" (x) : : "rdx");
dass rdtsc die Antwort in EDX: EAX ablegt, während GCC sie in einem einzelnen 64-Bit-Register erwartet. Sie können den Fehler sehen, indem Sie bei -O3 kompilieren. Siehe auch unten meinen Kommentar zu Paul R über einen schnelleren SWAP.
CMP EAX, EBX; SBB EAX, EAX
setzt entweder 0 oder 0xFFFFFFFF ein, EAX
je nachdem, ob EAX
es größer oder kleiner als EBX
ist. SBB
ist "mit leihen subtrahieren", das Gegenstück zu ADC
("mit Carry addieren"); Das Statusbit, auf das Sie sich beziehen, ist das Übertragsbit. Andererseits erinnere ich mich daran ADC
und SBB
hatte eine schreckliche Latenz und einen schrecklichen Durchsatz auf dem Pentium 4 im Vergleich zu ADD
und SUB
und war auf Core-CPUs immer noch doppelt so langsam. Seit dem 80386 gibt es auch Anweisungen zum SETcc
bedingten Speichern und zum CMOVcc
bedingten Verschieben, aber sie sind auch langsam.
x-y
undx+y
kein Unterlauf oder Überlauf verursacht wird?