Performance con Unaligned Loads unter Mingw und VC (SSE-Intrinsics)
-
Hallo!
Ich habe eine Frage zu einem Code mit SSE(2) Anweisungen, den ich geschrieben habe. Im Prinzip geht es mir darum, folgenden Code möglichst performant umzusetzen (der echte Code ist etwas komplizierter, aber vom Prinzip stimmt das so):result[i] = base[i-1] + base[i] + base[i+1];
Dabei bin ich auf die Idee gekommen, das ganze mit SSE-Intrinsics umzuschreiben (die Präzision muss hierbei double sein). Dabei habe ich sogar ca. Faktor 2 an Performance herausholen können. Zuerst habe ich die Blöcke, die ich addieren muss aber nicht 16-byte aligned sind, mit einem unaligned load geladen (Variante 1). In einem nächsten Schritt habe ich gesehen, dass ich eine __m128d variable wiederverweden kann und somit einen unaligned load sparen kann (Variante 2). Dann habe ich gelesen, dass unaligned loads sehr langsam sein sollen. Also habe ich das ganze so umgeschrieben, dass nur noch aligned loads vorkommen, die dann per shuffle richtig gesetzt werden (Variante 3). In einem letzten Schritt habe ich den Code dann einmal unrolled und dadurch konnte ich wieder einige Variablen wiederverweden und so loads/shuffle sparen (Variante 4).
Zusammengefasst: Operationen pro "Ergebnis"Variante 1: 1 aligned load, 2 unaligned loads Variante 2: 1 aligned load, 1 unaligned loads Variante 3: 3 aligned loads, 2 shuffle Variante 4: 1 aligned load, 1 shuffle
Hier erstmal der komplette Code:
#include <emmintrin.h> #include <iostream> #include <cstddef> #include <ctime> using namespace std; #define ALIGN_16 __declspec(align(16)) //je nach compiler auswählen //#define ALIGN_16 __attribute__((aligned(16))) const size_t N = 1000; //N ist Vielfaches von 4 const size_t M = 40000000; //Anzahl Wiederholungen für Zeitmesung int main() { double ALIGN_16 base[N] = {}; for(size_t i=0; i!=N; ++i) base[i]=static_cast<double>(i); //initialisiere base irgendwie //variante0 double var0[N] = {}; for(size_t i=2; i!=N-2; ++i) var0[i] = base[i-1] + base[i] + base[i+1]; //variante 1 (insg (N-4)/2 aligned loads, (N-4) unaligned loads double ALIGN_16 var1[N] = {}; clock_t start1 = clock(); for(size_t j=0; j!=M; ++j) for(size_t i=2; i!=N-2; i+=2) { __m128d m1 = _mm_loadu_pd(base+i-1); //lade "base[i-1]" (unaligned) __m128d m2 = _mm_loadu_pd(base+i+1); //lade "base[i+1]" (unaligned) __m128d m3 = _mm_load_pd(base+i); //lade "base[i]" ( aligned) m3 = _mm_add_pd(m3,m1); // base[i] + base[i-1] m3 = _mm_add_pd(m3,m2); // base[i] + base[i-1] + base[i+1] _mm_store_pd(var1+i, m3); } clock_t end1 = clock(); //variante 2 (insg. (N-4)/2 unaligned loads, (N-4)/2 aligned loads double ALIGN_16 var2[N] = {}; clock_t start2 = clock(); for(size_t j=0; j!=M; ++j) { __m128d m1 = _mm_loadu_pd(base+1); for(size_t i=2; i!=N-2; i+=4) //einmal "unrolled" { __m128d m2 = _mm_loadu_pd(base+i+1); __m128d m3 = _mm_load_pd(base+i); m3 = _mm_add_pd(m3,m1); //m1 enthält "base[i-1]" (das "base[i+1]" vom nächsten schritt) m3 = _mm_add_pd(m3,m2); //m2 enthält "base[i+1]" _mm_store_pd(var2+i, m3); m1 = _mm_loadu_pd(base+i+3); m3 = _mm_load_pd(base+i+2); m3 = _mm_add_pd(m3,m1); //m1 enthält "base[i+1]" m3 = _mm_add_pd(m3,m2); //m2 enthält "base[i-1]" (das "base[i+1]" vom vorherigen schritt) _mm_store_pd(var2+i+2, m3); } } clock_t end2 = clock(); //variante 3 (insg. (N-4)*3/2 aligned loads, (N-4) shuffles double ALIGN_16 var3[N] = {}; clock_t start3 = clock(); for(size_t j=0; j!=M; ++j) { for(size_t i=2; i!=N-2; i+=2) { __m128d m1 = _mm_load_pd(base+i-2); __m128d m3 = _mm_load_pd(base+i); __m128d m2 = _mm_load_pd(base+i+2); __m128d m4 = _mm_shuffle_pd(m1,m3, 0x1); __m128d m5 = _mm_shuffle_pd(m3,m2, 0x1); m3 = _mm_add_pd(m3,m4); // base[i] + base[i-1] m3 = _mm_add_pd(m3,m5); // base[i] + base[i-1] + base[i+1] _mm_store_pd(var3+i, m3); } } clock_t end3 = clock(); //variante 4 (insg. (N-4)/2 aligned loads, (N-4)/2 shuffles double ALIGN_16 var4[N] = {}; clock_t start4 = clock(); for(size_t j=0; j!=M; ++j) { __m128d prev = _mm_load_pd(base); __m128d m3 = _mm_load_pd(base+2); __m128d m1 = _mm_shuffle_pd(prev,m3, 0x1); //entspricht "base[i-1]" for(size_t i=2; i!=N-2; i+=4) //einmal "unrolled" { __m128d next = _mm_load_pd(base+i+2); __m128d m2 = _mm_shuffle_pd(m3,next, 0x1); //entspricht "base[i+1]" m3 = _mm_add_pd(m3,m1); m3 = _mm_add_pd(m3,m2); _mm_store_pd(var4+i, m3); //ab hier: next von oben ist hier m3 // m2 von oben entspricht hier m1 (base[i+1] -> base[i-1]) m3 = _mm_load_pd(base+i+4); //m3 wird später oben wiederverwendet m1 = _mm_shuffle_pd(next, m3, 0x1); m2 = _mm_add_pd(m2,next); //m1 wird später oben wiederverwendet m2 = _mm_add_pd(m2,m1); _mm_store_pd(var4+i+2, m2); } } clock_t end4 = clock(); cout<<"Variante 0: Kontrollwert: "<<var0[100]<<'\n'; cout<<"Variante 1: Kontrollwert: "<<var1[100]<<"\t zeit: "<<(end1-start1)/static_cast<double>(CLOCKS_PER_SEC)<<"s\n"; cout<<"Variante 2: Kontrollwert: "<<var2[100]<<"\t zeit: "<<(end2-start2)/static_cast<double>(CLOCKS_PER_SEC)<<"s\n"; cout<<"Variante 3: Kontrollwert: "<<var3[100]<<"\t zeit: "<<(end3-start3)/static_cast<double>(CLOCKS_PER_SEC)<<"s\n"; cout<<"Variante 4: Kontrollwert: "<<var4[100]<<"\t zeit: "<<(end4-start4)/static_cast<double>(CLOCKS_PER_SEC)<<"s\n"; }
Dabei habe ich folgende Werte gemessen (alles mit -O3 bzw -Ox)
Mingw Intel Compiler Microsoft Visual Studio 2010 (64 bit) (32bit) (64bit) 28.967 32.35 32.007 18.023 19.839 19.641 30.993 30.81 31.378 23.429 19.476 19.466
Der Intelcompiler und der Microsoftcompiler sind sich einig, dass Variante 3 etwas schneller ist als Variante 1. Beim Mingw64 ist Variante 1 aber schneller als Variante 3. Damit kann ich leben. Aber was mich überrascht hat:
Bei Mingw ist Variante 2 deutlich schneller als Variante 4. Beim Intel Compiler und bei VC Variante 4 aber immer etwas schneller als Variante 2.
Dabei verstehe ich 2 Dinge nicht:
Wenn unaligned loads so langsam sind, warum erzeugen dann Intel- und Microsoftcompiler fast gleich schnelle Codes für beide?
Und wenn unaligned loads so langsam sind, warum ist dann beim Mingw die Funktion mit den unaligned loads deutlich schneller als die mit shuffle?Hier sind nochmal die Assembler Outputs von Mingw und VC (leider schaffe ich es nicht wirklich, da Sinn reinzubringen....
Mingw64 Output: http://pastebin.com/nZ46a2JD
VC64 Output: http://pastebin.com/63mmW1PL (Da geht es glaub ich ab Zeile 1207 los)tl;dr:
Warum erzeugt der Mingw deutlich langsameren Code für shuffles als für unaligned codes? Warum sind diese beim Intel- und Microsoftcompiler gleich schnell? Sind unaligned loads vielleicht garnicht so gefährlich?
-
alle 3 warianten auf Windows getestet?
-
Ja, habe alles unter den gleichen Bedingungen gemessen (Windows 7 64 Bit, meine CPU ist ein i5 M 520 @ 2.40GHz). Ich habe nochmal die relevanten Assmebly Outputs für Variante 4 rauskopiert (zumindest glaube ich, dass es die relevanten sind):
Mingw64 (braucht ca. 23.5s) VC64 (brauch ca. 19.6s) .L4: $LL3@main: movapd 16(%rsi,%rdx,8), %xmm1 movapd xmm0, XMMWORD PTR base$[rsp+rcx+16] movapd %xmm0, %xmm3 addpd xmm2, xmm3 addpd %xmm2, %xmm0 movapd xmm1, xmm3 movapd %xmm1, %xmm2 movapd xmm3, XMMWORD PTR base$[rsp+rcx+32] shufpd $1, %xmm1, %xmm3 shufpd xmm1, xmm0, 1 addpd %xmm3, %xmm1 add rcx, 32 addpd %xmm3, %xmm0 addpd xmm2, xmm1 movapd %xmm0, (%rbx,%rdx,8) addpd xmm1, xmm0 addq $4, %rdx movapd XMMWORD PTR var4$[rsp+rcx-32], xmm2 cmpq $998, %rdx movapd xmm2, xmm0 movapd (%rsi,%rdx,8), %xmm0 shufpd xmm2, xmm3, 1 shufpd $1, %xmm0, %xmm2 addpd xmm1, xmm2 addpd %xmm2, %xmm1 movapd XMMWORD PTR var4$[rsp+rcx-16], xmm1 movapd %xmm1, -16(%rbx,%rdx,8) cmp rcx, 7984 jne .L4 jne SHORT $LL3@main
Sind das nicht (bis auf die Reihenfolge) die exakt gleichen Anweisungen? Warum läuft die vom VC erstellte exe bei mir ca. 20% schneller als die vom mingw erzeugte exe (und nur im Fall von Variante 4)?
-
mingw nutzt *8 bei der addressierung, vielleicht sind das weniger optimale instruktionen (vielleicht kostet die addressierung mehr oder die dekodierung).
mingw hat mehr register pressure z.b. wird in zeile 10 in xmm0 geschrieben und gleich in der naechsten wird das rausgeschrieben, zeile 14 wird xmm0 geladen und gleich danach im shuffle benutzt, waehrend VS den load weit an den anfang setzt.
zeile 12/13 ist auch eine unnoetige dependancy.sicherlich macht die CPU alles moegliche um dependancies aufzuloesen, aber instruktionen werden nunmal nacheinander abgearbeitet (es gibt kein 'vorausschauen') und wenn der load nunmal kurz vor dem shuffle kommt, danach ein store und dann wieder sofort ein load (in der naechsten iteration), kann die CPU die dependancy eventuel nicht aufloesen.
versuch mal mit __restrict den compilern einen hint zu geben, dass kein aliasing da ist zwischen input und output (base,var), vielleicht kann MinGW das dann besser.
-
Danke für deine Antwort! __restrict hat den generierten Code nicht verändert, aber jetzt weis ich wenigstens, woran der Unterschied liegt
Ich lese mich wohl mal weiter in Assembler ein, um die Outputs besser deuten zu können.
-
du koenntest den VS code testweise auch in den MinGW build einfuegen, als inline assembler. dann koenntest du sehen, ob das bessere reordering wirklich den unterschied ausmacht.