5.4 — Reguła „jak gdyby” i optymalizacja czasu kompilacji

Wprowadzenie do optymalizacji

W programowaniu optymalizacja to proces modyfikowania oprogramowania, aby działało wydajniej (np. aby działało szybciej lub zużywało mniej zasobów). Optymalizacja może mieć ogromny wpływ na ogólny poziom wydajności aplikacji.

Niektóre rodzaje optymalizacji są zwykle wykonywane ręcznie. Programu o nazwie profiler można użyć do sprawdzenia, ile czasu zajmuje działanie różnych części programu i które mają wpływ na ogólną wydajność. Programista może następnie szukać sposobów na złagodzenie tych problemów z wydajnością. Ponieważ ręczna optymalizacja jest powolna, programiści zazwyczaj skupiają się na wprowadzaniu ulepszeń wysokiego poziomu, które będą miały duży wpływ (takich jak wybór wydajniejszych algorytmów, optymalizacja przechowywania i dostępu do danych, zmniejszanie wykorzystania zasobów, równoległość zadań itp.)

Inne rodzaje optymalizacji można przeprowadzić automatycznie. Program optymalizujący inny program nazywany jest optymalizatorem. Optymalizatory zazwyczaj działają na niskim poziomie, szukając sposobów na ulepszenie instrukcji lub wyrażeń poprzez ich przepisanie, zmianę kolejności lub wyeliminowanie. Na przykład, gdy napiszesz i = i * 2;, optymalizator może przepisać to jako i *= 2;, i += i; lub i <<= 1;. W przypadku wartości całkowitych wszystkie dają ten sam wynik, ale jedna może być szybsza niż inne w danej architekturze. Programista prawdopodobnie nie wiedziałby, który wybór jest najskuteczniejszy (a odpowiedź może się różnić w zależności od architektury), ale optymalizator dla danego systemu tak. Poszczególne optymalizacje niskiego poziomu mogą dawać jedynie niewielki wzrost wydajności, ale ich skumulowany efekt może skutkować ogólną znaczną poprawą wydajności.

Nowoczesne kompilatory C++ optymalizują kompilatory, co oznacza, że ​​są w stanie automatycznie optymalizować programy w ramach procesu kompilacji. Podobnie jak preprocesor, te optymalizacje nie modyfikują plików kodu źródłowego — raczej są stosowane w sposób przezroczysty jako część procesu kompilacji.

Kluczowa informacja

Kompilatory optymalizujące pozwalają programistom skupić się na pisaniu kodu, który jest czytelny i łatwy w utrzymaniu, bez poświęcania wydajności.

Ponieważ optymalizacja wiąże się z pewnymi kompromisami (omówimy to na dole lekcji), kompilatory zazwyczaj obsługują wiele poziomów optymalizacji, które określają, czy optymalizują i jak agresywnie optymalizują, i jaki rodzaj optymalizacji traktują priorytetowo (np. szybkość vs rozmiar).

Większość kompilatorów domyślnie nie wyłącza optymalizacji, więc jeśli używasz kompilatora wiersza poleceń, musisz samodzielnie włączyć optymalizację. Jeśli używasz IDE, IDE prawdopodobnie automatycznie skonfiguruje kompilacje wydań, aby umożliwić optymalizację, a kompilacje debugowania, aby wyłączyć optymalizację.

Dla użytkowników gcc i Clang

Zobacz 0.9 — Konfigurowanie kompilatora: Konfiguracje kompilacji aby uzyskać informacje o tym, jak włączyć optymalizację.

Zasada „jak gdyby”

W C++ kompilatory mają dużą swobodę w optymalizacji programów. reguła as-if mówi, że kompilator może modyfikować program w dowolny sposób, aby wygenerować bardziej zoptymalizowany kod, pod warunkiem, że modyfikacje te nie wpływają na „obserwowalne zachowanie programu”.

Dla zaawansowanych czytelników

Istnieje jeden godny uwagi wyjątek od reguły as-if: niepotrzebne wywołania konstruktora kopiującego (lub przenoszącego) można pominąć (pominąć), nawet jeśli konstruktory te mają obserwowalne zachowanie. Omawiamy ten temat na lekcji 14.15 — Inicjalizacja klasy i elizja kopiowania.

