Poprawianie funkcji O (N ^ 2) (wszystkie jednostki iterujące się po wszystkich innych jednostkach)

21

Trochę tła, koduję grę ewolucyjną z przyjacielem w C ++, używając ENTT dla systemu encji. Stworzenia chodzą po mapie 2D, jedzą zielenie lub inne stworzenia, rozmnażają się, a ich cechy mutują.

Dodatkowo wydajność jest w porządku (60 klatek na sekundę bez problemu), gdy gra jest uruchomiona w czasie rzeczywistym, ale chcę móc ją znacznie przyspieszyć, aby nie musieć czekać 4 godziny, aby zobaczyć znaczące zmiany. Więc chcę uzyskać to tak szybko, jak to możliwe.

Staram się znaleźć skuteczną metodę dla stworzeń, aby znaleźć swoje pożywienie. Każde stworzenie powinno szukać najlepszego jedzenia, które jest im wystarczająco blisko.

Przykładowy zrzut ekranu z gry

Jeśli chce jeść, stworzenie przedstawione na środku ma rozejrzeć się w promieniu 149,64 (odległość widzenia) i ocenić, które jedzenie powinien realizować na podstawie żywienia, odległości i rodzaju (mięso lub roślina) .

Funkcja odpowiedzialna za odnalezienie każdego stworzenia, które je pożywienie, pochłania około 70% czasu pracy. Upraszczając sposób, w jaki jest obecnie napisany, wygląda mniej więcej tak:

for (creature : all_creatures)
{
  for (food : all_entities_with_food_value)
  {
    // if the food is within the creatures view and it's
    // the best food found yet, it becomes the best food
  }
  // set the best food as the target for creature
  // make the creature chase it (change its state)
}

Ta funkcja jest uruchamiana co tyknięcie dla każdego stworzenia szukającego pożywienia, dopóki stworzenie nie znajdzie jedzenia i nie zmieni stanu. Jest także uruchamiany za każdym razem, gdy nowe jedzenie odradza się dla stworzeń, które już gonią za danym jedzeniem, aby upewnić się, że wszyscy sięgają po najlepsze dostępne dla nich jedzenie.

Jestem otwarty na pomysły, jak usprawnić ten proces. Chciałbym zmniejszyć złożoność z , ale nie wiem, czy to w ogóle możliwe.O(N.2))

Jednym ze sposobów, które już poprawiłem, jest posortowanie all_entities_with_food_valuegrupy, aby gdy stworzenie przechodziło przez jedzenie zbyt duże, aby je zjeść, zatrzymywało się na nim. Wszelkie inne ulepszenia są mile widziane!

EDYCJA: Dziękuję wszystkim za odpowiedzi! Zaimplementowałem różne rzeczy z różnych odpowiedzi:

Po pierwsze i po prostu to zrobiłem, aby funkcja winy działała tylko raz na pięć tyknięć, dzięki czemu gra była 4x szybsza, nie zmieniając widocznie niczego w grze.

Następnie zapisałem w systemie wyszukiwania żywności tablicę z jedzeniem spawnowanym w tym samym tiksie, w którym działa. W ten sposób muszę tylko porównać jedzenie, które ściga stworzenie z nowymi, które się pojawiły.

Wreszcie, po badaniach nad podziałem przestrzeni i rozważeniem BVH i quadtree, poszedłem z tym drugim, ponieważ uważam, że jest to znacznie prostsze i lepiej dostosowane do mojego przypadku. Wdrażam to dość szybko i znacznie poprawia wydajność, wyszukiwanie żywności prawie nie zajmuje czasu!

Teraz renderowanie spowalnia mnie, ale to problem na kolejny dzień. Dziękuję wam wszystkim!

Alexandre Rodrigues
źródło
2
Czy eksperymentowałeś z wieloma wątkami na wielu rdzeniach procesora działających jednocześnie?
Ed Marty
6
Ile stworzeń masz średnio? Sądząc po migawce, nie wydaje się tak wysoka. W takim przypadku partycjonowanie przestrzeni niewiele pomoże. Czy za nie działa tę funkcję na każdym kleszcza? Możesz uruchomić go co powiedzmy 10 tyknięć. Wyniki symulacji nie powinny się jakościowo zmieniać.
Turms
4
Czy wykonałeś jakieś szczegółowe profilowanie, aby obliczyć najbardziej kosztowną część oceny żywności? Zamiast patrzeć na ogólną złożoność, być może musisz sprawdzić, czy dusi cię jakiś określony obliczenia lub dostęp do struktury pamięci.
Harabeck
Naiwna sugestia: możesz użyć kwadratu lub pokrewnej struktury danych zamiast O (N ^ 2) w taki sposób, jak robisz to teraz.
Seiyria
3
Jak sugerował @Harabeck, kopałbym głębiej, aby zobaczyć, gdzie w pętli spędza się cały ten czas. Jeśli na przykład oblicza się pierwiastek kwadratowy dla odległości, być może będziesz w stanie najpierw porównać kable XY, aby wstępnie wyeliminować wielu kandydatów, zanim będziesz musiał wykonać kosztowne sqrt na pozostałych. Dodawanie if (food.x>creature.x+149.64 or food.x<creature.x-149.64) continue;powinno być łatwiejsze niż wdrożenie „skomplikowanej” struktury pamięci, JEŚLI jest wystarczająco wydajna. (Powiązane: Może nam pomóc, jeśli opublikujesz trochę więcej kodu w wewnętrznej pętli)
AC

