Ile wątków powinienem mieć i po co?


81

Czy powinienem mieć osobne wątki do renderowania i logiki, a nawet więcej?

Jestem świadomy ogromnego spadku wydajności spowodowanego synchronizacją danych (nie mówiąc już o jakichkolwiek blokadach mutex).

Zastanawiałem się, czy nie podejść do tego ekstremalnie i zrobić nici dla każdego możliwego podsystemu. Ale martwię się, że to też może spowolnić. (Na przykład, czy rozsądnie jest oddzielić wątek wejściowy od wątków renderowania lub logiki gry?) Czy wymagana synchronizacja danych sprawiłaby, że byłby bezcelowy, a nawet wolniejszy?


6
która platforma? Komputer, konsola NextGen, smartfony?
Ellis,

Myślę o jednej rzeczy, która wymagałaby wielowątkowości; sieć.
Soapy

porzuć przesady, nie ma „ogromnego” spowolnienia, gdy zaangażowane są zamki. to miejska legenda i uprzedzenie.
v.oddou

Odpowiedzi:


61

Wspólne podejście do korzystania z wielu rdzeni jest po prostu mylące. Rozdzielenie podsystemów na różne wątki rzeczywiście podzieli część pracy na wiele rdzeni, ale wiąże się to z poważnymi problemami. Po pierwsze, bardzo ciężko z tym pracować. Kto chce wymazać z blokadami, synchronizacją, komunikacją i innymi rzeczami, gdy zamiast tego mogą po prostu pisać kod renderowania lub fizyki? Po drugie, podejście tak naprawdę się nie zwiększa. W najlepszym wypadku pozwoli Ci to wykorzystać trzy lub cztery rdzenie, a jeśli naprawdę wiesz, co robisz. W grze jest tylko tyle podsystemów, a tych, które zajmują dużo czasu procesora, jest jeszcze mniej. Jest kilka dobrych alternatyw, które znam.

Jednym z nich jest posiadanie głównego wątku wraz z wątkiem roboczym dla każdego dodatkowego procesora. Niezależnie od podsystemu główny wątek deleguje izolowane zadania do wątków roboczych za pośrednictwem pewnego rodzaju kolejek; zadania te same mogą tworzyć jeszcze inne zadania. Jedynym celem wątków roboczych jest każde pobranie zadań z kolejki i wykonywanie ich. Najważniejsze jest jednak to, że jak tylko wątek potrzebuje wyniku zadania, jeśli zadanie jest ukończone, może uzyskać wynik, a jeśli nie, może bezpiecznie usunąć zadanie z kolejki i kontynuować samo zadanie. Oznacza to, że nie wszystkie zadania zostaną zaplanowane równolegle. Mając więcej zadań niż mogą być wykonywane równolegle jest dobryrzecz w tym przypadku; oznacza to, że prawdopodobnie skaluje się wraz z dodawaniem kolejnych rdzeni. Jednym minusem tego jest to, że wymaga dużo pracy z góry, aby zaprojektować przyzwoitą kolejkę i pętlę roboczą, chyba że masz dostęp do biblioteki lub środowiska wykonawczego, które już to zapewnia. Najtrudniejsze jest upewnienie się, że Twoje zadania są naprawdę odizolowane i bezpieczne dla wątków, oraz upewnienie się, że Twoje zadania znajdują się na szczęśliwym środku między gruboziarnistym i drobnoziarnistym.

Inną alternatywą dla wątków podsystemu jest zrównoleglenie każdego podsystemu w izolacji. Oznacza to, że zamiast uruchamiać renderowanie i fizykę we własnych wątkach, napisz podsystem fizyki, aby używać wszystkich swoich rdzeni naraz, napisz podsystem renderowania, aby używać wszystkich rdzeni jednocześnie, a następnie poproś, aby oba systemy działały sekwencyjnie (lub przeplatane, w zależności od innych aspektów architektury gry). Na przykład w podsystemie fizyki możesz wziąć wszystkie masy punktowe w grze, podzielić je na swoje rdzenie, a następnie wszystkie rdzenie zaktualizować je jednocześnie. Każdy rdzeń może następnie pracować na twoich danych w ciasnych pętlach z dobrą lokalizacją. Ten równoległy styl blokowania jest podobny do tego, co robi GPU. Najtrudniejszą częścią jest upewnienie się, że dzielisz swoją pracę na drobnoziarniste kawałki, tak aby dzielić ją równomierniefaktycznie powoduje jednakową pracę we wszystkich procesorach.

