Kiedy koszty wywoływania funkcji nadal mają znaczenie w nowoczesnych kompilatorach?


95

Jestem osobą religijną i staram się nie popełniać grzechów. Dlatego mam tendencję do pisania małych ( mniejszych , aby przeformułować Roberta C. Martina) funkcji, aby zachować zgodność z kilkoma przykazaniami nakazanymi przez Biblię Czystego Kodu . Ale sprawdzając niektóre rzeczy, wylądowałem na tym poście , poniżej którego przeczytałem ten komentarz:

Pamiętaj, że koszt wywołania metody może być znaczny, w zależności od języka. Prawie zawsze istnieje kompromis między pisaniem czytelnego kodu a pisaniem kodu wykonawczego.

W jakich warunkach to cytowane stwierdzenie jest nadal aktualne, biorąc pod uwagę bogatą branżę wydajnych nowoczesnych kompilatorów?

To jest moje jedyne pytanie. I nie chodzi o to, czy powinienem pisać długie czy małe funkcje. Podkreślam tylko, że wasze opinie mogą - lub nie - przyczynić się do zmiany mojego nastawienia i uniemożliwić mi oparcie się pokusie bluźnierców .


11
Napisz czytelny i łatwy do utrzymania kod. Dopiero gdy napotkasz problem z przepełnieniem stosu, możesz ponownie pomyśleć o swojej spproach
Fabio

33
Ogólna odpowiedź tutaj jest niemożliwa. Istnieje zbyt wiele różnych kompilatorów, implementujących zbyt wiele różnych specyfikacji językowych. Są też języki skompilowane w JIT, języki interpretowane dynamicznie i tak dalej. Wystarczy powiedzieć, że jeśli kompilujesz natywny kod C lub C ++ za pomocą nowoczesnego kompilatora, nie musisz się martwić o koszty wywołania funkcji. Optymalizator wstawi je w dowolnym momencie. Jako entuzjasta mikrooptymalizacji rzadko widzę kompilatory podejmujące kluczowe decyzje, z którymi ja lub moje testy porównawcze nie zgadzam się.
Cody Gray,

6
Mówiąc z własnego doświadczenia, piszę kod w zastrzeżonym języku, który jest dość nowoczesny pod względem możliwości, ale wywołania funkcji są absurdalnie drogie, do tego stopnia, że ​​nawet typowe dla pętli muszą być zoptymalizowane pod kątem szybkości: for(Integer index = 0, size = someList.size(); index < size; index++)zamiast po prostu for(Integer index = 0; index < someList.size(); index++). To, że Twój kompilator powstał w ciągu ostatnich kilku lat, niekoniecznie oznacza, że ​​możesz zrezygnować z profilowania.
phyrfox,

5
@phyrfox, który ma sens, wyciągając wartość someList.size () poza pętlę zamiast wywoływać ją za każdym razem przez pętlę. Jest to szczególnie ważne, jeśli istnieje jakakolwiek szansa na problem z synchronizacją, w którym czytelnicy i pisarze mogą próbować kolidować podczas iteracji, w którym to przypadku należy również chronić listę przed wszelkimi zmianami podczas iteracji.
Craig

8
Uważaj, aby nie przesadzić z małymi funkcjami, może to zaciemnić kod tak samo skutecznie, jak robi to monolityczna mega-funkcja. Jeśli mi nie wierzysz, sprawdź zwycięzców ioccc.org : niektóre kodują wszystko w jeden main(), inni dzielą wszystko na około 50 małych funkcji i wszystkie są całkowicie nieczytelne. Sztuką jest, jak zawsze, zachowanie równowagi .
cmaster

Odpowiedzi:


148

To zależy od twojej domeny.

Jeśli piszesz kod mikrokontrolera o niskiej mocy, koszt wywołania metody może być znaczny. Ale jeśli tworzysz normalną stronę internetową lub aplikację, koszt wywołania metody będzie nieznaczny w porównaniu z resztą kodu. W takim przypadku zawsze warto skupić się na właściwych algorytmach i strukturach danych zamiast na mikrooptymalizacjach, takich jak wywołania metod.

Jest też kwestia kompilatora wprowadzającego metody dla ciebie. Większość kompilatorów jest wystarczająco inteligentna, aby wstawiać funkcje tam, gdzie jest to możliwe.

I wreszcie, złota zasada: ZAWSZE PROFIL ZAWSZE PIERWSZY. Nie pisz „zoptymalizowanego” kodu w oparciu o założenia. Jeśli nie masz pewności, napisz oba przypadki i sprawdź, co jest lepsze.


13
I np. Kompilator HotSpot wykonuje Inklinację spekulatywną , która w pewnym sensie jest inliniowana , nawet jeśli nie jest to możliwe.
Jörg W Mittag

49
W rzeczywistości w aplikacji internetowej cały kod jest prawdopodobnie nieistotny w odniesieniu do dostępu do bazy danych i ruchu w sieci ...
AnoE

72
Właściwie to jestem bardzo osadzony i ma bardzo niską moc dzięki bardzo staremu kompilatorowi, który ledwo wie, co oznacza optymalizacja, i uwierzcie mi, mimo że funkcja wywołuje znaczenie, nigdy nie jest to pierwsze miejsce na optymalizację. Nawet w tej niszowej dziedzinie jakość kodu jest na pierwszym miejscu.
Tim

2
@ Mehrdad Nawet w tym przypadku byłbym zaskoczony, gdyby w kodzie nie było nic ważniejszego do optymalizacji. Podczas profilowania kodu widzę rzeczy o wiele cięższe niż wywołania funkcji i właśnie tam warto szukać optymalizacji. Niektórzy deweloperzy oszaleli na punkcie jednego lub dwóch niezoptymalizowanych LOC, ale kiedy profilujesz SW, zdajesz sobie sprawę, że projekt ma większe znaczenie, przynajmniej dla największej części kodu. Kiedy znajdziesz wąskie gardło, możesz spróbować je zoptymalizować, a to będzie miało o wiele większy wpływ niż arbitralna optymalizacja niskiego poziomu, taka jak pisanie dużych funkcji, aby uniknąć narzutów wywołania.
Tim

