Zastąpienie 32-bitowego licznika pętli 64-bitowym wprowadza szalone odchylenia wydajności

Szukałem najszybszej drogi do popcount dużych tablic danych. Napotkałem bardzo dziwny efekt : Zmiana zmiennej pętli zunsigned na uint64_t sprawiła, że wydajność spadła o 50% na moim komputerze.

The Benchmark

#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);
}

Jak widzisz, tworzymy bufor losowych danych o rozmiarze x megabajtów, gdzie x jest odczytywany z linii poleceń. Następnie iterujemy nad buforem i używamy rozwiniętej wersji intrinsic x86 popcount do wykonania popcount. Aby uzyskać bardziej precyzyjny wynik, robimy popcount 10 000 razy. Mierzymy czasy dla liczby popcount. W przypadku dużych liter zmienną pętli wewnętrznej jest unsigned, W przypadku małych liter zmienną pętli wewnętrznej jest uint64_t. Myślałem, że to nie powinno mieć znaczenia, ale jest odwrotnie.

(absolutnie szalone) wyniki

Kompiluję to tak (Wersja G++: Ubuntu 4.8.2-19ubuntu1):

g++ -O3 -march=native -std=c++11 test.cpp -o test

Oto wyniki mojego Haswell Rdzeń i7-4770K CPU @ 3.50 GHz, running test 1 (więc 1 MB losowych danych):

  • unsigned 41959360000 0.401554 sec 26.113 GB / s
  • uint64_t 41959360000 0.759822 sek 13.8003 GB / s

Jak widzisz, przepustowość wersji uint64_t jest tylko połową wersji unsigned! Problem wydaje się być taki, że generowane są różne zespoły, ale dlaczego? Po pierwsze, pomyślałem o błędzie kompilatora, więc próbowałem clang++ (Ubuntu Clang Wersja 3.4-1ubuntu3):

clang++ -O3 -march=native -std=c++11 teest.cpp -o test

Wynik: test 1

  • unsigned 41959360000 0.398293 sek 26.3267 GB / s
  • uint64_t 41959360000 0.680954 sec 15.3986 GB / s

Więc jest to prawie ten sam wynik i nadal jest dziwny. Ale teraz robi się dziwnie. zamieniam rozmiar bufora, który został odczytany z wejścia na stałą 1, więc zmieniam:

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

Do

uint64_t size = 1 << 20;

Tak więc, kompilator zna teraz Rozmiar bufora podczas kompilacji. Może to może dodać kilka optymalizacji! Oto liczby dla g++:

  • unsigned 41959360000 0.509156 sec 20.5944 GB / s
  • uint64_t 41959360000 0.508673 sek 20.6139 GB / s
Obie wersje są równie szybkie. Jednak unsigned jeszcze wolniej ! Spadła z 26 do 20 GB/s, w ten sposób zastąpienie Nie stałej przez wartość stałą prowadzi do deoptymizacja . Poważnie, nie mam pojęcia, co tu się dzieje! Ale teraz clang++ z nową wersją:
  • unsigned 41959360000 0.677009 sek 15.4884 GB / s
  • uint64_t 41959360000 0.676909 sek 15.4906 GB / s

Czekaj, co? teraz obie wersje spadły do slow liczby 15 GB / s. W ten sposób zastąpienie Nie-stałej przez stałą wartości prowadzi nawet do powolnego kodu wobu przypadkach dla Clang!

Poprosiłem kolegę z procesorem Ivy Bridge , aby skompilował mój benchmark. Uzyskał podobne wyniki, więc nie wydaje się, że to Haswell. Ponieważ dwa Kompilatory dają tu dziwne wyniki, nie wydaje się, aby był to błąd kompilatora. Nie mamy tu procesora AMD, więc mogliśmy testować tylko z Intelem.

Więcej szaleństwa, proszę!

Weź pierwszy przykład (ten z atol(argv[1])) i umieść static przed zmienną, tzn.:

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

Oto moje wyniki w g++:

  • unsigned 41959360000 0.396728 sek 26.4306 GB / s
  • uint64_t 41959360000 0.509484 sek 20.5811 GB / s

