La sostituzione di un contatore di loop a 32 bit con 64 bit introduce deviazioni pazzesche delle prestazioni con _mm_popcnt_u64 su CPU Intel


1424

Stavo cercando il modo più veloce per popcountgrandi matrici di dati. Ho riscontrato un effetto molto strano : la modifica della variabile loop da unsigneda ha uint64_tfatto 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 xmegabyte dove xviene letto dalla riga di comando. Successivamente, eseguiamo l'iterazione sul buffer e utilizziamo una versione non srotolata dell' popcountintrinseco 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_tversione è solo metà di quello della unsignedversione! 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 26a 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 staticprima 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 u64almeno dai 13 GB / s alla versione da 20 GB / s! Sul PC del mio collega, la u64versione è diventata ancora più veloce della u32versione, 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 u32e u64?
  • 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 staticparola chiave rendere il u64ciclo 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 jbper 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 staticparola 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.


8
TANTI COMMENTI! Puoi visualizzarli in chat e persino lasciarne uno lì, se lo desideri, ma per favore non aggiungerne altri qui!
Shog9

3
Vedi anche GCC Numero 62011, Dipendenza da dati falsi nell'istruzione popcnt . Qualcun altro lo ha fornito, ma sembra che si sia perso durante le pulizie.
jww

Non posso dirlo ma è uno degli smontaggi per la versione con statico? In caso contrario, puoi modificare il post e aggiungerlo?
Kelly S. francese,

Risposte:


1552

Culprit: False Data Dependency (e il compilatore non ne è nemmeno a conoscenza)

Sui processori Sandy / Ivy Bridge e Haswell, le istruzioni:

popcnt  src, dest

sembra avere una falsa dipendenza dal registro di destinazione dest. Anche se l'istruzione vi scrive solo, l'istruzione attenderà fino a quando non destsarà pronta prima di essere eseguita. Questa falsa dipendenza è (ora) documentata da Intel come erratum HSD146 (Haswell) e SKL029 (Skylake)

