Mam 2 kolumny liczb całkowitych rozdzielanych tabulatorami, z których pierwsza jest liczbą całkowitą losową, druga liczbą całkowitą identyfikującą grupę, którą może wygenerować ten program. ( generate_groups.cc
)
#include <cstdlib>
#include <iostream>
#include <ctime>
int main(int argc, char* argv[]) {
int num_values = atoi(argv[1]);
int num_groups = atoi(argv[2]);
int group_size = num_values / num_groups;
int group = -1;
std::srand(42);
for (int i = 0; i < num_values; ++i) {
if (i % group_size == 0) {
++group;
}
std::cout << std::rand() << '\t' << group << '\n';
}
return 0;
}
Następnie używam drugiego programu ( sum_groups.cc
) do obliczenia sum na grupę.
#include <iostream>
#include <chrono>
#include <vector>
// This is the function whose performance I am interested in
void grouped_sum(int* p_x, int *p_g, int n, int* p_out) {
for (size_t i = 0; i < n; ++i) {
p_out[p_g[i]] += p_x[i];
}
}
int main() {
std::vector<int> values;
std::vector<int> groups;
std::vector<int> sums;
int n_groups = 0;
// Read in the values and calculate the max number of groups
while(std::cin) {
int value, group;
std::cin >> value >> group;
values.push_back(value);
groups.push_back(group);
if (group > n_groups) {
n_groups = group;
}
}
sums.resize(n_groups);
// Time grouped sums
std::chrono::system_clock::time_point start = std::chrono::system_clock::now();
for (int i = 0; i < 10; ++i) {
grouped_sum(values.data(), groups.data(), values.size(), sums.data());
}
std::chrono::system_clock::time_point end = std::chrono::system_clock::now();
std::cout << (end - start).count() << std::endl;
return 0;
}
Jeśli następnie uruchomię te programy na zestawie danych o danym rozmiarze, a następnie przetasuję kolejność wierszy tego samego zestawu danych, przetasowane dane obliczą sumy ~ 2x lub więcej szybciej niż zamówione dane.
g++ -O3 generate_groups.cc -o generate_groups
g++ -O3 sum_groups.cc -o sum_groups
generate_groups 1000000 100 > groups
shuf groups > groups2
sum_groups < groups
sum_groups < groups2
sum_groups < groups2
sum_groups < groups
20784
8854
8220
21006
Spodziewałbym się, że oryginalne dane posortowane według grup będą miały lepszą lokalizację danych i będą szybsze, ale obserwuję odwrotne zachowanie. Zastanawiałem się, czy ktoś może postawić hipotezę o przyczynie?
c++
performance
Jim
źródło
źródło
.at()
lub tryb debugowania,operator[]
który ogranicza sprawdzanie, czy zobaczysz.sum
. Zamiastsums.reserve(n_groups);
ciebie musisz zadzwonićsums.resize(n_groups);
- tak sugerował @Shawn.p_out[p_g[i]] += p_x[i];
. Być może w oryginalnej, zakodowanej kolejności, grupy faktycznie wykazują dobre grupowanie w odniesieniu do dostępu dop_out
tablicy. Sortowanie wartości może być przyczyną złego wzorca indeksowania grup dop_out
.Odpowiedzi:
Ustaw / spowolnij
Przede wszystkim program działa mniej więcej w tym samym czasie, niezależnie od:
Większość czasu spędza się w pętli wejściowej. Ale ponieważ jesteśmy zainteresowani
grouped_sum()
, zignorujmy to.Zmiana pętli testu z 10 na 1000 iteracji
grouped_sum()
zaczyna dominować w czasie wykonywania:perf diff
Teraz możemy użyć,
perf
aby znaleźć najgorętsze miejsca w naszym programie.I różnica między nimi:
Więcej czasu w
main()
, co prawdopodobnie sięgrouped_sum()
skończyło. Świetnie, wielkie dzięki, perf.adnotacja perf
Czy jest różnica w tym, gdzie spędza się czas w środku
main()
?Przetasowane:
Posortowane:
Nie, dominują te same dwie instrukcje. W obu przypadkach zajmują dużo czasu, ale są jeszcze gorsze, gdy dane są sortowane.
perf stat
W porządku. Ale powinniśmy je uruchamiać tyle samo razy, więc każda instrukcja z jakiegoś powodu musi być wolniejsza. Zobaczmy, co
perf stat
mówi.Wyróżnia się tylko jedno: front-stalled-cycles-frontend .
Okej, rurociąg instrukcji zwleka. W interfejsie. Dokładnie to, co to oznacza, prawdopodobnie różni się między mikroarchektekturami.
Ale zgaduję. Jeśli jesteś hojny, możesz nawet nazwać to hipotezą.
Hipoteza
Sortując dane wejściowe, zwiększasz lokalizację zapisów. W rzeczywistości będą one bardzo lokalne; prawie wszystkie dodatki, które robisz, będą zapisywać w tej samej lokalizacji, co poprzednia.
To świetnie dla pamięci podręcznej, ale nie jest dobre dla potoku. Wprowadzasz zależności danych, uniemożliwiając kontynuowanie następnej instrukcji dodawania, aż do zakończenia poprzedniej operacji dodawania (lub w inny sposób udostępnienia wyniku kolejnym instrukcjom )
To Twój problem.
Myślę.
Naprawić to
Wektory z wieloma sumami
Właściwie spróbujmy czegoś. Co jeśli użyjemy wielu wektorów sumarycznych, przełączając się między nimi dla każdego dodania, a następnie sumując je na końcu? Kosztuje nas trochę lokalizacji, ale powinien usunąć zależności danych.
(kod nie jest ładny; nie oceniaj mnie, internet !!)
(och, a także naprawiłem obliczenia n_groups; było wyłączone o jeden).
Wyniki
Po skonfigurowaniu mojego pliku makefile do podania
-DNSUMS=...
argumentu kompilatorowi mogłem to zrobić:Optymalna liczba wektorów sumy będzie prawdopodobnie zależeć od głębokości potoku procesora. Mój 7-letni ultrabook CPU może prawdopodobnie wydostać się z potoku za pomocą mniejszej liczby wektorów, niż byłby potrzebny nowy, wymyślny procesor do komputerów stacjonarnych.
Oczywiście, więcej niekoniecznie jest lepsze; kiedy oszalałem z powodu 128 wektorów sumarycznych, zaczęliśmy cierpieć bardziej z powodu braków w pamięci podręcznej - o czym świadczy to, że tasowanie danych wejściowych staje się wolniejsze niż sortowanie, jak pierwotnie oczekiwano. Zatoczyliśmy koło! :)
Suma na grupę w rejestrze
(zostało to dodane w edycji)
Agh, nerd snajper ! Jeśli wiesz, że twoje dane wejściowe zostaną posortowane i szukasz jeszcze większej wydajności, następujące przepisywanie funkcji (bez dodatkowych tablic sum) jest jeszcze szybsze, przynajmniej na moim komputerze.
Sztuczka polega na tym, że pozwala kompilatorowi przechowywać
gsum
zmienną, sumę grupy, w rejestrze. Zgaduję (ale może się mylić), że jest to szybsze, ponieważ pętla sprzężenia zwrotnego w potoku może być tutaj krótsza i / lub mniejszy dostęp do pamięci. Dobry predyktor gałęzi sprawi, że dodatkowe sprawdzenie równości grupy będzie tanie.Wyniki
To okropne dla tasowania danych wejściowych ...
... ale jest o około 40% szybszy niż moje rozwiązanie „wielu sum” dla posortowanych danych wejściowych.
Wiele małych grup będzie wolniejszych niż kilka dużych, więc to, czy jest to szybsza implementacja, naprawdę zależy od twoich danych. I jak zawsze w twoim modelu procesora.
Wektory z wieloma sumami, z przesunięciem zamiast maskowania bitów
Sopel zasugerował cztery rozwinięte dodatki jako alternatywę dla mojego podejścia maskowania bitów. Wdrożyłem uogólnioną wersję ich sugestii, która może obsługiwać różne
NSUMS
. Liczę na to, że kompilator rozwinie dla nas wewnętrzną pętlę (przynajmniej tak zrobiłaNSUMS=4
).Wyniki
Czas na pomiar. Zauważ, że ponieważ wczoraj pracowałem w / tmp, nie mam dokładnie tych samych danych wejściowych. Dlatego wyniki te nie są bezpośrednio porównywalne z poprzednimi (ale prawdopodobnie wystarczająco blisko).
Tak, wewnętrzna pętla z
NSUMS=8
jest najszybsza na moim komputerze. W porównaniu z moim podejściem „lokalnego gsum” ma to tę dodatkową zaletę, że nie staje się straszne dla przetasowanych danych wejściowych.Warto zauważyć:
NSUMS=16
staje się gorszy niżNSUMS=8
. Może to być spowodowane tym, że zaczynamy widzieć więcej braków pamięci podręcznej lub dlatego, że nie mamy wystarczającej liczby rejestrów, aby prawidłowo rozwinąć wewnętrzną pętlę.źródło
perf
.Oto dlaczego posortowane grupy są wolniejsze niż grupy nieposortowane;
Najpierw jest kod asemblera dla sumowania pętli:
Spójrzmy na instrukcję add, która jest głównym powodem tego problemu;
Gdy procesor najpierw wykona tę instrukcję, wyda żądanie odczytu (załadowania) pamięci na adres w edx, następnie doda wartość ecx, a następnie wyda żądanie zapisu (przechowywania) dla tego samego adresu.
istnieje funkcja ponownego zamawiania pamięci rozmówcy procesora
i jest reguła
Więc jeśli następna iteracja osiągnie instrukcję add przed zakończeniem żądania zapisu, nie będzie czekać, czy adres edx jest inny niż poprzednia wartość, i wyda żądanie odczytu i zmieni kolejność na starszym żądaniu zapisu, a instrukcja dodawania będzie kontynuowana. ale jeśli adres jest taki sam, instrukcja add będzie czekać na zakończenie starego zapisu.
Zauważ, że pętla jest krótka i procesor może wykonać ją szybciej, niż kontroler pamięci zakończy żądanie zapisu do pamięci.
więc dla posortowanych grup będziesz odczytywał i zapisywał wiele razy pod tym samym adresem, aby utracić poprawę wydajności za pomocą zmiany kolejności pamięci; tymczasem jeśli zostaną użyte losowe grupy, to każda iteracja będzie prawdopodobnie mieć inny adres, więc odczyt nie będzie czekał na starszy zapis i zostanie ponownie uporządkowany; instrukcja add nie będzie czekać na poprzednią.
źródło