Yay, yet another alternative . Wciąż mamy szybkie 26 GB/s z u32, ale udało nam się uzyskać u64 przynajmniej z wersji 13 GB/s do wersji 20 Gb / s! na komputerze mojego kolegi wersja u64 stała się jeszcze szybsza niż wersja u32, uzyskując najszybszy wynik ze wszystkich. niestety, to działa tylko dla g++, clang++ nie wydaje się dbać o static.

Moje pytanie

Możesz wyjaśnić te wyniki? Szczególnie:
  • jak może być taka różnica między u32 a u64?
  • jak zastąpienie Nie stałej przez stały bufor wyzwalacza mniej optymalnego kodu ?
  • jak wstawianie słowa kluczowego static może przyspieszyć pętlę u64? Jeszcze szybszy niż oryginalny kod na mojej uczelni komputer!

Wiem, że optymalizacja jest trudnym obszarem, jednak nigdy nie myślałem, że tak małe zmiany mogą prowadzić do {67]}100% różnicy {68]} w czasie wykonania i że małe czynniki, takie jak stały rozmiar bufora, mogą ponownie całkowicie mieszać wyniki. Oczywiście, zawsze chcę mieć wersję, która jest w stanie popcount 26 GB / s. jedynym niezawodnym sposobem, jaki mogę wymyślić, jest skopiuj wklej montaż w tym przypadku i użyj montażu inline. Tylko w ten sposób mogę pozbyć się kompilatorów to wydaje się szaleć na małe zmiany. Co o tym myślisz? Czy istnieje inny sposób na niezawodne uzyskanie kodu z największą wydajnością?

Demontaż

Oto Demontaż dla różnych wyników:

26 GB/s wersja z 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

Wersja 13 GB/s z 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

Wersja 15 GB/s od 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 wersja z 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

Wersja 15 GB/s z 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
Co ciekawe, najszybsza (26 GB/s) wersja jest również najdłuższa! Wydaje się, że jest to jedyne rozwiązanie, które wykorzystuje lea. Niektóre wersje używają jb do skoku, inne używają jne. Ale poza tym wszystkie wersje wydają się być porównywalne. Nie widzę, skąd mogłaby pochodzić 100% luka w wydajności, ale nie jestem zbyt biegły w rozszyfrowywaniu montaż. Najwolniejsza (13 GB/s) wersja wygląda nawet bardzo krótko i dobrze. Czy ktoś może to wyjaśnić?

Wyciągnięte wnioski

Bez względu na to, jaka będzie odpowiedź na to pytanie; nauczyłem się, że w naprawdę gorących pętlach {48]}każdy detal {49]} może mieć znaczenie, {48]}nawet szczegóły, które nie wydają się mieć żadnego związku z gorącym kodem {49]}. Nigdy nie myślałem o tym, jakiego typu użyć dla zmiennej pętli, ale jak widzisz taka drobna zmiana może sprawić, że 100% różnica! Parzyste typ przechowywania bufora może mieć ogromne znaczenie, jak widzieliśmy po wstawieniu static słowa kluczowego przed zmienną size! W przyszłości zawsze będę testował różne alternatywy na różnych kompilatorach, pisząc naprawdę ciasne i gorące pętle, które są kluczowe dla wydajności systemu.

Ciekawostką jest również to, że różnica w wydajności jest nadal tak wysoka, chociaż już cztery razy rozwinąłem pętlę. Więc nawet jeśli rozwiążesz, nadal możesz zostać trafiony przez majora odchylenia wydajności. Całkiem interesujące.

Author: einpoklum, 2014-08-01

8 answers

W przeciwieństwie do innych systemów, w których dane są przetwarzane, dane te są przetwarzane w celu uzyskania dostępu do danych.]}

Na procesorach Sandy/Ivy Bridge i Haswell, Instrukcja:

popcnt  src, dest

Wydaje się mieć fałszywą zależność od rejestru przeznaczenia dest. Mimo że instrukcja tylko do niej pisze, instrukcja będzie czekać, aż dest będzie gotowa przed wykonaniem.

Ta zależność nie tylko utrzymuje 4 popcnts z pojedynczej iteracji pętli. Może przenosić iteracje pętli uniemożliwiając procesorowi równoległe wykonywanie różnych iteracji pętli.

The unsigned vs. uint64_t i inne poprawki nie wpływają bezpośrednio na problem. Ale wpływają one na alokator rejestru, który przypisuje rejestry do zmiennych.

