Praktyki kodowania, które umożliwiają kompilatorowi / optymalizatorowi stworzenie szybszego programu


116

Wiele lat temu kompilatory C nie były szczególnie inteligentne. Aby obejść ten problem, K&R wymyślił słowo kluczowe register , aby wskazać kompilatorowi, że być może dobrym pomysłem byłoby przechowywanie tej zmiennej w rejestrze wewnętrznym. Zrobili również trzeciorzędny operator, aby pomóc w generowaniu lepszego kodu.

W miarę upływu czasu kompilatory dojrzewały. Stali się bardzo sprytni, ponieważ ich analiza przepływu umożliwiła im podejmowanie lepszych decyzji o tym, jakie wartości przechowywać w rejestrach, niż możesz to zrobić. Słowo kluczowe register stało się nieważne.

FORTRAN może być szybszy niż C dla niektórych rodzajów operacji z powodu problemów z aliasami . Teoretycznie przy starannym kodowaniu można obejść to ograniczenie, aby umożliwić optymalizatorowi generowanie szybszego kodu.

Jakie praktyki kodowania są dostępne, które mogą umożliwić kompilatorowi / optymalizatorowi szybsze generowanie kodu?

  • Będziemy wdzięczni za zidentyfikowanie używanej platformy i kompilatora.
  • Dlaczego ta technika działa?
  • Zachęcamy do przykładowego kodu.

Oto powiązane pytanie

[Edytuj] To pytanie nie dotyczy całego procesu profilowania i optymalizacji. Załóżmy, że program został poprawnie napisany, skompilowany z pełną optymalizacją, przetestowany i wdrożony do produkcji. W kodzie mogą znajdować się konstrukcje, które uniemożliwiają optymalizatorowi wykonanie najlepszej możliwej pracy. Co możesz zrobić, aby dokonać refaktoryzacji, która usunie te zakazy i umożliwi optymalizatorowi generowanie jeszcze szybszego kodu?

[Edytuj] Link powiązany z przesunięciem


7
Może być dobrym kandydatem na wiki społeczności imho, ponieważ nie ma „jednej” ostatecznej odpowiedzi na to (interesujące) pytanie ...
ChristopheD

Tęsknię za tym za każdym razem. Dziękuję za zwrócenie uwagi.
EvilTeach

Czy przez „lepszy” masz na myśli po prostu „szybciej”, czy masz na myśli inne kryteria doskonałości?
Znak wysokiej wydajności

1
Trudno jest napisać dobry alokator rejestrów, zwłaszcza przenośny, a alokacja rejestrów jest absolutnie niezbędna dla wydajności i rozmiaru kodu. registerw rzeczywistości uczynił kod wrażliwy na wydajność bardziej przenośnym poprzez zwalczanie słabych kompilatorów.
Potatoswatter

1
@EvilTeach: wiki społeczności nie oznacza „braku ostatecznej odpowiedzi”, nie jest synonimem subiektywnego tagu. Witryna wiki społeczności oznacza, że ​​chcesz przekazać swój post społeczności, aby inne osoby mogły go edytować. Nie czuj się zmuszony, aby opublikować swoje pytania na wiki, jeśli nie masz na to ochoty.
Julia,

Odpowiedzi:


54

Zapisuj do zmiennych lokalnych i nie wyprowadzaj argumentów! Może to być ogromna pomoc w obejściu aliasingu spowolnień. Na przykład, jeśli twój kod wygląda jak

void DoSomething(const Foo& foo1, const Foo* foo2, int numFoo, Foo& barOut)
{
    for (int i=0; i<numFoo, i++)
    {
         barOut.munge(foo1, foo2[i]);
    }
}

kompilator nie wie, że foo1! = barOut i dlatego musi za każdym razem przeładowywać foo1 przez pętlę. Nie może również odczytać foo2 [i], dopóki zapis do barOut nie zostanie zakończony. Możesz zacząć bawić się ograniczonymi wskaźnikami, ale jest to równie skuteczne (i znacznie jaśniejsze), aby zrobić to:

void DoSomethingFaster(const Foo& foo1, const Foo* foo2, int numFoo, Foo& barOut)
{
    Foo barTemp = barOut;
    for (int i=0; i<numFoo, i++)
    {
         barTemp.munge(foo1, foo2[i]);
    }
    barOut = barTemp;
}

Brzmi to głupio, ale kompilator może znacznie sprytniej radzić sobie ze zmienną lokalną, ponieważ nie może ona pokrywać się w pamięci z żadnym z argumentów. Może to pomóc w uniknięciu przerażającego sklepu z ładunkami (o którym wspomniał Francis Boivin w tym wątku).


7
Ma to tę dodatkową zaletę, że często ułatwia czytanie / zrozumienie rzeczy programistom, ponieważ nie muszą się oni martwić o możliwe nieoczywiste skutki uboczne.
Michael Burr

Większość IDE wyświetla domyślnie zmienne lokalne, więc jest mniej pisania
EvilTeach

9
możesz również włączyć tę optymalizację za pomocą ograniczonych wskaźników
Ben Voigt

4
@Ben - to prawda, ale myślę, że ta droga jest jaśniejsza. Ponadto, jeśli dane wejściowe i wyjściowe nakładały się, uważam, że wynik jest nieokreślony za pomocą ograniczonych wskaźników (prawdopodobnie uzyskuje się inne zachowanie między debugowaniem a wydaniem), podczas gdy w ten sposób przynajmniej będzie spójny. Nie zrozum mnie źle, lubię używać limited, ale nie potrzebuję go jeszcze bardziej.
celion

Musisz tylko mieć nadzieję, że Foo nie ma zdefiniowanej operacji kopiowania, która kopiuje kilka megabajtów danych ;-)
Skizz

76

Oto praktyka kodowania, która pomaga kompilatorowi tworzyć szybki kod - dowolny język, dowolna platforma, dowolny kompilator, każdy problem:

Czy nie stosować żadnych sztuczek, które zmuszają sprytnych, a nawet zachęcać, kompilator położyć zmienne w pamięci podręcznej (w tym i rejestry), jak myślisz najlepiej. Najpierw napisz program, który jest poprawny i możliwy do utrzymania.

Następnie profiluj swój kod.

Wtedy, i tylko wtedy, możesz chcieć rozpocząć badanie skutków mówienia kompilatorowi, jak używać pamięci. Wprowadź jedną zmianę naraz i zmierz jej wpływ.

Spodziewaj się rozczarowania i naprawdę ciężkiej pracy nad niewielkimi poprawkami wydajności. Nowoczesne kompilatory dla dojrzałych języków, takich jak Fortran i C, są bardzo, bardzo dobre. Jeśli czytasz opis „sztuczki” mającej na celu uzyskanie lepszej wydajności z kodu, pamiętaj, że pisarze kompilatorów również o tym czytali i, jeśli warto to zrobić, prawdopodobnie zaimplementowali ją. Prawdopodobnie napisali to, co przeczytałeś w pierwszej kolejności.


20
Programiści Compiier mają ograniczony czas, tak jak wszyscy inni. Nie wszystkie optymalizacje zostaną wprowadzone do kompilatora. Jak &vs. %do potęgi dwójki (rzadko, jeśli kiedykolwiek, zoptymalizowane, ale może mieć znaczący wpływ na wydajność). Jeśli czytasz sztuczkę dotyczącą wydajności, jedynym sposobem sprawdzenia, czy działa, jest wprowadzenie zmiany i zmierzenie jej wpływu. Nigdy nie zakładaj, że kompilator coś dla Ciebie zoptymalizuje.
Dave Jarvis

22
& i% są prawie zawsze optymalizowane, razem z większością innych tanich i darmowych sztuczek arytmetycznych. To, co nie jest optymalizowane, to przypadek, w którym operand po prawej stronie jest zmienną, która zawsze jest potęgą dwójki.
Potatoswatter

8
Aby wyjaśnić, wydaje mi się, że zdezorientowałem niektórych czytelników: rada w praktyce kodowania, którą proponuję, polega na tym, aby najpierw opracować prosty kod, który nie wykorzystuje instrukcji układu pamięci do ustalenia podstawy wydajności. Następnie próbuj pojedynczo i mierz ich wpływ. Nie udzieliłem żadnych porad dotyczących wykonywania operacji.
Znak wysokiej wydajności

