GHC ma wiele optymalizacji, które może wykonać, ale nie wiem, jakie są one wszystkie, ani jak prawdopodobne jest ich wykonanie i w jakich okolicznościach.
Moje pytanie brzmi: jakich przekształceń mogę się spodziewać za każdym razem, czy prawie tak? Jeśli często patrzę na fragment kodu, który będzie często wykonywany (oceniany), a moją pierwszą myślą jest „hmm, może powinienem to zoptymalizować”, w którym to przypadku powinna być moja druga myśl: „nawet o tym nie myśl, GHC ma to?
Czytałem gazetę Stream Fusion: od list do strumieni do niczego , a technika, którą zastosowali, przepisując przetwarzanie listy do innej formy, którą normalne optymalizacje GHC następnie niezawodnie zoptymalizowałyby do prostych pętli, była dla mnie nowością. Jak mogę sprawdzić, kiedy moje własne programy kwalifikują się do tego rodzaju optymalizacji?
W podręczniku GHC znajduje się kilka informacji , ale jest to tylko część odpowiedzi na pytanie.
EDYCJA: Zaczynam nagrodę. To, co chciałbym, to lista transformacji niższego poziomu, takich jak lambda / let / case-floating, specjalizacja argumentów typu / konstruktor / funkcja, analiza ścisłości i rozpakowywanie, pracownik / opakowanie i cokolwiek innego znaczącego GHC, które pominąłem , wraz z objaśnieniami i przykładami kodu wejściowego i wyjściowego oraz idealnie ilustruje sytuacje, w których całkowity efekt jest większy niż suma jego części. I najlepiej wspomnieć o tym, kiedy transformacje nie będązdarzyć. Nie oczekuję nowatorskich wyjaśnień każdej transformacji, wystarczy kilka zdań i przykłady kodu liniowego (lub link, jeśli nie jest to dwadzieścia stron artykułu naukowego), o ile duży obraz jest jasne do końca. Chcę być w stanie spojrzeć na fragment kodu i dobrze zgadywać, czy skompiluje się on do ciasnej pętli, dlaczego nie, lub co musiałbym zmienić, aby to zrobić. (Nie interesuje mnie tu tak bardzo duże ramy optymalizacji, takie jak synteza strumieniowa (po prostu przeczytałem o tym artykuł); bardziej wiedza, którą ludzie piszący te ramy mają).
źródło
Odpowiedzi:
Ta strona GHC Trac również dość dobrze wyjaśnia przejścia. Ta strona wyjaśnia kolejność optymalizacji, jednak, podobnie jak większość Wiki Trac, jest nieaktualna.
Jeśli chodzi o szczegóły, najlepiej jest sprawdzić, jak kompilowany jest określony program. Najlepszym sposobem, aby sprawdzić, jakie optymalizacje są przeprowadzane, jest kompilacja programu w trybie pełnym, przy użyciu
-v
flagi. Biorąc jako przykład pierwszy fragment Haskell, który mogłem znaleźć na moim komputerze:Patrząc od pierwszego
*** Simplifier:
do ostatniego, gdzie zachodzą wszystkie fazy optymalizacji, widzimy całkiem sporo.Po pierwsze, Simplifier działa pomiędzy prawie wszystkimi fazami. To znacznie ułatwia pisanie wielu podań. Na przykład przy wdrażaniu wielu optymalizacji po prostu tworzą reguły przepisywania w celu propagowania zmian zamiast konieczności ręcznego wykonywania tych zmian. Uproszczenie obejmuje szereg prostych optymalizacji, w tym wstawianie i łączenie. Głównym ograniczeniem tego, co wiem, jest to, że GHC odmawia wbudowania funkcji rekurencyjnych i że rzeczy muszą być poprawnie nazwane, aby fuzja zadziałała.
Następnie widzimy pełną listę wszystkich przeprowadzonych optymalizacji:
Specjalizować
Podstawową ideą specjalizacji jest usunięcie polimorfizmu i przeciążenia poprzez identyfikację miejsc, w których wywoływana jest funkcja, i tworzenie wersji funkcji, które nie są polimorficzne - są specyficzne dla typów, z którymi są wywoływane. Możesz także powiedzieć kompilatorowi, aby zrobił to z
SPECIALISE
pragmą. Jako przykład weź funkcję silni:Ponieważ kompilator nie zna żadnych właściwości mnożenia, które ma być użyte, nie może go w ogóle zoptymalizować. Jeśli jednak zobaczy, że jest używany na
Int
, może teraz utworzyć nową wersję, różniącą się tylko typem:Następnie reguły wymienione poniżej mogą zostać odpalone, a ty skończysz z czymś, co działa na rozpakowanym
Int
s, co jest znacznie szybsze niż oryginał. Innym sposobem spojrzenia na specjalizację jest częściowe zastosowanie w słownikach klas typów i zmiennych typu.Tutaj źródło zawiera mnóstwo notatek.
Wypłynąć
EDYCJA: Najwyraźniej wcześniej to źle zrozumiałem. Moje wyjaśnienie całkowicie się zmieniło.
Podstawową ideą tego jest przeniesienie obliczeń, których nie należy powtarzać poza funkcjami. Załóżmy na przykład, że mieliśmy:
W powyższej lambda za każdym razem, gdy funkcja jest wywoływana,
y
jest przeliczana. Lepszą funkcją, którą tworzy wypływanie, jestAby ułatwić ten proces, można zastosować inne transformacje. Na przykład dzieje się tak:
Ponownie, wielokrotne obliczenia są zapisywane.
źródło tym przypadku jest bardzo czytelne.
W tej chwili wiązania między dwoma sąsiednimi lambdami nie są unoszone. Na przykład tak się nie dzieje:
zamierzam
Unoszą się do wewnątrz
Cytując kod źródłowy,
Głównym celem
floatInwards
jest przestawienie się na gałęzie skrzynki, abyśmy nie przydzielali rzeczy, zapisywali je na stosie, a następnie odkrywali, że nie są one potrzebne w wybranej gałęzi.Jako przykład załóżmy, że mamy takie wyrażenie:
Jeśli
v
oceni toFalse
, to przydzielającx
, co jest prawdopodobnie jakaś wielka gratka, zmarnowaliśmy czas i przestrzeń. Pływające do wewnątrz naprawia to, powodując:, który jest następnie zastępowany przez uproszczenie za pomocą
Ten artykuł , choć obejmuje inne tematy, daje dość jasne wprowadzenie. Zauważ, że pomimo ich nazw, wypływanie i wypływanie nie wchodzi w nieskończoną pętlę z dwóch powodów:
case
instrukcje, a float zajmuje się funkcjami.Analiza popytu
Analiza popytu lub analiza ścisłości jest mniej transformacją, a bardziej, jak sugeruje nazwa, przepustką do gromadzenia informacji. Kompilator znajduje funkcje, które zawsze oceniają ich argumenty (lub przynajmniej niektóre z nich), i przekazuje te argumenty przy użyciu funkcji call-by-value zamiast call-by-need. Ponieważ możesz uniknąć ogólnych obciążeń, jest to często znacznie szybsze. Wiele problemów z wydajnością w Haskell wynika albo z tego błędu, albo kodu po prostu nie jest wystarczająco rygorystyczny. Prostym przykładem jest różnica między używaniem
foldr
,foldl
ifoldl'
podsumowując listę liczb całkowitych - pierwsza powoduje przepełnienie stosu, druga powoduje przepełnienie sterty, a ostatnia działa poprawnie, ze względu na ścisłość. Jest to prawdopodobnie najłatwiejszy do zrozumienia i najlepiej udokumentowany ze wszystkich. Uważam, że polimorfizm i kod CPS często to pokonują.Worker Wrapper wiąże
Podstawową ideą transformacji pracownik / opakowanie jest wykonanie ciasnej pętli na prostej strukturze, przekształcając ją do i z tej struktury na końcach. Weźmy na przykład tę funkcję, która oblicza silnię liczby.
Używając definicji
Int
w GHC, mamyZauważ, jak kod jest objęty
I#
s? Możemy je usunąć, wykonując następujące czynności:Chociaż ten konkretny przykład mógł być również wykonany przez SpecConstr, transformacja proces roboczy / otoki jest bardzo ogólna pod względem możliwości.
Wspólne podwyrażenie
Jest to kolejna naprawdę prosta optymalizacja, która jest bardzo skuteczna, podobnie jak analiza ścisłości. Podstawową ideą jest to, że jeśli masz dwa takie same wyrażenia, będą miały tę samą wartość. Na przykład, jeśli
fib
kalkulator liczb Fibonacciego, CSE przekształci sięw
co zmniejsza obliczenia o połowę. Niestety może to czasami przeszkadzać w innych optymalizacjach. Innym problemem jest to, że oba wyrażenia muszą znajdować się w tym samym miejscu i muszą być składniowo takie same, a nie takie same pod względem wartości. Na przykład CSE nie uruchomi się w następującym kodzie bez szeregu wstawiania:
Jednakże, jeśli kompilujesz za pomocą llvm, możesz uzyskać część tego łącznie, ze względu na przepustkę Global Value Numbering.
Uwolnij sprawę
To wydaje się być strasznie udokumentowaną transformacją, poza tym, że może powodować eksplozję kodu. Oto przeformatowana (i nieco przepisana) wersja małej dokumentacji, którą znalazłem:
Ten moduł podchodzi
Core
i szukacase
wolnych zmiennych. Kryterium jest takie: jeślicase
na drodze do wywołania rekurencyjnego znajduje się wolna zmienna, wówczas wywołanie rekurencyjne zostanie zastąpione rozwijaniem. Na przykład wwewnętrzna
f
jest wymieniona. robićZwróć uwagę na potrzebę zacienienia. Upraszczamy, rozumiemy
To jest lepszy kod, ponieważ
a
jest wolny w środkuletrec
, niż wymaga projekcjiv
. Zauważ, że dotyczy to wolnych zmiennych , w przeciwieństwie do SpecConstr, który zajmuje się argumentami o znanej formie.Zobacz poniżej, aby uzyskać więcej informacji o SpecConstr.
SpecConstr - przekształca programy takie jak
w
Jako rozszerzony przykład weź tę definicję
last
:Najpierw przekształcamy to w
Następnie działa uproszcznik i mamy
Zauważ, że program jest teraz szybszy, ponieważ nie boksujemy i nie rozpakowujemy na początku listy. Należy również pamiętać, że wstawianie jest kluczowe, ponieważ pozwala na faktyczne stosowanie nowych, bardziej wydajnych definicji, a także na ulepszanie definicji rekurencyjnych.
SpecConstr jest kontrolowany przez szereg heurystyk. Te wymienione w artykule to:
a
.Jednak heurystyka prawie na pewno się zmieniła. W rzeczywistości w artykule wspomniano o alternatywnej szóstej heurystyce:
Specjalizujemy się w argumentach
x
tylko wtedy, gdyx
jest on analizowany tylko przezcase
i nie jest przekazywany do zwykłej funkcji lub zwracany jako część wyniku.To był bardzo mały plik (12 linii), więc prawdopodobnie nie spowodował tak wielu optymalizacji (choć myślę, że to wszystko zrobił). To również nie mówi ci, dlaczego wybrał te podania i dlaczego ustawił je w tej kolejności.
źródło
Lenistwo
Nie jest to „optymalizacja kompilatora”, ale jest to gwarantowane przez specyfikację języka, więc zawsze możesz na to liczyć. Zasadniczo oznacza to, że praca nie jest wykonywana, dopóki „nie zrobisz czegoś” z wynikiem. (Chyba że zrobisz jedną z kilku rzeczy, aby celowo wyłączyć lenistwo).
To oczywiście cały temat sam w sobie, a SO ma już wiele pytań i odpowiedzi na ten temat.
Z mojego ograniczonego doświadczenia wynika, że uczynienie twojego kodu zbyt leniwym lub zbyt surowym ma znacznie większe kary wydajnościowe (w czasie i przestrzeni) niż jakikolwiek inny materiał, o którym zamierzam mówić ...
Analiza ścisłości
Lenistwo polega na unikaniu pracy, chyba że jest to konieczne. Jeśli kompilator może ustalić, że dany wynik będzie „zawsze” potrzebny, to nie będzie kłopotał się zapisaniem obliczeń i wykonaniem go później; po prostu wykona to bezpośrednio, ponieważ jest to bardziej wydajne. Jest to tak zwana „analiza ścisłości”.
Oczywiście, kompilator polega na tym, że kompilator nie zawsze może wykryć, kiedy coś może zostać zaostrzone. Czasami musisz dać kompilatorowi małe wskazówki. (Nie znam żadnego łatwego sposobu na ustalenie, czy analiza ścisłości zrobiła to, co według ciebie, inne niż przebranie przez rdzeń).
Inlining
Jeśli wywołujesz funkcję, a kompilator może stwierdzić, którą funkcję wywołujesz, może spróbować „wstawić” tę funkcję - to znaczy zastąpić wywołanie funkcji kopią samej funkcji. Narzut wywołania funkcji jest zwykle dość niewielki, ale wstawianie często pozwala na inne optymalizacje, które inaczej by się nie wydarzyły, więc wstawianie może być dużą wygraną.
Funkcje są wstawiane tylko wtedy, gdy są „wystarczająco małe” (lub jeśli dodasz pragmę z prośbą o wstawianie). Funkcje można również wstawiać tylko wtedy, gdy kompilator może powiedzieć, którą funkcję wywołujesz. Istnieją dwa główne sposoby, za pomocą których kompilator może nie być w stanie stwierdzić:
Jeśli wywoływana funkcja jest przekazywana z innego miejsca. Na przykład po
filter
skompilowaniu funkcji nie można wstawić predykatu filtru, ponieważ jest to argument podany przez użytkownika.Jeśli wywoływana funkcja jest metodą klasową, a kompilator nie wie, jaki typ jest zaangażowany. Na przykład, gdy
sum
funkcja jest kompilowana, kompilator nie może wstawić+
funkcji, ponieważsum
działa z kilkoma różnymi typami liczb, z których każdy ma inną+
funkcję.W tym drugim przypadku możesz użyć
{-# SPECIALIZE #-}
pragmy do wygenerowania wersji funkcji, które są zakodowane na stałe dla określonego typu. Na przykład{-# SPECIALIZE sum :: [Int] -> Int #-}
skompilowałbym wersję nasum
stałe dla tegoInt
typu, co oznacza, że+
można wstawić w tej wersji.Zauważ jednak, że nasza nowa
sum
funkcja specjalna zostanie wywołana tylko wtedy, gdy kompilator będzie w stanie stwierdzić, że pracujemyInt
. W przeciwnym raziesum
wywoływana jest oryginalna, polimorficzna . Ponownie, faktyczny narzut wywołania funkcji jest dość mały. Korzystne są dodatkowe optymalizacje, które może włączyć wbudowanie.Wspólna eliminacja podwyrażeń
Jeśli określony blok kodu oblicza dwukrotnie tę samą wartość, kompilator może zastąpić ją jednym wystąpieniem tego samego obliczenia. Na przykład jeśli tak
wtedy kompilator może to zoptymalizować
Można się spodziewać, że kompilator zawsze to zrobi. Jednak najwyraźniej w niektórych sytuacjach może to skutkować gorszą wydajnością, a nie lepszą, więc GHC nie zawsze to robi. Szczerze mówiąc, tak naprawdę nie rozumiem szczegółów tego. Ale sedno jest takie, że jeśli transformacja jest dla ciebie ważna, nie jest to trudne ręcznie. (A jeśli to nie jest ważne, dlaczego się o to martwisz?)
Wyrażenia przypadków
Rozważ następujące:
Wszystkie trzy pierwsze równania sprawdzają, czy lista nie jest pusta (między innymi). Ale sprawdzanie tego samego trzy razy jest marnotrawstwem. Na szczęście kompilator bardzo łatwo zoptymalizuje to do kilku wyrażeń zagnieżdżonych. W tym przypadku coś takiego
Jest to raczej mniej intuicyjne, ale bardziej wydajne. Ponieważ kompilator może łatwo wykonać tę transformację, nie musisz się tym martwić. Po prostu napisz swój wzór pasujący w najbardziej intuicyjny możliwy sposób; kompilator bardzo dobrze radzi sobie z porządkowaniem i porządkowaniem, aby był tak szybki, jak to możliwe.
Połączenie
Standardowym idiomem Haskella do przetwarzania list jest łączenie ze sobą funkcji, które pobierają jedną listę i tworzą nową listę. Przykładem kanonicznym
Niestety, podczas gdy lenistwo gwarantuje pominięcie niepotrzebnej pracy, wszystkie alokacje i zwolnienia dla wydajności sap listy pośredniej. „Fusion” lub „wylesianie” to miejsce, w którym kompilator próbuje wyeliminować te pośrednie kroki.
Problem w tym, że większość z tych funkcji ma charakter rekurencyjny. Bez rekurencji byłoby to elementarne ćwiczenie polegające na ściśnięciu wszystkich funkcji w jednym dużym bloku kodu, uruchomieniu nad nim prostownika i wygenerowaniu naprawdę optymalnego kodu bez list pośrednich. Ale z powodu rekurencji to nie zadziała.
Możesz użyć
{-# RULE #-}
pragmy, aby naprawić niektóre z tych problemów. Na przykład,Teraz za każdym razem, gdy GHC widzi
map
wniosekmap
, ściska go w jednym przejściu nad listą, eliminując listę pośrednią.Problem w tym, że działa to tylko w przypadku, gdy
map
następujemap
. Istnieje wiele innych możliwości -map
po których następujefilter
,filter
a następniemap
itd. Zamiast ręcznego kodowania opracowano rozwiązanie dla każdej z nich, tak zwane „połączenie strumieniowe”. To bardziej skomplikowana sztuczka, której nie będę tutaj opisywał.Krótko mówiąc: są to wszystkie specjalne sztuczki optymalizacyjne napisane przez programistę . Sam GHC nic nie wie o fuzji; wszystko to znajduje się na liście bibliotek i innych bibliotek kontenerów. Tak więc, jakie są optymalizacje, zależy od tego, jak są napisane biblioteki kontenerów (lub, bardziej realistycznie, z jakich bibliotek zdecydujesz się korzystać).
Na przykład, jeśli pracujesz z tablicami Haskell '98, nie spodziewaj się żadnego fuzji. Ale rozumiem, że
vector
biblioteka ma szerokie możliwości łączenia. Chodzi o biblioteki; kompilator po prostu zapewniaRULES
pragmę. (Nawiasem mówiąc, co jest niezwykle potężne. Jako autor biblioteki możesz go użyć do przepisania kodu klienta!)Meta:
Zgadzam się z ludźmi mówiącymi: „najpierw kod, drugi profil, trzeci optymalizuj”.
Zgadzam się również z ludźmi mówiącymi: „warto mieć model mentalny, ile kosztują dane decyzje projektowe”.
Równowaga we wszystkich rzeczach i to wszystko ...
źródło
it's something guaranteed by the language specification ... work is not performed until you "do something" with the result.
- nie dokładnie. Specyfikacja językowa obiecuje nie surową semantykę ; nie obiecuje nic o tym, czy zostanie wykonana zbędna praca.Jeśli używane jest wiązanie let v = rhs tylko w jednym miejscu, możesz liczyć na kompilator, aby wstawić go, nawet jeśli rhs jest duży.
Wyjątkiem (który prawie nie jest jednym w kontekście bieżącego pytania) jest lambdas ryzykujący powielanie pracy. Rozważać:
tam wstawianie v byłoby niebezpieczne, ponieważ jedno (syntaktyczne) użycie przełożyłoby się na 99 dodatkowych ocen rh. Jednak w tym przypadku jest mało prawdopodobne, aby wstawić go ręcznie. Zasadniczo możesz użyć reguły:
Jeśli rozważysz wstawienie nazwy, która pojawia się tylko raz, kompilator i tak to zrobi.
Jako szczęśliwy wniosek, użycie wiązania let po prostu w celu rozłożenia długiego stwierdzenia (z nadzieją na uzyskanie przejrzystości) jest zasadniczo bezpłatne.
Pochodzi z community.haskell.org/~simonmar/papers/inline.pdf, który zawiera o wiele więcej informacji na temat wstawiania.
źródło