2014-07-15 33 views
16

Ho studiato l'uso delle nuove istruzioni di raccolta del set di istruzioni AVX2. Nello specifico, ho deciso di confrontare un semplice problema, in cui una matrice in virgola mobile è permutata e aggiunta a un'altra. In c, questo può essere implementato comeIn quale situazione le istruzioni AVX2 potrebbero essere più veloci del caricamento individuale dei dati?

void vectortest(double * a,double * b,unsigned int * ind,unsigned int N) 
{ 
    int i; 
    for(i=0;i<N;++i) 
    { 
     a[i]+=b[ind[i]]; 
    } 
} 

compilo questa funzione con g ++ -O3 -march = native. Ora, lo implemento in assemblaggio in tre modi. Per semplicità suppongo che la lunghezza degli array N sia divisibile per quattro. La semplice, non vettorizzati implementazione:

align 4 
global vectortest_asm 
vectortest_asm: 
     ;; double * a = rdi                                                         
     ;; double * b = rsi                                                         
     ;; unsigned int * ind = rdx                                                       
     ;; unsigned int N = rcx                                                        

     push rax 
     xor rax,rax 

loop: sub rcx, 1 
     mov eax, [rdx+rcx*4] ;eax = ind[rcx]                                                     
     vmovq xmm0, [rdi+rcx*8]   ;xmm0 = a[rcx]                                                   
     vaddsd xmm0, [rsi+rax*8]  ;xmm1 += b[rax] (and b[rax] = b[eax] = b[ind[rcx]])                                         
     vmovq [rdi+rcx*8], xmm0 
     cmp rcx, 0 
     jne loop 

     pop rax 

     ret 

Il ciclo Vectorised senza l'istruzione di raccogliere:

loop: sub rcx, 4 

     mov eax,[rdx+rcx*4] ;first load the values from array b to xmm1-xmm4 
     vmovq xmm1,[rsi+rax*8] 
     mov eax,[rdx+rcx*4+4] 
     vmovq xmm2,[rsi+rax*8] 

     mov eax,[rdx+rcx*4+8] 
     vmovq xmm3,[rsi+rax*8] 
     mov eax,[rdx+rcx*4+12] 
     vmovq xmm4,[rsi+rax*8] 

     vmovlhps xmm1,xmm2  ;now collect them all to ymm1 
     vmovlhps xmm3,xmm4 
     vinsertf128 ymm1,ymm1,xmm3,1 

     vaddpd ymm1, ymm1, [rdi+rcx*8] 
     vmovupd [rdi+rcx*8], ymm1 

     cmp rcx, 0 
     jne loop 

E, infine, un'implementazione con vgatherdpd:

loop: sub rcx, 4    
     vmovdqu xmm2,[rdx+4*rcx]   ;load the offsets from array ind to xmm2 
     vpcmpeqw ymm3,ymm3     ;set ymm3 to all ones, since it acts as the mask in vgatherdpd                                         
     vgatherdpd ymm1,[rsi+8*xmm2],ymm3 ;now gather the elements from array b to ymm1 

     vaddpd ymm1, ymm1, [rdi+rcx*8] 
     vmovupd [rdi+rcx*8], ymm1 

     cmp rcx, 0 
     jne loop 

I benchmark queste funzioni su una macchina con una CPU Haswell (Xeon E3-1245 v3). Alcuni risultati tipici sono (volte in secondi):

Array length 100, function called 100000000 times. 
Gcc version: 6.67439 
Nonvectorized assembly implementation: 6.64713 
Vectorized without gather: 4.88616 
Vectorized with gather: 9.32949 

Array length 1000, function called 10000000 times. 
Gcc version: 5.48479 
Nonvectorized assembly implementation: 5.56681 
Vectorized without gather: 4.70103 
Vectorized with gather: 8.94149 

Array length 10000, function called 1000000 times. 
Gcc version: 7.35433 
Nonvectorized assembly implementation: 7.66528 
Vectorized without gather: 7.92428 
Vectorized with gather: 8.873 