17
Dla stałych power-of-two nzastępuje gcc % nze & (n-1) nawet gdy optymalizacja jest wyłączona . To nie jest dokładnie „rzadko, jeśli w ogóle” ...
Porculus,

12
% NIE MOŻE być zoptymalizowane jako &, gdy typ jest podpisany, ze względu na idiotyczne zasady C dotyczące ujemnego dzielenia liczb całkowitych (zaokrąglenie w kierunku 0 i ujemna reszta zamiast zaokrąglania w dół i zawsze dodatniej reszty). I przez większość czasu ignoranci używają znaków ze znakiem ...
R .. GitHub STOP HELPING ICE

47

Kolejność przemierzania pamięci może mieć głęboki wpływ na wydajność, a kompilatory nie są zbyt dobre w rozwiązywaniu tego i naprawianiu. Pisząc kod, należy być świadomym problemów związanych z lokalizacją pamięci podręcznej, jeśli zależy Ci na wydajności. Na przykład tablice dwuwymiarowe w języku C są alokowane w formacie wiersz-główny. Przechodzenie po tablicach w głównym formacie kolumny spowoduje, że będziesz mieć więcej błędów w pamięci podręcznej i sprawi, że twój program będzie bardziej związany z pamięcią niż z procesorem:

#define N 1000000;
int matrix[N][N] = { ... };

//awesomely fast
long sum = 0;
for(int i = 0; i < N; i++){
  for(int j = 0; j < N; j++){
    sum += matrix[i][j];
  }
}

//painfully slow
long sum = 0;
for(int i = 0; i < N; i++){
  for(int j = 0; j < N; j++){
    sum += matrix[j][i];
  }
}

Ściśle mówiąc, nie jest to kwestia optymalizatora, ale kwestia optymalizacji.
EvilTeach

10
Jasne, że to problem z optymalizatorem. Od dziesięcioleci ludzie piszą artykuły na temat optymalizacji automatycznej wymiany pętli.
Phil Miller

20
@Potatoswatter O czym ty mówisz? Kompilator C może robić, co tylko zechce, pod warunkiem, że obserwuje ten sam wynik końcowy, a faktycznie GCC 4.4 ma -floop-interchangeodwrócenie pętli wewnętrznej i zewnętrznej, jeśli optymalizator uzna to za opłacalne.
ephemient

2
Huh, dobrze, proszę. Semantyka języka C jest często niszczona przez problemy z aliasami. Myślę, że prawdziwą radą jest przekazanie tej flagi!
Potatoswatter

36

Optymalizacje ogólne

Oto niektóre z moich ulubionych optymalizacji. W rzeczywistości zwiększyłem czas wykonywania i zmniejszyłem rozmiary programów, używając ich.

Zadeklaruj małe funkcje jako inlinemakra lub

Każde wywołanie funkcji (lub metody) wiąże się z narzutem, takim jak umieszczanie zmiennych na stosie. Niektóre funkcje mogą również wiązać się z kosztami po powrocie. Nieefektywna funkcja lub metoda ma mniej instrukcji w swojej treści niż połączony narzut. Są to dobrzy kandydaci do wstawiania, zarówno jako #definemakra, jak i inlinefunkcje. (Tak, wiem, że inlineto tylko sugestia, ale w tym przypadku traktuję to jako przypomnienie dla kompilatora).

Usuń martwy i zbędny kod

Jeśli kod nie jest używany lub nie ma wpływu na wynik programu, pozbądź się go.

Uprość projektowanie algorytmów

Kiedyś usunąłem dużo kodu asemblera i czasu wykonywania z programu, zapisując równanie algebraiczne, które obliczał, a następnie uprościłem wyrażenie algebraiczne. Realizacja uproszczonego wyrażenia algebraicznego zajęła mniej miejsca i czasu niż pierwotna funkcja.

Rozwijanie pętli

Każda pętla ma narzut związany z zwiększaniem i sprawdzaniem zakończenia. Aby uzyskać oszacowanie współczynnika wydajności, policz liczbę instrukcji w narzutu (minimum 3: zwiększ, sprawdź, przejdź do początku pętli) i podziel przez liczbę instrukcji wewnątrz pętli. Im niższa liczba, tym lepiej.

Edycja: podaj przykład rozwijania pętli Przed:

unsigned int sum = 0;
for (size_t i; i < BYTES_TO_CHECKSUM; ++i)
{
    sum += *buffer++;
}

Po rozwinięciu:

unsigned int sum = 0;
size_t i = 0;
**const size_t STATEMENTS_PER_LOOP = 8;**
for (i = 0; i < BYTES_TO_CHECKSUM; **i = i / STATEMENTS_PER_LOOP**)
{
    sum += *buffer++; // 1
    sum += *buffer++; // 2
    sum += *buffer++; // 3
    sum += *buffer++; // 4
    sum += *buffer++; // 5
    sum += *buffer++; // 6
    sum += *buffer++; // 7
    sum += *buffer++; // 8
}
// Handle the remainder:
for (; i < BYTES_TO_CHECKSUM; ++i)
{
    sum += *buffer++;
}

Zaletą tej korzyści jest dodatkowa korzyść: wykonywanych jest więcej instrukcji, zanim procesor będzie musiał ponownie załadować pamięć podręczną instrukcji.

Osiągnąłem niesamowite rezultaty, kiedy rozwinąłem pętlę do 32 instrukcji. Było to jednym z wąskich gardeł, ponieważ program musiał obliczyć sumę kontrolną pliku o wielkości 2 GB. Ta optymalizacja w połączeniu z odczytem bloków poprawiła wydajność od 1 godziny do 5 minut. Rozwijanie pętli zapewniało doskonałą wydajność również w języku asemblera, mój memcpybył dużo szybszy niż kompilator memcpy. - TM

Redukcja ifinstrukcji

Procesory nienawidzą rozgałęzień lub skoków, ponieważ zmusza procesor do ponownego załadowania kolejki instrukcji.

Boolean Arithmetic ( Edytowano: zastosowany format kodu do fragmentu kodu, dodany przykład)

Konwertuj ifinstrukcje na przypisania logiczne. Niektóre procesory mogą warunkowo wykonywać instrukcje bez rozgałęziania:

bool status = true;
status = status && /* first test */;
status = status && /* second test */;

Krótki spięciom z logicznego I operatora ( &&) uniemożliwia wykonywanie testów jeżeli statusjest false.

Przykład:

struct Reader_Interface
{
  virtual bool  write(unsigned int value) = 0;
};

struct Rectangle
{
  unsigned int origin_x;
  unsigned int origin_y;
  unsigned int height;
  unsigned int width;

  bool  write(Reader_Interface * p_reader)
  {
    bool status = false;
    if (p_reader)
    {
       status = p_reader->write(origin_x);
       status = status && p_reader->write(origin_y);
       status = status && p_reader->write(height);
       status = status && p_reader->write(width);
    }
    return status;
};

Alokacja zmiennej czynnika poza pętlami

Jeśli zmienna jest tworzona w locie wewnątrz pętli, przenieś tworzenie / alokację na miejsce przed pętlą. W większości przypadków zmienna nie musi być przydzielana podczas każdej iteracji.

Czynnikowe wyrażenia stałe poza pętlami

Jeśli wartość obliczenia lub zmiennej nie zależy od indeksu pętli, przenieś ją poza pętlę (przed).

I / O w blokach

Odczyt i zapis danych w dużych porcjach (blokach). Im większy tym lepszy. Na przykład czytanie jednego oktektu na raz jest mniej wydajne niż czytanie 1024 oktetów przy jednym odczycie.
Przykład:

static const char  Menu_Text[] = "\n"
    "1) Print\n"
    "2) Insert new customer\n"
    "3) Destroy\n"
    "4) Launch Nasal Demons\n"
    "Enter selection:  ";
static const size_t Menu_Text_Length = sizeof(Menu_Text) - sizeof('\0');
//...
std::cout.write(Menu_Text, Menu_Text_Length);

Skuteczność tej techniki można wykazać wizualnie. :-)

Nie używaj printf rodziny do stałych danych

Stałe dane można wyprowadzać za pomocą zapisu blokowego. Sformatowany zapis marnuje czas na skanowanie tekstu pod kątem formatowania znaków lub przetwarzania poleceń formatujących. Zobacz powyższy przykład kodu.