Czasami jednak najłatwiej jest, ze względu na politykę, istniejący kod lub inne frustrujące okoliczności, nadać każdemu podsystemowi wątek. W takim przypadku najlepiej unikać tworzenia większej liczby wątków systemu operacyjnego niż rdzeni w przypadku dużych obciążeń procesora (jeśli masz środowisko wykonawcze z lekkimi wątkami, które akurat równoważą się w rdzeniach, nie jest to aż tak duże). Unikaj także nadmiernej komunikacji. Jedną fajną sztuczką jest wypróbowanie potoku; każdy główny podsystem może jednocześnie pracować w innym stanie gry. Przetwarzanie potokowe zmniejsza niezbędną komunikację między podsystemami, ponieważ nie wszystkie potrzebują dostępu do tych samych danych w tym samym czasie, a także może zniwelować niektóre szkody spowodowane przez wąskie gardła. Na przykład, jeśli ukończenie podsystemu fizyki zajmuje dużo czasu, a podsystem renderowania zawsze na niego czeka, bezwzględna liczba klatek na sekundę może być większa, jeśli uruchomisz podsystem fizyki dla następnej klatki, podczas gdy podsystem renderowania nadal działa na poprzedniej rama. W rzeczywistości, jeśli masz takie wąskie gardła i nie możesz ich usunąć w żaden inny sposób, potokowanie może być najbardziej uzasadnionym powodem do niepokoju z wątkami podsystemu.


„jak tylko wątek potrzebuje wyniku zadania, jeśli zadanie zostanie zakończone, może uzyskać wynik, a jeśli nie, może bezpiecznie usunąć zadanie z kolejki i przystąpić do wykonania zadania samodzielnie”. Czy mówisz o zadaniu zrodzonym przez ten sam wątek? Jeśli tak, to czy nie miałoby większego sensu, gdyby zadanie to wykonał wątek, który zrodził to zadanie?
jmp97

tzn. wątek mógłby, bez planowania zadania, wykonać to zadanie od razu.
jmp97

3
Chodzi o to, że wątek niekoniecznie wie z góry, czy lepiej byłoby uruchomić zadanie równolegle, czy nie. Chodzi o spekulatywne wywołanie pracy, którą w końcu będziesz musiał wykonać, a jeśli inny wątek znajdzie się na biegu jałowym, może on wykonać tę pracę za Ciebie. Jeśli to się nie stanie, zanim będziesz potrzebować rezultatu, możesz po prostu wyciągnąć zadanie z kolejki. Ten schemat służy do dynamicznego równoważenia obciążenia między wieloma rdzeniami, a nie statycznie.
Jake McArthur,

Przepraszamy za tak długi powrót do tego wątku. Ostatnio nie zwracam uwagi na gamedev. To prawdopodobnie najlepsza odpowiedź, tępa, ale na temat i obszerna.
j riv

1
Masz rację w tym sensie, że zaniedbałem mówienie o dużych obciążeniach we / wy. Moją interpretacją tego pytania było to, że dotyczyło to tylko obciążeń procesora.
Jake McArthur

30

Jest kilka rzeczy do rozważenia. Łatwo przemyśleć trasę wątek na podsystem, ponieważ separacja kodu jest oczywista od samego początku. Jednak w zależności od tego, ile komunikacji potrzebują twoje podsystemy, komunikacja między wątkami może naprawdę zabić twoją wydajność. Ponadto skaluje się to tylko do rdzeni N, gdzie N jest liczbą podsystemów abstrakcyjnych w wątki.

Jeśli szukasz tylko wielowątkowości istniejącej gry, prawdopodobnie jest to ścieżka najmniejszego oporu. Jeśli jednak pracujesz nad systemami niskiego poziomu, które mogą być współużytkowane przez kilka gier lub projektów, rozważę inne podejście.

Może to wymagać trochę skręcenia umysłu, ale jeśli możesz rozbić wszystko jako kolejkę zadań z zestawem wątków roboczych, na dłuższą metę będzie skalować się znacznie lepiej. Ponieważ najnowsze i najlepsze żetony wychodzą z rdzeniami gazillionów, wydajność Twojej gry będzie się zwiększać wraz z nią, po prostu odpalając więcej wątków roboczych.

