Nadpisywanie zmiennej - optymalizacja kompilatora

0
unsigned int A = 0;

for (int i = 0; i < 100; i++)
{
	A = 0;
	//JAKIŚ KOD, KTÓRY MODYFIKUJE ZMIENNĄ A
	Tablica[i] = A;
}

Na początku każdej iteracji w pętli zmienna A zostaje zresetowana (ustawiona na 0). W teorii, dla uzyskania lepszej wydajności, szybsze byłoby sprawdzenie stanu A przed jej nadpisaniem (czyli if (A) A = 0;), żeby nie modyfikować pamięci bez potrzeby, ale z tego co wiem, kompilator domyślnie optymalizuje taki kod i nie przypisuje zmiennej wartości, którą ta już posiada. Debug powyższego kodu to potwierdza, ale czy na takie zachowanie kompilatora można liczyć zawsze? Taka niby pierdółka, ale zastanawia mnie jaką praktykę powinno się stosować w takich sytuacjach, ręcznie sprawdzać, czy liczyć na kompilator.

3

Nie możesz na nic liczyć, szczególnie że to może zależeć od wielu czynników. A jest zmienną globalną, ale nie jest atomic/volatile, więc kompilator ma prawo założyć, że nie jest modyfikowana z żadnego innego miejsca w kodzie, więc jeśli zna definicje JAKIŚ KOD, KTÓRY MODYFIKUJE ZMIENNĄ A, bo to szablony albo wszystko jest w tym samym TU, to może w ogóle nie zapisywać do zmiennej globalnej i wartość A trzymać w jakimś rejestrze, a zapisać ją dopiero na sam koniec. Może być też odwrotnie.

Zbenchmarkowałbym też rzeczywisty zysk z warunku, który opisujesz, bo wydaje mi się mało wiarygodnym, aby był zauważalny.

7
  1. Sprawdzenie i skok warunkowy zabiera na większości procesorów więcej czasu niż wyzerowanie (wyzerowanie to jeden xor tylko).
  2. Optymalizacje kompilatora zazwyczaj są nie gorsze od ludzkich, o ile nie wiesz czegoś nietypowego, czego nie możesz wyrazić kodem. W takiej sytuacji pierwszym pomysłem powinno być skupienie się na ekspresyjności kodu, a dopiero w drugiej kolejności — na pisaniu swojego asma.
  3. Benchmark, benchmark, benchmark.
  4. I zobaczenie na Godbolcie jaki jest generowany kod też nie zaszkodzi.
2

Ad. 2. Jeśli nie masz toolchainu sprzed 40 i więcej lat to wszelkie mikrooptymalizacje wykonane przez kompilator raczej będą lepsze od ludzkich, a wprowadzenie własnych może nawet kod popsuć.

Nie ma jednej dobrej praktyki, ale imho - mierzyć, mierzyć, mierzyć ;) Przy czym dla intów to o tyle proste, że skok z reguły jest droższy niż xor. Przy wielkich obiektach musisz po prostu profilować.

6

Nie kombinuj.
W C++ i w C obowiązuje zasada "AS IF" (tak jak).
Z włączonymi optymalizacjami, kompilator ma prawo zrobić dowolne przekształcenie kodu tak długo jeśli widoczny wynik działania pozostaje bez zmian.

Twój przykład dla kompilatora jest jak spacerek przez park, więc kompilator może zdecydować, że przesunie ci zmienną lokalną do wnętrza pętli for, albo wręcz trzymać ja tylko w rejestrze.
Założę się, że jak dopiszesz ten dodatkowy if to kompilator będzie w stanie ustalić, że ten dodatek nie ma sensu i usunie ten warunek.

Jako, że kompilator jest dość inteligentny to nawet pisanie testów sprawdzających wydajność wymaga dość dużej świadomości, co kompilator potrafi. Widziałem już wiele przypadków, kiedy ktoś napisał piękne testy, a jak się popatrzyło w kod maszynowy, to okazywało się, że kompilator pozbył się większości kodu, który miał być testowany. Na stackoverflow jest dużo takich pytań i czasami nikt nie zauważa takiej wpadki, tak dużej ostrożności to wymaga.

