Wydajne rozdzielanie kroków odczytu / obliczania / zapisu dla jednoczesnego przetwarzania encji w systemach Entity / Component

11

Ustawiać

Mam architekturę encji-komponentu, w której encje mogą mieć zestaw atrybutów (które są czystymi danymi bez zachowania) i istnieją systemy, które uruchamiają logikę encji, która działa na te dane. Zasadniczo w nieco pseudo-kodzie:

Entity
{
    id;
    map<id_type, Attribute> attributes;
}

System
{
    update();
    vector<Entity> entities;
}

Może to być system, który porusza się po wszystkich jednostkach ze stałą szybkością

MovementSystem extends System
{
   update()
   {
      for each entity in entities
        position = entity.attributes["position"];
        position += vec3(1,1,1);
   }
}

Zasadniczo próbuję zrównoleglać aktualizację () tak skutecznie, jak to możliwe. Można to zrobić, uruchamiając całe systemy równolegle lub przez przekazanie każdej aktualizacji () jednego systemu kilku komponentów, aby różne wątki mogły wykonać aktualizację tego samego systemu, ale dla innego podzbioru jednostek zarejestrowanych w tym systemie.

Problem

W przypadku pokazanego MovementSystem równoległość jest banalna. Ponieważ jednostki nie są od siebie zależne i nie modyfikują udostępnionych danych, moglibyśmy po prostu przenieść wszystkie jednostki równolegle.

Jednak systemy te czasami wymagają od jednostek interakcji (odczytu / zapisu danych z / do) nawzajem, czasem w tym samym systemie, ale często między różnymi systemami, które są od siebie zależne.

Na przykład w systemie fizyki byty mogą czasem oddziaływać na siebie. Dwa obiekty zderzają się, ich pozycje, prędkości i inne atrybuty są od nich odczytywane, są aktualizowane, a następnie zaktualizowane atrybuty są zapisywane z powrotem do obu jednostek.

Zanim system renderujący w silniku będzie mógł rozpocząć renderowanie jednostek, musi poczekać, aż inne systemy zakończą wykonywanie, aby upewnić się, że wszystkie odpowiednie atrybuty są tym, czym powinny być.

Jeśli spróbujemy to na ślepo zrównoważyć, doprowadzi to do klasycznych warunków wyścigu, w których różne systemy mogą jednocześnie odczytywać i modyfikować dane.

Idealnie byłoby istnieć rozwiązanie, w którym wszystkie systemy mogłyby odczytywać dane z dowolnych encji, które chcą, bez martwienia się o to, że inne systemy modyfikują te same dane w tym samym czasie i bez dbania przez programistę o właściwe zamówienie wykonania i równoległości systemy te ręcznie (co może czasem nawet nie być możliwe).

W podstawowej implementacji można to osiągnąć poprzez umieszczenie wszystkich odczytów i zapisów danych w krytycznych sekcjach (ochrona ich za pomocą muteksów). Jednak powoduje to znaczne obciążenie środowiska wykonawczego i prawdopodobnie nie nadaje się do aplikacji wrażliwych na wydajność.

Rozwiązanie?

Moim zdaniem możliwym rozwiązaniem byłby system, w którym odczytywanie / aktualizowanie i zapisywanie danych są oddzielone, tak że w jednej kosztownej fazie systemy tylko odczytują dane i obliczają to, co muszą obliczyć, w jakiś sposób buforują wyniki, a następnie zapisują wszystkie zmienione dane z powrotem do jednostek docelowych w osobnym zapisie. Wszystkie systemy działałyby na danych w stanie, w jakim znajdowały się na początku ramki, a następnie przed końcem ramki, gdy wszystkie systemy zakończyły aktualizację, następuje szeregowe przejście zapisu, w którym buforowane wyniki pochodzą z różnych systemy są iterowane i zapisywane do jednostek docelowych.