Skylake ha risolto questo problema per lzcntetzcnt .
Cannon Lake (e Ice Lake) risolto questo problema popcnt.
bsf/ bsrhanno una vera dipendenza di output: output non modificato per input = 0. (Ma non c'è modo di sfruttarlo con intrinseci : solo AMD lo documenta e i compilatori non lo espongono.)

(Sì, tutte queste istruzioni vengono eseguite sulla stessa unità di esecuzione ).


Questa dipendenza non regge solo i 4 popcnts da una iterazione a loop singolo. Può trasportare attraverso iterazioni di loop rendendo impossibile per il processore parallelizzare diverse iterazioni di loop.

Le modifiche unsignedvs. uint64_te altre non influiscono direttamente sul problema. Ma influenzano l'allocatore di registro che assegna i registri alle variabili.

Nel tuo caso, le velocità sono il risultato diretto di ciò che è bloccato nella (falsa) catena di dipendenze a seconda di ciò che l'allocatore di registro ha deciso di fare.

  • 13 GB / s ha una catena: popcnt- add- popcnt- popcnt→ iterazione successiva
  • 15 GB / s ha una catena: popcnt- add- popcnt-add → iterazione successiva
  • 20 GB / s ha una catena: popcnt-popcnt → prossima iterazione
  • 26 GB / s ha una catena: popcnt- popcnt→ prossima iterazione

La differenza tra 20 GB / se 26 GB / s sembra essere un artefatto minore dell'indirizzamento indiretto. Ad ogni modo, il processore inizia a colpire altri colli di bottiglia una volta raggiunta questa velocità.


Per provare questo, ho usato l'assemblaggio in linea per bypassare il compilatore e ottenere esattamente l'assemblaggio che desidero. Ho anche diviso la countvariabile per interrompere tutte le altre dipendenze che potrebbero interferire con i benchmark.

Ecco i risultati:

Sandy Bridge Xeon @ 3,5 GHz: (il codice di prova completo si trova in fondo)

  • GCC 4.6.3: g++ popcnt.cpp -std=c++0x -O3 -save-temps -march=native
  • Ubuntu 12

Registri diversi: 18.6195 GB / s

.L4:
    movq    (%rbx,%rax,8), %r8
    movq    8(%rbx,%rax,8), %r9
    movq    16(%rbx,%rax,8), %r10
    movq    24(%rbx,%rax,8), %r11
    addq    $4, %rax

    popcnt %r8, %r8
    add    %r8, %rdx
    popcnt %r9, %r9
    add    %r9, %rcx
    popcnt %r10, %r10
    add    %r10, %rdi
    popcnt %r11, %r11
    add    %r11, %rsi

    cmpq    $131072, %rax
    jne .L4

Stesso registro: 8.49272 GB / s

.L9:
    movq    (%rbx,%rdx,8), %r9
    movq    8(%rbx,%rdx,8), %r10
    movq    16(%rbx,%rdx,8), %r11
    movq    24(%rbx,%rdx,8), %rbp
    addq    $4, %rdx

    # This time reuse "rax" for all the popcnts.
    popcnt %r9, %rax
    add    %rax, %rcx
    popcnt %r10, %rax
    add    %rax, %rsi
    popcnt %r11, %rax
    add    %rax, %r8
    popcnt %rbp, %rax
    add    %rax, %rdi

    cmpq    $131072, %rdx
    jne .L9

Stesso registro con catena spezzata: 17.8869 GB / s

.L14:
    movq    (%rbx,%rdx,8), %r9
    movq    8(%rbx,%rdx,8), %r10
    movq    16(%rbx,%rdx,8), %r11
    movq    24(%rbx,%rdx,8), %rbp
    addq    $4, %rdx

    # Reuse "rax" for all the popcnts.
    xor    %rax, %rax    # Break the cross-iteration dependency by zeroing "rax".
    popcnt %r9, %rax
    add    %rax, %rcx
    popcnt %r10, %rax
    add    %rax, %rsi
    popcnt %r11, %rax
    add    %rax, %r8
    popcnt %rbp, %rax
    add    %rax, %rdi

    cmpq    $131072, %rdx
    jne .L14

Cosa è andato storto nel compilatore?

Sembra che né GCC né Visual Studio lo sappiano popcnt una dipendenza così falsa. Tuttavia, queste false dipendenze non sono rare. È solo una questione di conoscenza del compilatore.

popcntnon è esattamente l'istruzione più utilizzata. Quindi non è davvero una sorpresa che un grande compilatore possa perdere qualcosa del genere. Sembra inoltre che non ci sia documentazione da nessuna parte che menzioni questo problema. Se Intel non lo rivela, nessuno al di fuori lo saprà fino a quando qualcuno non lo incontrerà per caso.

( Aggiornamento: dalla versione 4.9.2 , GCC è a conoscenza di questa falsa dipendenza e genera codice per compensarlo quando le ottimizzazioni sono abilitate. I principali compilatori di altri fornitori, tra cui Clang, MSVC e persino l'ICC di Intel non sono ancora a conoscenza di questo errore microarchitetturale e non emetterà codice che lo compensi.)

Perché la CPU ha una dipendenza così falsa?

Possiamo speculare: gira sulla stessa unità di esecuzione come bsf/ bsrche fare avere una dipendenza uscita. ( Come viene implementato POPCNT nell'hardware? ). Per queste istruzioni, Intel documenta il risultato intero per input = 0 come "non definito" (con ZF = 1), ma l'hardware Intel in realtà offre una garanzia più forte per evitare la rottura del vecchio software: output non modificato. AMD documenta questo comportamento.

Presumibilmente è stato in qualche modo scomodo rendere alcuni uops per questa unità di esecuzione dipendenti dall'output ma altri no.

I processori AMD non sembrano avere questa falsa dipendenza.


Il codice di prova completo è sotto per riferimento:

#include <iostream>
#include <chrono>
#include <x86intrin.h>

int main(int argc, char* argv[]) {

   using namespace std;
   uint64_t size=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;
   {
      uint64_t c0 = 0;
      uint64_t c1 = 0;
      uint64_t c2 = 0;
      uint64_t c3 = 0;
      startP = chrono::system_clock::now();
      for( unsigned k = 0; k < 10000; k++){
         for (uint64_t i=0;i<size/8;i+=4) {
            uint64_t r0 = buffer[i + 0];
            uint64_t r1 = buffer[i + 1];
            uint64_t r2 = buffer[i + 2];
            uint64_t r3 = buffer[i + 3];
            __asm__(
                "popcnt %4, %4  \n\t"
                "add %4, %0     \n\t"
                "popcnt %5, %5  \n\t"
                "add %5, %1     \n\t"
                "popcnt %6, %6  \n\t"
                "add %6, %2     \n\t"
                "popcnt %7, %7  \n\t"
                "add %7, %3     \n\t"
                : "+r" (c0), "+r" (c1), "+r" (c2), "+r" (c3)
                : "r"  (r0), "r"  (r1), "r"  (r2), "r"  (r3)
            );
         }
      }
      count = c0 + c1 + c2 + c3;
      endP = chrono::system_clock::now();
      duration=chrono::duration_cast<std::chrono::nanoseconds>(endP-startP).count();
      cout << "No Chain\t" << count << '\t' << (duration/1.0E9) << " sec \t"
            << (10000.0*size)/(duration) << " GB/s" << endl;
   }
   {
      uint64_t c0 = 0;
      uint64_t c1 = 0;
      uint64_t c2 = 0;
      uint64_t c3 = 0;
      startP = chrono::system_clock::now();
      for( unsigned k = 0; k < 10000; k++){
         for (uint64_t i=0;i<size/8;i+=4) {
            uint64_t r0 = buffer[i + 0];
            uint64_t r1 = buffer[i + 1];
            uint64_t r2 = buffer[i + 2];
            uint64_t r3 = buffer[i + 3];
            __asm__(
                "popcnt %4, %%rax   \n\t"
                "add %%rax, %0      \n\t"
                "popcnt %5, %%rax   \n\t"
                "add %%rax, %1      \n\t"
                "popcnt %6, %%rax   \n\t"
                "add %%rax, %2      \n\t"
                "popcnt %7, %%rax   \n\t"
                "add %%rax, %3      \n\t"
                : "+r" (c0), "+r" (c1), "+r" (c2), "+r" (c3)
                : "r"  (r0), "r"  (r1), "r"  (r2), "r"  (r3)
                : "rax"
            );
         }
      }
      count = c0 + c1 + c2 + c3;
      endP = chrono::system_clock::now();
      duration=chrono::duration_cast<std::chrono::nanoseconds>(endP-startP).count();
      cout << "Chain 4   \t"  << count << '\t' << (duration/1.0E9) << " sec \t"
            << (10000.0*size)/(duration) << " GB/s" << endl;
   }
   {
      uint64_t c0 = 0;
      uint64_t c1 = 0;
      uint64_t c2 = 0;
      uint64_t c3 = 0;
      startP = chrono::system_clock::now();
      for( unsigned k = 0; k < 10000; k++){
         for (uint64_t i=0;i<size/8;i+=4) {
            uint64_t r0 = buffer[i + 0];
            uint64_t r1 = buffer[i + 1];
            uint64_t r2 = buffer[i + 2];
            uint64_t r3 = buffer[i + 3];
            __asm__(
                "xor %%rax, %%rax   \n\t"   // <--- Break the chain.
                "popcnt %4, %%rax   \n\t"
                "add %%rax, %0      \n\t"
                "popcnt %5, %%rax   \n\t"
                "add %%rax, %1      \n\t"
                "popcnt %6, %%rax   \n\t"
                "add %%rax, %2      \n\t"
                "popcnt %7, %%rax   \n\t"
                "add %%rax, %3      \n\t"
                : "+r" (c0), "+r" (c1), "+r" (c2), "+r" (c3)
                : "r"  (r0), "r"  (r1), "r"  (r2), "r"  (r3)
                : "rax"
            );
         }
      }
      count = c0 + c1 + c2 + c3;
      endP = chrono::system_clock::now();
      duration=chrono::duration_cast<std::chrono::nanoseconds>(endP-startP).count();
      cout << "Broken Chain\t"  << count << '\t' << (duration/1.0E9) << " sec \t"
            << (10000.0*size)/(duration) << " GB/s" << endl;
   }

   free(charbuffer);
}

Un benchmark altrettanto interessante può essere trovato qui: http://pastebin.com/kbzgL8si
Questo benchmark varia il numero di popcnts che si trovano nella catena (falsa) delle dipendenze.

False Chain 0:  41959360000 0.57748 sec     18.1578 GB/s
False Chain 1:  41959360000 0.585398 sec    17.9122 GB/s
False Chain 2:  41959360000 0.645483 sec    16.2448 GB/s
False Chain 3:  41959360000 0.929718 sec    11.2784 GB/s
False Chain 4:  41959360000 1.23572 sec     8.48557 GB/s

3
Ciao gente! Molti commenti precedenti qui; prima di lasciarne uno nuovo, si prega di rivedere l'archivio .
Shog9

1
@ JustinL.it sembra che questo particolare problema sia stato risolto a Clang dal 7.0
Dan M.

@PeterCordes Non penso che sia l'unità di esecuzione tanto quanto lo scheduler. È lo scheduler che tiene traccia delle dipendenze. A tale scopo, le istruzioni sono raggruppate in una serie di "classi di istruzioni", ognuna delle quali viene trattata in modo identico dallo scheduler. Pertanto tutte le istruzioni "slow-int" a 3 cicli sono state gettate nella stessa "classe" ai fini della pianificazione delle istruzioni.
Mistico il

@Mysticial: lo pensi ancora adesso? È plausibile, ma imul dst, src, immnon ha una dipendenza dall'output, e nemmeno rallenta lea. Nemmeno pdep, ma questo è VEX codificato con 2 operandi di input. D'accordo, non è l'unità di esecuzione stessa che causa il falso dep; dipende dalla RAT ed emette / rinomina la fase mentre rinomina gli operandi del registro di architettura in registri fisici. Presumibilmente ha bisogno di una tabella di uop-code -> modello di dipendenza e scelte di porte, e raggruppare tutti gli uops per la stessa unità di esecuzione semplifica quella tabella. Questo è ciò che intendevo in modo più dettagliato.
Peter Cordes,

Fammi sapere se vuoi che lo modifichi nella tua risposta, o se vuoi rimetterlo a dire qualcosa di simile a quello che hai detto in origine sullo scheduler. Il fatto che SKL abbia eliminato il falso dep per lzcnt / tzcnt ma non popcnt dovrebbe dirci qualcosa, ma IDK cosa. Un altro possibile segno che è correlato a rinomina / RAT è che SKL annulla una modalità di indirizzamento indicizzato come fonte di memoria per lzcnt / tzcnt ma non popcnt. Ovviamente l'unità di rinomina deve creare uops che il back-end può rappresentare, comunque.
Peter Cordes,

50

Ho codificato un programma C equivalente per sperimentare e posso confermare questo strano comportamento. Inoltre, gccritiene che l'intero a 64 bit (che probabilmente dovrebbe essere size_tcomunque un ...) sia migliore, poiché l'utilizzo uint_fast32_tfa sì che gcc usi un uint a 64 bit.

Ho fatto un po 'di confusione con l'assemblaggio:
prendi semplicemente la versione a 32 bit, sostituisci tutte le istruzioni / i registri a 32 bit con la versione a 64 bit nel loop popcount interno del programma. Osservazione: il codice è veloce quanto la versione a 32 bit!

Questo è ovviamente un trucco, poiché la dimensione della variabile non è in realtà a 64 bit, poiché altre parti del programma usano ancora la versione a 32 bit, ma fintanto che il popcount-loop interno domina le prestazioni, questo è un buon inizio .

Ho quindi copiato il codice del ciclo interno dalla versione a 32 bit del programma, l'ha hackerato a 64 bit, armeggiato con i registri per renderlo un rimpiazzo per il ciclo interno della versione a 64 bit. Questo codice funziona anche alla velocità della versione a 32 bit.

La mia conclusione è che questa è una cattiva pianificazione delle istruzioni da parte del compilatore, non un vantaggio di velocità / latenza effettivo delle istruzioni a 32 bit.

(Avvertenza: ho hackerato il montaggio, avrei potuto rompere qualcosa senza accorgermene. Non credo.)


1
"Inoltre, gcc ritiene che l'intero a 64 bit [...] sia migliore, poiché l'utilizzo di uint_fast32_t fa sì che gcc utilizzi un uint a 64 bit." Sfortunatamente, e con mio rammarico, non c'è magia e nessuna profonda introspezione di codice dietro questi tipi. Devo ancora vederli forniti in altro modo che come singoli typedef per ogni luogo possibile e ogni programma su tutta la piattaforma. Probabilmente ci sono stati alcuni pensieri dietro l'esatta scelta dei tipi, ma l'unica definizione per ognuno di essi non può adattarsi ad ogni applicazione che ci sarà mai. Qualche ulteriore lettura: stackoverflow.com/q/4116297 .
Keno,

2
@Keno Questo perché sizeof(uint_fast32_t)deve essere definito. Se permetti che non lo sia, puoi fare quel trucco, ma ciò può essere realizzato solo con un'estensione del compilatore.
wizzwizz4

25

Questa non è una risposta, ma è difficile da leggere se inserisco risultati nei commenti.

Ottengo questi risultati con un Mac Pro ( Westmere 6-Cores Xeon 3.33 GHz). L'ho compilato con clang -O3 -msse4 -lstdc++ a.cpp -o a(-O2 ottiene lo stesso risultato).

clang con uint64_t size=atol(argv[1])<<20;

unsigned    41950110000 0.811198 sec    12.9263 GB/s
uint64_t    41950110000 0.622884 sec    16.8342 GB/s

clang con uint64_t size=1<<20;

unsigned    41950110000 0.623406 sec    16.8201 GB/s
uint64_t    41950110000 0.623685 sec    16.8126 GB/s

Ho anche provato a:

  1. Invertire l'ordine del test, il risultato è lo stesso, quindi esclude il fattore cache.
  2. Avere la for dichiarazione in senso inverso: for (uint64_t i=size/8;i>0;i-=4). Questo dà lo stesso risultato e dimostra che la compilazione è abbastanza intelligente da non dividere la dimensione per 8 ogni iterazione (come previsto).

Ecco la mia ipotesi selvaggia:

Il fattore velocità è suddiviso in tre parti:

  • cache del codice: uint64_t versione ha dimensioni del codice maggiori, ma ciò non ha alcun effetto sulla mia CPU Xeon. Questo rende la versione a 64 bit più lenta.

  • Istruzioni utilizzate. Notare non solo il conteggio dei loop, ma si accede al buffer con un indice a 32 e 64 bit sulle due versioni. L'accesso a un puntatore con un offset a 64 bit richiede un registro e un indirizzamento a 64 bit dedicati, mentre è possibile utilizzare immediatamente per un offset a 32 bit. Ciò potrebbe rendere più veloce la versione a 32 bit.

  • Le istruzioni vengono emesse solo sulla compilazione a 64 bit (ovvero prefetch). Questo rende più veloce a 64 bit.

I tre fattori insieme corrispondono ai risultati osservati apparentemente contrastanti.


4
Interessante, puoi aggiungere la versione del compilatore e i flag del compilatore? La cosa migliore è che sulla tua macchina, i risultati sono invertiti, cioè usando u64 è più veloce . Fino ad ora, non ho mai pensato a quale tipo abbia la mia variabile loop, ma sembra che dovrò pensarci due volte la prossima volta :).
gexicide

2
@gexicide: non definirei un salto da 16.8201 a 16.8126 rendendolo "più veloce".
user541686

2
@Mehrdad: il salto intendo è quello tra 12.9e 16.8, quindi unsignedè più veloce qui. Nel mio benchmark, è stato il contrario, ovvero 26 per unsigned, 15 peruint64_t
gexicide il

@gexicide Hai notato la differenza nell'indirizzamento del buffer [i]?
Interruzione non mascherabile

@Calvin: No, che vuoi dire?
gexicide

10

Non posso dare una risposta autorevole, ma fornire una panoramica di una probabile causa. Questo riferimento mostra abbastanza chiaramente che per le istruzioni nel corpo del tuo loop c'è un rapporto 3: 1 tra latenza e throughput. Mostra anche gli effetti della spedizione multipla. Poiché ci sono (dare o prendere) tre unità intere nei moderni processori x86, è generalmente possibile inviare tre istruzioni per ciclo.

Quindi tra picco di pipeline e prestazioni di invio multiplo e fallimento di questi meccanismi, abbiamo un fattore sei in termini di prestazioni. È abbastanza noto che la complessità del set di istruzioni x86 rende abbastanza facile che si verifichino rotture stravaganti. Il documento sopra ha un ottimo esempio:

Le prestazioni di Pentium 4 per i turni a destra a 64 bit sono davvero scarse. Lo spostamento a sinistra a 64 bit e tutti gli spostamenti a 32 bit offrono prestazioni accettabili. Sembra che il percorso dei dati dai 32 bit superiori ai 32 bit inferiori dell'ALU non sia ben progettato.

Personalmente mi sono imbattuto in uno strano caso in cui un hot loop ha funzionato notevolmente più lentamente su un core specifico di un chip a quattro core (AMD se ricordo). Abbiamo effettivamente ottenuto prestazioni migliori su un calcolo di riduzione della mappa disattivando quel core.

Qui la mia ipotesi è contesa per le unità intere: che i popcntcalcoli, il contatore di loop e l'indirizzo possono funzionare a malapena alla massima velocità con il contatore largo a 32 bit, ma il contatore a 64 bit provoca contese e blocchi di pipeline. Poiché ci sono solo circa 12 cicli in totale, potenzialmente 4 cicli con invio multiplo, esecuzione del corpo per circuito, un singolo stallo potrebbe ragionevolmente influenzare il tempo di esecuzione di un fattore 2.

La modifica indotta dall'uso di una variabile statica, che suppongo causi solo un piccolo riordino delle istruzioni, è un altro indizio del fatto che il codice a 32 bit è in qualche punto critico per la contesa.

So che questa non è un'analisi rigorosa, ma è una spiegazione plausibile.


2
Sfortunatamente, da allora (Core 2?) Non ci sono praticamente differenze di prestazioni tra le operazioni di numero intero a 32 e 64 bit tranne che per moltiplicare / dividere - che non sono presenti in questo codice.
Mistico

@Gene: si noti che tutte le versioni memorizzano le dimensioni in un registro e non le leggono mai dallo stack nel ciclo. Pertanto, il calcolo dell'indirizzo non può essere nel mix, almeno non all'interno del loop.
gexicide

@Gene: spiegazione interessante davvero! Ma non spiega i principali punti WTF: 64 bit è più lento di 32 bit a causa delle bancarelle della pipeline è una cosa. Ma se questo è il caso, non dovrebbe la versione a 64 bit sia in modo affidabile più lento del 32 bit uno? Invece, tre diversi compilatori emettono codice lento anche per la versione a 32 bit quando si utilizzano dimensioni del buffer costanti in fase di compilazione; cambiando di nuovo le dimensioni del buffer in statico, le cose cambiano completamente. C'è stato anche un caso sulla macchina dei miei colleghi (e nella risposta di Calvin) in cui la versione a 64 bit è notevolmente più veloce! Sembra assolutamente imprevedibile ..
gexicide

@Mysticial Questo è il mio punto. Non vi è alcuna differenza di prestazioni di picco quando non vi è contesa zero per IU, tempo di bus, ecc. Il riferimento lo mostra chiaramente. La contesa rende tutto diverso. Ecco un esempio dalla letteratura Intel Core: "Una nuova tecnologia inclusa nel design è Macro-Ops Fusion, che combina due istruzioni x86 in una singola micro-operazione. Ad esempio, una sequenza di codice comune come un confronto seguita da un salto condizionale diventerebbe una singola micro-op. Sfortunatamente, questa tecnologia non funziona in modalità 64-bit. " Quindi abbiamo un rapporto 2: 1 nella velocità di esecuzione.
Gene,

@gexicide Capisco quello che stai dicendo, ma stai inferendo più di quanto intendessi. Sto dicendo che il codice che esegue il più veloce è mantenere la pipeline e le code di invio piene. Questa condizione è fragile. Modifiche minori come l'aggiunta di 32 bit al flusso totale di dati e il riordino delle istruzioni sono sufficienti per interromperlo. In breve, l'affermazione del PO secondo cui giocherellare e testare è l'unica strada percorribile è corretta.
Gene

10

Ho provato questo con Visual Studio 2013 Express , utilizzando un puntatore anziché un indice, che ha accelerato un po 'il processo. Sospetto che ciò sia dovuto al fatto che l'indirizzamento è offset + register, anziché offset + register + (registro << 3). Codice C ++.

   uint64_t* bfrend = buffer+(size/8);
   uint64_t* bfrptr;

// ...

   {
      startP = chrono::system_clock::now();
      count = 0;
      for (unsigned k = 0; k < 10000; k++){
         // Tight unrolled loop with uint64_t
         for (bfrptr = buffer; bfrptr < bfrend;){
            count += __popcnt64(*bfrptr++);
            count += __popcnt64(*bfrptr++);
            count += __popcnt64(*bfrptr++);
            count += __popcnt64(*bfrptr++);
         }
      }
      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;
   }

codice assembly: r10 = bfrptr, r15 = bfrend, rsi = count, rdi = buffer, r13 = k:

$LL5@main:
        mov     r10, rdi
        cmp     rdi, r15
        jae     SHORT $LN4@main
        npad    4
$LL2@main:
        mov     rax, QWORD PTR [r10+24]
        mov     rcx, QWORD PTR [r10+16]
        mov     r8, QWORD PTR [r10+8]
        mov     r9, QWORD PTR [r10]
        popcnt  rdx, rax
        popcnt  rax, rcx
        add     rdx, rax
        popcnt  rax, r8
        add     r10, 32
        add     rdx, rax
        popcnt  rax, r9
        add     rsi, rax
        add     rsi, rdx
        cmp     r10, r15
        jb      SHORT $LL2@main
$LN4@main:
        dec     r13
        jne     SHORT $LL5@main

9

Hai provato a passare -funroll-loops -fprefetch-loop-arraysa GCC?

Ottengo i seguenti risultati con queste ottimizzazioni aggiuntive:

[1829] /tmp/so_25078285 $ cat /proc/cpuinfo |grep CPU|head -n1
model name      : Intel(R) Core(TM) i3-3225 CPU @ 3.30GHz
[1829] /tmp/so_25078285 $ g++ --version|head -n1
g++ (Ubuntu/Linaro 4.7.3-1ubuntu1) 4.7.3

[1829] /tmp/so_25078285 $ g++ -O3 -march=native -std=c++11 test.cpp -o test_o3
[1829] /tmp/so_25078285 $ g++ -O3 -march=native -funroll-loops -fprefetch-loop-arrays -std=c++11     test.cpp -o test_o3_unroll_loops__and__prefetch_loop_arrays

[1829] /tmp/so_25078285 $ ./test_o3 1
unsigned        41959360000     0.595 sec       17.6231 GB/s
uint64_t        41959360000     0.898626 sec    11.6687 GB/s

[1829] /tmp/so_25078285 $ ./test_o3_unroll_loops__and__prefetch_loop_arrays 1
unsigned        41959360000     0.618222 sec    16.9612 GB/s
uint64_t        41959360000     0.407304 sec    25.7443 GB/s

3
Tuttavia, i tuoi risultati sono del tutto strani (prima senza firma più veloce, poi uint64_t più veloce) poiché lo srotolamento non risolve il problema principale della falsa dipendenza.
gexicide,

7

Hai provato a spostare la fase di riduzione all'esterno del loop? In questo momento hai una dipendenza dati che non è davvero necessaria.

Provare:

  uint64_t subset_counts[4] = {};
  for( unsigned k = 0; k < 10000; k++){
     // Tight unrolled loop with unsigned
     unsigned i=0;
     while (i < size/8) {
        subset_counts[0] += _mm_popcnt_u64(buffer[i]);
        subset_counts[1] += _mm_popcnt_u64(buffer[i+1]);
        subset_counts[2] += _mm_popcnt_u64(buffer[i+2]);
        subset_counts[3] += _mm_popcnt_u64(buffer[i+3]);
        i += 4;
     }
  }
  count = subset_counts[0] + subset_counts[1] + subset_counts[2] + subset_counts[3];

Hai anche qualche strano aliasing in corso, che non sono sicuro sia conforme alle rigide regole di aliasing.


2
Questa è stata la prima cosa che ho fatto dopo aver letto la domanda. Rompere la catena di dipendenze. Come si è scoperto, la differenza di prestazioni non cambia (almeno sul mio computer - Intel Haswell con GCC 4.7.3).
Nils Pipenbrinck,

1
@BenVoigt: è conforme al rigoroso aliasing. void*e char*sono i due tipi che possono essere aliasati, poiché sono essenzialmente considerati "puntatori in un pezzo di memoria"! La tua idea relativa alla rimozione della dipendenza dai dati è utile per l'ottimizzazione, ma non risponde alla domanda. E, come dice @NilsPipenbrinck, non sembra cambiare nulla.
gexicide

@gexicide: la rigida regola di aliasing non è simmetrica. È possibile utilizzare char*per accedere a T[]. voi possibile utilizzare in modo sicuro a T*per accedere a char[]e il codice sembra fare quest'ultimo.
Ben Voigt,

@BenVoigt: Allora non potresti mai salvare mallocuna matrice di niente, poiché malloc ritorna void*e lo interpreti come T[]. E sono abbastanza sicuro che void*e char*aveva la stessa semantica materia rigorosa aliasing. Tuttavia, immagino che questo sia abbastanza offtopico qui :)
gexicide

1
Personalmente penso che la strada giusta siauint64_t* buffer = new uint64_t[size/8]; /* type is clearly uint64_t[] */ char* charbuffer=reinterpret_cast<char*>(buffer); /* aliasing a uint64_t[] with char* is safe */
Ben Voigt,

6

TL; DR: utilizzare __builtininvece intrinseci; potrebbero capitare di aiutare.

Sono stato in grado di fare in modo che gcc4.8.4 (e anche 4.7.3 su gcc.godbolt.org) generassero un codice ottimale per questo usando quello __builtin_popcountllche usa le stesse istruzioni di assemblaggio, ma è fortunato e capita di creare un codice che non ha un dipendenza trasportata da loop lunghi a causa del bug di dipendenza falsa.

Non sono sicuro al 100% del mio codice di benchmarking, ma l' objdumpoutput sembra condividere le mie opinioni. Uso alcuni altri trucchi ( ++ivs i++) per rendere il compilatore unroll loop per me senza alcuna movlistruzione (comportamento strano, devo dire).

risultati:

Count: 20318230000  Elapsed: 0.411156 seconds   Speed: 25.503118 GB/s

Codice di benchmarking:

#include <stdint.h>
#include <stddef.h>
#include <time.h>
#include <stdio.h>
#include <stdlib.h>

uint64_t builtin_popcnt(const uint64_t* buf, size_t len){
  uint64_t cnt = 0;
  for(size_t i = 0; i < len; ++i){
    cnt += __builtin_popcountll(buf[i]);
  }
  return cnt;
}

int main(int argc, char** argv){
  if(argc != 2){
    printf("Usage: %s <buffer size in MB>\n", argv[0]);
    return -1;
  }
  uint64_t size = atol(argv[1]) << 20;
  uint64_t* buffer = (uint64_t*)malloc((size/8)*sizeof(*buffer));

  // Spoil copy-on-write memory allocation on *nix
  for (size_t i = 0; i < (size / 8); i++) {
    buffer[i] = random();
  }
  uint64_t count = 0;
  clock_t tic = clock();
  for(size_t i = 0; i < 10000; ++i){
    count += builtin_popcnt(buffer, size/8);
  }
  clock_t toc = clock();
  printf("Count: %lu\tElapsed: %f seconds\tSpeed: %f GB/s\n", count, (double)(toc - tic) / CLOCKS_PER_SEC, ((10000.0*size)/(((double)(toc - tic)*1e+9) / CLOCKS_PER_SEC)));
  return 0;
}

Opzioni di compilazione:

gcc --std=gnu99 -mpopcnt -O3 -funroll-loops -march=native bench.c -o bench

Versione GCC:

gcc (Ubuntu 4.8.4-2ubuntu1~14.04.1) 4.8.4

Versione del kernel Linux:

3.19.0-58-generic

Informazioni sulla CPU:

processor   : 0
vendor_id   : GenuineIntel
cpu family  : 6
model       : 70
model name  : Intel(R) Core(TM) i7-4870HQ CPU @ 2.50 GHz
stepping    : 1
microcode   : 0xf
cpu MHz     : 2494.226
cache size  : 6144 KB
physical id : 0
siblings    : 1
core id     : 0
cpu cores   : 1
apicid      : 0
initial apicid  : 0
fpu     : yes
fpu_exception   : yes
cpuid level : 13
wp      : yes
flags       : fpu vme de pse tsc msr pae mce cx8 apic sep mtrr pge mca cmov pat pse36 clflush mmx fxsr sse sse2 ss ht syscall nx rdtscp lm constant_tsc nopl xtopology nonstop_tsc eagerfpu pni pclmulqdq ssse3 fma cx16 pcid sse4_1 sse4_2 x2apic movbe popcnt tsc_deadline_timer aes xsave avx f16c rdrand hypervisor lahf_lm abm arat pln pts dtherm fsgsbase tsc_adjust bmi1 hle avx2 smep bmi2 invpcid xsaveopt
bugs        :
bogomips    : 4988.45
clflush size    : 64
cache_alignment : 64
address sizes   : 36 bits physical, 48 bits virtual
power management:

3
È solo una buona fortuna che -funroll-loopscapiti di creare un codice che non colli di bottiglia in una catena di dipendenze trasportata da loop creata dal popcntfalso dep. L'uso di una vecchia versione del compilatore che non conosce la falsa dipendenza è un rischio. Senza -funroll-loops, il ciclo di gcc 4.8.5 rallenterà la latenza di popcnt anziché il throughput, perché contardx . Lo stesso codice, compilato da gcc 4.9.3 aggiunge un xor edx,edxper interrompere la catena di dipendenze.
Peter Cordes,

3
Con i vecchi compilatori, il tuo codice sarebbe comunque vulnerabile esattamente alla stessa variazione di prestazioni dell'OP: cambiamenti apparentemente banali potrebbero rendere gcc qualcosa di lento perché non aveva idea che avrebbe causato un problema. Trovare qualcosa che capita di funzionare in un caso su un vecchio compilatore non è la domanda.
Peter Cordes,

2
Per la cronaca, x86intrin.hle _mm_popcnt_*funzioni di GCC sono wrapper forzatamente allineati attorno al__builtin_popcount* ; il rivestimento dovrebbe rendere l'uno esattamente equivalente all'altro. Dubito fortemente che vedresti qualsiasi differenza che potrebbe essere causata dal passaggio tra di loro.
ShadowRanger

-2

Prima di tutto, prova a stimare le massime prestazioni - esamina https://www.intel.com/content/dam/www/public/us/en/documents/manuals/64-ia-32-architectures-optimization-manual.pdf , in particolare, l'appendice C.

Nel tuo caso, è la tabella C-10 che mostra che l'istruzione POPCNT ha latenza = 3 clock e throughput = 1 clock. La velocità effettiva mostra la frequenza massima in clock (moltiplicare per frequenza core e 8 byte in caso di popcnt64 per ottenere il miglior numero di larghezza di banda possibile).

Ora esamina cosa ha fatto il compilatore e riassumi i throughput di tutte le altre istruzioni nel loop. Ciò fornirà la migliore stima possibile per il codice generato.

Infine, osserva le dipendenze dei dati tra le istruzioni nel ciclo in quanto imporranno un ritardo di latenza elevata anziché la velocità effettiva, quindi dividi le istruzioni di singola iterazione sulle catene di flussi di dati e calcola la latenza attraverso di esse, quindi raccogli ingenuamente il massimo da esse. fornirà una stima approssimativa tenendo conto delle dipendenze del flusso di dati.

Tuttavia, nel tuo caso, scrivere semplicemente il codice nel modo giusto eliminerebbe tutte queste complessità. Invece di accumulare nella stessa variabile count, accumula solo quelli diversi (come count0, count1, ... count8) e sommali alla fine. O persino creare una matrice di conteggi [8] e accumularsi nei suoi elementi - forse, verrà vettorializzato anche e otterrai una resa molto migliore.

PS e non eseguire mai il benchmark per un secondo, prima riscalda il core, quindi esegui il loop per almeno 10 secondi o meglio 100 secondi. in caso contrario, testerai il firmware di gestione dell'alimentazione e l'implementazione DVFS nell'hardware :)

PPS Ho ascoltato infiniti dibattiti su quanto tempo dovrebbe davvero correre il benchmark. La maggior parte delle persone più intelligenti si sta persino chiedendo perché 10 secondi non 11 o 12. Devo ammettere che in teoria è divertente. In pratica, vai a correre il benchmark centinaia di volte di seguito e registra deviazioni. Quel IS divertente. La maggior parte delle persone cambia sorgente ed esegue la panchina dopo esattamente UNA VOLTA per acquisire nuovi record di prestazioni. Fai le cose giuste nel modo giusto.

Non sei ancora convinto? Basta usare la versione C sopra del benchmark di assp1r1n3 ( https://stackoverflow.com/a/37026212/9706746 ) e provare 100 anziché 10000 nel ciclo di tentativi.

Il mio 7960X mostra, con RETRY = 100:

Conteggio: 203182300 Tempo trascorso: 0,008385 secondi Velocità: 12,505379 GB / s

Conteggio: 203182300 Scaduto: 0,011063 secondi Velocità: 9,478225 GB / s

Conteggio: 203182300 Scaduto: 0,011188 secondi Velocità: 9,372327 GB / s

Conteggio: 203182300 Tempo trascorso: 0,010393 secondi Velocità: 10,089252 GB / s

Conteggio: 203182300 Scaduto: 0,009076 secondi Velocità: 11,553283 GB / s

con RETRY = 10000:

Conteggio: 20318230000 Tempo trascorso: 0,661791 secondi Velocità: 15,844519 GB / s

Conteggio: 20318230000 Tempo trascorso: 0,665422 secondi Velocità: 15,758060 GB / s

Conteggio: 20318230000 Tempo trascorso: 0,660983 secondi Velocità: 15,863888 GB / s

Conteggio: 20318230000 Tempo trascorso: 0,665337 secondi Velocità: 15,760073 GB / s

Conteggio: 20318230000 Tempo trascorso: 0,662138 secondi Velocità: 15,836215 GB / s

PPPS Infine, su "risposta accettata" e altri misteri ;-)

Usiamo la risposta di assp1r1n3: ha un core da 2,5 Ghz. POPCNT ha 1 clock throuhgput, il suo codice utilizza popcnt a 64 bit. Quindi la matematica è 2,5 Ghz * 1 orologio * 8 byte = 20 GB / s per la sua configurazione. Sta vedendo 25Gb / s, forse a causa del turbo boost a circa 3Ghz.

Quindi vai su ark.intel.com e cerca i7-4870HQ: https://ark.intel.com/products/83504/Intel-Core-i7-4870HQ-Processor-6M-Cache-up-to-3-70 -GHz-? q = i7-4870HQ

Quel core potrebbe funzionare fino a 3,7 Ghz e la velocità massima reale è di 29,6 GB / s per il suo hardware. Allora, dove sono altri 4 GB / s? Forse, è speso in logica di loop e altro codice circostante all'interno di ogni iterazione.

Ora dov'è questa falsa dipendenza? l'hardware funziona a una velocità quasi di picco. Forse la mia matematica è cattiva, a volte succede :)