Moja rada jako początkujący na razie nie zastanawiaj się nad takimi mikrooptymalizacjami. Lepiej jak zapoznasz się z pojęciem złożoności obliczeniowej.

0
for (int i = 0; i < 100; ++i)
{
    unsigned int A = 0; // tu masz sporą szanse że tej zmiennej nie będzie jako zmiennej - zoptymalizuje się do rejestru.
    //JAKIŚ KOD, KTÓRY MODYFIKUJE ZMIENNĄ A
    Tablica[i] = A;
}
1
alagner napisał(a):

Ad. 2. Jeśli nie masz toolchainu sprzed 40 i więcej lat to wszelkie mikrooptymalizacje wykonane przez kompilator raczej będą lepsze od ludzkich, a wprowadzenie własnych może nawet kod popsuć.

To też nie do końca prawda, w szczególnych przypadkach dobrze przemyślany kod z wykorzystaniem SSE/AVX itp. może być znacznie szybszy od tego co kompilatory potrafią wypluć.

1

@Azarien: nie nazywałbym celowego korzystania z SIMD "mikrooptymalizacją" ;)

0

Heh, to bardziej skomplikowane, niż sądziłem :).

Mój rzeczywisty kod wygląda mniej więcej tak. Na początku każdej klatki leci GetKeyboardState(), który zapisuje stan klawiatury do zmiennej CurrentControlState (które jest unsigned char[256], a stan może wynosić albo 1 - klawisz wciśnięty, albo 0 - klawisz nie jest wciśnięty). Następnie w pętli porównywane są kolejne indeksy z CurrentControlState i PreviousControlState (który to przechowuje stan klawiatury z ostatniej klatki). W zależności od wyniku porównania, zapalane są odpowiednie flagi dla każdego klawisza, przechowywane w osobnej tablicy:

struct BasicKey
{
	bool Pressed;
	bool Released:
	bool Held;
}

Flagi Pressed i Released są jednorazowymi 'eventami' więc zostają zapalone tylko to końca klatki, w których nastąpiła zmiana flagi (dzięki czemu możliwe jest np. if (Key[VK_SPACE].Pressed), ktore będzie wykonane tylko jednokrotnie, niezależnie od tego w czasie ilu klatek klawisz rzeczywiście był wciśnięty). To oznacza, że obie te flagi na początku każdej kolejnej klatki muszą zostać zgaszone, więc u mnie wygląda to tak:

for (int I = 0; I < 256; I++)
	{
		if (Control[I].Pressed) Control[I].Pressed = false;
		if (Control[I].Released) Control[I].Released = false;

		if (CurrentControlState[I] != PreviousControlState[I])
		{
			if (CurrentControlState[I])
			{
				Control[I].Pressed = true;
				Control[I].Held = true;
			}
			else
			{
				Control[I].Released = true;
				Control[I].Held = false;
			}
			PreviousControlState[I] = CurrentControlState[I];
		}
	}

I to zerowanie Pressed i Released jest nieoptymalne, bo często będzie dochodziło do sprawdzania stanu tych flag bez potrzeby. Np. gdy klawisz jest cały czas nieaktywny, a więc PreviousControlState = 0 i CurrentControlState = 0. Zastanawiałem się więc, czy w takiej sytuacji nie zastosować nadpisania zmiennej (bez sprawdzania), a w przypadku zmiany false na false kompilator mógłby po prostu to zoptymalizować.

2

Primo: czy z tym kodem jest problem, czy tylko wydaje Ci się, że jest? W sensie: zmierzyłeś? Secundo: na ślepo bym pewmie sprobował operacjami logicznymi albo bitowymi ograć, ale to jest mój embeddowy nos a nie cokolwiek zmierzonego. Poza tym, możliwe, że kompilator te ify np. już zoptymalizuje do xorów z samym sobą. Sprawdź godboltem np. Niekoniecznie masz się czym martwić.

2

