Mirosław Jedynak Krzysztof Lewandowski Algorytm Chaitin a Wstęp Po raz kolejny spotykamy się z problemem dysproporcji między szybkością pracy procesorów i pamięci. W ciągu kilku ostatnich dekad szybkość pracy procesorów zwiększała się w sposób drastyczny w porównaniu z szybkością pamięci głównej. Dlatego też współczesne architektury komputerowe zawierają kilka poziomów magazynów (pamięci pośrednich) pomiędzy pamięcią główną a procesorem. Są one niewielkie, ale za to dostęp do nich jest szybszy niż do pamięci głównej. Konsekwentnie, nowoczesne kompilatory muszą dawać gwarancje, że często używane wartości są przechowywane w pamięci o jak najszybszym dostępie, czyli znajdującej się na wysokim poziomie w hierarchii. W szczególności pożądaną sytuacją byłaby taka, gdzie wszystkie wartości przechowujemy w rejestrach, do których dostęp jest najszybszy. Dlatego kompilatory przeprowadzają proces przydziału rejestrów (ang. register allocation ), tak aby optymalnie zmapować wartości występujące w programie na rejestry dostępne w danej architekturze. Faza ta jest krytyczna w tworzenia szybko działających programów. Niestety optymalny przydział rejestrów jest problemem NP-zupełnym! Moduły kompilatorów dokonujące alokacji rejestrów korzystają zwykle z pośredniej reprezentacji programu jako danych wejściowych. Reprezantacja ta nie narzuca żadnych ograniczeń architektonicznych, jeśli chodzi o liczbę rejestrów, tzn. wszystkie wartości przechowywane są w tzw. rejestrach wirtualnych. Zadaniem alokatora jest zmapowanie teoretycznie nieskończonej ilości rejestrów wirtualnych na skończoną liczbę fizycznych rejestrów maszyny. Oczywistym jest, że alokacja rejestrów nie może zmienić semantycznego znaczenia instrukcji programu. Teoria grafów a teoria kompilacji Pierwszego matematycznego opracowania problemu przydziału rejestrów dokonał G. J. Chaitin w publikacji pt. Register allocation via graph coloring. Udowodnił on, że program alokacji rejestrów jest izomorficzny ze znanym już wówczas problemem kolorowania grafu. Złożoność obliczeniowa wyznaczania minimalnego kolorowania grafu dla dowolnego grafu jest problemem NP.-zupełnym, jednak istnieją pewne
przypadki szczególne, tzw. doskonałe grafy, dla których złożoność jest już tylko wielomianowa. Dla ustalenia uwagi, przez k-kolorowanie grafu będziemy rozumieli takie oznaczenie wszystkich wierzchołków grafu (korzystając z k kolorów), aby żadne sąsiadujące wierzchołki nie miały tego samego koloru. W oparciu o kolejność porcji instrukcji kompilator tworzy szereg grafów zależności (ang. interference graph ). Każdy wierzchołek w grafie reprezentuje tymczasową wartość, z kolei każda krawędź (t1,t2) wskazuje że zmienne t1 i t2 nie mogą zostać przypisane do jednego rejestru, ponieważ obie wartości są używane równocześnie. Stąd, widzimy, że zbiór wartości tymczasowych oraz wzajemne zależności między tymi wartościami odpowiadają pewnemu grafowi z krawędziami pomiędzy wierzchołkami, które na siebie wzajemnie oddziaływają. Chcemy otrzymać taki podział rejestrów, dla którego żaden z dwóch wzajemnie oddziaływujących wierzchołków nie jest przypisany do tego samego rejestru. Problem łatwo zredukować do problemu kolorowania grafu k kolorami, gdzie k odpowiada ilości rejestrów, tzn. chcemy wykonać takie kolorowanie, dla którego każdym dwóm sąsiadującym wierzchołkom przypisany jest różny, inny kolor. W przypadku, gdy takie kolorowanie nie istnieje kompilator nie jest w stanie przypisać wszystkich wartości tymczasowych do rejestrów z zachowaniem wzajemnych oddziaływań. Aby zapewnić późniejszą poprawność kompilowanego programu niezbędnym jest odesłanie pewnych wartości do pamięci, które w czasie wykonywania programu będą wymieniane przy użyciu instrukcji load i store. Konsekwencją występowania pewnego opóźnienia w dostępie do pamięci w porównaniu z czasem dostępu do rejestrów jest kara, jaką przesłania te reprezentują, ponoszoną przez skompilowany program w czasie wykonania. Ponieważ przesłania wymuszają zwiększenie się ilości kodu obsługującego odzyskiwanie przesłanych wartości oraz mogą zwiększyć liczbę wartości tymczasowych prowadzi się pracę w celu takich ulepszeń algorytmów, które pozwolą na zmniejszenie ilości przesłań wartości tymczasowych do pamięci. Prace te mają silny związek z architekturą maszyn, dla których buduje się kompilatory. Przykładowymi rozszerzeniami algorytmu Chaitin a są na przykład algorytmy: Briggs a czy Callahan-Koblenz a. Algorytm Chaitin a Schemat blokowy:
Algorytm jest podzielony na cztery fazy: 1) Build Konstrukcja grafu zależności wykorzystująca informacje o czasie życia zmiennych dodawanie krawędzi pomiędzy zmiennymi, które żyją jednocześnie. 2) Simplify Pierwszy krok to inicjalizacja pustego stosu, a następnie usuwanie z grafu kolejnych wierzchołków, które są lokalnie kolorowalne, tzn. mają mniej niż k sąsiadów. Wierzchołki usuwane są, aż do zaistnienia jednej z dwu możliwych sytuacji: i. Graf jest pusty przejście do fazy Select ii. Pozostałe wierzchołki nie spełniają warunku lokalnej kolorowalności kontynuowanie algorytmu w fazie Spill 3) Select Przypisanie kolorów wierzchołkom grafu. Kolejno, jeden po drugim, wierzchołki są zdejmowane ze stosu, utworzonego w fazie Simplify i na nowo wkładane do grafu zależności. Po włożeniu wierzchołka do grafu, przypisuje się mu jeden z k kolorów, w ten sposób, aby żaden z jego sąsiadów nie miał przypisanego takiego samego koloru. 4) Spill Faza ta jest przeprowadzane, jeżeli w fazie Simplify nie udało się usunąć wszystkich wierzchołków z grafu. Jeden z pozostałych wierzchołków jest przesyłany do pamięci oraz przed każde użycie zmiennej przypisanej do tego wierzchołka wprowadzane jest instrukcja LOAD oraz instrukcja STORE po każdej definicji. Następnie proces alokacji jest restartowany począwszy od fazy Build. Zapis algorytmu w postaci pseudokodu: Dla grafu G i k dostępnych kolorów wykonaj następujące kroki: 1) Usuń z G wierzchołek v, którego ilość sąsiadów jest mniejsza niż k i odłóż go na stos 2) Usuń wszystkie krawędzie wychodzące v i powtarzaj krok (1) dopóki nie zachodzi któryś z następujących warunków: a) Graf G jest pusty b) Nie ma wierzchołka z mniej niż k sąsiadami 3) Jeśli spełniony warunek (2a) to pokoloruj graf ściągając ze stosu wierzchołki v odtwarzając strukturę grafu, przypisując wierzchołkowi v taki kolor c, którym nie jest pokolorowany żaden z sąsiadów v 4) Jeśli spełniony (2b) wybierz wierzchołek v przeznaczony do przesłania do pamięci (jest to wierzchołek, który blokuje dalszą procedurę, czyli ten, który ma więcej niż k sąsiadów), usuń go z grafu i rozpocznij od początku
Przykłady: 1) Budowa grafu zależności: (faza Build algorytmu Chaitin a) Mając daną instrukcję: x = y++ + z, rozkładamy ją na poszczególne instrukcje elementarne, otrzymując: #1: leaf y #2: operate #1 + 1 #3: assign y := #2 #4: leaf z #5: operate #2 + #4 #6: assign x := #5 Powiązania pomiędzy instrukcjami możemy zobrazować za pomocą takiego grafu: 2) Działanie algorytmu dla przykładowego grafu. Zadaniem jest pokolorowanie poniższego grafu trzema kolorami. Dla danego grafu wykonujemy fazę Simplify, kolejno wybierając wierzchołki o liczbie sąsiadów mniejszej niż 3 i odkładając je na stosie, np.:
Postępujemy tak, aż do zatrzymania się algorytmu z powodu braku dalszych wierzchołków z liczbą sąsiadów mniejszą niż 3. Ponieważ dalsze działanie algorytmu blokuje wierzchołek #8 to odsyłamy go do pamięci i rozpoczynamy od proces początku, tym razem nie uwzględniając już tego wierzchołka. (faza Spill) Tym razem faza Simplify zatrzymuje się tym razem na pustym grafie, wszystkie wierzchołki są odłożone na stosie. Przystępujemy do fazy kolorowania. Zdejmując kolejne wierzchołki przypisujemy im odpowiednie kolory (wybierając z palety trzech barw), w ten sposób, aby nie powodować konfliktów, tzn. żeby żaden z wierzchołków nie był pokolorowany identycznie ze swoim sąsiadem. (faza Select algorytmu Chaitin a) W efekcie otrzymaliśmy graf pokolorowany trzema kolorami, co równoznaczne jest z przydziałem 3 rejestrów. Podczas działania wykonane zostało jedno przesłanie do pamięci, co w zaowocuje dodatkowymi instrukcjami odczytu i zapisu zmiennej w kodzie wynikowym programu.
Efekt końcowy działania algorytmu Chaitin a dla wejściowego grafu. Podsumowanie: Prezentowany algorytm jest prawidłowy, ale niestety niekompletny. Łatwo znaleźć przykład grafu, który jest k-kolorowalny, a dla którego metoda na pewno zawiedzie. Na przykład dla grafu reprezentującego czworokąt (ad. Rysunek), który z pewnością jest kolorowalny 2 kolorami algorytm zawodzi. Algorytm posiada też inne niedoskonałości, nie jest on zorientowany na przepływ sterowania, co uwydatnia się podczas kompilowania kodu pętli. [???] Niewątpliwą zaletą algorytmu Chaitin a jest fakt, że przy efektywnej reprezentacji grafu działa on w czasie liniowym w stosunku do ilości wierzchołków (dla grafów, dla których istnieje k-kolorowanie) Jak już było wspomniane istnieją rozszerzenia algorytmu Chaitin a zorientowane na poprawę wydajności jego działania. Na zakończenie możemy tu przytoczyć prace dowodzące, że algorytm byłby wydajniejszy na etapie linkowania, a nie kompilacji. Pozwala to na efektywniejszą alokację rejestrów na przykład dla procedur znajdujących się w różnych modułach, a korzystających z tych samych zmiennych globalnych.
Bibliografia: 1. Global Register Allocation at Link Time David W. Wall. 2. Register Allocation in Gardens Point Compilers K.J. Gough, J. Ledermann 3. Revisiting Graph Coloring Allocation K. Cooper, A. Dasgupta, J. Eckhardt 4. Generalizing Chaitin s Algorithm: Graph-Coloring Register Allocation for Irregular Architectur J. Runeson, S. Nystrom 5. A combined algorithm for graph-coloring in register allocation M. Allen, G. Kumaran, T. Liu