Współczesne kompilatory wykorzystują wiele różnych technik w celu skutecznej optymalizacji programu. To, które techniki można zastosować, zależy od programu oraz jakości kompilatora i optymalizatora.

Powiązana treść

Wikipedia posiada listę konkretnych technik używanych przez kompilatory.

Możliwość optymalizacji

Rozważ następujący krótki program:

#include <iostream>

int main()
{
	int x { 3 + 4 };
	std::cout << x << '\n';

	return 0;
}

Wyjście jest proste:

7

Skrywa się w nim jednak interesująca możliwość optymalizacji.

Gdyby ten program został skompilowany dokładnie tak, jak został napisany (bez optymalizacji), kompilator wygenerowałby plik wykonywalny, który oblicza wynik 3 + 4 w czasie wykonywania (kiedy program jest uruchamiany). Jeśli program zostałby wykonany milion razy, 3 + 4 byłby oceniany milion razy, a wynikowa wartość 7 wygenerowana byłaby milion razy.

Ponieważ wynik 3 + 4 nigdy się nie zmienia (zawsze jest 7), ponowne obliczanie tego wyniku przy każdym uruchomieniu programu jest marnotrawstwem.

Czas kompilacji ocena

Nowoczesne kompilatory C++ są w stanie całkowicie lub częściowo oceniać pewne wyrażenia w czasie kompilacji (a nie w czasie wykonywania). Kiedy kompilator całkowicie lub częściowo ocenia wyrażenie w czasie kompilacji, nazywa się to oceną w czasie kompilacji.

Kluczowa informacja

Oceną w czasie kompilacji pozwala kompilatorowi na wykonanie pracy w czasie kompilacji, która w innym przypadku zostałaby wykonana w czasie wykonywania. Ponieważ takie wyrażenia nie muszą już być oceniane w czasie wykonywania, powstałe pliki wykonywalne są szybsze i mniejsze (kosztem nieco wolniejszego czasu kompilacji).

Dla celów ilustracyjnych, w tej lekcji przyjrzymy się kilku prostym technikom optymalizacji, które wykorzystują ocenę w czasie kompilacji. Następnie będziemy kontynuować dyskusję na temat oceny czasu kompilacji na kolejnych lekcjach.

Ciągłe składanie

Jedna z oryginalnych form oceny czasu kompilacji nazywa się „ciągłym składaniem”. Ciągłe składanie to technika optymalizacji, w której kompilator zastępuje wyrażenia zawierające dosłowne operandy wynikiem wyrażenia. Stosując ciągłe składanie, kompilator rozpoznałby, że wyrażenie 3 + 4 ma stałe operandy, a następnie zastąpił wyrażenie wynikiem 7.

Wynik byłby równoważny następującemu:

#include <iostream>

int main()
{
	int x { 7 };
	std::cout << x << '\n';

	return 0;
}

Ten program generuje te same dane wyjściowe (7) co poprzednia wersja, ale powstały plik wykonywalny nie musi już spędzać cykli procesora na obliczaniu 3 + 4 w runtime!

Stałe zwijanie można również zastosować do podwyrażeń, nawet jeśli pełne wyrażenie musi zostać wykonane w czasie wykonywania.

#include <iostream>

int main()
{
	std::cout << 3 + 4 << '\n';

	return 0;
}

W powyższym przykładzie 3 + 4 jest podwyrażeniem pełnego wyrażenia std::cout << 3 + 4 << '\n';. Kompilator może to zoptymalizować w celu std::cout << 7 << '\n';.

Stała propagacja

Następujący program zawiera kolejną możliwość optymalizacji:

#include <iostream>

int main()
{
	int x { 7 };
	std::cout << x << '\n';

	return 0;
}

Po wywołaniu x jest inicjowany, wartość 7 będzie przechowywana w pamięci przydzielonej dla x. Następnie w następnej linii program ponownie przejdzie do pamięci, aby pobrać wartość 7 , aby można ją było wydrukować. Wymaga to dwóch operacji dostępu do pamięci (jednej do przechowywania wartości i drugiej do jej pobrania).