Il gcc e la versione assemblaggio nonvectorized sono molto vicini l'uno all'altro. (Ho anche controllato l'output di assembly di gcc, che è abbastanza simile alla mia versione codificata a mano.) La vettorizzazione offre alcuni vantaggi per i piccoli array, ma è più lenta per i grandi array. La grande sorpresa (almeno per me) è che la versione che utilizza vgatherpdp sia così lenta. Quindi, la mia domanda è, perché? Sto facendo qualcosa di stupido qui? Qualcuno può fornire un esempio in cui l'istruzione di raccolta potrebbe effettivamente fornire un vantaggio sulle prestazioni semplicemente eseguendo più operazioni di caricamento? In caso contrario, qual è il punto di avere effettivamente una tale istruzione?

Il codice di prova, completo di un makefile per g ++ e nasm, è disponibile al https://github.com/vanhala/vectortest.git nel caso qualcuno voglia provare questo.

+0

Bene, non sorprende che le funzioni codificate a mano siano più veloci, il compilatore C deve produrre * codice corretto *, dopotutto. I vostri loop non prevedono la lunghezza di array che non è un multiplo della dimensione di vettorizzazione e non controllano nemmeno se il conteggio fosse zero ... – EOF

+0

@EOF Sì, ma questo è accanto al punto. Il punto principale di questo benchmark era confrontare l'efficienza delle istruzioni di caricamento raccolte rispetto all'implementazione della stessa cosa usando i carichi scalari. La versione generata dal compilatore era lì solo per essere sicuro che i tempi fossero nel giusto ambito, cioè per controllare che non stia facendo nulla di completamente stupido nelle versioni codificate a mano. – infinitesimal

risposta

8

Sfortunatamente le istruzioni di caricamento raccolte non sono particolarmente "intelligenti" - sembrano generare un ciclo di bus per elemento, indipendentemente dagli indirizzi di carico, quindi anche se vi capita di avere elementi contigui apparentemente non esiste una logica interna per coalizzare il carichi. Quindi in termini di efficienza un carico raccolto non è migliore di N carichi scalari, tranne per il fatto che utilizza solo un'istruzione.

L'unico vero vantaggio delle istruzioni di raccolta è quando si sta implementando comunque il codice SIMD e si devono caricare dati non contigui a cui successivamente si applicheranno ulteriori operazioni SIMD. In tal caso, un'istruzione di caricamento raccolta da SIMD sarà molto più efficiente di una serie di codici scalari che tipicamente sarebbero generati da es. _mm256_set_xxx() (o un mucchio di carichi contigui e permuti, ecc., A seconda del modello di accesso effettivo).

+1

Non sono sicuro di aver capito il tuo ultimo punto.Nell'esempio sopra, carico i dati non contigui dall'array b, quindi applico alcune istruzioni SIMD a quei dati. In questo caso la sostituzione dell'istruzione di raccolta con un gruppo di movimenti scalari produce codice più veloce. Potete fornire un puntatore a un benchmark effettivo o un esempio in cui il carico raccolto non sarà più lento di più carichi scalari? O vuoi dire che è più semplice per il compilatore generare codice usando l'istruzione gather anche se è più lento? – infinitesimal

+0

E con "più lento" nell'ultima frase del commento sopra intendo "più lento di un codice di caricamento creato a mano usando istruzioni scalari". – infinitesimal

+0

Stavo parlando del caso generale in cui è possibile utilizzare ad es. '_mm256_set_xxx' per caricare N valori sparsi, rispetto all'utilizzo di un carico raccolto - in genere il compilatore genererà un sacco di codice scalare per fare il primo. Il tuo esempio è un po 'diverso in quanto hai codificato a mano alcuni assemblatori per un caso d'uso specifico. Dipende anche dal numero di elementi - Io lavoro principalmente con dati a 8 e 16 bit (ovviamente in AVX2) dove il problema dei carichi raccolti è un po 'maggiore rispetto agli elementi a 32/64 bit. –