Sformatuj do pamięci, a następnie napisz

Sformatuj do chartablicy przy użyciu wielu sprintf, a następnie użyj fwrite. Umożliwia to również podział układu danych na „sekcje stałe” i sekcje zmienne. Pomyśl o korespondencji seryjnej .

Zadeklaruj stały tekst (literały ciągów) jako static const

Gdy zmienne są deklarowane bez static, niektóre kompilatory mogą przydzielić miejsce na stosie i skopiować dane z pamięci ROM. To są dwie niepotrzebne operacje. Można to naprawić za pomocą staticprefiksu.

Wreszcie, kod taki jak kompilator

Czasami kompilator może lepiej zoptymalizować kilka małych instrukcji niż jedną skomplikowaną wersję. Pomocne jest również pisanie kodu, który pomoże kompilatorowi w optymalizacji. Jeśli chcę, aby kompilator korzystał ze specjalnych instrukcji transferu bloków, napiszę kod, który wygląda tak, że powinien korzystać ze specjalnych instrukcji.


2
Ciekawe, czy możesz podać przykład, w którym otrzymałeś lepszy kod z kilkoma małymi instrukcjami, zamiast większej. Czy możesz pokazać przykład przepisywania warunku if przy użyciu wartości logicznych. Generalnie zostawiłbym rozwijanie pętli kompilatorowi, ponieważ prawdopodobnie lepiej wyczuwa rozmiar pamięci podręcznej. Jestem trochę zaskoczony pomysłem sprintu, a potem pisania. Myślę, że fprintf faktycznie robi to pod maską. Czy możesz podać tutaj trochę więcej szczegółów?
EvilTeach

1
Nie ma gwarancji, że fprintfformatowanie do oddzielnego bufora następnie wyprowadza bufor. Uproszczony (do wykorzystania pamięci) fprintfwyprowadziłby cały niesformatowany tekst, a następnie sformatował i wyprowadził i powtarzał, aż cały ciąg formatu zostanie przetworzony, tworząc w ten sposób 1 wywołanie wyjściowe dla każdego typu danych wyjściowych (sformatowane lub niesformatowane). Inne implementacje musiałyby dynamicznie przydzielać pamięć dla każdego wywołania, aby przechowywać cały nowy ciąg (co jest złe w środowisku systemów wbudowanych). Moja sugestia ogranicza liczbę wyjść.
Thomas Matthews

3
Kiedyś uzyskałem znaczną poprawę wydajności, zwijając pętlę. Potem wymyśliłem, jak zwinąć go mocniej, używając jakiegoś pośredniego, i program stał się zauważalnie szybszy. (Profilowanie wykazało, że ta konkretna funkcja zajmuje 60-80% czasu działania, a wydajność dokładnie przetestowałem przed i po.) Uważam, że poprawa wynikała z lepszej lokalizacji, ale nie jestem tego do końca pewien.
David Thornley

16
Wiele z nich to raczej optymalizacje programistów niż sposoby pomagania programistom w optymalizacji, co było głównym tematem pierwotnego pytania. Na przykład rozwijanie pętli. Tak, możesz samodzielnie rozwinąć rozwijanie, ale myślę, że ciekawsze jest ustalenie, jakie blokady utrudniają rozwijanie kompilatora za Ciebie i ich usuwanie.
Adrian McCarthy

26

Optymalizator tak naprawdę nie kontroluje wydajności twojego programu, ty tak. Stosuj odpowiednie algorytmy i struktury oraz profil, profil, profil.

To powiedziawszy, nie powinieneś wykonywać pętli wewnętrznej na małej funkcji z jednego pliku w innym pliku, ponieważ powstrzymuje to przed wstawieniem.

Jeśli to możliwe, unikaj przyjmowania adresu zmiennej. Pytanie o wskaźnik nie jest „wolne”, ponieważ oznacza, że ​​zmienna musi być przechowywana w pamięci. Nawet tablica może być przechowywana w rejestrach, jeśli unikniesz wskaźników - jest to niezbędne do wektoryzacji.

Co prowadzi do następnego punktu, przeczytaj instrukcję ^ # $ @ ! GCC może wektoryzować zwykły kod C, jeśli posypiesz __restrict__tu i __attribute__( __aligned__ )tam. Jeśli potrzebujesz czegoś bardzo konkretnego od optymalizatora, być może będziesz musiał być dokładny.


14
To dobra odpowiedź, ale zwróć uwagę, że optymalizacja całego programu staje się coraz bardziej popularna i może w rzeczywistości wbudowywać funkcje w jednostkach tłumaczeniowych.
Phil Miller

1
@Novelocrat Tak - nie trzeba dodawać, że byłem bardzo zaskoczony, gdy po raz pierwszy zobaczyłem coś, co zostało A.cwbudowane w B.c.
Jonathon Reinhart

18

W większości nowoczesnych procesorów największym wąskim gardłem jest pamięć.

Aliasing: Load-Hit-Store może być niszczycielski w ciasnej pętli. Jeśli czytasz jedną lokalizację pamięci i piszesz do innej i wiesz, że są one rozłączne, ostrożne umieszczenie słowa kluczowego alias na parametrach funkcji może naprawdę pomóc kompilatorowi w wygenerowaniu szybszego kodu. Jeśli jednak regiony pamięci nakładają się i użyłeś „aliasu”, czeka Cię dobra sesja debugowania niezdefiniowanych zachowań!

Brak pamięci podręcznej: Nie jestem pewien, jak możesz pomóc kompilatorowi, ponieważ jest to głównie algorytmiczne, ale są nieodłączne elementy pamięci wstępnego pobierania.

Nie próbuj też zbytnio konwertować wartości zmiennoprzecinkowych na int i odwrotnie, ponieważ używają różnych rejestrów, a konwersja z jednego typu na inny oznacza wywołanie właściwej instrukcji konwersji, zapisanie wartości do pamięci i odczytanie jej z powrotem w odpowiednim zestawie rejestrów .


4
+1 dla sklepów z ładunkami i różnych typów rejestrów. Nie jestem pewien, jak wielka jest to sprawa w x86, ale niszczą one na PowerPC (np. Xbox360 i Playstation3).
celion

Większość artykułów na temat technik optymalizacji pętli kompilatora zakłada idealne zagnieżdżenie, co oznacza, że ​​treść każdej pętli z wyjątkiem najbardziej wewnętrznej jest po prostu kolejną pętlą. W tych artykułach po prostu nie omówiono kroków niezbędnych do uogólnienia, nawet jeśli jest bardzo jasne, że tak jest. Dlatego spodziewałbym się, że wiele implementacji w rzeczywistości nie będzie obsługiwać tych uogólnień, ze względu na dodatkowy wysiłek z tym związany. W związku z tym wiele algorytmów optymalizujących wykorzystanie pamięci podręcznej w pętlach może działać znacznie lepiej na doskonałych gniazdach niż na niedoskonałych gniazdach.
Phil Miller,

11

Zdecydowana większość kodu, który ludzie piszą, będzie związana z I / O (wierzę, że cały kod, który napisałem dla pieniędzy w ciągu ostatnich 30 lat, był tak ograniczony), więc działania optymalizatora dla większości ludzi będą miały charakter akademicki.

Chciałbym jednak przypomnieć ludziom, że aby zoptymalizować kod, musisz powiedzieć kompilatorowi, aby go zoptymalizował - wiele osób (w tym ja, gdy zapomnę) publikuje tutaj testy porównawcze C ++, które są bez znaczenia bez włączonego optymalizatora.


7
Przyznaję, że jestem dziwny - pracuję nad dużymi naukowymi kodami przetwarzającymi liczby, które są związane z przepustowością pamięci. W przypadku ogólnej populacji programów zgadzam się z Neilem.
Znak wysokiej wydajności

6
Prawdziwe; ale bardzo dużo tego związanego z I / O kodu jest obecnie napisanych w językach, które są praktycznie pesymizatorami - językami, które nawet nie mają kompilatorów. Podejrzewam, że obszary, w których nadal używane są C i C ++, będą zwykle obszarami, w których ważniejsza jest optymalizacja (użycie procesora, użycie pamięci, rozmiar kodu ...)
Porculus