Stała propagacja to technika optymalizacji, w której kompilator zastępuje zmienne, o których wiadomo, że mają stałe wartości, ich wartościami. Stosując stałą propagację, kompilator zdałby sobie sprawę, że x zawsze ma stałą wartość 7 i zastępuje wszelkie użycie zmiennej x wartością 7.

Wynik byłby równoważny następującemu:

#include <iostream>

int main()
{
	int x { 7 };
	std::cout << 7 << '\n';

	return 0;
}

To eliminuje potrzebę przechodzenia programu do pamięci w celu pobrania wartości x.

Może wystąpić ciągła propagacja wynik, który można następnie zoptymalizować poprzez ciągłe zwijanie:

#include <iostream>

int main()
{
	int x { 7 };
	int y { 3 };
	std::cout << x + y << '\n';

	return 0;
}

W tym przykładzie ciągła propagacja przekształciłaby x + y do 7 + 3, co można następnie stale złożyć do wartości 10.

Eliminacja martwego kodu

Eliminacja martwego kodu to technika optymalizacji, w której kompilator usuwa kod, który może zostać wykonany, ale nie ma wpływu na działanie programu zachowanie.

Powrót do poprzedniego przykładu:

#include <iostream>

int main()
{
	int x { 7 };
	std::cout << 7 << '\n';

	return 0;
}

W tym programie zmienna x jest zdefiniowany i zainicjowany, ale nigdy nie jest nigdzie używany, więc nie ma wpływu na zachowanie programu. Eliminacja martwego kodu spowodowałaby usunięcie definicji x.

Wynik byłby równoważny następującemu:

#include <iostream>

int main()
{
	std::cout << 7 << '\n';

	return 0;
}

Kiedy zmienna jest usuwana z programu, ponieważ nie jest już potrzebna, mówimy, że zmienna została zoptymalizowana (lub zoptymalizowana).

W porównaniu z wersją pierwotną, ta zoptymalizowana wersja nie wymaga już wyrażeń obliczeniowych w czasie wykonywania 3 + 4 ani nie wymaga dwóch operacji dostępu do pamięci (jednej do zainicjuj zmienną x i jedną do odczytania wartości z x). Oznacza to, że program będzie mniejszy i szybszy.

Zmienne stałe są łatwiejsze do optymalizacji

W niektórych przypadkach są proste rzeczy, które możemy zrobić, aby pomóc kompilatorowi w skuteczniejszej optymalizacji.

Ciągła propagacja może być wyzwaniem dla kompilatora. W części poświęconej stałej propagacji podaliśmy następujący przykład:

#include <iostream>

int main()
{
	int x { 7 };
	std::cout << x << '\n';

	return 0;
}

Ponieważ x jest zdefiniowana jako zmienna inna niż stała, aby zastosować tę optymalizację, kompilator musi zdać sobie sprawę, że wartość x w rzeczywistości się nie zmienia (nawet jeśli mogłaby). To, czy kompilator jest w stanie to zrobić, zależy od tego, jak złożony jest program i jak zaawansowane są procedury optymalizacyjne kompilatora.

Możemy pomóc kompilatorowi w skuteczniejszej optymalizacji, używając tam, gdzie to możliwe, zmiennych stałych. Na przykład:

#include <iostream>

int main()
{
	const int x { 7 }; // x is now const
	std::cout << x << '\n';

	return 0;
}

Ponieważ x jest teraz const, kompilator ma gwarancję, że x nie da się zmienić po inicjalizacji. Zwiększa to prawdopodobieństwo, że kompilator zastosuje stałą propagację, a następnie całkowicie zoptymalizuje zmienną.

Kluczowa informacja

Użycie zmiennych const może pomóc kompilatorowi w skuteczniejszej optymalizacji.

Optymalizacja może utrudnić debugowanie programów

Jeśli optymalizacja przyspiesza nasze programy, dlaczego nie jest domyślnie włączona?

Kiedy kompilator optymalizuje program, w rezultacie zmienne wyrażenia, instrukcje i wywołania funkcji można zmieniać, modyfikować, zastępować lub całkowicie usuwać. Takie zmiany mogą utrudnić skuteczne debugowanie programu.

