Ale czy ten OOP może być niekorzystny dla oprogramowania opartego na wydajności, tj. Jak szybko program działa?
Często tak !!! ALE...
Innymi słowy, czy wiele odniesień między wieloma różnymi obiektami lub przy użyciu wielu metod z wielu klas może spowodować „ciężką” implementację?
Niekoniecznie. To zależy od języka / kompilatora. Na przykład optymalizujący kompilator C ++, pod warunkiem, że nie używasz funkcji wirtualnych, często obniża narzut obiektu do zera. Możesz wykonywać takie czynności, jak napisanie opakowania nad int
nim lub inteligentnego wskaźnika o ograniczonym zasięgu nad zwykłym starym wskaźnikiem, który działa tak samo szybko, jak bezpośrednie używanie tych zwykłych starych typów danych.
W innych językach, takich jak Java, istnieje pewien narzut na obiekt (często dość mały w wielu przypadkach, ale astronomiczny w niektórych rzadkich przypadkach z naprawdę małymi obiektami). Na przykład,Integer
jest znacznie mniej wydajny niż int
(zajmuje 16 bajtów zamiast 4 w wersji 64-bitowej). Ale to nie są tylko rażące marnotrawstwo czy coś takiego. W zamian Java oferuje takie elementy, jak jednolita refleksja nad każdym typem zdefiniowanym przez użytkownika, a także możliwość zastąpienia dowolnej funkcji nieoznaczonej jako final
.
Przyjmijmy jednak najlepszy scenariusz: optymalizujący kompilator C ++, który może zoptymalizować interfejsy obiektów aż do zera . Nawet wtedy OOP często obniża wydajność i uniemożliwia jej osiągnięcie szczytu. To może brzmieć jak kompletny paradoks: jak to możliwe? Problem polega na:
Projektowanie i enkapsulacja interfejsu
Problem polega na tym, że nawet jeśli kompilator może zmiażdżyć strukturę obiektu do zera narzutu (co jest co najmniej bardzo często prawdziwe w przypadku optymalizacji kompilatorów C ++), hermetyzacja i projekt interfejsu (i akumulacja zależności) drobnoziarnistych obiektów często zapobiegają najbardziej optymalne reprezentacje danych dla obiektów, które mają być agregowane przez masy (co często ma miejsce w przypadku oprogramowania o krytycznym znaczeniu).
Weź ten przykład:
class Particle
{
public:
...
private:
double birth; // 8 bytes
float x; // 4 bytes
float y; // 4 bytes
float z; // 4 bytes
/*padding*/ // 4 bytes of padding
};
Particle particles[1000000]; // 1mil particles (~24 megs)
Powiedzmy, że naszym wzorcem dostępu do pamięci jest po prostu sekwencyjne przechodzenie przez te cząsteczki i kilkakrotne przesuwanie ich wokół każdej klatki, odbijanie ich od rogów ekranu, a następnie renderowanie wyniku.
Już teraz widzimy rażące 4 bajtowe wypełnienie nad głową wymagane do birth
prawidłowego wyrównania elementu, gdy cząstki są agregowane w sposób ciągły. Już około 16,7% pamięci jest marnowane z martwą przestrzenią używaną do wyrównywania.
Może się to wydawać sporne, ponieważ w dzisiejszych czasach mamy gigabajty pamięci DRAM. Jednak nawet najbardziej bestialskie maszyny, jakie mamy obecnie, często mają zaledwie 8 megabajtów, jeśli chodzi o najwolniejszy i największy obszar pamięci podręcznej procesora (L3). Im mniej możemy się tam zmieścić, tym więcej płacimy za powtarzający się dostęp do pamięci DRAM i tym wolniej. Nagle marnowanie 16,7% pamięci przestało być banalną sprawą.
Możemy łatwo wyeliminować ten narzut bez żadnego wpływu na wyrównanie pola:
class Particle
{
public:
...
private:
float x; // 4 bytes
float y; // 4 bytes
float z; // 4 bytes
};
Particle particles[1000000]; // 1mil particles (~12 megs)
double particle_birth[1000000]; // 1mil particle births (~8 bytes)
Teraz zmniejszyliśmy pamięć z 24 MB do 20 MB. Dzięki sekwencyjnemu wzorowi dostępu maszyna będzie teraz zużywać te dane nieco szybciej.
Ale spójrzmy na to birth
pole nieco bliżej. Powiedzmy, że rejestruje czas początkowy, w którym cząstka się rodzi (tworzy). Wyobraź sobie, że pole jest dostępne tylko wtedy, gdy cząstka jest tworzona po raz pierwszy, i co 10 sekund, aby zobaczyć, czy cząstka powinna umrzeć i odrodzić się w losowym miejscu na ekranie. W takim przypadku birth
jest zimne pole. To nie jest dostępne w naszych pętlach krytycznych dla wydajności.
W rezultacie rzeczywiste dane krytyczne pod względem wydajności nie wynoszą 20 megabajtów, ale w rzeczywistości ciągły blok 12 megabajtów. Rzeczywista gorąca pamięć, do której często uzyskujemy dostęp, skurczyła się do połowy swojej wielkości! Spodziewaj się znacznego przyspieszenia w stosunku do naszego oryginalnego 24-megabajtowego rozwiązania (nie trzeba tego mierzyć - robiłeś już takie rzeczy tysiąc razy, ale w razie wątpliwości możesz się swobodnie).
Zauważ jednak, co tutaj zrobiliśmy. Całkowicie złamaliśmy enkapsulację tego obiektu cząstek. Jego stan jest teraz podzielony między Particle
prywatne pola typu i oddzielną, równoległą tablicę. I właśnie tam przeszkadza ziarniste projektowanie obiektowe.
Nie możemy wyrazić optymalnej reprezentacji danych, gdy ograniczamy się do projektu interfejsu pojedynczego, bardzo ziarnistego obiektu, takiego jak pojedyncza cząstka, pojedynczy piksel, nawet pojedynczy 4-komponentowy wektor, być może nawet pojedynczy obiekt „stworzenia” w grze , itp. Prędkość geparda zostanie zmarnowana, jeśli stoi on na malutkiej wyspie, która ma 2 metry kwadratowe, i to właśnie robi bardzo szczegółowa, obiektowa konstrukcja pod względem wydajności. Ogranicza reprezentację danych do natury nieoptymalnej.
Aby pójść dalej, powiedzmy, że ponieważ po prostu poruszamy cząstkami, możemy faktycznie uzyskać dostęp do ich pól x / y / z w trzech oddzielnych pętlach. W takim przypadku możemy skorzystać z funkcji SIMD w stylu SoA dzięki rejestrom AVX, które mogą wektoryzować 8 operacji SPFP równolegle. Ale aby to zrobić, musimy teraz użyć tej reprezentacji:
float particle_x[1000000]; // 1mil particle X positions (~4 megs)
float particle_y[1000000]; // 1mil particle Y positions (~4 megs)
float particle_z[1000000]; // 1mil particle Z positions (~4 megs)
double particle_birth[1000000]; // 1mil particle births (~8 bytes)
Teraz latamy z symulacją cząstek, ale spójrz, co się stało z naszym projektem cząstek. Został całkowicie zburzony, a teraz patrzymy na 4 równoległe tablice i nie ma obiektu, aby je agregować. Nasz obiektowy Particle
projekt przeszedł w sayonara.
Zdarzyło mi się to wiele razy, pracując w obszarach krytycznych pod względem wydajności, w których użytkownicy wymagają szybkości, a tylko poprawność jest tym, czego wymagają więcej. Te małe projekty obiektowe musiały zostać zburzone, a kaskadowe pękanie często wymagało zastosowania strategii powolnej amortyzacji w celu szybszego projektowania.
Rozwiązanie
Powyższy scenariusz przedstawia jedynie problem z granulowanymi projektami obiektowymi. W takich przypadkach często zdarza się, że musimy zburzyć strukturę, aby wyrazić bardziej wydajne reprezentacje w wyniku powtórzeń SoA, podziału pola gorącego / zimnego, zmniejszenia dopełniania dla sekwencyjnych wzorców dostępu (wypełnienie jest czasem pomocne dla wydajności z dostępem losowym) wzorce w przypadkach AoS, ale prawie zawsze przeszkoda dla wzorców sekwencyjnego dostępu) itp.
Możemy jednak przyjąć ostateczną reprezentację, na której się zdecydowaliśmy, i nadal modelować interfejs obiektowy:
// Represents a collection of particles.
class ParticleSystem
{
public:
...
private:
double particle_birth[1000000]; // 1mil particle births (~8 bytes)
float particle_x[1000000]; // 1mil particle X positions (~4 megs)
float particle_y[1000000]; // 1mil particle Y positions (~4 megs)
float particle_z[1000000]; // 1mil particle Z positions (~4 megs)
};
Teraz jesteśmy dobrzy. Możemy uzyskać wszystkie przedmioty, które lubimy. Gepard ma do pokonania cały kraj tak szybko, jak to możliwe. Nasze projekty interfejsów nie uwięziły nas już w wąskim rogu.
ParticleSystem
potencjalnie może być abstrakcyjny i korzystać z funkcji wirtualnych. Teraz jest dyskusja, płacimy za koszty ogólne na poziomie gromadzenia cząstek zamiast na poziomie cząstek . Narzut stanowi 1/1 000 000 tego, co byłoby inaczej, gdybyśmy modelowali obiekty na poziomie pojedynczych cząstek.
Jest to rozwiązanie w obszarach krytycznych pod względem wydajności, które radzą sobie z dużym obciążeniem, i dla wszystkich rodzajów języków programowania (ta technika przynosi korzyści w C, C ++, Python, Java, JavaScript, Lua, Swift itp.). Nie można go łatwo nazwać „przedwczesną optymalizacją”, ponieważ dotyczy to projektowania interfejsu i architektury . Nie możemy napisać bazy kodu modelującej pojedynczą cząsteczkę jako obiekt z mnóstwem zależności klienta od plikuParticle's
interfejs publiczny, a następnie zmień nasze zdanie później. Zrobiłem to bardzo często, gdy jestem powołany do optymalizacji starszych kodowych baz danych, co może zająć miesiące przepisania dziesiątek tysięcy wierszy kodu ostrożnie, aby użyć obszerniejszego projektu. To idealnie wpływa na to, jak projektujemy rzeczy z góry, pod warunkiem, że możemy przewidzieć duże obciążenie.
Powtarzam tę odpowiedź w takiej czy innej formie w wielu pytaniach dotyczących wydajności, a zwłaszcza tych, które dotyczą projektowania obiektowego. Projektowanie obiektowe może być nadal zgodne z najwyższymi wymaganiami dotyczącymi wydajności, ale musimy nieco zmienić sposób myślenia. Musimy dać temu gepardowi trochę miejsca, by biegł tak szybko, jak to możliwe, a to często jest niemożliwe, jeśli projektujemy małe obiekty, które ledwo przechowują jakikolwiek stan.