3
Spędziłem większość ostatnich 30 lat pracując nad kodem z bardzo małą liczbą operacji we / wy. Oszczędzaj przez 2 lata robiąc bazy danych. Grafika, układy sterowania, symulacja - nic z tego nie jest związane z I / O Gdyby I / O było wąskim gardłem większości ludzi, nie poświęcalibyśmy Intelowi i AMD zbytniej uwagi.
phkahler

2
Tak, naprawdę nie kupuję tego argumentu - w przeciwnym razie (w mojej pracy) nie szukalibyśmy sposobów na spędzanie większej ilości czasu obliczeniowego również na wykonywaniu I / O. Ponadto - większość oprogramowania związanego z I / O, z którym się spotkałem, jest związana z I / O, ponieważ I / O zostało wykonane niechlujnie; jeśli zoptymalizuje się wzorce dostępu (tak jak w przypadku pamięci), można uzyskać ogromny wzrost wydajności.
dash-tom-bang

3
Niedawno odkryłem, że prawie żaden kod napisany w języku C ++ nie jest powiązany z I / O. Jasne, jeśli wywołujesz funkcję systemu operacyjnego do masowego transferu dysku, twój wątek może przejść do oczekiwania na wejście / wyjście (ale z buforowaniem, nawet to jest wątpliwe). Ale zwykłe funkcje biblioteki I / O, te, które wszyscy polecają, ponieważ są standardowe i przenośne, są w rzeczywistości żałośnie powolne w porównaniu z nowoczesną technologią dyskową (nawet te o umiarkowanej cenie). Najprawdopodobniej I / O jest wąskim gardłem tylko wtedy, gdy przepłukujesz całą drogę na dysk po zapisaniu zaledwie kilku bajtów. OTOH, UI to inna sprawa, my, ludzie, jesteśmy powolni.
Ben Voigt

11

używaj stałej poprawności tak często, jak to możliwe w swoim kodzie. Pozwala kompilatorowi na znacznie lepszą optymalizację.

W tym dokumencie znajduje się wiele innych wskazówek dotyczących optymalizacji: Optymalizacje CPP (choć trochę stary)

najważniejsze:

  • użyj list inicjalizacyjnych konstruktorów
  • użyj operatorów przedrostków
  • użyj jawnych konstruktorów
  • funkcje inline
  • unikaj tymczasowych obiektów
  • zdawać sobie sprawę z kosztu funkcji wirtualnych
  • zwracać obiekty za pomocą parametrów referencyjnych
  • rozważ dla przydziału klas
  • rozważ alokatory kontenerów STL
  • optymalizacja „pustego członka”
  • itp

8
Niewiele, rzadko. Poprawia to jednak rzeczywistą poprawność.
Potatoswatter

5
W językach C i C ++ kompilator nie może używać const do optymalizacji, ponieważ odrzucanie go jest dobrze zdefiniowanym zachowaniem.
dsimcha

+1: const jest dobrym przykładem czegoś, co będzie miało bezpośredni wpływ na skompilowany kod. komentarz re @ dsimcha - dobry kompilator sprawdzi, czy tak się stanie. Oczywiście, dobry kompilator „znajdzie” elementy const, które i tak nie są zadeklarowane w ten sposób ...
Hogan,

@dsimcha: Zmiana wskaźnika const i restrict kwalifikowanego wskaźnika jest jednak niezdefiniowana. Zatem kompilator może w takim przypadku optymalizować inaczej.
Dietrich Epp

6
@dsimcha odlewania oddalony constw constodniesieniu lub constwskaźnik do niebędącego constprzedmiotem jest dobrze zdefiniowana. modyfikowanie rzeczywistego constobiektu (tj. zadeklarowanego jako constpierwotnie) nie jest.
Stephen Lin

9

W miarę możliwości próbuj programować przy użyciu statycznego pojedynczego przypisania. SSA jest dokładnie tym samym, co otrzymujesz w większości funkcjonalnych języków programowania, i właśnie na to większość kompilatorów konwertuje Twój kod, aby dokonać optymalizacji, ponieważ jest łatwiejszy w obsłudze. W ten sposób ujawniają się miejsca, w których kompilator może się pomylić. Sprawia również, że wszystkie oprócz najgorszych alokatorów rejestrów działają tak dobrze, jak najlepsze alokatory rejestrów i pozwala na łatwiejsze debugowanie, ponieważ prawie nigdy nie musisz się zastanawiać, skąd zmienna wzięła swoją wartość, ponieważ było tylko jedno miejsce, do którego została przypisana.
Unikaj zmiennych globalnych.

Podczas pracy z danymi przez odniesienie lub wskaźnik przeciągnij je do zmiennych lokalnych, wykonaj swoją pracę, a następnie skopiuj ją z powrotem. (chyba że masz dobry powód, aby tego nie robić)

Skorzystaj z prawie darmowego porównania z 0, które oferuje większość procesorów podczas wykonywania operacji matematycznych lub logicznych. Prawie zawsze otrzymujesz flagę dla == 0 i <0, z której możesz łatwo uzyskać 3 warunki:

x= f();
if(!x){
   a();
} else if (x<0){
   b();
} else {
   c();
}

jest prawie zawsze tańsze niż testowanie innych stałych.

Inną sztuczką jest użycie odejmowania, aby wyeliminować jedno porównanie w testowaniu zakresu.

#define FOO_MIN 8
#define FOO_MAX 199
int good_foo(int foo) {
    unsigned int bar = foo-FOO_MIN;
    int rc = ((FOO_MAX-FOO_MIN) < bar) ? 1 : 0;
    return rc;
} 

Pozwala to bardzo często uniknąć przeskoku w językach, w których występują zwarcia w wyrażeniach boolowskich i pozwala kompilatorowi uniknąć konieczności zastanowienia się, jak poradzić sobie z nadążaniem za wynikiem pierwszego porównania, wykonując drugie, a następnie łącząc je. Może to wyglądać na możliwość wykorzystania dodatkowego rejestru, ale prawie nigdy tak się nie dzieje. Często i tak nie potrzebujesz już foo, a jeśli to zrobisz, rc nie jest jeszcze używany, więc może tam iść.

Używając funkcji łańcuchowych w c (strcpy, memcpy, ...) pamiętaj, co zwracają - przeznaczenie! Często można uzyskać lepszy kod, „zapominając” o swojej kopii wskaźnika do miejsca docelowego i po prostu odzyskując ją z powrotu tych funkcji.

Nigdy nie zapomnij o możliwości zwrócenia dokładnie tego samego, co zwróciła ostatnia funkcja, którą wywołałeś. Kompilatory nie są tak dobre w wychwytywaniu, że:

foo_t * make_foo(int a, int b, int c) {
        foo_t * x = malloc(sizeof(foo));
        if (!x) {
             // return NULL;
             return x; // x is NULL, already in the register used for returns, so duh
        }
        x->a= a;
        x->b = b;
        x->c = c;
        return x;
}

Oczywiście możesz odwrócić logikę, jeśli masz tylko jeden punkt powrotu.

(sztuczki, które przypomniałem sobie później)

Deklarowanie funkcji jako statycznych zawsze jest dobrym pomysłem. Jeśli kompilator może udowodnić sobie, że uwzględnił każdego wywołującego określoną funkcję, może złamać konwencje wywoływania tej funkcji w imię optymalizacji. Kompilatory często mogą uniknąć przenoszenia parametrów do rejestrów lub pozycji na stosie, które wywoływane funkcje zwykle oczekują, że ich parametry się znajdą (aby to zrobić, musi się różnić zarówno w wywołanej funkcji, jak i lokalizacji wszystkich wywołujących). Kompilator może również często skorzystać ze znajomości pamięci i rejestrów, których będzie potrzebować wywoływana funkcja i uniknąć generowania kodu w celu zachowania wartości zmiennych znajdujących się w rejestrach lub miejscach pamięci, których wywoływana funkcja nie zakłóca. Działa to szczególnie dobrze, gdy jest niewiele wywołań funkcji.


2
W rzeczywistości nie jest konieczne używanie odejmowania podczas testowania zakresów, LLVM, GCC i mój kompilator przynajmniej robią to automatycznie. Niewiele osób prawdopodobnie zrozumiałoby, co robi kod z odejmowaniem, a jeszcze mniej, dlaczego tak naprawdę działa.
Gratian Lup

