Miałem funkcję, która wyglądała tak (pokazując tylko ważną część):
double CompareShifted(const std::vector<uint16_t>& l, const std::vector<uint16_t> &curr, int shift, int shiftY) {
...
for(std::size_t i=std::max(0,-shift);i<max;i++) {
if ((curr[i] < 479) && (l[i + shift] < 479)) {
nontopOverlap++;
}
...
}
...
}
Napisana w ten sposób, funkcja zajęła około 34 ms na moim komputerze. Po zmianie warunku na mnożenie bool (nadanie kodowi takiego wyglądu):
double CompareShifted(const std::vector<uint16_t>& l, const std::vector<uint16_t> &curr, int shift, int shiftY) {
...
for(std::size_t i=std::max(0,-shift);i<max;i++) {
if ((curr[i] < 479) * (l[i + shift] < 479)) {
nontopOverlap++;
}
...
}
...
}
czas wykonania zmniejszył się do ~ 19 ms.
Zastosowanym kompilatorem był GCC 5.4.0 z -O3 i po sprawdzeniu wygenerowanego kodu asm za pomocą godbolt.org stwierdziłem, że pierwszy przykład generuje skok, a drugi nie. Zdecydowałem się wypróbować GCC 6.2.0, który również generuje instrukcję skoku w pierwszym przykładzie, ale wydaje się, że GCC 7 już jej nie generuje.
Znalezienie sposobu na przyspieszenie kodu było raczej makabryczne i zajęło trochę czasu. Dlaczego kompilator zachowuje się w ten sposób? Czy jest to zamierzone i czy jest to coś, na co programiści powinni zwracać uwagę? Czy jest więcej podobnych rzeczy?
EDYCJA: link do godbolt https://godbolt.org/g/5lKPF3
&&
powoduje to.&
.Odpowiedzi:
Operator logiczny AND (
&&
) używa oceny zwarcia, co oznacza, że drugi test jest wykonywany tylko wtedy, gdy pierwsze porównanie daje wynik prawda. Często jest to dokładnie taka semantyka, jakiej potrzebujesz. Weźmy na przykład pod uwagę następujący kod:Musisz upewnić się, że wskaźnik nie jest zerowy, zanim go wyłuskujesz. Jeśli to nie było ocena zwarcia, miałbyś niezdefiniowane zachowanie, ponieważ wyłuskiwałbyś wskaźnik zerowy.
Możliwe jest również, że ocena zwarcia daje wzrost wydajności w przypadkach, gdy ocena warunków jest kosztownym procesem. Na przykład:
Jeśli
DoLengthyCheck1
zawiedzie, nie ma sensu dzwonićDoLengthyCheck2
.Jednak w wynikowym pliku binarnym operacja zwarcia często powoduje powstanie dwóch gałęzi, ponieważ jest to najłatwiejszy sposób dla kompilatora na zachowanie tej semantyki. (Dlatego z drugiej strony, ocena zwarć może czasami hamować potencjał optymalizacji). Możesz to zobaczyć, patrząc na odpowiednią część kodu wynikowego wygenerowanego dla twojego
if
oświadczenia przez GCC 5.4:Widzisz tutaj dwa porównania (
cmp
instrukcje), po których następuje oddzielny warunkowy skok / gałąź (ja
lub skok, jeśli powyżej).Ogólna praktyczna zasada mówi, że gałęzie są powolne i dlatego należy ich unikać w ciasnych pętlach. Dotyczyło to praktycznie wszystkich procesorów x86, od skromnego 8088 (którego powolne czasy pobierania i bardzo mała kolejka pobierania wstępnego [porównywalne z pamięcią podręczną instrukcji], w połączeniu z całkowitym brakiem przewidywania gałęzi, oznaczały, że pobrane gałęzie wymagały zrzucenia pamięci podręcznej ) do nowoczesnych wdrożeń (których długie potoki powodują, że źle przewidywane gałęzie są podobnie drogie). Zwróć uwagę na małe zastrzeżenie, które tam wśliznąłem. Nowoczesne procesory od czasu Pentium Pro mają zaawansowane silniki przewidywania gałęzi, które zostały zaprojektowane tak, aby zminimalizować koszt oddziałów. Jeśli kierunek gałęzi można właściwie przewidzieć, koszt jest minimalny. W większości przypadków działa to dobrze, ale jeśli dostaniesz się do patologicznych przypadków, w których predyktor gałęzi nie jest po twojej stronie,Twój kod może działać bardzo wolno . Prawdopodobnie jest to miejsce, w którym tutaj jesteś, ponieważ mówisz, że twoja tablica jest nieposortowana.
Mówisz, że testy porównawcze potwierdziły, że zastąpienie
&&
a*
sprawia, że kod jest zauważalnie szybszy. Przyczyna tego jest oczywista, gdy porównamy odpowiednią część kodu wynikowego:Trochę sprzeczne z intuicją jest to, że mogłoby to być szybsze, ponieważ jest tutaj więcej instrukcji, ale tak czasami działa optymalizacja. Widzisz, jak
cmp
wykonywane są tutaj te same porównania ( ), ale teraz każde jest poprzedzone przez,xor
a po nim następujesetbe
. XOR to po prostu standardowa sztuczka do czyszczenia rejestru.setbe
Jest instrukcją x86 że ustawia bit w oparciu o wartości flagi, i jest często używany do implementacji kodu branchless. Tutajsetbe
jest odwrotnościąja
. Ustawia swój rejestr docelowy na 1, jeśli porównanie było poniżej lub równe (ponieważ rejestr został wstępnie wyzerowany, w przeciwnym razie będzie wynosił 0), aja
rozgałęziony, jeśli porównanie było powyżej. Po uzyskaniu tych dwóch wartości wr15b
ir14b
rejestry są mnożone razem za pomocąimul
. Mnożenie było tradycyjnie stosunkowo powolną operacją, ale na nowoczesnych procesorach jest cholernie szybkie, a będzie to szczególnie szybkie, ponieważ mnoży tylko dwa bajty.Równie łatwo można by zastąpić mnożenie operatorem bitowym AND (
&
), który nie wykonuje oceny zwarcia. To sprawia, że kod jest znacznie bardziej przejrzysty i jest wzorcem, który kompilatory ogólnie rozpoznają. Ale kiedy robisz to ze swoim kodem i kompilujesz go z GCC 5.4, nadal emituje pierwszą gałąź:Nie ma żadnego technicznego powodu, dla którego musiałby emitować kod w ten sposób, ale z jakiegoś powodu jego wewnętrzna heurystyka mówi mu, że jest to szybsze. To będzie prawdopodobnie szybciej jeśli predyktorem oddział był po twojej stronie, ale to będzie prawdopodobnie wolniejszy jeśli przewidywania rozgałęzień nie częściej niż to się uda.
Nowsze generacje kompilatora (i innych kompilatorów, takich jak Clang) znają tę regułę i czasami używają jej do generowania tego samego kodu, którego szukałbyś przez ręczną optymalizację. Regularnie widzę, jak Clang tłumaczy
&&
wyrażenia na ten sam kod, który zostałby wyemitowany, gdybym użył&
. Poniżej przedstawiono odpowiednie dane wyjściowe z GCC 6.2 z Twoim kodem przy użyciu zwykłego&&
operatora:Zauważ, jak mądry to jest! Używa warunków ze znakiem (
jg
isetle
) w przeciwieństwie do warunków bez znaku (ja
isetbe
), ale nie jest to ważne. Możesz zobaczyć, że nadal wykonuje porównanie i rozgałęzienie dla pierwszego warunku, podobnie jak starsza wersja, i używa tej samejsetCC
instrukcji do wygenerowania bezgałęziowego kodu dla drugiego warunku, ale stał się znacznie bardziej wydajny w sposobie wykonywania inkrementacji . Zamiast robić drugie, redundantne porównanie w celu ustawienia flagsbb
operacji, używa wiedzy, którar14d
będzie wynosić 1 lub 0, aby po prostu bezwarunkowo dodać tę wartośćnontopOverlap
. Jeślir14d
wynosi 0, to dodawanie nie działa; w przeciwnym razie dodaje 1, dokładnie tak, jak powinien.GCC 6.2 w rzeczywistości generuje bardziej wydajny kod, gdy używasz
&&
operatora zwarcia niż&
operator bitowy :Gałąź i zestaw warunkowy nadal istnieją, ale teraz wraca do mniej sprytnego sposobu zwiększania
nontopOverlap
. To jest ważna lekcja, dlaczego powinieneś być ostrożny, próbując przechytrzyć swój kompilator!Ale jeśli możesz udowodnić za pomocą testów porównawczych, że kod rozgałęziający jest faktycznie wolniejszy, może się opłacić wypróbowanie sprytnego kompilatora. Wystarczy to zrobić, uważnie sprawdzając dezasemblację - i być przygotowanym na ponowną ocenę swoich decyzji podczas aktualizacji do nowszej wersji kompilatora. Na przykład kod, który posiadasz, można przepisać jako:
Nie ma
if
tutaj żadnego stwierdzenia, a ogromna większość kompilatorów nigdy nie pomyśli o wyemitowaniu w tym celu kodu rozgałęzienia. GCC nie jest wyjątkiem; wszystkie wersje generują coś podobnego do następującego:Jeśli śledziłeś poprzednie przykłady, powinno to wyglądać znajomo. Oba porównania są wykonywane w sposób bezgałęziowy, wyniki pośrednie są
and
łączone, a następnie ten wynik (który będzie równy 0 lub 1) jestadd
edytowanynontopOverlap
. Jeśli potrzebujesz kodu bez gałęzi, to praktycznie zapewni, że go otrzymasz.GCC 7 stało się jeszcze mądrzejsze. Obecnie generuje praktycznie identyczny kod (z wyjątkiem niewielkich zmian w instrukcjach) dla powyższej sztuczki, jak kod oryginalny. A więc odpowiedź na twoje pytanie: „Dlaczego kompilator zachowuje się w ten sposób?” , prawdopodobnie dlatego, że nie są doskonałe! Próbują użyć heurystyki, aby wygenerować jak najbardziej optymalny kod, ale nie zawsze podejmują najlepsze decyzje. Ale przynajmniej z czasem mogą stać się mądrzejsi!
Jednym ze sposobów spojrzenia na tę sytuację jest to, że kod rozgałęziający ma lepszą wydajność w najlepszym przypadku . Jeśli przewidywanie rozgałęzień powiedzie się, pomijanie niepotrzebnych operacji spowoduje nieco szybszy czas działania. Jednak kod bezgałęziowy ma lepszą wydajność w najgorszym przypadku . Jeśli przewidywanie rozgałęzienia się nie powiedzie, wykonanie kilku dodatkowych instrukcji niezbędnych do uniknięcia rozgałęzienia będzie zdecydowanie szybsze niż w przypadku źle przewidzianej gałęzi. Nawet najmądrzejszy i najbardziej sprytny kompilator będzie miał trudności z dokonaniem takiego wyboru.
A jeśli chodzi o pytanie, czy jest to coś, na co programiści muszą uważać, odpowiedź prawie na pewno brzmi nie, z wyjątkiem pewnych gorących pętli, które próbujesz przyspieszyć za pomocą mikro-optymalizacji. Następnie siadasz przy demontażu i znajdujesz sposoby, aby go poprawić. I, jak powiedziałem wcześniej, przygotuj się na powrót do tych decyzji po zaktualizowaniu kompilatora do nowszej wersji, ponieważ może on albo zrobić coś głupiego z twoim podstępnym kodem, albo może zmienić jego heurystykę optymalizacji na tyle, że możesz wrócić do korzystania z oryginalnego kodu. Skomentuj dokładnie!
źródło
j*
instrukcji), więc w takim przypadku będzie szybszy. [ciąg dalszy]Jedną ważną rzeczą, na którą należy zwrócić uwagę, jest to
i
nie są semantycznie równoważne! W szczególności, jeśli kiedykolwiek wystąpi sytuacja, w której:
0 <= i
ii < curr.size()
oba są prawdziwecurr[i] < 479
to fałszi + shift < 0
lubi + shift >= l.size()
jest prawdąto wyrażenie
(curr[i] < 479) && (l[i + shift] < 479)
ma gwarancję, że będzie dobrze zdefiniowaną wartością logiczną. Na przykład nie powoduje błędu segmentacji.Jednak w tych okolicznościach wyrażenie
(curr[i] < 479) * (l[i + shift] < 479)
oznacza niezdefiniowane zachowanie ; to jest dozwolone, aby spowodować błąd segmentacji.Oznacza to, że na przykład dla oryginalnego fragmentu kodu kompilator nie może po prostu napisać pętli, która wykonuje oba porównania i wykonuje
and
operację, chyba że kompilator może również udowodnić, żel[i + shift]
nigdy nie spowoduje to segfaulta w sytuacji, w której nie jest wymagane.Krótko mówiąc, oryginalny fragment kodu oferuje mniej możliwości optymalizacji niż ten drugi. (oczywiście, czy kompilator rozpoznaje możliwość, to zupełnie inna kwestia)
Zamiast tego możesz naprawić oryginalną wersję
źródło
shift
(imax
) jest tutaj UB ...&&
Operator realizuje oceny zwarcie. Oznacza to, że drugi operand jest oceniany tylko wtedy, gdy pierwszy z nich ma wartośćtrue
. To z pewnością skutkuje skokiem w tym przypadku.Możesz stworzyć mały przykład, aby to pokazać:
Wyjście asemblera można znaleźć tutaj .
Możesz zobaczyć wygenerowany kod najpierw wywołania
f(x)
, a następnie sprawdza dane wyjściowe i przechodzi do oceny,g(x)
kiedy to nastąpiłotrue
. W przeciwnym razie opuszcza funkcję.Użycie mnożenia "boolowskiego" zamiast tego wymusza ocenę obu operandów za każdym razem, a zatem nie wymaga skoku.
W zależności od danych, skok może spowodować spowolnienie, ponieważ zakłóca potok procesora i inne rzeczy, takie jak wykonanie spekulacyjne. Zwykle przewidywanie gałęzi pomaga, ale jeśli dane są losowe, niewiele można przewidzieć.
źródło
&&
operatora, mnożenie może być obliczane leniwie z pierwszym lub drugim argumentem, co pozwala na większą swobodę optymalizacji.0 * f()
i maszf
obserwowalne zachowanie, kompilator musi to wywołać. Różnica polega na tym, że ocena zwarcia jest obowiązkowa,&&
ale dozwolona, jeśli może wykazać, że jest równoważna*
.Może to być spowodowane tym, że podczas korzystania z operatora logicznego
&&
kompilator musi sprawdzić dwa warunki, aby instrukcja if zakończyła się powodzeniem. Jednak w drugim przypadku, ponieważ niejawnie konwertujesz wartość int na bool, kompilator przyjmuje pewne założenia na podstawie przekazywanych typów i wartości, wraz z (prawdopodobnie) jednym warunkiem skoku. Możliwe jest również, że kompilator całkowicie optymalizuje jmps z przesunięciami bitów.źródło