Wydajność aplikacji wielowątkowej

Odpowiedz Nowy wątek
2019-08-17 17:44
0

Ostatnio próbuję ogarnąć wielowątkowość i wykorzystać ją do zwiększenia wydajności mojego silnika (głównie w zakresie renderowania). Napisałem sobie proste demko:

type
  TTest = class(TThread)
    procedure Execute; override;
  end;

var
  Test: array[0..4] of TTest;
  C: Integer = 0;
  F: Integer = 0;
  Before, After: Int64;

procedure Loop;
var
  I: Integer;
begin
  for I := 0 to 1000 do Inc(F, 1);
  Inc(C, 1);
  if C = 5 then
    begin
      QueryPerformanceCounter(After);
      ShowMessage(IntToStr(After - Before));
    end;
end;

procedure TTest.Execute;
begin
  Loop;
end;

procedure Start;
var
  I: Integer;
begin
  QueryPerformanceCounter(Before);
  for I := 0 to 4 do Test[I].Resume;
end;

initialization
  Test[0] := TTest.Create(True);
  Test[1] := TTest.Create(True);
  Test[2] := TTest.Create(True);
  Test[3] := TTest.Create(True);
  Test[4] := TTest.Create(True);

Kod jest oczywiście prymitywny i robi wiele rzeczy, których się robić nie powinno (np. użycie w taki sposób zmiennych globalnych), ale to tylko dla testu. Problem mam taki, że całość działa strasznie wolno i nie rozumiem dlaczego.

Przy wywołaniu tego samego kodu w pętli (a więc bez dodatkowych wątków)

  for I := 0 to 4 do Loop;

jego wykonanie zajmuje jakieś 80-90 ticków procesora. Z wieloma wątkami... to zależy. Gdy odpalam apkę z debugowaniem, zajmuje to od 60000 do 90000 ticków. Bez debugowania od 1000 do 3000.

Moje pytania:

  1. Robię coś źle? Jeżeli tak, to gdzie popełniam błąd?
  2. Czemu występuje taka różnica w szybkości wykonywania kodu i to na niekorzyść wielowątkowości?
  3. Czemu debugowanie lub jego brak, aż tak drastycznie wpływa na wydajność? W przypadku wywołania kodu w zwykłej pętli, taki problem wydaje się nie występować.
edytowany 1x, ostatnio: furious programming, 2019-08-18 01:17

Pozostało 580 znaków

2019-08-18 09:49
2

Wielowątkowość pomaga w zadaniach, które są CPU-bound (np. raytracing) - Ty orzesz po pamięci bez wykonywania dodatkowych obliczeń, stąd prawdopodobnie dochodzisz do limitu prędkości Twojego RAMu bądź szyny danych.

Dla zobrazowania: załóżmy, że Twoja płyta główna oraz procesor mogą maksymalnie z RAMu odbierać 2 GB/s; mając jeden wątek możesz bez problemu osiągnąć taką wydajność, lecz już przy dwóch wątkach każdy z nich średnio przetworzy tylko 1 GB/s, ponieważ nie będziesz w stanie przeskoczyć szybkości szyny (jest ona współdzielona przez cały procesor).

Niekoniecznie musi to mieć miejsce w Twoim kodzie, choć tak właśnie bym obstawiał.

Ewentualnie spróbuj jeszcze zrobić:

repeat
  sleep(1);
until Buffer.Done[1] and Buffer.Done[2] and Buffer.Done[3] and Buffer.Done[4];

... ponieważ Twoja pierwotna wersja (bez sleep()) zabiera praktycznie cały jeden wątek tylko na sprawdzanie stanu innych.


edytowany 6x, ostatnio: Patryk27, 2019-08-18 09:54

Pozostało 580 znaków

2019-08-18 10:06
0