8
Dobra odpowiedź! Twój ostatni punkt powinien być pierwszy: zawsze profiluj, zanim zdecydujesz, gdzie zoptymalizować .
CJ Dennis,

56

Narzut wywołania funkcji zależy całkowicie od języka i od poziomu, który optymalizujesz.

Na bardzo niskim poziomie wywołania funkcji, a nawet więcej, dlatego wirtualne wywołania metod mogą być kosztowne, jeśli prowadzą do nieprzewidywalności gałęzi lub błędów pamięci podręcznej procesora. Jeśli napisałeś asembler , będziesz również wiedział, że potrzebujesz kilku dodatkowych instrukcji, aby zapisać i przywrócić rejestry wokół rozmowy. Nie jest prawdą, że „wystarczająco inteligentny” kompilator byłby w stanie wprowadzić właściwe funkcje, aby uniknąć tego narzutu, ponieważ kompilatory są ograniczone semantyką języka (szczególnie wokół takich funkcji, jak wysyłanie metod interfejsu lub dynamicznie ładowane biblioteki).

Na wysokim poziomie języki takie jak Perl, Python, Ruby wykonują wiele operacji księgowych na wywołanie funkcji, co czyni je stosunkowo kosztownymi. Sytuację pogarsza metaprogramowanie. Kiedyś przyspieszyłem oprogramowanie Python 3x po prostu podnosząc wywołania funkcji z bardzo gorącej pętli. W kodzie krytycznym dla wydajności wbudowane funkcje pomocnicze mogą mieć zauważalny efekt.

Jednak zdecydowana większość oprogramowania nie jest tak bardzo krytyczna pod względem wydajności, że można zauważyć ogólne wywołanie funkcji. W każdym razie pisanie czystego, prostego kodu się opłaca:

  • Jeśli kod nie ma krytycznego wpływu na wydajność, ułatwia to konserwację. Nawet w oprogramowaniu krytycznym pod względem wydajności większość kodu nie będzie „gorącym punktem”.

  • Jeśli kod ma krytyczny wpływ na wydajność, prosty kod ułatwia zrozumienie kodu i dostrzega możliwości optymalizacji. Największe wygrane zwykle nie pochodzą z mikrooptymalizacji, takich jak funkcje wstawiania, ale z ulepszeń algorytmicznych. Lub inaczej: nie rób tego samego szybciej. Znajdź sposób na zrobienie mniej.

Zauważ, że „prosty kod” nie oznacza „podzielony na tysiąc drobnych funkcji”. Każda funkcja wprowadza również trochę narzutu poznawczego - trudniej jest uzasadnić bardziej abstrakcyjny kod. W pewnym momencie te małe funkcje mogą zrobić tak niewiele, że ich nie użycie uprości twój kod.


16
Naprawdę inteligentny DBA powiedział mi kiedyś: „Normalizuj, aż boli, a potem denormalizuj, aż nie”. Wydaje mi się, że można by to przeformułować na „Wyodrębniaj metody, dopóki nie będzie boleć, a następnie wstawiaj, dopóki nie będzie”.
RubberDuck

1
Oprócz narzutu kognitywnego, w informacjach debuggera występuje narzut symboliczny, a zwykle narzut w końcowych plikach binarnych jest nieunikniony.
Frank Hileman,

Jeśli chodzi o inteligentne kompilatory - MOGĄ to zrobić, ale nie zawsze. Na przykład jvm może wstawiać rzeczy w oparciu o profil środowiska wykonawczego z bardzo tanią / wolną pułapką dla nietypowej ścieżki lub wbudowanej funkcji polimorficznej, dla której istnieje tylko jedna implementacja danej metody / interfejsu, a następnie dezoptymalizować to wywołanie, aby poprawnie polimorficzne, gdy nowa podklasa jest ładowana dynamicznie na środowisko uruchomieniowe. Ale tak, istnieje wiele języków, w których takie rzeczy nie są możliwe, aw wielu przypadkach nawet w jvm, gdy nie jest to opłacalne lub możliwe w ogóle.
Artur Biesiadowski

19

Prawie wszystkie reklamy dotyczące strojenia kodu pod kątem wydajności są specjalnymi przypadkami prawa Amdahla . Krótkie, humorystyczne stwierdzenie prawa Amdahla brzmi:

Jeśli jedna część Twojego programu zajmuje 5% czasu wykonywania, a Ty zoptymalizujesz tę część, aby teraz zajmowała zero procent czasu wykonywania, program jako całość będzie tylko o 5% szybszy.

(Optymalizacja rzeczy do zera w czasie wykonywania jest całkowicie możliwa: kiedy usiądziesz, aby zoptymalizować duży, skomplikowany program, prawdopodobnie zauważysz, że wydaje on przynajmniej część swojego czasu pracy na rzeczy, których wcale nie musi robić .)

Dlatego ludzie zwykle mówią, aby nie martwić się kosztami wywołań funkcji: bez względu na to, jak są drogie, zwykle program jako całość spędza tylko niewielką część czasu wykonywania na kosztach połączeń, więc przyspieszenie ich nie pomaga bardzo .

Ale jeśli istnieje sztuczka, którą można wyciągnąć, która przyspiesza wszystkie wywołania funkcji, taka sztuczka jest prawdopodobnie tego warta. Deweloperzy kompilatorów spędzają mnóstwo czasu na optymalizacji funkcji „prologów” i „epilogów”, ponieważ przynosi to korzyści wszystkim programom kompilowanym z tym kompilatorem, nawet jeśli jest to tylko odrobina dla każdego.