W Twoim przypadku, prędkości są bezpośrednim wynikiem tego, co jest przyklejone do (fałszywego) łańcucha zależności w zależności od tego, co alokator rejestru zdecydował się zrobić.

  • 13 GB/s ma łańcuch: popcnt-add-popcnt-popcnt → następny iteracja
  • 15 GB / s ma łańcuch: popcnt-add-popcnt-add → następna iteracja
  • 20 GB / s ma łańcuch: popcnt-popcnt → następna iteracja
  • 26 GB / s ma łańcuch: popcnt-popcnt → następna iteracja

Różnica pomiędzy 20 GB/s a 26 GB/s wydaje się być drobnym artefaktem adresowania pośredniego. Tak czy inaczej, procesor zaczyna uderzać w inne wąskie gardła po osiągnięciu tej prędkości.


Aby to przetestować, użyłem inline assembly, aby ominąć kompilator i zdobądź dokładnie taki zestaw, jaki chcę. Podzieliłem również zmienną count, aby złamać wszystkie inne zależności, które mogą namieszać w benchmarkach.

Oto wyniki:

Sandy Bridge Xeon @ 3.5 GHz: (Pełny kod testowy można znaleźć na dole)

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

Różne rejestry: 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

Ten sam rejestr: 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

Ten sam rejestr z złamany łańcuch: 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

Co poszło nie tak z kompilatorem?

Wydaje się, że ani GCC, ani Visual Studio nie są świadomi, że popcnt mA tak fałszywą zależność. Niemniej jednak te fałszywe zależności nie są rzadkością. To tylko kwestia tego, czy kompilator jest tego świadomy.

popcnt nie jest to najczęściej używana Instrukcja. Nie jest więc zaskoczeniem, że duży kompilator może przegapić coś takiego. Tam też wydaje się, że nigdzie nie ma dokumentacji, która wspomina o tym problemie. Jeśli Wywiad tego nie ujawni, nikt na zewnątrz nie dowie się, dopóki ktoś nie wpadnie na to przypadkowo.

(Aktualizacja: od wersji 4.9.2, GCC jest świadomy tej fałszywej zależności i generuje kod, aby skompensować go, gdy optymalizacje są włączone. Główne Kompilatory innych producentów, w tym Clang, MSVC, a nawet własne ICC Intela nie są jeszcze świadome tej mikroarchitektury erratum i nie będą emitować kodu to rekompensuje.)

Dlaczego procesor ma taką fałszywą zależność?

Możemy tylko spekulować, ale jest prawdopodobne, że Intel ma taką samą obsługę dla wielu dwuoperandowych instrukcji. Typowe instrukcje, takie jak add, sub weź dwa operandy, z których oba są wejściami. Więc Intel pewnie wrzucił popcnt do tej samej kategorii, aby utrzymać prostą konstrukcję procesora.

Procesory AMD nie wydają się mieć tej fałszywej zależności.


Pełna kod testu znajduje się poniżej w celach informacyjnych:

#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);
}

Równie interesujący benchmark można znaleźć tutaj: http://pastebin.com/kbzgL8si
Ten benchmark zmienia liczbę popcnt S, które znajdują się w (fałszywym) łańcuchu zależności.

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

 1342
Author: Mysticial,
Warning: date(): Invalid date.timezone value 'Europe/Kyiv', we selected the timezone 'UTC' for now. in /var/www/agent_stack/data/www/doraprojects.net/template/agent.layouts/content.php on line 54
2017-01-18 17:37:16

Zakodowałem odpowiedni program C do eksperymentu i mogę potwierdzić to dziwne zachowanie. Co więcej, gcc uważa 64-bitową liczbę całkowitą (która i tak powinna być size_t...) być lepszym, ponieważ użycie uint_fast32_t powoduje, że gcc używa 64-bitowego uint.

Trochę pofatygowałem się z montażem:
Po prostu weź 32-bitową wersję, Zamień wszystkie 32-bitowe instrukcje/rejestry na 64-bitową wersję w wewnętrznej pętli popcount programu. Uwaga: kod jest tak jak szybka jak wersja 32-bitowa!

Jest to oczywiście hack, ponieważ rozmiar zmiennej nie jest tak naprawdę 64-bitowy, ponieważ inne części programu nadal używają wersji 32-bitowej, ale tak długo, jak wewnętrzna pętla popcount dominuje nad wydajnością, jest to dobry początek.