Więc co powinienem zrobić, żeby to działało dobrze? Mój silnik - na potrzeby którego staram się ogarnąć wielowątkowość - ma właśnie coś takiego jak buffor klatki, który jest odświeżany określoną ilość razy na sekundę, a potem konwertowany do bitmapy i rysowany na ekranie (mniej więcej tak, jak w tym demku). Chciałbym jednak, żeby zamiast 1 wątku - jak obecnie - obsługą buffora zajmowało się kilka wątków (np. 1 wątek na rdzeń), w celu skrócenia czasu renderowania jednej klatki (obecnie jest on zbyt długi i silnik nie wyrabia z 60FPS, a taki jest mój cel).

Próbowałem dodać ten Sleep i nadal nic. Czas wydłużył się w obu przypadkach, ale nadal zwykła pętla działa szybciej.

edytowany 1x, ostatnio: Crow, 2019-08-18 10:07

Pozostało 580 znaków

2019-08-18 10:10
1

Zrób 3 pomiary:

  1. Czas wykonania zwykłej pętli
  2. Czas wykonania tej pętli, gdy wrzucisz ją do osobnego wątku
  3. Czas wykonania 4 wątków działających naraz

Wtedy zobaczymy jaki jest narzut przy przejściu z punktu 1. do punktu 2. Jak na razie to nie wiadomo co jest przyczyną spowolnienia przy przejściu z punktu 1. do punktu 3.

Poza tym dwie kolejne uwagi:

  • tablice najlepiej dzielić na ciągłe podbloki. Pamięć RAM nie jest zorganizowana w żadne dwuwymiarowe (lub więcej) tablice (a zwłaszcza w tablice o rozmiarze jakim sobie w danym momencie zażyczysz). RAM trzeba traktować jak jedną jednowymiarową tablicę. Stąd najlepiej zrób tak by podzielić obraz na 4 poziome pasy.
  • repeat until (Buffer.Done[1] = True) and (Buffer.Done[2] = True) and (Buffer.Done[3] = True) and (Buffer.Done[4] = True); - to jest busy loop ( https://en.wikipedia.org/wiki/Busy_waiting ) który zarzyna dodatkowy wątek. Jeśli twój CPU ma 4 fizyczne wątki to w takim razie masz więcej aktywnych wątków (sumarycznie 5 = 4 zapisujące do tablicy + 1 kręcący się bez przerwy na repeat until) niż jednocześnie obsłuży ci CPU, więc będzie tutaj sporo niepotrzebnego przełączania kontekstu między wątkami. Zamień busy loop na zasypianie (którego pojedyncze wywołanie kosztuje niewielką ilość czasu procesora) za pomocą TThread.WaitFor. Oczywiście logikę programu trzeba będzie nieco zmienić, zgodnie z dokumentacją TThread.WaitFor:

    Call WaitFor to obtain the value of ReturnValue when the thread finishes executing. WaitFor doesn't return until the thread terminates, so the thread must exit either by finishing the Execute method or by exiting when the Terminated property is true.

( @Patryk27 zdążył odpisać przede mną, więc nieco się posty pokryły )


"Programs must be written for people to read, and only incidentally for machines to execute." - Abelson & Sussman, SICP, preface to the first edition
"Ci, co najbardziej pragną planować życie społeczne, gdyby im na to pozwolić, staliby się w najwyższym stopniu niebezpieczni i nietolerancyjni wobec planów życiowych innych ludzi. Często, tchnącego dobrocią i oddanego jakiejś sprawie idealistę, dzieli od fanatyka tylko mały krok."
Demokracja jest fajna, dopóki wygrywa twoja ulubiona partia.
edytowany 2x, ostatnio: Wibowit, 2019-08-18 10:14

Pozostało 580 znaków

2019-08-18 11:19
0

Ogólnie, jeśli zależy Ci na wydajności, nie powinieneś przetwarzać takiego rodzaju grafiki na CPU - dlaczego nie wykorzystasz OpenGL / OpenCL?


Pozostało 580 znaków

2019-08-19 18:45
2

Spróbuję w wolnej chwili wdrożyć podpowiedzi, które dostałem (dzięki!) i napiszę co mi wyszło :).