W czasie wykonywania debugowanie skompilowanego kodu, który nie jest już zbyt dobrze skorelowany z oryginalnym kodem źródłowym, może być trudne. Na przykład, jeśli spróbujesz obejrzeć zoptymalizowaną zmienną, debuger nie będzie w stanie jej zlokalizować. Jeśli spróbujesz wejść do zoptymalizowanej funkcji, debuger po prostu ją pominie. Jeśli więc debugujesz swój kod, a debuger zachowuje się dziwnie, jest to najbardziej prawdopodobny powód.

W czasie kompilacji mamy niewielką widoczność i niewiele narzędzi, które pomogą nam zrozumieć, co w ogóle robi kompilator. Jeśli zmienna lub wyrażenie zostanie zastąpione wartością, a ta wartość będzie błędna, jak w ogóle przystąpimy do debugowania problemu? Jest to ciągłe wyzwanie.

Aby zminimalizować takie problemy, kompilacje debugowania zazwyczaj pozostawiają wyłączone optymalizacje, dzięki czemu skompilowany kod będzie bardziej zgodny z kodem źródłowym.

Nota autora

Debugowanie w czasie kompilacji to słabo rozwinięty obszar. Począwszy od C++23, rozważanych jest wiele artykułów dotyczących przyszłych standardów językowych (takich jak ten), które (jeśli zostaną zatwierdzone) dodadzą do języka przydatne możliwości.

Nomenklatura: Stałe czasu kompilacji a stałe czasu wykonania

Stałe w C++ są czasami dzielone na dwie nieformalne kategorie.

A stała czasu kompilacji jest stała, której wartość jest znana w czasie kompilacji. Przykłady obejmują:

  • Literały.
  • Obiekty stałe, których inicjatory są stałymi w czasie kompilacji.

A stała wykonawcza jest stałą, której wartość jest określana w kontekście środowiska wykonawczego. Przykłady obejmują:

  • Parametry funkcji stałych.
  • Obiekty stałe, których inicjatory nie są stałymi lub stałymi czasu wykonywania.

Na przykład:

#include <iostream>

int five()
{
    return 5;
}

int pass(const int x) // x is a runtime constant
{
    return x;
}

int main()
{
    // The following are non-constants:
    [[maybe_unused]] int a { 5 };

    // The following are compile-time constants:
    [[maybe_unused]] const int b { 5 };
    [[maybe_unused]] const double c { 1.2 };
    [[maybe_unused]] const int d { b };       // b is a compile-time constant

    // The following are runtime constants:
    [[maybe_unused]] const int e { a };       // a is non-const
    [[maybe_unused]] const int f { e };       // e is a runtime constant
    [[maybe_unused]] const int g { five() };  // return value isn't known until runtime
    [[maybe_unused]] const int h { pass(5) }; // return value isn't known until runtime

    return 0;
}

Chociaż te terminy spotkasz w środowisku naturalnym, w C++ te definicje nie są zbyt przydatne:

  • Niektóre stałe czasu wykonania (a nawet inne niż stałe) mogą być oceniane w czasie kompilacji dla celów optymalizacji (zgodnie z zasadą as-if).
  • Niektóre stałe czasu kompilacji (np. const double d { 1.2 };) nie mogą być używane w funkcjach czasu kompilacji (zgodnie z definicją w standardzie językowym). Omówimy to szerzej na lekcji 5.5 -- Wyrażenia stałe.

Z tego powodu zalecamy unikanie tych terminów. Nazewnictwo, którego powinieneś używać, omówimy podczas następnej lekcji.

Nota autora

Jesteśmy w trakcie wycofywania tych terminów z przyszłych artykułów.

guest
Twój adres e-mail nie zostanie wyświetlony
Znalazłeś błąd? Zostaw komentarz powyżej!
Komentarze związane z poprawkami zostaną usunięte po przetworzeniu, aby pomóc zmniejszyć bałagan. Dziękujemy za pomoc w ulepszaniu witryny dla wszystkich!
Awatary z https://gravatar.com/ są połączone z podanym adresem e-mail.
Powiadamiaj mnie o odpowiedziach:  
35 Komentarze
Najnowsze
Najstarsze Najczęściej głosowane
Wbudowane opinie
Wyświetl wszystkie komentarze