Odpowiedzi:

34

Wiem, że nie traktujesz tego jako zderzenia, jednak to, co robisz, to zderzanie się koła wokół istoty z całym jedzeniem.

Naprawdę nie chcesz sprawdzać jedzenia, o którym wiesz, że jest odległe, tylko to, co jest w pobliżu. To jest ogólna rada dotycząca optymalizacji kolizji. Chciałbym zachęcić do szukania technik optymalizacji kolizji i nie ograniczać się do C ++ podczas wyszukiwania.


Stworzenie znajduje jedzenie

W twoim scenariuszu sugerowałbym postawienie świata na planszy. Ustaw komórki co najmniej w promieniu okręgów, które chcesz zderzyć. Następnie możesz wybrać jedną komórkę, na której znajduje się stworzenie, i jego maksymalnie ośmiu sąsiadów i przeszukać tylko te do dziewięciu komórek.

Uwaga : możesz utworzyć mniejsze komórki, co oznaczałoby, że poszukiwany krąg rozciągałby się poza pośrednich sąsiadów, wymagając od ciebie iteracji. Jeśli jednak problem polega na tym, że jest za dużo pożywienia, mniejsze komórki mogą oznaczać iterację łącznie mniejszą ilością bytów, co w pewnym momencie zmienia się na twoją korzyść. Jeśli podejrzewasz, że tak jest, przetestuj.

Jeśli jedzenie się nie porusza, możesz dodawać byty żywności do siatki podczas tworzenia, aby nie trzeba było wyszukiwać, które byty są w komórce. Zamiast tego przeszukujesz komórkę i ma ona listę.

Jeśli sprawisz, że rozmiar komórek będzie potęgą dwóch, możesz znaleźć komórkę, na której znajduje się stworzenie, po prostu obcinając jego współrzędne.

Możesz pracować z odległością do kwadratu (czyli nie rób sqrt), porównując, aby znaleźć najbliższą. Mniej operacji sqrt oznacza szybsze wykonanie.


Dodano nowe jedzenie

Kiedy dodaje się nowe jedzenie, tylko pobliskie stworzenia muszą się obudzić. To ten sam pomysł, tyle że teraz zamiast tego musisz pobrać listę stworzeń w komórkach.

O wiele bardziej interesujące jest to, że jeśli zauważysz w stworzeniu adnotację, jak daleko jest od jedzenia, które ściga ... możesz bezpośrednio sprawdzić tę odległość.

Kolejną rzeczą, która Ci pomoże, jest uświadomienie sobie jedzenia, które goni je stworzenia. To pozwoli ci uruchomić kod znajdujący pożywienie dla wszystkich stworzeń, które gonią kawałek jedzenia, który właśnie został zjedzony.

W rzeczywistości rozpocznij symulację bez jedzenia, a wszystkie stworzenia mają opisaną odległość nieskończoności. Następnie zacznij dodawać jedzenie. Aktualizuj odległości w miarę przemieszczania się stworzeń ... Kiedy jedzenie jest spożywane, weź listę stworzeń, które go gonią, i znajdź nowy cel. Poza tym, wszystkie inne aktualizacje są obsługiwane po dodaniu jedzenia.


Pomijanie symulacji

Znając szybkość stworzenia, wiesz, ile to kosztuje, dopóki nie osiągnie celu. Jeśli wszystkie stworzenia mają tę samą prędkość, ta, która osiągnie pierwszą, będzie miała najmniejszą odległość z adnotacjami.

Jeśli znasz również czas, zanim dodasz więcej jedzenia ... I mam nadzieję, że masz podobną przewidywalność dla rozmnażania i śmierci, to znasz czas na następne wydarzenie (albo jedzenie, albo stworzenie jedzące).

Przejdź do tego momentu. Nie musisz symulować poruszających się stworzeń.