PPPPPS Ancora persone che suggeriscono che HW errata è colpevole, quindi seguo il suggerimento e ho creato un esempio inline, vedi sotto.

Sul mio 7960X, la prima versione (con uscita singola a cnt0) funziona a 11 MB / s, la seconda versione (con uscita a cnt0, cnt1, cnt2 e cnt3) funziona a 33 MB / s. E si potrebbe dire: voilà! è dipendenza dall'output.

OK, forse, il punto che ho sottolineato è che non ha senso scrivere codice in questo modo e non è un problema di dipendenza dall'output ma una generazione di codice stupida. Non stiamo testando l'hardware, stiamo scrivendo codice per liberare le massime prestazioni. Potresti aspettarti che HW OOO rinomini e nasconda quelle "dipendenze dell'output" ma, sfarzo, fai solo le cose giuste e non dovrai mai affrontare alcun mistero.

uint64_t builtin_popcnt1a(const uint64_t* buf, size_t len) 
{
    uint64_t cnt0, cnt1, cnt2, cnt3;
    cnt0 = cnt1 = cnt2 = cnt3 = 0;
    uint64_t val = buf[0];
    #if 0
        __asm__ __volatile__ (
            "1:\n\t"
            "popcnt %2, %1\n\t"
            "popcnt %2, %1\n\t"
            "popcnt %2, %1\n\t"
            "popcnt %2, %1\n\t"
            "subq $4, %0\n\t"
            "jnz 1b\n\t"
        : "+q" (len), "=q" (cnt0)
        : "q" (val)
        :
        );
    #else
        __asm__ __volatile__ (
            "1:\n\t"
            "popcnt %5, %1\n\t"
            "popcnt %5, %2\n\t"
            "popcnt %5, %3\n\t"
            "popcnt %5, %4\n\t"
            "subq $4, %0\n\t"
            "jnz 1b\n\t"
        : "+q" (len), "=q" (cnt0), "=q" (cnt1), "=q" (cnt2), "=q" (cnt3)
        : "q" (val)
        :
        );
    #endif
    return cnt0;
}