A jeśli mają powody, aby sądzić, że program jest spędzać dużo jej wykonywania tylko wywołań funkcji, to należy zacząć myśleć o tym, czy niektóre z tych wywołań funkcji są niepotrzebne. Oto kilka praktycznych zasad określających, kiedy należy to zrobić:

  • Jeśli czas działania funkcji dla pojedynczego wywołania jest krótszy niż milisekunda, ale funkcja ta jest wywoływana setki tysięcy razy, prawdopodobnie należy ją wstawić.

  • Jeśli profil programu pokazuje tysiące funkcji i żadna z nich nie zajmuje więcej niż 0,1% czasu wykonywania, wówczas narzut wywołania funkcji jest prawdopodobnie znaczący łącznie.

  • Jeśli masz „ kod lasagna ”, w którym istnieje wiele warstw abstrakcji, które nie wykonują prawie żadnej pracy poza przeniesieniem do następnej warstwy, a wszystkie te warstwy są implementowane za pomocą wirtualnych wywołań metod, istnieje duża szansa, że ​​procesor zmarnuje dużo czasu na pośrednich przeciągnięciach rurociągów. Niestety, jedynym lekarstwem na to jest pozbycie się niektórych warstw, co często jest bardzo trudne.


7
Uważaj tylko na drogie rzeczy wykonane głęboko w zagnieżdżonych pętlach. Zoptymalizowałem jedną funkcję i uzyskałem kod, który działa 10 razy szybciej. Było to po tym, jak profiler wskazał winowajcę. (Nazywano go w kółko, w pętlach od O (n ^ 3) do małego n O (n ^ 6).)
Loren Pechtel,