Jest to oparte na (być może błędnym?) Założeniu, że wygrana w łatwej równoległości może być wystarczająco duża, aby przewyższyć koszty (zarówno pod względem wydajności środowiska uruchomieniowego, jak i narzutu kodu) wynikającego z buforowania wyników i zapisu.

Pytanie

Jak taki system można wdrożyć, aby osiągnąć optymalną wydajność? Jakie są szczegóły wdrożenia takiego systemu i jakie są warunki wstępne dla systemu Entity-Component, który chce korzystać z tego rozwiązania?

TravisG
źródło

Odpowiedzi:

1

----- (na podstawie zmienionego pytania)

Pierwszy punkt: ponieważ nie wspomniałeś o profilowaniu środowiska wykonawczego kompilacji wersji i znalazłeś konkretną potrzebę, sugeruję, abyś zrobił to jak najszybciej. Jak wygląda twój profil, czy przebijasz pamięć podręczną złym układem pamięci, czy jeden rdzeń jest ustawiony na 100%, ile czasu względnego spędza się na przetwarzaniu ECS w porównaniu z resztą silnika itp.

Czytasz z encji i obliczasz coś ... i trzymasz wyniki gdzieś w pośrednim obszarze przechowywania do później? Nie sądzę, że można oddzielić magazyn read + compute + w sposób, w jaki myślisz i oczekujesz, że ten sklep pośredni będzie niczym innym jak tylko czystym kosztem.

Ponadto, ponieważ wykonujesz ciągłe przetwarzanie, główną zasadą, którą chcesz przestrzegać, jest posiadanie jednego wątku na rdzeń procesora. Myślę, że patrzysz na to na niewłaściwej warstwie , spróbuj spojrzeć na całe systemy, a nie na poszczególne podmioty.

Utwórz wykres zależności między systemami, drzewo tego, czego potrzebuje system, wynika z wcześniejszej pracy systemu. Po utworzeniu tego drzewa zależności można łatwo wysyłać całe systemy pełne encji do przetworzenia w wątku.

Powiedzmy więc, że twoje drzewo zależności jest bagno buraków i pułapek na niedźwiedzie, co jest problemem projektowym, ale musimy pracować z tym, co mamy. Najlepszym przykładem jest tutaj to, że w każdym systemie każdy byt nie zależy od żadnego innego wyniku w tym systemie. Tutaj łatwo podzielić przetwarzanie na wątki, 0-99 i 100-199 na dwa wątki, na przykład z dwoma rdzeniami i 200 jednostkami, które ten system ma.

W obu przypadkach na każdym etapie trzeba czekać na wyniki, od których zależy następny etap. Jest to jednak w porządku, ponieważ oczekiwanie na wyniki dziesięciu dużych bloków danych przetwarzanych masowo znacznie przewyższa synchronizację tysiąc razy dla małych bloków.

Pomysł na zbudowanie wykresu zależności polegał na trywializacji pozornie niemożliwego zadania „znajdowania i składania innych systemów do równoległego działania” poprzez automatyzację. Jeśli taki wykres pokazuje oznaki blokowania przez ciągłe oczekiwanie na poprzednie wyniki, wówczas utworzenie odczytu + modyfikacji i opóźnionego zapisu powoduje jedynie zablokowanie i nie usuwa szeregowego charakteru przetwarzania.

Przetwarzanie seryjne można obracać tylko równolegle między każdym punktem sekwencji, ale nie ogólnie. Ale zdajesz sobie z tego sprawę, ponieważ jest to sedno twojego problemu. Nawet jeśli buforujesz odczyty z danych, które nie zostały jeszcze zapisane, nadal musisz poczekać na tę pamięć podręczną, aby stała się dostępna.

Gdyby tworzenie równoległych architektur było łatwe lub nawet możliwe z tego rodzaju ograniczeniami, informatyka nie miałaby problemu z tym problemem od czasu Bletchley Park.