A czemu używam CPU do renderowania? Po pierwsze dlatego, że chyba nie ma żadnego sensownego wrappera OpenGL czy DirectX dla Delphi. Po drugie dlatego, że podoba mi się "praca u podstaw" i możliwość sterowania całym procesem w dosyć drobiazgowy sposób. Np. sam musiałem napisać algorytmy odpowiedzialne za przeliczanie geometrii, rzutowanie na płaszczyznę, obcinanie, niewidocznych wierzchołków, przeliczanie oświetlenia, rasteryzację tekstur, korekcję perspektywy itd. W przypadku bibliotek graficznych, takie rzeczy robi się za pomocą wbudowanych funkcji i w sumie cała ta "magia" znika. Wiem, że sensu nie ma w tym żadnego, ale robię to hobbystycznie :).

Po trzecie wreszcie dlatego, że jakiś czas temu znalazłem w necie stary (bo z 2007 roku) silnik, napisany w C++, w którym autor wrzucił po prostu mapkę z pierwszego Quake (w pełni oteksturowaną i animowaną) z możliwością poruszania się po niej (latanie kamerą). Z opisu wynika, że grafika jest wyświetlana w full HD (1080p), a silnik używa software'owego renderingu z wykorzystaniem wielowątkowości. U mnie bez włączonego limitera, śmiga to w 700 klatkach na sekundę, przy zużyciu CPU na poziomie 60%. To, w połączeniu z faktem, że od zawsze kręciła mnie grafika 3D, sprawiło, że zawładnęła mną chęć stworzenia czegoś podobnego :). Póki co mierzę trochę niżej (to znaczy staram się napisać trójwymiarowego Tetrisa ze sztywną kamerą), ale nawet do tak skromnego zadania, wielowątkowość raczej będzie nieodzowna.

edytowany 1x, ostatnio: Crow, 2019-08-19 18:46

Pozostało 580 znaków

2019-08-19 20:57
1

fajno że się tak bawisz :D swego czasu któryś z użytkowników pisał jakąś gireke bodaj @furious programming tworzył odnośnie rendringu na swoim mikroblogu, może napisz do niego bezpośrednio to Ci pomoże

Nie no furious to mój lokalny guru, bez niego mój silnik utknąłby już gdzieś na etapie... wczesnego pomysłu :). Jak skończę pisać, to nie omieszkam umieścić go w creditsach i będzie mógł prawnukom pokazać (bo wcześniej tego raczej nie skończę) ;d. - Crow 2019-08-19 21:35

Pozostało 580 znaków

2019-08-19 23:10
0

@hzmzp: tak, to ja pisałem gierkę i umieszczałem wpisy na blogu odnośnie renderowania różnych jej elementów. Jednak mimo że sama gra potrafi wykonywać dziesiątki zadań jednocześnie, to jest całkowicie jednowątkowa.

Zresztą żaden ze mnie specjalista w dziedzinie gamedevu, a mój platformer to projekt tylko dla zabawy i wolałbym, aby nikt się na nim nie wzorował, jeśli ma zamiar stworzyć typową grę – za dużo w niej cudów, trików i złamanych zasad. ;)


Pozostało 580 znaków

2019-08-29 16:26
0

Ok, wykorzystałem te wskazówki i skleciłem coś bardzo prostego:

type
  TLoop = class(TThread)
    procedure Execute; override;
  end;

var
  Before, AfterL Int64;
  Loop: TLoop;

procedure Junk;
var
  I, A, B, C: Int64;
  R: Currency;
begin
  for I := 0 to 10000 do
    begin
      A := Random(100000) + 1;
      B := Random(100000) + 1;
      C := Random(100000);
      R := ((A / B) * C) / A;
    end;
end;

//Junk to - jak nazwa wskazuje - śmieciowa procedurka, która celowo używa dużych typów liczbowych, żeby spowolnić cały proces i przez to uzyskać bardziej realny pomiar prędkości.

initialization
  Randomize;
  Loop := TLoop.Create(True);

//KOD WŁAŚCIWY:

procedure Test;
begin
  QueryPerformanceCounter(Before);
  Junk;
  QueryPerformanceCounter(After);
  ShowMessage(IntToStr(After - Before));