w powyższym przykładzie nie można wywołać b (), ponieważ if (x <0) zostanie wywołana a ().
EvilTeach

@EvilTeach Nie, nie będzie. Porównanie, które skutkuje wywołaniem a () to! X
nategoose

@nategoose. jeśli x wynosi -3, to! x jest prawdą.
EvilTeach

@EvilTeach W C 0 jest fałszem i wszystko inne jest prawdą, więc -3 jest prawdą, więc! -3 jest fałszem
nategoose

9

Napisałem optymalizujący kompilator C i oto kilka bardzo przydatnych rzeczy do rozważenia:

  1. Uczyń większość funkcji statyczną. Umożliwia to stałą propagację międzyprocedurową i analizę aliasów, w przeciwnym razie kompilator musi założyć, że funkcję można wywołać spoza jednostki tłumaczącej z całkowicie nieznanymi wartościami parametrów. Jeśli spojrzysz na dobrze znane biblioteki open source, wszystkie one oznaczają funkcje statyczne, z wyjątkiem tych, które naprawdę muszą być zewnętrzne.

  2. Jeśli używane są zmienne globalne, oznacz je jako statyczne i stałe, jeśli to możliwe. Jeśli są inicjalizowane raz (tylko do odczytu), lepiej jest użyć listy inicjalizującej, takiej jak static const int VAL [] = {1,2,3,4}, w przeciwnym razie kompilator może nie wykryć, że zmienne są faktycznie zainicjowanymi stałymi i nie zastąpi obciążeń ze zmiennej stałymi.

  3. NIGDY nie używaj goto do wnętrza pętli, pętla nie będzie już rozpoznawana przez większość kompilatorów i żadna z najważniejszych optymalizacji nie zostanie zastosowana.

  4. Używaj parametrów wskaźnika tylko wtedy, gdy jest to konieczne, i oznacz je jako ograniczające, jeśli to możliwe. To bardzo pomaga w analizie aliasów, ponieważ programista gwarantuje, że alias nie istnieje (międzyproceduralna analiza aliasów jest zwykle bardzo prymitywna). Bardzo małe obiekty strukturalne powinny być przekazywane przez wartość, a nie przez odwołanie.

  5. Jeśli to możliwe, używaj tablic zamiast wskaźników, zwłaszcza wewnątrz pętli (a [i]). Tablica zwykle oferuje więcej informacji do analizy aliasów, a po kilku optymalizacjach ten sam kod zostanie wygenerowany i tak (jeśli jesteś ciekawy, poszukaj zmniejszenia siły pętli). Zwiększa to również szansę zastosowania ruchu kodu niezmiennego w pętli.

  6. Spróbuj wyciągnąć poza pętle wywołania dużych funkcji lub funkcji zewnętrznych, które nie mają skutków ubocznych (nie zależą od bieżącej iteracji pętli). Małe funkcje są w wielu przypadkach wstawiane lub konwertowane na wewnętrzne, które są łatwe do przeniesienia, ale duże funkcje mogą wydawać się kompilatorowi mieć skutki uboczne, podczas gdy w rzeczywistości ich nie ma. Skutki uboczne funkcji zewnętrznych są całkowicie nieznane, z wyjątkiem niektórych funkcji z biblioteki standardowej, które są czasami modelowane przez niektóre kompilatory, co umożliwia ruch kodu niezmiennego w pętli.

  7. Pisząc testy z wieloma warunkami, najpierw umieść ten najbardziej prawdopodobny. if (a || b || c) powinno być if (b || a || c) jeśli b jest bardziej prawdopodobne niż inne. Kompilatory zwykle nie wiedzą nic o możliwych wartościach warunków ani o tym, które gałęzie są pobierane częściej (można je poznać, używając informacji o profilu, ale niewielu programistów z nich korzysta).

  8. Użycie przełącznika jest szybsze niż wykonanie testu, takiego jak if (a || b || ... || z). Najpierw sprawdź, czy Twój kompilator robi to automatycznie, a niektóre robią, a bardziej czytelne jest ustawienie if .


7

W przypadku systemów embedded i kodu napisanego w C / C ++ staram się unikać dynamicznej alokacji pamięci jak najbardziej . Głównym powodem, dla którego to robię, niekoniecznie jest wydajność, ale ta praktyczna zasada ma wpływ na wydajność.

Algorytmy używane do zarządzania stertą są notorycznie powolne na niektórych platformach (np. Vxworks). Co gorsza, czas potrzebny na powrót z połączenia do malloc zależy w dużym stopniu od aktualnego stanu sterty. Dlatego każda funkcja, która wywołuje malloc, będzie miała wpływ na wydajność, którego nie można łatwo uwzględnić. Ten spadek wydajności może być minimalny, jeśli sterta jest nadal czysta, ale po pewnym czasie działania tego urządzenia sterta może ulec fragmentacji. Połączenia będą trwać dłużej i nie można łatwo obliczyć, jak wydajność spadnie z czasem. Tak naprawdę nie można oszacować gorszego przypadku. W tym przypadku również optymalizator nie może Ci pomóc. Co gorsza, jeśli sterta zostanie zbyt mocno podzielona, ​​wywołania zaczną całkowicie kończyć się niepowodzeniem. Rozwiązaniem jest użycie pul pamięci (np.glib slices ) zamiast stosu. Wezwania do alokacji będą znacznie szybsze i deterministyczne, jeśli zrobisz to dobrze.


Moja praktyczna zasada jest taka, że ​​jeśli musisz alokować dynamicznie, pobierz tablicę, aby nie trzeba było tego robić ponownie. Przydziel im wstępnie wektory.
EvilTeach

7

Głupia mała wskazówka, ale taka, która pozwoli Ci zaoszczędzić mikroskopijne ilości szybkości i kodu.

Zawsze przekazuj argumenty funkcji w tej samej kolejności.

Jeśli masz f_1 (x, y, z), które wywołuje f_2, zadeklaruj f_2 jako f_2 (x, y, z). Nie deklaruj go jako f_2 (x, z, y).

Powodem tego jest fakt, że platforma C / C ++ ABI (konwencja wywoływania AKA) obiecuje przekazywać argumenty w określonych rejestrach i lokalizacjach stosu. Kiedy argumenty znajdują się już w odpowiednich rejestrach, nie trzeba ich przesuwać.

Czytając zdemontowany kod, widziałem śmieszne tasowanie rejestrów, ponieważ ludzie nie przestrzegali tej zasady.


2
Ani C, ani C ++ nie dają żadnych gwarancji, ani nawet nie wspominają o przekazywaniu określonych rejestrów lub lokalizacji stosów. To ABI (np. Linux ELF) określa szczegóły przekazywania parametrów.
Emmet,

5

Dwie techniki kodowania, których nie widziałem na powyższej liście:

Omiń konsolidator, pisząc kod jako unikalne źródło

Chociaż oddzielna kompilacja jest naprawdę dobra do czasu kompilacji, jest bardzo zła, gdy mówisz o optymalizacji. Zasadniczo kompilator nie może optymalizować poza jednostką kompilacji, czyli domeną zarezerwowaną dla konsolidatora.

Ale jeśli dobrze zaprojektujesz swój program, możesz go również skompilować za pomocą unikalnego wspólnego źródła. Oznacza to, że zamiast kompilowania jednostek unit1.c i unit2.c, połącz oba obiekty, skompiluj all.c, które po prostu # zawierają jednostki1.c i jednostka2.c. W ten sposób skorzystasz ze wszystkich optymalizacji kompilatora.

To bardzo przypomina pisanie tylko nagłówków programów w C ++ (a nawet łatwiejsze w C).

Ta technika jest dość łatwa, jeśli napiszesz swój program, aby włączyć go od samego początku, ale musisz także mieć świadomość, że zmienia on część semantyki C i możesz napotkać pewne problemy, takie jak zmienne statyczne lub kolizje makr. W przypadku większości programów dość łatwo jest pokonać pojawiające się drobne problemy. Należy również pamiętać, że kompilacja jako unikalnego źródła jest znacznie wolniejsza i może wymagać dużej ilości pamięci (zwykle nie jest to problem w przypadku nowoczesnych systemów).

