Jak teoretyczną szczytową wydajność 4 operacji zmiennoprzecinkowych (podwójna precyzja) na cykl można uzyskać na nowoczesnym procesorze Intel x86-64?
O ile rozumiem, potrzeba trzech cykli dla SSE add
i pięciu cykli na mul
ukończenie większości współczesnych procesorów Intela (patrz na przykład „Tabele instrukcji” Agner Fog ). Ze względu na potokowanie można uzyskać przepustowość jednego add
na cykl, jeśli algorytm ma co najmniej trzy niezależne sumy. Ponieważ dotyczy to zarówno wersji spakowanych, addpd
jak i addsd
wersji skalarnych, a rejestry SSE mogą zawierać dwa double
, przepustowość może wynosić nawet dwa klapy na cykl.
Co więcej, wydaje się (chociaż nie widziałem żadnej właściwej dokumentacji na ten temat) add
i mul
mogą być wykonywane równolegle, dając teoretyczną maksymalną przepustowość czterech flopów na cykl.
Jednak nie byłem w stanie replikować tej wydajności za pomocą prostego programu C / C ++. Moja najlepsza próba przyniosła około 2,7 flopa / cykl. Jeśli ktoś może wnieść prosty program C / C ++ lub asembler, który wykazuje najwyższą wydajność, co byłoby bardzo mile widziane.
Moja próba:
#include <stdio.h>
#include <stdlib.h>
#include <math.h>
#include <sys/time.h>
double stoptime(void) {
struct timeval t;
gettimeofday(&t,NULL);
return (double) t.tv_sec + t.tv_usec/1000000.0;
}
double addmul(double add, double mul, int ops){
// Need to initialise differently otherwise compiler might optimise away
double sum1=0.1, sum2=-0.1, sum3=0.2, sum4=-0.2, sum5=0.0;
double mul1=1.0, mul2= 1.1, mul3=1.2, mul4= 1.3, mul5=1.4;
int loops=ops/10; // We have 10 floating point operations inside the loop
double expected = 5.0*add*loops + (sum1+sum2+sum3+sum4+sum5)
+ pow(mul,loops)*(mul1+mul2+mul3+mul4+mul5);
for (int i=0; i<loops; i++) {
mul1*=mul; mul2*=mul; mul3*=mul; mul4*=mul; mul5*=mul;
sum1+=add; sum2+=add; sum3+=add; sum4+=add; sum5+=add;
}
return sum1+sum2+sum3+sum4+sum5+mul1+mul2+mul3+mul4+mul5 - expected;
}
int main(int argc, char** argv) {
if (argc != 2) {
printf("usage: %s <num>\n", argv[0]);
printf("number of operations: <num> millions\n");
exit(EXIT_FAILURE);
}
int n = atoi(argv[1]) * 1000000;
if (n<=0)
n=1000;
double x = M_PI;
double y = 1.0 + 1e-8;
double t = stoptime();
x = addmul(x, y, n);
t = stoptime() - t;
printf("addmul:\t %.3f s, %.3f Gflops, res=%f\n", t, (double)n/t/1e9, x);
return EXIT_SUCCESS;
}
Kompilowany z
g++ -O2 -march=native addmul.cpp ; ./a.out 1000
produkuje następujące dane wyjściowe na procesorze Intel Core i5-750, 2,66 GHz.
addmul: 0.270 s, 3.707 Gflops, res=1.326463
Oznacza to, że tylko około 1,4 klap na cykl. Patrzenie na kod asemblera z
g++ -S -O2 -march=native -masm=intel addmul.cpp
główną pętlą wydaje mi się optymalne:
.L4:
inc eax
mulsd xmm8, xmm3
mulsd xmm7, xmm3
mulsd xmm6, xmm3
mulsd xmm5, xmm3
mulsd xmm1, xmm3
addsd xmm13, xmm2
addsd xmm12, xmm2
addsd xmm11, xmm2
addsd xmm10, xmm2
addsd xmm9, xmm2
cmp eax, ebx
jne .L4
Zmiana wersji skalarnej na wersję spakowaną ( addpd
i mulpd
) podwoiłaby liczbę flopów bez zmiany czasu wykonania, więc brakowało mi tylko 2,8 flopów na cykl. Czy istnieje prosty przykład, który pozwala uzyskać cztery klapy na cykl?
Miły mały program Mysticial; oto moje wyniki (uruchom tylko na kilka sekund):
gcc -O2 -march=nocona
: 5,6 Gflops z 10,66 Gflops (2,1 flops / cykl)cl /O2
, usunięto openmp: 10,1 Gflops z 10,66 Gflops (3,8 flops / cykl)
Wszystko wydaje się nieco skomplikowane, ale moje dotychczasowe wnioski:
gcc -O2
zmienia kolejność niezależnych operacji zmiennoprzecinkowych w celu naprzemiennegoaddpd
imulpd
, jeśli to możliwe. To samo dotyczygcc-4.6.2 -O2 -march=core2
.gcc -O2 -march=nocona
wydaje się utrzymywać kolejność operacji zmiennoprzecinkowych, jak zdefiniowano w źródle C ++.cl /O2
, 64-bitowy kompilator z zestawu SDK dla systemu Windows 7 automatycznie rozwija pętlę i wydaje się, że próbuje zorganizować operacje tak, aby grupy trzechaddpd
zmieniały się z trzemamulpd
(cóż, przynajmniej w moim systemie i dla mojego prostego programu) .Mój Core i5 750 ( architektura Nehalem ) nie lubi na przemian dodawania i dodawania i wydaje się, że nie jest w stanie wykonywać obu operacji równolegle. Jednak po zgrupowaniu w 3 nagle działa jak magia.
Inne architektury (prawdopodobnie Sandy Bridge i inne) wydają się być w stanie wykonywać add / mul równolegle bez problemów, jeśli występują naprzemiennie w kodzie asemblera.
Chociaż trudno to przyznać, ale w moim systemie
cl /O2
wykonuje znacznie lepszą pracę przy operacjach optymalizacji niskiego poziomu w moim systemie i osiąga prawie najwyższą wydajność w przypadku małego przykładu C ++ powyżej. Zmierzyłem między 1,85-2,01 flop / cykl (użyłem clock () w Windowsie, co nie jest tak precyzyjne. Chyba muszę użyć lepszego timera - dzięki Mackie Messer).Najlepsze, z czym mogłem zarządzać,
gcc
to ręczne zapętlanie rozwijania i układanie dodatków i mnożenia w grupach po trzy. Zeg++ -O2 -march=nocona addmul_unroll.cpp
mam w najlepszym wypadku0.207s, 4.825 Gflops
co odpowiada 1,8 japonki / cykl którego jestem bardzo zadowolony z obecnie.
W kodzie C ++ zastąpiłem for
pętlę
for (int i=0; i<loops/3; i++) {
mul1*=mul; mul2*=mul; mul3*=mul;
sum1+=add; sum2+=add; sum3+=add;
mul4*=mul; mul5*=mul; mul1*=mul;
sum4+=add; sum5+=add; sum1+=add;
mul2*=mul; mul3*=mul; mul4*=mul;
sum2+=add; sum3+=add; sum4+=add;
mul5*=mul; mul1*=mul; mul2*=mul;
sum5+=add; sum1+=add; sum2+=add;
mul3*=mul; mul4*=mul; mul5*=mul;
sum3+=add; sum4+=add; sum5+=add;
}
A teraz zestaw wygląda
.L4:
mulsd xmm8, xmm3
mulsd xmm7, xmm3
mulsd xmm6, xmm3
addsd xmm13, xmm2
addsd xmm12, xmm2
addsd xmm11, xmm2
mulsd xmm5, xmm3
mulsd xmm1, xmm3
mulsd xmm8, xmm3
addsd xmm10, xmm2
addsd xmm9, xmm2
addsd xmm13, xmm2
...
źródło
-funroll-loops
). Próbowałem z gcc w wersji 4.4.1 i 4.6.2, ale wyjście asm wygląda dobrze?-O3
gcc, który umożliwia-ftree-vectorize
? Może w połączeniu z-funroll-loops
tym nie robię, jeśli jest to naprawdę konieczne. W końcu porównanie wydaje się niesprawiedliwe, jeśli jeden z kompilatorów wykonuje wektoryzację / rozwijanie, podczas gdy drugi nie robi tego, ponieważ nie może, ale dlatego, że nie jest mu powiedziane.-funroll-loops
to prawdopodobnie coś, czego można spróbować. Ale myślę, że-ftree-vectorize
to poza tym. OP stara się utrzymać 1 milion + 1 instrukcja dodawania / cykl. Instrukcje mogą być skalarne lub wektorowe - nie ma to znaczenia, ponieważ opóźnienia i przepustowość są takie same. Jeśli więc możesz utrzymać 2 / cykl za pomocą skalarnego SSE, możesz zastąpić je wektorowym SSE i uzyskasz 4 flopy / cykl. W mojej odpowiedzi właśnie to zrobiłem wychodząc z SSE -> AVX. Wszystkie SSE zastąpiłem AVX - te same opóźnienia, te same przepustowości, 2x flop.Odpowiedzi:
Zrobiłem już dokładnie to zadanie. Ale miał głównie na celu pomiar zużycia energii i temperatur procesora. Poniższy kod (który jest dość długi) osiąga wartość zbliżoną do optymalnej na moim Core i7 2600K.
Najważniejszą rzeczą do odnotowania tutaj jest ogromna ilość ręcznego rozwijania pętli, a także przeplatanie mnożników i dodaje ...
Pełny projekt można znaleźć na moim GitHubie: https://github.com/Mysticial/Flops
Ostrzeżenie:
Jeśli zdecydujesz się go skompilować i uruchomić, zwróć uwagę na temperaturę procesora !!!
Upewnij się, że go nie przegrzejesz. I upewnij się, że dławienie procesora nie wpływa na twoje wyniki!
Ponadto nie biorę odpowiedzialności za jakiekolwiek szkody, które mogą wyniknąć z uruchomienia tego kodu.
Uwagi:
ICC 11 (Intel Compiler 11) nieoczekiwanie ma problemy z jego kompilacją.
Dane wyjściowe (1 wątek, 10000000 iteracji) - Kompilacja z Visual Studio 2010 SP1 - wydanie x64:
Maszyna to Core i7 2600K @ 4,4 GHz. Teoretyczny szczyt SSE to 4 klapy * 4,4 GHz = 17,6 GFlops . Ten kod osiąga 17,3 GFlops - niezły.
Dane wyjściowe (8 wątków, 10000000 iteracji) - Kompilacja z Visual Studio 2010 SP1 - wydanie x64:
Teoretyczny szczyt SSE to 4 klapy * 4 rdzenie * 4,4 GHz = 70,4 GFlops. Rzeczywista jest 65,5 GFlops .
Zróbmy krok dalej. AVX ...
Dane wyjściowe (1 wątek, 10000000 iteracji) - Kompilacja z Visual Studio 2010 SP1 - wydanie x64:
Teoretyczny szczyt AVX to 8 klap * 4,4 GHz = 35,2 GFlops . Rzeczywista jest 33,4 GFlops .
Dane wyjściowe (8 wątków, 10000000 iteracji) - Kompilacja z Visual Studio 2010 SP1 - wydanie x64:
Teoretyczny szczyt AVX to 8 klap * 4 rdzenie * 4,4 GHz = 140,8 GFlops. Rzeczywista wartość to 138,2 GFlops .
Teraz kilka wyjaśnień:
Najważniejszą częścią wydajności jest oczywiście 48 instrukcji wewnątrz wewnętrznej pętli. Zauważysz, że jest on podzielony na 4 bloki po 12 instrukcji. Każdy z tych 12 bloków instrukcji jest całkowicie od siebie niezależny - jego wykonanie zajmuje średnio 6 cykli.
Jest więc 12 instrukcji i 6 cykli między wydaniami. Opóźnienie mnożenia wynosi 5 cykli, więc wystarczy, aby uniknąć opóźnień.
Krok normalizacji jest potrzebny, aby zapobiec przepełnieniu / niedopełnieniu danych. Jest to konieczne, ponieważ kod „nic nie rób” powoli zwiększy / zmniejszy wielkość danych.
Tak więc rzeczywiście można to zrobić lepiej, jeśli użyjesz wszystkich zer i pozbędziesz się etapu normalizacji. Ponieważ jednak napisałem test porównawczy do pomiaru zużycia energii i temperatury, musiałem się upewnić, że na flopach są „rzeczywiste” dane, a nie zera - ponieważ jednostki wykonawcze mogą bardzo dobrze obsługiwać przypadki dla zer, które zużywają mniej energii i wytwarzają mniej ciepła.
Więcej wyników:
Wątki: 1
Teoretyczny szczyt SSE: 4 klapy * 3,5 GHz = 14,0 GFlops . Rzeczywista jest 13,3 GFlops .
Wątki: 8
Teoretyczny szczyt SSE: 4 klapy * 4 rdzenie * 3,5 GHz = 56,0 GFlops . Rzeczywista wartość to 51,3 GFlops .
Mój procesor temps uderzył 76C na wielowątkowy przebieg! Jeśli je uruchomisz, upewnij się, że dławienie procesora nie wpływa na wyniki.
Wątki: 1
Teoretyczny szczyt SSE: 4 klapy * 3,2 GHz = 12,8 GFlops . Rzeczywista jest 12,3 GFlops .
Wątki: 8
Teoretyczny szczyt SSE: 4 klapy * 8 rdzeni * 3,2 GHz = 102,4 GFlops . Rzeczywista wartość to 97,9 GFlops .
źródło
1.814s, 5.292 Gflops, sum=0.448883
osiągam prawie tak dobrych wyników: 100 000 iteracji, poza szczytowym 10,68 Gflops lub po prostu 2,0 2,0 na cykl. Wydaje sięadd
/mul
nie są wykonywane równolegle. Kiedy zmieniam kod i zawsze dodam / pomnożę z tym samym rejestrem, powiedzmyrC
, nagle osiąga prawie szczyt:0.953s, 10.068 Gflops, sum=0
lub 3,8 flops / cykl. Bardzo dziwny.cl /O2
(64-bit z Windows SDK), a nawet mój przykład działa tam blisko szczytu dla operacji skalarnych (1,9 flops / cykl). Pętla kompilatora rozwija się i zmienia kolejność, ale to może nie być powód, aby przyjrzeć się temu trochę bardziej. Ograniczanie nie stanowi problemu Jestem miły dla mojego procesora i utrzymuję iteracje na 100k. :)W architekturze Intel jest punkt, o którym ludzie często zapominają, że porty wysyłania są wspólne dla Int i FP / SIMD. Oznacza to, że otrzymasz tylko pewną liczbę serii FP / SIMD, zanim logika pętli utworzy bąbelki w strumieniu zmiennoprzecinkowym. Mystical uzyskał więcej klap ze swojego kodu, ponieważ używał dłuższych kroków w rozwiniętej pętli.
Jeśli spojrzysz na architekturę Nehalem / Sandy Bridge tutaj http://www.realworldtech.com/page.cfm?ArticleID=RWT091810191937&p=6 , jest całkiem jasne, co się dzieje.
Z drugiej strony powinno być łatwiej osiągnąć maksymalną wydajność na AMD (Bulldozer), ponieważ rury INT i FP / SIMD mają osobne porty danych z własnym harmonogramem.
Jest to tylko teoretyczne, ponieważ nie mam żadnego z tych procesorów do przetestowania.
źródło
inc
,cmp
orazjl
. Wszystkie z nich mogą przejść do portu nr 5 i nie zakłócać ani wektoryzacji,fadd
anifmul
. Wolałbym raczej podejrzewać, że dekoder (czasami) przeszkadza. Musi utrzymać od dwóch do trzech instrukcji na cykl. Nie pamiętam dokładnych ograniczeń, ale długość instrukcji, prefiksy i wyrównanie wchodzą w grę.cmp
i najl
pewno udaj się do portu 5,inc
nie tak pewny, ponieważ zawsze jest w grupie z 2 innymi. Ale masz rację, trudno powiedzieć, gdzie jest wąskie gardło, a dekodery również mogą być jego częścią.Oddziały zdecydowanie mogą powstrzymać Cię od utrzymania maksymalnej wydajności teoretycznej. Czy widzisz różnicę, jeśli ręcznie rozwijasz pętlę? Na przykład, jeśli dodasz 5 lub 10 razy więcej operacji na iterację w pętli:
źródło
-funroll-loops
opcji, która nawet nie jest uwzględniona-O3
. Zobaczyćg++ -c -Q -O2 --help=optimizers | grep unroll
.Używam Intels icc wersja 11.1 na 2,4 GHz Intel Core 2 Duo
To bardzo blisko idealnych 9,6 Gflops.
EDYTOWAĆ:
Ups, patrząc na kod asemblera, wydaje się, że icc nie tylko wektoryzuje mnożenie, ale także wyciąga dodatki z pętli. Wymuszając surowszą semantykę fp, kod nie jest już wektoryzowany:
EDYCJA 2:
Zgodnie z prośbą:
Wewnętrzna pętla kodu clanga wygląda następująco:
EDYCJA 3:
Na koniec dwie sugestie: Po pierwsze, jeśli podoba ci się ten typ testu porównawczego, rozważ użycie
rdtsc
instrukcji zamiastgettimeofday(2)
. Jest o wiele bardziej dokładny i zapewnia czas w cyklach, co zwykle jest tym, czym jesteś zainteresowany. W przypadku gcc i znajomych możesz to zdefiniować w następujący sposób:Po drugie, powinieneś uruchomić program testowy kilka razy i korzystać wyłącznie z najlepszej wydajności . We współczesnych systemach operacyjnych wiele rzeczy dzieje się równolegle, procesor może być w trybie oszczędzania energii niskiej częstotliwości itp. Wielokrotne uruchamianie programu daje wynik zbliżony do idealnego przypadku.
źródło
addsd
,mulsd
czy też są w grupach jak w danych wyjściowych mojego zestawu? Dostaję również około 1 flopa / cykl, gdy kompilator je miesza (bez czego otrzymuję-march=native
). Jak zmienia się wydajność, jeśli dodasz wierszadd=mul;
na początku funkcjiaddmul(...)
?addsd
isubsd
są rzeczywiście mieszane w dokładnej wersji. Próbowałem też clang 3.0, nie miesza instrukcji i zbliża się do 2 flopów / cyklu w duecie Core 2. Kiedy uruchamiam ten sam kod na rdzeniu mojego laptopa i5, mieszanie kodu nie ma znaczenia. W obu przypadkach otrzymuję około 3 flopów / cykl.icc
wcześniej, czy możesz dwukrotnie sprawdzić zespół?