Czy „magia” JVM utrudnia wpływ programisty na mikrooptymalizacje w Javie? Niedawno czytałem w C ++, czasem porządkowanie elementów danych może zapewnić optymalizację (przyznane, w środowisku mikrosekund) i założyłem, że ręce programisty są związane, jeśli chodzi o wyciskanie wydajności z Javy?
Rozumiem, że przyzwoity algorytm zapewnia większy wzrost prędkości, ale kiedy masz prawidłowy algorytm, trudniej jest dostosować Javę ze względu na kontrolę JVM?
Jeśli nie, ludzie mogą podać przykłady sztuczek, które można zastosować w Javie (oprócz prostych flag kompilatora).
java
c++
performance
latency
użytkownik997112
źródło
źródło
Odpowiedzi:
Jasne, na poziomie mikrooptymalizacji JVM zrobi pewne rzeczy, nad którymi będziesz miał niewielką kontrolę, szczególnie w porównaniu do C i C ++.
Z drugiej strony różnorodność zachowań kompilatora szczególnie w C i C ++ będzie miała o wiele większy negatywny wpływ na twoją zdolność do mikrooptymalizacji w jakikolwiek sposób mało przenośny (nawet w różnych wersjach kompilatora).
To zależy od tego, jaki projekt poprawiasz, jakie środowiska docelowe i tak dalej. I w końcu nie ma to tak naprawdę znaczenia, ponieważ i tak uzyskuje się kilka rzędów wielkości lepszych wyników z optymalizacji algorytmicznej / struktury danych / projektu.
źródło
Mikrooptymalizacje prawie nigdy nie są warte czasu, a prawie wszystkie łatwe są wykonywane automatycznie przez kompilatory i środowiska wykonawcze.
Istnieje jednak jeden ważny obszar optymalizacji, w którym C ++ i Java różnią się zasadniczo, a mianowicie dostęp do pamięci masowej. C ++ ma ręczne zarządzanie pamięcią, co oznacza, że możesz zoptymalizować układ danych aplikacji i wzorce dostępu, aby w pełni wykorzystać pamięci podręczne. Jest to dość trudne, nieco specyficzne dla sprzętu, na którym pracujesz (więc wzrost wydajności może zniknąć na innym sprzęcie), ale jeśli zrobisz to dobrze, może to prowadzić do absolutnie zapierającej dech w piersiach wydajności. Oczywiście płacisz za to z potencjalnym rodzajem straszliwych błędów.
W przypadku śmieciowego języka, takiego jak Java, tego rodzaju optymalizacji nie można wykonać w kodzie. Niektóre mogą być wykonane przez środowisko wykonawcze (automatycznie lub przez konfigurację, patrz poniżej), a niektóre są po prostu niemożliwe (cena, którą płacisz za ochronę przed błędami zarządzania pamięcią).
Flagi kompilatora nie mają znaczenia w Javie, ponieważ kompilator Java prawie nie optymalizuje; środowisko wykonawcze działa.
Rzeczywiście, środowiska wykonawcze Java mają wiele parametrów, które można modyfikować, szczególnie w przypadku śmieciarza. W tych opcjach nie ma nic „prostego” - wartości domyślne są dobre dla większości aplikacji, a uzyskanie lepszej wydajności wymaga dokładnego zrozumienia, co robią opcje i jak zachowuje się aplikacja.
źródło
Mikrosekundy sumują się, jeśli zapętlamy ponad miliony do miliardów rzeczy. Osobista sesja optymalizacji vtune / mikro z C ++ (bez ulepszeń algorytmicznych):
Wszystko oprócz „wielowątkowości”, „SIMD” (odręcznie pokonany kompilator) oraz optymalizacji łatki 4-walencyjnej były optymalizacjami pamięci na poziomie mikro. Również oryginalny kod, począwszy od początkowych czasów 32 sekund, został już dość zoptymalizowany (teoretycznie optymalna złożoność algorytmu) i jest to ostatnia sesja. Przetwarzanie oryginalnej wersji na długo przed ostatnią sesją zajęło ponad 5 minut.
Optymalizacja wydajności pamięci może często pomóc w dowolnym miejscu, od kilku razy do rzędów wielkości w kontekście jednowątkowym, a więcej w kontekstach wielowątkowych (korzyści z wydajnego rep pamięci często mnożą się z wieloma wątkami w mieszance).
O znaczeniu mikrooptymalizacji
Trochę niepokoi mnie myśl, że mikrooptymalizacje to strata czasu. Zgadzam się, że to dobra ogólna rada, ale nie wszyscy robią to niepoprawnie w oparciu o przeczucia i przesądy, a nie pomiary. Prawidłowo wykonane nie musi wywoływać mikro uderzenia. Jeśli weźmiemy własny Embree (jądro raytracing) Intela i przetestujemy tylko prosty skalarny BVH, który napisali (nie pakiet ray, który jest wykładniczo trudniejszy do pokonania), a następnie spróbujemy pokonać wydajność tej struktury danych, może to być najbardziej upokarzające doświadczenie nawet dla weterana przyzwyczajonego do profilowania i strojenia kodu przez dziesięciolecia. A wszystko to dzięki zastosowanym mikrooptymalizacjom. Ich rozwiązanie może przetwarzać ponad sto milionów promieni na sekundę, gdy widziałem specjalistów przemysłowych pracujących w raytracingu, którzy potrafią „
Nie ma sposobu, aby zastosować prostą implementację BVH z jedynie algorytmicznym skupieniem i uzyskać ponad sto milionów przecięć pierwotnego promienia na sekundę w stosunku do dowolnego kompilatora optymalizującego (nawet własnego ICC Intela). Prosty często nie dostaje nawet miliona promieni na sekundę. Wymaga rozwiązań profesjonalnej jakości, aby często uzyskać nawet kilka milionów promieni na sekundę. Mikrooptymalizacja na poziomie Intela pozwala uzyskać ponad sto milionów promieni na sekundę.
Algorytmy
Myślę, że mikrooptymalizacja nie jest ważna, dopóki wydajność nie jest ważna na poziomie minut do sekund, np. Godzin lub minut. Jeśli weźmiemy przerażający algorytm, taki jak sortowanie bąbelkowe, i wykorzystamy go jako przykład danych wejściowych masy, a następnie porównamy go nawet z podstawową implementacją sortowania korespondencji seryjnej, przetworzenie tego pierwszego może potrwać miesiące, a w rezultacie 12 minut. złożoności kwadratowej vs liniowo-rytmicznej.
Różnica między miesiącami a minutami prawdopodobnie sprawi, że większość ludzi, nawet tych, którzy nie pracują w obszarach krytycznych pod względem wydajności, uważa czas wykonania za niedopuszczalny, jeśli wymaga to od użytkowników oczekiwania miesięcy na uzyskanie wyniku.
Tymczasem, jeśli porównamy niezoptymalizowany mikro-prosty, prosty sposób scalania z sortowaniem scalonym (który wcale nie jest lepszy algorytmicznie od sortowania scalonego i oferuje jedynie ulepszenia na poziomie mikro dla lokalizacji odniesienia), mikrooptymalizowany szybki zestaw może zakończyć się w 15 sekund zamiast 12 minut. Zmuszanie użytkowników do czekania na 12 minut może być całkowicie do przyjęcia (rodzaj przerwy na kawę).
Myślę, że ta różnica jest prawdopodobnie nieistotna dla większości ludzi, powiedzmy, od 12 minut do 15 sekund, i dlatego mikrooptymalizacja jest często uważana za bezużyteczną, ponieważ często przypomina jedynie różnicę między minutami a sekundami, a nie minutami i miesiącami. Innym powodem, dla którego uważam, że jest bezużyteczny, jest to, że często stosuje się go w obszarach, które nie mają znaczenia: jakiś niewielki obszar, który nie jest nawet zapętlony i krytyczny, co daje pewną wątpliwą różnicę 1% (co może być po prostu hałasem). Ale dla osób, które dbają o tego rodzaju różnice czasowe i są skłonne zmierzyć i zrobić to dobrze, myślę, że warto zwrócić uwagę przynajmniej na podstawowe pojęcia hierarchii pamięci (szczególnie na wyższe poziomy związane z błędami strony i brakami pamięci podręcznej) .
Java pozostawia dużo miejsca na dobre mikrooptymalizacje
Uff, przepraszam - z takim narzekaniem na bok:
Trochę, ale nie tak bardzo, jak ludzie mogą pomyśleć, jeśli zrobisz to dobrze. Na przykład, jeśli wykonujesz przetwarzanie obrazu, w natywnym kodzie z ręcznie napisaną kartą SIMD, wielowątkowością i optymalizacją pamięci (wzorce dostępu, a być może nawet reprezentacja w zależności od algorytmu przetwarzania obrazu), łatwo jest zgnieść setki milionów pikseli na sekundę przez 32- bit RGBA (8-bitowe kanały kolorów), a czasem nawet miliardy na sekundę.
Nie można zbliżyć się do Javy, jeśli powiesz, że stworzyłeś
Pixel
obiekt (to samo zwiększyłoby rozmiar piksela z 4 bajtów do 16 na 64-bit).Ale możesz być w stanie podejść o wiele bliżej, jeśli unikniesz
Pixel
obiektu, użyjesz tablicy bajtów i zamodelujeszImage
obiekt. Java jest nadal dość kompetentna, jeśli zaczniesz używać tablic zwykłych starych danych. Próbowałem już tego rodzaju rzeczy w Javie i byłem pod dużym wrażeniem, pod warunkiem , że nie stworzysz wszędzie małych małych obiektów, które są 4 razy większe niż normalnie (np. Użyjint
zamiastInteger
) i zaczniesz modelować masowe interfejsy jakImage
interfejs, a niePixel
interfejs. Zaryzykuję nawet stwierdzenie, że Java może konkurować z wydajnością C ++, jeśli zapętlasz stare, zwykłe dane, a nie obiekty (ogromne tablicefloat
, np. NieFloat
).Być może nawet ważniejsze niż rozmiary pamięci jest to, że tablica
int
gwarantuje ciągłą reprezentację. TablicaInteger
nie. Ciągłość jest często niezbędna dla lokalizacji odniesienia, ponieważ oznacza, że wiele elementów (np. 16ints
) może zmieścić się w jednej linii pamięci podręcznej i potencjalnie być dostępnym razem przed eksmisją dzięki wydajnym wzorcom dostępu do pamięci. Tymczasem pojedynczyInteger
może być spleciony gdzieś w pamięci, a otaczająca pamięć jest nieistotna, tylko po to, aby ten obszar pamięci został załadowany do linii pamięci podręcznej, aby użyć tylko jednej liczby całkowitej przed eksmisją, w przeciwieństwie do 16 liczb całkowitych. Nawet jeśli mieliśmy cudowne szczęście i otoczenieIntegers
były w porządku obok siebie w pamięci, możemy zmieścić tylko 4 w linii pamięci podręcznej, do której można uzyskać dostęp przed eksmisją, ponieważInteger
jest 4 razy większy, i to jest najlepszy scenariusz.Jest tam wiele mikrooptymalizacji, ponieważ jesteśmy zunifikowani w ramach tej samej architektury / hierarchii pamięci. Wzorce dostępu do pamięci są ważne bez względu na to, jakiego języka używasz, pojęcia takie jak kafelkowanie / blokowanie pętli mogą być generalnie stosowane znacznie częściej w C lub C ++, ale w równym stopniu korzystają z języka Java.
Kolejność elementów danych na ogół nie ma znaczenia w Javie, ale to w większości dobra rzecz. W C i C ++ zachowanie kolejności elementów danych jest często ważne z powodów ABI, więc kompilatory nie mają z tym problemu. Pracujący tam programiści muszą być ostrożni, wykonując czynności takie jak rozmieszczanie członków danych w porządku malejącym (od największego do najmniejszego), aby uniknąć marnowania pamięci na wypełnianie. W przypadku Javy najwyraźniej JIT może zmieniać kolejność elementów w locie, aby zapewnić prawidłowe wyrównanie przy jednoczesnym zminimalizowaniu wypełniania, więc pod warunkiem, że tak jest, automatyzuje coś, co przeciętni programiści C i C ++ często robią źle i w ten sposób marnują pamięć ( co nie tylko marnuje pamięć, ale często marnuje prędkość, niepotrzebnie zwiększając krok między strukturami AoS i powodując więcej braków pamięci podręcznej). To' jest bardzo robotyczną rzeczą do zmiany układu pól w celu zminimalizowania paddingu, więc idealnie ludzie nie radzą sobie z tym. Jedynym momentem, w którym rozmieszczenie pól może mieć znaczenie w sposób, który wymaga od człowieka znajomości optymalnego ustawienia, jest to, że obiekt jest większy niż 64 bajty, a my układamy pola w oparciu o wzorzec dostępu (nie optymalne wypełnienie) - w takim przypadku może być przedsięwzięciem bardziej ludzkim (wymaga zrozumienia kluczowych ścieżek, z których niektóre są informacjami, których kompilator nie mógłby przewidzieć, nie wiedząc, co użytkownicy zrobią z oprogramowaniem).
Największą różnicą dla mnie pod względem optymalizującej mentalności między Javą a C ++ jest to, że C ++ może pozwalać na używanie obiektów nieco (nieco mniejszych) niż Java w scenariuszu krytycznym pod względem wydajności. Na przykład C ++ może zawijać liczbę całkowitą do klasy bez żadnego narzutu (testowany w każdym miejscu). Java musi mieć ten styl metadanych w stylu wskaźnika + wypełnienia wyrównania na obiekt, dlatego
Boolean
jest większy niżboolean
(ale w zamian zapewnia jednolite korzyści z odbicia i możliwość zastąpienia dowolnej funkcji nieoznaczonej jakfinal
dla każdego UDT).W C ++ jest nieco łatwiej kontrolować ciągłość układów pamięci w niejednorodnych polach (np. Przeplatanie liczb zmiennoprzecinkowych i liczb całkowitych w jednej tablicy poprzez strukturę / klasę), ponieważ lokalizacja przestrzenna jest często gubiona (lub przynajmniej traci się kontrolę) w Javie podczas przydzielania obiektów za pomocą GC.
... ale często rozwiązania o najwyższej wydajności często i tak je dzielą i wykorzystują wzorzec dostępu SoA na ciągłych tablicach zwykłych starych danych. Tak więc w obszarach, które wymagają najwyższej wydajności, strategie optymalizacji układu pamięci między Javą i C ++ są często takie same i często zmuszają cię do demolowania tych niewielkich interfejsów obiektowych na rzecz interfejsów w stylu kolekcji, które mogą wykonywać takie czynności jak hot / dzielenie pola zimnego, powtórzenia SoA itp. Niejednorodne powtórzenia AoSoA wydają się w Javie trochę niemożliwe (chyba że użyłeś surowej tablicy bajtów lub czegoś podobnego), ale są to rzadkie przypadki, w których obasekwencyjne i losowe wzorce dostępu muszą być szybkie, a jednocześnie mieć mieszankę typów pól dla gorących pól. Dla mnie większość różnic w strategii optymalizacji (na ogólnym poziomie) między tymi dwoma jest sporna, jeśli sięgasz po szczytową wydajność.
Różnice różnią się znacznie bardziej, jeśli po prostu sięgasz po „dobrą” wydajność - nie jest w stanie zrobić tyle z małymi obiektami, jak
Integer
vs.,int
może być trochę bardziej PITA, szczególnie ze względu na sposób, w jaki współdziała z lekami generycznymi . Jest to nieco trudniejsze, aby po prostu zbudować jeden rodzajowy struktury danych jako centralny cel optymalizacji w Javie, który pracuje dlaint
,float
itp unikając tych większych i droższych UDTs, ale często najbardziej obszary wydajności krytycznych wymagać będzie ręcznie toczenia własnych struktur danych i tak dostrojony do bardzo konkretnego celu, więc denerwuje tylko kod, który dąży do dobrej wydajności, ale nie do maksymalnej wydajności.Obiekt nad głową
Zauważ, że narzut obiektu Java (metadane i utrata lokalizacji przestrzennej oraz tymczasowa utrata lokalizacji czasowej po początkowym cyklu GC) jest często duży dla rzeczy, które są naprawdę małe (jak
int
vs.Integer
), które są przechowywane przez miliony w jakiejś strukturze danych, która w dużej mierze przylegające i dostępne w bardzo ciasnych pętlach. Wydaje się, że w tym temacie jest dużo wrażliwości, więc powinienem wyjaśnić, że nie chcesz się martwić o narzut obiektów w przypadku dużych obiektów, takich jak obrazy, tylko bardzo małe obiekty, takie jak pojedynczy piksel.Jeśli ktoś ma wątpliwości co do tej części, proponuję zrobić punkt odniesienia między zsumowaniem miliona losowych
ints
a milionem losowychIntegers
i zrobić to wielokrotnie (Integers
przetasowanie pamięci po początkowym cyklu GC).Ultimate Trick: projekty interfejsów, które pozwalają zoptymalizować
Tak więc najlepsza sztuczka Java, jaką widzę, jeśli masz do czynienia z miejscem, które wytrzymuje duże obciążenie małych obiektów (np. A
Pixel
, 4-wektorowa, macierz 4x4, aParticle
nawetAccount
jeśli ma tylko kilka małych pola) to unikanie używania obiektów dla tych drobiazgów i używanie tablic (ewentualnie połączonych razem) zwykłych starych danych. Obiektów następnie stać interfejsy kolekcji jakImage
,ParticleSystem
,Accounts
, zbiór macierzy lub wektorów itp Poszczególne te mogą być dostępne przez indeks, np Jest to również jeden z ostatecznych sztuczek projektowych w C i C ++, ponieważ nawet bez tego podstawowego napowietrznych obiektu i rozłączona pamięć, modelowanie interfejsu na poziomie pojedynczej cząstki zapobiega najbardziej wydajnym rozwiązaniom.źródło
user204677
odszedł. To świetna odpowiedź.Pomiędzy mikrooptymalizacją, z jednej strony, a dobrym wyborem algorytmu, jest z drugiej strony.
Jest to obszar przyspieszeń o stałym współczynniku i może przynieść rzędy wielkości.
Robi to tak, że skraca całe ułamki czasu wykonania, na przykład pierwsze 30%, następnie 20% pozostałej części, następnie 50% tego i tak dalej przez kilka iteracji, aż prawie nic nie pozostanie.
Nie widać tego w małych programach w stylu demonstracyjnym. Widzisz to w dużych poważnych programach z dużą ilością klasowych struktur danych, w których stos wywołań ma zwykle wiele warstw głębokości. Dobrym sposobem na znalezienie możliwości przyspieszenia jest zbadanie losowych próbek stanu programu.
Zasadniczo przyspieszenia obejmują:
minimalizowanie wywołań
new
poprzez łączenie i ponowne wykorzystywanie starych obiektów,rozpoznawanie rzeczy, które są tam robione ze względu na ogólność, a nie są faktycznie konieczne,
przegląd struktury danych przy użyciu różnych klas kolekcji, które zachowują się tak samo jak duże O, ale korzystają z faktycznie używanych wzorców dostępu,
zapisywanie danych pozyskanych przez wywołania funkcji zamiast ponownego wywoływania funkcji (naturalną i zabawną tendencją programistów jest zakładanie, że funkcje o krótszych nazwach działają szybciej).
tolerowanie pewnej niespójności między redundantnymi strukturami danych, w przeciwieństwie do prób zachowania ich pełnej zgodności ze zdarzeniami powiadomień,
itd itd.
Ale oczywiście żadna z tych rzeczy nie powinna zostać wykonana bez uprzedniego wykazania problemów przy pobieraniu próbek.
źródło
O ile mi wiadomo, Java nie daje żadnej kontroli nad lokalizacjami zmiennych w pamięci, więc masz trudność, aby uniknąć takich rzeczy, jak fałszywe współdzielenie i wyrównanie zmiennych (możesz uzupełnić klasę kilkoma nieużywanymi elementami). Inną rzeczą, której nie sądzę, że możesz skorzystać, są instrukcje takie jak
mmpause
, ale te są specyficzne dla procesora, więc jeśli uważasz, że potrzebujesz, Java może nie być językiem używanym.Istnieje klasa Niebezpieczna, która daje elastyczność w C / C ++, ale także w niebezpieczeństwie C / C ++.
Pomoże ci to spojrzeć na kod asemblera generowany przez JVM dla twojego kodu
Aby przeczytać o aplikacji Java, która analizuje ten rodzaj szczegółów, zobacz kod Disruptor wydany przez LMAX
źródło
Odpowiedź na to pytanie jest bardzo trudna, ponieważ zależy od implementacji języka.
Ogólnie rzecz biorąc, obecnie jest bardzo mało miejsca na takie „mikrooptymalizacje”. Głównym powodem jest to, że kompilatory korzystają z takich optymalizacji podczas kompilacji. Na przykład nie ma różnicy w wydajności między operatorami wstępnego i późniejszego przyrostu w sytuacjach, w których ich semantyka jest identyczna. Innym przykładem może być na przykład taka pętla
for(int i=0; i<vec.size(); i++)
której można argumentować, że zamiast wywoływaćsize()
funkcja elementu podczas każdej iteracji lepiej byłoby uzyskać rozmiar wektora przed pętlą, a następnie porównać z tą pojedynczą zmienną, unikając w ten sposób wywołania funkcji na iterację. Są jednak przypadki, w których kompilator wykryje ten głupi przypadek i buforuje wynik. Jest to jednak możliwe tylko wtedy, gdy funkcja nie ma skutków ubocznych, a kompilator może być pewien, że rozmiar wektora pozostaje stały podczas pętli, więc ma on zastosowanie tylko w dość trywialnych przypadkach.źródło
const
metody tylko na tym wektorze, jestem pewien, że wiele optymalizujących kompilatorów to rozwiąże.Oprócz ulepszeń algorytmów należy wziąć pod uwagę hierarchię pamięci i sposób, w jaki procesor z niej korzysta. Zmniejszenie opóźnień w dostępie do pamięci ma duże zalety, gdy zrozumiesz, w jaki sposób dany język przydziela pamięć do swoich typów danych i obiektów.
Przykład Java, aby uzyskać dostęp do tablicy 1000 x 1000 int
Rozważ poniższy przykładowy kod - uzyskuje on dostęp do tego samego obszaru pamięci (tablica ints 1000x1000), ale w innej kolejności. Na moim komputerze Mac mini (Core i7, 2,7 GHz) moc wyjściowa jest następująca, co pokazuje, że przemierzanie tablicy o rzędy ponad dwukrotnie zwiększa wydajność (średnio ponad 100 rund każda).
Jest tak, ponieważ tablica jest przechowywana w taki sposób, że kolejne kolumny (tj. Wartości int) są umieszczane obok siebie w pamięci, podczas gdy kolejne wiersze nie są. Aby procesor rzeczywiście wykorzystał dane, muszą zostać przesłane do pamięci podręcznej. Transfer pamięci odbywa się za pomocą bloku bajtów, zwanego linią pamięci podręcznej - ładowanie linii pamięci podręcznej bezpośrednio z pamięci wprowadza opóźnienia, a tym samym zmniejsza wydajność programu.
W przypadku Core i7 (mostek piaskowy) linia bufora zawiera 64 bajty, dlatego każdy dostęp do pamięci pobiera 64 bajty. Ponieważ pierwszy test uzyskuje dostęp do pamięci w przewidywalnej sekwencji, procesor pobierze dane przed ich faktycznym wykorzystaniem przez program. Ogólnie rzecz biorąc, powoduje to mniejsze opóźnienia w dostępie do pamięci, a tym samym poprawia wydajność.
Kod próbki:
źródło
JVM może i często przeszkadza, a kompilator JIT może zmieniać się znacznie między wersjami Niektóre mikrooptymalizacje są niemożliwe w Javie z powodu ograniczeń językowych, takich jak przyjazna dla hiperwątkowości lub najnowsza kolekcja SIMD procesorów Intel.
Bardzo pouczający blog na ten temat od jednego z autorów Disruptor zaleca się przeczytać:
Zawsze trzeba zapytać, dlaczego warto używać Java, jeśli chcesz mikrooptymalizować, istnieje wiele alternatywnych metod przyspieszania funkcji, takich jak użycie JNA lub JNI do przekazania do biblioteki natywnej.
źródło