Se cronometri in cicli di core clock (anziché secondi), 1 secondo è un sacco di tempo per un piccolo loop associato alla CPU. Anche 100ms vanno bene per trovare differenze importanti o controllare i contatori di perf per i conteggi superiori. Soprattutto su uno Skylake, in cui la gestione dello stato P hardware consente di aumentare la velocità di clock massima in microsecondi dopo l'avvio del carico.
Peter Cordes,

clang può auto-vettorializzare __builtin_popcountlcon AVX2 vpshufbe non ha bisogno di più accumulatori nella sorgente C per farlo. Non ne sono sicuro _mm_popcnt_u64; che potrebbe solo vettorializzare automaticamente con AVX512-VPOPCNT. (Vedi Conteggio 1 bit (conteggio della popolazione) su dati di grandi dimensioni utilizzando AVX-512 o AVX-2 /)
Peter Cordes,

Tuttavia, guardare il manuale di ottimizzazione di Intel non aiuta: come mostra la risposta accettata, il problema è una dipendenza inaspettata dell'output popcnt. Questo è documentato negli errata di Intel per alcune delle loro recenti microarchitetture, ma penso che non lo fosse al momento. La tua analisi dep-chain fallirà se ci sono false dipendenze impreviste, quindi questa risposta è un buon consiglio generico ma non applicabile qui.
Peter Cordes,