Używając tej prostej techniki zdarzyło mi się zrobić kilka programów, które napisałem dziesięć razy szybciej!

Podobnie jak słowo kluczowe register, ta sztuczka może wkrótce stać się przestarzała. Optymalizacja przez linker zaczyna być obsługiwana przez kompilatory gcc: Optymalizacja czasu łącza .

Oddzielne zadania atomowe w pętlach

Ten jest trudniejszy. Chodzi o interakcję między projektem algorytmu a sposobem zarządzania pamięcią podręczną i alokacją rejestrów przez optymalizator. Dość często programy muszą zapętlić jakąś strukturę danych i dla każdego elementu wykonać pewne czynności. Dość często wykonywane czynności można podzielić na dwa logicznie niezależne zadania. W takim przypadku możesz napisać dokładnie ten sam program z dwiema pętlami na tej samej granicy, wykonując dokładnie jedno zadanie. W niektórych przypadkach zapisanie tego w ten sposób może być szybsze niż w przypadku pętli unikatowej (szczegóły są bardziej złożone, ale wyjaśnienie może być takie, że w przypadku prostego zadania wszystkie zmienne mogą być przechowywane w rejestrach procesora, a przy bardziej złożonym nie jest to możliwe, a niektóre rejestry muszą być zapisane w pamięci i później odczytane, a koszt jest wyższy niż dodatkowa kontrola przepływu).

Bądź ostrożny z tym (profilowe wyniki używające tej sztuczki lub nie), ponieważ podobnie jak użycie rejestru może równie dobrze dawać gorsze wyniki niż ulepszone.


2
Tak, do tej pory LTO sprawiło, że pierwsza połowa tego postu stała się zbędna i prawdopodobnie jest złą radą.
underscore_d

@underscore_d: nadal istnieją pewne problemy (głównie związane z widocznością eksportowanych symboli), ale z samego punktu widzenia wydajności prawdopodobnie już nie ma.
kriss

4

Rzeczywiście widziałem to zrobione w SQLite i twierdzą, że powoduje to wzrost wydajności o ~ 5%: Umieść cały kod w jednym pliku lub użyj preprocesora, aby zrobić to samo. W ten sposób optymalizator będzie miał dostęp do całego programu i będzie mógł wykonać więcej optymalizacji międzyproceduralnych.


5
Umieszczenie funkcji, które są używane razem, w bliskiej odległości fizycznej w źródle, zwiększa prawdopodobieństwo, że będą one blisko siebie w plikach obiektowych i blisko siebie w pliku wykonywalnym. Ta ulepszona lokalizacja instrukcji może pomóc uniknąć błędów pamięci podręcznej instrukcji podczas działania.
paxos1977

Kompilator AIX ma przełącznik kompilatora, który zachęca do tego zachowania -qipa [= <suboptions_list>] | -qnoipa Włącza lub dostosowuje klasę optymalizacji znaną jako analiza międzyproceduralna (IPA).
EvilTeach

4
Najlepiej mieć sposób na rozwój, który tego nie wymaga. Używanie tego faktu jako wymówki do pisania niemodułowego kodu spowoduje ogólnie po prostu powolny kod i problemy z utrzymaniem.
Hogan

3
Myślę, że ta informacja jest nieco przestarzała. Teoretycznie funkcje optymalizacji całego programu wbudowane obecnie w wiele kompilatorów (np. „Optymalizacja czasu łącza” w gcc) pozwalają na te same korzyści, ale przy całkowicie standardowym przepływie pracy (plus krótszy czas ponownej kompilacji niż umieszczenie wszystkiego w jednym pliku !)
Ponkadoodle

@Wallacoloo Na pewno jest to nieaktualna data. FWIW, właśnie dzisiaj po raz pierwszy użyłem LTO GCC i - wszystko inne jest równe -O3- zrzuciło ono 22% pierwotnego rozmiaru z mojego programu. (Nie jest związany z procesorem, więc nie mam wiele do powiedzenia na temat szybkości.)
underscore_d

4

Większość nowoczesnych kompilatorów powinna wykonać dobrą robotę, przyspieszając rekurencję ogonową , ponieważ wywołania funkcji można zoptymalizować.

Przykład:

int fac2(int x, int cur) {
  if (x == 1) return cur;
  return fac2(x - 1, cur * x); 
}
int fac(int x) {
  return fac2(x, 1);
}

Oczywiście ten przykład nie ma żadnego sprawdzania granic.

Późna edycja

Chociaż nie mam bezpośredniej znajomości kodu; wydaje się jasne, że wymagania używania CTE na SQL Server zostały specjalnie zaprojektowane tak, aby można było je optymalizować za pomocą rekurencji końca.


1
pytanie dotyczy C. C nie usuwa rekurencji ogonowej, więc rekurencja ogona lub inna rekurencja może ulec zniszczeniu, jeśli rekursja zajdzie zbyt głęboko.
Toad

1
Uniknąłem problemu z konwencją wywoływania, używając goto. W ten sposób jest mniej kosztów ogólnych.
EvilTeach

2
@hogan: to dla mnie nowość. Czy możesz wskazać kompilator, który to robi? A skąd możesz mieć pewność, że faktycznie je optymalizuje? Jeśli tak, to naprawdę trzeba mieć pewność, że to robi. To nie jest coś, co masz nadzieję, że optymalizator kompilatora podejmie (na przykład wstawianie inline, które może, ale nie musi działać)
Toad

6
@hogan: Poprawiono mnie. Masz rację, że Gcc i MSVC optymalizują rekurencję ogonową.
Toad

5
Ten przykład nie jest rekurencją ogonową, ponieważ nie jest to ostatnie wywołanie rekurencyjne, jest to mnożenie.
Brian Young,

4

Nie wykonuj w kółko tej samej pracy!

Typowy antywzor, który widzę, przebiega w ten sposób:

void Function()
{
   MySingleton::GetInstance()->GetAggregatedObject()->DoSomething();
   MySingleton::GetInstance()->GetAggregatedObject()->DoSomethingElse();
   MySingleton::GetInstance()->GetAggregatedObject()->DoSomethingCool();
   MySingleton::GetInstance()->GetAggregatedObject()->DoSomethingReallyNeat();
   MySingleton::GetInstance()->GetAggregatedObject()->DoSomethingYetAgain();
}

Kompilator musi w rzeczywistości wywoływać wszystkie te funkcje przez cały czas. Zakładając, że programista wie, że zagregowany obiekt nie zmienia się w trakcie tych wezwań, z miłości do wszystkiego, co święte ...

void Function()
{
   MySingleton* s = MySingleton::GetInstance();
   AggregatedObject* ao = s->GetAggregatedObject();
   ao->DoSomething();
   ao->DoSomethingElse();
   ao->DoSomethingCool();
   ao->DoSomethingReallyNeat();
   ao->DoSomethingYetAgain();
}

W przypadku pojedynczego gettera wywołania mogą nie być zbyt kosztowne, ale z pewnością jest to koszt (zazwyczaj „sprawdź, czy obiekt został utworzony, jeśli nie, utwórz go, a następnie zwróć). tym bardziej skomplikowany staje się ten łańcuch pochłaniaczy, tym więcej będziemy tracić czasu.


3
  1. Użyj możliwie największego zakresu lokalnego dla wszystkich deklaracji zmiennych.

  2. Używaj w constmiarę możliwości

  3. Nie używaj register, chyba że planujesz profilować zarówno z, jak i bez niego

Pierwsze 2 z nich, zwłaszcza numer 1, pomagają optymalizatorowi w analizie kodu. Szczególnie pomoże mu to w dokonywaniu dobrych wyborów dotyczących tego, jakie zmienne należy przechowywać w rejestrach.

Ślepe użycie słowa kluczowego register może równie dobrze pomóc, co zaszkodzić optymalizacji. Po prostu zbyt trudno jest wiedzieć, co będzie miało znaczenie, dopóki nie spojrzysz na wyjście zespołu lub profil.

Są inne rzeczy, które mają znaczenie dla uzyskania dobrej wydajności z kodu; na przykład projektowanie struktur danych w celu maksymalizacji spójności pamięci podręcznej. Ale pytanie dotyczyło optymalizatora.



3