„Niestety, jedynym lekarstwem na to jest pozbycie się niektórych warstw, co często jest bardzo trudne”. - zależy to bardzo od kompilatora języka i / lub technologii maszyn wirtualnych. Jeśli możesz zmodyfikować kod, aby ułatwić kompilatorowi wstawianie (np. Za pomocą finalklas i metod, tam gdzie ma to zastosowanie w Javie, lub nie virtualmetod w C # lub C ++), wówczas kompilator / środowisko wykonawcze może wyeliminować pośrednie działanie, a Ty ' Zobaczę zysk bez ogromnej restrukturyzacji. Jak wskazuje @JorgWMittag, JVM może nawet inline inline w przypadkach, w których nie można udowodnić, że optymalizacja jest ...
Jules

... ważne, więc może się zdarzyć, że robi to w kodzie mimo warstw.
Jules

@Jules Chociaż prawdą jest, że kompilatory JIT mogą przeprowadzać optymalizację spekulacyjną, nie oznacza to, że takie optymalizacje stosowane jednolicie. Jeśli chodzi o Javę, moje doświadczenie jest takie, że kultura programistów preferuje warstwy ułożone na wierzchu, co prowadzi do wyjątkowo głębokich stosów wywołań. Anegdotycznie przyczynia się to do powolnego, rozdętego działania wielu aplikacji Java. Taka wysoce warstwowa architektura działa przeciwko środowisku wykonawczemu JIT, niezależnie od tego, czy warstwy są technicznie nierozłączne. JIT nie jest magiczną kulą, która może automatycznie rozwiązać problemy strukturalne.
amon

@amon Moje doświadczenie z „kodem lasagna” pochodzi z bardzo dużych aplikacji C ++ z dużą ilością kodu datowanych na lata 90., kiedy modą były głęboko zagnieżdżone hierarchie obiektów i COM. Kompilatory C ++ podejmują dość heroiczne wysiłki, aby zmiażdżyć kary za abstrakcję w programach takich jak ten, a mimo to możesz zobaczyć, jak spędzają znaczną część czasu zegara ściennego na pośrednich odgałęzieniach rurociągów (i kolejny znaczny fragment na brakach pamięci podręcznej I) .
zwolnić

17

Podważę ten cytat:

Prawie zawsze istnieje kompromis między pisaniem czytelnego kodu a pisaniem kodu wykonawczego.

Jest to bardzo mylące stwierdzenie i potencjalnie niebezpieczne podejście. Istnieją pewne szczególne przypadki, w których musisz dokonać kompromisu, ale ogólnie dwa czynniki są niezależne.

Przykładem koniecznego kompromisu jest prosty algorytm w porównaniu z bardziej złożonym, ale bardziej wydajnym. Implementacja hashtable jest wyraźnie bardziej złożona niż implementacja listy połączonej, ale wyszukiwanie będzie wolniejsze, więc może być konieczne wymienienie prostoty (która jest czynnikiem wpływającym na czytelność) na wydajność.

Jeśli chodzi o narzut wywołania funkcji, przekształcenie algorytmu rekurencyjnego w iteracyjny może mieć znaczącą korzyść w zależności od algorytmu i języka. Ale jest to ponownie bardzo specyficzny scenariusz i ogólnie narzut wywołania funkcji będzie znikomy lub zoptymalizowany.

(Niektóre dynamiczne języki, takie jak Python, mają znaczny narzut wywołania metod. Ale jeśli wydajność staje się problemem, prawdopodobnie nie powinieneś używać Pythona.)

Większość zasad dotyczących czytelnego kodu - spójne formatowanie, znaczące nazwy identyfikatorów, odpowiednie i pomocne komentarze itd. Nie mają wpływu na wydajność. A niektóre - na przykład używanie wyliczeń zamiast ciągów - mają również zalety w zakresie wydajności.


5

Narzut wywołania funkcji jest w większości przypadków nieistotny.

Jednak większy zysk z wstawiania kodu polega na optymalizacji nowego kodu po wstawieniu .

Na przykład, jeśli wywołasz funkcję ze stałym argumentem, optymalizator może teraz stale składać ten argument tam, gdzie nie mógł, zanim wstawi wywołanie. Jeśli argument jest wskaźnikiem funkcji (lub lambda), optymalizator może teraz również wywoływać wywołania tej lambdy.

Jest to duży powód, dla którego funkcje wirtualne i wskaźniki funkcji nie są atrakcyjne, ponieważ nie można ich w ogóle wstawić, chyba że rzeczywisty wskaźnik funkcji jest stale składany aż do strony wywołania.


5

Zakładając, że wydajność ma znaczenie dla twojego programu i rzeczywiście ma wiele połączeń, koszt nadal może, ale nie musi mieć znaczenia, w zależności od rodzaju połączenia.

Jeśli wywoływana funkcja jest mała, a kompilator jest w stanie ją wstawić, wówczas koszt będzie zasadniczo zerowy. Nowoczesne kompilatory / implementacje językowe mają JIT, optymalizacje czasu łącza i / lub systemy modułowe zaprojektowane w celu maksymalizacji możliwości wstawiania funkcji, gdy jest to korzystne.

OTOH, istnieje nieoczywisty koszt wywoływania funkcji: ich samo istnienie może hamować optymalizacje kompilatora przed i po wywołaniu.

Jeśli kompilator nie może zrozumieć, co robi wywoływana funkcja (np. Wirtualne / dynamiczne wysyłanie lub funkcja w bibliotece dynamicznej), może być konieczne pesymistyczne założenie, że funkcja może mieć jakikolwiek efekt uboczny - wyrzucić wyjątek, zmodyfikować stan globalny lub zmień dowolną pamięć widzianą przez wskaźniki. Kompilator może być zmuszony zapisać wartości tymczasowe do pamięci zapasowej i ponownie odczytać je po wywołaniu. Nie będzie w stanie zmienić kolejności instrukcji wokół połączenia, więc może nie być w stanie wektoryzować pętli lub wyciągać zbędne obliczenia z pętli.

Na przykład, jeśli niepotrzebnie wywołujesz funkcję w każdej iteracji pętli:

for(int i=0; i < /* gasp! */ strlen(s); i++) x ^= s[i];

Kompilator może wiedzieć, że jest to czysta funkcja i przenieść ją poza pętlę (w strasznym przypadku, takim jak ten przykład, nawet naprawia przypadkowy algorytm O (n ^ 2) na O (n)):

for(int i=0, end=strlen(s); i < end; i++) x ^= s[i];

A może nawet przepisać pętlę do przetwarzania elementów 4/8/16 jednocześnie za pomocą instrukcji wide / SIMD.

Ale jeśli dodasz wywołanie do jakiegoś nieprzezroczystego kodu w pętli, nawet jeśli wywołanie nic nie robi i samo jest super tanie, kompilator musi przyjąć najgorsze - że wywołanie uzyska dostęp do zmiennej globalnej, która wskazuje na tę samą pamięć jak szmiana jego zawartość (nawet jeśli jest constw twojej funkcji, może być constnigdzie indziej), co uniemożliwia optymalizację:

for(int i=0; i < strlen(s); i++) {
    x ^= s[i];
    do_nothing();
}

3

Ten stary artykuł może odpowiedzieć na twoje pytanie:

Guy Lewis Steele, Jr .. „Obalenie mitu„ Drogiego wezwania do postępowania ”lub implementacje wezwania do postępowania uznane za szkodliwe, lub Lambda: The Ultimate GOTO”. MIT AI Lab. AI Lab Memo AIM-443. Październik 1977 r.

Abstrakcyjny:

Folklor stwierdza, że ​​oświadczenia GOTO są „tanie”, podczas gdy wywołania procedur są „drogie”. Ten mit jest w dużej mierze wynikiem źle zaprojektowanych implementacji językowych. Uwzględniono historyczny rozwój tego mitu. Omawiane są zarówno pomysły teoretyczne, jak i istniejące wdrożenie, które obalają ten mit. Pokazano, że nieograniczone stosowanie wywołań procedur zapewnia dużą stylową swobodę. W szczególności każdy schemat blokowy można zapisać jako program „strukturalny” bez wprowadzania dodatkowych zmiennych. Trudność związana z instrukcją GOTO i wywołaniem procedury charakteryzuje się konfliktem między abstrakcyjnymi koncepcjami programowania a konkretnymi konstrukcjami językowymi.


12
Bardzo wątpię w ten stary artykuł, który odpowie na pytanie, czy „koszty wywołania funkcji nadal mają znaczenie we współczesnych kompilatorach”.
Cody Gray

6
@CodyGray Myślę, że technologia kompilatora powinna się rozwijać od 1977 roku. Jeśli więc wywołania funkcji można taniej w 1977 roku, powinniśmy być w stanie to zrobić teraz. Więc odpowiedź brzmi nie. Oczywiście zakłada to, że używasz przyzwoitej implementacji językowej, która może wykonywać funkcje takie jak wstawianie funkcji.
Alex Vong

4
@AlexVong Opieranie się na optymalizacjach kompilatora z 1977 r. Jest jak poleganie na trendach cen towarów w epoce kamienia łupanego. Wszystko za bardzo się zmieniło. Na przykład mnożenie było zastępowane przez dostęp do pamięci jako tańsza operacja. Obecnie jest znacznie droższy. Wirtualne wywołania metod są stosunkowo dużo droższe niż kiedyś (dostęp do pamięci i nieprzewidywalne rozgałęzienia), ale często można je zoptymalizować, a wirtualne wywołanie metody może być nawet wbudowane (Java robi to cały czas), więc koszt jest dokładnie zero. Nic takiego nie było w 1977 r.
maaartinus,

3
Jak zauważyli inni, nie tylko zmiany w technologii kompilatora unieważniły stare badania. Gdyby kompilatory nadal się poprawiały, a mikroarchitektury pozostały w dużej mierze niezmienione, wnioski z tego dokumentu byłyby nadal aktualne. Ale tak się nie stało. Jeśli już, to mikroarchitekty zmieniły się bardziej niż kompilatory. Rzeczy, które kiedyś były szybkie, są teraz stosunkowo powolne.
Cody Gray,

2
@AlexVong Aby być bardziej precyzyjnym na temat zmian procesora, które powodują, że papier staje się przestarzały: W 1977 roku dostęp do pamięci głównej był jednym cyklem procesora. Obecnie nawet prosty dostęp do pamięci podręcznej L1 (!) Ma opóźnienie od 3 do 4 cykli. Teraz wywołania funkcji są dość obciążone w dostępie do pamięci (tworzenie ramki stosu, zapisywanie adresu zwrotnego, zapisywanie rejestrów zmiennych lokalnych), co z łatwością podnosi koszty pojedynczego wywołania funkcji do 20 i więcej cykli. Jeśli twoja funkcja zmienia tylko argumenty i być może dodaje kolejny stały argument do przekazania, to prawie 100% narzut.
cmaster

3
  • W C ++ uważaj na projektowanie wywołań funkcji kopiujących argumenty, domyślnie jest to „pass by value”. Narzut wywołania funkcji z powodu zapisywania rejestrów i innych rzeczy związanych z ramkami stosu może zostać przytłoczony przez niezamierzoną (i potencjalnie bardzo kosztowną) kopię obiektu.

  • Istnieją optymalizacje związane z ramkami stosu, które należy zbadać przed rezygnacją z wysoce faktoryzowanego kodu.

  • Przez większość czasu, kiedy miałem do czynienia z wolnym programem, stwierdziłem, że wprowadzanie zmian algorytmicznych przyniosło znacznie większe przyspieszenie niż wywołania funkcji wstawiania. Na przykład: inny inżynier przerobił analizator składni, który wypełnił strukturę map-of-map. W ramach tego usunął indeks z pamięci podręcznej z jednej mapy do logicznie powiązanej. To był niezły ruch w zakresie odporności kodu, jednak sprawił, że program był bezużyteczny z powodu 100-krotnego spowolnienia ze względu na wykonanie wyszukiwania skrótu dla wszystkich przyszłych dostępów w porównaniu z użyciem przechowywanego indeksu. Profilowanie wykazało, że większość czasu poświęcono na funkcję haszującą.


4
Pierwsza rada jest trochę stara. Od wersji C ++ 11 przenoszenie jest możliwe. W szczególności w przypadku funkcji, które muszą modyfikować swoje argumenty wewnętrznie, najbardziej efektywnym wyborem może być pobranie argumentu według wartości i zmodyfikowanie go w miejscu.
MSalters

@MSalters: Myślę, że pomyliłeś „w szczególności” z „ponadto” lub czymś podobnym. Decyzja o przekazaniu kopii lub referencji zapadła przed C ++ 11 (choć wiem, że ją znasz).
fresnel

@phresnel: Myślę, że mam rację. Konkretny przypadek, o którym mówię, to przypadek, w którym tworzysz tymczasowe w dzwoniącym, przenosisz go do argumentu, a następnie modyfikujesz w odbierającym. Nie było to możliwe przed C ++ 11, ponieważ C ++ 03 nie może / nie będzie wiązać niepowiązanego odwołania do tymczasowego ..
MSalters

@MSalters: W takim razie źle zrozumiałem twój komentarz po pierwszym przeczytaniu. Wydawało mi się, że sugerujesz, że przed C ++ 11 przekazywanie wartości nie było czymś, co zrobiłoby się, gdyby ktoś chciał zmodyfikować przekazywaną wartość.
fresnel

Pojawienie się „ruchu” najbardziej pomaga w zwrocie obiektów, które są wygodniej zbudowane w funkcji niż na zewnątrz i są przekazywane przez odniesienie. Wcześniej zwracanie obiektu z funkcji wywoływało kopię, często kosztowny ruch. To nie dotyczy argumentów funkcji. Ostrożnie umieszczam słowo „projektowanie” w komentarzu, ponieważ należy wyraźnie zezwolić kompilatorowi na „przejście” do argumentów funkcji (składnia &&). Mam zwyczaj „usuwania” konstruktorów kopii w celu zidentyfikowania miejsc, w których jest to cenne.
user2543191

3

Jak mówią inni, najpierw powinieneś zmierzyć wydajność swojego programu i prawdopodobnie nie znajdziesz żadnej różnicy w praktyce.

Mimo to, z poziomu koncepcyjnego, myślałem, że wyjaśnię kilka rzeczy, które są powiązane z twoim pytaniem. Po pierwsze pytasz:

Czy we współczesnych kompilatorach koszty wywołania funkcji nadal mają znaczenie?

Zwróć uwagę na słowa kluczowe „funkcja” i „kompilatory”. Twój cytat jest subtelnie inny:

Pamiętaj, że koszt wywołania metody może być znaczny, w zależności od języka.

Mówi się o metodach w sensie obiektowym.

Podczas gdy „funkcja” i „metoda” są często używane zamiennie, istnieją różnice, jeśli chodzi o ich koszt (o który pytasz) i jeśli chodzi o kompilację (który jest kontekstem, który podałeś).

W szczególności musimy wiedzieć o wysyłce statycznej a dynamicznej . Na razie zignoruję optymalizacje.

W języku takim jak C zwykle wywołujemy funkcje z wysyłaniem statycznym . Na przykład:

int foo(int x) {
  return x + 1;
}

int bar(int y) {
  return foo(y);
}

int main() {
  return bar(42);
}

Gdy kompilator widzi wywołanie foo(y), wie, do jakiej funkcji fooodnosi się nazwa, więc program wyjściowy może przejść bezpośrednio do foofunkcji, co jest dość tanie. To właśnie oznacza wysyłkę statyczną .

Alternatywą jest dynamiczne wysyłanie , w którym kompilator nie wie, która funkcja jest wywoływana. Jako przykład podajemy kod Haskell (ponieważ odpowiednik C byłby niechlujny!):

foo x = x + 1

bar f x = f x

main = print (bar foo 42)

Tutaj barfunkcja wywołuje swój argument f, którym może być cokolwiek. Stąd kompilator nie może po prostu skompilować bardo instrukcji szybkiego skoku, ponieważ nie wie, do którego skoku. Zamiast tego generowany przez nas kod nie barbędzie ustalał, fdo której funkcji wskazuje, a następnie przeskoczy do niej. To właśnie oznacza dynamiczna wysyłka .

Oba te przykłady dotyczą funkcji . Wspomniałeś o metodach , które można traktować jako szczególny styl dynamicznie wywoływanej funkcji. Na przykład, oto niektóre Python:

class A:
  def __init__(self, x):
    self.x = x

  def foo(self):
    return self.x + 1

def bar(y):
  return y.foo()

z = A(42)
bar(z)

y.foo()Wezwanie wykorzystuje dynamiczne wysyłkę, ponieważ patrzy się wartość foowłaściwości w yobiekcie, a nazywając cokolwiek znajdzie; nie wie, że ybędzie miała klasę Alub że Aklasa zawiera foometodę, więc nie możemy po prostu przejść do niej od razu.

OK, to podstawowy pomysł. Pamiętaj, że wysyłka statyczna jest szybsza niż wysyłka dynamiczna, niezależnie od tego, czy kompilujemy, czy interpretujemy; wszystko inne jest równe. Dereferencing wiąże się z dodatkowymi kosztami.

Jak to wpływa na nowoczesne, optymalizujące kompilatory?

Pierwszą rzeczą, na którą należy zwrócić uwagę, jest to, że statyczne wysyłanie można zoptymalizować bardziej: gdy wiemy, do której funkcji przeskakujemy, możemy wykonywać takie czynności jak wstawianie. Dzięki dynamicznej wysyłce nie wiemy, że skaczemy do czasu wykonania, więc nie możemy wiele zoptymalizować.

Po drugie, w niektórych językach można wywnioskować, gdzie niektóre dynamiczne wysyłki zakończą przeskakiwanie, a tym samym zoptymalizować je do wysyłki statycznej. Dzięki temu możemy przeprowadzać inne optymalizacje, takie jak wstawianie itp.

W powyższym przykładzie Python takie wnioskowanie jest dość beznadziejne, ponieważ Python pozwala innym kodom na przesłonięcie klas i właściwości, więc trudno jest wywnioskować wiele, które będą obowiązywać we wszystkich przypadkach.

Jeśli nasz język pozwala nam nałożyć więcej ograniczeń, na przykład ograniczając się ydo klasy Aza pomocą adnotacji, moglibyśmy wykorzystać te informacje do wnioskowania o funkcji docelowej. W językach z podklasą (czyli prawie wszystkie języki z klasami!) To w rzeczywistości za mało, ponieważ ymoże mieć inną (pod) klasę, więc potrzebowalibyśmy dodatkowych informacji, takich jak finaladnotacje Javy, aby dokładnie wiedzieć, która funkcja zostanie wywołana.

Haskell nie jest językiem OO, ale możemy wywnioskować wartość fprzez inline bar(co jest statycznie wysłane) do mainpodstawiając fooza y. Ponieważ cel parametru fooin mainjest statycznie znany, wywołanie jest statycznie wysyłane i prawdopodobnie zostanie wbudowane i całkowicie zoptymalizowane (ponieważ te funkcje są małe, kompilator jest bardziej skłonny je wbudować; chociaż nie możemy na to liczyć ).

Stąd koszt sprowadza się do:

  • Czy język wysyła połączenie statycznie czy dynamicznie?
  • Jeśli to drugie, czy język pozwala implementacji na wnioskowanie o celu przy użyciu innych informacji (np. Typów, klas, adnotacji, inlinizacji itp.)?
  • Jak agresywnie można zoptymalizować wysyłkę statyczną (wywnioskowaną lub inną)?

Jeśli używasz „bardzo dynamicznego” języka, z dużą ilością dynamicznej wysyłki i kilkoma gwarancjami dostępnymi dla kompilatora, każde połączenie będzie wiązało się z kosztami. Jeśli używasz „bardzo statycznego” języka, dojrzały kompilator wygeneruje bardzo szybki kod. Jeśli jesteś pomiędzy, może to zależeć od twojego stylu kodowania i tego, jak inteligentna jest implementacja.


Nie zgadzam się z tym, że wywołanie zamknięcia (lub jakiegoś wskaźnika funkcji ) - jak w przykładzie Haskell - jest dynamiczną wysyłką. dynamiczna wysyłka wymaga pewnych obliczeń (np. przy użyciu vtable ), aby uzyskać to zamknięcie, więc jest bardziej kosztowne niż połączenia pośrednie. W przeciwnym razie fajna odpowiedź.
Basile Starynkevitch,

2

Tak, przewidywane pominięcie gałęzi jest bardziej kosztowne na nowoczesnym sprzęcie niż jeszcze kilkadziesiąt lat temu, ale kompilatory są znacznie mądrzejsze w optymalizacji tego.

Jako przykład rozważmy Javę. Na pierwszy rzut oka narzut powinien być szczególnie dominujący w tym języku:

  • drobne funkcje są szeroko rozpowszechnione ze względu na konwencję JavaBean
  • funkcje domyślnie są wirtualne i zwykle są
  • jednostką kompilacji jest klasa; środowisko wykonawcze obsługuje ładowanie nowych klas w dowolnym momencie, w tym podklas, które zastępują poprzednio monomorficzne metody

Przerażony tymi praktykami przeciętny programista C przewidziałby, że Java musi być co najmniej o jeden rząd wielkości wolniejsza niż C. A 20 lat temu miałby rację. Nowoczesne testy porównawcze umieszczają jednak idiomatyczny kod Java w granicach kilku procent równoważnego kodu C. Jak to możliwe?

Jednym z powodów jest to, że nowoczesne funkcje wbudowane JVM są oczywiście wywoływane. Czyni to za pomocą inkluzywnego wstawiania:

  1. Świeżo załadowany kod jest wykonywany bez optymalizacji. Na tym etapie dla każdej witryny wywołującej JVM śledzi, które metody zostały faktycznie wywołane.
  2. Po zidentyfikowaniu kodu jako hotspotu wydajności środowisko wykonawcze korzysta z tych statystyk w celu zidentyfikowania najbardziej prawdopodobnej ścieżki wykonania i wstawia ją, poprzedzając ją gałęzią warunkową w przypadku, gdy optymalizacja spekulacyjna nie ma zastosowania.

To znaczy kod:

int x = point.getX();

zostaje przepisany na

if (point.class != Point) GOTO interpreter;
x = point.x;

I oczywiście środowisko wykonawcze jest wystarczająco inteligentne, aby przejść w górę tego sprawdzania typu, dopóki punkt nie jest przypisany, lub pomijać go, jeśli typ jest znany kodowi wywołującemu.

Podsumowując, jeśli nawet Java zarządza automatycznym wstawianiem metod, nie ma nieodłącznego powodu, dla którego kompilator nie może obsługiwać automatycznego wstawiania, i każdy powód, aby to robić, ponieważ wstawianie jest bardzo korzystne dla nowoczesnych procesorów. Dlatego nie mogę sobie wyobrazić żadnego nowoczesnego kompilatora głównego nurtu, nieświadomego tej najbardziej podstawowej strategii optymalizacji, i założyłbym, że jest to kompilator, o ile nie zostanie udowodnione inaczej.


4
„Nie ma nieodłącznego powodu, dla którego kompilator nie obsługiwał automatycznego wstawiania” - jest. Mówiłeś o kompilacji JIT, która sprowadza się do kodu samodopasowującego się (któremu system operacyjny może zapobiec ze względu na bezpieczeństwo) i możliwości automatycznej optymalizacji pełnego programu pod kontrolą profilu. Kompilator AOT dla języka, który umożliwia dynamiczne łączenie, nie wie wystarczająco dużo, aby zdirirtualizować i wprowadzić dowolne połączenie. OTOH: kompilator AOT ma czas na zoptymalizowanie wszystkiego, co może, kompilator JIT ma czas na skoncentrowanie się na tanich optymalizacjach w gorących punktach. W większości przypadków sprawia to, że JIT ma niewielką wadę.
amon

2
Powiedz mi jeden system operacyjny, który uniemożliwia uruchomienie Google Chrome „z powodu bezpieczeństwa” (V8 kompiluje JavaScript do kodu natywnego w czasie wykonywania). Ponadto, chęć wstawienia AOT nie jest dość nieodłącznym powodem (nie jest determinowane przez język, ale architekturę wybraną dla kompilatora) i chociaż dynamiczne łączenie hamuje wstawianie AOT między jednostkami kompilacji, nie hamuje wstawiania w kompilacji jednostki, w których odbywa się większość połączeń. W rzeczywistości użyteczne wstawianie jest prawdopodobnie łatwiejsze w języku, który używa dynamicznego łączenia mniej nadmiernie niż Java.
meriton

4
W szczególności iOS zapobiega JIT dla aplikacji nieuprzywilejowanych. Chrome lub Firefox muszą używać widoku internetowego dostarczonego przez Apple zamiast własnych silników. Warto jednak zauważyć, że AOT vs. JIT to poziom implementacji, a nie wybór języka.
amon

@meriton Windows 10 S i systemy operacyjne konsoli gier również mają tendencję do blokowania silników JIT innych firm.
Damian Yerrick

2

Pamiętaj, że koszt wywołania metody może być znaczny, w zależności od języka. Prawie zawsze istnieje kompromis między pisaniem czytelnego kodu a pisaniem kodu wykonawczego.

Jest to niestety wysoce zależne od:

  • łańcuch narzędzi kompilatora, w tym JIT, jeśli istnieje,
  • domena.

Po pierwsze, pierwszą zasadą optymalizacji wydajności jest profil pierwszy . Istnieje wiele domen, w których wydajność części oprogramowania jest nieistotna dla wydajności całego stosu: wywołania bazy danych, operacje sieciowe, operacje systemu operacyjnego, ...

Oznacza to, że wydajność oprogramowania jest całkowicie nieistotna, nawet jeśli nie poprawia opóźnień, optymalizacja oprogramowania może przynieść oszczędności energii i sprzętu (lub oszczędności baterii w aplikacjach mobilnych), co może mieć znaczenie.

Jednak zazwyczaj NIE można ich oczarować i często ulepszenia algorytmów przebijają mikrooptymalizacje z dużym marginesem.

Tak więc przed optymalizacją musisz zrozumieć, dla czego optymalizujesz ... i czy warto.


Teraz, jeśli chodzi o czystą wydajność oprogramowania, różni się znacznie między łańcuchami narzędzi.

Wywołanie funkcji wiąże się z dwoma kosztami:

  • koszt czasu pracy,
  • koszt czasu kompilacji.

Koszt czasu działania jest raczej oczywisty; w celu wykonania wywołania funkcji konieczna jest pewna ilość pracy. Na przykład przy użyciu C na x86 wywołanie funkcji będzie wymagało (1) rozlewania rejestrów na stos, (2) wypychania argumentów do rejestrów, wykonywania wywołania, a następnie (3) przywracania rejestrów ze stosu. Zobacz to podsumowanie konwencji wywoływania, aby zobaczyć zaangażowaną pracę .

Rozlewanie / przywracanie rejestru zajmuje niebanalną ilość razy (kilkadziesiąt cykli procesora).

Ogólnie oczekuje się, że koszt ten będzie trywialny w porównaniu z faktycznym kosztem wykonania funkcji, jednak niektóre wzorce przynoszą efekt przeciwny do zamierzonego: funkcje pobierające, funkcje chronione przez prosty warunek itp.

Prócz interpretatorów programista będzie miał zatem nadzieję, że ich kompilator lub JIT zoptymalizują niepotrzebne wywołania funkcji; chociaż ta nadzieja może czasami nie przynieść owoców. Ponieważ optymalizatory nie są magią.

Optymalizator może wykryć, że wywołanie funkcji jest trywialne, a inline połączenia: w zasadzie, kopiowanie / wklejanie ciało funkcji w miejscu wywołania. Nie zawsze jest to dobra optymalizacja (może wywoływać wzdęcia), ale ogólnie jest opłacalna, ponieważ wstawianie odsłania kontekst , a kontekst umożliwia więcej optymalizacji.

Typowym przykładem jest:

void func(condition: boolean) {
    if (condition) {
        doLotsOfWork();
    }
}

void call() { func(false); }

Jeśli funczostanie wstawiony, optymalizator zda sobie sprawę, że gałąź nigdy nie zostanie przejęta i zoptymalizuje callvoid call() {}.

W tym sensie wywołania funkcji, ukrywając informacje przed optymalizatorem (jeśli jeszcze nie zostały wstawione), mogą blokować niektóre optymalizacje. Wirtualne wywołania funkcji są szczególnie winne, ponieważ dewirtualizacja (dowodzenie, która funkcja ostatecznie zostanie wywołana w czasie wykonywania) nie zawsze jest łatwa.


Podsumowując, radzę najpierw napisać wyraźnie , unikając przedwczesnej pesymizacji algorytmicznej (złożoność sześcienna lub gorsze brania szybko), a następnie zoptymalizuj tylko to, co wymaga optymalizacji.


1

„Pamiętaj, że koszt wywołania metody może być znaczny, w zależności od języka. Niemal zawsze istnieje kompromis między pisaniem czytelnego kodu a pisaniem kodu wykonawczego”.

W jakich warunkach to cytowane stwierdzenie jest nadal aktualne, biorąc pod uwagę bogatą branżę wydajnych nowoczesnych kompilatorów?

Będę stanowczo powiedzieć, że nigdy. Uważam, że cytat jest nierozważny, by po prostu tam rzucić.

Oczywiście nie mówię pełnej prawdy, ale nie dbam o to, aby być tak szczerym. To tak, jak w tym filmie Matrix, zapomniałem, czy to był 1, 2 czy 3 - myślę, że to ten z seksowną włoską aktorką z dużymi melonami (tak naprawdę nie lubiłem żadnego oprócz pierwszego), kiedy Pani wyroczni powiedziała Keanu Reevesowi: „Właśnie powiedziałam ci, co musisz usłyszeć” lub coś w tym rodzaju, właśnie to chcę teraz zrobić.

Programiści nie muszą tego słyszeć. Jeśli mają doświadczenie z profilerami w ręku, a cytat jest w pewnym stopniu odpowiedni dla ich kompilatorów, to już to wiedzą i nauczą się tego we właściwy sposób, pod warunkiem, że rozumieją swoje wyniki profilowania i dlaczego niektóre wywołania liści są hotspotami poprzez pomiar. Jeśli nie mają doświadczenia i nigdy nie profilowali swojego kodu, jest to ostatnia rzecz, którą muszą usłyszeć, że powinni zacząć zabobonnie kompromitować sposób pisania kodu do punktu wstawiania wszystkiego, zanim nawet zidentyfikują punkty aktywne w nadziei, że to zrobi stać się bardziej wydajnym.

Tak czy inaczej, zależy to od dokładniejszej odpowiedzi. Niektóre z wielu warunków są już wymienione wśród dobrych odpowiedzi. Możliwe warunki wyboru jednego języka są już same w sobie ogromne, takie jak C ++, który musiałby zostać dynamicznie wysłany w rozmowach wirtualnych i kiedy można go zoptymalizować i pod którym kompilatory, a nawet linkery, i to już gwarantuje szczegółową odpowiedź, nie mówiąc już o próbie aby sprostać warunkom w każdym możliwym języku i kompilatorze. Ale dodam na górze: „kogo to obchodzi?” ponieważ nawet pracując w obszarach krytycznych pod względem wydajności, takich jak raytracing, ostatnią rzeczą, którą zacznę robić od początku, są metody ręcznego wprowadzania, zanim będę miał jakiekolwiek pomiary.

Uważam, że niektórzy ludzie nadgorliwie sugerują, aby nigdy nie dokonywać żadnych mikrooptymalizacji przed pomiarem. Jeśli optymalizacja pod kątem lokalizacji odniesień liczy się jako mikrooptymalizacja, to często zaczynam stosować takie optymalizacje od samego początku z podejściem do projektowania zorientowanym na dane w obszarach, które z pewnością będą kluczowe dla wydajności (np. Kod raytracing), bo inaczej wiem, że będę musiał przepisać duże sekcje wkrótce po pracy w tych domenach przez lata. Optymalizacja reprezentacji danych dla trafień w pamięci podręcznej może często mieć ten sam rodzaj poprawy wydajności co ulepszenia algorytmu, chyba że mówimy o czasie kwadratowym do liniowego.

Ale nigdy nie widzę dobrego powodu, aby rozpocząć wstawianie przed pomiarami, zwłaszcza, że ​​profilerzy są w stanie ujawnić, co może skorzystać z wstawiania, ale nie ujawnić, co może zyskać na braku wstawiania (a brak wstawiania może w rzeczywistości przyspieszyć kod, jeśli Wywołanie funkcji bez podszewki jest rzadkim przypadkiem, poprawiając lokalizację odniesienia dla icache dla kodu gorącego, a czasem nawet pozwalając optymalizatorom wykonać lepszą pracę dla wspólnej ścieżki wykonywania instrukcji).

Korzystając z naszej strony potwierdzasz, że przeczytałeś(-aś) i rozumiesz nasze zasady używania plików cookie i zasady ochrony prywatności.
Licensed under cc by-sa 3.0 with attribution required.