1
Ma stai scherzando? Non devo "credere" in cose che posso misurare sperimentalmente con contatori di prestazioni in un loop asm scritto a mano. Sono solo fatti. Ho provato e Skylake ha corretto la falsa dipendenza per lzcnt/ tzcnt, ma non per popcnt. Vedere l'erratum SKL029 di Intel in intel.com/content/dam/www/public/us/en/documents/… . Inoltre, gcc.gnu.org/bugzilla/show_bug.cgi?id=62011 è "risolto risolto", non "non valido". Non vi è alcuna base per l'affermazione secondo cui non esiste alcuna dipendenza dell'output in HW.
Peter Cordes,

1
Se esegui un ciclo semplice come popcnt eax, edx/ dec ecx / jnz, ti aspetteresti che venga eseguito a 1 per clock, con un collo di bottiglia sul throughput popcnt e sul throughput derivato. Ma in realtà funziona solo a 1 per 3 orologi colli di bottiglia alla popcntlatenza per sovrascrivere ripetutamente EAX, anche se ti aspetteresti che fosse solo in scrittura. Hai uno Skylake, quindi puoi provarlo tu stesso.
Peter Cordes,

-3

Ok, voglio fornire una piccola risposta a una delle domande secondarie poste dall'OP che non sembrano essere affrontate nelle domande esistenti. Un avvertimento, non ho fatto alcun test o generazione di codice, o disassemblaggio, volevo solo condividere un pensiero che altri potrebbero eventualmente spiegare.

