Wydajność aplikacji wielowątkowej

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ć.
0

Ile masz rdzeni w swoim kompie?

0

4, procesor to Intel i7 7700k

1

Wszystkie Twoje wątki próbują niesynchronicznie dobijać się do tej samej zmiennej - prawdopodobnie padłeś ofiarą false sharing.

Spróbuj każdemu wątkowi dać odrębną zmienną, oddaloną od siebie przynajmniej o rozmiar cache line Twojego procesora.

0

Czyli, jak rozumiem, muszę zaprojektować to tak, że każdy wątek pracuje na zmiennych, których żaden inny wątek nie tyka?

Dajmy na to, że mam tablicę, która składa się z 200 integerów i chciałbym, żeby 4 osobne wątki symultanicznie przetworzyły ją w taki sposób, żeby każdy z tych integerów został podniesiony do potęgi 2. Czyli jak rozumiem odpada "rozdzielenie pracy" w taki sposób, by pierwszy wątek obskoczył indexy w tablicy 0-49, drugi 50-99, trzeci 100-149, a czwarty 150-199 i zamiast tego muszę stworzyć 4 osobne tablice, bo wątki nie mogą pracować na tej samej? Brzmi strasznie niepraktycznie...

1

zamiast tego muszę stworzyć 4 osobne tablice, by wątki nie dobijały się do tych samych zmiennych?

Podany przez Ciebie przykład nie będzie wymagał zmian - tzn. możesz tak rozdzielić tablicę i wysłać do przetworzenia. Chodziło mi to o, że wątki nawzajem nie mogą dobijać do tego samego adresu w pamięci (plus minus rozmiar cache line). W przypadku tablicy każdy wątek otrzyma inny kawałek pamięci, czyli będzie luks.

Brzmi strasznie niepraktycznie...

Bezpieczne programowanie wielowątkowe to niełatwa sztuka ;-)

0

Przerobiłem kod, teraz wygląda tak:

procedure Loop;
var
  I, F: Integer;
begin
  for I := 0 to 1000 do Inc(F, 1);
  PostMessage(Form1.Handle, WM_USER, 0, 0);
end;

procedure TForm1.ReadMSG(var MSG: TMessage);
begin
  Inc(C, 1);
  if C = 5 then
    begin
      QueryPerformanceCounter(After);
      C := 0;
      ShowMessage(IntToStr(After - Before));
    end;
end;

A więc F jest teraz zmienną lokalną i każdy wątek ma własną. Dodatkowo wątki już nie próbują dostać się do zmiennej C. Zamiast tego wysyłają do wątku głównego komunikat, sygnalizujący wykonanie zadania. Gdy wątek główny otrzyma takich 5 (zliczając je w C, do którego tylko on ma dostęp), melduje zakończenie pracy. Efekt? Działa jeszcze wolniej, ilość ticków wzrosła do 12 tysięcy... Co tym razem robię źle?

2

zamiast tego muszę stworzyć 4 osobne tablice, bo wątki nie mogą pracować na tej samej? Brzmi strasznie niepraktycznie...

Nie. Tu nie chodzi o to, żeby zawsze rozbijać tablicę na podtablice, tylko żeby zminimalizować transfer linii pamięci podręcznej między rdzeniami procesora. Transfer musi zachodzić wtedy, gdy rdzeń A próbuje odczytać dane które były zmodyfikowane przez rdzeń B i nie rozpropagowane jeszcze po innych rdzeniach. Dokładnie to chodzi o mechanizm zachowania spójności danych w pamięci podręcznej - https://en.wikipedia.org/wiki/Cache_coherence - który może być kosztowny.

2
Crow napisał(a):
...
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;
...

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.

4 * 1000 przebiegów pętli = 80 ticków procesora? Prawdopodobnie kompilator zamienił tę pętlę na F += 1000, a ty liczysz narzut na stworzenie wątków. Zmień obliczenia w pętli na coś co kompilator nie będzie w stanie policzyć w czasie kompilacji, a potem zwiększ ilość przebiegów pętli tak by trwała ona przynajmniej sekundę i wtedy te twoje benchmarki będą miały chociaż iluzję sensu.

0

Nie jestem pewien czy dobrze pamiętam ale używając PostMessage (tak jak innych metod spoza aplikacji) kod wykonuje tak zwany far call który jest kosztowny. Choć w tym przypadku to i tak znikomy narzut.

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ć.

Kod kompilowany w trybie debug jest niemal taki jak sam napisałeś w ide, oraz posiada 'debug symbol' które ułatwiają debugowanie.
W trybie release nie masz już tych 'ficzerów' oraz kod jest bardzo zmodyfikowany pod względem optymalizacji, które kompilator sam wykrywa i stosuje.
W skrócie po dekompilacji debuga miał byś kod niemal 1 do 1; w trybie release będzie się znacząco różnił, za sprawą optymalizacji kompilatora.

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