Szukałem najszybszego sposobu na popcount
duże tablice danych. Spotkałem bardzo dziwny efekt: zmiana zmiennej pętli z unsigned
na uint64_t
sprawiła, że wydajność spadła o 50% na moim komputerze.
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 widać, tworzymy bufor losowych danych, których rozmiar to x
megabajty, gdzie x
są odczytywane z wiersza poleceń. Następnie popcount
iterujemy bufor i używamy rozwiniętej wersji wewnętrznej x86, aby wykonać popcount. Aby uzyskać dokładniejszy wynik, robimy to 10 000 razy. Mierzymy czasy dla popcount. W górnej części zmienną pętli wewnętrznej jest unsigned
, w małym przypadku 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 w ten sposób (wersja g ++: Ubuntu 4.8.2-19ubuntu1):
g++ -O3 -march=native -std=c++11 test.cpp -o test
Oto wyniki na moim Haswell Core i7-4770K CPU @ 3,50 GHz, działającym test 1
(więc 1 MB danych losowych):
- bez znaku 41959360000 0,401554 sec 26,133 GB / s
- uint64_t 41959360000 0,759822 s 13,8003 GB / s
Jak widać, przepustowość uint64_t
wersji jest tylko o połowę mniejsza od unsigned
wersji! Problem polega na tym, że generowany jest inny zestaw, ale dlaczego? Najpierw pomyślałem o błędzie kompilatora, więc spróbowałem clang++
(Ubuntu Clang wersja 3.4-1ubuntu3):
clang++ -O3 -march=native -std=c++11 teest.cpp -o test
Wynik: test 1
- bez znaku 41959360000 0,398293 s 26,26267 GB / s
- uint64_t 41959360000 0,680954 s 15,3986 GB / s
Jest to więc prawie taki sam wynik i nadal jest dziwny. Ale teraz robi się bardzo dziwnie. Rozmiar bufora odczytanego z danych wejściowych zastępuję stałą 1
, więc zmieniam:
uint64_t size = atol(argv[1]) << 20;
do
uint64_t size = 1 << 20;
Dlatego kompilator zna teraz rozmiar bufora w czasie kompilacji. Może może dodać kilka optymalizacji! Oto liczby dla g++
:
- bez znaku 41959360000 0,509156 s 20,5944 GB / s
- uint64_t 41959360000 0,508673 s 20,6139 GB / s
Teraz obie wersje są równie szybkie. Jednak unsigned
stało się jeszcze wolniejsze ! Spadał od 26
do 20 GB/s
, zastępując w ten sposób zmienną stałą wartością, co prowadzi do deoptimizacji . Poważnie, nie mam pojęcia, co się tutaj dzieje! Ale teraz do clang++
nowej wersji:
- bez znaku 41959360000 0,677009 s 15,4884 GB / s
- uint64_t 41959360000 0,676909 s 15,4906 GB / s
Czekaj, co? Teraz obie wersje spadły do wolnej liczby 15 GB / s. Tak więc zastąpienie zmiennej niestałej stałą wartością prowadzi nawet do spowolnienia kodu w obu przypadkach dla Clanga!
Poprosiłem kolegę z procesorem Ivy Bridge o opracowanie mojego testu porównawczego. Otrzymał podobne wyniki, więc nie wygląda na Haswella. Ponieważ dwa kompilatory dają tutaj dziwne wyniki, nie wydaje się, aby był to błąd kompilatora. Nie mamy tutaj 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ą, tj .:
static uint64_t size=atol(argv[1])<<20;
Oto moje wyniki w g ++:
- bez znaku 41959360000 0.396728 sec 26,4306 GB / s
- uint64_t 41959360000 0,509484 s 20,5811 GB / s
Tak, jeszcze jedna alternatywa . Nadal mamy szybkie 26 GB / s u32
, ale udało nam się uzyskać u64
co najmniej z 13 GB / s do wersji 20 GB / s! Na komputerze mojego kolegi u64
wersja stała się jeszcze szybsza niż u32
wersja, dając najszybszy wynik ze wszystkich. Niestety, to działa tylko na g++
, clang++
nie wydaje się, aby się tym przejmować static
.
Moje pytanie
Czy możesz wyjaśnić te wyniki? Szczególnie:
- Jak może istnieć taka różnica między
u32
iu64
? - Jak zastąpienie zmiennej niestałej stałym rozmiarem bufora może powodować mniej optymalny kod ?
- W jaki sposób wstawienie
static
słowa kluczowego możeu64
przyspieszyć pętlę? Nawet szybciej niż oryginalny kod na komputerze mojego kolegi!
Wiem, że optymalizacja to podchwytliwe terytorium, jednak nigdy nie myślałem, że tak małe zmiany mogą prowadzić do 100% różnicy w czasie wykonywania i że małe czynniki, takie jak stały rozmiar bufora, mogą znów całkowicie wymieszać wyniki. Oczywiście zawsze chcę mieć wersję, która jest w stanie obsłużyć 26 GB / s. Jedyny niezawodny sposób, jaki mogę wymyślić, to skopiować i wkleić zestaw dla tego przypadku i użyć zestawu wbudowanego. To jedyny sposób, aby pozbyć się kompilatorów, które wydają się szaleć z powodu drobnych zmian. Co myślisz? Czy istnieje inny sposób na niezawodne uzyskanie kodu o największej wydajności?
Demontaż
Oto demontaż różnych wyników:
Wersja 26 GB / s od 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 od 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 z 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
Wersja 20 GB / s od g ++ / u32 i 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 wersja (26 GB / s) 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 skakania, inne używają jne
. Ale poza tym wszystkie wersje wydają się porównywalne. Nie wiem, skąd mogłaby pochodzić 100% różnica w wydajności, ale nie jestem zbyt biegła w odszyfrowywaniu zestawu. Najwolniejsza wersja (13 GB / s) wygląda nawet bardzo krótko i dobrze. Czy ktoś może to wyjaśnić?
Zdobyta wiedza
Bez względu na to, jaka będzie odpowiedź na to pytanie; Dowiedziałem się, że w naprawdę gorących pętlach każdy szczegół może mieć znaczenie, nawet szczegóły, które nie wydają się mieć żadnego związku z gorącym kodem . Nigdy nie zastanawiałem się, jakiego typu użyć dla zmiennej pętli, ale jak widzisz, tak niewielka zmiana może mieć 100% różnicę! Nawet typ przechowywania bufora może mieć ogromną różnicę, jak widzieliśmy po wstawieniu static
słowa kluczowego przed zmienną size! W przyszłości zawsze będę testować różne alternatywy dla różnych kompilatorów, pisząc naprawdę ciasne i gorące pętle, które są kluczowe dla wydajności systemu.
Ciekawe jest również to, że różnica w wydajności jest wciąż tak wysoka, chociaż już czterokrotnie rozwinąłem pętlę. Więc nawet po rozwinięciu możesz nadal zostać dotknięty poważnymi odchyleniami wydajności. Dość ciekawe.
źródło
Odpowiedzi:
Culprit: fałszywa zależność danych (a kompilator nawet o tym nie wie)
W przypadku procesorów Sandy / Ivy Bridge i Haswell instrukcja:
wydaje się mieć fałszywą zależność od rejestru docelowego
dest
. Mimo że instrukcja tylko do niej zapisuje,dest
przed wykonaniem będzie czekać, aż będzie gotowa. Ta fałszywa zależność jest (obecnie) udokumentowana przez firmę Intel jako erratum HSD146 (Haswell) i SKL029 (Skylake)Skylake naprawił to dla
lzcnt
itzcnt
.Naprawiono to w Cannon Lake (i Ice Lake)
popcnt
.bsf
/bsr
mają prawdziwą zależność wyjściową: wyjście niezmodyfikowane dla wejścia = 0. (Ale nie ma sposobu, aby skorzystać z tego w przypadku wewnętrznych rozwiązań - tylko AMD to dokumentuje, a kompilatory tego nie ujawniają.)(Tak, te instrukcje działają na tej samej jednostce wykonawczej ).
Ta zależność nie tylko utrzymuje 4
popcnt
s od iteracji pojedynczej pętli. Może przenosić iteracje pętli, co uniemożliwia procesorowi równoległe wykonywanie różnych iteracji pętli.unsigned
Vs.uint64_t
i innych poprawek nie wpływają bezpośrednio na problem. Ale wpływają na alokator rejestru, który przypisuje rejestry do zmiennych.W twoim przypadku prędkości są bezpośrednim wynikiem tego, co utknęło w (fałszywym) łańcuchu zależności w zależności od tego, co postanowił zrobić alokator rejestru.
popcnt
-add
-popcnt
-popcnt
→ następna iteracjapopcnt
-add
-popcnt
-add
→ następna iteracjapopcnt
-popcnt
→ następna iteracjapopcnt
-popcnt
→ następna iteracjaRóżnica między 20 GB / si 26 GB / s wydaje się być niewielkim artefaktem adresowania pośredniego. Tak czy inaczej, procesor zaczyna napotykać inne wąskie gardła, gdy osiągniesz tę prędkość.
Aby to przetestować, użyłem zestawu wbudowanego, aby ominąć kompilator i uzyskać dokładnie taki zestaw, jaki chcę. Podzieliłem również
count
zmienną, aby przełamać wszystkie inne zależności, które mogą zepsuć się z testami porównawczymi.Oto wyniki:
Sandy Bridge Xeon @ 3,5 GHz: (pełny kod testu można znaleźć na dole)
g++ popcnt.cpp -std=c++0x -O3 -save-temps -march=native
Różne rejestry: 18,6195 GB / s
Ten sam rejestr: 8,49272 GB / s
Ten sam rejestr z zerwanym łańcuchem: 17,8869 GB / s
Co poszło nie tak z kompilatorem?
Wygląda na to, że ani GCC, ani Visual Studio nie zdają sobie z tego sprawy
popcnt
tak fałszywej zależności. Niemniej jednak te fałszywe zależności nie są rzadkie. To tylko kwestia tego, czy kompilator jest tego świadomy.popcnt
nie jest najczęściej używaną instrukcją. Nic więc dziwnego, że główny kompilator może przeoczyć coś takiego. Wydaje się również, że nigdzie nie ma dokumentacji, która wspomniałaby o tym problemie. Jeśli Intel tego nie ujawni, nikt na zewnątrz nie będzie wiedział, dopóki ktoś nie wpadnie na to przypadkiem.( Aktualizacja: Począwszy od wersji 4.9.2 , GCC jest świadomy tej fałszywej zależności i generuje kod, aby zrekompensować ją, gdy włączone są optymalizacje. Główne kompilatory innych dostawców, w tym Clang, MSVC, a nawet ICC Intela nie są jeszcze świadomi ten mikroarchitekturalny erratum i nie będzie emitował kodu, który go kompensuje).
Dlaczego CPU ma tak fałszywą zależność?
Możemy spekulować: działa na tej samej jednostce wykonawczej jako
bsf
/bsr
, które zrobić mają zależność sygnału wyjściowego. (W jaki sposób POPCNT jest implementowany sprzętowo? ). W przypadku tych instrukcji Intel dokumentuje wynik liczb całkowitych dla input = 0 jako „niezdefiniowany” (przy ZF = 1), ale sprzęt Intel daje silniejszą gwarancję, aby uniknąć uszkodzenia starego oprogramowania: wyjście niezmodyfikowane. AMD dokumentuje to zachowanie.Przypuszczalnie w pewien sposób niewygodne było uzależnienie niektórych poprawek dla tej jednostki wykonawczej od wyników, a inne nie.
Wydaje się, że procesory AMD nie mają tej fałszywej zależności.
Pełny kod testu znajduje się poniżej w celach informacyjnych:
Równie interesujący test porównawczy można znaleźć tutaj: http://pastebin.com/kbzgL8si
Ten test porównawczy różni liczbę
popcnt
s, które znajdują się w (fałszywym) łańcuchu zależności.źródło
imul dst, src, imm
nie ma zależności wyjściowej i nie spowalnialea
. Nie robi tegopdep
, ale jest to zakodowane w VEX za pomocą 2 argumentów wejściowych. Uzgodniliśmy, że to nie jest to jednostka wykonawcza sam , który powoduje fałszywe dep; to zależy od RAT i wydania / zmiany nazwy, ponieważ zmienia nazwy operandów rejestru architektonicznego na rejestry fizyczne. Przypuszczalnie potrzebuje tabeli uop-code -> wzorca zależności i wyborów portów, a grupowanie wszystkich uopsów dla tej samej jednostki wykonawczej razem upraszcza tę tabelę. Właśnie to miałem na myśli bardziej szczegółowo.Zakodowałem równoważny program C do eksperymentu i mogę potwierdzić to dziwne zachowanie. Co więcej,
gcc
uważa , że 64-bitowa liczba całkowita (która prawdopodobnie isize_t
tak powinna być ...) jest lepsza, ponieważ użycieuint_fast32_t
powoduje, że gcc używa 64-bitowego uinta.Zrobiłem trochę zabawy 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 samo szybki jak wersja 32-bitowa!
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 dopóki wewnętrzna pętla popcount dominuje w 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 wersji 64-bitowej, majstrowałem przy rejestrach, aby zastąpić wewnętrzną pętlę wersji 64-bitowej. Ten kod działa również tak szybko, jak wersja 32-bitowa.
Doszedłem do wniosku, że jest to zły harmonogram kompilowania instrukcji przez kompilator, a nie faktyczna przewaga szybkości / opóźnienia w przypadku instrukcji 32-bitowych.
(Uwaga: zhakowałem zgromadzenie, mogłem coś zepsuć bez zauważenia. Nie sądzę.)
źródło
sizeof(uint_fast32_t)
musi zostać zdefiniowane. Jeśli na to nie pozwalasz, możesz to zrobić, ale można to zrobić tylko przy pomocy rozszerzenia kompilatora.To nie jest odpowiedź, ale trudno jest przeczytać, jeśli umieszczę wyniki w komentarzu.
Otrzymuję te wyniki z komputerem Mac Pro ( Westmere 6-rdzeniowy Xeon 3,33 GHz). Skompilowałem to z
clang -O3 -msse4 -lstdc++ a.cpp -o a
(-O2 uzyskać ten sam wynik).brzęknąć z
uint64_t size=atol(argv[1])<<20;
brzęknąć z
uint64_t size=1<<20;
Próbowałem także:
for
oświadczenie w odwrotnej kolejności: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ć wielkości przez 8 podczas każdej iteracji (zgodnie z oczekiwaniami).Oto moje dzikie przypuszczenie:
Współczynnik prędkości składa się z trzech części:
pamięć podręczna kodu:
uint64_t
wersja ma większy rozmiar kodu, ale nie ma to wpływu na mój procesor Xeon. To powoduje, że wersja 64-bitowa działa wolniej.Zastosowane instrukcje. Należy zwrócić uwagę nie tylko na liczbę pętli, ale dostęp do bufora można uzyskać za pomocą indeksu 32-bitowego i 64-bitowego w obu wersjach. Dostęp do wskaźnika z 64-bitowym przesunięciem wymaga dedykowanego 64-bitowego rejestru i adresowania, podczas gdy można użyć natychmiastowego dla 32-bitowego przesunięcia. Może to przyspieszyć wersję 32-bitową.
Instrukcje są emitowane tylko na kompilacji 64-bitowej (to znaczy, pobranie wstępne). To sprawia, że 64-bit jest szybszy.
Te trzy czynniki pasują do obserwowanych pozornie sprzecznych wyników.
źródło
12.9
a16.8
więcunsigned
tutaj jest szybszy. W moimunsigned
uint64_t
Nie mogę udzielić wiarygodnej odpowiedzi, ale przedstawiam 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 także skutki wielokrotnej wysyłki. Ponieważ w nowoczesnych procesorach x86 istnieją (dają lub biorą) trzy jednostki całkowite, generalnie możliwe jest wysłanie trzech instrukcji na cykl.
Tak więc między szczytową wydajnością potoku a wydajnością wielu wysyłek i awarią tych mechanizmów mamy sześciokrotnie wyższą wydajność. Powszechnie wiadomo, że złożoność zestawu instrukcji x86 sprawia, że dość dziwne jest pękanie. Powyższy dokument ma świetny przykład:
Osobiście wpadłem na dziwny przypadek, w którym gorąca pętla działała znacznie wolniej na konkretnym rdzeniu czterordzeniowego układu scalonego (AMD, o ile pamiętam). W rzeczywistości uzyskaliśmy lepszą wydajność w obliczeniach zmniejszania mapy poprzez wyłączenie tego rdzenia.
Zgaduję tutaj, że rywalizuję o jednostki całkowite:
popcnt
liczniki pętli i obliczenia adresu mogą ledwo działać z pełną prędkością z licznikiem szerokim na 32 bity, ale licznik 64-bitowy powoduje rywalizację i utknięcie w potoku. Ponieważ w sumie jest tylko około 12 cykli, potencjalnie 4 cykle z wielokrotną wysyłką, na wykonanie korpusu pętli, pojedyncze przeciągnięcie może w rozsądny sposób wpłynąć na czas pracy 2 razy.Zmiana wywołana przez użycie zmiennej statycznej, którą, jak sądzę, powoduje jedynie niewielkie zmiany kolejności instrukcji, to kolejna wskazówka, że 32-bitowy kod jest w punkcie krytycznym dla niezgody.
Wiem, że to nie jest rygorystyczna analiza, ale jest to wiarygodne wytłumaczenie.
źródło
Próbowałem tego w programie Visual Studio 2013 Express , używając wskaźnika zamiast indeksu, co nieco przyspieszyło proces. Podejrzewam, że dzieje się tak, ponieważ adresowanie to offset + rejestr zamiast offset + rejestr + (rejestr << 3). Kod C ++.
kod zestawu: r10 = bfrptr, r15 = bfrend, rsi = liczba, rdi = bufor, r13 = k:
źródło
Czy próbowałeś przejść?
-funroll-loops -fprefetch-loop-arrays
do GCC?Otrzymuję następujące wyniki z tymi dodatkowymi optymalizacjami:
źródło
Czy próbowałeś przenieść krok redukcji poza pętlę? W tej chwili masz zależność danych, która tak naprawdę nie jest potrzebna.
Próbować:
Prowadzisz również dziwne aliasing, który nie jestem pewien, czy jest zgodny z surowymi zasadami aliasingu.
źródło
void*
ichar*
są to dwa typy, które można aliasować, ponieważ są one zasadniczo uważane za „wskaźniki do jakiejś części pamięci”! Twój pomysł dotyczący usuwania zależności danych jest dobry do optymalizacji, ale nie odpowiada na pytanie. I, jak mówi @NilsPipenbrinck, wydaje się, że nic to nie zmienia.char*
aby uzyskać dostęp doT[]
. Nie możesz bezpiecznie użyć a,T*
aby uzyskać dostęp dochar[]
, a twój kod wydaje się robić to drugie.malloc
niczego, ponieważ malloc powracavoid*
i interpretujesz to jakoT[]
. I jestem tego całkiem pewienvoid*
ichar*
miałem taką samą semantykę dotyczącą ścisłego aliasingu. Wydaje mi się jednak, że jest to dość nie na temat :)uint64_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 */
TL; DR:
__builtin
Zamiast tego użyj funkcji wewnętrznych; może zdarzyć się, że pomogą.Byłem w stanie zmusić
gcc
4.8.4 (a nawet 4.7.3 na gcc.godbolt.org) do wygenerowania optymalnego kodu w tym celu, używając__builtin_popcountll
tej samej instrukcji asemblera, ale mam szczęście i zdarza się, że tworzy kod, który nie ma nieoczekiwanie długa zależność od pętli z powodu błędu fałszywej zależności.Nie jestem w 100% pewien swojego kodu porównawczego, ale
objdump
wydaje się, że wyniki dzielą moje poglądy. Używam innych sztuczek (++i
vsi++
), aby kompilator rozwinął dla mnie pętlę bez żadnychmovl
instrukcji (dziwne zachowanie, muszę powiedzieć).Wyniki:
Kod porównawczy:
Opcje kompilacji:
Wersja GCC:
Wersja jądra Linux:
Informacje o procesorze:
źródło
-funroll-loops
zdarza się tworzyć kod, który nie powoduje wąskiego gardła w łańcuchu zależności przenoszonym przez pętlę, utworzonym przezpopcnt
fałszywe dep. Korzystanie ze starej wersji kompilatora, która nie wie o fałszywej zależności, stanowi ryzyko. Bez-funroll-loops
tego pętla gcc 4.8.5 spowoduje wąskie gardło opóźnień popcnt zamiast przepustowości, ponieważ się liczyrdx
. Ten sam kod, skompilowany przez gcc 4.9.3, dodajexor edx,edx
przerwanie łańcucha zależności.x86intrin.h
„s_mm_popcnt_*
funkcje na GCC są przymusowo inlined obwoluty wokół__builtin_popcount*
; inlining powinien sprawić, że jeden będzie dokładnie taki sam jak drugi. Bardzo wątpię, czy zobaczysz jakąkolwiek różnicę, która mogłaby być spowodowana przełączaniem się między nimi.Przede wszystkim spróbuj oszacować szczytową wydajność - sprawdź https://www.intel.com/content/dam/www/public/us/en/documents/manuals/64-ia-32-architectures-optimization-manual.pdf , w szczególności dodatek C.
W twoim przypadku jest to tabela C-10, która pokazuje, że instrukcja POPCNT ma opóźnienie = 3 zegary i przepustowość = 1 zegar. Przepustowość pokazuje maksymalną prędkość w zegarach (pomnóż przez częstotliwość rdzenia i 8 bajtów w przypadku popcnt64, aby uzyskać najlepszą możliwą liczbę pasm).
Teraz sprawdź, co zrobił kompilator i zsumuj przepustowości wszystkich innych instrukcji w pętli. To da najlepsze możliwe oszacowanie wygenerowanego kodu.
Na koniec spójrz na zależności danych między instrukcjami w pętli, ponieważ wymuszą duże opóźnienie zamiast przepustowości - więc podziel instrukcje pojedynczej iteracji w łańcuchach przepływu danych i oblicz opóźnienie na nich, a następnie naiwnie wybierz maksimum z nich. da przybliżone oszacowanie, biorąc pod uwagę zależności przepływu danych.
Jednak w twoim przypadku pisanie kodu we właściwy sposób wyeliminowałoby wszystkie te złożoności. Zamiast kumulować się do tej samej zmiennej zliczającej, po prostu kumuluj do różnych (takich jak count0, count1, ... count8) i zsumuj je na końcu. Lub nawet stwórz tablicę zliczeń [8] i kumuluj do jej elementów - być może nawet zostanie wektoryzowana i uzyskasz znacznie lepszą przepustowość.
PS i nigdy nie uruchamiaj testu przez sekundę, najpierw rozgrzej rdzeń, a następnie uruchom pętlę przez co najmniej 10 sekund lub lepiej 100 sekund. w przeciwnym razie przetestujesz sprzętowo oprogramowanie do zarządzania energią i implementację DVFS sprzętowo :)
PPS Słyszałem niekończące się debaty na temat tego, ile czasu naprawdę powinien potrwać test porównawczy. Większość najmądrzejszych ludzi pyta nawet, dlaczego 10 sekund a nie 11 lub 12. Muszę przyznać, że jest to śmieszne w teorii. W praktyce wystarczy przejść i uruchomić test porównawczy sto razy z rzędu i zapisać odchylenia. To jest zabawne. Większość ludzi zmienia źródło i biegnie po tym dokładnie RAZ, aby zdobyć nowy rekord wydajności. Rób właściwe rzeczy we właściwy sposób.
Nie jesteś jeszcze przekonany? Wystarczy użyć powyższej wersji testu porównawczego C autorstwa assp1r1n3 ( https://stackoverflow.com/a/37026212/9706746 ) i wypróbować 100 zamiast 10000 w pętli ponownych prób.
Moje programy 7960X z RETRY = 100:
Liczba: 203182300 Upłynęło: 0,008385 sekund Prędkość: 12,505379 GB / s
Liczba: 203182300 Upłynęło: 0,011063 sekundy Prędkość: 9,478225 GB / s
Liczba: 203182300 Upłynęło: 0,011188 sekund Prędkość: 9,372327 GB / s
Liczba: 203182300 Upłynęło: 0,010393 sekundy Prędkość: 10,089252 GB / s
Liczba: 203182300 Upłynęło: 0,009076 sekund Prędkość: 11,553283 GB / s
z RETRY = 10000:
Liczba: 20318230000 Upłynęło: 0,661791 sekund Prędkość: 15,844519 GB / s
Liczba: 20318230000 Upłynęło: 0,665422 sekundy Prędkość: 15,758060 GB / s
Liczba: 20318230000 Upłynęło: 0,660983 sekundy Prędkość: 15,863888 GB / s
Liczba: 20318230000 Upłynęło: 0,665337 sekund Prędkość: 15,760073 GB / s
Liczba: 20318230000 Upłynęło: 0,662138 sekund Prędkość: 15,836215 GB / s
PPPS Wreszcie na temat „zaakceptowanej odpowiedzi” i innych zagadek ;-)
Użyjmy odpowiedzi assp1r1n3 - ma on rdzeń 2,5 GHz. POPCNT ma 1 zegar, jego kod używa 64-bitowego popcnt. Tak więc matematyka to 2,5 GHz * 1 zegar * 8 bajtów = 20 GB / s dla jego konfiguracji. Widzi 25 Gb / s, być może z powodu zwiększenia turbo do około 3 GHz.
Przejdź zatem do ark.intel.com i poszukaj i7-4870HQ: https://ark.intel.com/products/83504/Intel-Core-i7-4870HQ-Processor-6M-Cache-up-to-3-70 -GHz-? Q = i7-4870HQ
Ten rdzeń może pracować do 3,7 Ghz, a rzeczywista maksymalna szybkość dla jego sprzętu wynosi 29,6 GB / s. Więc gdzie są kolejne 4 GB / s? Być może jest wydawany na logikę pętli i inny otaczający kod w każdej iteracji.
Teraz gdzie jest ta fałszywa zależność? sprzęt działa prawie w szczytowym tempie. Może moja matematyka jest zła, czasem się zdarza :)
PPPPPS Wciąż ludzie sugerujący erratę HW są winni, więc podążam za sugestią i stworzyłem przykładowy asm, patrz poniżej.
W moim 7960X pierwsza wersja (z pojedynczym wyjściem do cnt0) działa z prędkością 11 MB / s, druga wersja (z wyjściem do cnt0, cnt1, cnt2 i cnt3) działa z prędkością 33 MB / s. I można powiedzieć - voila! to zależność wyjściowa.
OK, może chodziło mi o to, że pisanie takiego kodu nie ma sensu i nie jest to problem zależności wyjściowej, ale generowanie głupiego kodu. Nie testujemy sprzętu, piszemy kod, aby uwolnić maksymalną wydajność. Można się spodziewać, że HW OOO zmieni nazwę i ukryje te „zależności wyjściowe”, ale, rany, po prostu rób właściwe rzeczy we właściwy sposób i nigdy nie spotkasz się z żadną tajemnicą.
źródło
__builtin_popcountl
w AVX2vpshufb
i nie potrzebuje do tego wielu akumulatorów w źródle C. Nie jestem pewien_mm_popcnt_u64
; który może automatycznie się wektoryzować tylko za pomocą AVX512-VPOPCNT. (Zobacz Liczenie 1 bitów (liczba populacji) na dużych danych przy użyciu AVX-512 lub AVX-2 /)popcnt
. Jest to udokumentowane w erracie Intela dla niektórych z ich ostatnich mikroarchitektur, ale myślę, że wtedy nie było. Twoja analiza łańcucha nie powiedzie się, jeśli wystąpią nieoczekiwane fałszywe zależności, więc ta odpowiedź jest dobrą ogólną radą, ale nie ma tutaj zastosowania.lzcnt
/tzcnt
, ale nie dlapopcnt
. Zobacz Erratum SKL029 Intela w intel.com/content/dam/www/public/us/en/documents/... . Ponadto gcc.gnu.org/bugzilla/show_bug.cgi?id=62011 jest „rozwiązany naprawiony”, a nie „nieprawidłowy”. Nie ma podstaw do twierdzenia, że w HW nie ma zależności wyjściowej.popcnt eax, edx
/dec ecx / jnz
, możesz oczekiwać, że będzie ona działać z prędkością 1 na zegar, z wąskim gardłem w zakresie przepustowości popcnt i przepustowości przejętej gałęzi. Ale tak naprawdę działa tylko z częstotliwością 1 na 3 zegary z wąskim gardłem wpopcnt
przypadku wielokrotnego nadpisywania EAX, nawet jeśli można oczekiwać, że będzie to tylko do zapisu. Masz Skylake, więc możesz spróbować sam.Ok, chcę udzielić krótkiej odpowiedzi na jedno z pytań cząstkowych zadanych przez PO, które wydają się nie zostać uwzględnione w istniejących pytaniach. Zastrzegam sobie, że nie przeprowadzałem żadnych testów ani generowania kodu ani dezasemblacji, chciałem tylko podzielić się przemyśleniami, które inni mogliby wyjaśnić.
Dlaczego
static
zmiana wydajności?Linia, o której mowa:
uint64_t size = atol(argv[1])<<20;
Krótka odpowiedź
Chciałbym spojrzeć na zestaw wygenerowany dla uzyskania dostępu
size
i sprawdzić, czy dla wersji niestatycznej występują dodatkowe etapy pośredniego wskaźnika.Długa odpowiedź
Ponieważ istnieje tylko jedna kopia zmiennej, bez względu na to, czy została zadeklarowana,
static
czy nie, a rozmiar się nie zmienia, teoretyzuję, że różnicą jest lokalizacja pamięci użytej do utworzenia kopii zmiennej wraz z miejscem jej użycia w kodzie na dół.Ok, aby zacząć od oczywistości, pamiętaj, że wszystkie zmienne lokalne (wraz z parametrami) funkcji mają zapewnione miejsce na stosie do wykorzystania jako pamięć. Oczywiście rama stosu dla main () nigdy nie czyści się i jest generowana tylko raz. Ok, a co z tym zrobić
static
? Cóż, w takim przypadku kompilator wie, aby zarezerwować miejsce w globalnej przestrzeni danych procesu, więc lokalizacji nie można wyczyścić przez usunięcie ramki stosu. Ale wciąż mamy tylko jedną lokalizację, więc jaka jest różnica? Podejrzewam, że ma to związek ze sposobem odwoływania się do lokalizacji pamięci na stosie.Gdy kompilator generuje tablicę symboli, po prostu wprowadza etykietę wraz z odpowiednimi atrybutami, takimi jak rozmiar itp. Wie, że musi zarezerwować odpowiednią przestrzeń w pamięci, ale w rzeczywistości nie wybiera tej lokalizacji, dopóki nie później proces po przeprowadzeniu analizy żywotności i ewentualnym przypisaniu rejestru. Skąd zatem linker wie, jaki adres podać w kodzie maszynowym dla kodu końcowego zestawu? Zna ostateczną lokalizację lub wie, jak do niej dotrzeć. W przypadku stosu odniesienie do jednego z dwóch elementów, wskaźnika do ramki stosu, a następnie przesunięcia w ramce jest dość proste. Jest to w zasadzie spowodowane tym, że linker nie może znać położenia ramki stosu przed uruchomieniem.
źródło
static
zmieniło przydział rejestru dla funkcji w sposób, który wpłynął na fałszywą zależność wyjściowąpopcnt
procesorów Intel, na których testował OP, z kompilatorem, który nie wiedział, jak ich uniknąć. (Ponieważ ta dziura w wydajności procesorów Intela nie została jeszcze odkryta.) Kompilator może przechowywaćstatic
zmienną lokalną w rejestrze, podobnie jak zmienna pamięci automatycznej, ale jeśli nie zoptymalizują założenia, żemain
działa tylko raz, wpłynie to na code-gen (ponieważ wartość jest ustawiana tylko przez pierwsze wywołanie).[RIP + rel32]
i ich[rsp + 42]
adresowania jest w większości przypadków znikoma.cmp dword [RIP+rel32], immediate
nie mogę mikro-połączyć w jedno obciążenie + cmp uop, ale nie sądzę, że to będzie czynnik. Jak powiedziałem, wewnątrz pętli i tak prawdopodobnie pozostaje w rejestrze, ale poprawienie C ++ może oznaczać różne wybory kompilatora.