Perché staticcambia la prestazione?

La linea in questione: uint64_t size = atol(argv[1])<<20;

Risposta breve

Vorrei esaminare l'assemblaggio generato per l'accesso size e vedere se ci sono passaggi aggiuntivi di indirizzamento puntatore coinvolti per la versione non statica.

Risposta lunga

Poiché esiste una sola copia della variabile, indipendentemente dal fatto che sia stata dichiarata static o meno, e la dimensione non cambi, teorizzo che la differenza sia la posizione della memoria utilizzata per il backup della variabile e la posizione in cui viene utilizzata ulteriormente nel codice giù.

Ok, per iniziare con l'ovvio, ricorda che a tutte le variabili locali (insieme ai parametri) di una funzione viene fornito spazio nello stack per l'uso come memoria. Ora, ovviamente, il frame dello stack per main () non pulisce mai e viene generato solo una volta. Ok, che ne dici di farcela static? Bene, in quel caso il compilatore sa di riservare spazio nello spazio dati globale del processo, quindi la posizione non può essere cancellata rimuovendo un frame di stack. Tuttavia, abbiamo solo una posizione, quindi qual è la differenza? Sospetto che abbia a che fare con il modo in cui vengono referenziate le posizioni di memoria nello stack.

Quando il compilatore sta generando la tabella dei simboli, crea solo una voce per un'etichetta insieme ad attributi rilevanti, come dimensione, ecc. Sa che deve riservare lo spazio appropriato in memoria ma in realtà non sceglie quella posizione fino a qualche tempo dopo in processo dopo aver effettuato l'analisi del liveness e possibilmente registrare l'allocazione. Come fa quindi il linker a sapere quale indirizzo fornire al codice macchina per il codice assembly finale? O conosce la posizione finale o sa come arrivare alla posizione. Con uno stack, è abbastanza semplice fare riferimento a una posizione basata su due elementi, il puntatore allo stackframe e quindi un offset nel frame. Ciò è fondamentalmente perché il linker non può conoscere la posizione dello stackframe prima del runtime.


