Stavo cercando il modo più veloce per popcount
grandi matrici di dati. Ho riscontrato un effetto molto strano : la modifica della variabile loop da unsigned
a ha uint64_t
fatto diminuire le prestazioni del 50% sul mio PC.
Il punto di riferimento
#include <iostream>
#include <chrono>
#include <x86intrin.h>
int main(int argc, char* argv[]) {
using namespace std;
if (argc != 2) {
cerr << "usage: array_size in MB" << endl;
return -1;
}
uint64_t size = atol(argv[1])<<20;
uint64_t* buffer = new uint64_t[size/8];
char* charbuffer = reinterpret_cast<char*>(buffer);
for (unsigned i=0; i<size; ++i)
charbuffer[i] = rand()%256;
uint64_t count,duration;
chrono::time_point<chrono::system_clock> startP,endP;
{
startP = chrono::system_clock::now();
count = 0;
for( unsigned k = 0; k < 10000; k++){
// Tight unrolled loop with unsigned
for (unsigned i=0; i<size/8; i+=4) {
count += _mm_popcnt_u64(buffer[i]);
count += _mm_popcnt_u64(buffer[i+1]);
count += _mm_popcnt_u64(buffer[i+2]);
count += _mm_popcnt_u64(buffer[i+3]);
}
}
endP = chrono::system_clock::now();
duration = chrono::duration_cast<std::chrono::nanoseconds>(endP-startP).count();
cout << "unsigned\t" << count << '\t' << (duration/1.0E9) << " sec \t"
<< (10000.0*size)/(duration) << " GB/s" << endl;
}
{
startP = chrono::system_clock::now();
count=0;
for( unsigned k = 0; k < 10000; k++){
// Tight unrolled loop with uint64_t
for (uint64_t i=0;i<size/8;i+=4) {
count += _mm_popcnt_u64(buffer[i]);
count += _mm_popcnt_u64(buffer[i+1]);
count += _mm_popcnt_u64(buffer[i+2]);
count += _mm_popcnt_u64(buffer[i+3]);
}
}
endP = chrono::system_clock::now();
duration = chrono::duration_cast<std::chrono::nanoseconds>(endP-startP).count();
cout << "uint64_t\t" << count << '\t' << (duration/1.0E9) << " sec \t"
<< (10000.0*size)/(duration) << " GB/s" << endl;
}
free(charbuffer);
}
Come vedi, creiamo un buffer di dati casuali, con la dimensione di x
megabyte dove x
viene letto dalla riga di comando. Successivamente, eseguiamo l'iterazione sul buffer e utilizziamo una versione non srotolata dell' popcount
intrinseco x86 per eseguire il popcount. Per ottenere un risultato più preciso, eseguiamo il popcount 10.000 volte. Misuriamo i tempi per il popcount. Nel maiuscolo, la variabile del loop interno è unsigned
, nel minuscolo, la variabile del loop interno èuint64_t
. Pensavo che ciò non dovesse fare alcuna differenza, ma è vero il contrario.
I risultati (assolutamente folli)
Lo compilo in questo modo (versione g ++: Ubuntu 4.8.2-19ubuntu1):
g++ -O3 -march=native -std=c++11 test.cpp -o test
Ecco i risultati sulla mia CPU Haswell Core i7-4770K a 3,50 GHz, in esecuzione test 1
(quindi 1 MB di dati casuali):
- non firmato 41959360000 0,401554 sec 26,113 GB / s
- uint64_t 41959360000 0,759822 sec 13,8003 GB / s
Come vedi, il throughput della uint64_t
versione è solo metà di quello della unsigned
versione! Il problema sembra essere che viene generato un assembly diverso, ma perché? Innanzitutto, ho pensato a un bug del compilatore, quindi ho provato clang++
(Ubuntu Clang versione 3.4-1ubuntu3):
clang++ -O3 -march=native -std=c++11 teest.cpp -o test
Risultato: test 1
- non firmato 41959360000 0,398293 sec 26,3267 GB / s
- uint64_t 41959360000 0,680954 sec 15,3986 GB / s
Quindi, è quasi lo stesso risultato ed è ancora strano. Ma ora diventa super strano. Sostituisco la dimensione del buffer che è stata letta dall'input con una costante 1
, quindi cambio:
uint64_t size = atol(argv[1]) << 20;
per
uint64_t size = 1 << 20;
Pertanto, il compilatore ora conosce la dimensione del buffer al momento della compilazione. Forse può aggiungere alcune ottimizzazioni! Ecco i numeri per g++
:
- non firmato 41959360000 0,509156 sec 20,5944 GB / s
- uint64_t 41959360000 0,508673 sec 20,6139 GB / s
Ora, entrambe le versioni sono ugualmente veloci. Tuttavia, è unsigned
diventato ancora più lento ! È sceso da 26
a 20 GB/s
, sostituendo così una non costante con un valore costante che porta a una deottimizzazione . Seriamente, non ho idea di cosa stia succedendo qui! Ma ora clang++
con la nuova versione:
- non firmato 41959360000 0,677009 sec 15,4884 GB / s
- uint64_t 41959360000 0,676909 sec 15,4906 GB / s
Aspetta cosa? Ora, entrambe le versioni sono scese al numero lento di 15 GB / s. Pertanto, la sostituzione di una non costante con un valore costante porta anche a un codice lento in entrambi casi per Clang!
Ho chiesto a un collega con una CPU Ivy Bridge di compilare il mio benchmark. Ha ottenuto risultati simili, quindi non sembra essere Haswell. Poiché due compilatori producono risultati strani qui, anche questo non sembra essere un bug del compilatore. Non abbiamo una CPU AMD qui, quindi abbiamo potuto testare solo con Intel.
Più follia, per favore!
Prendi il primo esempio (quello con atol(argv[1])
) e metti un static
prima della variabile, cioè:
static uint64_t size=atol(argv[1])<<20;
Ecco i miei risultati in g ++:
- non firmato 41959360000 0,396728 sec 26,4306 GB / s
- uint64_t 41959360000 0,509484 sec 20,5811 GB / s
Già, un'altra alternativa . Abbiamo ancora i 26 GB / s veloci con u32
, ma siamo riusciti a passare u64
almeno dai 13 GB / s alla versione da 20 GB / s! Sul PC del mio collega, la u64
versione è diventata ancora più veloce della u32
versione, ottenendo il risultato più veloce di tutti. Purtroppo, questo funziona solo per g++
, clang++
non sembra preoccuparsene static
.
La mia domanda
Puoi spiegare questi risultati? Particolarmente:
- Come può esserci una tale differenza tra
u32
eu64
? - In che modo la sostituzione di una non costante con una dimensione del buffer costante può innescare un codice meno ottimale ?
- Come può l'inserimento della
static
parola chiave rendere ilu64
ciclo più veloce? Ancora più veloce del codice originale sul computer del mio collega!
So che l'ottimizzazione è un territorio difficile, tuttavia, non ho mai pensato che cambiamenti così piccoli possano portare a una differenza del 100% nei tempi di esecuzione e che piccoli fattori come una dimensione del buffer costante possano mescolare nuovamente i risultati totalmente. Certo, voglio sempre avere la versione in grado di contare 26 GB / s. L'unico modo affidabile a cui riesco a pensare è copia incolla l'assemblaggio per questo caso e usa l'assemblaggio in linea. Questo è l'unico modo in cui posso liberarmi dei compilatori che sembrano impazzire per piccoli cambiamenti. Cosa ne pensi? Esiste un altro modo per ottenere il codice in modo affidabile con la maggior parte delle prestazioni?
Lo smontaggio
Ecco lo smontaggio per i vari risultati:
26 GB / s versione da g ++ / u32 / non const bufsize :
0x400af8:
lea 0x1(%rdx),%eax
popcnt (%rbx,%rax,8),%r9
lea 0x2(%rdx),%edi
popcnt (%rbx,%rcx,8),%rax
lea 0x3(%rdx),%esi
add %r9,%rax
popcnt (%rbx,%rdi,8),%rcx
add $0x4,%edx
add %rcx,%rax
popcnt (%rbx,%rsi,8),%rcx
add %rcx,%rax
mov %edx,%ecx
add %rax,%r14
cmp %rbp,%rcx
jb 0x400af8
13 GB / s versione da g ++ / u64 / non const bufsize :
0x400c00:
popcnt 0x8(%rbx,%rdx,8),%rcx
popcnt (%rbx,%rdx,8),%rax
add %rcx,%rax
popcnt 0x10(%rbx,%rdx,8),%rcx
add %rcx,%rax
popcnt 0x18(%rbx,%rdx,8),%rcx
add $0x4,%rdx
add %rcx,%rax
add %rax,%r12
cmp %rbp,%rdx
jb 0x400c00
15 GB / s versione da clang ++ / u64 / non const bufsize :
0x400e50:
popcnt (%r15,%rcx,8),%rdx
add %rbx,%rdx
popcnt 0x8(%r15,%rcx,8),%rsi
add %rdx,%rsi
popcnt 0x10(%r15,%rcx,8),%rdx
add %rsi,%rdx
popcnt 0x18(%r15,%rcx,8),%rbx
add %rdx,%rbx
add $0x4,%rcx
cmp %rbp,%rcx
jb 0x400e50
20 GB / s versione da g ++ / u32 & u64 / const bufsize :
0x400a68:
popcnt (%rbx,%rdx,1),%rax
popcnt 0x8(%rbx,%rdx,1),%rcx
add %rax,%rcx
popcnt 0x10(%rbx,%rdx,1),%rax
add %rax,%rcx
popcnt 0x18(%rbx,%rdx,1),%rsi
add $0x20,%rdx
add %rsi,%rcx
add %rcx,%rbp
cmp $0x100000,%rdx
jne 0x400a68
15 GB / s versione da clang ++ / u32 & u64 / const bufsize :
0x400dd0:
popcnt (%r14,%rcx,8),%rdx
add %rbx,%rdx
popcnt 0x8(%r14,%rcx,8),%rsi
add %rdx,%rsi
popcnt 0x10(%r14,%rcx,8),%rdx
add %rsi,%rdx
popcnt 0x18(%r14,%rcx,8),%rbx
add %rdx,%rbx
add $0x4,%rcx
cmp $0x20000,%rcx
jb 0x400dd0
È interessante notare che la versione più veloce (26 GB / s) è anche la più lunga! Sembra essere l'unica soluzione che utilizza lea
. Alcune versioni usano jb
per saltare, altre usano jne
. Ma a parte questo, tutte le versioni sembrano essere comparabili. Non vedo da dove possa originare un gap prestazionale del 100%, ma non sono troppo abile nel decifrare l'assemblaggio. La versione più lenta (13 GB / s) sembra anche molto breve e buona. Qualcuno può spiegare questo?
Lezioni imparate
Non importa quale sarà la risposta a questa domanda; Ho appreso che nei loop davvero hot ogni dettaglio può essere importante, anche i dettagli che non sembrano avere alcuna associazione con il hot code . Non ho mai pensato a quale tipo usare per una variabile loop, ma come vedi una modifica così piccola può fare la differenza del 100% ! Anche il tipo di archiviazione di un buffer può fare un'enorme differenza, come abbiamo visto con l'inserimento della static
parola chiave davanti alla variabile size! In futuro, testerò sempre varie alternative su vari compilatori quando scrivo loop molto stretti e caldi che sono cruciali per le prestazioni del sistema.
La cosa interessante è anche che la differenza di prestazioni è ancora così elevata anche se ho già srotolato il loop quattro volte. Quindi, anche se ti srotoli, puoi comunque essere colpito da importanti deviazioni delle prestazioni. Abbastanza interessante.