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
...
-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.