Następnie skopiowałem kod wewnętrznej pętli z 32-bitowej wersji programu, zhakowałem go do 64-bitowej, majstrowałem z rejestrami, aby zastąpić wewnętrzną pętlę wersji 64-bitowej. Ten kod działa również jako szybki jak wersja 32-bitowa.

Mój wniosek jest taki, że jest to złe planowanie instrukcji przez kompilator, a nie rzeczywista zaleta szybkości / opóźnienia instrukcji 32-bitowych.

(Zastrzeżenie: zhakowałem montaż, mogłem coś zepsuć nie zauważając. Nie sądzę.)

 47
Author: EOF,
Warning: date(): Invalid date.timezone value 'Europe/Kyiv', we selected the timezone 'UTC' for now. in /var/www/agent_stack/data/www/doraprojects.net/template/agent.layouts/content.php on line 54
2014-08-01 22:55:56

To nie jest odpowiedź, ale trudno ją odczytać, jeśli umieszczę wyniki w komentarzu.

Otrzymuję te wyniki z Mac Pro (Westmere 6-rdzenie Xeon 3,33 GHz). Skompilowałem go za pomocą clang -O3 -msse4 -lstdc++ a.cpp -o a (- O2 uzyskać ten sam wynik).

Clang with 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 with uint64_t size=1<<20;

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

Próbowałem też:

  1. Odwróć kolejność testu, wynik jest taki sam, więc wyklucza współczynnik bufora.
  2. mieć for twierdzenie odwrotne: for (uint64_t i=size/8;i>0;i-=4). Daje to ten sam wynik i dowodzi, że kompilacja jest wystarczająco inteligentna, aby nie dzielić rozmiaru przez 8 każdej iteracji (zgodnie z oczekiwaniami).

Oto moje Dzikie przypuszczenie:

Współczynnik prędkości składa się z trzech części:

  • Code cache: uint64_t wersja ma większy rozmiar kodu, ale nie ma to wpływu na mój procesor Xeon. To sprawia, że wersja 64-bitowa jest wolniejsza.

  • Użyte instrukcje. Uwaga nie tylko liczba pętli, ale bufor jest dostępny z 32-bitowym i 64-bitowy indeks dwóch wersji. Dostęp do wskaźnika z przesunięciem 64-bitowym wymaga dedykowanego rejestru i adresowania 64-bitowego, podczas gdy możesz użyć natychmiastowego przesunięcia 32-bitowego. Może to sprawić, że wersja 32-bitowa będzie szybsza.

  • Instrukcje są emitowane tylko na kompilacji 64-bitowej (czyli prefetch). To sprawia, że 64-bit jest szybszy.

Te trzy czynniki razem pasują do obserwowanych pozornie sprzecznych wyników.

 24
Author: Non-maskable Interrupt,
Warning: date(): Invalid date.timezone value 'Europe/Kyiv', we selected the timezone 'UTC' for now. in /var/www/agent_stack/data/www/doraprojects.net/template/agent.layouts/content.php on line 54
2014-08-04 09:57:49

Nie mogę dać mi wiarygodnej odpowiedzi, ale podać przegląd prawdopodobnej przyczyny. to odniesienie pokazuje dość wyraźnie, że dla instrukcji w ciele pętli istnieje stosunek 3:1 między opóźnieniem a przepustowością. Pokazuje również efekty wielokrotnej wysyłki. Ponieważ w nowoczesnych procesorach x86 istnieją (dawać-lub-brać) trzy jednostki całkowite, generalnie możliwe jest wysłanie trzech instrukcji na cykl.

Więc między szczytowym rurociągiem a wielokrotnym wysyłaniem wydajność i awaria tych mechanizmów, mamy czynnik sześciu w wydajności. Powszechnie wiadomo, że złożoność zestawu instrukcji x86 sprawia, że dość łatwo dochodzi do dziwacznego pęknięcia. Powyższy dokument ma świetny przykład:

[1]}Wydajność Pentium 4 dla 64-bitowych prawych zmian jest naprawdę słaba. 64-bitowy lewy shift, jak również wszystkie 32-bitowe zmiany mają akceptowalną wydajność. Wygląda na to, że ścieżka danych z górnych 32 bitów do dolnych 32 bitów ALU nie jest dobrze zaprojektowane.

