C++: tajemniczo ogromne przyspieszenie od posiadania jednego operanda w rejestrze
Próbowałem uzyskać wyobrażenie o wpływie posiadania tablicy w pamięci podręcznej L1 W porównaniu z pamięcią, synchronizując procedurę, która skaluje i sumuje elementy tablicy za pomocą następującego kodu (jestem świadomy, że powinienem po prostu skalować wynik przez 'a' na końcu; chodzi o to, aby zarówno mnożenie, jak i dodawanie w pętli - jak dotąd kompilator nie domyślił się, aby uwzględnić 'a'): {]}
double sum(double a,double* X,int size)
{
double total = 0.0;
for(int i = 0; i < size; ++i)
{
total += a*X[i];
}
return total;
}
#define KB 1024
int main()
{
//Approximately half the L1 cache size of my machine
int operand_size = (32*KB)/(sizeof(double)*2);
printf("Operand size: %d\n", operand_size);
double* X = new double[operand_size];
fill(X,operand_size);
double seconds = timer();
double result;
int n_iterations = 100000;
for(int i = 0; i < n_iterations; ++i)
{
result = sum(3.5,X,operand_size);
//result += rand();
}
seconds = timer() - seconds;
double mflops = 2e-6*double(n_iterations*operand_size)/seconds;
printf("Vector size %d: mflops=%.1f, result=%.1f\n",operand_size,mflops,result);
return 0;
}
Zauważ, że procedury timer() i fill() nie są uwzględniane dla zwięzłości; ich pełne źródło może znajdziesz tutaj, jeśli chcesz uruchomić kod:
Tutaj robi się ciekawie. To jest wyjście:Operand size: 2048
Vector size 2048: mflops=588.8, result=-67.8
Jest to wydajność całkowicie niebuforowana, pomimo faktu, że wszystkie elementy x powinny być przechowywane w pamięci podręcznej między iteracjami pętli. Patrząc na kod assembly wygenerowany przez:
g++ -O3 -S -fno-asynchronous-unwind-tables register_opt_example.cpp
Zauważam jedną dziwność w pętli funkcji sum:
L55:
movsd (%r12,%rax,8), %xmm0
mulsd %xmm1, %xmm0
addsd -72(%rbp), %xmm0
movsd %xmm0, -72(%rbp)
incq %rax
cmpq $2048, %rax
jne L55
Instrukcja:
addsd -72(%rbp), %xmm0
movsd %xmm0, -72(%rbp)
Wskaż, że jest zapisanie wartości "total" w sum () na stosie oraz odczyt i zapis przy każdej iteracji pętli. Zmodyfikowałem zespół tak, aby ten operand był przechowywany w rejestrze a:
...
addsd %xmm0, %xmm3
...
Ta mała zmiana tworzy ogromny wzrost wydajności:
Operand size: 2048
Vector size 2048: mflops=1958.9, result=-67.8
Tl; dr Moje pytanie brzmi: dlaczego zastąpienie dostępu do pojedynczej lokalizacji pamięci rejestrem przyspiesza kod tak bardzo, biorąc pod uwagę, że pojedyncza lokalizacja powinna być przechowywana w pamięci podręcznej L1? Jakie czynniki architektoniczne to możliwe? Wydaje się bardzo dziwne, że wielokrotne pisanie jednej lokalizacji stosu całkowicie zniszczyłoby skuteczność pamięci podręcznej.
Dodatek
Moja wersja gcc to:
Target: i686-apple-darwin10
Configured with: /var/tmp/gcc/gcc-5646.1~2/src/configure --disable-checking --enable-werror --prefix=/usr --mandir=/share/man --enable-languages=c,objc,c++,obj-c++ --program-transform-name=/^[cg][^.-]*$/s/$/-4.2/ --with-slibdir=/usr/lib --build=i686-apple-darwin10 --with-gxx-include-dir=/include/c++/4.2.1 --program-prefix=i686-apple-darwin10- --host=x86_64-apple-darwin10 --target=i686-apple-darwin10
Thread model: posix
gcc version 4.2.1 (Apple Inc. build 5646) (dot 1)
Mój procesor to:
Intel Xeon X5650
3 answers
Jest to prawdopodobnie kombinacja dłuższego łańcucha zależności, wraz z błędną interpretacją obciążenia*.
Dłuższy Łańcuch Zależności:
Najpierw identyfikujemy krytyczne ścieżki zależności. Następnie przyjrzymy się latencji instrukcji dostarczonej przez: http://www.agner.org/optimize/instruction_tables.pdf (strona 117)
W wersji nieoptymalizowanej krytyczna ścieżka zależności To:
addsd -72(%rbp), %xmm0
movsd %xmm0, -72(%rbp)
Wewnętrznie, prawdopodobnie dzieli się na:
- obciążenie (2 cykle)
- addsd (3 cykle)
- store (3 cykle)
Jeśli spojrzymy na wersję zoptymalizowaną, to po prostu:
- addsd (3 cykle)
Więc masz 8 cykli vs.3 cykle. Prawie 3.
Nie jestem pewien, jak wrażliwa jest linia procesorów Nehalem do przechowywania zależności obciążenia i jak dobrze to robiforwarding . Ale rozsądne jest wierzyć, że nie jest zero.
Błąd w Load-store:
Współczesne procesory wykorzystują przewidywania na więcej sposobów, jakie można sobie wyobrazić. Najbardziej znanym z nich jest prawdopodobnie Przewidywanie gałęzi . Jednym z mniej znanych jest przewidywanie obciążenia.
Gdy procesor widzi obciążenie, natychmiast załaduje je przed zakończeniem wszystkich oczekujących zapisów. Zakłada, że te zapisy nie będą sprzeczne z załadowanymi wartościami.
Jeśli wcześniejszy zapis okaże się sprzeczny z obciążeniem, to obciążenie musi zostać ponownie wykonane, a obliczenia wycofane do punktu obciążenia. (w taki sam sposób, w jaki błędne interpretacje gałęzi cofają się)
Jak to jest istotne tutaj:
Nie trzeba dodawać, że nowoczesne procesory będą w stanie wykonywać wiele iteracji tej pętli jednocześnie. Procesor będzie więc próbował wykonać load (addsd -72(%rbp), %xmm0)
, zanim zakończy Store (movsd %xmm0, -72(%rbp)
) z poprzedniej iteracji.
*zauważ, że nie jestem pewien nazwy "Przewidywanie obciążenia". Czytałem o tym tylko w dokumentach i nie dali mu nazwy.
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-05-23 12:02:02
Domyślam się, że problem nie leży w pamięci podręcznej / dostępie do pamięci, ale w procesorze(wykonanie kodu). Jest tu kilka widocznych wąskich gardeł.
Numery wydajności były oparte na pudełkach, których używałem (sandybridge lub westmere)
Maksymalna wydajność dla matematyki skalarnej to 2,7 Ghz X2 FLOPS / Clock x2 ponieważ procesor może jednocześnie dodawać i mnożyć. Teoretyczna sprawność kodu to 0.6/(2.7*2) = 11%
Potrzebna przepustowość: 2 dwuosobowe na ( + ) i (x) - > 4bytes / Flop 4 bajty * 5.4 GFLOPS = 21.6 GB/s
Jeśli wiesz, że został odczytany niedawno prawdopodobnie w L1( 89GB / s), L2 (42GB/s) lub L3(24GB/s), Więc możemy wykluczyć pamięć podręczną B/w
Pamięć susbsystem wynosi 18,9 GB / s, więc nawet w pamięci głównej, Maksymalna wydajność powinna zbliżać się do 18,9/21,6 GB / s = 87,5%
- może chcieć załadować żądania (poprzez rozwijanie) tak wcześnie, jak to możliwe
Nawet przy wykonaniu spekulacyjnym, tot + = a * X [i] dodawanie będzie serializowane, ponieważ tot(n) musi być eval ' d zanim tot (n+1) może być rozpoczęty
Pierwsza pętla rozwiń
move I by 8 ' s and do
{//your func
for( int i = 0; i < size; i += 8 ){
tot += a * X[i];
tot += a * X[i+1];
...
tot += a * X[i+7];
}
return tot
}
Użyj wielu akumulatorów
Spowoduje to zerwanie zależności i pozwoli nam uniknąć przeciągania się potoku dodawania
{//your func//
int tot,tot2,tot3,tot4;
tot = tot2 = tot3 = tot4 = 0
for( int i = 0; i < size; i += 8 )
tot += a * X[i];
tot2 += a * X[i+1];
tot3 += a * X[i+2];
tot4 += a * X[i+3];
tot += a * X[i+4];
tot2 += a * X[i+5];
tot3 += a * X[i+6];
tot4 += a * X[i+7];
}
return tot + tot2 + tot3 + tot4;
}
UPDATE Po uruchomieniu tego na SandyBridge box mam dostęp do: (2.7 GHZ SandyBridge with-O2-march=native-mtune=native
Oryginalny kod:
Operand size: 2048
Vector size 2048: mflops=2206.2, result=61.8
2.206 / 5.4 = 40.8%
Poprawiony Kod:
Operand size: 2048
Vector size 2048: mflops=5313.7, result=61.8
5.3137 / 5.4 = 98.4%
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
2013-04-05 23:04:04
Nie mogę tego odtworzyć, ponieważ mój kompilator (gcc 4.7.2) trzyma total
w rejestrze.
Podejrzewam, że główny powód spowolnienia nie ma związku z buforem L1, ale raczej wynika z zależności między danymi w
movsd %xmm0, -72(%rbp)
I obciążenie kolejnej iteracji:
addsd -72(%rbp), %xmm0
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
2013-03-27 17:56:21