Zasadniczo więc, jeśli chcesz wzmocnić trochę równoległości do istniejącego projektu, zrównoleglę wszystkie podsystemy. Jeśli budujesz nowy silnik od podstaw z myślą o równoległej skalowalności, zajrzałbym do kolejki zadań.


System, o którym wspominasz, jest bardzo podobny do systemu planowania wymienionego w odpowiedzi udzielonej przez Innego Jamesa, wciąż jest bardzo szczegółowy w tej dziedzinie, więc daje +1, ponieważ dodaje do dyskusji.
James

3
byłoby fajne wiki społecznościowe, jak skonfigurować kolejkę zadań i wątki robocze.
bot_bot 15.11.11

23

To pytanie nie ma najlepszej odpowiedzi, ponieważ zależy od tego, co próbujesz osiągnąć.

Xbox ma trzy rdzenie i może obsłużyć kilka wątków, zanim problem z przełączaniem kontekstu stanie się problemem. Komputer może poradzić sobie z kilkoma innymi.

Wiele gier jest zazwyczaj jednowątkowych dla ułatwienia programowania. Jest to dobre w przypadku większości gier osobistych. Jedyną rzeczą, do której prawdopodobnie będziesz musiał mieć inny wątek, jest sieć i audio.

Unreal ma wątek gry, wątek renderujący, wątek sieciowy i wątek audio (jeśli dobrze pamiętam). Jest to dość standardowe w przypadku wielu silników obecnej generacji, chociaż obsługa oddzielnego wątku renderującego może być uciążliwa i wymaga wielu prac przygotowawczych.

Silnik idTech5 opracowany dla Rage faktycznie wykorzystuje dowolną liczbę wątków i robi to poprzez dzielenie zadań gry na „zadania” przetwarzane za pomocą systemu zadań. Ich wyraźnym celem jest dobre skalowanie silnika gry, gdy liczba rdzeni w przeciętnym systemie do gier wzrośnie.

Technologia, której używam (i którą napisałem) ma osobny wątek dla sieci, wejścia, audio, renderowania i planowania. Następnie ma dowolną liczbę wątków, które można wykorzystać do wykonywania zadań w grze, i zarządza nim wątek planowania. Dużo pracy poszedł do uzyskania wszystkie wątki grać ładnie ze sobą, ale wydaje się działać dobrze i coraz bardzo dobry użytek z systemów wielordzeniowych, więc być może jest to misja zakończona (na razie; mógłbym rozbić audio / sieci / input działa tylko na „zadania”, które wątki robocze mogą aktualizować).

To naprawdę zależy od twojego ostatecznego celu.


+1 za wzmiankę o systemie planowania. Zazwyczaj dobre miejsce do centrowania komunikacji wątek / system :)
James

Dlaczego głosowanie w dół, zwycięzca?
jcora

12

Wątek na podsystem jest niewłaściwy. Nagle Twoja aplikacja nie skaluje się, ponieważ niektóre podsystemy wymagają dużo więcej niż inne. To było podejście wątkowe stosowane przez Supreme Commander i nie skalowało się poza dwa rdzenie, ponieważ miały tylko dwa podsystemy, które zajmowały znaczną ilość renderowania procesora i logiki fizyki / gry, mimo że miały 16 wątków, pozostałe wątki ledwo wystarczyło do wykonania jakiejkolwiek pracy, w wyniku czego gra skalowała się tylko do dwóch rdzeni.

To, co powinieneś zrobić, to użyć czegoś o nazwie pula wątków. W pewien sposób odzwierciedla to podejście zastosowane w procesorach graficznych - oznacza to, że publikujesz pracę, a każdy dostępny wątek po prostu pojawia się i wykonuje ją, a następnie wraca do oczekiwania na pracę - pomyśl o tym jak o buforze pierścieniowym wątków. Takie podejście ma tę zaletę, że skaluje N-rdzeń i jest bardzo dobre w skalowaniu zarówno dla niskiej, jak i wysokiej liczby rdzeni. Wadą jest to, że dość ciężko jest przepracować własność wątku dla tego podejścia, ponieważ nie można wiedzieć, który wątek robi to, co działa w danym momencie, więc trzeba bardzo mocno zamknąć problemy z własnością. Utrudnia także korzystanie z technologii takich jak Direct3D9, które nie obsługują wielu wątków.