Osobiście natknąłem się na dziwny przypadek, w którym gorąca pętla działała znacznie wolniej na konkretnym rdzeniu czterordzeniowego układu (AMD, jeśli dobrze pamiętam). W rzeczywistości uzyskaliśmy lepszą wydajność na mapie-zmniejszyliśmy obliczenia, wyłączając rdzeń.

Tutaj domyślam się twierdzenia o jednostkach całkowitych: że popcnt, licznik pętli i obliczenia adresów mogą ledwo działać z pełną prędkością z 32-bitowym licznikiem szerokości, ale licznik 64-bitowy powoduje niezgodność i potoki stragany. Ponieważ w sumie jest tylko około 12 cykli, potencjalnie 4 cykle z wieloma wysyłkami, Na wykonanie korpusu pętli, pojedyncze stoisko może rozsądnie wpłynąć na czas pracy przez współczynnik 2.

Zmiana wywołana przez użycie zmiennej statycznej, która, jak zgaduję, powoduje drobną zmianę kolejności instrukcji, jest kolejną wskazówką, że 32-bitowy kod jest w pewnym punkcie krytycznym dla twierdzenia.

Wiem, że to nie jest rygorystyczna analiza, ale to jest wiarygodnym wyjaśnieniem.
 10
Author: Gene,
Warning: date(): Invalid date.timezone value 'Europe/Kyiv', we selected the timezone 'UTC' for now. in /var/www/agent_stack/data/www/doraprojects.net/template/agent.layouts/content.php on line 54
2014-08-04 15:18:42

Próbowałem tego z Visual Studio 2013 Express, używając wskaźnika zamiast indeksu, co nieco przyspieszyło proces. Podejrzewam, że to dlatego, że adresowanie jest offset + register, zamiast offset + register + (register

   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;
   }

Kod zestawu: 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
 10
Author: rcgldr,
Warning: date(): Invalid date.timezone value 'Europe/Kyiv', we selected the timezone 'UTC' for now. in /var/www/agent_stack/data/www/doraprojects.net/template/agent.layouts/content.php on line 54
2017-03-11 01:04:46

Próbowałeś przejść do GCC?

Otrzymuję następujące wyniki z tymi dodatkowymi optymalizacjami:

[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
 9
Author: Dangelov,
Warning: date(): Invalid date.timezone value 'Europe/Kyiv', we selected the timezone 'UTC' for now. in /var/www/agent_stack/data/www/doraprojects.net/template/agent.layouts/content.php on line 54
2014-09-07 22:47:44

Próbowałeś przesunąć krok redukcji poza pętlę? W tej chwili masz zależność od danych, która naprawdę nie jest potrzebna.

Try:

  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];

Masz też dziwne aliasing, który nie jestem pewien, czy jest zgodny ze ścisłymi zasadami aliasingu.

 7
Author: Ben Voigt,
Warning: date(): Invalid date.timezone value 'Europe/Kyiv', we selected the timezone 'UTC' for now. in /var/www/agent_stack/data/www/doraprojects.net/template/agent.layouts/content.php on line 54
2014-08-01 18:33:23

TL;DR: zamiast tego użyj __builtin wewnętrznych.

Udało mi się zrobić gcc 4.8.4 (a nawet 4.7.3 na gcc.godbolt.org) wygenerować optymalny kod za pomocą __builtin_popcountll, który używa tej samej instrukcji asemblera, ale nie ma tego fałszywego błędu zależności.

Nie jestem w 100% pewien mojego kodu porównawczego, ale objdump wyjście wydaje się dzielić moje poglądy. Używam innych tricków (++i vs i++) aby kompilator rozwijał pętlę dla mnie bez instrukcji movl (dziwne zachowanie, muszę powiedz).

Wyniki:

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

Kod porównawczy:

#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;
}

Opcje kompilacji:

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

Wersja GCC:

gcc (Ubuntu 4.8.4-2ubuntu1~14.04.1) 4.8.4

Wersja jądra Linuksa:

3.19.0-58-generic

Informacje o procesorze:

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:
 6
Author: assp1r1n3,
Warning: date(): Invalid date.timezone value 'Europe/Kyiv', we selected the timezone 'UTC' for now. in /var/www/agent_stack/data/www/doraprojects.net/template/agent.layouts/content.php on line 54
2017-03-11 01:11:51