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.


Anmelden zum Antworten