Po raz pierwszy zauważyłem w 2009 roku, że GCC (przynajmniej w moich projektach i na moich maszynach) ma tendencję do generowania zauważalnie szybszego kodu, jeśli optymalizuję pod kątem rozmiaru ( -Os
) zamiast prędkości ( -O2
lub -O3
), i od tego czasu zastanawiam się, dlaczego.
Udało mi się stworzyć (raczej głupiutki) kod, który pokazuje to zaskakujące zachowanie i jest wystarczająco mały, aby go tutaj opublikować.
const int LOOP_BOUND = 200000000;
__attribute__((noinline))
static int add(const int& x, const int& y) {
return x + y;
}
__attribute__((noinline))
static int work(int xval, int yval) {
int sum(0);
for (int i=0; i<LOOP_BOUND; ++i) {
int x(xval+sum);
int y(yval+sum);
int z = add(x, y);
sum += z;
}
return sum;
}
int main(int , char* argv[]) {
int result = work(*argv[1], *argv[2]);
return result;
}
Jeśli go skompiluję -Os
, wykonanie tego programu zajmie 0,38 s, a 0,43 s, jeśli jest kompilowany z -O2
lub -O3
. Czasy te są uzyskiwane konsekwentnie i praktycznie bez hałasu (gcc 4.7.2, x86_64 GNU / Linux, Intel Core i5-3320M).
(Aktualizacja: przeniosłem cały kod asemblera do GitHub : sprawili, że post był rozdęty i najwyraźniej dodają bardzo mało wartości do pytań, ponieważ fno-align-*
flagi mają taki sam efekt).
Oto wygenerowany zespół za pomocą -Os
i -O2
.
Niestety, moje rozumienie montaż jest bardzo ograniczony, więc nie mam pojęcia, czy to, co zrobiłem obok była prawidłowa: Złapałem zespół do -O2
i połączyła wszystkie swoje różnice w zespole za -Os
wyjątkiem tych .p2align
linii, prowadzić tutaj . Ten kod nadal działa w ciągu 0,38 s, a jedyną różnicą jest to, co jest .p2align
.
Jeśli dobrze się domyślam, są to podkładki do wyrównania stosu. Według Dlaczego podkładka GCC działa z NOP? robi się to z nadzieją, że kod będzie działał szybciej, ale najwyraźniej ta optymalizacja nie powiodła się w moim przypadku.
Czy winowajcą jest winowajca w tym przypadku? Dlaczego i jak?
Hałas, który powoduje, praktycznie uniemożliwia mikrooptymalizacje czasowe.
Jak mogę się upewnić, że takie przypadkowe wyrównania szczęścia / nieszczęścia nie przeszkadzają, gdy przeprowadzam mikrooptymalizacje (niezwiązane z wyrównaniem stosu) w kodzie źródłowym C lub C ++?
AKTUALIZACJA:
Po odpowiedzi Pascala Cuoqa majstrowałem trochę przy ustawieniach. Po przekazaniu -O2 -fno-align-functions -fno-align-loops
do gcc wszystkie .p2align
znikają ze złożenia, a wygenerowany plik wykonywalny działa w 0,38 sekundy. Zgodnie z dokumentacją gcc :
-Os włącza wszystkie optymalizacje -O2 [ale] -Os wyłącza następujące flagi optymalizacji:
-falign-functions -falign-jumps -falign-loops -falign-labels -freorder-blocks -freorder-blocks-and-partition -fprefetch-loop-arrays
Wydaje się więc, że jest to (źle) problem z wyrównaniem.
Nadal jestem sceptyczny, -march=native
co sugeruje odpowiedź Marata Dukhana . Nie jestem przekonany, że to nie tylko przeszkadza w tym (błędnym) problemie z wyrównaniem; nie ma absolutnie żadnego wpływu na moją maszynę. (Niemniej jednak głosowałem za jego odpowiedzią).
AKTUALIZACJA 2:
Możemy -Os
usunąć zdjęcie. Poniższe czasy są uzyskiwane przez kompilację z
-O2 -fno-omit-frame-pointer
0,37s-O2 -fno-align-functions -fno-align-loops
0,37s-S -O2
następnie ręcznie przesuwając zespóładd()
powork()
0,37 sekundy-O2
0,44s
Wygląda na to, że odległość add()
od strony połączeń ma duże znaczenie. Próbowałem perf
, ale wyniki perf stat
i perf report
nie mają dla mnie większego sensu. Mogłem jednak uzyskać tylko jeden spójny wynik:
-O2
:
602,312,864 stalled-cycles-frontend # 0.00% frontend cycles idle
3,318 cache-misses
0.432703993 seconds time elapsed
[...]
81.23% a.out a.out [.] work(int, int)
18.50% a.out a.out [.] add(int const&, int const&) [clone .isra.0]
[...]
¦ __attribute__((noinline))
¦ static int add(const int& x, const int& y) {
¦ return x + y;
100.00 ¦ lea (%rdi,%rsi,1),%eax
¦ }
¦ ? retq
[...]
¦ int z = add(x, y);
1.93 ¦ ? callq add(int const&, int const&) [clone .isra.0]
¦ sum += z;
79.79 ¦ add %eax,%ebx
Dla fno-align-*
:
604,072,552 stalled-cycles-frontend # 0.00% frontend cycles idle
9,508 cache-misses
0.375681928 seconds time elapsed
[...]
82.58% a.out a.out [.] work(int, int)
16.83% a.out a.out [.] add(int const&, int const&) [clone .isra.0]
[...]
¦ __attribute__((noinline))
¦ static int add(const int& x, const int& y) {
¦ return x + y;
51.59 ¦ lea (%rdi,%rsi,1),%eax
¦ }
[...]
¦ __attribute__((noinline))
¦ static int work(int xval, int yval) {
¦ int sum(0);
¦ for (int i=0; i<LOOP_BOUND; ++i) {
¦ int x(xval+sum);
8.20 ¦ lea 0x0(%r13,%rbx,1),%edi
¦ int y(yval+sum);
¦ int z = add(x, y);
35.34 ¦ ? callq add(int const&, int const&) [clone .isra.0]
¦ sum += z;
39.48 ¦ add %eax,%ebx
¦ }
Dla -fno-omit-frame-pointer
:
404,625,639 stalled-cycles-frontend # 0.00% frontend cycles idle
10,514 cache-misses
0.375445137 seconds time elapsed
[...]
75.35% a.out a.out [.] add(int const&, int const&) [clone .isra.0] ¦
24.46% a.out a.out [.] work(int, int)
[...]
¦ __attribute__((noinline))
¦ static int add(const int& x, const int& y) {
18.67 ¦ push %rbp
¦ return x + y;
18.49 ¦ lea (%rdi,%rsi,1),%eax
¦ const int LOOP_BOUND = 200000000;
¦
¦ __attribute__((noinline))
¦ static int add(const int& x, const int& y) {
¦ mov %rsp,%rbp
¦ return x + y;
¦ }
12.71 ¦ pop %rbp
¦ ? retq
[...]
¦ int z = add(x, y);
¦ ? callq add(int const&, int const&) [clone .isra.0]
¦ sum += z;
29.83 ¦ add %eax,%ebx
Wygląda na to, że opóźniamy połączenie do add()
wolnej sprawy.
Sprawdziłem wszystko , coperf -e
może wypluć na moją maszynę; nie tylko statystyki podane powyżej.
Dla tego samego pliku wykonywalnego stalled-cycles-frontend
pokazuje liniową korelację z czasem wykonania; Nie zauważyłem nic innego, co by tak wyraźnie korelowało. (Porównywanie stalled-cycles-frontend
różnych plików wykonywalnych nie ma dla mnie sensu.)
Jako pierwszy komentarz uwzględniłem brakujące dane z pamięci podręcznej. Sprawdziłem wszystkie błędy pamięci podręcznej, które można zmierzyć na mojej maszynie perf
, a nie tylko te podane powyżej. Błędy w pamięci podręcznej są bardzo bardzo głośne i wykazują niewielką lub żadną korelację z czasami wykonania.
Odpowiedzi:
Domyślnie kompilatory optymalizują pod kątem „przeciętnego” procesora. Ponieważ różne procesory preferują różne sekwencje instrukcji, optymalizacje kompilatora włączone przez
-O2
mogą być korzystne dla przeciętnego procesora, ale zmniejszają wydajność konkretnego procesora (i to samo dotyczy-Os
). Jeśli spróbujesz tego samego przykładu na różnych procesorach, przekonasz się, że na niektórych z nich skorzystasz,-O2
podczas gdy inne są bardziej korzystne dla-Os
optymalizacji.Oto wyniki dla
time ./test 0 0
kilku procesorów (raportowany czas użytkownika):W niektórych przypadkach możesz złagodzić efekt niekorzystnych optymalizacji, prosząc
gcc
o optymalizację pod kątem konkretnego procesora (używając opcji-mtune=native
lub-march=native
):Aktualizacja: na Core i3 z Ivy Bridge trzy wersje
gcc
(4.6.4
,4.7.3
i4.8.1
) produkują pliki binarne o znacząco różnej wydajności, ale kod asemblera ma tylko subtelne odmiany. Jak dotąd nie mam wyjaśnienia tego faktu.Montaż z
gcc-4.6.4 -Os
(wykonuje się w 0,709 sek.):Montaż z
gcc-4.7.3 -Os
(wykonuje się w 0,222 sekundy):Montaż z
gcc-4.8.1 -Os
(wykonuje się w 0,994 s):źródło
-O2 -fno-align-functions -fno-align-loops
upuszcza czas0.340s
, więc można to wyjaśnić przez wyrównanie. Jednak optymalne wyrównanie zależy od procesora: niektóre procesory preferują wyrównane pętle i funkcje.Mój kolega pomógł mi znaleźć wiarygodną odpowiedź na moje pytanie. Zauważył wagę granicy 256 bajtów. Nie jest tu zarejestrowany i zachęcił mnie do opublikowania odpowiedzi osobiście (i weź całą sławę).
Krótka odpowiedź:
Wszystko sprowadza się do wyrównania. Dostosowania mogą mieć znaczący wpływ na wydajność, dlatego
-falign-*
na pierwszym miejscu są flagi.Ja składać się (Bogus?) Raport o błędzie do programistów gcc . Okazuje się, że domyślnym zachowaniem jest „ domyślnie wyrównujemy pętle do 8 bajtów, ale próbujemy wyrównać go do 16 bajtów, jeśli nie musimy wypełniać więcej niż 10 bajtów”. Najwyraźniej ta domyślna opcja nie jest najlepszym wyborem w tym konkretnym przypadku i na moim komputerze. Clang 3.4 (trunk)
-O3
wykonuje odpowiednie wyrównanie, a wygenerowany kod nie pokazuje tego dziwnego zachowania.Oczywiście, jeśli zostanie wykonane niewłaściwe wyrównanie, pogorszy to sytuację. Niepotrzebne / złe wyrównanie po prostu zjada bajty bez powodu i potencjalnie zwiększa liczbę braków w pamięci podręcznej itp.
Po prostu mówiąc gcc, aby wykonał właściwe wyrównanie:
g++ -O2 -falign-functions=16 -falign-loops=16
Długa odpowiedź:
Kod będzie działał wolniej, jeśli:
an
XX
bajt brzegowe cięciaadd()
w środku (XX
będąc uzależnionym od maszyny).jeśli wezwanie do
add()
musi przeskoczyćXX
granicę bajtu, a cel nie jest wyrównany.jeśli
add()
nie jest wyrównany.jeśli pętla nie jest wyrównana.
Pierwsze 2 są pięknie widoczne na kodach i wynikach, które uprzejmie opublikował Marat Dukhan . W takim przypadku
gcc-4.8.1 -Os
(wykonuje się w 0,994 s):256-bajtowa granica przecina się
add()
na środku iadd()
ani pętla nie jest wyrównana. Niespodzianka, niespodzianka, to najwolniejszy przypadek!W przypadku
gcc-4.7.3 -Os
(wykonuje się w 0,222 s) granica 256 bajtów przecina tylko zimną sekcję (ale ani pętla, ani nieadd()
jest cięta):Nic nie jest wyrównane, a wywołanie
add()
musi przeskoczyć granicę 256 bajtów. Ten kod jest drugim najwolniejszym.W przypadku
gcc-4.6.4 -Os
(wykonuje się w 0,709 sek.), Chociaż nic nie jest wyrównane, wywołanie doadd()
nie musi przeskakiwać granicy 256 bajtów, a cel jest oddalony o dokładnie 32 bajty:To najszybszy ze wszystkich trzech. Dlaczego granica 256 bajtów jest specyficzna na jego maszynie, zostawię to mu, aby ją rozgryźć. Nie mam takiego procesora.
Teraz na mojej maszynie nie widzę efektu granicy 256 bajtów. Na mojej maszynie uruchamia się tylko funkcja i wyrównanie pętli. Jeśli zdam,
g++ -O2 -falign-functions=16 -falign-loops=16
wszystko wraca do normy: zawsze otrzymuję najszybszy przypadek i czas nie jest już wrażliwy na-fno-omit-frame-pointer
flagę. Mogę przekazaćg++ -O2 -falign-functions=32 -falign-loops=32
lub dowolne wielokrotności 16, kod też nie jest wrażliwy na to.Prawdopodobnym wyjaśnieniem jest to, że miałem punkty aktywne wrażliwe na wyrównanie, tak jak w tym przykładzie. Przez bałagan z flagami (przekazywanie
-Os
zamiast-O2
), te punkty aktywne zostały przypadkowo wyrównane, a kod stał się szybszy. Nie miało to nic wspólnego z optymalizacją pod kątem wielkości: przypadkiem przypadkiem punkty dostępowe zostały lepiej dostosowane. Od teraz sprawdzę efekty wyrównania moich projektów.Aha i jeszcze jedno. Jak mogą powstać takie punkty aktywne, takie jak pokazane w przykładzie? W jaki sposób inliniowanie tak małej funkcji jak
add()
zawodzenie może zawieść?Rozważ to:
oraz w osobnym pliku:
i skompilowany jako:
g++ -O2 add.cpp main.cpp
.gcc nie będzie inline
add()
!To wszystko, tak łatwo nieumyślnie utworzyć punkty aktywne, takie jak ten w PO. Oczywiście to częściowo moja wina: gcc jest doskonałym kompilatorem. Jeśli kompilacji powyższego jako:
g++ -O2 -flto add.cpp main.cpp
, czyli gdybym wykonać łącza optymalizację czasu, uruchamia kod w 0.19s!(Inlining jest sztucznie wyłączony w OP, stąd kod w OP był 2x wolniejszy).
źródło
inline
definicji funkcji + w nagłówku. Nie jestem pewien, jak dojrzałe lto jest w gcc. Moje doświadczenia z tym przynajmniej w mingw są hitem lub chybił.-flto
. to całkiem rewolucyjne, jeśli nigdy wcześniej go nie używałeś, mówiąc z doświadczenia :)Dodam to po zaakceptowaniu, aby podkreślić, że zbadano wpływ dostosowania na ogólną wydajność programów - w tym dużych. Na przykład ten artykuł (i uważam, że jego wersja pojawiła się również w CACM) pokazuje, w jaki sposób sama kolejność łączy i zmiany rozmiaru środowiska systemu operacyjnego były wystarczające, aby znacznie zmienić wydajność. Przypisują to do wyrównania „gorących pętli”.
Artykuł zatytułowany „Wytwarzanie niewłaściwych danych bez robienia niczego oczywiście złego!” mówi, że nieumyślne stronniczość eksperymentalna z powodu prawie niekontrolowanych różnic w środowiskach uruchomionych programów prawdopodobnie unieważnia wiele wyników testów.
Myślę, że napotykasz inny kąt widzenia podczas tej samej obserwacji.
W przypadku kodu krytycznego dla wydajności jest to całkiem dobry argument dla systemów, które oceniają środowisko podczas instalacji lub uruchamiania i wybierają najlepsze lokalne spośród różnych zoptymalizowanych wersji kluczowych procedur.
źródło
Myślę, że możesz uzyskać taki sam wynik, jak to, co zrobiłeś:
… Za pomocą
-O2 -falign-functions=1 -falign-jumps=1 -falign-loops=1 -falign-labels=1
. Kompiluję wszystko z tymi opcjami, które były szybsze niż zwykłe-O2
za każdym razem, gdy próbowałem zmierzyć, przez 15 lat.Ponadto, dla zupełnie innego kontekstu (w tym innego kompilatora), zauważyłem, że sytuacja jest podobna : opcja, która ma „optymalizować rozmiar kodu zamiast prędkości” optymalizuje rozmiar i szybkość kodu.
Nie, to nie ma nic wspólnego ze stosem, domyślnie generowane są NOP, a opcje -falign - * = 1 zapobiegają wyrównaniu kodu.
Jest bardzo prawdopodobne, że przyczyną jest wyściółka. Powód jest uważany za konieczny i przydatny w niektórych przypadkach, ponieważ kod jest zazwyczaj pobierany w wierszach o wielkości 16 bajtów (szczegółowe informacje można znaleźć w zasobach optymalizacyjnych Agner Fog , które różnią się w zależności od modelu procesora). Wyrównanie funkcji, pętli lub etykiety na 16-bajtowej granicy oznacza, że szanse są statystycznie zwiększone, że do zawarcia funkcji lub pętli będzie potrzebna jedna mniejsza liczba wierszy. Oczywiście odpala, ponieważ te NOP zmniejszają gęstość kodu, a tym samym wydajność pamięci podręcznej. W przypadku pętli i etykiety NOP mogą nawet wymagać wykonania raz (gdy wykonanie dotrze do pętli / etykiety normalnie, w przeciwieństwie do skoku).
źródło
-O2 -fno-omit-frame-pointer
jest tak samo dobry jak-Os
. Sprawdź zaktualizowane pytanie.Jeśli twój program jest ograniczony pamięcią podręczną CODE L1, optymalizacja rozmiaru nagle zaczyna się opłacać.
Kiedy ostatnio sprawdzałem, kompilator nie jest wystarczająco inteligentny, aby to rozgryźć we wszystkich przypadkach.
W twoim przypadku -O3 prawdopodobnie generuje kod wystarczający dla dwóch linii pamięci podręcznej, ale -Os mieści się w jednej linii pamięci podręcznej.
źródło
-falign-*=16
flag wszystko wraca do normy, wszystko zachowuje się konsekwentnie. Jeśli o mnie chodzi, to pytanie zostało rozwiązane.W żadnym wypadku nie jestem ekspertem w tej dziedzinie, ale wydaje mi się, że nowoczesne procesory są dość wrażliwe, jeśli chodzi o przewidywanie gałęzi . Algorytmy stosowane do przewidywania rozgałęzień są (a przynajmniej były w czasach, gdy pisałem kod asemblera) oparte na kilku właściwościach kodu, w tym odległości od celu i kierunku.
Scenariusz, który przychodzi mi na myśl, to małe pętle. Gdy gałąź cofała się, a odległość nie była zbyt duża, przewidywanie gałęzi optymalizowało się w tym przypadku, ponieważ wszystkie małe pętle są wykonywane w ten sposób. Te same zasady mogą wejść w grę, gdy zamienisz lokalizację
add
iwork
w wygenerowanym kodzie lub gdy pozycja obu nieznacznie się zmieni.To powiedziawszy, nie mam pojęcia, jak to zweryfikować, i chciałem tylko poinformować cię, że może to być coś, na co chcesz spojrzeć.
źródło
add()
iwork()
jeśli-O2
zostanie zaliczony. We wszystkich innych przypadkach kod staje się znacznie wolniejszy przez zamianę. Podczas weekendu analizowałem również statystyki przewidywania gałęzi / błędnych prognozperf
i nie zauważyłem niczego, co mogłoby wyjaśnić to dziwne zachowanie. Jedynym spójnym wynikiem jest to, że w wolnym przypadkuperf
zgłasza 100,0 caliadd()
i dużą wartość na linii zaraz po wywołaniuadd()
w pętli. Wygląda na to, że z jakiegoś powodu przeciągamy sięadd()
w wolnym przypadku, ale nie w szybkich biegach.perf
obsługuje tylko ograniczoną liczbę rzeczy, być może rzeczy Intela są nieco bardziej przydatne na ich własnym procesorze.