2
Mi sembra molto più probabile che l'utilizzo sia staticavvenuto per modificare l'allocazione dei registri per la funzione in un modo che ha influito sulla falsa dipendenza dell'output dalle popcntCPU Intel su cui stava testando l'OP, con un compilatore che non sapeva evitarle. (Dato che questo buco delle prestazioni nelle CPU Intel non era stato ancora scoperto.) Un compilatore può mantenere una staticvariabile locale in un registro, proprio come una variabile di archiviazione automatica, ma se non ottimizzano supponendo che venga maineseguito solo una volta, influenzerà code-gen (perché il valore è impostato solo dalla prima chiamata).
Peter Cordes,

1
In ogni caso, la differenza di prestazioni tra [RIP + rel32]e le [rsp + 42]modalità di indirizzamento è abbastanza trascurabile per la maggior parte dei casi. cmp dword [RIP+rel32], immediatenon riesco a fondere in un singolo carico + cmp uop, ma non credo che sarà un fattore. Come ho detto, all'interno dei loop probabilmente rimane comunque in un registro, ma modificare il C ++ può significare diverse scelte del compilatore.
Peter Cordes,
Utilizzando il nostro sito, riconosci di aver letto e compreso le nostre Informativa sui cookie e Informativa sulla privacy.
Licensed under cc by-sa 3.0 with attribution required.