Przypomniało mi się coś, co kiedyś napotkałem, gdzie objawem było po prostu to, że kończyła się nam pamięć, ale rezultatem był znaczny wzrost wydajności (jak również ogromne zmniejszenie zużycia pamięci).

Problem w tym przypadku polegał na tym, że oprogramowanie, którego używaliśmy, generowało mnóstwo niewielkich przydziałów. Na przykład przydzielanie czterech bajtów tutaj, sześciu bajtów tam itd. Wiele małych obiektów również działa w zakresie 8-12 bajtów. Problem nie polegał na tym, że program potrzebował wielu drobiazgów, ale na tym, że przydzielał wiele drobiazgów indywidualnie, co powodowało rozdęcie każdego przydziału do (na tej konkretnej platformie) 32 bajtów.

Częścią rozwiązania było złożenie małej puli obiektów w stylu Alexandrescu, ale rozszerzenie jej, aby móc przydzielać zarówno tablice małych obiektów, jak i pojedyncze elementy. Pomogło to również ogromnie w wydajności, ponieważ więcej elementów mieści się w pamięci podręcznej w dowolnym momencie.

Drugą częścią rozwiązania było zastąpienie szalejącego używania ręcznie zarządzanych elementów char * ciągiem SSO (optymalizacji małych ciągów). Minimalna alokacja to 32 bajty, zbudowałem klasę ciągów, która miała osadzony 28-znakowy bufor za char *, więc 95% naszych ciągów nie musiało wykonywać dodatkowej alokacji (a następnie ręcznie zastąpiłem prawie każdy wygląd char * w tej bibliotece z tą nową klasą, było fajnie, czy nie). Pomogło to również tonowi w fragmentacji pamięci, co następnie zwiększyło lokalność odniesienia dla innych wskazanych obiektów i podobnie nastąpił wzrost wydajności.


3

Zgrabna technika, której nauczyłem się z komentarza @MSalters do tej odpowiedzi, pozwala kompilatorom kopiować elision nawet wtedy, gdy zwracają różne obiekty zgodnie z pewnym warunkiem:

// before
BigObject a, b;
if(condition)
  return a;
else
  return b;

// after
BigObject a, b;
if(condition)
  swap(a,b);
return a;

2

Jeśli masz małe funkcje, które wywołujesz wielokrotnie, miałem w przeszłości duże korzyści, umieszczając je w nagłówkach jako „statyczne wbudowane”. Wywołania funkcji w ix86 są zaskakująco drogie.

Reimplementacja funkcji rekurencyjnych w sposób nierekurencyjny przy użyciu jawnego stosu również może wiele zyskać, ale tak naprawdę jesteś w sferze czasu programowania w porównaniu do zysku.


Konwersja rekurencji na stos jest założoną optymalizacją na ompf.org, dla osób tworzących raytracery i piszących inne algorytmy renderujące.
Tom

... Powinienem dodać do tego, że największym narzutem w moim osobistym projekcie raytracera jest rekursja oparta na tabelach vtable poprzez hierarchię objętości ograniczających przy użyciu wzorca Composite. To tak naprawdę tylko kilka zagnieżdżonych pudełek w postaci drzewa, ale użycie wzorca powoduje rozdęcie danych (wirtualne wskaźniki tabeli) i zmniejsza spójność instrukcji (to, co może być małą / ciasną pętlą, jest teraz łańcuchem wywołań funkcji)
Tom

2

Oto moja druga rada dotycząca optymalizacji. Podobnie jak w przypadku mojej pierwszej rady, jest to cel ogólny, a nie specyficzny dla języka lub procesora.

Przeczytaj dokładnie instrukcję kompilatora i zrozum, o czym ona mówi. Użyj kompilatora do maksimum.

Zgadzam się z jednym lub dwoma innymi respondentami, którzy wskazali, że wybór odpowiedniego algorytmu ma kluczowe znaczenie dla wyciśnięcia wydajności z programu. Poza tym stopa zwrotu (mierzona poprawą wykonywania kodu) w czasie inwestowania w użycie kompilatora jest znacznie wyższa niż stopa zwrotu w ulepszaniu kodu.

Tak, twórcy kompilatorów nie są z rasy gigantów kodowania, a kompilatory zawierają błędy, a to, co powinno, zgodnie z instrukcją i zgodnie z teorią kompilatora, przyspieszyć, czasami spowalnia. Dlatego musisz robić krok po kroku i mierzyć wydajność przed i po poprawieniu.

I tak, ostatecznie możesz stanąć w obliczu kombinatorycznej eksplozji flag kompilatora, więc musisz mieć skrypt lub dwa, aby uruchomić make z różnymi flagami kompilatora, ustawić w kolejce zadania w dużym klastrze i zebrać statystyki czasu wykonywania. Jeśli jesteś tylko ty i Visual Studio na komputerze PC, stracisz zainteresowanie na długo przed wypróbowaniem wystarczającej liczby kombinacji wystarczającej liczby flag kompilatora.

pozdrowienia

znak

Kiedy po raz pierwszy odbieram fragment kodu, zwykle mogę uzyskać współczynnik 1,4 - 2,0 razy większą wydajność (tj. Nowa wersja kodu działa w 1 / 1,4 lub 1/2 czasu starej wersji) w ciągu dzień lub dwa, bawiąc się flagami kompilatora. To prawda, że ​​może to być raczej komentarz dotyczący braku umiejętności kompilatora wśród naukowców, którzy są twórcami większości kodu, nad którym pracuję, a nie symptom mojej doskonałości. Po ustawieniu flag kompilatora na max (i rzadko jest to tylko -O3) może zająć miesiące ciężkiej pracy, aby uzyskać kolejny współczynnik 1,05 lub 1,1


2

Kiedy DEC pojawił się ze swoimi procesorami alfa, było zalecenie, aby utrzymywać liczbę argumentów funkcji poniżej 7, ponieważ kompilator zawsze próbowałby automatycznie umieścić do 6 argumentów w rejestrach.


bity x86-64 pozwalają również na wiele parametrów przekazywanych przez rejestr, co może mieć dramatyczny wpływ na obciążenie wywołania funkcji.
Tom

1

Aby uzyskać wydajność, skoncentruj się najpierw na pisaniu kodu, który można konserwować - skomponowany, luźno powiązany itp., Więc jeśli musisz wyodrębnić część w celu przepisania, optymalizacji lub po prostu profilowania, możesz to zrobić bez większego wysiłku.

Optymalizator nieznacznie zwiększy wydajność programu.


3
Działa to tylko wtedy, gdy same „interfejsy” sprzęgające nadają się do optymalizacji. Interfejs może być z natury „wolny”, np. Przez wymuszanie zbędnych wyszukiwań lub obliczeń lub wymuszanie złego dostępu do pamięci podręcznej.
Tom

1

Otrzymujesz tutaj dobre odpowiedzi, ale zakładają, że Twój program jest na początku dość bliski optymalnego, i mówisz

Załóżmy, że program został poprawnie napisany, skompilowany z pełną optymalizacją, przetestowany i wdrożony do produkcji.

Z mojego doświadczenia wynika, że ​​program może być napisany poprawnie, ale to nie znaczy, że jest bliski optymalnego. Dojście do tego punktu wymaga dodatkowej pracy.

Jeśli mogę podać przykład, ta odpowiedź pokazuje, jak doskonale wyglądający program został wykonany ponad 40 razy szybciej dzięki optymalizacji makro . Duże przyspieszenia nie mogą być wykonane w każdym programie, tak jak napisano na początku, ale w wielu (z wyjątkiem bardzo małych programów) jest to możliwe, z mojego doświadczenia.

Po wykonaniu tej czynności mikro-optymalizacja (hot-spotów) może przynieść niezłe korzyści.


1

używam kompilatora Intel. zarówno w systemie Windows, jak i Linux.

kiedy mniej więcej skończyłem, profiluję kod. następnie zawieszaj się na hotspotach i próbuj zmienić kod, aby umożliwić kompilatorowi lepszą pracę.

jeśli kod jest kodem obliczeniowym i zawiera dużo pętli - bardzo pomocny jest raport wektoryzacji w kompilatorze Intel - poszukaj w pomocy 'vec-report'.