Pule wątków są bardzo trudne w użyciu, ale zapewniają najlepsze możliwe wyniki. Jeśli potrzebujesz wyjątkowo dobrego skalowania lub masz dużo czasu, aby nad nim popracować, użyj puli wątków. Jeśli próbujesz wprowadzić równoległość do istniejącego projektu z nieznanymi problemami zależności i technologiami jednowątkowymi, nie jest to rozwiązanie dla Ciebie.


Żeby być nieco bardziej precyzyjnym: procesory graficzne nie używają pul wątków, a zamiast tego harmonogram wątków jest implementowany sprzętowo, co sprawia, że ​​tworzenie nowych wątków i przełączanie wątków jest bardzo tanie, w przeciwieństwie do procesorów, w których tworzenie wątków i przełączanie kontekstu są kosztowne. Zobacz na przykład Nvidias CUDA Programmer Guide.
Nils,

2
+1: najlepsza odpowiedź tutaj. Użyłbym nawet bardziej abstrakcyjnych konstrukcji niż pule wątków (na przykład kolejki zadań i pracownicy), jeśli pozwala na to twój framework. O wiele łatwiej jest myśleć / programować w tych terminach niż w czystych wątkach / blokadach itp. Plus: dzielenie gry na renderowanie, logikę itp. To nonsens, ponieważ rendering musi czekać na zakończenie logiki. Zamiast tego twórz zadania, które mogą być faktycznie wykonywane równolegle (na przykład: Oblicz AI dla jednego NPC dla następnej ramki).
Dave O.

@DaveO. Twój punkt „plus” jest taki bardzo prawdziwy.
Inżynier

11

Masz rację, że najważniejszą częścią jest unikanie synchronizacji tam, gdzie to możliwe. Istnieje kilka sposobów na osiągnięcie tego.

  1. Poznaj swoje dane i przechowuj je w pamięci zgodnie z potrzebami przetwarzania. Umożliwia to planowanie równoległych obliczeń bez potrzeby synchronizacji. Niestety jest to najczęściej trudne do osiągnięcia, ponieważ dane są często dostępne z różnych systemów w nieprzewidywalnych czasach.

  2. Określ jasne czasy dostępu do danych. Możesz podzielić główny tik na x faz. Jeśli masz pewność, że Wątek X odczytuje dane tylko w określonej fazie, wiesz również, że dane te mogą być modyfikowane przez inne wątki w innej fazie.

  3. Podwój buforuj swoje dane. Jest to najprostsze podejście, ale zwiększa opóźnienie, ponieważ Wątek X pracuje z danymi z ostatniej ramki, podczas gdy Wątek Y przygotowuje dane do następnej ramki.

Moje osobiste doświadczenie pokazuje, że najdrobniejsze obliczenia są najskuteczniejszym sposobem, ponieważ można je skalować znacznie lepiej niż rozwiązania oparte na podsystemie. Jeśli wątkujesz swoje podsystemy, czas ramki będzie związany z najdroższym podsystemem. Może to prowadzić do wszystkich wątków z wyjątkiem jednego na biegu jałowym, dopóki drogi podsystem w końcu nie zakończy pracy. Jeśli możesz podzielić duże części gry na małe zadania, zadania te można odpowiednio zaplanować, aby uniknąć rdzenia na biegu jałowym. Ale jest to coś, co jest trudne do osiągnięcia, jeśli masz już dużą bazę kodu.

Aby wziąć pod uwagę niektóre ograniczenia sprzętowe, należy starać się nigdy nie przesadzać z subskrypcją sprzętu. Z nadsubskrybowaniem mam na myśli posiadanie większej liczby wątków oprogramowania niż wątków sprzętowych platformy. Zwłaszcza na architekturach PPC (Xbox360, PS3) zmiana zadań jest naprawdę droga. Oczywiście jest całkowicie w porządku, jeśli masz kilka subskrybowanych wątków, które są uruchamiane tylko na krótki czas (na przykład raz klatka). Jeśli celujesz w komputer, powinieneś pamiętać, że liczba rdzeni (lub lepsza HW) -Threads) stale rośnie, więc chciałbyś znaleźć skalowalne rozwiązanie, które wykorzystuje dodatkową moc CPU. Dlatego w tym obszarze powinieneś spróbować zaprojektować swój kod tak, aby był jak najlepiej oparty na zadaniach.