Theraot
źródło
1
„i szukaj tylko tam”. oraz komórki najbliższych sąsiadów - czyli łącznie 9 komórek. Dlaczego 9 Bo co, jeśli stworzenie znajduje się w rogu komórki.
UKMonkey
1
@UKMonkey „Ustaw komórki co najmniej w promieniu okręgów, które chcesz zderzyć”, jeśli strona komórki ma promień, a stworzenie znajduje się w rogu… cóż, przypuszczam, że w tym przypadku musisz przeszukać tylko cztery. Jednak na pewno możemy zmniejszyć komórki, co może być przydatne, jeśli jest za dużo jedzenia i za mało stworzeń. Edycja: wyjaśnię.
Theraot
2
Jasne - jeśli chcesz poćwiczyć, jeśli musisz szukać w dodatkowych komórkach ... ale biorąc pod uwagę, że większość komórek nie będzie miała jedzenia (z podanego obrazu); szybciej będzie po prostu przeszukać 9 komórek, niż obliczyć, które 4 trzeba przeszukać.
UKMonkey
@UKMonkey, dlatego początkowo o tym nie wspominałem.
Theraot
16

Należy zastosować algorytm podziału przestrzeni, taki jak BVH, aby zmniejszyć złożoność. Aby być specyficznym dla twojego przypadku, musisz zrobić drzewo, które składa się z wyrównanych do osi obwiedni zawierających kawałki żywności.

Aby stworzyć hierarchię, umieść kawałki żywności blisko siebie w AABB, a następnie umieść te AABB w większych AABB, ponownie, według odległości między nimi. Zrób to, aż będziesz mieć węzeł główny.

Aby skorzystać z drzewa, najpierw wykonaj test przecięcia okręgu-AABB względem węzła głównego, a następnie, jeśli dojdzie do kolizji, przetestuj na elementach potomnych każdego kolejnego węzła. Na koniec powinieneś mieć grupę kawałków jedzenia.

Możesz także skorzystać z biblioteki AABB.cc.

Ocelot
źródło
1
To rzeczywiście zmniejszyłoby złożoność do N log N, ale wykonanie partycjonowania byłoby również kosztowne. Biorąc pod uwagę, że muszę zrobić podział każdego tika (ponieważ stworzenia poruszają się każdym tikiem), czy nadal byłoby warto? Czy istnieją rozwiązania, które pomagają rzadziej partycjonować?
Alexandre Rodrigues
3
@AlexandreRodrigues nie musisz odbudowywać całego drzewa co tyknięcie, aktualizujesz tylko części, które się poruszają i tylko wtedy, gdy coś wychodzi poza konkretny kontener AABB. Aby jeszcze bardziej zwiększyć wydajność, może być konieczne tuczenie węzłów (pozostawiając miejsce między dziećmi), aby nie trzeba było odbudowywać całej gałęzi po aktualizacji liścia.
Ocelot
6
Myślę, że BVH może być tutaj zbyt skomplikowane - jednolita siatka zaimplementowana jako tabela haszująca jest wystarczająco dobra.
Steven
1
@Steven, wdrażając BVH, możesz z łatwością rozszerzyć skalę symulacji w przyszłości. I tak naprawdę nic nie tracisz, jeśli robisz to dla małej symulacji.
Ocelot
2

Chociaż opisane metody podziału przestrzeni rzeczywiście mogą skrócić czas, twoim głównym problemem nie jest tylko wyszukiwanie. To sama liczba wyszukiwań sprawia, że ​​twoje zadanie jest powolne. Więc optymalizujesz wewnętrzną pętlę, ale możesz również zoptymalizować zewnętrzną pętlę.

Problem polega na tym, że ciągle odpytujesz dane. To trochę tak, jakby dzieci na tylnym siedzeniu pytały po raz tysięczny „jesteśmy jeszcze”, nie ma potrzeby, aby kierowca poinformował cię, kiedy tam będziesz.

Zamiast tego powinieneś starać się, jeśli to możliwe, rozwiązać każdą akcję do jej zakończenia, umieścić ją w kolejce i wypuścić te bąbelkowe zdarzenia, może to wprowadzić zmiany w kolejce, ale to jest w porządku. To się nazywa symulacja zdarzeń dyskretnych. Jeśli możesz zaimplementować swoją symulację w ten sposób, to szukasz znacznego przyspieszenia, które znacznie przewyższa przyspieszenie, jakie możesz uzyskać dzięki lepszemu wyszukiwaniu partycji kosmicznej.