więc główna idea - dopracuj kod krytyczny dla wydajności. co do reszty - pierwszeństwo poprawności i utrzymania - krótkie funkcje, czytelny kod, który można było zrozumieć 1 rok później.


Zbliżasz się do odpowiedzi na pytanie ..... jakie rzeczy robisz z kodem, aby umożliwić kompilatorowi wykonanie tego rodzaju optymalizacji?
EvilTeach

1
Próbując pisać więcej w stylu C (w porównaniu z C ++), np. Unikając funkcji wirtualnych bez absolutnej potrzeby, zwłaszcza jeśli mają być często wywoływane, unikaj AddRefs ... i wszystkich fajnych rzeczy (ponownie, chyba że naprawdę potrzebne). Pisz kod łatwy do wbudowania - mniej parametrów, mniej „jeśli” -s. Nie używaj zmiennych globalnych, chyba że jest to absolutnie konieczne. W strukturze danych - najpierw umieść szersze pola (double, int64 poprzedza int) - więc kompilator wyrówna strukturę na pierwszym polu o naturalnym rozmiarze - wyrównanie dobre dla perf.
jf.

1
Układ danych i dostęp do nich są absolutnie krytyczne dla wydajności. Czyli po profilowaniu - czasami rozbijam strukturę na kilka według lokalizacji dostępów. Jeszcze jedna ogólna sztuczka - użyj int lub size-t vs. char - nawet wartości danych są małe - unikaj różnych perf. kary magazynują blokowanie wczytywania, problemy z częściowymi rejestrami przeciągają się. oczywiście nie ma to zastosowania, gdy potrzebne są duże tablice takich danych.
jf.

Jeszcze jedno - unikaj wywołań systemowych, chyba że jest taka potrzeba :) - są BARDZO drogie
jf.

2
@jf: Dałem +1 Twojej odpowiedzi, ale czy możesz przenieść odpowiedź z komentarzy do treści odpowiedzi? Będzie łatwiej się czytać.
kriss

1

Jedną z optymalizacji, której użyłem w C ++, jest utworzenie konstruktora, który nic nie robi. Należy ręcznie wywołać init () w celu wprowadzenia obiektu w stan roboczy.

Jest to korzystne w przypadku, gdy potrzebuję dużego wektora tych klas.

Wywołuję Reserve (), aby przydzielić miejsce na wektor, ale konstruktor w rzeczywistości nie dotyka strony pamięci, na której znajduje się obiekt. Więc spędziłem trochę przestrzeni adresowej, ale tak naprawdę nie zużyłem dużo pamięci fizycznej. Unikam błędów stron związanych z kosztami budowy.

Kiedy generuję obiekty do wypełnienia wektora, ustawiam je za pomocą init (). Ogranicza to całkowitą liczbę błędów stron i pozwala uniknąć konieczności zmiany rozmiaru () wektora podczas wypełniania go.


6
Uważam, że typowa implementacja std :: vector w rzeczywistości nie konstruuje więcej obiektów, gdy zarezerwujesz () większą pojemność. Po prostu przydziela strony. Konstruktory są wywoływane później, używając umieszczania new, kiedy faktycznie dodajesz obiekty do wektora - co jest (prawdopodobnie) tuż przed wywołaniem init (), więc tak naprawdę nie potrzebujesz oddzielnej funkcji init (). Pamiętaj również, że nawet jeśli Twój konstruktor jest „pusty” w kodzie źródłowym, skompilowany konstruktor może zawierać kod inicjujący takie rzeczy, jak wirtualne tabele i RTTI, więc strony i tak są dotykane w czasie tworzenia.
Wyzard

1
Tak. W naszym przypadku używamy push_back do wypełnienia wektora. Obiekty nie mają żadnych funkcji wirtualnych, więc nie stanowi to problemu. Gdy pierwszy raz spróbowaliśmy tego z konstruktorem, byliśmy zdumieni ilością błędów stronicowania. Zdałem sobie sprawę, co się stało, i wyrwaliśmy konstruktorowi wnętrzności, a problem z błędem strony zniknął.
EvilTeach

To mnie raczej zaskakuje. Z jakich implementacji C ++ i STL korzystałeś?
David Thornley

3
Zgadzam się z innymi, to brzmi jak zła implementacja std :: vector. Nawet jeśli obiekty miałyby vtables, nie zostałyby one skonstruowane do momentu push_back. Powinieneś móc to przetestować, deklarując domyślny konstruktor jako prywatny, ponieważ wszystko, czego potrzebujesz, to konstruktor kopiujący dla push_back.
Tom

1
@David - implementacja była w systemie AIX.
EvilTeach

1

Jedną z rzeczy, które zrobiłem, jest próba zatrzymania kosztownych działań w miejscach, w których użytkownik może oczekiwać, że program trochę się opóźni. Ogólna wydajność jest związana z responsywnością, ale nie jest taka sama, a pod wieloma względami czas reakcji jest ważniejszą częścią wydajności.

Ostatnim razem, gdy naprawdę musiałem poprawić ogólną wydajność, zwracałem uwagę na nieoptymalne algorytmy i szukałem miejsc, w których prawdopodobnie wystąpiły problemy z pamięcią podręczną. Najpierw profilowałem i mierzyłem wydajność, a potem ponownie po każdej zmianie. Potem firma upadła, ale i tak była to ciekawa i pouczająca praca.


0

Od dawna podejrzewałem, ale nigdy nie udowodniłem, że zadeklarowanie tablic tak, aby miały potęgę 2, jako liczbę elementów, umożliwia optymalizatorowi zmniejszenie siły poprzez zastąpienie mnożenia przez przesunięcie o liczbę bitów, patrząc w górę poszczególne elementy.


6
Kiedyś to było prawdą, teraz jest teraz. W rzeczywistości jest dokładnie odwrotnie. Jeśli zadeklarujesz swoje tablice z potęgami dwójki, najprawdopodobniej napotkasz w pamięci sytuację, w której pracujesz nad dwoma wskaźnikami o potęgach dwóch. Problem polega na tym, że pamięci podręczne procesora są tak zorganizowane i możesz skończyć z dwiema macierzami walczącymi wokół jednej linii pamięci podręcznej. W ten sposób uzyskujesz okropną wydajność. Posiadanie jednego ze wskaźników kilka bajtów do przodu (np. Brak potęgi dwóch) zapobiega takiej sytuacji.
Nils Pipenbrinck,

+1 Nils, a jednym z konkretnych przypadków tego jest „aliasowanie 64k” na sprzęcie Intela.
Tom

Nawiasem mówiąc, można to łatwo obalić, patrząc na demontaż. Byłem zdumiony, lata temu, widząc, jak gcc optymalizowałoby wszelkiego rodzaju ciągłe mnożenia za pomocą przesunięć i dodań. Np. val * 7Zamienił się w coś, co inaczej by wyglądało (val << 3) - val.
dash-tom-bang

0

Umieść małe i / lub często wywoływane funkcje na górze pliku źródłowego. Ułatwia to kompilatorowi znalezienie możliwości wbudowania.


Naprawdę? Czy możesz przytoczyć uzasadnienie i przykłady? Nie mówiąc, że to nieprawda, po prostu brzmi nieintuicyjnie, że lokalizacja miałaby znaczenie.
underscore_d

@underscore_d nie może coś wstawić, dopóki nie będzie znana definicja funkcji. Chociaż współczesne kompilatory mogą wykonywać wiele przebiegów, aby definicja była znana w czasie generowania kodu, nie zakładam tego.
Mark Ransom

Zakładałem, że kompilatory pracują na abstrakcyjnych wykresach wywołań, a nie na fizycznej kolejności funkcji, co oznacza, że ​​nie ma to znaczenia. Jasne, przypuszczam, że nie zaszkodzi być szczególnie ostrożnym - zwłaszcza gdy pomijając wydajność, IMO wydaje się bardziej logiczne, aby zdefiniować funkcje, które są wywoływane przed tymi, które je wywołują. Musiałbym przetestować wydajność, ale byłbym zaskoczony, gdyby to miało znaczenie, ale do tego czasu jestem otwarty na zaskoczenie!
underscore_d
Korzystając z naszej strony potwierdzasz, że przeczytałeś(-aś) i rozumiesz nasze zasady używania plików cookie i zasady ochrony prywatności.
Licensed under cc by-sa 3.0 with attribution required.