3

Ogólna ogólna zasada dla wątków aplikacji: 1 wątek na rdzeń procesora. Na czterordzeniowym komputerze PC oznacza to 4. Jak zauważono, XBox 360 ma jednak 3 rdzenie, ale 2 wątki sprzętowe, więc w tym przypadku 6 wątków. W systemie takim jak PS3 ... powodzenia na tym :) Ludzie wciąż próbują to rozgryźć.

Sugerowałbym zaprojektowanie każdego systemu jako samodzielnego modułu, który można wątkować, jeśli chcesz. Zwykle oznacza to bardzo jasno określone ścieżki komunikacji między modułem a resztą silnika. Szczególnie podoba mi się procesy tylko do odczytu, takie jak renderowanie i audio, a także procesy „jesteśmy tam jeszcze”, takie jak czytanie danych wejściowych odtwarzacza w celu wyeliminowania problemów. Jeśli chodzi o odpowiedź udzieloną przez AttackingHobo, kiedy renderujesz 30-60 fps, jeśli twoje dane są 1/30 do 1/60 sekundy nieaktualne, tak naprawdę nie wpłynie to na wrażliwość Twojej gry. Zawsze pamiętaj, że główną różnicą między oprogramowaniem użytkowym a grami wideo jest robienie wszystkiego 30–60 razy na sekundę. Jednak w tej samej notatce

Jeśli odpowiednio projektujesz układy silnika, każdy z nich można przenosić z wątku na wątek, aby odpowiednio wyważyć silnik w zależności od gry i tym podobne. Teoretycznie możesz również użyć silnika w systemie rozproszonym, jeśli zajdzie taka potrzeba, gdzie każdy komponent obsługuje całkowicie osobne systemy komputerowe.


2
Xbox360 ma 2 wątki sprzętowe na rdzeń, więc optymalna liczba wątków to 6.
DarthCoder

Ach, +1 :) Zawsze byłem ograniczony do obszarów sieciowych 360 i ps3, hehe :)
James

0

Tworzę jeden wątek na rdzeń logiczny (minus jeden, aby uwzględnić główny wątek, który, nawiasem mówiąc, jest odpowiedzialny za renderowanie, ale poza tym działa również jako wątek roboczy).

Zdarzenia urządzeń wejściowych zbieram w czasie rzeczywistym w całej ramce, ale nie stosuję ich do końca ramki: będą obowiązywać w następnej klatce. I używam podobnej logiki do renderowania (stary stan) w porównaniu do aktualizacji (nowy stan).

Używam zdarzeń atomowych, aby odłożyć niebezpieczne operacje na później w tej samej ramce, i używam więcej niż jednej kolejki zdarzeń (kolejki zadań) w celu zaimplementowania bariery pamięci, która daje żelazną gwarancję co do kolejności operacji, bez blokowania lub oczekiwania (zablokuj wolne współbieżne kolejki w kolejności priorytetów zadań).

Warto wspomnieć, że każde zadanie może wydawać poddania (które są drobniejsze i zbliżają się do atomowości) do tej samej kolejki priorytetowej lub wyższej (obsługiwanej później w ramce).

Biorąc pod uwagę, że mam trzy takie kolejki, wszystkie wątki, z wyjątkiem jednego, mogą potencjalnie utknąć dokładnie trzy razy na ramkę (czekając, aż inne wątki zakończą wszystkie zaległe zadania wydane na bieżącym poziomie priorytetu).

To wydaje się akceptowalnym poziomem nieaktywności wątków!


Moja ramka zaczyna się od GŁÓWNEGO renderowania STANU STANOWEGO z przejścia aktualizacji poprzedniej ramki, podczas gdy wszystkie inne wątki natychmiast zaczynają obliczać stan ramki NASTĘPNEJ, po prostu używam Zdarzenia, aby podwoić zmiany stanu bufora, aż do punktu w ramce, w którym nikt już nie czyta .
Homer

0

Zwykle używam jednego głównego wątku (oczywiście) i dodam go za każdym razem, gdy zauważę spadek wydajności o około 10 do 20 procent. Aby uwolnić taką kroplę, używam narzędzi wydajnościowych Visual Studio. Częstymi zdarzeniami są (od) ładowanie niektórych obszarów mapy lub dokonywanie ciężkich obliczeń.

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.