Czy uważasz, że istnieje kompromis między pisaniem „ładnego” kodu obiektowego a pisaniem bardzo szybkiego kodu o niskim opóźnieniu? Na przykład unikanie funkcji wirtualnych w C ++ / narzutu polimorfizmu itp. - ponowne pisanie kodu, który wygląda paskudnie, ale jest bardzo szybki itp.?
Pracuję w dziedzinie, która jest nieco bardziej skoncentrowana na przepustowości niż na opóźnieniu, ale jest to bardzo ważne z punktu widzenia wydajności i powiedziałbym „trochę” .
Problemem jest jednak to, że tak wielu ludzi błędnie rozumie swoje wyniki. Nowicjusze często źle rozumieją wszystko, a cały ich koncepcyjny model „kosztu obliczeniowego” wymaga przerobienia, przy czym jedyną możliwą rzeczą jest poprawność algorytmiczna. Półprodukty źle się mylą. Eksperci nie rozumieją pewnych rzeczy.
Mierzenie za pomocą dokładnych narzędzi, które mogą dostarczać takich danych, jak błędy pamięci podręcznej i nieprzewidywalne oddziały, to wszystko to, co zapewnia kontrolę wszystkim osobom o dowolnym poziomie wiedzy w tej dziedzinie.
Mierzenie jest również tym, co wskazuje, czego nie należy optymalizować . Eksperci często spędzają mniej czasu na optymalizacji niż nowicjusze, ponieważ optymalizują naprawdę zmierzone punkty aktywne i nie próbują optymalizować dzikich dźgnięć w ciemności w oparciu o przeczucia co może być powolne (co w skrajnej formie może kusić jednego do mikrooptymalizacji tylko o każdej innej linii w bazie kodu).
Projektowanie pod kątem wydajności
Poza tym kluczem do projektowania pod kątem wydajności jest część projektowa , podobnie jak projektowanie interfejsu. Jednym z problemów związanych z niedoświadczeniem jest to, że istnieje wczesna zmiana bezwzględnych wskaźników implementacji, takich jak koszt wywołania funkcji pośredniej w pewnym uogólnionym kontekście, tak jakby koszt (który jest lepiej zrozumiany w bezpośrednim znaczeniu z punktu widzenia optymalizatora widoku, a nie rozgałęziony punkt widzenia) jest powodem, aby unikać go w całej bazie kodu.
Koszty są względne . Podczas gdy pośrednie wywołanie funkcji wiąże się z kosztami, np. Wszystkie koszty są względne. Jeśli płacisz ten koszt jednorazowo, by wywołać funkcję, która przechodzi przez miliony elementów, martwienie się tym kosztem jest jak spędzanie godzin na targowaniu się o grosze na zakup produktu wartego miliard dolarów, tylko po to, aby nie kupować tego produktu, ponieważ był o jeden grosz za drogi.
Prostszy projekt interfejsu
Aspekt wydajności interfejsu związany z projektowaniem często dąży wcześniej do podniesienia tych kosztów na wyższy poziom. Zamiast płacić na przykład koszty abstrakcji dla pojedynczej cząstki, możemy na przykład przesunąć ten koszt do poziomu układu cząstek / emitera, skutecznie przekształcając cząstkę w szczegół implementacyjny i / lub po prostu surowe dane z tej kolekcji cząstek.
Dlatego projektowanie obiektowe nie musi być niezgodne z projektowaniem pod kątem wydajności (opóźnienia lub przepustowości), ale mogą istnieć pokusy w języku, który skupia się na tym, aby modelować coraz mniejsze obiekty ziarniste, i tam najnowszy optymalizator nie może Wsparcie. Nie może robić rzeczy takich jak łączenie klasy reprezentującej pojedynczy punkt w sposób, który zapewnia wydajną reprezentację SoA dla wzorców dostępu do pamięci oprogramowania. Zbiór punktów z projektem interfejsu modelowanym na poziomie zgrubności daje taką możliwość i pozwala na iterację w kierunku coraz bardziej optymalnych rozwiązań w razie potrzeby. Taki projekt jest przeznaczony do pamięci masowej *.
* Zwróć uwagę na nacisk na pamięć , a nie na dane , ponieważ praca w obszarach krytycznych pod względem wydajności przez długi czas będzie miała tendencję do zmiany twojego widoku typów danych i struktur danych oraz zobaczenia, jak łączą się z pamięcią. Drzewo wyszukiwania binarnego nie skupia się już wyłącznie na złożoności logarytmicznej w takich przypadkach, jak potencjalnie rozbieżne i nieprzyjazne dla pamięci podręcznej fragmenty pamięci dla węzłów drzew, chyba że jest to wspomagane przez stały alokator. Widok nie odrzuca złożoności algorytmicznej, ale widzi, że nie jest już niezależny od układów pamięci. Zaczyna się także widzieć, że iteracje pracy są bardziej na temat iteracji dostępu do pamięci. *
Wiele projektów o kluczowym znaczeniu dla wydajności może w rzeczywistości być bardzo zgodnych z koncepcją projektów interfejsów wysokiego poziomu, które są łatwe do zrozumienia i użytkowania przez ludzi. Różnica polega na tym, że „wysoki poziom” w tym kontekście dotyczyłby masowej agregacji pamięci, interfejsu modelowanego dla potencjalnie dużych zbiorów danych oraz implementacji pod maską, która może być dość niskiego poziomu. Wizualną analogią może być samochód, który jest naprawdę wygodny, łatwy w prowadzeniu i prowadzeniu oraz bardzo bezpieczny podczas jazdy z prędkością dźwięku, ale jeśli otworzysz maskę, w środku jest niewiele demonów oddychających ogniem.
Przy bardziej zgrubnej konstrukcji łatwiejszy jest również sposób na zapewnienie bardziej wydajnych wzorców blokowania i wykorzystanie równoległości w kodzie (wielowątkowość jest wyczerpującym tematem, który pominę tutaj).
Pula pamięci
Krytycznym aspektem programowania o niskim opóźnieniu będzie prawdopodobnie bardzo wyraźna kontrola pamięci w celu poprawy lokalizacji odniesienia, a także po prostu ogólna szybkość przydzielania i zwalniania pamięci. Pamięć puli niestandardowego alokatora faktycznie odzwierciedla ten sam sposób myślenia, który opisaliśmy. Jest przeznaczony dla luzem ; jest zaprojektowany na grubym poziomie. Wstępnie przydziela pamięć w dużych blokach i gromadzi pamięć już przydzieloną w małych porcjach.
Pomysł jest dokładnie taki sam, jak wypychanie kosztownych rzeczy (alokowanie fragmentu pamięci do alokatora ogólnego przeznaczenia, np.) Na bardziej zgrubny i grubszy poziom. Pula pamięci została zaprojektowana do pracy z pamięcią masową .
Typy systemów Segreguj pamięć
Jedną z trudności związanych ze szczegółowym projektowaniem obiektowym w dowolnym języku jest to, że często chce wprowadzić wiele drobnych typów i struktur danych zdefiniowanych przez użytkownika. Te typy mogą następnie chcieć być przydzielane w małych kawałkach, jeśli są dynamicznie przydzielane.
Typowym przykładem w C ++ byłyby przypadki, w których wymagany jest polimorfizm, w których naturalną pokusą jest przydzielenie każdej instancji podklasy względem alokatora pamięci ogólnego przeznaczenia.
To ostatecznie rozbija prawdopodobnie sąsiadujące ze sobą układy pamięci na małe, bity kawałki i fragmenty rozproszone w całym zakresie adresowania, co przekłada się na więcej błędów stron i braków pamięci podręcznej.
Pola wymagające reakcji o najniższym opóźnieniu, bez zacinania się i deterministycznej reakcji są prawdopodobnie jedynym miejscem, w którym punkty aktywne nie zawsze sprowadzają się do pojedynczego wąskiego gardła, w którym niewielkie nieefektywności mogą rzeczywiście „akumulować” się (coś, co wielu ludzi sobie wyobraża) dzieje się niepoprawnie z profilerem, aby utrzymać je w ryzach, ale w polach opartych na opóźnieniach mogą zdarzyć się rzadkie przypadki, w których kumulują się niewielkie nieefektywności). A najczęstszymi przyczynami takiej akumulacji mogą być: nadmierna alokacja małych fragmentów pamięci w całym miejscu.
W językach takich jak Java pomocne może być użycie większej liczby tablic zwykłych starych typów danych, gdy jest to możliwe, dla wąskich obszarów (obszarów przetwarzanych w ciasnych pętlach), takich jak tablica int
(ale nadal za obszernym interfejsem wysokiego poziomu) zamiast, powiedzmy , obiektów ArrayList
zdefiniowanych przez użytkownika Integer
. Pozwala to uniknąć segregacji pamięci, która zwykle towarzyszy temu ostatniemu. W C ++ nie musimy tak bardzo degradować struktury, jeśli nasze wzorce alokacji pamięci są wydajne, ponieważ typy zdefiniowane przez użytkownika mogą być alokowane w sposób ciągły, a nawet w kontekście ogólnego kontenera.
Łączenie pamięci z powrotem razem
Rozwiązaniem tutaj jest sięgnięcie po niestandardowy alokator dla jednorodnych typów danych, a być może nawet dla jednorodnych typów danych. Kiedy małe typy danych i struktury danych są spłaszczone do bitów i bajtów w pamięci, przyjmują one jednorodny charakter (aczkolwiek z pewnymi różnymi wymaganiami dotyczącymi wyrównania). Kiedy nie patrzymy na nie z nastawienia na pamięć, system typów języków programowania „chce” podzielić / segregować potencjalnie sąsiadujące regiony pamięci na małe, maleńkie porozrzucane fragmenty.
Stos wykorzystuje skupienie skoncentrowane na pamięci, aby tego uniknąć i potencjalnie przechowywać w nim wszelkie możliwe mieszane kombinacje instancji typów zdefiniowanych przez użytkownika. Lepsze wykorzystanie stosu to świetny pomysł, gdy jest to możliwe, ponieważ jego górna część prawie zawsze znajduje się w linii pamięci podręcznej, ale możemy również zaprojektować alokatory pamięci, które naśladują niektóre z tych cech bez wzorca LIFO, łącząc pamięć w różnych typach danych w ciągłe części nawet w przypadku bardziej złożonych wzorców alokacji pamięci i dezalokacji.
Nowoczesny sprzęt zaprojektowano tak, aby osiągał szczyt w przetwarzaniu ciągłych bloków pamięci (wielokrotny dostęp do tej samej linii pamięci podręcznej, tej samej strony, np.). Słowo kluczowe jest przylegające, ponieważ jest to korzystne tylko wtedy, gdy istnieją otaczające dane zainteresowania. Tak więc kluczową kwestią (a jednocześnie trudnością) w wydajności jest ponowne scalenie segregowanych fragmentów pamięci z powrotem w ciągłe bloki, które są dostępne w całości (wszystkie dane otaczające są istotne) przed eksmisją. Największą przeszkodą może być tutaj bogaty system typów szczególnie zdefiniowanych przez użytkownika w językach programowania, ale zawsze możemy sięgnąć i rozwiązać problem za pomocą niestandardowego alokatora i / lub bardziej obszernych projektów, gdy jest to właściwe.
Brzydki
Trudno powiedzieć „brzydkie”. To subiektywna metryka, a ktoś, kto pracuje w bardzo krytycznym dla wydajności polu, zacznie zmieniać swoje pojęcie „piękna” na takie, które jest bardziej zorientowane na dane i skupia się na interfejsach przetwarzających rzeczy masowo.
Niebezpieczny
„Niebezpieczne” może być łatwiejsze. Ogólnie rzecz biorąc, wydajność zwykle chce sięgać w kierunku kodu niższego poziomu. Na przykład implementacja alokatora pamięci jest niemożliwa bez sięgania poniżej typów danych i pracy na niebezpiecznym poziomie surowych bitów i bajtów. W rezultacie może pomóc skupić się na starannej procedurze testowania w tych podsystemach o kluczowym znaczeniu dla wydajności, skalując dokładność testowania z poziomem zastosowanych optymalizacji.
Piękno
Wszystko to byłoby jednak na poziomie szczegółowości wdrożenia. Zarówno weterani nastawieni na dużą skalę, jak i krytyczni pod względem wydajności, „piękno” dąży raczej do projektowania interfejsów niż do szczegółów implementacji. Poszukiwanie „pięknych”, użytecznych, bezpiecznych i wydajnych interfejsów zamiast implementacji staje się wykładniczo wyższym priorytetem ze względu na pęknięcia sprzężenia i kaskady, które mogą wystąpić w obliczu zmiany projektu interfejsu. Implementacje można zamienić w dowolnym momencie. Zazwyczaj iterujemy w kierunku wydajności w razie potrzeby i zgodnie z pomiarami. Kluczem w projekcie interfejsu jest modelowanie na wystarczająco grubym poziomie, aby pozostawić miejsce na takie iteracje bez uszkodzenia całego systemu.
W rzeczywistości sugerowałbym, że weteran skupiający się na rozwoju krytycznym pod względem wydajności często będzie koncentrował się głównie na bezpieczeństwie, testowaniu, łatwości konserwacji, tylko uczniu SE w ogóle, ponieważ baza kodu na dużą skalę, która ma wiele wyników - krytyczne podsystemy (systemy cząstek, algorytmy przetwarzania obrazu, przetwarzanie wideo, sprzężenie audio, raytracery, silniki siatkowe itp.) będą musiały zwracać szczególną uwagę na inżynierię oprogramowania, aby uniknąć utonięcia w koszmarze konserwacji. To nie przypadek, że często najbardziej zadziwiająco wydajne produkty mogą zawierać najmniej błędów.
TL; DR
W każdym razie to moje podejście do tematu, począwszy od priorytetów w obszarach naprawdę krytycznych pod względem wydajności, co może zmniejszyć opóźnienia i powodować niewielką nieefektywność, i to, co faktycznie stanowi „piękno” (patrząc na rzeczy najbardziej produktywnie).