Jedynym realnym rozwiązaniem byłoby zminimalizowanie wszystkich tych zależności, aby punkty sekwencji były tak rzadko potrzebne, jak to możliwe. Może to obejmować podział systemów na sekwencyjne etapy przetwarzania , w których wewnątrz każdego podsystemu równoległe łączenie z wątkami staje się banalne.

Najlepiej, jak mogłem rozwiązać ten problem i naprawdę jest to niczym innym, jak zaleceniem, że jeśli uderzenie głową w ścianę z cegły boli, to rozbij ją na mniejsze ściany z cegły, aby uderzać tylko w goleń.

Patrick Hughes
źródło
Przykro mi to mówić, ale ta odpowiedź wydaje się trochę bezproduktywna. Po prostu mówisz mi, że to, czego szukam, nie istnieje, co wydaje się logicznie złe (przynajmniej w zasadzie), a także dlatego, że widziałem, jak ludzie nawołują do takiego systemu w kilku miejscach wcześniej (nikt nigdy nie daje wystarczająco dużo szczegóły, które są główną motywacją do zadania tego pytania). Chociaż być może możliwe, że nie byłem wystarczająco szczegółowy w moim pierwotnym pytaniu, dlatego właśnie go szeroko zaktualizowałem (i będę go aktualizował, jeśli mój umysł natknie się na coś).
TravisG,
Również przestępstwo nie jest zamierzone: P
TravisG
@TravisG Często istnieją systemy zależne od innych systemów, jak zauważył Patrick. W celu uniknięcia opóźnień ramek lub uniknięcia wielokrotnych przejść aktualizacji w ramach logicznego kroku, przyjętym rozwiązaniem jest serializacja fazy aktualizacji, równoległe uruchamianie podsystemów tam, gdzie to możliwe, serializowanie podsystemów z zależnościami przez cały czas, wsadowe przekazywanie mniejszych aktualizacji wewnątrz każdego podsystem wykorzystujący koncepcję parallel_for (). Jest to idealne rozwiązanie dla dowolnej kombinacji potrzeb przejścia aktualizacji podsystemu i najbardziej elastyczne.
Naros
0

Słyszałem o ciekawym rozwiązaniu tego problemu: Chodzi o to, że byłyby 2 kopie danych bytu (marnotrawstwo, wiem). Jedna kopia byłaby kopią obecną, a druga kopią przeszłą. Obecna kopia jest przeznaczona wyłącznie do zapisu, a poprzednia kopia jest przeznaczona tylko do odczytu. Zakładam, że systemy nie chcą pisać do tych samych elementów danych, ale jeśli tak nie jest, systemy te powinny mieć ten sam wątek. Każdy wątek miałby dostęp do zapisu do obecnych kopii wzajemnie wykluczających się części danych, a każdy wątek miałby dostęp do odczytu do wszystkich poprzednich kopii danych, a zatem może aktualizować obecne kopie przy użyciu danych z poprzednich kopii bez zamykający. Pomiędzy poszczególnymi ramkami obecna kopia staje się kopią przeszłą, jednak chcesz poradzić sobie z zamianą ról.

Ta metoda usuwa również warunki wyścigu, ponieważ wszystkie systemy będą działać ze starym stanem, który nie zmieni się przed / po przetworzeniu przez system.

John McDonald
źródło
To sztuczka Johna Carmacka do kopiowania sterty, prawda? Zastanawiałem się nad tym, ale nadal potencjalnie ma ten sam problem, że wiele wątków może zapisywać w tej samej lokalizacji wyjściowej. Prawdopodobnie jest to dobre rozwiązanie, jeśli wszystko jest utrzymywane w trybie „pojedynczego przejścia”, ale nie jestem pewien, czy jest to wykonalne.
TravisG,
Opóźnienie wyświetlania na ekranie wzrosłoby o 1 klatkę, w tym reaktywność GUI. Co może mieć znaczenie w przypadku gier akcji / synchronizacji lub ciężkich manipulacji GUI, takich jak RTS. Podoba mi się to jednak jako pomysł.
Patrick Hughes,
Słyszałem o tym od znajomego i nie wiedziałem, że to sztuczka Carmacka. W zależności od sposobu renderowania renderowanie komponentów może być o jedną klatkę za sobą. Możesz po prostu użyć tego w fazie aktualizacji, a następnie renderować z bieżącej kopii, gdy wszystko będzie aktualne.
John McDonald
0

