Interesuje mnie złożoność czasowa kompilatora. Oczywiście jest to bardzo skomplikowane pytanie, ponieważ istnieje wiele kompilatorów, opcji kompilatora i zmiennych do rozważenia. W szczególności interesuję się LLVM, ale interesują mnie wszelkie przemyślenia i miejsca rozpoczęcia badań. Całkiem google wydaje się niewiele rozjaśniać.
Domyślam się, że istnieją pewne kroki optymalizacji, które są wykładnicze, ale mają niewielki wpływ na rzeczywisty czas. Np. Wykładnicza zależna od liczby jest argumentem funkcji.
Z góry mojej głowy powiedziałbym, że generowanie drzewa AST byłoby liniowe. Generowanie IR wymagałoby przejścia przez drzewo podczas wyszukiwania wartości w ciągle rosnących tabelach, więc lub . Generowanie kodu i łączenie byłoby podobnym rodzajem operacji. Dlatego domyślam się , gdybyśmy usunęli wykładnicze zmiennych, które nie rosną realistycznie.
Mogę się jednak całkowicie mylić. Czy ktoś ma coś na ten temat?
Odpowiedzi:
Najlepszą książką, w której można odpowiedzieć na twoje pytanie, byłyby prawdopodobnie: Cooper i Torczon, „Engineering a Compiler”, 2003. Jeśli masz dostęp do biblioteki uniwersyteckiej, powinieneś być w stanie pożyczyć kopię.
W kompilatorze produkcyjnym, takim jak llvm lub gcc, projektanci dokładają wszelkich starań, aby wszystkie algorytmy pozostawały poniżej gdzie jest rozmiarem danych wejściowych. W przypadku niektórych analiz dla faz „optymalizacji” oznacza to, że należy użyć heurystyki zamiast tworzyć naprawdę optymalny kod.O(n2) n
Lexer jest skończoną maszyną stanu, więc w wielkości wejściowej (w znakach) i wytwarza strumień tokenów który jest przekazywany do parsera.O(n) O(n)
W wielu kompilatorach dla wielu języków analizatorem jest LALR (1), a zatem przetwarza strumień tokenów w czasie w liczbie tokenów wejściowych. Podczas parsowania zwykle musisz śledzić tabelę symboli, ale w wielu językach można to obsłużyć za pomocą stosu tabel skrótów („słowniki”). Każdy dostęp do słownika to , ale czasami może być konieczne przejście stosu w celu wyszukania symbolu. Głębokość stosu wynosi gdzie jest głębokością zagnieżdżenia zakresów. (Więc w językach podobnych do C, ile warstw nawiasów klamrowych jesteś w środku.)O(n) O(1) O(s) s
Następnie parsowane drzewo jest zwykle „spłaszczane” do grafu kontrolnego. Węzły wykresu przepływu sterowania mogą być instrukcjami 3-adresowymi (podobnymi do języka asemblera RISC), a rozmiar wykresu przepływu sterowania będzie zwykle liniowy w stosunku do wielkości drzewa analizy.
Następnie zwykle stosuje się szereg etapów eliminacji redundancji (wspólna eliminacja podwyrażenia, ruch kodu niezmiennika w pętli, ciągła propagacja, ...). (Jest to często nazywane „optymalizacją”, chociaż rzadko jest coś optymalnego w wyniku, prawdziwym celem jest poprawienie kodu w jak największym stopniu w ramach ograniczeń czasowych i przestrzennych, które nałożyliśmy na kompilator.) Każdy krok eliminacji nadmiarowości będzie zazwyczaj wymagają dowodów potwierdzających niektóre fakty dotyczące grafu kontrolnego. Te dowody są zwykle wykonywane przy użyciu analizy przepływu danych . Większość analiz przepływu danych zaprojektowano tak, aby zbiegały się w przebiegach nad wykresem przepływu, gdzie jest (z grubsza) głębokością zagnieżdżenia pętli, a przejście przez wykres przepływu zajmuje czasO(d) d O(n) gdzie jest liczbą instrukcji 3-adresowych.n
Aby uzyskać bardziej wyrafinowane optymalizacje, możesz przeprowadzić bardziej wyrafinowane analizy. W tym momencie zaczynasz wpadać na kompromisy. Chcesz, aby algorytmy analizy zajmowały znacznie mniej niżO(n2) czas wielkości wykresu przepływu całego programu, ale oznacza to, że musisz obejść się bez informacji (i usprawnień transformacji programu), których udowodnienie może być kosztowne. Klasycznym przykładem tego jest analiza aliasów, w której dla niektórych zapisów pamięci chciałbyś udowodnić, że te dwa zapisy nigdy nie mogą być kierowane na to samo miejsce w pamięci. (Możesz przeprowadzić analizę aliasu, aby sprawdzić, czy możesz przenieść jedną instrukcję nad drugą.) Ale aby uzyskać dokładne informacje na temat aliasów, może być konieczne przeanalizowanie każdej możliwej ścieżki sterowania przez program, która jest wykładnicza pod względem liczby rozgałęzień w programie (a zatem wykładniczo w liczbie węzłów na grafie przepływu sterowania).
Następnie przejdziesz do alokacji rejestru. Przydział rejestru można sformułować jako problem polegający na zabarwieniu wykresu, a kolorowanie wykresu przy minimalnej liczbie kolorów jest znane jako NP-Hard. Dlatego większość kompilatorów używa pewnego rodzaju chciwej heurystyki w połączeniu z rozlewaniem rejestrów w celu jak najlepszego ograniczenia liczby wycieków rejestrów w rozsądnych ramach czasowych.
Wreszcie możesz przejść do generowania kodu. Generowanie kodu jest zwykle wykonywane jako maksymalny blok podstawowy w czasie, gdy blok podstawowy jest zestawem liniowo połączonych węzłów grafowych sterowania przepływem z jednym wejściem i jednym wyjściem. Można to przeformułować jako wykres obejmujący problem, w którym wykres, który próbujesz pokryć, jest wykresem zależności zestawu instrukcji 3-adresowych w bloku podstawowym, a próbujesz pokryć się zestawem wykresów reprezentujących dostępną maszynę instrukcje. Ten problem ma wykładniczy rozmiar największego bloku podstawowego (który w zasadzie może być tego samego rzędu co rozmiar całego programu), więc znów dzieje się tak zwykle z heurystyką, gdzie tylko niewielka część możliwych pokryć jest badany.
źródło
W rzeczywistości niektóre języki (takie jak C ++, Lisp i D) są kompletne w czasie kompilacji Turinga, więc ich kompilacja jest w ogóle nierozstrzygalna. W przypadku C ++ wynika to z rekurencyjnej instancji szablonu. W przypadku Lisp i D możesz wykonać prawie dowolny kod w czasie kompilacji, więc możesz wrzucić kompilator do nieskończonej pętli, jeśli chcesz.
źródło
Z mojego doświadczenia z kompilatorem C # mogę powiedzieć, że w przypadku niektórych programów rozmiar wyjściowego pliku binarnego rośnie wykładniczo w stosunku do wielkości źródła wejściowego (jest to faktycznie wymagane przez specyfikację C # i nie można go zmniejszyć), więc złożoność czasu musi być co najmniej wykładniczy.
Ogólne zadanie rozwiązywania przeciążenia w języku C # jest znane jako trudne dla NP (a rzeczywista złożoność implementacji jest co najmniej wykładnicza).
Przetwarzanie komentarzy do dokumentacji XML w źródłach w języku C # wymaga również oceny dowolnych wyrażeń XPath 1.0 w czasie kompilacji, czyli również wykładniczo, AFAIK.
źródło
class X<A,B,C,D,E> { class Y : X<Y,Y,Y,Y,Y> { Y.Y.Y.Y.Y.Y.Y.Y.Y y; } }
Zmierz go za pomocą realistycznych baz kodu, takich jak zestaw projektów open source. Jeśli wydrukujesz wyniki jako (codeSize, finishTime), możesz wykreślić te wykresy. Jeśli twoje dane f (x) = y to O (n), to wykreślenie g = f (x) / x powinno dać ci linię prostą po tym, jak dane zaczną się powiększać.
Wykres f (x) / x, f (x) / lg (x), f (x) / (x * lg (x)), f (x) / (x * x) itp. Wykres zanurkuje wyzerować, zwiększyć bez ograniczeń lub spłaszczyć. Pomysł ten przydaje się w sytuacjach takich jak pomiar czasu wstawiania zaczynając od pustej bazy danych (np. W celu znalezienia „przecieku wydajności” przez długi okres.).
źródło