Aby podkreślić punkt poprzedniej kariery, stworzyłem symulatory fabryczne. Metodą tę symulowaliśmy tygodnie dużych fabryk / lotnisk w całym przepływie materiałów na każdym poziomie pozycji w niecałą godzinę. Podczas gdy symulacja oparta na timestepie mogła symulować tylko 4-5 razy szybciej niż w czasie rzeczywistym.

Również jako bardzo nisko wiszący owoc rozważ oddzielenie procedur rysowania od symulacji. Mimo że twoja symulacja jest prosta, nadal istnieje pewien narzut związany z rysowaniem. Co gorsza, sterownik ekranu może ograniczać Cię do x aktualizacji na sekundę, podczas gdy w rzeczywistości Twoje procesory mogą robić rzeczy setki razy szybciej. To podkreśla potrzebę profilowania.

joojaa
źródło
@ Theraot nie wiemy, jak zorganizowane są rzeczy do rysowania. Ale tak, wezwania staną się wąskimi gardłami, gdy i tak będziesz wystarczająco szybki
joojaa
1

Możesz użyć algorytmu linii przeciągnięcia, aby zredukować złożoność do Nlog (N). Teoria polega na diagramach Voronoi, które dzielą obszar otaczający stworzenie na regiony składające się ze wszystkich punktów bliższych temu stworzeniu niż jakiemukolwiek innemu.

Tak zwany algorytm fortuny robi to za Ciebie w Nlog (N), a strona wiki na tym pseudo-kodzie do jego implementacji. Jestem pewien, że istnieją również implementacje bibliotek. https://en.wikipedia.org/wiki/Fortune%27s_alameterm

nabla
źródło
Witamy w GDSE i dziękuję za odpowiedź. Jak dokładnie zastosowałbyś to do sytuacji OP? Opis problemu mówi, że jednostka powinna wziąć pod uwagę całe jedzenie w zasięgu wzroku i wybrać najlepsze. Tradycyjny Voronoi wykluczyłby w zasięgu jedzenie bliższe innej istocie. Nie twierdzę, że Voronoi nie zadziałałoby, ale z twojego opisu nie wynika, jak OP powinien wykorzystać jeden do opisanego problemu.
Pikalek
Podoba mi się ten pomysł, chciałbym go rozwinąć. Jak reprezentujesz diagram voronoi (jak w strukturze danych pamięci)? Jak to zrobić?
Theraot
@Theraot nie potrzebujesz diagramu voronoi, po prostu tge sam pomysł zamiatania.
joojaa
-2

Najłatwiejszym rozwiązaniem byłoby zintegrowanie silnika fizyki i użycie tylko algorytmu wykrywania kolizji. Po prostu zbuduj okrąg / kulę wokół każdej istoty i pozwól, aby silnik fizyki obliczył kolizje. W przypadku 2D sugerowałbym Box2D lub Chipmunk i Bullet dla 3D.

Jeśli uważasz, że zintegrowanie całego silnika fizyki to zbyt wiele, sugerowałbym przyjrzenie się konkretnym algorytmom kolizji. Większość bibliotek wykrywania kolizji działa w dwóch krokach:

  • Wykrywanie fazy szerokiej: celem tego etapu jest uzyskanie listy kandydujących par obiektów, które mogą kolidować tak szybko, jak to możliwe. Dwie typowe opcje to:
    • Przeciągnij i przycinaj : posortuj obwiednie wzdłuż osi X i zaznacz te pary obiektów, które się przecinają. Powtórz dla każdej innej osi. Jeśli para kandydatów przejdzie wszystkie testy, przechodzi do następnego etapu. Ten algorytm jest bardzo dobry w wykorzystywaniu spójności czasowej: możesz przechowywać listy posortowanych jednostek i aktualizować je co każdą klatkę, ale ponieważ są one prawie posortowane, będzie to bardzo szybkie. Wykorzystuje również spójność przestrzenną: ponieważ byty są sortowane w rosnącej kolejności przestrzennej, podczas sprawdzania kolizji możesz zatrzymać się, gdy tylko byt nie zderzy się, ponieważ wszystkie następne będą dalej.
    • Struktury partycjonowania danych przestrzennych, takie jak czwórki, ośmioboki i siatki. Siatki są łatwe do wdrożenia, ale mogą być bardzo marnotrawne, jeśli gęstość jednostek jest niska i bardzo trudne do wdrożenia w przypadku nieograniczonej przestrzeni. Statyczne drzewa przestrzenne są również łatwe do wdrożenia, ale trudne do wyważenia lub aktualizacji w miejscu, więc trzeba będzie przebudować je w każdej ramce.
  • Faza wąska: pary kandydatów znajdujące się w fazie szerokiej są dalej testowane za pomocą bardziej precyzyjnych algorytmów.
José Franco Campos
źródło