Znam 3 projekty oprogramowania obsługujące równoległe przetwarzanie danych:

  1. Przetwarzaj dane sekwencyjnie : może to zabrzmieć dziwnie, ponieważ chcemy przetwarzać dane przy użyciu wielu wątków. Jednak większość scenariuszy wymaga wielu wątków tylko po to, aby zakończyć pracę, podczas gdy inne wątki czekają lub wykonują długotrwałe operacje. Najczęstsze zastosowania to wątki interfejsu użytkownika, które aktualizują interfejs użytkownika w jednym wątku, podczas gdy inne wątki mogą działać w tle, ale nie mają bezpośredniego dostępu do elementów interfejsu użytkownika. Aby przekazać wyniki z wątków w tle, używane są kolejki zadań, które zostaną przetworzone przez pojedynczy wątek przy następnej rozsądnej okazji.
  2. Synchronizuj dostęp do danych: jest to najczęstszy sposób obsługi wielu wątków uzyskujących dostęp do tych samych danych. Większość języków programowania ma wbudowane klasy i narzędzia do blokowania sekcji, w których dane są odczytywane i / lub zapisywane jednocześnie przez wiele wątków. Należy jednak zachować ostrożność, aby nie blokować operacji. Z drugiej strony takie podejście kosztuje dużo narzutów w aplikacjach czasu rzeczywistego.
  3. Obsługuj jednoczesne modyfikacje tylko wtedy, gdy się pojawią: to optymistyczne podejście można wykonać, jeśli kolizje występują rzadko. Dane zostaną odczytane i zmodyfikowane, jeśli w ogóle nie będzie dostępu wielokrotnego, ale istnieje mechanizm wykrywający, kiedy dane były aktualizowane jednocześnie. Jeśli tak się stanie, pojedyncze obliczenia zostaną ponownie wykonane do czasu sukcesu.

Oto kilka przykładów każdego podejścia, które można zastosować w systemie encji:

  1. Pomyślmy o tym, CollisionSystemże czyta Positioni RigidBodyskładniki i powinien zaktualizować Velocity. Zamiast manipulować Velocitybezpośrednio, CollisionSystemzamiast tego umieści a CollisionEventw kolejce roboczej an EventSystem. To wydarzenie będzie następnie przetwarzane sekwencyjnie z innymi aktualizacjami Velocity.
  2. EntitySystemDefiniuje zestaw składników, które potrzebuje do odczytu i zapisu. Dla każdego z Entitynich uzyska blokadę odczytu dla każdego komponentu, który chce odczytać, oraz blokadę zapisu dla każdego komponentu, który chce zaktualizować. W ten sposób każdy EntitySystembędzie mógł jednocześnie odczytywać komponenty podczas synchronizacji operacji aktualizacji.
  3. Biorąc przykład z tego MovementSystem, Positionkomponent jest niezmienny i zawiera numer wersji . MovementSystemSavely czyta Positioni Velocitykomponent i oblicza nowy Position, zwiększając odczytu wersji numer i próbuje aktualizujące Positionskładnik. W przypadku równoczesnej modyfikacji, struktura wskazuje to na aktualizację i Entityzostanie ponownie umieszczona na liście podmiotów, które muszą zostać zaktualizowane przez MovementSystem.

W zależności od systemów, jednostek i częstotliwości aktualizacji każde podejście może być dobre lub złe. Struktura systemu encji może pozwolić użytkownikowi wybierać między tymi opcjami, aby poprawić wydajność.

Mam nadzieję, że mógłbym dodać kilka pomysłów do dyskusji i proszę o informację, czy są jakieś wiadomości na ten temat.

benez
źródło