Ja nadal nie rozumiem czegoś, ty myślisz, że sprawdzenie (tzn odczyt z pamięci w najgorszym razie, plus skok warunkowy, o ile kompilator nie optymalizuje), jest szybsze niż wpisanie czegoś do pamięci? Z tego co mi wiadomo (zakładając, ze kompilator tego nie uprości), to odczyt i zapis zajmują mniej więcej tyle samo czasu, podczas gdy sprawdzanie warunku jest bardzo kosztowne. Więc dlatego tylu z nas tutaj się cię pyta o to czy w ogóle sprawdziłeś, że to ma jakikolwiek wpływ. Albo chociaż, że kompilator nie generuje dokładnie tego samego kodu w obu przypadkach.

Oczywiście, byłoby zupełnie co innego, gdybyś tam miał zamiast boola/inta jakiś duży typ w którym kopia jest wolna, a sprawdzenie warunku szybkie (ale też ciężko mi sobie wyobrazić typ gdzie sprawdzenie faktycznie byłoby szybsze, może taki jest, idk).

1
for(int I=0;I<256;++I)
    {
        Control[I].Pressed=Control[I].Released=false;
        if(PreviousControlState[I]==CurrentControlState[I]) continue;
        bool on=CurrentControlState[I];
        (Control[I].Held=on?Control[I].Pressed:Control[I].Released)=true;
        PreviousControlState[I]=CurrentControlState[I];
    }

Z tym że wg mnie wystarczy ci:

for(int I=0;I<256;++I) Control[I].WasPresed|=(Control[I].Held=CurrentControlState[I]);
0

@_13th_Dragon:

Proszę bardzo:

if (Controls[Key::CONTROL_LEFT].Pressed) Player.AimMode();
if (Controls[Key::CONTROL_LEFT].Released) Player.Shoot();
if (Controls[Key::RIGHT].Held) Player.Walk();
0

Po optymalizacji używając wyłącznie zdrowego rozsądku:

if(CurrentControlState[Key::CONTROL_LEFT]) Player.AimMode();
else if(Player.isInAimMode()) Player.Shoot();
if(CurrentControlState[Key::RIGHT]) Player.Walk();

Zaś tą pętlę można wsadzić ... Husarzy! Milczeć!

0

Nie ma spóźnionej reakcji. GameLoop wygląda mniej więcej tak:

while (ThreadAlive)
{
	UpdateInputs() //Tutaj następuje odczytanie stanu klawiatury i przepisanie jej do tablicy 'Controls`.
	UpdateGameLogic() //Tutaj wykonywane są wszystkie akcje zaprogramowane dla danej gry, ruch postaci, reakcja na klawisze itd.
	DrawFrame() //Rysowanie klatki na ekranie.
}

Reakcja na klawisze jest więc natychmiastowa.

0
Crow napisał(a):

Reakcja na klawisze jest więc natychmiastowa.

Doprawdy? Radzę wyłączyć emocje oraz włączyć myślenie na obrazku widać kiedy nastąpi reakcja na naciśnięty klawisz.
screenshot-20210622220609.png

2

Przechodzę z komentarzy bo mi miejsca brakło ;P Przy czym nie traktuj moich słów jak wyroczni, ktoś mądry może przyjść i wymyślić coś lepszego, chętnie to uznam ;)
Ad rem:
Radziłbym synchronizować się jakimś timerem i kolejką eventów.
Ew. zrobić vsync/frame rate limiter i czytać ze stałą f? Nie wiem co piszesz dokładnie, możesz zobaczyć np. to https://github.com/exult/exult/ (chociaż to nie jest najpiękniejszy kod, to używa sprawdzonego libSDL i w oparciu "timer queue" jeździ). No i pomyśl: jaki ma sens sprawdzanie stanu klawiatury 7000x/sek.? Synchronizacja do ramki miała sens np na C64, gdzie proc renderował, grał i czytał - ok, ale tam FPSy wynikały z cyklu maszynowego. Obecnie to niezbyt ma sens, chyba, że zrobisz ten cykl maszynowy... no timerem/vsynciem ;P

1 użytkowników online, w tym zalogowanych: 0, gości: 1