end;

 //Wykonanie procedury 'Junk' w powyższy sposób, daje prędkość na poziomie 1800-2000 ticków.

procedure TLoop.Execute;
begin
  QueryPerformanceCounter(Before);
  Junk;
  QueryPerformanceCounter(After);
  ShowMessage(IntToStr(After - Before));
end;

procedure Test2;
begin
  Loop.Resume;
end;

//Próba przeprowadzona w powyższy sposób daje podobny wynik, czyli 1800-2000 ticków, a więc wszystko wydaje się działać poprawnie i nie wskazuje, aby obecność wątku wpływała negatywnie na prędkość. Nie jest to jednak próba miarodajna, bo nie uwzględnia czasu potrzebnego na "rozkręcenie" wątku, a więc odstępu między jego wznowieniem ('Resume'), a wykonaniem określonych czynności.

procedure TLoop.Execute;
begin
  Junk;
  QueryPerformanceCounter(After);
  ShowMessage(IntToStr(After - Before));
end;

procedure Test2;
begin
  QueryPerformanceCounter(Before);
  Loop.Resume;
end;

Powyższy sposób pomiaru taki odstęp uwzględnia i wtedy czas wykonania całości wzrasta do ok. 15000 - 16000 ticków. Da się temu jakoś zaradzić?

edytowany 4x, ostatnio: Crow, 2019-08-29 16:28

Pozostało 580 znaków

2019-08-29 18:40
0

Startowanie wątku zawsze trochę kosztuje, więc by uniknąć tego kosztu i ogólnie by ułatwić sobie życie przy nietrywialnych programach stosuje się pule wątków. Poguglałem na szybko i znalazłem jakieś wzmianki o TThreadPool, TTask itd Zobacz jak tego użyć i jak to się będzie sprawować.


"Programs must be written for people to read, and only incidentally for machines to execute." - Abelson & Sussman, SICP, preface to the first edition
"Ci, co najbardziej pragną planować życie społeczne, gdyby im na to pozwolić, staliby się w najwyższym stopniu niebezpieczni i nietolerancyjni wobec planów życiowych innych ludzi. Często, tchnącego dobrocią i oddanego jakiejś sprawie idealistę, dzieli od fanatyka tylko mały krok."
Demokracja jest fajna, dopóki wygrywa twoja ulubiona partia.
edytowany 1x, ostatnio: Wibowit, 2019-08-29 18:40
Dzięki, spróbuję i napiszę co wyszło :). - Crow 2019-08-29 19:23

Pozostało 580 znaków

2019-09-07 12:24
0

Dramatu ciąg dalszy...


uses
  System.Threading;

var
  T: array [0..2] of ITask;
  Before, After: Int64;

procedure TForm1.GetMSG(var MSG: TMessage);
begin
  ShowMessage(IntToStr(MSG.LParam));
end;

procedure Junk;
var
  I, N: Int64;
begin
  for I := 0 to 100000 do N := Random(1000);
  QueryPerformanceCounter(After);
  PostMessage(Form1.Handle, WM_USER, 0, After - Before);
end;

procedure Test;
var
  I: Integer;
begin
  QueryPerformanceCounter(Before);
  //for I := 0 to 2 do Junk;  
  for I := 0 to 2 do T[I].Start;
end;

initialization
  Randomize;
  T[0] := TTask.Create(Junk);
  T[1] := TTask.Create(Junk);
  T[2] := TTask.Create(Junk);

Przy trzykrotnym wywołaniu procedury Junk, pierwsza zostaje zakończona po upływie ok. 2000 tickow, druga po ok. 4000, trzecia po ok. 6000, czyli wszystko się zgadza. Przy użyciu tasków, wszystkie procedury kończą mniej więcej w jednym czasie (plus minus 1000 ticków), czyli po ok. 9300 - 11000 ticków... Nadal coś robię źle?

Pozostało 580 znaków

Odpowiedz
Liczba odpowiedzi na stronę

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