Leibniz Universität Hannover Fachgebiet für Programmiersprachen und Übersetzer Masterarbeit Optimierung der Java-Bytecode-Erzeugung für gerichtete azyklische Graphen Michael Mainik Betreuer: M. Sc. Torben Wichers Erstprüfer: Prof. Dr. R. Parchmann Zweitprüfer: Prof. Dr. H. Vollmer Hannover, März 2009 Danksagung Hiermit möchte ich meinem Betreuer Torben Wichers für die Unterstützung und Geduld während der gesamten Arbeit danken. Ich erkläre hiermit, dass ich die vorliegende Arbeit selbstständig angefertigt und keine anderen als die angegebenen Quellen und Hilfsmittel verwendet habe. Hannover, den 27.03.2009 Michael Mainik Kurzfassung Der von Compilern erzeugte Maschinencode ist oft in Bezug auf Laufzeit oder Speicherverbrauch nicht optimal. Verschiedene Optimierungsansätze versuchen deshalb die Laufzeit zum Beispiel durch Verminderung von Speicherzugriffen oder durch Erkennung und Einschränkung von identischen Berechnungen zu verbessern. Ein bereits häufig untersuchtes Problem ist die Entfernung mehrfacher Ausdrücke, das auch als common subexpression elimination“ bekannt ” ist. Die optimale Codeerzeugung für Programme, die mehrfache Ausdrücke enthalten gilt als NP-vollständig. Im Rahmen dieser Arbeit werden verschiedene Lösungsansätze zur optimalen Berechnung von mehrfachen Ausdrücken auf der JavaVM als Zielarchitektur untersucht. Dabei soll auch die Komplexität der common subexpression“-Problematik ausführlich beschrieben ” werden. Die Untersuchung ist in zwei Phasen aufgeteilt, wobei in der ersten Phase Möglichkeiten zur effizienten Speicherung der Werte von mehrfachen Ausdrücken und in der zweiten Phase die Bestimmung des optimalen Pfades zur Berechnung eines solchen Ausdrucks analysiert werden. Die Speicherung der Werte von mehrfachen Ausdrücken auf dem Stack ist somit nur für kleinere Teil-Ausdrücke geeignet, da andernfalls der Stack nicht effizient ausgenutzt wird. Zur Bestimmung des optimalen Berechnungspfades wurden sog. collabierbare“ DAGs, als eine ” Untermenge aller DAGs untersucht und Wege zur effizienten Auswertung nicht-collabierbarer“ Graphen analysiert. Die gewonnenen ” Erkenntnisse führten zur Entwicklung eines Dynamic ProgrammingAlgorithmus, der optimierten Stackcode in linearer Laufzeit generiert. Inhaltsverzeichnis 1 Einleitung 1.1 Motivation 1.2 Aufgabe . 1.3 Ergebnisse 1.4 Gliederung . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 2 Grundlagen 2.1 Stackarchitektur . . . . . . . . . . . . . . . . . . . 2.2 Die Java Virtual Machine . . . . . . . . . . . . . 2.3 Ausdrucksbäume und DAGs . . . . . . . . . . . . 2.4 Optimierte Codeerzeugung für Stackarchitekturen 2.5 Problematik der gemeinsamen Ausdrücke . . . . . 3 Optimierte Codeerzeugung 3.1 Die Wahl der Kosten . . . . . . . . . . . . 3.2 Lokales Auslagern . . . . . . . . . . . . . . 3.3 Belassen auf dem Stack . . . . . . . . . . . 3.3.1 Vorstellung der Problematik . . . . 3.3.2 Koopmans Stack Scheduling . . . . 3.3.3 Vergleich zum dfpo-Durchlauf . . . 3.4 Lokal vs. Stack . . . . . . . . . . . . . . . 3.5 Optimale Durchlaufordnung . . . . . . . . 3.5.1 Minimierung der Stacktiefe . . . . . 3.5.2 Alternative Reihenfolge für Bäume 3.5.3 Alternative Reihenfolge für DAGs . 3.5.4 Lösungsansätze . . . . . . . . . . . 4 Ein 4.1 4.2 4.3 4.4 Dynamic Programming-Ansatz Berechnung eines Ausdrucksbaumes . . Berechnung eines DAGs . . . . . . . . Berechnung nicht-collabierbarer DAGs Laufzeit und Speicherverbrauch . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 3 3 4 5 6 . . . . . 7 7 9 13 15 19 . . . . . . . . . . . . 22 22 25 29 29 32 34 36 41 41 45 47 52 . . . . 55 56 62 72 77 5 Der JBCG-Zwischencode 79 5.1 Einführung . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 79 5.2 Erweiterung . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 82 1 Inhaltsverzeichnis 5.3 2 Vor- und Nachteile . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 6 Implementierung 6.1 Durchlauf und Entkopplung . . . . . . 6.2 Erweiterung des Modells . . . . . . . . 6.3 Dynamic Programming-Ansatz . . . . 6.3.1 Tabellen und Matrizen . . . . . 6.3.2 Kombination der Tabellen . . . 6.3.3 Identifizierung wichtiger Knoten 6.3.4 Stackcodeerzeugung . . . . . . . 6.4 Zukünftige Weiterentwicklung . . . . . 6.5 Fazit . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 84 86 . 86 . 89 . 90 . 92 . 93 . 96 . 99 . 103 . 104 7 Ergebnisse 105 7.1 Zusammenfassung . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 105 7.2 Ausblick . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 108 1 Einleitung 1.1 Motivation Ein Entwickler gibt in einen Editor eine Abfolge von Befehlen in einer Programmiersprache ein und führt das Programm aus. Der zugehörige Compiler analysiert dabei die eingegebene Befehlsreihenfolge und generiert einen semantisch äquivalenten Maschinencode. Jedoch gibt es zu einem in einer Hochsprache geschriebenen Programm eine unendliche Anzahl von semantisch äquivalenten Maschinencodes. Werte werden unnötig oft gespeichert und gelesen, oft genutzte Werte werden nicht erkannt und mehrmals neu berechnet oder es werden überflüssige Berechnungen angestellt. Compiler erzeugen oft einen nicht optimalen Code. Um solche Schwächen bei der Übersetzung zu entdecken und zu beseitigen werden eine oder mehrere zusätzliche Phasen in den Übersetzungsvorgang eingeführt, in denen der auszuführende Code auf mögliche Optimierungen hin untersucht wird. Die meisten Compiler wandeln das eingegebene Programm in einen Zwischencode um, welcher vor der Ausführung optimiert wird. Dabei haben sich mit der Zeit eine Menge interessanter Ansätze entwickelt, um ein auszuführbares Programm kompakter, ressourcenschonender und schneller zu machen. Globale Optimierungstechniken analysieren den gesamten Datenfluss im Programm, um zum Beispiel, feststellen zu können, ob eine Variable später im Programm noch gebraucht wird oder ob der ihr zugewiesene Speicher freigegeben werden kann. Lokale Optimierungsverfahren dagegen betrachten sequenzielle Befehlsabfolgen, sog. basic blocks, die sich aufgrund der stark eingeschränkten Sicht auf den Programmablauf deutlich einfacher optimieren lassen. Eine der lokalen Optimierungstechniken, der das Hauptinteresse dieser Arbeit gilt, ist die Entfernung bzw. Zusammenfassung von Ausdrücken, die mehrfach in einem Codeabschnitt vorkommen, so dass diese nicht mehrmals berechnet werden müssen. Die Schwierigkeit dieser Optimierung liegt darin, dass die Berechnung und Speicherung des Wertes eines solchen Ausdrucks beim erstmaligen Vorkommen im Code nicht immer den optimalen Maschinencode ergibt. Außerdem ist es aufgrund von Unterschieden in Architekturen von Rechnern nicht einfach zu entscheiden, wo die berechneten Werte solcher common subexpression“ gespeichert werden sollen. Eine der Hauptmotivationen ist ” demnach die Untersuchung von Verfahren zur optimalen Berechnung solcher Ausdrücke. Die common subexpression elimination“-Problematik wurde in der Vergangenheit so” wohl für Register- als auch Stackarchitekturen untersucht. Bruno und Sethi [BruSet76] zeigten bereits 1976, dass das Problem der optimalen Codeerzeugung für mehrmals genutzte Ausdrücke NP-vollständig ist. Aho, Johnson und Ullman [AJU76] bewiesen, dass 3 1 Einleitung 4 die Problematik sogar für Programme mit nur einem mehrfach genutzten Ausdruck und mit einer unendlichen Anzahl von Registern, also unendlichen Ressourcen, NPvollständig bleibt. Jedoch stellen Prahabla und Sethi in [PraSet80] ein Verfahren vor, welches die Problematik für eine bestimmte Untermenge von Programmen, die sich durch sog. collabierbare Graphen“ darstellen lassen, effizient löst. ” Besonders für Stackarchitekturen ist das Problem sehr interessant, da Stacks keine wahlfrei zugreifbaren Speicher sind und oft eine beschränkte Größe besitzen. Nach ihrem Nischendasein bis in die Mitte der 1990er erhielten Stackarchitekturen mit der komplett objektorientierten Programmiersprache Java und der zugehörigen Java Virtual Machine (JVM) sowohl in der Wirtschaft als auch in der Forschung wieder mehr Beachtung. Stackarchitekturen werden oft in embedded“-Systemen aufgrund der recht einfachen ” Adressierung und sich daraus ergebenden Kompaktheit des Codes verwendet. Java und die JVM schafften es erstmals eine breitere Masse an Forschern und Entwicklern wieder für die Stackarchitektur zu begeistern, die nach Wegen suchen, die Ergebnisse der langjährigen Arbeit an Registermaschinen auch auf Stackarchitekturen anwenden zu können. 1.2 Aufgabe Das Ziel dieser Arbeit ist die Untersuchung der möglichen Ansätze zur optimalen Erzeugung von Java-Bytecode für Programme, die sich durch gerichtete azyklische Graphen (directed acyclic graphs, DAGs) darstellen lassen. DAGs lassen im Vergleich zu traditionellen Ausdrucksbäumen für jeden Knoten ungleich der Wurzel mehr als einen Elternknoten zu, wodurch mehrere Pfade zu diesen Knoten möglich werden. Aufgrund dessen eröffnen sich neue Möglichkeiten zur Optimierung der durch die DAGs repräsentierten Programme. Das Hauptinteresse gilt hier also der bekannten common subex” pression“-Problematik, die als NP-vollständig gilt. Die folgende Abbildung zeigt einen Ausdrucksbaum und einen DAG: Abbildung 1.1: Kompliziertere Optimierung Für den Ausdrucksbaum werden zwei mögliche Programme vorgestellt, die dasselbe Ergebnis 1 + (2 ∗ 3) liefern. Das zweite Programm hat jedoch einen zusätzlichen Befehl 1 Einleitung 5 swap“, der die Berechnung mit nur zwei anstatt von drei Stackpositionen erlaubt. Man ” hat das Programm also in Bezug auf Stackpositionen optimiert wodurch es aber länger wurde. Im Falle von DAGs kommen zusätzlich mehrere Pfade zu einem mehrfachen Ausdruck dazu (rote und grüne Kante), was die Anzahl von unterschiedlichen Programmen, die denselben Wert berechnen erhöht und dadurch die Optimierung noch komplexer macht. Neben den Möglichkeiten der Optimierung der Java-Bytecode-Erzeugung von DAGs sollen auch die Grenzen dieser Optimierungstechniken aufgezeigt und erläutert werden, um zukünftigen Forschungen ein detailliertes Bild der Problematik und der bisher entwickelten Lösungsansätze zu geben. Die Stackarchitektur der Java Virtual Machine (JVM) mit ihrem speziellen Befehlssatz macht dieses Vorhaben zu einer interessanten Herausforderung. Die Erzeugung von DAGs und des zugehörigen optimierten Java-Bytecodes soll mit Hilfe des in [Pra08] entwickelten Zwischencode-Frameworks durchgeführt werden. Dieses Zwischencodemodell strukturiert beliebigen Code in basic blocks und macht damit die Anwendung lokaler Optimierungstechniken besonders elegant. Das Framework soll außerdem erweitert und von der Bytecode-Erzeugung und deren Optimierung entkoppelt werden. 1.3 Ergebnisse Im Rahmen dieser Arbeit wurden verschiedene Ansätze zur Optimierung der Stackcodeerzeugung für Programme, die sich durch beliebige DAGs darstellen lassen ausführlich analysiert, kombiniert und erweitert. Die Untersuchung der Möglichkeiten zur Speicherung einmal berechneter Werte ergab, dass der Stack sich dafür nur eingeschränkt eignet, wenn die Größe des Hardware-Stacks begrenzt ist, was in realen Architekturen der Fall ist. Jedoch ist die Nutzung des Stacks in Kombination mit dem Speicher durchaus von Vorteil, da auf diese Weise kleinere Teil-Ausdrücke vollständig auf dem Stack berechnet werden können. Das verringert die häufig aufwendige Kommunikation mit dem Speicher und nutzt den vorhandenen Hardware-Stack besser aus. Für die Bestimmung des optimalen Pfades durch einen DAG, in dem ein mehrfacher Ausdruck berechnet werden soll eignen sich collabierbare“ DAGs besonders gut, da sie ” keine Abhängigkeiten zwischen mehrfachen Ausdrücken beinhalten, die sich nicht effizient optimieren lassen. Obwohl die Häufigkeit des Auftretens von nicht-collabierbaren“ ” DAGs in der Praxis noch nicht untersucht wurde, ist die optimale Behandlung solcher DAGs durchaus von Interesse. In Abschnitt 4.3 wurden deshalb Techniken zur Erkennung und Behandlung nicht-collabierbarer“ DAGs präsentiert. ” Die gewonnenen Erkenntnisse mündeten in der Entwicklung eines Dynamic ProgrammingVerfahrens, welches die Erzeugung von Java-Bytecode in Bezug auf die zur Ausführung eines Programms benötigten Bytes und eine begrenzte Stacktiefe optimiert. Der Algo- 1 Einleitung 6 rithmus läuft abhängig von der Anzahl der Knoten n in linearer Zeit und Speicher O(n). Für die Analyse und Entwicklung unterschiedlicher Optimierungstechniken wurde das JBCG-Zwischencodemodell [Pra08] um die Objektorientierung erweitert und in zwei unabhängige Module gegliedert. Die Gliederung des Zwischencodemodells in Objektstruktur und Java-Bytecodeerzeugung ermöglicht besseres Pflegen und Erweitern sowohl des Frameworks als auch der Optimierungen. 1.4 Gliederung Diese Arbeit ist insgesamt in sieben Kapitel gegliedert. Nach der Einleitung werden im zweiten Kapitel Grundlagen über Stackarchitekturen, Ausdrucksbäume und DAGs sowie Problemstellungen bei der optimalen Stackcodeerzeugung erläutert. Im dritten Kapitel werden Techniken zur Nutzung einmal berechneter Werte von mehrfachen Ausdrücken vorgestellt und miteinander verglichen. Außerdem wird in Abschnitt 3.5 die Problematik der optimalen Durchlaufordnung durch einen DAG ausführlich diskutiert. Das vierte Kapitel beschreibt die Entwicklung eines Dynamic Programming-Ansatzes zur Bestimmung der optimalen Berechnung eines DAGs auf einem Stack begrenzter Größe. In Kapitel fünf wird kurz das JBCG-Zwischencodemodell vorgestellt, welches in einer früheren Arbeit [Pra08] entwickelt wurde und als Teil dieser Arbeit erweitert und für die Umsetzung der untersuchten Optimierungsalgorithmen verwendet wurde. Im sechsten und vorletzten Kapitel werden die für die Implementierung des Dynamic Programming-Ansatzes und der Erweiterungen des Zwischencodemodells gewählten Herangehensweisen und Ideen beschrieben. Das letzte Kapitel fasst die gewonnenen Erkenntnisse zusammen und beschreibt mögliche Forschungsrichtungen für die Zukunft. 2 Grundlagen In diesem Kapitel sollen dem Leser alle zum Verständnis der angewandten Techniken und deren Ergebnissen nötigen Grundlagen vermittelt werden. In 2.1 wird zunächst die Idee und Funktionsweise des Stacks beschrieben. 2.2 stellt die Stackarchitektur der Java Virtual Machine vor. In 2.3 und 2.4 folgen die Beschreibung der Darstellungen von Programmen in Form von Graphen und die Optimierungen der durch sie repräsentierten Programme. 2.5 schließt das Grundlagenkapitel mit einer ausführlicheren Beschreibung der common subexpression“-Problematik ab. ” 2.1 Stackarchitektur Bevor man direkt auf die spezifischen Probleme der Codeerzeugung für Stackmaschinen eingeht, ist es notwendig, die Idee und Funktionsweise des Stacks und der auf ihm möglichen Operationen zu kennen und zu verstehen. Im Folgenden wird neben dem Grundaufbau des Stacks die Entstehungsgeschichte von Stackarchitekturen und deren heutige Anwendungsbereiche kurz vorgestellt. Der Stack, auch als Kellerspeicher bekannt, ist eine Speicherstruktur für eine begrenzte oder beliebige Anzahl von Elementen, zum Beispiel Operanden einer arithmetischen Operation, auf welchen verschiedene Berechnungen durchgeführt werden können. Die Besonderheit des Stacks im Vergleich zu anderen Speicheranordnungen ist die Reihenfolge mit der die Elemente gelesen oder geschrieben werden können. Nur das Element, welches zuletzt auf den Stack geschrieben wurde, darf als erstes wieder entnommen werden. Der Stack ist also eine last-in-first-out-Warteschlange (LIFO). Um den Stack schreiben oder lesen zu können bedarf es zwei Grundoperationen: • push: Schreibt ein Element auf die oberste Position des Stacks und macht somit dieses Element lesbar. • pop: Gibt das oberste Element des Stacks zurück, wobei es vom Stack entfernt wird. Nun ist es nicht möglich nur mit diesen zwei Befehlen jegliche Berechnungen durchzuführen. Deshalb besitzen Stackarchitekturen eine Reihe zusätzlicher Befehle, die entweder den Stackinhalt verändern, arithmetische oder logische Berechnungen durchführen oder die Position des TOS-Zeigers (Top of the Stack) verändern (branch/jump-Befehle). Operationen auf den Stackelementen verwenden je nach Art der Operation und je nach 7 2 Grundlagen 8 Datentyp der Operanden die obersten n Elemente des aktuellen Stackinhaltes, wobei diese bei der Berechnung vom Stack entfernt werden und das Ergebnis der Operation auf die oberste Position des Stacks geschrieben wird. Die folgenden Beispiele zeigen einige der gebräuchlichsten Stackbefehle: 1 int i = 1 + 2 ; Abbildung 2.1: Addition auf dem Stack Abbildung 2.2: Funktionsweise von swap und dup x1 In der Abbildung 2.1 wird eine Addition durchgeführt und ihr Ergebnis lokal gespeichert. Hierfür müssen die Summanden auf den Stack geschrieben werden bevor der Befehl zur Addition iadd“ angewendet werden kann. Dabei muss der linke Summand vor dem rech” ten auf den Stack geschrieben werden. Für dieses Beispiel dürfte man auch die Operanden in einer anderen Reihenfolge auf den Stack schreiben, da die Addition kommutativ ist. Bei nicht-kommutativen Operationen, wie der Subtraktion oder der Division, würde eine andere Reihenfolge auch ein anderes Ergebnis bedeuten. Stackmanipulationsbefehle wie swap“ oder dup x1“ erlauben im begrenzten Maße ei” ” ne Abweichung vom LIFO-Prinzip, indem sie die obersten Elemente vertauschen (swap) oder das oberste Element tiefer in den Stack schreiben (dup, dup x1, dup x2) (siehe Abbildung 2.2). Mit Hilfe dieser Befehle werden erst Optimierungen bei der Codeerzeugung möglich. Eine ausführlichere Beschreibung der Optimierungsmöglichkeiten mit Hilfe der Stackmanipulationsbefehle findet in 3.3.1 und 3.5.2 statt. Bevor aber die für diese Arbeit relevante Stackarchitektur der JVM näher vorgestellt wird, folgt ein kurzer Abriss in die Entstehungsgeschichte der Stackmaschinen. Aufgrund der Verbreitung der Programmiersprache Algol“ Ende 1950er entstand großes ” Interesse an Architekturen, die auf die Sprache zugeschnitten waren. So brachte ein 2 Grundlagen 9 Team unter der Führung von Robert Barton 1962 samt Compiler und Betriebssystem MCP“ die Stackarchitektur Burroughs B5000“ heraus. Außerdem war das master ” ” ” control program“ (MCP) der B5000 das erste Betriebssystem, welches komplett in einer Hochsprache (Algol60) geschrieben wurde. Fast zur selben Zeit entwickelten auch Forscher in Australien (Charles Hamblin), Deutschland (F.L. Bauer und K. Samelson) und England (English Electric KDF9) ähnliche Ansätze oder Architekturen. Friedrich L. Bauer und K. Samelson patentierten 1957 das Kellerprinzip“ zur Übersetzung von Pro” grammiersprachen mittels eines Stapelspeichers. Ab Mitte der 1970iger rückten Stackarchitekturen aufgrund der Registermaschinen immer mehr in den Hintergrund bis Mitte der 1990iger Java und die JVM wieder das Interesse der Fachwelt in Stackarchitekturen weckten. Java repräsentiert heute eine gelungene Umsetzung einer objektorientierten, sicheren und plattformunabhängigen Programmiersprache. Stackarchitekturen finden oft, aufgrund ihrer Kompaktheit und im Vergleich zu Registermaschinen geringeren Komplexität, Anwendung im embedded controller“-Bereich ” und allgemein in vielen Bereichen wo kleine und portable Prozessoren gebraucht werden. Außerdem sind Stacks aus modernen Architekturen kaum mehr wegzudenken. Der call-stack“, zum Beispiel, speichert Informationen über aufgerufene Subroutinen, deren ” Parameter und Return-Adressen. Stacks sind allgemein wichtig für die Unterstützung rekursiver und innerer Funktionsaufrufe. 2.2 Die Java Virtual Machine Alle in dieser Arbeit durchgeführten Untersuchungen und entwickelten Techniken besitzen die JVM als Zielarchitektur. Dieser Abschnitt bietet einen kompakten Überblick über den Aufbau, die Funktionsweise und die Eigenarten dieser Stackarchitektur. In der Fachliteratur steht die Plattformunabhängigkeit als eine Eigenschaft von Java oft an erster Stelle. Diese Eigenschaft verdankt Java ihrer virtuellen Maschine“, die einen ” speziellen Zwischencode, den Bytecode, interpretiert und ausführt. Die JVM ist meist nur eine Software, die auf einer realen Hardware läuft und damit nicht nur ein hohes Maß an Portierbarkeit erlaubt, sondern auch die Ausführung der Programme sicher macht. Denn laufende Prozesse werden von der JVM überwacht, um zum Beispiel Zugriff auf unerlaubte Speicherbereiche zu unterbinden. Es existieren jedoch auch Prozessoren, die Java-Bytecode direkt auf der Hardware ausführen können. Das hat den Vorteil, dass die Übersetzung von Java-Programmen dadurch deutlich beschleunigt wird. Einige der entwickelten Java-Prozessoren sind zum Beispiel die Sun’s Java-Prozessor-Familie (picoJava, microJava, ultraJava), die Jazelle“” Erweiterung für ARM-Prozessoren oder die Atmel AVR32. Die folgende Abbildung zeigt die Bestandteile der JVM und ihre Abhängigkeiten: 2 Grundlagen 10 Abbildung 2.3: Aufbau der JVM Die Ausführung eines jeden Programms beginnt mit der Eingabe einer class-Datei. Eine class-Datei enthält stets genau eine Java-Klasse (oder Schnittstelle) und besteht aus einer Abfolge von Bytes. Die Struktur einer class-Datei ist genau festgelegt [JVMSpec] und enthält Informationen über Variablen, Methoden, Zugriffsrechte, Sichtbarkeiten und Oberklassen. Jedes Programm, welches auf der JVM ausgeführt werden soll, muss also zuerst in eine class-Datei konvertiert werden. Der class loader“ trägt die nötigen Klasseninformationen zusammen, die zur Ausführung ” benötigt werden, überprüft die class-Datei auf ihre Korrektheit (zum Beispiel Typprüfung), initialisiert Variablen, reserviert Speicher und löst Referenzen auf. Die Speicherbereiche der JVM (runtime data areas) werden in fünf Bestandteile gegliedert: Method Area: Enthält klassenspezifische Daten (zum Beispiel Typinformationen), existiert ein Mal pro VM und wird von allen Threads der VM genutzt. Heap: Enthält erzeugte Objekte, existiert ein Mal pro VM, Zugriff wird zwischen Threads synchronisiert, ungenutzter Speicher wird automatisch durch den Gargbage Collector freigegeben. Pc Registers (programm counter): Enthält die Adresse der aktuell ausgeführten Instruktion, existiert einmal pro Thread. Native Method Stacks: Dieser Stack wird zur Behandlung der nativen Methodenaufrufe benutzt. Native Methoden sind plattformabhängige Funktionen, die in Java nicht implementiert sind, aber verwendet werden können. 2 Grundlagen 11 Runtime Constant Pool: Repräsentiert die Symboltabelle einer Klasse, die in Form eines Feldes abgelegt ist. Java VM Stacks: Dieser Speicherbereich ist der interessanteste für diese Arbeit, denn er enthält unter anderem den Operanden-Stack, auf dem Berechnungen stattfinden. Jeder Thread der JVM bekommt seinen eigenen Java-Stack zugeordnet, auf den nur dieser Thread Zugriff hat. Jede Methode, die aufgerufen wird, erzeugt einen Stack Frame; reserviert also einen Bereich bestimmter Größe auf den obersten Positionen des JavaStacks. Der Stack Frame der aktuell ausgeführten Methode ist also zugleich der oberste Frame. Jeder Eintrag im Java-Stack ist 32 Bit lang. Alle Werte die 32 Bit oder weniger benötigen nehmen eine Position im Stack ein. 64 Bit Werte (wie double oder long) erhalten zwei Positionen. Ein Stack Frame ist außerdem in drei Bereiche gegliedert: lokale Variablen, Operandenstack und Frame-Daten. Alle lokalen Variablen werden auf dem Stack Frame in einem Feld abgelegt. Bei statischen Methoden sind die ersten Einträge im Feld stets die Übergabeparamter der Methode in der selben Reihenfolge wie in der Methodendefinition. Bei Klassenmethoden beinhaltet der erste Eintrag das Klassenobjekt. Die Reihenfolge der Speicherung für die restlichen Variablen ist nicht fest vorgeschrieben. Auf dem Operanden-Stack werden alle arithmetischen Operationen durchgeführt. Jede für eine Berechnung relevante Information und auch das Ergebnis werden hier gespeichert. Da die JVM keine Datentypen automatisch erkennt, muss der Compiler entscheiden, welche der typspezifischen Operationen zum Einsatz kommen. Der Befehl iadd“ ” geht zum Beispiel davon aus, dass sich zwei Integer-Werte als oberste Elemente auf dem Operanden-Stack befinden. Für dadd“ müssen es dagegen zwei Double-Werte sein, die ” vier oberste Stackpositionen einnehmen. Um statische Methoden aufrufen zu können, müssen die Parameter vor dem Aufruf auf dem Stack liegen. Diese werden in lokalen Variablen gespeichert, um in der aufgerufenen Funktion benutzt zu werden. KlassenMethoden benötigen neben den Übergabeparametern auch die entsprechende Objektreferenz auf dem Stack, welche vorher durch einen Konstruktoraufruf initialisiert sein muss. Der Bereich der Frame-Daten enthält zusätzliche Informationen, die für die Ausführung der Methode wichtig sind. So werden hier zum Beispiel die Referenzen auf die Exceptions und die Constant Pool-Tabellen oder auch verschiedene Debug-Informationen abgelegt. Der Stack Frame einer Methode wird vom Java-Stack entfernt sobald die Methode fertig ist oder sie durch eine Exception abgebrochen wurde. Execution Engine (EE): Die EE ist dafür verantwortlich die in den Speicher geladene class-Datei und damit den Bytecode auszuführen. Es gibt verschiedene Techniken, um den Bytecode zur Ausführung zu bringen. Das für die heutigen Verhältnisse zu langsame Interpretationsverfahren führt die Instruktionen aus, ohne sie in nativen Code zu übersetzen, was keine Optimierungen zulässt. Die Just-In-Time-Übersetzung (JIT) kompiliert aufgerufene Methoden in nativen Code, der optimiert und effizienter ausgeführt 2 Grundlagen 12 werden kann. Um plattformspezifische Funktionen nutzen zu können existiert das Native Method Interface (NMI), welches von jedem Nutzer individuell spezifiziert werden kann. Für die Entwicklung von konkreten Anwendungen spielen der Operanden-Stack und die auf ihm definierten Befehle eine zentrale Rolle, denn hier finden alle Berechnungen statt. Es scheint daher sinnvoll Optimierungstechniken speziell für diesen Bereich zu entwickeln. Dabei geben die definierten Befehle die Optimierungsmöglichkeiten vor (siehe 2.1 und 3.3). Die folgende Tabelle enthält eine Liste ausgewählter Stackbefehle, die in den folgenden Kapiteln für die Optimierung der Codeerzeugung verwendet werden: Befehl iconst n Bytes 1 iadd 1 dadd 1 iload 2 istore 2 pop pop2 dup 1 1 1 dup2 1 dup x1 1 dup2 x1 1 dup x2 1 dup2 x2 1 swap new 1 2 invokevirtual invokespecial 2 2 invokestatic 2 Auswirkung Schreibt eine Integer-Konstante n auf den Stack 0≤n≤5 Addiert die obersten zwei Stackelemente und schreibt die Summe auf den Stack Addiert die obersten vier Stackelemente wobei jeweils zwei einen Double-Wert darstellen Lädt den Wert einer lokalen Variablen auf den Stack Speichert das oberste Stackelement in eine lokale Variable und entfernt es vom Stack Entfernt das oberste Element des Stacks Entfernt die obersten zwei Elemente des Stacks Erstellt eine Kopie des obersten Stackelements und schreibt sie auf den Stack Erstellt eine Kopie der obersten zwei Stackelemente und schreibt sie auf den Stack Erstellt eine Kopie des obersten Stackelements und schreibt sie auf die dritte Position von oben Erstellt eine Kopie der obersten zwei Stackelemente und schreibt sie auf die dritte und vierte Position von oben Erstellt eine Kopie des obersten Stackelements und schreibt sie auf die vierte Position von oben Erstellt eine Kopie der obersten zwei Stackelemente und schreibt sie auf die vierte und fünfte Position von oben Vertauscht die obersten zwei Stackelemente Generiert ein Objekt und legt es als oberstes Element auf den Stack Ruft eine Instanzmethode auf Ruft eine Methode einer spezifischen Klasse auf, zum Beispiel Konstruktoraufruf Ruft eine statische Methode auf Tabelle 2.1: Ausgewählte JVM Befehle 2 Grundlagen 13 Der Grundbefehlssatz der JVM gleicht größtenteils allen anderen Stackarchitekturen. Unterschiede gibt es oft bei Stackmanipulationsbefehlen, die mehr oder weniger Zugriff auf die Elemente des Stacks erlauben. So besitzen zum Beispiel RTX-Architekturen den Forth-Befehl rot“, welcher den aktuellen Stackinhalt rotiert oder over“, welcher vom ” ” untersten Element auf dem Stack eine Kopie anfertigt und diese als oberstes Element auf den Stack schreibt. Optimierungen der Stackcodeerzeugung hängen also in gewisser Weise von der verwendeten Architektur ab. Da die JVM alle für eine Berechnung notwendigen Operanden auf dem Stack benötigt und das Ergebnis dieser Berechnung auch zunächst auf dem Stack gespeichert wird, ist die Stackzugriffsgeschwindigkeit der kritische Faktor bei Programmausführung. Die Elemente des Stacks wurden in der Vergangenheit vollständig im Hauptspeicher gelagert, was im Vergleich zu Registerarchitekturen deutlich höhere Zugriffszeiten bedeutete. Das stack buffering“ wurde eingeführt, wobei die obersten n Elemente des Stacks di” rekt in der CPU in den dafür vorgesehenen Registern gespeichert werden, wodurch auf diese Elemente mit voller Geschwindigkeit zugegriffen werden kann. Die restlichen Elemente liegen nach wie vor im Hauptspeicher. Da die heutigen Prozessoren zwischen 4 und 16 Stackelementen aufnehmen können, sind Optimierungstechniken, die den Stack nicht tiefer als den CPU-Stack“ werden lassen, besonders interessant. Die Stacktiefe ” beschreibt die Anzahl der Elemente auf dem Stack. Die Bezeichnung folgt daraus, dass Stacks normalerweise von höheren zu niedrigeren Speicheradressen wachsen und somit tiefer“ werden. ” 2.3 Ausdrucksbäume und DAGs Ein Ausdrucksbaum ist eine Darstellung der Auswertung eines beliebigen Ausdrucks oder einer Sequenz von Ausdrücken. Ein Ausdruck ist dabei ein berechenbares Konstrukt, das in einer bestimmten Programmiersprache erstellt wurde. Ausdrucksbäume sind in der theoretischen Informatik auch als Syntax- oder Ableitungsbäume bekannt, welche die Ableitung eines Wortes einer formalen Sprache nach den Regeln dieser Sprache repräsentieren. Definition 2.0: Ein Ausdrucksbaum A = (V, E, m, r) besteht aus einer geordneten Menge von Knoten V (vertices), die entweder die Operanden oder die Operationen des Ausdrucks repräsentieren. Dabei sei r ∈ V ein ausgezeichneter Wurzelknoten, der genau einmal in A existiert und von dem aus alle anderen Knoten ∈ V erreichbar sind. Für die Menge der Knoten V sei außerdem die Markierungsfunktion m : V → W definiert, die einem Knoten eine Markierung bestehend aus einer Abfolge von Zeichen w ∈ W zuordnet. Verbindungen zwischen den Knoten bilden die Menge der Kanten E (edges). In Ausdrucksbäumen sind Kanten ungerichtet und besitzen keinerlei Markierungen. Formal definiert ist E ⊆ {(u, v) | u, v ∈ V mit u 6= v}, wobei (u, v) ein Eltern-Kindpaar repräsentiert und u stets der v übergeordnete Knoten ist. Sei ferner GE (u), u ∈ V der Eingangsgrad eines Knotens, d.h. die Anzahl der Vorfahren, und sei GA (u), u ∈ V der 2 Grundlagen 14 Ausgangsgrad/Grad eines Knotens, d.h. die Anzahl der Nachkommen, so unterscheidet man zwischen drei Arten von Knoten: • Blattknoten (Operanden): GE = 1, GA = 0 • Innere Knoten (Operatoren): GE = 1, GA = n • Wurzelknoten (Operator): GE = 0, GA = n Die folgende Abbildung zeigt einen möglichen Ausdrucksbaum: Abbildung 2.4: Ein Ausdrucksbaum Definition 2.1: Existieren in einem Programm bestimmte Ausdrücke, die mehrmals auftreten, zum Beispiel als Teil-Ausdrücke, so lassen sie sich als ein Knoten mit mehreren Vorfahren darstellen. Da für Ausdrucksbäume max(GE ) = 1 gilt, benötigt man in so einem Fall eine allgemeinere Graphenstruktur. Hierfür eignen sich gerichtete azyklische Graphen (directed acyclic graphs oder DAGs). Diese erweitern einen Ausdrucksbaum um gerichtete Kanten, die stets vom Eltern- zum Kindknoten zeigen und heben die Beschränkung max(GE ) = 1 auf. Jedem Paar (u, v) ∈ E wird also eine Richtung von u nach v zugeordnet und es gilt max(GE ) = n, n ∈ N . Hierbei wird jedoch aus der Menge der Kanten E eine Multi-Menge, weil eine Kante (u, v) ∈ E mit u, v ∈ V mehr als einmal in E vorkommen darf. Sei u 7→ v ein Pfad von u nach v, für u, v, v 0 ∈ V , falls u = v oder (u, v 0 ) ∈ E und ∃ v 0 7→ v. Die Länge eines Pfades u 7→ v sei definiert durch: ( 0, u = v |u 7→ v| = 1 + |v 0 7→ v|, (u, v 0 ) ∈ E und ∃ v 0 7→ v Ein DAG A = (V, E, m, r) ist azyklisch, wenn für alle u, v ∈ V ∧ u = v kein Pfad u 7→ v existiert mit |u 7→ v| > 0. Die restlichen Eigenschaften von Ausdrucksbäumen treffen auch für DAGs zu. Das folgende Beispiel zeigt ein Programm mit einem mehrfach genutzten Ausdruck a und dem zugehörigen DAG: 2 Grundlagen 15 1 2 3 4 int int int int a = 2 + 2; b = 5 − a; c = a ∗ 6; result = b + c ; Abbildung 2.5: Ein DAG Der mehrfach verwendete Ausdruck wird hier durch einen Knoten mit zwei direkten Vorfahren repräsentiert. Das bedeutet, dass dieser Knoten von zwei Ausdrücken im Programm verwendet wird und deshalb optimalerweise nur einmal berechnet werden sollte. Die Untersuchung des optimalen Pfades in dem solch ein Knoten berechnet wird und die Weise, wie die restlichen Nutzungen auf den Wert zurückgreifen ist eine der Hauptmotivationen dieser Arbeit. 2.4 Optimierte Codeerzeugung für Stackarchitekturen Um möglichst optimalen Stackcode für ein durch einen Ausdrucksbaum repräsentiertes Programm zu erzeugen, muss man sich für eine bestimmte Durchlaufordnung des Ausdrucksbaumes entscheiden, d.h. welche Knoten in welcher Reihenfolge besucht und ausgewertet werden. Blattknoten repräsentieren konkrete Werte, die auf den Stack geschrieben werden. Innere Knoten und der Wurzelknoten sind Operationen, die auf den Stackinhalt angewendet werden, die Operanden vom Stack nehmen und das Ergebnis wieder auf den Stack schreiben. Da die Reihenfolge der Operanden auf dem Stack für nicht-kommutative Operatoren fest vorgeschrieben ist (siehe 2.1), müssen die Nachfolger eines Knotens in der geordneten Reihenfolge von links nach rechts auf den Stack geschrieben werden. Hierfür ist der Depth-first-post-order-Durchlauf (dfpo) eines Ausdrucksbaumes am besten geeignet, da der Ausdruck auf diese Weise direkt in der umgekehrten polnischen Notation auf den Stack geschrieben wird. Der dfpo-Durchlauf funktioniert folgendermaßen: 2 Grundlagen 1 2 3 4 5 6 7 16 dfpo ( v ) { mark( v ) foreach d i r e c t c h i l d o f v and d i r e c t c h i l d not marked dfpo ( d i r e c t c h i l d ) evaluate ( v ) } Für das Beispiel in der Abbildung 2.4 werden also zunächst die Werte a, b und c auf den Stack geschrieben. Da b und c die obersten zwei Elemente auf dem Stack sind, kann die Addition durchgeführt werden, wonach sich a und das Ergebnis der ersten Addition b + c auf dem Stack befinden. Die zweite Addition hinterlässt das Ergebnis von a + b + c auf dem Stack. Dieses kann nun durch eine Funktion auf den Bildschirm ausgegeben oder für weitere Berechnungen verwendet werden. Entscheidet man sich für eine alternative Durchlaufordnung aus Optimierungsgründen und werden deshalb Operanden nicht in der richtigen Reihenfolge auf dem Stack abgelegt, so bietet sich die swap“-Operation an, um die zwei obersten Stackelemente mitein” ander zu vertauschen. Ebenso können die dup x“-Operationen dazu verwendet werden, ” Elemente etwas tiefer (eins oder zwei Positionen) in den Stack zu kopieren. Die dritte Option korrekter Berechnung, trotz abweichender Berechnungsreihenfolge, wäre die lokale Speicherung der Werte von Teilbäumen, die nicht in dfpo bezüglich des gesamten Baumes berechnet wurden. Die so lokal gespeicherten Werte können dann im richtigen Moment geladen werden, so dass sie sich, trotz abweichender Berechnungsreihenfolge, in der richtigen Reihenfolge auf dem Stack befinden. Für die Codeerzeugung im Falle von einem DAG stehen dieselben Möglichkeiten wie bei Ausdrucksbäumen zur Verfügung. Jedoch stellt sich die Frage an welcher Stelle im Graphen ein Knoten mit mehr als einem Vorgänger berechnet werden soll und wie man dessen Wert speichert, wenn man ihn nicht mehrmals neu berechnen möchte. Eine ausführliche Diskussion dieser Problematik findet im nächsten Abschnitt statt. Ist das Ziel jedoch nicht nur die einfache Codeerzeugung, sondern strebt man auch eine effiziente Ausnutzung der Laufzeit und des Speichers an, so müssen zwangsläufig Optimierungstechniken angewendet werden. Zur Optimierung der Programmcodeerzeugung haben sich in der Vergangenheit viele interessante Ansätze entwickelt. Die Techniken lassen sich grob in globale und lokale Optimierungstechniken gliedern. Globale Optimierungen arbeiten mit dem Datenfluss des gesamten Programms und untersuchen unter anderem die Lebendigkeit von lokalen Variablen. Lokale Optimierungsalgorithmen arbeiten mit relativ kurzen Codeblöcken“, sog. basic blocks. ” Basic blocks sind sequentielle Codeabschnitte, die genau einen Eingangs- und einen Ausgangspunkt besitzen. Das bedeutet, dass innerhalb dieser Blöcke keine Ausdrücke Zieloder Quellpunkt von Sprunganweisungen sind. Eingangspunkte können zum Beispiel Funktionseingänge oder Exception-Handler sein. Endpunkte sind oft return-Anweisungen oder if-Abfragen. Funktions-/Methodenaufrufe gelten dabei nicht als Sprünge, da sie die 2 Grundlagen 17 sequentielle Eigenschaft eines basic blocks nicht beeinflussen. Basic blocks repräsentieren also die Knoten des Datenflussgraphen. Formal definiert sind basic blocks Sequenzen von Instruktionen, wobei jede Instruktion alle folgenden dominiert. Außerdem darf zwischen zwei auf einander folgenden Instruktionen keine andere ausgeführt werden. Im Falle von Java-Programmen lassen sich lokale Optimierungen besonders gut anwenden, da die basic blocks aufgrund des modularen Programmstils relativ kurz ausfallen und sich dadurch schon recht simple Optimierungstechniken anwenden lassen. Zu lokalen Optimierungstechniken, die auf basic blocks angewendet werden, zählen unter anderem constant folding“ (Auswertung konstanter Ausdrücke zur Übersetzungszeit) oder com” ” mon subexpression elimination“ (Erkennung und Auflösung gemeinsamer Ausdrücke). Abbildung 2.6: Basic blocks Die Abbildung 2.6 zeigt einen schematischen Datenflussgraphen eines Programms welches aus einer if-Abfrage, einem Funktionsaufruf und einer for-Schleife besteht. Das Programm besitzt sechs basic blocks, welche jeder für sich durch einen DAG darstellbar und optimierbar sind. Ein Compiler würde also ein im Editor geschriebenes Programm scannen, parsen und den Zwischencode erzeugen. Der Zwischencode ist dabei die Repräsentation des aktuellen Programms im Hauptspeicher, auf der Optimierungen durch- 2 Grundlagen 18 geführt werden können. Häufig wird für den Zwischencode eine Struktur ähnlich einem Ausdrucksbaum gewählt. Wenn in seiner Struktur keine basic blocks vorgesehen sind (im Gegensatz dazu siehe Kapitel 5), wird der Zwischencode in basic blocks aufgeteilt. Ein DAG-Erzeuger generiert für die Abfolge der Anweisungen eines basic blocks den entsprechenden DAG, der durch eine oder mehrere zusätzliche Phasen optimiert wird. Jeder basic block enthält“ also einen zu optimierenden DAG. Schließlich wird Maschinencode ” erzeugt und das Programm kann auf der Zielmaschine ausgeführt werden. Abbildung 2.7: Modulare Codeerzeugung Die Abbildung 2.7 zeigt nochmal zusammenfassend den modularen Aufbau der Codeerzeugung. Dieser Aufbau erlaubt beliebiges Austauschen und unabhängiges Anpassen aller Module und macht es somit möglich effizientere Codeerzeugungs- und Optimierungstechniken auf beliebige Sprachen und Architekturen anzuwenden. In dieser Arbeit soll jedoch hauptsächlich das Optimierungsmodul beschrieben werden. Was bedeutet es aber ein Programm oder einen Codeblock“ zu optimieren? Optimie” ren bedeutet zunächst entweder den Speicherverbrauch oder das Laufzeitverhalten eines Programms mit den vorhandenen Mitteln im Vergleich zu einer naiven“ Ausführung ” möglichst effizient zu gestalten. Bei der Optimierung der Laufzeit ist das Ziel die Vermeidung unnötiger Berechnungen, indem zum Beispiel Ausdrücke, die mehrfach vorkommen, wie der Aufruf von Funktion f im folgenden Beispiel, nur ein Mal berechnet werden. 1 2 3 4 int a = f ( 2 , 3 ) + 5 ; int b = f ( 2 , 3 ) + 6 ; int c = a ∗ b ; print ( c ) ; In dem Beispiel wird die Funktion f im Ausdruck für a und im Ausdruck für b jeweils mit denselben Parametern aufgerufen. Benötigt f für die Berechnung des Ergebnisses n Schritte, so würde man für die Ausgabe print(a*b) zwei Mal genau dieselben n Schritte durchführen, obwohl das Ergebnis bereits nach der ersten Berechnung bekannt ist. Hätte man das Ergebnis nach der ersten Berechnung gespeichert, so könnte man sich die zweite Berechnung vollständig sparen. Beansprucht der Aufruf von f die meiste Laufzeit des Programms, so erreicht man durch diesen Optimierungsschritt eine deutliche Verbesserung der Laufzeit. 2 Grundlagen 19 Bei der Optimierung des Speicherverbrauchs gilt es so wenig wie möglich in den Speicher auszulagern und bei der Ausführung eines Programms möglichst wenig Speicherplatz zu belegen. Diese Ziele erscheinen auf den ersten Blick gleich oder ähnlich, jedoch gibt es wichtige Unterschiede. So kann ein laufendes Programm den Platz ungenutzter Variablen freigeben und unnötige Erzeugungen von Variablen vermeiden. Im oberen Beispiel kann der Speicherplatz von a und b nach der Berechnung von c freigegeben werden, da a und b im späteren Programmverlauf nicht mehr auftauchen. a, b und c müssten unter genauerer Betrachtung sogar nicht unbedingt erzeugt werden, da der Ausdruck print((f(2,3) +5) * (f(2,3) +6) dasselbe Ergebnis liefert und a, b und c als lokale Variablen nicht mehr gebraucht werden. Architekturspezifische Optimierungen zielen auf die effiziente Nutzung von speziellen Ressourcen ab, die durch die unterschiedlichen Architekturarten bereitgestellt werden, wie zum Beispiel den Stack oder die Menge der Register und ihre Adressierung. So kann es im Falle des Stacks interessant sein ein Programm auf eine begrenzte Stacktiefe hin zu optimieren, wenn bekannt ist, dass ab dieser bestimmten Tiefe mit deutlichen Laufzeitoder Speicherplatzeinbußen zu rechnen ist. 2.5 Problematik der gemeinsamen Ausdrücke Mehrfach genutzte oder gemeinsame Ausdrücke können bekannterweise in realen Programmen häufig auftauchen. Deshalb ist die Beachtung solcher Ausdrücke für die effiziente Codeerzeugung unumgänglich. Obwohl das Problem der optimalen Codeerzeugung lange untersucht wurde und verschiedene Verfahren optimalen Code für unterschiedliche Architekturen effizient generieren [AhoJoh76, BurLas75, SetUll70], gilt das Problem der common subexpression elimination“ als NP-vollständig [BruSet76], sogar wenn die ” gemeinsamen Ausdrücke genau eine Operation repräsentieren und keine weiteren gemeinsamen Ausdrücke beinhalten. Auch für 1-Register- oder unendlich-Register-Maschinen bleibt das Problem NP-vollständig [AJU76] und somit auch für Stack-Architekturen. Es gibt in der Regel zwei Vorgehensweisen, wie man NP-vollständige Probleme behandelt: • Entwicklung oder Untersuchung der Anwendbarkeit von Heuristiken, die meist gute Lösungen liefern, aber theoretisch nicht optimal sind. • Suche nach einer Untermenge von Fällen, die sich effizient berechnen lassen und die möglicherweise relativ gut reale Fälle widerspiegeln. In dieser Arbeit werden beide Techniken angewendet, um der Lösung der Problematik möglichst nahe zu kommen und auch, um die Grenzen aufzuzeigen, die diese Aufgabe so schwierig machen. Bei einem dfpo-Durchlauf eines DAGs, ohne Knoten mit mehr als einem Vorgänger (im Folgenden gemeinsame Knoten“ genannt) gesondert zu behandeln, würde ein gemein” 2 Grundlagen 20 samer Knoten jedes Mal für jede eingehende Kante neu berechnet werden. Sind solche Knoten dazu noch tief verzweigt, d.h. der Berechnungsaufwand ist relativ hoch, so ist es besonders ineffizient diesen immer wieder neu zu berechnen. Die offensichtliche Lösung dieser Situation wäre also den gemeinsamen Knoten nur einmal zu berechnen und das Ergebnis für die restlichen Nutzungen zur Verfügung zu stellen. Wenn man zunächst von der optimalen Berechnung solcher Knoten absieht, stellt sich die Frage, wo der Wert eines mehrfach genutzten Ausdrucks gespeichert werden soll. Es gibt zwei Möglichkeiten das Ergebnis für die spätere Nutzung auf einer Stackarchitektur zu speichern: • Den Wert so auf dem Stack ablegen, dass er im richtigen Moment mit den zur Verfügung stehenden Stackoperationen erreichbar ist und sich außerdem in die richtige Reihenfolge mit den restlichen Operanden einer Operation bringen lässt. • Nach der Berechnung den Wert in einer lokalen Variable speichern und bei späterer Nutzung auf den Stack laden. Die erste Variante, also die Beibehaltung des Ergebnisses auf dem Stack, hat ihre Vorund Nachteile. Zwar wird dadurch die oft teurere Kommunikation mit dem Hauptspeicher verhindert, doch bieten die JVM-spezifischen Stackmanipulationsbefehle nur sehr begrenzte Möglichkeiten die Kopie des Ergebnisses auf dem Stack optimal zu platzieren (siehe Tabelle 2.1). Außerdem bedeutet die Beibehaltung der Kopien auf dem Stack automatisch eine höhere Stacktiefe, da sie untere Stackpositionen beanspruchen während andere Ausdrücke berechnet werden. Da Stack-Buffer/Hardware-Stacks nur eine begrenzte Anzahl von Elementen aufnehmen können, ist dies ein wichtiger Kritikpunkt für die optimale Stackcodeerzeugung. Ein Algorithmus, der Kopien von Werten gemeinsamer Knoten auf dem Stack speichert wird in Abschnitt 3.3 vorgestellt. Die zweite Möglichkeit besteht darin, das Ergebnis in einer lokalen Variable im Speicher abzulegen und jedes Mal wenn der Wert benötigt wird, ihn auf den Stack zu laden. Dies würde die Stacktiefe vergleichbar gering halten, da die Kopien nicht mehr auf dem Stack auf ihren Einsatz warten. Jedoch bedeuten Speicher- und Ladeoperationen je nach System mehr Aufwand und lassen unter Umständen, wie im Falle der JVM, den resultierenden Bytecode größer werden. So würde das Laden einer lokalen Variable zwei Bytes an Befehlscode benötigen und ein dup“-Befehl nur ein Byte, wenn man die ” Kopie auf dem Stack belässt. Das lokale Speichern von gemeinsamen Knoten wird in 3.2 ausführlicher diskutiert. Zwar berechnen die beiden Varianten gemeinsame Knoten nicht mehrmals, jedoch lohnt sich für manche Fälle der Zusatzaufwand nicht, da die Kosten für eine erneute Berechnung deutlich geringer ausfallen. Es ist zu beachten, dass es stark von dem Programm und dem zugehörigen DAG abhängt wie viele Bytes an Stackcode für die jeweilige Herangehensweise benötigt werden. Außerdem macht alleine die Speicherung der Werte von gemeinsamen Knoten für die spätere Nutzung das Verfahren nicht zwangsläufig optimal. Um zum Beispiel die maximale Stacktiefe eines Programms zu optimieren, lohnt es sich in manchen Fällen von der dfpo-Auswertungsreihenfolge des zugehörigen Graphen abzuweichen und bestimmte Teilausdrücke in einer anderen Reihenfolge zu berechnen. Da 2 Grundlagen 21 die optimale Reihenfolge schon für einfache DAGs nicht effizient berechenbar ist (siehe oben) werden in Abschnitt 3.5 mögliche Heuristiken und interessante Fälle vorgestellt und untersucht. 3 Optimierte Codeerzeugung Dieses Kapitel stellt einige Techniken zur optimierten Codeerzeugung unter Beachtung mehrfach genutzter Ausdrücke vor. Der Schwerpunkt ist zunächst die Art der Speicherung einmal berechneter Werte der Ausdrücke. Die Verfahren werden miteinander verglichen und mögliche Verbesserungen diskutiert. In späteren Abschnitten folgen Untersuchungen der Umsetzung alternativer Durchlaufordnungen, um Stackpositionen zu sparen und den optimalen Pfad zur Berechnung gemeinsamer Ausdrücke zu finden. 3.1 Die Wahl der Kosten Damit Verfahren, die der Optimierung der Codeerzeugung dienen sollen, auf ihre Leistungsfähigkeit überprüft werden können, benötigt man zunächst bestimmte Kriterien, an denen man die Leistung eines Optimierungsverfahrens ablesen“ kann. Es soll also ” herausgefunden werden, welche Eigenschaften eines Programms sich für die Beurteilung und den Vergleich verschiedener Optimierungsansätze eignen. Man spricht auch oft von der Wahl der richtigen Kosten, um vergleichen zu können, welches Verfahren teurer“ ” oder billiger“ bezüglich dieser Kosten ist. ” Theoretisch sind unendlich viele verschiedene Arten von Kosten für die Stackcodeerzeugung denkbar, zum Beispiel Laufzeit, Codegröße, Speicherzugriffe, Stackzugriffe, Stacktiefe usw. Jedes Kriterium wäre dabei mehr oder weniger stark von dem System, auf dem das Programm ausgeführt wird abhängig. Deshalb versucht man solche Kriterien auszuwählen, die vollständig von der Systemart unabhängig sind oder diejenigen, die auf den meisten Systemen gleich oder ähnlich sind. Außerdem ist es oft nicht möglich Kosten so zu wählen, dass sie vollständig die Realität widerspiegeln, weil man nur beschränkte Informationen über die tatsächliche Umsetzung bestimmter Grundfunktionalitäten hat oder es zu viele Faktoren gibt, die man berücksichtigen müsste. So ist es zum Beispiel bekannt, dass Stack-Buffer in der Regel eine begrenzte Anzahl von Werten aufnehmen können und den Rest im Hauptspeicher ablegen. Wie genau ist solch ein Stack-Buffer organisiert und wie wäre die Kostenrelation zwischen dem Schreiben auf den Stack-Buffer und dem Schreiben in den Hauptspeicher? Oder wie genau ist die dup x1“-Operation implementiert? Ist sie doppelt oder vier Mal so aufwendig ” wie eine push“-Operation? Es sind theoretisch viele solcher Fragen denkbar, die einen ” Einfluss auf die Wahl eines Kostenmodells hätten. Jedoch müssen Kostenmodelle auch nicht unbedingt die Realität vollständig repräsentieren, sondern können auf das konkrete 22 3 Optimierte Codeerzeugung 23 Problem zugeschnitten sein, so dass sie die Unterschiede der Verfahren, die man miteinander vergleichen möchte, korrekt in Relation gesetzt werden. Die tatsächlichen Kosten werden also soweit wie nötig abgeschätzt und da alle Ansätze nach denselben Kosten beurteilt werden, bleiben die Relationen erhalten. Man kann somit Optimierungstechniken miteinander vergleichen, ohne deren tatsächliche Kosten bis ins kleinste Detail zu kennen. Wie bereits in Abschnitt 2.4 eingeführt, gilt das Interesse dieser Arbeit der optimalen Erzeugung von DAGs, also Ausdrucksbäumen mit mehrfach genutzten Knoten/Ausdrücken. Da die Werte solcher Ausdrücke, einmal berechnet, entweder auf dem Stack oder im Hauptspeicher abgelegt werden können, um sie nicht noch mal berechnen zu müssen, sind die Unterschiede der Kosten für Stack- und Hauptspeicherzugriffe von zentraler Bedeutung. Da man davon ausgehen kann, dass jedes System und jede Stackarchitektur die Stackelemente unterschiedlich verwaltet, die Hauptspeicherzugriffszeiten stark variieren können und die Stackoperationen unterschiedlich implementiert sind, benötigt man ein Kostenmodell, das weitgehend von den Systemeigenarten unabhängig ist. Jeder Befehl der JVM hat eine Größe von 1 bis n Bytes. Das erste Byte ist dabei immer die Codierung des Befehls und die restlichen n−1 Bytes repräsentieren Parameter des Befehls, wie zum Beispiel die interne Nummer der lokalen Variable (siehe Tabelle 2.1). Die Anzahl der Bytes der JVM-Befehle ist auf jedem System gleich. Jedes Java-Programm ist eine Abfolge solcher Befehls-Bytes“. Die Gesamtheit der Bytes repräsentiert also auf al” len Systemen die Kompaktheit des Codes, denn je weniger Bytes benötigt werden, desto weniger Befehle und Zusatzinformationen braucht das Programm für die Ausführung. Definition 3.0: Sei c : B → N die Kostenfunktion, die für einen Befehl b ∈ B, mit B als die Menge der Befehle, die zugehörigen Kosten zurückgibt. Die folgende Tabelle zeigt eine Möglichkeit die Kosten für die Bewertung von Optimierungsalgorithmen zu definieren: Operation push dup dup x1 dup x2 swap store load c(Operation) 1 1 1 1 1 2 2 Tabelle 3.1: Mögliche Kosten Jeder Befehl wird also mit der Anzahl der Bytes, die zu seiner Ausführung benötigt werden, bewertet. Mit diesen Kosten wird nicht nur die Kompaktheit eines Programms gemessen, man macht auch gleichzeitig die Annahme, dass Speicherzugriffe generell teurer 3 Optimierte Codeerzeugung 24 als Stackoperationen sind. Das muss nicht auf alle Systeme zutreffen, ist jedoch eine begründete Annahme, wenn die meisten Berechnungen auf dem Stack-Buffer durchgeführt werden und die Kommunikation mit dem Hauptspeicher zwangsläufig aufwendiger ist. Befehle, die auf dem Stackinhalt ausgeführt werden, also z. B. Addition oder Funktionsaufrufe, erhalten absichtlich keine Kosten, obwohl sie genau so als Bytes im JavaBytecode vertreten sind. Denn das Ziel dieser Arbeit ist die Optimierung der BytecodeErzeugung mit einer begrenzten Stacktiefe. Das Interesse gilt also hauptsächlich der Anzahl der Elemente auf dem Stack und nicht den Operationen auf diesen Elementen. Das folgende Beispiel zeigt wie das Kostenmodell an einem DAG angewendet wird: Abbildung 3.1: Beispiel-DAG Für den DAG aus Abbildung 3.1 sollen die Kosten ermittelt werden, dabei soll verglichen werden, ob die Auslagerung des gemeinsamen Knotens a in den Speicher teurer“ ist als ” die zweifache Berechnung. So ergibt sich für einen dfpo-Durchlauf beginnend mit der Wurzel e für den linken Nachkommen c: c(c) = c(push) + c(b) = c(push) + c(push) + c(a) = 5 ∗ c(push) = 5 ∗ 1 = 5 Kostenpunkte (KP). Für den rechten Zweig von e ergibt sich: c(d) = c(a) + c(push) = 4 ∗ c(push) = 4 KP. Also kostet der gesamte DAG mit Wurzel e: c(e) = c(c) + c(d) = 9 KP. Speichert man den Wert des Knotens a nach der Berechnung lokal ab, so muss der Wert zuerst dupliziert und anschließend in den Speicher geschrieben werden, damit er für die erste Nutzung nicht geladen werden muss. Bei seiner Verwendung als linker Kindknoten von d muss der Wert aus dem Speicher geladen werden. So ergibt sich für den linken Zweig der Wurzel e c(c) = (c(push) + c(push) + c(a)) + (c(dup) + c(store)) = 5 + (1 + 2) = 8 KP. 3 Optimierte Codeerzeugung 25 Für den rechten Zweig ergibt sich: c(d) = c(load) + c(push) = 3 KP. Somit ist c(e) = 11 > 9 KP. Die Auslagerung von Knoten a ist also teurer“ als die erneute Berechnung nach dem ” gewählten Kostenmaß. Sind die store“- und load“-Operationen genau so teuer wie ” ” push“, also der Zugriff auf den Hauptspeicher genau so aufwendig wie auf den Stack, ” so sind die Kosten für die lokale Speicherung geringer. Die Wahl der Kosten und ihre Bedeutung beeinflussen also die Optimierungen entscheidend. Das zweite Kriterium für die Bewertung der Optimierungsalgorithmen sind die nötigen Stackpositionen für die Ausführung eines Programms, also die maximale Stacktiefe. Da Stack-Buffer in der Praxis nur eine begrenzte Anzahl von Elementen aufnehmen können (siehe Abschnitt 2.1), erwartet man von einem Programm, das sich komplett oder größtenteils auf dem Stack-Buffer berechnen lässt, dass es effizienter ist, als solche, die zusätzlich mit dem Hauptspeicher kommunizieren müssen. Die folgenden Abschnitte präsentieren und diskutieren unterschiedliche Optimierungsansätze für die common ” subexpression“-Problematik basierend auf den gewählten Kosten und auf der Annahme eines Stacks begrenzter Tiefe. 3.2 Lokales Auslagern Die einfachste Vorgehensweise, um Werte von gemeinsamen Knoten mehrfach nutzbar zu machen, ist diese Werte im Speicher zu lagern und jedes Mal wenn sie gebraucht werden auf den Stack zu laden. Hierfür muss das Verfahren wissen, wann es bei einem mehrfach genutzten Knoten angelangt ist und diesen bei der ersten Nutzung berechnen und in den Speicher schreiben. Nach dem Speichern des Wertes müssen die restlichen Nutzungen des gemeinsamen Knotens durch eine Ladeoperation des entsprechenden Wertes aus dem Speicher ersetzt werden. Die Verbindung zum gemeinsamen Knoten im DAG wird also aufgetrennt und an ihre Stelle kommen Blattknoten mit Ladeinformationen. Der berechnete Wert eines gemeinsamen Knotens bei der ersten Nutzung muss jedoch auf dem Stack liegen bleiben, da er sonst gleich wieder aus dem Speicher geladen werden müsste. Nach der ersten Berechnung ist also eine dup“-Operation notwendig, damit ” die Kopie in den Speicher geschrieben wird und nicht der originale Wert. Damit ein gemeinsamer Knoten seinen Eingangsgrad GE und die direkten Vorfahren kennt, wird entweder bei der Erzeugung des DAGs oder in einer Vorberechnungsphase jedem Knoten die Menge seiner direkten Vorfahren mitgeteilt. Bei dieser Technik (im folgenden LocVar genannt) fallen also im Vergleich zu einem dfpoDurchlauf einmalig pro gemeinsamen Knoten die zusätzlichen Kosten für eine dup“- und ” eine store“-Operation an. Für jede weitere Nutzung eines gemeinsamen Knotens fallen ” nur noch die Kosten für die load“-Operation an. Das bedeutet, dass sich diese Technik ” besonders lohnt wenn Folgendes gilt: 3 Optimierte Codeerzeugung 26 • Häufige Benutzung eines Knotens, da die store“-Kosten dann weniger ins Gewicht ” fallen. • Stark verzweigte oder tiefe gemeinsame Ausdrücke, da die relativ aufwendige Berechnung des gemeinsamen Knotens nur einmal durchgeführt wird. Das folgende Beispiel soll nun Schritt für Schritt zeigen, wie diese Technik funktioniert: 1 2 3 public s t a t i c int getSum( int a , int b , int c ) { return a + b + c ; } 4 5 6 7 public s t a t i c int getProd ( int a , int b , int c ) { return a ∗ b ∗ c ; } 8 9 10 11 12 13 14 15 int int int int int int int a = 6 ∗ 5; b = 2 + 2; c = 3 − 1; t 1 = getSum( a , b , c ) + 5 ; t 2 = 10 − getSum( a , b , c ) ; t 3 = getSum( a , b , c ) + getSum( a , b , c ) ; b l o c k R e s u l t = getProd ( t1 , t2 , t 3 ) ; Abbildung 3.2: Gemeinsame Nutzung eines Funktionsaufrufes In der Abbildung 3.2 ist ein DAG abgebildet, der einen gemeinsamen Knoten getSum() besitzt, der von insgesamt drei Operationen verwendet wird. Dieser Knoten repräsentiert einen Funktionsaufruf mit drei Parametern. Einer der Operanden, die auf getSum() zeigen, benutzt ihn zwei Mal. Es gilt also GE (getSum()) = 4. Der gemeinsame Knoten 3 Optimierte Codeerzeugung 27 besitzt drei Kindknoten, die jeweils zwei Blätter haben. Die Kosten zur Berechnung des gemeinsamen Knotens getSum() betragen somit 6 ∗ c(push) = 6 ∗ 1 = 6 KP. Würde dieser DAG ohne die Beachtung des gemeinsamen Knotens berechnet werden, so müsste jedes Mal bei seiner Verwendung der Aufwand von 6 KP aufgebracht werden. Insgesamt ergibt sich 4 ∗ 6 = 24 KP. Die Kosten für den gesamten DAG betragen damit 26 KP. Verwendet man hier aber die LocVar-Technik, so kommt man insgesamt auf 17 KP: 2 KP für die Konstanten 10“ und 5“, 6 KP für Berechnung des gemeinsamen Knotens, ” ” 3 KP für das lokale Speichern und 3 ∗ c(load) = 3 ∗ 2 = 6KP für die drei späteren Nutzungen, d.h. Nachladen der lokalen Variable. Die folgenden Abbildung zeigt, wie sich der Java-Bytecode während der Optimierung verändert: Abbildung 3.3: Veränderung des Stackcodes Durch die Optimierung des Programms entsteht also ein anderes Programm, welches dasselbe Ergebnis liefert, jedoch geringere Kosten oder Stacktiefe besitzt. Durch den Einsatz der LocVar-Technik werden außerdem weniger Stackpositionen für die Ausführung des Programms benötigt, denn alle bis auf die erste Nutzung des gemeinsamen Knotens benötigen nur noch eine Stackposition, da der Wert bereits berechnet wurde und im Speicher liegt. Das Beispielprogramm aus Abbildung 3.2 würde in einem dfpo-Durchlauf ohne die Beachtung von mehrfachen Nutzungen von getSum() maximal sechs und mit der lokalen Speicherung vier Stackpositionen benötigen. Wird getSum() jedes Mal neu berechnet, so liegen vor der Berechnung des rechten Kindknotens von t3 bereits drei Werte auf dem Stack. Durch die erneute Berechnung von getSum() kommen nochmals drei Werte auf den Stack, was die maximale Stacktiefe von sechs ergibt. Speichert man den gemeinsamen Knoten nach der Berechnung bei t1 lokal ab, so verringert sich die maximale Stacktiefe auf vier Positionen, da der rechte Zweig von t3 nur noch eine Position zum Laden des Wertes aus dem Speicher benötigt. Die LocVar-Technik verringert jedoch nicht für alle denkbaren Programme die Kosten und auch nicht immer die maximale Stacktiefe. Dies zeigt das folgende Beispiel: 3 Optimierte Codeerzeugung 28 1 2 3 4 int int int int a = 2 + 2; b = 5 − a; c = a ∗ 6; result = b + c ; Abbildung 3.4: Optimierung nicht nötig In diesem Fall wird ein gemeinsamer Knoten von nur zwei Operationen genutzt und berechnet sich mit relativ wenig Aufwand von 2 KP. Bei einem dfpo-Durchlauf ohne die Beachtung des gemeinsamen Knotens kommt man auf 6 KP und mit Beachtung auf 9 KP. Der gemeinsame Knoten kann also in diesem Fall aufgrund der relativ teuren Speicheroperationen genau so gut zwei Mal berechnet werden. Die maximale Stacktiefe beträgt hier sowohl bei dem LocVar-Verfahren als auch bei der naiven“ Vorgehensweise ” drei Stackpositionen. Liegt also ein Programm und der entsprechende DAG vor, so wird der DAG in dfpoOrdnung durchlaufen. Jeder besuchte Blattknoten, der eine Ladeoperation repräsentiert, setzt seine Kosten auf c(load) und die restlichen Blattknoten auf c(push). Jeder innere Knoten setzt seine Kosten auf die Summe der Kosten seiner Kindknoten. Die Kosten der Wurzel repräsentieren die Kosten des gesamten Programms. Wird ein innerer Knoten erreicht, der mehr als einen Vorfahren besitzt, so muss eine Entscheidung getroffen werden, ob sich die lokale Speicherung des Wertes dieses Knotens in Bezug auf die Kosten lohnt. Hierfür werden der Eingangsgrad des Knotens und die Kosten seiner Kinder herangezogen. Befindet man sich bei einem Knoten x mit GE (x) > 1 so lohnt sich das Speichern von x in einer lokalen Variable im Vergleich zur mehrfachen Berechnung nur dann wenn: c(x) + c(dup) + c(store) + c(load) ∗ (GE (x) − 1) < c(x) ∗ GE (x) Es lohnt sich also, wenn das Neuberechnen von x bei jeder Nutzung teurer ist als das Speichern und Laden. Ist jedem Knoten sein Eingangsgrad GE bekannt, so kann für jeden 3 Optimierte Codeerzeugung 29 inneren Knoten mit GE > 1 diese Entscheidung während der Codeerzeugung getroffen werden. In Bezug auf die maximale Stacktiefe können keine allgemeinen Aussagen wie bei den Kosten getroffen werden, da die Stacktiefe von der Lage des gemeinsamen Knotens im DAG und somit von der Durchlaufordnung abhängt. Es ist jedoch möglich die Stackanforderungen von einem DAG in einem extra Durchlauf zu bestimmen, um entscheiden zu können, welches Vorgehen zu einer geringeren maximalen Stacktiefe führt. Ein Markierungsalgorithmus, der die Stackanforderungen von Ausdrucksbäumen während der Codeerzeugung berechnet, wird in 3.5.1 vorgestellt und auf die Anwendbarkeit bei DAGs untersucht. Das LocVar-Verfahren benötigt bei einem DAG mit n Knoten O(2 ∗ n) = O(n) Schritte, da beim ersten Durchlauf des DAGs die gemeinsamen Knoten identifiziert werden und beim zweiten Durchlauf die Codeerzeugung stattfindet. Die Identifizierung gemeinsamer Knoten kann mit einem einfachen Zähler realisiert werden, der bei jedem Besuch eines Knotens erhöht wird. Nach der Berechnung eines gemeinsamen Knotens wird er als berechnet markiert und die Information für das Laden des Wertes kann direkt im Knoten abgelegt werden, so dass nicht jede Nutzung extra aktualisiert werden muss. Wird ein gemeinsamer Knoten ein zweites Mal besucht, so wird der Wert aus dem Speicher geholt, ohne den Knoten ein weiteres Mal zu berechnen. Das Verfahren arbeitet also in linearer Zeit. Außer den Knoten muss bei diesem Ansatz nichts anderes gespeichert werden, deshalb ist die Speicherklasse von LocVar ebenfalls O(n). 3.3 Belassen auf dem Stack 3.3.1 Vorstellung der Problematik In diesem Abschnitt wird ein Verfahren zum Speichern der Werte von gemeinsamen Knoten auf dem Stack vorgestellt. Wie in 2.1 und 2.3 bereits einführend erörtert, gestaltet sich das Ablegen von Werten auf dem Stack für die spätere Nutzung als besonders schwierig, da diese Werte vor der Berechnung in der richtigen Reihenfolge als oberste Elemente auf dem Stack liegen müssen. Hat eine Operation also n Operanden oder Argumente und ist die Operation nicht kommutativ, so liegen die Elemente in der umgekehrten polnischen Notation auf dem Stack. Liegt also ein gemeinsamer Knoten vor, so besitzt die JVM drei Befehle, um eine oder mehrere Kopien nach der Auswertung des Knotens für die spätere Nutzung auf dem Stack abzulegen: dup“, dup x1“ und ” ” dup x2“. Siehe Tabelle 2.1 für die Funktionsweise der Befehle. ” 3 Optimierte Codeerzeugung 30 Abbildung 3.5: Beispiel-DAG Möchte man den gemeinsamen Knoten x für einen der DAGs aus Abb. 3.5 nicht mehrmals berechnen und dafür seinen Wert nach der ersten Berechnung im dfpo-Durchlauf auf dem Stack ablegen, so benötigt man eine der dup“-Operationen und manchmal einen ” swap“-Befehl. Welche Operation jedoch eingesetzt wird, hängt davon ab wie weit“ die ” ” zweite Nutzung des mehrfachen Ausdrucks im DAG entfernt ist und wie viele Elemente vor der Berechnung von x bereits auf dem Stack liegen. Die Entfernung gibt die Anzahl der Knoten an, die vor der zweiten Nutzung eines mehrfachen Ausdrucks ausgewertet werden und ihre Werte dadurch auf dem Stack liegen. Für den ersten DAG aus Abb. 3.5 beträgt somit die Entfernung von der ersten Nutzung und Berechnung von Knoten x zur zweiten Nutzung 0, weil nach der Berechnung von x keine weiteren Knoten ausgewertet werden. Die Kosten für die optimierte Berechnung in diesem Fall betragen also: c(x) + c(dup) Die zweite Nutzung kann also einfach durch das Duplizieren des Wertes von x bedient werden, da nach der Berechnung von x keine anderen Elemente auf den Stack kommen. Im Falle des zweiten DAGs findet vor der zweiten Nutzung von x die Auswertung eines anderen binären Knotens y statt, welcher seinen Wert auf dem Stack ablegt. Die Entfernung zur ersten Nutzung ist somit 1, da der Wert von y über dem von x liegt. Eine einzige dup“-Operation reicht hier für die Optimierung deshalb nicht aus, weil auch die ” Kopie von x (rotes x) unter dem Wert von y liegt und deshalb bei z nicht ohne weiteres genutzt werden kann. Jedoch ist es möglich mit Hilfe von swap“ die Kopie von x und ” den Wert von y zu vertauschen, da diese beiden Elemente die obersten Elemente des Stack sind. Auf diese Weise kann der Wert von x bei z genutzt werden. Die Kosten bei einer Entfernung von 1 betragen also: 3 Optimierte Codeerzeugung 31 c(x) + c(dup) + c(swap) Beim dritten DAG beträgt die Entfernung ebenso 1, da auch hier der Wert von y vor der zweiten Nutzung von x auf den Stack kommt. Jedoch muss in diesem Fall die dup x1“” Operation angewendet werden, da vor der Berechnung von x bereits der Wert von z auf dem Stack liegt und ein einfaches dup“ nach der Auswertung von x die Berechnung von ” y behindern würde. Da in dem hier verwendeten Kostenmodell die Kosten für alle dup“” Operationen gleich sind, c(dup) = 1, sind die Kosten für die optimierte Berechnung dieses DAGs dieselben wie beim zweiten DAG aus Abb. 3.5. Analog kann auch die dup x2“” Operation zum Einsatz kommen, falls vor der Berechnung von x maximal zwei Elemente auf dem Stack liegen. Bei einer Entfernung > 1 kann nicht mehr mit einem einfach swap“ die Kopie an die ” richtige Stackposition verschoben werden. Leider besitzt die JVM keine weiteren Befehle, um Elemente, die tiefer im Stack liegen auf die oberste oder eine beliebige Positionen zu verschieben. Zwar existiert die swap2“-Operation, die zwei oberste Elemente mit ” zwei darunter liegenden vertauscht, jedoch ist das nur mit Datentypen, die nur eine Stackposition benötigen möglich. Bei long- oder double-Werten wäre mit diesem Befehl nur ein einfacher swap“ möglich. Für Datentypen, die zwei Stackpositionen belegen, ” existieren außerdem die Kopierbefehle dup2“, dup2 x1“ und dup2 x2“. ” ” ” Um Kopien von gemeinsamen Knoten korrekt verwenden zu können benötigt man also je nach Situation bestimmte Kopierbefehle und vor der Nutzung einer Kopie je nach Entfernung eine swap“-Operation. Jedoch lassen sich schnell DAGs finden, wo das Spei” chern der Kopien auf dem Stack aufgrund des beschränkten Befehlssatzes der JVM nicht möglich ist. Entweder kann der Wert nicht tief genug in den Stack kopiert werden, da schon mehr als drei Elemente auf dem Stack liegen, oder es ist nicht möglich die Kopie vor der Nutzung auf die richtige Position zu bringen, weil sie sich für den swap“-Befehl ” zu tief im Stack befindet. Abbildung 3.6: Unmöglich zu kopieren/ zu nutzen 3 Optimierte Codeerzeugung 32 Bei der Berechnung des linken DAG in Abbildung 3.6 im dfpo-Durchlauf kann der Wert von e nicht tief genug in den Stack kopiert werden, da unter dem Wert von e bereits drei Elemente liegen. Die Kopie würde die Berechnungen von d, c oder b behindern. Die verfügbaren Befehle der JVM können aber maximal zwei untere Elemente überspringen“ ” und den Wert auf diese Position kopieren. Man müsste also den Wert von e nochmals bei f berechnen oder lokal speichern. Im Falle des zweiten DAGs aus Abbildung 3.6 kann der Wert von d zwar auf den Boden des Stacks kopiert werden, jedoch liegen vor seiner Benutzung in Knoten c bereits die Werte b und 4“ auf dem Stack, so dass man d mit den Befehlen der JVM nicht mehr ” nutzen kann. Natürlich könnte man vor dem Schreiben von 4“ auf den Stack die swap“” ” Operation durchführen und nach dem Schreiben von 4“ nochmals die swap“-Operation ” ” anwenden, damit d doch noch in richtiger Reihenfolge liegt. Dieses lohnt sich aber von den Kosten her bereits nach wenigen swaps“ nicht mehr gegenüber einem normalen ” dfpo-Durchlauf oder im Vergleich zur lokalen Speicherung. Das Speichern der Kopien auf dem Stack ist also nur beschränkt möglich. Im Folgenden wird ein Verfahren vorgestellt, das vor dem Erstellen der Kopie des Wertes eines gemeinsamen Knotens auf dem Stack zuerst prüft, ob die Kopie tief genug in den Stack geschrieben werden kann und ob bei der Nutzung des Wertes dieser auch erreichbar ist. 3.3.2 Koopmans Stack Scheduling P. Koopmann präsentiert in [Koo92] eine Technik namens stack scheduling“, die als Ziel ” die Verringerung der Kommunikation mit dem Hauptspeicher hat. Wird eine Variable oder ein Wert in einem Programm mehrfach genutzt, so versucht die Technik Paare der Nutzungen dieser Werte zu finden. Dabei fertigt das erste Vorkommen eine Kopie seines Wertes für das zweite Vorkommen an, damit bei der zweiten Nutzung nicht erneut auf den Speicher zugegriffen werden muss. Aufgrund der meist sehr beschränkten Möglichkeiten, Elemente auf dem Stack abzulegen oder gar an eine gewünschte Stelle zu kopieren, werden Paare nach bestimmten Kriterien gesucht. So soll die erste Nutzung in der Lage sein, die Kopie seines Werte so auf den Stack abzulegen, dass die zweite Nutzung auf ihn zugreifen kann. In [Mai97] wird dieses Verfahren ausführlich diskutiert und erweitert. Da die Ziele dieser Technik und die in dieser Arbeit behandelte Problematik sehr ähnlich sind, nämlich Vermeidung von mehrfachen Berechnungen/Speicherzugriffen, wird im Folgenden untersucht, inwieweit sich das Verfahren für eine optimierte Berechnung von DAGs einsetzen lässt. Um die Suche nach solchen Paaren zu ermöglichen besitzt jede Operation (also jeder innere Knoten im DAG) eine Repräsentation des Stackzustandes vor der Ausführung dieser Operation. Jede Operation hat also Zugriff auf den Stackzustand vor der Ausführung der Operation. Denn nach der Ausführung der Operation existiert ein bestimmter Wert möglicherweise nicht mehr, weil die Operation für ihre Berechnung diesen benutzt und 3 Optimierte Codeerzeugung 33 somit vom Stack entfernt hat. Um Paare von gleichen Werten zu finden, werden vor jedem Laden eines Wertes die vorherigen Stackzustände nach diesem Wert durchsucht. Genauer gesagt: das am nächsten liegende Vorkommen, also das mit kleinster Entfernung, wird gesucht, da in diesem Fall das Kopieren und Nutzen auf dem Stack mit großer Wahrscheinlichkeit möglich ist. Jedes so gefundene Paar (liegt auf dem Stack - wird auf den Stack geladen) wird daraufhin untersucht, ob das Kopieren eines bestimmten Wertes so möglich ist, dass es vor dem späteren Laden verwendet werden kann. Ist das der Fall, so muss direkt nach dem Vorkommen des Wertes auf dem Stack ein passender Kopierbefehl und beim Laden je nach der Entfernung eine swap“-Anweisung eingefügt werden. ” [Mai97] erweitert das Verfahren insofern, als dass nicht nur das nächste Vorkommen des Wertes gesucht wird, sondern auch weiter weg liegende, im Falle einer nicht möglichen Kopie oder Nutzung des Wertes. Das folgende Beispiel zeigt anschaulich, wie das Verfahren funktioniert: Abbildung 3.7: Einfacher DAG Abbildung 3.8: Stackbilder aller Knoten Bevor für das in Abbildung 3.7 dargestellte Programm Stackcode erzeugt werden kann, wird der zugehörige DAG in einem dfpo-Durchlauf besucht und Stackbilder werden vor der Ausführung jeder Operation generiert (Abbildung 3.8). So liegen zum Beispiel vor der Berechnung von Knoten d 1“ und der Wert von g bereits auf dem Stack. Operation ” d verwendet diese Werte für ihre Berechnung und lässt danach ihren Wert auf dem Stack liegen. In der Stackcodeerzeugungsphase sind die Stackzustände direkt nach der Berechnung gemeinsamer Knoten interessant, weil zu diesem Zeitpunkt der Wert des gemeinsamen 3 Optimierte Codeerzeugung 34 Knotens auf dem Stack liegt und kopiert werden kann. Möchte man also entscheiden, ob der Wert von g auf den Stack kopiert werden kann, so dass er später auch erreichbar ist, betrachtet man zunächst das Stackbild vor der Berechnung von Knoten d, also nach Berechnung von g. Der Wert von g liegt als oberstes Element auf dem Stack und da nur ein zusätzliches Element unter dem Wert von g liegt, könnte man mit der dup x1“” Operation g auf den Boden des Stacks kopieren. Um zu untersuchen, ob man die Kopie bei Knoten e auch verwenden kann, betrachtet man den Zustand vor der Berechnung von e. Es liegt nur ein Element auf dem Stack und würde die Kopie unter diesem Element liegen, so könnte man sie mit der swap“-Operation vertauschen und die Kopie auf diese ” Weise nutzen. Würde jedoch der rechte Zweig von e den Wert von g benutzen, so wäre dies nicht mehr möglich. Knoten e muss also vor der Berechnung seiner Kindknoten entscheiden, ob er auf die Kopie auf dem Stack zugreifen könnte. Vor der Berechnung von f liegen bereits zwei Elemente auf dem Stack und der linke Kindknoten würde eine zusätzliche Position benötigen. Hätte man den Wert von e auf den Boden des Stacks kopiert, so könnte man ihn bei f nicht mehr benutzen. Der Wert von e muss also entweder lokal gespeichert oder erneut berechnet werden. Bei n Knoten im DAG und davon k ≤ n mehrfach genutzte sucht das Verfahren in O(k ∗ n) Schritten nach Paaren für das Kopieren der Werte der gemeinsamen Knoten. Sei D die maximale Stacktiefe so benötigt die Phase, in der alle Stackbilder berechnet werden eine Laufzeit von O(n) und O(n∗D) zusätzlichen Speicher für die Stackbilder, da für jeden Knoten der Stackzustand vor seiner Berechnung gespeichert wird. Im späteren Optimierungsdurchlauf werden O(n − k) nicht-gemeinsame Knoten besucht. Ist k = n, so arbeitet der Algorithmus in O(n)+O(n−k)+O(n∗n) = O(n2 ) Schritten und benötigt für die Knoten und deren Stackbilder O(n) + O(n ∗ D) = O(n ∗ D) Speicherplatz. Da k jedoch in praktischen Anwendungen << n ist, kann man in den meisten Fällen von einer linearen Laufzeitklasse ausgehen. 3.3.3 Vergleich zum dfpo-Durchlauf Ungeachtet der eingeschränkten Möglichkeiten der Technik für die Erstellung der Kopien auf dem Stack (im folgenden StackCopy genannt) verhält es sich mit den Kosten hier ähnlich wie beim LocVar-Verfahren: je öfter der gemeinsame Knoten verwendet wird und je verzweigter er ist, desto mehr lohnt es sich diesen zu kopieren und dadurch nicht nochmal berechnen zu müssen. Bei dieser Technik kommen jedoch außer den Kopierbefehlen ’“dup“,“dup x1“ und dup x2“ situationsabhängig swap“-Befehle hinzu, ” ” um die Kopien in die richtige Reihenfolge mit den restlichen Operanden der jeweiligen Operation zu bringen. 3 Optimierte Codeerzeugung 35 Abbildung 3.9: Unterschiedlich große gemeinsame Knoten Im Falle des ersten DAG der Abbildung 3.9 betragen die Kosten sowohl der dfpoBerechnung als auch bei der StackCopy-Technik 6 KP. Das Belassen auf dem Stack würde hier nach der Berechnung des gemeinsamen Knotens d die dup x1“-Operation ” benötigen, damit die Kopie des Ergebnisses auf den Stackboden geschrieben wird und die Berechnungen bis zu der Nutzung der Kopie nicht stört. Um die Kopie später benutzen zu können, muss die swap“-Operation durchgeführt werden, weil die Kopie vom ” Ergebnis von b verdeckt wird. Steigt der Ausgangsgrad GA oder die Tiefe des gemeinsamen Knotens wie im zweiten Beispiel, so erhöhen sich die Kosten der normalen Technik deutlich gegenüber dem StackCopy-Verfahren, weil die erneute Berechnung des Knotens nun deutlich aufwendiger ist als beim ersten Beispiel. Allgemein lohnt sich diese Technik im Vergleich zum dfpo-Durchlauf ohne Beachtung gemeinsamer Knoten nur, wenn für einen gemeinsamen Knoten x mit GE (x) > 1 gilt: c(x) + (c(dup) + c(swap)) ∗ (GE (x) − 1) < c(x) ∗ GE (x) Das bedeutet, dass die Berechnung von x und das Erstellen und Nutzen von Kopien mit dup“ und swap“ billiger als die erneute Berechnung ist. Dabei ist zu beachten, ” ” dass c(dup) + c(swap) den schlechtesten Fall repräsentiert, da ein swap“ nicht immer ” notwendig ist. Ob die swap“-Operation wirklich jedes Mal ausgeführt werden muss, ” hängt davon ab, ob der jeweilige Operator kommutativ ist und wie groß die Entfernung zum Knoten, der die Kopie angefertigt hat ist. Diese Aussage ist aber nur dann gültig, falls es überhaupt möglich ist den Wert des gemeinsamen Knotens so auf den Stack zu kopieren, dass er später genutzt werden kann. Dieses Verfahren zielt also im Gegensatz zu LocVar auf die Verminderung der Speicherzugriffe ab, indem man Werte, die später gebraucht werden auf den Stack und nicht in den Speicher kopiert. Das bedeutet, falls Stackoperationen wie dup x1“ und swap“ in ” ” gewissen Kombinationen billiger als entsprechende Operationen auf dem Speicher sind, so wäre dieses Verfahren günstiger. Da man jedoch die Werte, die man nicht doppelt 3 Optimierte Codeerzeugung 36 berechnen möchte, auf dem Stack behält, steigt auch je nach Programm dementsprechend die maximale Stacktiefe. Tatsächlich kann mit der oben beschriebenen Technik der Paarsuche, die maximale Stacktiefe pro gemeinsamen Knoten im DAG maximal um eine Position steigen. Der folgenden Abschnitt erklärt diese Tatsache etwas ausführlicher. 3.4 Lokal vs. Stack In diesem Abschnitt sollen die beiden bisher vorgestellten Techniken LocVar und StackCopy mit einander verglichen und Möglichkeiten zu deren Kombination untersucht werden. Beide Verfahren haben das Ziel, den berechneten Wert eines gemeinsamen Ausdrucks zu speichern und für alle Nutzungen dieses Ausdrucks zugänglich zu machen. Während LocVar den Wert direkt nach seiner Berechnung im Speicher ablegt, von wo aus er von allen anderen Ausdrücken genutzt werden kann, versucht StackCopy die Kopien der Werte auf dem Stack weiterzugeben, ohne den Speicher zu verwenden. Dadurch entstehen Unterschiede sowohl in den Kosten als auch in der resultierenden maximalen Stacktiefe eines Programms. Kosten: Wie oben eingeführt benötigt, jede Kopie eines gemeinsam genutzten Knotens mit der StackCopy-Technik jeweils eine der dup“-Anweisungen und möglicherweise eine swap“” ” Anweisung, um die Kopie an die richtige Position vor ihrer Verwendung zu bringen. Das ergibt pro Kopie Zusatzkosten von maximal c(dup) + c(swap) = 2 KP, also insgesamt 2 ∗ n KP, für n als die Anzahl der nicht-berechnenden Nutzungen. Würde man die Kopien in lokalen Variablen ablegen, so entstehen einmalig pro gemeinsamen Knoten c(dup)+c(store) = 3 und pro Kopie c(load) = 2 KP, also insgesamt c(dup)+c(store)+n∗ (c(load)). Nimmt man an, dass alle Kopien des gemeinsamen Knotens mit StackCopy auf den Stack so abgelegt werden könnten, dass sie im richtigen Moment auch erreichbar sind und dass jedes Mal dafür auch eine swap“-Operation benötigt wird, so unterscheiden ” sich die Kosten der beiden Techniken, LocVar und StackCopy, nach dem oben gewählten Kostenmaß nur in (3 + 2 ∗ n) − 2 ∗ n = 3 KP. LocVar wäre also immer um diese drei Punkte teurer, wenn StackCopy alle Kopien erstellen und nutzbar machen könnte. Müssen nicht alle Kopien durch ein swap“ in die richtige Reihenfolge gebracht werden, ” so wird StackCopy sogar mehr als drei Kostenpunkte billiger. Kann die StackCopy-Technik nicht auf alle Nutzungen des gemeinsamen Knotens angewendet werden, so steigen die Kosten solch eines Programms auf (c(x)) + (c(x) ∗ m) + ((c(dup) + c(swap)) ∗ (n − m − 1)) für x als gemeinsamen Knoten, n als die Gesamtanzahl der Nutzungen und m < n als die Anzahl der nicht bedienbaren Nutzungen, die den Wert von x neu berechnen müssen. Der erste Summand c(x) repräsentiert also die Kosten für die einmalige Berechnung 3 Optimierte Codeerzeugung 37 von x. Der zweite Summand repräsentiert die Kosten für die Neuberechnung bei allen m nicht bedienbaren Nutzungen und der letzte Summand die bedienbaren Nutzungen. Falls für die Kosten der Neuberechnung c(x) ∗ m > 3KP gilt, so wird LocVar günstiger, da sich StackCopy im Schnitt um mindestens 3KP von LocVar unterscheidet. Kann eine Kopie des Wertes eines gemeinsamen Knotens nicht so auf dem Stack abgelegt werden, dass sie auch genutzt werden kann, muss der Wert jedoch nicht unbedingt neu berechnet werden. In so einem Fall kann eine lokale Speicherung des Wertes sowohl die Kosten als auch die Stacktiefe gering halten. Existiert also eine Nutzung, für die keine Kopie erstellt werden kann, so wird der Wert lokal gespeichert und für die nachfolgenden Nutzungen, die nicht bedient werden können, zur Verfügung gestellt. Sei x der gemeinsamen Knoten, so würde dieses Vorgehen die Kosten einmalig pro gemeinsamen Knoten um c(store) + c(dup) = 3 und für jede Nutzung um c(load) − c(x) erhöhen. Das bedeutet wenn folgendes gilt: c(load) < c(x) und ((c(x) − c(load)) ∗ m) > (c(store) + c(dup)) das Laden des Wertes also billiger als die Neuberechnung ist und das Laden der m Werte die Kosten für die Speicherung wiedergutmachen. In solch einem Fall lohnt sich diese Erweiterung alleine schon von den Kosten her. Die Gesamtkosten der StackCopy-Technik mit Auslagerung der m nicht bedienbaren Nutzungen betragen also pro gemeinsamen Knoten x: (c(x)) + (c(dup) + c(store)) + (c(load) ∗ m) + ((c(dup) + c(swap)) ∗ (n − 1 − m)) Diese Formel enthält also die Kosten für das Berechnen von x, das lokale Auslagern und Nutzen bei nicht kopierbaren Fällen auf dem Stack und das Erzeugen der Kopien auf dem Stack. Das bedeutet, dass diese Technik immer billiger oder gleich teuer als LocVar ist, falls: c(StackCopy mit Erweiterung) ≤ c(LocVar) ⇔ (c(load) ∗ m) + ((c(dup) + c(swap)) ∗ (n − m − 1)) ≤ (c(load) ∗ (n − 1)) Denn wenn alle Nutzungen durch Stackkopien befriedigt werden, sind die Kosten von StackCopy um mindestens 3KP geringer (siehe oben). Falls nicht alle Nutzungen durch Kopien bedient werden können, werden sie durch lokale Variablen ersetzt. Geht man davon aus, dass jede Erzeugung und Nutzung einer Kopie auf dem Stack maximal c(dup) + c(swap) = 2KP kostet, so kann für das vorliegende Kostenmodell jede Nutzung des gemeinsamen Knotens durch eine load“-Anweisung ersetzt werden, falls ein ” nicht bedienbarer Knoten entdeckt wurde, denn im schlechtesten Fall gilt: c(load) == c(dup) + c(swap). Nach der lokalen Speicherung können auch die nachfolgenden bedienbaren Nutzungen durch load“ ersetzt werden. Für die obere Formel mit m > 0 würde ” also gelten: 3 Optimierte Codeerzeugung 38 (c(load) ∗ m) + ((c(dup) + c(swap)) ∗ (n − m − 1)) ≤ (c(load) ∗ (n − 1)) ⇔ m+n−m−1≤n−1⇔n−1≤n−1 Die LocVar-Technik kann also gleich nach dem Finden der ersten nicht bedienbaren Nutzung eingesetzt werden. Abbildung 3.10: Unmöglich zu kopieren Stacktiefe: Die Kosten, um den in der Abbildung 3.10 dargestellten DAG zu berechnen betragen bei der StackCopy-Technik 8 und bei der LocVar 11 KP. Jedoch ist hier die maximale Stacktiefe bei der StackCopy-Technik höher, nämlich 6 Positionen, da der bei Knoten b duplizierte Wert, bis zu seiner Nutzung im rechten Zweig von c, auf dem Stack verweilt, während d berechnet wird. Die LocVar-Technik würde die Kopie gleich nach der Berechnung von b lokal speichern, also vom Stack entfernen, und nach der Berechnung von d auf den Stack laden. Dies resultiert in einer maximalen Stacktiefe von 5 Positionen. Aufgrund der Implementierung der StackCopy-Technik, die Paare von Nutzungen gemeinsamer Knoten sucht (siehe Abschnitt 3.3) und für jeden gemeinsamen Knoten jeweils maximal eine Kopie auf dem Stack ablegt, unterscheidet sich die maximale Stacktiefe der beiden Techniken um n Stackpositionen, falls n die Anzahl gemeinsamer Knoten im DAG repräsentiert. Denn ähnlich wie für das Beispiel aus Abb. 3.10 ist es denkbar, dass n Kopien von Werten gemeinsamer Knoten auf ihre Nutzung warten und dementsprechend die Stacktiefe beeinflussen. Der folgende DAG zeigt solch einen Fall: 3 Optimierte Codeerzeugung 39 Abbildung 3.11: Kopie für jeden gemeinsamen Knoten Vor der Berechnung des Knotens d liegen also bereits drei Kopien der drei gemeinsamen Knoten a,b und c auf dem Stack, obwohl sie erst später gebraucht werden. Durch die Auswertung des tiefen Teil-DAGs von d auf diesem Stackinhalt wird die Stacktiefe unnötig erhöht. Dabei kann pro gemeinsamen Knoten maximal eine Kopie auf dem Stack liegen, weil der StackCopy-Algorithmus für jede Benutzung eines gemeinsamen Knotens den ersten passenden Knoten sucht, der den benötigten Wert tief genug auf den Stack kopieren kann, so dass ein anderer diesen nutzt. Das bedeutet, dass sich zu jeder Zeit maximal nur eine Kopie des Wertes eines gemeinsamen Knotens auf dem Stack befindet, bis diese gebraucht wird. Für alle nachkommenden Nutzungen wird eine Kopie der Kopie angefertigt. Die Situation, dass ein Knoten gleich zwei Kopien anfertigen muss kann bei dem verwendeten Algorithmus nicht vorkommen. Hierfür müsste die zweite Nutzung des gemeinsamen Knotens zwar die Kopie von dem ersten Knoten benutzen, aber nicht die Kopie für die dritte Nutzung anfertigen können. Beim ersten Besuch des gemeinsamen Knotens müssten also gleich zwei Kopien erstellt werden. Dies ist jedoch nicht möglich, da sowohl für die Nutzung als auch für die Erzeugung der Kopie eines Wertes dieser zu einem bestimmten Zeitpunkt als oberstes Element auf dem Stack liegen muss. Kann ein Knoten zwar die Kopie nutzen, den Wert aber für die nachfolgende Nutzung nicht so auf den Stack schreiben, dass auf ihn zugegriffen werden kann, so kann das auch kein früherer Knoten. Dieser würde die Kopie noch tiefer in den Stack schreiben. Sei m die Anzahl der nicht mit einer Kopie auf dem Stack bedienbaren Nutzungen eines der gemeinsamen Knoten und gelte m > 0, so kann die maximale Stacktiefe deutlich ansteigen, falls sich einige der m Knoten in großen Teilbäumen befinden. 3 Optimierte Codeerzeugung 40 Abbildung 3.12: Unterschiedliche Stacktiefen Sowohl bei dem ersten als auch bei dem zweiten DAG in Abbildung 3.12 kann die zweite Nutzung von Knoten b nicht durch eine Kopie auf dem Stack erfolgen. Beim ersten DAG befindet sich die zweite Nutzung in dem Teilbaum mit Wurzel d, der die maximale Stacktiefe bestimmt, und beim zweiten DAG befindet er sich in einem weniger tiefen Teilbaum. Wird also im Falle vom ersten DAG die zweite Nutzung erneut berechnet, so steigt die maximale Tiefe um GA (b) − 1, da die Neuberechnung den tiefen Teilbaum noch tiefer macht. Die Differenz GA (b) − 1 bedeutet, dass im Falle der Neuberechnung alle GA (b) Kindknoten von b auf den Stack geschrieben werden und während der Nutzung der Kopie nur eine Stackposition belegt wird. Im Falle vom zweiten DAG bleibt die maximale Stacktiefe gleich, da es einen größeren Teilbaum gibt, der die maximale Stacktiefe vorgibt. Würde man die gemeinsamen Knoten, die nicht alle Nutzungen mit Kopien auf dem Stack bedienen können, mit Hilfe der LocVar-Technik berechnen, so kann sich die maximale Stacktiefe im Vergleich zur Neuberechnung der Werte verringern. Für das Beispiel des ersten DAG aus Abbildung 3.12 wären es GA (x) − 1 Positionen weniger, wenn x einer der gemeinsamen Knoten ist. Denn auf den Wert im Speicher kann unabhängig von der Struktur des DAGs zugegriffen werden, wobei dieser stets nur eine Stackposition beansprucht (zwei bei double- oder long-Werten). Zur Minimierung der Stacktiefe verwendeten J. L. Bruno und T. Lassagne in [BruLas75] ebenfalls die lokale Auslagerung an geeigneten Stellen. So wird jeder Teilbaum, der eine bestimmte Tiefe überschreitet, in einer lokalen Variable abgelegt und damit seine Tiefe nach der Berechnung auf eine Stackposition reduziert. Die Funktionsweise des Verfahren und eine mögliche Anwendung auf DAGs wird im Abschnitt 3.5.1 untersucht. Zusammenfassend kann also festgehalten werden, dass die StacCopy-Technik stets günsti- 3 Optimierte Codeerzeugung 41 ger als die LocVar-Technik ist, falls alle Nutzungen eines gemeinsamen Knotens durch Kopien auf dem Stack bedient werden können. Ausgehend von dem hier gewählten Kostenmodell wird die StacCopy-Technik bei Auslagerung der nicht bedienbaren Nutzungen maximal gleich teuer wie das LocVar-Verfahren. Möchte man jedoch die Stacktiefe gering halten, so eignet sich die StackCopy-Technik weniger gut. Im schlimmsten Fall wird für jeden gemeinsamen Knoten je eine Kopie auf dem Stack gehalten, während das restliche Programm ausgeführt wird. LocVar ist demnach die bessere Wahl, weil die Werte gemeinsamer Knoten im Speicher und nicht auf dem Stack lagern. 3.5 Optimale Durchlaufordnung Wie in Abschnitt 2.3 bereits eingeführt geben zwar die Stackoperationen die Reihenfolge der Operanden vor, jedoch lohnt es sich manchmal die Reihenfolge der Berechnung von Operanden zu ändern, damit die Ausführung eines Programms beispielsweise weniger Stackpositionen beansprucht. Wird eine andere Reihenfolge gewählt, liegen die Operanden dann natürlich auch nicht in der richtigen Reihenfolge auf dem Stack und müssen deshalb je nach Grad des Elternknotens später geswapped“ oder sogar als lokale Va” riablen ausgelagert werden. Die folgenden Abschnitte präsentieren einige interessante Herangehensweisen, um DAGs durch die Änderung der Reihenfolge der Berechnung von Knoten in Bezug auf die Stacktiefe zu optimieren. 3.5.1 Minimierung der Stacktiefe J. L. Bruno und T. Lassagne stellten in [BruLas75] eine Technik vor, die Ausdrücke in Form von Bäumen effizient auf einem Stack begrenzter Tiefe berechnet. Das Ziel des Verfahrens ist es einen Ausdrucksbaum so auf einem Stack zu berechnen, dass nur eine bestimmte Anzahl D von Stackpositionen benötigt wird. Dabei kann D nicht kleiner sein als der maximale Grad max(GA ) eines Knotens im Ausdrucksbaum. Abbildung 3.13: Vorberechnung notwendig [BruLas75] 3 Optimierte Codeerzeugung 42 Der Ausdrucksbaum aus Abbildung 3.13 besitzt einen Knoten d mit GA (d) = 4 , der zu seiner Auswertung vier Stackplätze beansprucht. Würde man den gesamten Baum in der dfpo-Ordnung auswerten, so ergäbe sich eine maximale Stacktiefe von 5 Positionen. Da der maximale Grad im Baum max(GA ) = 4 ist, lässt er sich theoretisch mit vier Stackpositionen berechnen. Dies ist nur möglich, wenn der Knoten maximalen Grades vor allen anderen ausgewertet wird, während der Stack noch leer ist, also durch eine Änderung der Auswertungsreihenfolge. Würde man den Knoten d vor allen anderen berechnen und seinen Wert für die spätere Nutzung lokal speichern, so reichen für die Berechnung des Baums aus Abbildung 3.13 vier Stackpositionen. Das entspricht auch der Idee des Verfahrens von Bruno und Lassagne: Falls ein Knoten für seine Berechnung mehr Positionen benötigt als ein festgelegtes D mit D ≥ max(GA ), so wird die Auswertung des Kindknotens, der am meisten Stackpositionen beansprucht, vor das eigentliche Programm gelegt und sein Wert lokal gespeichert. Der Algorithmus arbeitet demnach in zwei Phasen, wobei die erste Phase für die Identifizierung und Behandlung zu großer Knoten und die zweite Phase für die Codeerzeugung zuständig ist. Wählt man also zum Beispiel für den Baum aus Abbildung 3.13 D = 4, so würde der Algorithmus beim Knoten b merken, dass dieser insgesamt fünf Stackpositionen benötigt. Deshalb würde die Berechnung von d vorgeschoben und seine Stackanforderungen auf eine Position gesetzt werden. Aus Knoten d wird also ein Blattknoten mit einer load“-Anweisung. Somit ergibt sich eine maximale Stacktiefe von vier, da Knoten ” d vier Positionen benötigt und b nach der Speicherung des Ergebnisses von d nur noch zwei Positionen. Der Algorithmus arbeitet mit der Hilfe eines Markierungsverfahrens, der die Stackanforderungen eines Knotens abhängig von den Kindknoten berechnet. Der Markierungsalgorithmus arbeitet bottom-up, wobei anfangs jeder Blattknoten mit einer 1“ (braucht ” also genau eine Stackposition) und jeder andere Knoten mit einer 0“ (noch unbe” kannte Anzahl von Positionen) markiert wird (rote Zahlen neben den Knoten). Seien V = {v1 , ..., vn } die Menge der direkten Nachfolger eines Knotens node, wobei v1 der am weitesten links liegende und vn am weitesten rechts liegende ist und gelte GA (node) = n, so markiert der Algorithmus die restlichen Knoten nach der folgenden Vorschrift: 1 2 3 while ( r o o t == 0 ) { f o r e a c h ( node i n dfpo−Ordnung ) { s t a c k = maxni=1 (vi + (i − 1)) ; 4 i f ( stack > D){ Ein vi mit vi + (i − 1) == s t a c k auswählen ; vi f ü r Vorberechnung vormerken ; vi = 1 ; } else node = s t a c k ; 5 6 7 8 9 10 11 } 12 13 } 3 Optimierte Codeerzeugung 43 Nachdem der Wurzelknoten mit einer Markierung 6= 0 versehen wurde, werden die für die Vorberechnung eingetragenen Teilbäume und der restliche Ausdrucksbaum in dfpoOrdnung ausgewertet und der Stackcode erzeugt. Nach diesem Vorgehen hätten die Knoten a, b, c und d in der Abbildung 3.13 nach der Initialmarkierung den Markierungswert 0“. Nach der ersten Iteration wäre d mit 4“ ” ” und c mit 2“ markiert (rote Zahlen durch Kommas getrennt). Nach der zweiten Iteration ” wäre b mit 5“ markiert, was im Falle von D = 4 zu einer Vorberechnung von d führen ” würde. Der Knoten d erhielte damit die Markierung 1“, da er nun nur einen Stackplatz ” einnähme und b somit die Markierung 2“. Der Knoten a würde anschließend auch ” mit 2“ markiert, was bedeutet, dass der modifizierte Baum mit zwei Stackpositionen ” berechnet werden kann. Das Verfahren lässt sich auch auf beliebige DAGs anwenden. Nur muss bei der Vorberechnung von Knoten, die für die maximale Stacktiefe D zu groß sind, darauf geachtet werden, ob sie gemeinsamen Knoten als Nachkommen besitzen. Diese müssen vor der Vorberechnung des Knotens ausgewertet und entsprechend behandelt werden. Wird für die Speicherung des Wertes des gemeinsamen Knotens die LocVar-Technik verwendet, so hat das keine Auswirkungen auf die Minimierung der Stackanforderungen, da die Kopien in den Speicher geschrieben werden. Im Falle der StackCopy-Technik kann jedoch eine Kopie des Wertes eines gemeinsamen Knotens die Minimierung verhindern, falls eine oder mehrere Kopien auf dem Stack liegen, während ein Unter-Baum, der D Stackplätze benötigt, ausgewertet wird. Allgemein wirkt das Ziel von StackCopy, Kopien auf dem Stack zu behalten dem Ziel dieses Verfahrens entgegen. Deshalb ist es in diesem Fall für die Speicherung der Werte von gemeinsamen Knoten nicht geeignet. Die Minimierung der benötigten Stackpositionen für die Berechnung eines DAGs hat selbstverständlich auch ihren Preis. So müssen im schlimmsten Fall alle inneren Knoten bis auf die Wurzel vorberechnet werden, falls alle inneren Knoten und die Wurzel den Grad GA = D haben. So hat das Verfahren im Vergleich zur LocVar-Technik und zum naiven“ Verfahren Mehrkosten von n ∗ (c(store) + c(load)) KP, für n als Anzahl der ” inneren Knoten. Eine erweiterte Version des Verfahrens berücksichtigt kommutative Operatoren und versucht die Reihenfolgen bestimmter Knoten zu vertauschen. Knoten, die am meisten Stackpositionen benötigen werden also möglichst links angeordnet, damit sie nicht berechnet werden müssen, während bereits Operanden auf dem Stack liegen. 3 Optimierte Codeerzeugung 44 Abbildung 3.14: Umordnung möglich [BruLas75] Der Ausdrucksbaum aus Abbildung 3.14 soll berechnet werden. Alle Knoten bis auf 4 und 1 wurden bereits markiert. Man sieht, dass Knoten 4 in der Anordnung vier Stackpositionen benötigen würde. Da dieser Knoten jedoch kommutativ ist (v ∈ V ∧ v ∈ C), wird Knoten 11 als der am weitesten links liegende angeordnet, was den Stackplatzbedarf auf drei Positionen senkt. Die Menge V repräsentiert in diesem Beispiel die Menge aller Knoten und C die Menge der kommutativen Knoten. Somit muss der Knoten 4 mit D = 3 nicht in eine lokale Variable ausgelagert werden. Die Umordnung geschieht durch die absteigende Sortierung aller Kindknoten nach ihren Stackanforderungen. Ist die Stackanforderung der neuen Anordnung ≤ D, so wird die Berechnung des Knotens dementsprechend geändert. Ansonsten wird der größte Kindknoten für die Vorberechnung vorgemerkt und im Baum zu einem Blattknoten umgewandelt. Für diese Erweiterung muss vor der Optimierung die Menge der kommutativen Knoten C bekannt sein. Der Algorithmus mit und ohne der Erweiterung ist besonders in Verbindung mit gemeinsamen Knoten interessant, da sie die Reihenfolge, in der Kindknoten berechnet werden ändert, falls die Änderung eine Verringerung der Stackanforderungen mit sich bringt. Durch die Änderung der Reihenfolge verändert sich auch das ursprüngliche Programm, weil Ausdrücke in einer alternativen Reihenfolge ausgewertet werden. Ist ein gemeinsamer Ausdruck ein Teil eines solchen Ausdrucks, bei dem die Auswertungsreihenfolge der Kindknoten modifiziert wird, so wird auch er in einer alternativen Reihenfolge berechnet. Die folgenden Abschnitte befassen sich ausführlich mit möglichen Änderungen der Berechnung von Kindknoten in einem DAG, um den Zeitpunkt in der Ausführung eines Programms zu dem ein gemeinsamer Ausdruck ausgewertet wird und somit die Stackanforderungen des Programms zu optimieren. Die zweite und letzte Erweiterung bezieht auch assoziative Operatoren mit ein. Dadurch wird nochmals die Anzahl der nötigen Auslagerungen zur Stacktiefenoptimierung und somit der Speicherzugriffe vermindert. Jedoch muss hier der Ausdrucksbaum unter Beachtung der assoziativen Eigenschaften von Operatoren umgeformt/transformiert werden. Dabei können Kindknoten eines bestimmten Knotens einen anderen Elternkno- 3 Optimierte Codeerzeugung 45 ten erhalten und dadurch die Semantik eines Ausdrucks verändern, wenn diese Kindknoten Teile eines gemeinsam genutzten Teilbaumes sind. Assoziative Transformationen würden die common subexpression“-Problematik unnötig komplizierter machen und ” werden deshalb in dieser Arbeit nicht weiter untersucht. 3.5.2 Alternative Reihenfolge für Bäume In Abschnitt 2.4 wurde bereits erwähnt, dass ein dfpo-Durchlauf sich am besten für die Auswertung von Ausdrucksbäumen eignet, da alle Operanden in der richtigen Reihenfolge auf den Stack geschrieben werden. Es existieren aber auch Situationen, wo eine Abweichung von dfpo von Vorteil sein kann. Befindet man sich zum Beispiel bei einem binären Knoten/Operator a, dessen rechter Kindknoten/Operand c zu seiner Berechnung mehr Stackpositionen benötigt als der linke, so gibt es zwei Möglichkeiten diesen binären Knoten auszuwerten. Abbildung 3.15: Einfacher binärer Baum Berechnet man den linken Nachfolger b zuerst, so benötigt dieser eine Stackposition und lässt seinen Wert auf dem Stack, der einen Stackplatz einnimmt. Der rechte Nachfolger c benötigt zwei Positionen, da aber bereits der Wert von b auf dem Stack liegt, sind insgesamt max(1 + 0, 2 + 1) = 3 Stackplätze zur Ausführung notwendig. Wird c jedoch zuerst ausgewertet, so benötigt er auch zwei Stackpositionen. Da aber in dem Moment der Stack noch leer ist, bleibt es insgesamt bei diesen zwei Plätzen. Während der Auswertung von b liegt der Wert von c bereits auf dem Stack, also werden insgesamt max(2 + 0, 1 + 1) = 2 Stackplätze benötigt. Da die Werte der beiden Operanden b und c aber nun in falscher Reihenfolge auf dem Stack liegen, müssen sie mit Hilfe der swap“-Operation vertauscht werden. ” Definition 3.1: Sei A = (V, E, m, r) ein Ausdrucksbaum, so berechne R : V → N die Stackanforderungen eines Knotens v ∈ V . Die minimalen Stackplatzanforderungen für die Berechnung eines binären Knotens a mit den Nachfolgern b und c, ohne die Nutzung des Speichers, lassen sich also mit der folgenden Formel berechnen: R(a) = min(max(R(c), R(b) + 1), max(R(b), R(c) + 1)) | a, b, c ∈ V 3 Optimierte Codeerzeugung 46 Eine mögliche Implementierung von R ist der in 3.5.1 vorgestellte Markierungsalgorithmus. Analog zum Verfahren von Bruno und Sethi aus dem vorherigen Abschnitt kann also ein beliebiger Ausdrucksbaum bottom-up ausgewertet werden. Dabei kann bei jedem binären Knoten, dessen rechter Nachfolger mehr Stackpositionen beansprucht als der linke, eine Änderung der Reihenfolge in der Abarbeitung der Kindknoten vorgenommen werden. Dadurch lässt sich jeder Ausdrucksbaum ohne die Nutzung des Speichers in Bezug auf die Stacktiefe optimal berechnen. Jeder Binärknoten, der sich für eine Änderung entscheidet, muss außerdem nach der Berechnung des zweiten Nachfolgers den swap“-Befehl ausführen. Die Minimierung der Stacktiefe ist mit dieser Vorgehens” weise jedoch nicht möglich, denn wenn für das Beispiel aus Abbildung 3.15, b ebenso wie c zwei Stackpositionen benötigt, so ergeben beide Varianten eine Stacktiefe von drei, obwohl mit der Nutzung des Speichers zwei Positionen möglich sind. Im Falle von Operatoren mit mehr als zwei Operanden, zum Beispiel Funktionsaufrufe mit n Parametern, sind die eingeschränkten Möglichkeiten der swap“-Operation zu ” beachten. Denn swap“ kann nur die obersten zwei Elemente des Stacks miteinander ” vertauschen. Ein Element auf der Position > 2 von oben kann also nicht mehr auf die oberste Position gebracht werden. Alle gültigen Reihenfolgen der Kindknoten in Bezug auf die swap“-Operation lassen sich finden, indem man versucht alle Permutationen ” der n-elementigen Menge der Operanden in die korrekte Reihenfolge zu bringen. Die folgende Abbildung zeigt alle n! möglichen Reihenfolgen der Kindknoten von a und ihre Gültigkeit in Bezug auf die swap“-Operation: ” Abbildung 3.16: Permutationen der Kinder von a Ist eine Permutation gültig (grünes Häkchen), so lassen sich die Nachkommen von a in dieser Reihenfolge berechnen und mit swap“ in die korrekte Reihenfolge b, c, d“ brin” ” gen. Für die Auswahl der optimalen Reihenfolge vergleicht man die Stackanforderungen aller gültigen Permutationen miteinander. Die Berechnung der Stacktiefe eines Knotens x mit n direkten Nachfolgern, die nach ihrer Berechnungsreihenfolge durchnummeriert sind, erfolgt dabei nach der folgenden Formel: R(x) = maxni=1 (R(i) + (i − 1)) Mit Hilfe dieser Technik lässt sich die zur Ausführung eines Programms benötigte Stacktiefe um maximal n − 1 Positionen im Vergleich zur naiven dfpo-Ordnung verringern, 3 Optimierte Codeerzeugung 47 wenn der größte Nachfolger zuerst berechnet werden kann. Benötigt Knoten d in Abb. 3.16 zum Beispiel drei Stackpositionen, so könnte man mit der 5. Permutation den Wert von a mit drei anstatt mit fünf Stackplätzen berechnen. Hierfür sind zwei swap“” Operationen notwendig, was die Kosten der Variante um 2 ∗ c(swap) steigen lässt. Bei einem Knoten des Ausgangsgrades GA = n und dem größten direkten Nachfolger auf Position i ≤ n bedeutet das Zusatzkosten von (i − 1) ∗ c(swap). Der zur Bestimmung der Gültigkeit einer Permutation verwendete Algorithmus wird im Implementierungskapitel 6 beschrieben. Die Nutzung des Speichers erlaubt dagegen eine beliebige Gestaltung der Reihenfolge der Auswertung von Nachkommen, da jeder einmal berechnete und gespeicherte Wert sofort auf den Stack geschrieben werden kann. Außerdem belegen einmal berechnete und noch nicht genutzte Werte keine Stackplätze, so dass praktisch jederzeit auf einem leeren Stack gearbeitet werden kann. Bei n Nachkommen eines Knotens ergäbe das n! gültige Kombinationen der Kindknoten. Die resultierenden Stackplatzanforderungen und Kosten einer solchen Vorgehensweise im Vergleich zu anderen Techniken müssen situationsabhängig bewertet werden. Im Rahmen der Entwicklung eines Dynamic Programming-Ansatzes in Kapitel 4 wird diese Problematik ausführlicher behandelt. Die folgende Diskussion untersucht die Möglichkeiten mittels einer alternativen Durchlaufordnung die maximale Stacktiefe bei der Berechnung eines Programms, das sich durch einen DAG darstellen lässt, zu optimieren. Dabei ist die Berechnung der Knoten mit mehr als einem Vorfahren besonders interessant, wenn man das Ziel verfolgt, den Wert eines solchen Knotens nur ein einziges Mal zu berechnen. 3.5.3 Alternative Reihenfolge für DAGs Mehrfach verwendete Ausdrücke eines Programms, die sich in DAGs durch Knoten mit einem Eingangsgrad GE > 1 darstellen lassen, werden in einem optimalen Programm nur ein einziges Mal berechnet. In Abschnitten 3.1 bis 3.4 wurden verschiedene Techniken mit ihren Vor- und Nachteilen zur optimierten Nutzung der einmal berechneten Werte vorgestellt und diskutiert. Die Berechnung eines mehrfach genutzten Ausdrucks fand dabei stets beim erstmaligen Vorkommen in einem dfpo-Durchlauf statt. Da der Knoten, der solch einen Ausdruck repräsentiert, im Folgenden als gemeinsamer Kno” ten“ bezeichnet, auf GE > 1 verschiedenen Pfaden erreichbar ist, ist es auch möglich diesen als Teil einer der GE Pfade zu berechnen und nicht nur als Teil des ersten Pfades in dfpo-Durchlaufordnung. In Abschnitt 3.5.1 wurde ein möglicher Ansatz zur Änderung des Berechnungspfades vorgestellt, der für die begrenzte Stacktiefe D zu große Ausdrücke lokal auslagert oder bei kommutativen Operatoren diese in der Ausführung vorschiebt. Im Folgenden sollen Möglichkeiten zur Änderung der Reihenfolge ohne die Nutzung des Speichers untersucht werden. 3 Optimierte Codeerzeugung 48 Angenommen es liegt ein simples Programm vor, welches mit Hilfe einer binären Operation a zwei gleiche Werte b miteinander verknüpft. Knoten b repräsentiere dabei eine binäre Operation: Abbildung 3.17: Alternative Durchläufe Da man b als gemeinsamen Ausdruck/Knoten nur einmal berechnen möchte, existieren für dieses Beispiel genau zwei Möglichkeiten dieses Ziel zu erreichen: b als linken oder als rechten Kindknoten von a berechnen. Der jeweils andere Nachkomme von a nutzt dann den zuvor berechneten Wert. Durch die Entscheidung für eine der Alternativen entstehen zwei unterschiedliche Graphen und damit auch zwei unterschiedliche Programme. Dabei ändert sich nicht nur die Struktur des DAGs, sondern auch die Reihenfolge der Berechnung, da der Pfad dem die Berechnung von b zugeordnet wurde stets als erster ausgewertet werden muss. Deshalb kann im Falle des dritten Graphen in Abbildung 3.17 der linke Nachkomme von a nicht als erster ausgewertet werden, da sein Wert noch nicht berechnet wurde. Um entscheiden zu können welche der beiden Berechnungsmöglichkeiten am besten geeignet ist, werden die Kosten und Stackplatzanfoderungen der beiden Varianten miteinander verglichen. Dabei spielen auch die bereits in Abschnitten 3.1 bis 3.4 angestellten Überlegungen eine Rolle, denn die Kopie des Wertes des berechneten gemeinsamen Knotens b kann sowohl in den Speicher ausgelagert werden als auch auf dem Stack verbleiben. Beide Varianten haben Vor- und Nachteile in Bezug auf die Kosten und Stackplatzanforderungen. Die Entscheidung über den Pfad, in dem ein gemeinsamer Knoten berechnet wird und auch über die Art der Speicherung bereits berechneter Werte kann nur bei bestimmten Knoten in einem DAG erfolgen. Solche Knoten zeichnen sich dadurch aus, dass von ihnen aus mindestens zwei Pfade, deren Knotenmenge disjunkt ist, zu dem gemeinsamen Knoten führen. 3 Optimierte Codeerzeugung 49 Abbildung 3.18: Mehrere Entscheidungsknoten Es ist aber durchaus möglich, dass ein gemeinsamer Knoten, wie in Abbildung 3.18, einen Eingangsgrad GE > 2 hat und nicht alle Pfade zu diesem Knoten von einem einzigen Knoten ungleich der Wurzel ausgehen. Damit sind für einen gemeinsamen Knoten mehrere Entscheidungen über die optimale Berechnungsreihenfolge möglich. Im Teil-DAG aus Abbildung 3.18 sind a und c solche Entscheidungsknoten“, denn besucht man den ” DAG bottom-up, so gelangt man zunächst zu Knoten a, von dem zwei Pfade zu b ausgehen und dann zu Knoten c von dem man ebenso auf zwei Pfaden zu b gelangt. Knoten a entscheidet also für seinen Unter-DAG in welchem Pfad b am besten zu berechnen ist und c nutzt diese Entscheidung für die eigene Berechnung. Bei nur einem gemeinsamen Ausdruck in einem Programm ist es also relativ einfach den Pfad, in dem die Berechnung des gemeinsamen Ausdrucks optimal ist, zu bestimmen. Man besucht jeden Knoten bottom-up und vergleicht bei den Entscheidungsknoten die Varianten miteinander. Die günstigste Reihenfolge und ihre Kosten und Stackanforderungen werden gespeichert. Übergeordnete Knoten nutzen diese Daten um die eigenen Entscheidungen zu treffen. Existieren jedoch in einem Programm mehrere gemeinsame Ausdrücke, so wird die Optimierung deutlich komplizierter. Hierfür soll zunächst der folgende DAG betrachtet werden: 3 Optimierte Codeerzeugung 50 Abbildung 3.19: Unabhängige gemeinsame Knoten Der DAG aus Abbildung 3.19 ist dabei noch ein relativ einfacher Fall, da sowohl die gemeinsamen Knoten d und e als auch die Unter-DAGs der Entscheidungsknoten b und c unabhängig sind. Es existieren keine Kanten von einem Unter-DAG zum anderen. Die Entscheidungen bei b und c über die optimale Berechnung der zugehörigen gemeinsamen Knoten können also unabhängig voneinander getroffen werden und Knoten a entscheidet schließlich ob b oder c zuerst berechnet werden soll, damit zum Beispiel die Stacktiefe optimal ist. Interessanter ist der folgende DAG, der nur die zwei gemeinsame Knoten d und e besitzt. Abbildung 3.20: Direkt abhängige gemeinsame Knoten In diesem Fall ist jedoch der gemeinsame Knoten d ein Nachkomme von dem gemeinsamen Knoten e. Besucht man den DAG bottom-up beginnend bei Knoten d, so kommt man irgendwann bei Knoten c an, der über den optimalen Pfad zur Berechnung des gemeinsamen Knotens e entscheidet. Da für diese Entscheidung konkrete Kosten und Stackanforderungen des Unter-DAGs von c notwendig sind, über den Pfad, in dem der 3 Optimierte Codeerzeugung 51 zweite gemeinsame Knoten d berechnet werden soll, aber noch nicht entschieden werden konnte, so kennt auch c nicht die genauen Kosten und Stackplatzanforderungen seiner Nachkommen. Obwohl d zwar zum Unter-DAG von c gehört, entscheidet a über den optimalen Pfad zu d. Da Knoten a aber ein Vorfahre von c ist, kann c den Knoten d nicht auflösen. Knoten a kann also aufgrund von c nicht entscheiden und c nicht aufgrund von a. Diese Situation kann jedoch aufgelöst werden, denn der gesamte Teil-DAG von c bis e hängt gleichermaßen von dem gemeinsamen Knoten d ab. Ungeachtet der Kosten und Stackanforderungen von d werden also alle Nachkommen von c von diesen im gleichen Maß abhängig sein. Die Entscheidung über den Pfad, in dem Knoten e zuerst berechnet wird, kann also in diesem Fall unabhängig von d getroffen werden. Da Knoten a aber in jedem Fall auf die tatsächlichen Kosten und Stackanforderungen von c angewiesen ist, darf Knoten d nicht vollständig ignoriert werden. In Abschnitt 4.2 wird eine Technik vorgestellt, mit der beide Ziele, optimale Entscheidung über den Pfad der Berechnung eines gemeinsamen Knotens und Sicherstellung der Vollständigkeit der Daten innerhalb eines DAGs, erreicht werden können. Dabei wird die Abhängigkeit zu einem gemeinsamen Knoten temporär aufgelöst, so dass eine Entscheidung getroffen werden kann. Abbildung 3.21: Abhängigkeit der Entscheidungen Hängt jedoch nur ein Teil des Teil-DAGs zwischen c und e von d ab, zum Beispiel ein Knoten x, der sowohl d als auch e als Nachkommen hat, so ist es unmöglich d in irgendeiner Weise zu ignorieren, weil nun auch von d die Entscheidung bei c abhängt. Deshalb können hier genauso, weder bei a noch bei c, Entscheidungen über den optimalen Pfad zum jeweiligen gemeinsamen Knoten getroffen werden, da jeder von der Entscheidung des anderen abhängt. Dieses Problem lässt sich auf beliebig viele gemeinsame Knoten und damit verbundene Entscheidungen ausweiten. Besitzt also zum Beispiel der Unter-DAG von c n Nachkommen, in deren Unter-DAGs Nutzungen von verschiedenen gemeinsamen Knoten stattfinden, die wiederum auf gemeinsame Knoten verweisen können, so wächst der Entscheidungsraum mit jedem weiteren gemeinsamen Knoten oder einem Verweis auf solchen 3 Optimierte Codeerzeugung 52 exponentiell. Würde man also wie oben eingeführt die Kosten und Stackanforderungen aller möglichen Reihenfolgen vergleichen wollen, so wäre der Berechnungsaufwand für größere DAGs solcher Struktur relativ hoch. Der folgende Abschnitt diskutiert mögliche Lösungen zur Bestimmung der optimalen Berechnungsreihenfolge von gemeinsamen Knoten in einem beliebigen DAG. 3.5.4 Lösungsansätze Vergleich aller Pfade Um die optimale Berechnungsreihenfolge aller Knoten eines DAGs zu finden ist der erste denkbare Ansatz, einfach alle Möglichkeiten auszuprobieren und die resultierenden Kosten und Stackanforderungen miteinander zu vergleichen. Besteht ein DAG ausschließlich aus n binären Knoten, so gibt es O(2n ) unterschiedliche Pfade durch den DAG, weil jeder Knoten entweder den linken oder rechten Kindknoten zuerst berechnen kann. Um den besten Pfad zu finden vergleicht man die Kosten und Stackanforderungen aller Pfade miteinander. Sind Knoten beliebigen Grades m zugelassen, so gibt es für jeden solchen Knoten m! mögliche Anordnungen seiner direkten Nachfolger. Es sind also O(m!n ) Fälle miteinander zu vergleichen. Da die Anzahl der zu untersuchenden Fälle mit der Anzahl der Knoten exponentiell wächst, eignet sich also dieses Verfahren nicht für die effiziente Berechnung der optimalen Reihenfolge. Bei relativ kleinen DAGs in praktischen Anwendungen, wie zum Beispiel dem aus Abb. 3.21, würde jedoch dieses recht naive“ Vorgehen trotzdem in einer kurzen Zeit das ” optimale Ergebnis liefern. Dabei wären weder gemeinsame Knoten noch Entscheidungsknoten zu beachten und auch Abhängigkeiten zwischen gemeinsamen Knoten würden die Anwendung des Ansatzes nicht viel schwieriger machen. Die Komplexität eines DAGs spielt hier also praktisch keine Rolle, sondern nur die Anzahl der Knoten und ihr maximaler Ausgangsgrad GA . Vorberechnung aller gemeinsamer Ausdrücke Eine weitere Möglichkeit ist die Umgehung der common subexpression“-Problematik, ” indem man alle gemeinsamen Ausdrücke in einer zusätzlichen Übersetzungsphase vorberechnet und lokal abspeichert. Jede Operation, die einen gemeinsamen Ausdruck verwendet, kann seinen Wert dann in der Codeerzeugungsphase aus dem Speicher laden. Ein DAG wird also praktisch zu einem Ausdrucksbaum, da alle Knoten mit GE > 1 durch Blattknoten ersetzt werden. Der Ausdrucksbaum kann dann mit den bereits bekannten Verfahren für die optimale Berechnung von Ausdrucksbäumen [AhoJoh76, BurLas75, SetUll70] effizient ausgewertet werden. Bei n Knoten im DAG beträgt die Laufzeit dieses Ansatzes 2 ∗ O(n), da einmal für die Auslagerung der mehrfach verwendeten Knoten und ein weiteres Mal für die Berechnung der restlichen Knoten der gesamte DAG durchlaufen werden muss. In der Auslagerungsphase wird, genau wie beim LocVar-Verfahren, die Information über die 3 Optimierte Codeerzeugung 53 zugehörige lokale Variable im gemeinsamen Knoten nach der Berechnung abgelegt, so dass jeder Knoten Zugriff darauf hat. Der Speicherverbrauch dieses Ansatzes entspricht dem der LocVar-Technik und beträgt O(n). Liegt also eine Architektur vor, die zum Beispiel eine begrenzte Hardware-Stacktiefe besitzt und dazu billige Speicherkommunikation (sowohl von der Laufzeit als auch Speichervolumen) erlaubt, so ist dieses Verfahren aufgrund seiner linearen Laufzeit und relativ einfachen Umsetzung eine gute Wahl. Dynamic Programming Die dritte Möglichkeit ist ein Dynamic Programming-Ansatz, der für jeden Knoten in einem DAG die optimale Berechnung aus allen möglichen Auswertungen des Knotens bestimmt. Dabei greift er nur auf die Informationen seiner direkten Nachfolger zu. Das Verfahren von B. Prabhala und R. Sethi [PraSet80] realisiert diesen Ansatz auf einer Untermenge von DAGs, die sich collapsible“ oder collabierbare“ Graphen nennen. ” ” Solche Graphen repräsentieren eine Klasse der series-parallel graphs“, die von Rior” dan und Shannon in [RioSha42] formuliert wurden. Ein DAG A = (V, E, m, r) gilt als collabierbar“, falls er sich durch eine beliebig oft wiederholbare Abfolge der folgenden ” Transformationen in beliebiger Reihenfolge auf einen einzigen Knoten reduzieren lässt: • Regel 1: Liegt ein Blattknoten b ∈ V vor, d.h. GE (b) = 1 und GA (b) = 0, so entferne den Knoten b und die Kante, die auf ihn zeigt. • Regel 2: Gibt es eine Knotenanordnung aus drei Knoten x, y, z ∈ V , so dass Kanten (x, y) ∈ E und (y, z) ∈ E existieren und ist GA (y) = GE (y) = 1, so entferne y und die ein- und ausgehenden Kanten von y und füge die Kante (x, z) zu E hinzu. • Regel 3: Gibt es zwei Knoten x, y ∈ V und existiert mehr als eine direkte Kante (x, y) ∈ E, so entferne alle bis auf eine der Kanten aus E. Zur Veranschaulichung kann man sagen, dass ein DAG collabierbar ist, wenn er keine der folgenden Strukturen enthält: 3 Optimierte Codeerzeugung 54 Abbildung 3.22: Nicht-collabierbare Strukturen Der Algorithmus aus [PraSet80] berechnet einen collabierbaren DAG mit ausschließlich binären Operatoren mit einer Laufzeit von O(D3 ∗ n), wobei D die begrenzte Anzahl von Stackpositionen und n die Anzahl der Knoten eines DAGs darstellt. Es wird außerdem eine kommutative Stackmaschine angenommen. Diese repräsentiert eine allgemeinere Darstellung einer Stackarchitektur, d.h. dass die Operanden in beliebiger Reihenfolge auf dem Stack liegen dürfen und dass mindestens ein Operand vor der Ausführung der zugehörigen Operation auf dem Stack ist. Die restlichen Operanden können direkt aus dem Speicher verwendet werden. Soll der Algorithmus auf Stackmaschinen mit einer Teilmenge der möglichen Operationsarten angewendet werden, wie zum Beispiel der Teilmenge der JVM, so können laut der Autoren einfach die Kosten für die restlichen Operationen auf unendlich gesetzt werden. Der Algorithmus arbeitet in zwei Phasen: • Bestimmung der Collabierbarkeit des vorliegenden DAGs und Berechnung der Kostentabellen für jeden Knoten. • Bestimmung des optimalen Codes für D Stackpositionen mit Hilfe der Kostentabellen. Es wird also ein kosten-optimaler Durchlauf für eine maximale Stacktiefe D berechnet. Während in [PraSet80] zum Großteil auf die Bestimmung der optimalen Stackanforderungen und der Collabierbarkeit eines DAGs eingegangen wird, fällt die Beschreibung des Dynamic Programming-Ansatzes kurz und wenig konkretisiert aus. Aus diesem Grund wird im folgenden Kapitel die Umsetzung eines solchen Ansatzes mit einigen interessanten Erweiterungen ausführlich beschrieben. Der folgende Algorithmus optimiert DAGs mit Knoten beliebigen Grades und stellt einen Ansatz zur Erkennung und Berechnung nicht-collabierbarer“ DAGs vor. ” 4 Ein Dynamic Programming-Ansatz Der Prozess des Dynamic Programming ist ein typischer divide & conquer“-Ansatz, der ” sich folgendermaßen beschreiben lässt: 1 2 3 4 solve ( problem ) { i f ( problem not e l e m e n t a r ) { f o r e a c h ( sub−problem : problem ) solve ( sub−problem ) ; 5 combine ( s u b r e s u l t s ) ; 6 7 return o p t i m a l s o l u t i o n ; } else return o p t i m a l s o l u t i o n ; 8 9 10 11 12 } Listing 4.1: Aufteilung in Teilprobleme Ein komplexes Problem wird solange in kleinere Teil-Probleme zerlegt bis sich diese in konstanter Zeit lösen lassen. Jedes so entstandene elementare Teil-Problem wird gelöst und die Lösung an die übergeordnete Problem-Ebene weitergegeben. Das übergeordnete Problem verwendet wiederum die Lösungen der elementaren Teil-Probleme um seine günstigste Lösung zu finden und gibt dieses Ergebnis ebenso eine Ebene höher. Auf diese Weise wird das ursprüngliche Problem Schritt für Schritt optimal gelöst, indem die Teil-Probleme optimal gelöst werden. Der Entscheidungsraum bleibt dabei für jedes Teil-Problem relativ klein, da nur die nächst niedrige Problemebene für die Berechnung der optimalen Lösung herangezogen wird. Ist das Problem die Berechnung der optimalen Durchlaufordnung für Ausdrucksbäume bezüglich der Kosten bei einer begrenzten Stacktiefe D, so lässt es sich mit einem Dynamic Programming-Ansatz relativ einfach lösen. Dynamic Programming funktioniert, da jede Problemebene, also die Entscheidung über die optimale Berechnung eines Knotens im Ausdrucksbaum, nur auf die Lösungen der eigenen Teil-Probleme angewiesen ist. Jeder Knoten im Ausdrucksbaum muss also nur die Lösungen der direkten Nachfolger kennen, um seine eigene Lösung berechnen zu können. Die restlichen Knoten spielen bei dieser Entscheidung keine Rolle. Für die Speicherung von Lösungen eines Problems werden normalerweise tabellarische Datenstrukturen verwendet. Diese müssen für die 55 4 Ein Dynamic Programming-Ansatz 56 korrekte Ausführung des Algorithmus keine bestimmten Kriterien erfüllen, sondern nur effizient iterierbar sein und alle nötigen Informationen, die für die Optimierung benötigt werden aufnehmen können. 4.1 Berechnung eines Ausdrucksbaumes Zunächst soll das Verfahren auf Ausdrucksbäumen erläutert werden. Für das Problem der Berechnung eines Ausdrucksbaumes mit optimalen Kosten bei einer begrenzten Stacktiefe D muss man die Kosten und die zugehörige Stacktiefe abspeichern können. Denn das sind die Kriterien der Bewertung einer Optimierung. Ist D zum Beispiel als die Tiefe des Stack-Buffers vorgegeben, so möchte man für jeden Knoten herausfinden, mit welchen Kosten sich dieser in dem vorgegebenen Stackplatzbereich berechnen lässt. Abbildung 4.1: Einfache Tabellen Der Ausdrucksbaum in Abb. 4.1 besteht aus drei binären Knoten a, b und c mit der zugehörigen Tabelle table, die Kosten für die Berechnung eines Knotens mit einer bestimmen Anzahl von Stackpositionen enthält. Der Ausdrucksbaum, also Wurzelknoten a, kann ohne die Nutzung des Speichers auf drei verschiedenen Arten berechnet werden, wobei er entweder 2, 3 oder 4 Stackpositionen benötigt (Index i von Tabelle tablea von Knoten a) mit den Kosten 6 KP, 5 KP und 4 KP (tablea [i]). Zwei Stackpositionen werden benötigt wenn bei b und bei a der rechte Nachfolger vor dem linken berechnet wird, wodurch zusätzliche Kosten von 2 KP für die swap“-Operationen entstehen. ” Drei Stackpositionen sind mit einem swap“ möglich und vier Positionen ohne jegliche ” swap“-Operationen, also in der dfpo-Ordnung ausgehend von a. Für die optimale Be” rechnung wird demnach die Variante mit den geringsten Kosten im gültigen Stackplatzbereich ausgewählt. Ist D also die maximale Stacktiefe und i der Index der Tabelle table eines Knotens, so wähle die Variante min(table[i]) aus, wobei i ≤ D. Stehen zum Beispiel D = 4 Stackpositionen zur Verfügung, so ist die Berechnung in der dfpo-Ordnung optimal mit 4 KP, denn min(tablea [i]) = 4 KP. 4 Ein Dynamic Programming-Ansatz 57 Damit tablea als Tabelle des Wurzelknotens korrekte Werte enthält, wird der Ausdrucksbaum bottom-up durchlaufen. Jeder Knoten auf dem Weg von den Blättern zur Wurzel berechnet seine eigene Tabelle. So sieht die Tabelle eines einfachen Blattknotens, der eine Konstante eines bestimmten Datentyps repräsentiert, immer gleich aus, da der Wert mit c(push) KP genau eine Stackposition einnimmt. Innere Knoten verwenden die Tabellen der direkten Nachkommen zur Erzeugung der eigenen Tabellen. Dabei werden die Einträge aus den Tabellen der direkten Nachkommen miteinander kombiniert und in die entsprechenden Tabellenfelder des Elternknotens eingetragen. Die Reihenfolge der Kombinationen ist wichtig, da sie die Auswertungsreihenfolge der Kindknoten repräsentiert. Die Tabelle tableb kommt also zustande, indem die Tabellen table2 und tablec miteinander kombiniert werden. Schreibt man zuerst 2“ auf den Stack und dann den Wert ” von c, so werden table2 [1] und tablec [2] zu tableb [3] = 3 kombiniert. Der Index ie der Ergebnistabelle eines binären Knotens berechnet sich aus den Indizes der zwei Nachkommentabellen durch: ie = max(i1 , i2 + 1), für i1 = als Index der ersten Tabelle und i2 = als Index der zweiten Tabelle. Die jeweiligen Kosten werden einfach addiert. Schreibt man also den Wert von c nach der 2“ auf den Stack, so werden ib = 3 Stackpositionen ” mit 3 KP benötigt. Wird c jedoch zuerst berechnet, so werden nur noch zwei Stackplätze verbraucht, dafür ist aber eine swap“-Operation nötig, um die korrekte Reihenfolge der ” Nachfolger zu erhalten, wodurch die Kosten von b um c(swap) KP steigen. Wieso speichert man aber die Ergebnisse aller möglichen Kombinationen, wenn man nur an der Variante mit den kleinsten Kosten innerhalb i ≤ D interessiert ist? Würde man bei jedem Knoten nur den günstigsten Wert speichern, so könnte man Speicher und Laufzeit sparen. Das folgende Beispiel soll zeigen, dass alle Einträge einer Tabelle für die Optimierung wichtig sind. Die Tabellen der Blattknoten werden zur besseren Übersicht weggelassen. Abbildung 4.2: Alle Einträge sind wichtig Man nehme an, ein Programm, das durch den Ausdrucksbaum in Abb. 4.2 dargestellt werden kann, soll auf einem Stack der Tiefe D = 3 berechnet werden. Die Tabelle des Wurzelknotens a besagt, dass der Ausdrucksbaum mit ia = D = 3 Stackpositionen und 7 KP berechnet werden kann. Hierfür wertet man zuerst b mit ib = 3 Stackpositionen 4 Ein Dynamic Programming-Ansatz 58 und tableb [3] =3KP und danach c mit ic = 2 Stackpositionen und 4 KP aus. Die zweite Variante von c mit ic = 3 Stackpositionen ist in diesem Fall nicht zu gebrauchen, da der Wert von b bereits auf dem Stack liegt und c mit seinen zusätzlichen Stackpositionen den Stack auf eine Tiefe von max(ib , ic + 1) = 4 > D anwachsen lässt. Hätte man also bei der Berechnung der Tabellen für b und c nur die minimalen Kosten table[3] = 3 KP gespeichert, die noch im gültigen Stackbereich sind, so hätte man nicht mehr die Information zur Verfügung, dass c auch mit zwei Stackpositionen berechenbar ist. Es müssen demnach alle Varianten im Stackplatzbereich ≤ D gespeichert werden, damit die Elternknoten alle Informationen für die Optimierung nutzen können. Existieren zwei Kombinationen der Kindknoten, die gleich viele Stackpositionen i benötigen, so wird die mit den geringsten Kosten gespeichert. Der Algorithmus zur Bestimmung der Tabellen für den kosten-optimalen Durchlauf eines Ausdrucksbaumes mit begrenzter Anzahl von Stackpositionen D lässt sich folgendermaßen formulieren: 1 2 d u r c h l a u f e e i n e n Ausdrucksbaum A r e k u r s i v i n dfpo ; f ü r j e d e n b e s u c h t e n Knoten node u n t e r s c h e i d e : 3 4 5 6 i f ( node i s t B l a t t ) // Kosten f ü r das S c h r e i b e n e i n e s Wertes node . t a b l e [ 1 ] = c ( push ) ; 7 8 9 10 else // Kindknoten von node k o m b i n i e r e n node . t a b l e = combine children ( node ) ; Listing 4.2: Tabellenberechnung Die äußerste Schleife des Verfahrens besucht alle Knoten in dfpo-Ordnung und berechnet die Tabellen jedes Knotens. Für Blattknoten lassen sich sofort Tabellen erstellen, wohingegen für innere Knoten alle Kombinationen der direkten Nachkommen und ihrer Varianten untersucht werden müssen. Sei C = {c1 , ..., cn } | n ∈ N die Menge der Kindknoten eines Knotens node des Grades n, wobei c1 stets der am weitesten links und cn der am weitesten rechts liegende Nachkomme ist. Für die korrekte Berechnung des Wertes von node müssen diese also vor dem Ausführen der Operation von node in der Reihenfolge c1 bis cn auf dem Stack liegen. Jeder Knoten cj ∈ C, j ∈ {1...n} besitze eine Tabelle tablecj der Größe D, die sich durch die Menge ihrer Elemente Tcj = {t1 , ..., tD } darstellen lässt. Ein Tabellenelement tm ∈ Tcj , m ∈ {1, ..., D} repräsentiere die Kosten der Berechnungsvariante m mit den Stackanforderungen m. Ferner sei die Existenz einer Tabelle permutationen mit allen Permutationen von C angenommen. Jede der Permutationen pi |i ∈ {1, ..., n!} sei in Bezug auf die swap“” Operation entweder gültig, d.h. wenn sie sich mit swaps“ in die Reihenfolge 1 bis n ” 4 Ein Dynamic Programming-Ansatz 59 bringen lässt, oder ungültig, falls es nicht möglich ist. Bei Korrekturen in der Reihenfolge werden die Kosten für swap“-Operationen in swap costs gespeichert. Für jede ” Permutation der Kindknoten pi werde außerdem die Menge der Kombinationen ki der nicht-leeren Tabelleneinträge tm der Kindknoten gebildet, welche die Verknüpfung der verschiedenen Berechnungsvarianten repräsentieren. Die Tabelle eines Knotens node wird also folgendermaßen berechnet: 1 combine children ( node ) { 2 foreach ( pi : p e r m u t a t i o n e n ) { // Kombination a l l e r n i c h t −l e e r e n T a b e l l e n e i n t r ä g e // i n d e r R e i h e n f o l g e pi kombinationen ; 3 4 5 6 7 foreach ( ki : kombinationen ) { //pi = {c1 , ..., cn |cj ∈ C, cj 6= cj 0 } 8 9 10 //ki = {t1m , ..., tnm |tjm ∈ Tcj , cj ∈ pi }, m ∈ {1, ..., D} 11 12 costs = 13 Pn j=1 tjm + swap costspi ; 14 n−1 max stack = maxj=0 (m + j) ; //m I n d e x von tjm 15 16 i f ( tablenode [ max stack ] > c o s t s ) tablenode [ max stack ] = c o s t s ; 17 18 } } return tablenode ; 19 20 21 22 } Listing 4.3: Kombination der Kindknoten Für jede Permutation der Kindknoten und jede Kombination der Tabelleneinträge werden die Kosten und die Stackplatzanforderungen berechnet und an die entsprechende Stelle in der Tabelle des Elternknotens eingetragen, falls eine günstigere Variante gefunden wurde. Die Tabelle des Elternknotens sei anfangs mit unendlichen Kosten gefüllt. Die Kosten einer Kombination berechnen sich durch die Addition der Tabelleneinträge, wobei zusätzliche swap-“Kosten einer Permutation hinzukommen können. ” Ist die maximale Stacktiefe D so gewählt, dass sich ein Baum mit den zur Verfügung stehenden Stackmanipulationsbefehlen, wie zum Beispiel denen der JVM, nicht berechnen lässt, so gibt es noch die Möglichkeit Teile des Baumes nach ihrer Berechnung lokal zu speichern, um den Stack zwischendurch zu leeren. 4 Ein Dynamic Programming-Ansatz 60 Abbildung 4.3: Erweiterung um die 0-Felder Der Ausdrucksbaum in Abbildung 4.3, zum Beispiel, besteht ausschließlich aus binären Knoten und könnte somit theoretisch mit zwei Stackpositionen berechnet werden. Ohne eine lokale Speicherung ist es jedoch nicht möglich, da bei der Berechnung von c der Wert von b bereits auf dem Stack liegt oder umgekehrt. Speichert man aber den Wert von b nach seiner Berechnung lokal ab, so benötigt er nur noch eine Stackposition. Dadurch kann Knoten a mit zwei Stackplätzen und einer zusätzlichen swap“-Operation berechnet ” werden, indem zuerst c berechnet und danach b aus dem Speicher geladen wird. Die Auslagerung von Knoten c und die Berechnung von b wäre sogar noch günstiger, da in diesem Fall kein swap“ mehr nötig ist. Die Berechnung eines Knotens, dessen Wert lokal ” gespeichert wird findet dabei vor der Berechnung des Ausdrucksbaumes statt während der Stack noch leer ist. Es wäre also durchaus interessant für die Berechnung eines Ausdrucksbaumes zu wissen, welche Stacktiefen mit der Nutzung der lokalen Auslagerung möglich wären. Diese Information kann unter Index i = 0 in einer Tabelle abgelegt werden, da nach der Berechnung des Knotens der Stack geleert wird und sich somit 0 Elemente auf dem Stack befinden. Jedoch sollten korrekterweise auch die benötigten Stackanforderungen bis zur Auslagerung mitgespeichert werden, da der Index i = 0 alleine diese nicht widerspiegelt. Auch wenn der Stack nach der lokalen Auslagerung leer ist, so benötigte der gespeicherte Wert für seine Berechnung eine bestimmte Anzahl von Stackpositionen, welche auf die Stackanforderungen des gesamten Programms Einfluss haben. Die Bedeutung des Wertes in table[0] weicht also etwas von den restlichen ab, denn die Werte i > 0 bedeuten, dass der Knoten mit i Stackpositionen und table[i] Kostenpunkten berechnet werden kann, während i = 0 bedeutet, dass die Berechnung dieses Knotens und all seiner Nachkommen außerhalb des Baumes geschieht. Mit table[0] Kostenpunkten wird der Knoten berechnet und der Stack geleert. Für die Optimierung des restlichen Ausdrucksbaumes benötigt die Variante mit Index i = 0 genau eine Stackposition. table[0] lässt sich für jeden Knoten folgendermaßen berechnen: table[0] = minD i=1 (table[i]) + c(store) + c(load) Die Kosten für das Speichern und Laden nach der Berechnung der günstigsten Variante werden also einfach dazuaddiert und in table[0] gespeichert. Das Beispiel aus Abb. 4.3 4 Ein Dynamic Programming-Ansatz 61 ließe sich also mit i = 2 Stackpositionen und tablea [i] = 8 KP berechnen, indem man zuerst den Wert von c berechnet, ihn lokal Speichert und nach der Berechnung von b wieder auf den Stack lädt. Bei Operatoren/Knoten mit mehr als zwei Operanden/Nachfolgern ist die Auslagerung mehrerer Knoten ebenso von Interesse. Dabei könnten unterschiedlich viele Knoten in beliebiger Reihenfolge lokal gespeichert werden, um die Stacktiefe zu minimieren. Möchte man also die Auslagerung bestimmter Knoten zur Verringerung der Stacktiefe zulassen, so muss in combine children() für den Tabellenindex m = 0 eine Fallunterscheidung gemacht werden. Ist also der Index m eines Eintrages tjm ∈ ki gleich 0, so benötigt der zugehörige Kindknoten cj ∈ C genau eine Stackposition, da sein Wert bereits im Speicher liegt. In Abschnitt 2.2 wurde bereits kurz erwähnt, dass auch Hardware-Java-Prozessoren existieren, die die Ausführung von Java-Bytecode beschleunigen, indem Operationen direkt auf der Hardware ausgeführt werden. Die AVR32 32-bit MCU [AVR09, AVRJSpec], zum Beispiel, stellt insgesamt acht Register für die Speicherung von acht obersten Stackelementen des JVM Operanden-Stacks zur Verfügung. Dementsprechend lassen sich Programme, die nur oder größtenteils mit diesen acht Stackpositionen auskommen deutlich schneller ausführen, da der Zugriff auf Stackelemente keine Speicherzugriffe benötigt. Braucht ein Programm jedoch mehr als D = 8 Stackplätze zu seiner Ausführung, so tritt ein stack overf low auf, in Folge dessen einige Stackelemente, z. B. die untersten vier, in den Hauptspeicher ausgelagert werden. Dadurch rutschen“ die obersten vier ” Elemente nach unten und machen Platz für vier neue oberste Stackelemente. Sind die auf dem Hardware-Stack liegenden Elemente von Operationen konsumiert worden und werden die ausgelagerten Elemente gebraucht, so erfolgt wieder ein Speicherzugriff, der die Elemente auf den Hardware-Stack lädt. Dies bedeutet in der Regel deutliche Einbußen in der Laufzeit, da für jedes ausgelagerte Element mindestens zwei Speicherzugriffe notwendig sind. Für die Optimierung der Stackcodeerzeugung bedeutet das, dass solange sich ein Programm mit ≤ D Stackpositionen berechnen lässt es auch mit der höchsten Geschwindigkeit ausgeführt wird. Überschreitet man diese Grenze, so ist das Programm zwar immer noch ausführbar, da die überschüssigen unteren Elemente in den Speicher ausgelagert werden, jedoch wird die Ausführung dadurch deutlich aufwendiger. Für die Berücksichtigung dieser Eigenschaft im oben eingeführten Algorithmus würde es also ausreichen einen Wert mit dem Index i = D + 1 in der Tabelle zu speichern, der eine Berechnung mit mehr als D Stackpositionen repräsentiert. Die Kosten jedes Knotens, die mehr als D Positionen benötigen, steigen dabei, als eine Art Abstrafung“, um einen festen Wert, ” der den zusätzlichen Aufwand der Auslagerung darstellt. Dieser Wert kann abhängig von der speziellen Architektur angepasst werden. Falls D eine nicht zu überschreitende Grenze darstellen soll, so können die Abstrafungskosten auf unendlich gesetzt werden, so dass diese Variante nie als die günstigste ausgewählt wird. 4 Ein Dynamic Programming-Ansatz 62 Abbildung 4.4: Erweiterung um die D+1-Felder Das Beispiel aus Abb. 4.4 zeigt einen Ausdrucksbaum bestehend aus binären Knoten. Der Baum repräsentiert ein Programm, das mit einem Hardware-Stack berechnet werden soll, der die obersten D = 2 Stackelemente aufnehmen kann. Sollten mehr als zwei Positionen nötig sein, so werden alle bis auf die zwei obersten Stackelemente in den Speicher geschrieben und wenn sie gebraucht werden wieder auf den Hardware-Stack geholt. Die Zusatzkosten pro Stackelement, das in den Speicher ausgelagert wird betragen x KP. Bereits bei Knoten b gibt es eine Möglichkeit den Wert sowohl mit zwei als auch mit drei Stackpositionen zu berechnen. Da D = 2, wird die Variante mit drei Stackpositionen in das D + 1-Feld gespeichert. Der Wert des D + 1-Feldes berechnet sich aus den üblichen Kosten plus die Zusatzkosten für die Elemente, die nicht auf den Hardware-Stack passen. Eine mögliche Berechnung der Abstrafungskosten ist (s − D) ∗ x, wobei s die Stackanforderungen der jeweiligen Variante darstellt. Im Falle von Knoten b braucht die D + 1-Variante drei Stackpositionen, es muss also ein Stackelement im Speicher gelagert werden. Es fallen die Zusatzkosten (3−2)∗x = x KP an. Die Gesamtkosten der Variante betragen also 3 + x KP. Fließen in die Berechnung der Abstrafungskosten die tatsächlich gebrauchten Stackplätze > D mit ein, so muss genau wie beim 0-Feld auch beim D + 1Feld die nötige Anzahl von Stackpositionen gespeichert werden. Bei der Berechnung des 0-Feldes wird das D + 1-Feld wie alle anderen berücksichtigt. Für den Wert von x soll im Folgenden stets x > 0 gelten. 4.2 Berechnung eines DAGs Für die optimale Berechnung eines Programms, das sich durch einen DAG darstellen lässt reichen die oben beschriebenen Tabellen nicht aus. Denn schon für einen einfachen DAG wie in der folgenden Abb. 4.5 mit nur einem gemeinsamen Knoten müssten alle Varianten für die Berechnung des gemeinsamen Knotens f gespeichert werden. Knoten f kann sowohl als Teil-DAG von b als auch von c zuerst berechnet werden, was Auswirkungen auf die Berechnung des restlichen DAGs haben kann (siehe Abschnitt 3.5.3). 4 Ein Dynamic Programming-Ansatz 63 Abbildung 4.5: DAG Tabellen Definition 4.0: Sei A = (V, E, m, r) ein DAG, so wird ein v ∈ V als ein gemeinsamer Knoten bezeichnet, falls GE (v) > 1. Dann sind die auf v ∈ V folgenden gemeinsamen Knoten G(v), definiert als: G(v) = {v 0 |v 7→ v 0 , v 6= v 0 und GE (v) > 1} Definition 4.1: Sei A = (V, E, m, r) ein DAG und e ∈ V ein Entscheidungsknoten, falls |G(e)| > 0, für |G(e)| als die Anzahl der Elemente von G(e). Der DAG aus Abb. 4.5 soll optimiert und der zugehörige Stackcode erzeugt werden. Für Knoten a als einen Entscheidungsknoten besteht die Menge G(a) nur aus Knoten f. Nutzt man die lokale Speicherung für den Wert von f, so existieren zwei Möglichkeiten diesen zu nutzen, ohne ihn mehr als einmal zu berechnen: 1. Berechnung und anschließende Speicherung oder 2. Laden aus dem Speicher. Die einfachste Möglichkeit, beide Fälle sowohl für Knoten d als auch für e zur Verfügung zu stellen, ist für f zwei Tabellen zu kreieren. Eine Tabelle beschreibt die Kosten und Stackanforderungen für die Berechnung von f und die anschließende Speicherung und die andere Tabelle für die Nutzung des berechneten Wertes, zum Beispiel durch Laden einer lokalen Variable. Beide Tabellen werden von f aus bis hin zu a an die Vorfahren weitergereicht und aktualisiert. Knoten a kann dann als Entscheidungsknoten aus den insgesamt vier Tabellen (zwei von b und zwei von c) eine erstellen, indem analysiert wird, welcher Zweig (b oder c) sich für die Berechnung von f am besten eignet. Es wird also festgestellt, ob die Kosten für die Berechnung von f in b kleiner sind als die Berechnung von f in c innerhalb des Stacktiefenbereiches D. Um das zu erreichen kombiniert Knoten a die Tabelle aus b, welche den Fall für die Berechnung von f repräsentiert mit der Tabelle aus c, welche den Fall für das Laden des Wertes von f darstellt. Das Gleiche geschieht nochmals umgekehrt, wobei c den Wert von f berechnet und b diesen nutzt. 4 Ein Dynamic Programming-Ansatz 64 Die Ergebnistabelle, die die günstigsten Kosten innerhalb von D enthält wird ausgewählt und in Knoten a gespeichert. Befinden sich jedoch mehrere gemeinsame Knoten im DAG, so können problematische Situationen, wie in Abschnitt 3.5.3 vorgestellt, auftreten. Abbildung 4.6: Verdopplung der Tabellen Möchte man den DAG aus Abb. 4.6 mit dem Ansatz der zwei Tabellen für jeden gemeinsamen Knoten berechnen, so würde Knoten f zwei Tabellen generieren, welche von den Vorfahren verwendet werden. Da Knoten e jedoch auch ein mehrfach genutzter Knoten ist und zwei Tabellen von f bereits vorliegen, so muss e für jede dieser Tabellen ebenfalls zwei Tabellen erstellen. Bei Knoten e entstehen also insgesamt vier Tabellen, die alle möglichen Berechnungen von e und f abdecken. Diese vier Tabellen werden im Teil-DAG zwischen b und e verwendet. Bei b wird eine Entscheidung getroffen, ob e als Teil von c oder d zu berechnen ist. Dadurch werden aus den vier Tabellen zwei erzeugt. Für jeden gemeinsamen Knoten, der sich in solch einer Anordnung befindet würde die Anzahl der Tabellen um eine 2er-Potenz wachsen. Das bedeutet O(2n ) Speicherplatz für n gemeinsamer Knoten im DAG, wenn alle voneinander paarweise abhängen. Da jedoch die genauen Kosten und Stackplatzanforderungen von f aus Abb. 4.6 keinen Einfluss auf die Entscheidung bei Knoten b haben, da alle Nachkommen von b gleichermaßen von f abhängen, könnte die Entscheidung, in welchem Zweig von b der gemeinsame Knoten e berechnet wird, vollständig unabhängig von f getroffen werden. Die genauen Stackplatzanforderungen von f sind aber auch nicht ganz unwichtig, denn sie bestimmen die Berechnungsreihenfolge innerhalb des Teil-DAGs von b und e mit. Für die Lösung des Problems sollen zunächst wichtige Teile eines DAGs genau definiert werden: 4 Ein Dynamic Programming-Ansatz 65 Definition 4.2: Sei A = (V, E, m, r) ein DAG und e ∈ V ein Entscheidungsknoten, dann wird Ue = (Ve , Ee , m, e) als ein Unter-DAG bzgl. e bezeichnet, falls folgendes zutrifft: • 1. für alle Knoten v ∈ V mit e 7→ v gilt v ∈ Ve , d.h. v ist im Unter-DAG enthalten • 2. Ue ist minimal. Bemerkung: Somit gilt auch, dass G(e) ⊆ Ve , d.h. dass alle gemeinsamen Knoten, die auf e folgen in Ue enthalten sind. Definition 4.3: Sei A = (V, E, m, r) ein DAG und e ∈ V ein Entscheidungsknoten und Ue = (Ve , Ee , m, e) der Unter-DAG für e, dann ist k ∈ V ein unentscheidbarer Knoten bzgl. e falls folgendes gilt: • 1. k ist ein gemeinsamer Knoten, der auf ein e folgt, d.h. k ∈ G(e). • 2. für k lässt sich nicht in Ue effizient der optimale Berechnungspfad finden, da ein direkter Vorfahre von k außerhalb von Ue liegt, d.h. ∃ v ∈ V und k 0 ∈ G(e) mit (v, k 0 ) ∈ E und k 0 7→ k, so dass v ∈ / Ve und k 0 = k oder k 0 ist ebenfalls ein unentscheidbarer Knoten bzgl. e. Falls dann zusätzlich gilt, dass es einen Pfad v0 , v1 , ..., vl mit v0 = e, vl = k und (vi , vi+1 ) ∈ E für 0 ≤ i < l mit der Eigenschaft gibt, dass kein vi ein unentscheidbarer Knoten für 0 < i < l ist, dann ist k ein kritischer Knoten bzgl. e. Die Menge aller kritischen Knoten bzgl. e soll als Gk (e) bezeichnet werden. Knoten f aus Abb. 4.6 gehört zum Beispiel zur Menge der kritischen Knoten Gk (b). Die Idee zu einer möglichen Lösung des Problems lieferte der Algorithmus aus [PrabSet80]. Das Prinzip des Ansatzes basiert darauf, dass bei einer begrenzten Stacktiefe D jeder Knoten eines DAGs, ebenso die gemeinsamen, stets 1 bis D Stackpositionen benötigen. Hängt das Programm für einen Entscheidungsknoten e ∈ V von einem kritischen Knoten k ∈ Gk (e) ab, so kann dieses nicht unabhängig vom restlichen DAG berechnet werden, weil das optimale Programm für k noch nicht bekannt ist. Die Abhängigkeit von e zu k kann jedoch temporär aufgelöst werden indem für k einfach alle möglichen Stackanforderungen mit den Kosten 0 angenommen werden. Auf diese Weise wird die Abhängigkeit zunächst irrelevant, da für Knoten e die Stackgröße für jede Stackgröße von k bekannt ist. Dadurch geht keine Information verloren und man muss die Abhängigkeiten nicht mehr beachten. Kommt man dann bei einem Entscheidungsknoten e0 an, der oberhalb von e liegt, d.h. e0 7→ e, an dem k vollständig entschieden werden kann, d.h. k ist kein unentscheidbarer Knoten bzgl. e0 mehr, dann kann die verzögerte Entscheidung aufgelöst werden. Existiert also ein kritischer Knoten k für einen Entscheidungsknoten e, d.h. k ∈ Gk (e), dann berechnet man für e eine Kostenmatrix Mek in der der Eintrag in Zeile j und Spalte i das Programm für e repräsentiert, das i Stackplätze benötigt, falls das optimale Programm für k j Stackplätze erfordert und die Kosten 0 besitzt. Für den DAG in Abb. 4.6 erzeugt also der Knoten e eine Matrix Mef , da f für e ein kritischer Knoten ist. Für 4 Ein Dynamic Programming-Ansatz 66 die Knoten c und d wird jeweils eine temporäre Matrix Mce und Mde in Abhängigkeit zu e aufgestellt. Diese Matrix kann dann im Knoten b lokal aufgelöst werden. Der Entscheidungsknoten b bestimmt dazu für jede Zeile der Matrix seiner Nachfolger den optimalen Pfad, in dem e berechnet werden soll und entfernt damit die Abhängigkeit zu e, d.h. nach der Entscheidung gilt e ∈ / G(b). Allerdings ist b dann abhängig vom kritischen Knoten f, was zu der Matrix Mbf führt. Am Knoten a kann dann auch die Abhängigkeit zu f aufgelöst werden. Behauptung: Bei collabierbaren DAGs existiert für jeden Entscheidungsknoten e ∈ V maximal ein kritischer gemeinsamer Knoten k ∈ Gk (e). Man nehme zunächst ohne den Beweis an, die Behauptung wäre wahr. Der folgende Abschnitt 4.3 untersucht die Behauptung noch ausführlicher und genauer. Die Annahme hat jedoch zur Folge, dass in einem collabierbaren DAG für jeden Knoten mit Hilfe einer Matrix das optimale Programm ermittelt werden kann, da nur maximal ein kritischer Knoten k existiert, auf den sich die Matrix bezieht. Für alle Knoten e mit |G(e)| = 0 und somit auch |Gk (e)| = 0 würde eine einzige Tabelle bzw. Zeile der Matrix wie bei Ausdrucksbäumen ausreichen. Für den Entscheidungsknoten b aus dem DAG in Abb. 4.6 kann die Matrix Mbe mit dem folgenden Aufbau erzeugt werden: Abbildung 4.7: Matrix Mbe Die Zeilen der Matrix repräsentieren die Stackplatzanforderungen vom gemeinsamen Knoten e ∈ G(b) und die Spalten die Anforderungen von b. Da stets von einer begrenzten Stacktiefe D ausgegangen wird, ist auch die Größe einer solchen Matrix durch D begrenzt. Übernimmt man die Tabelleneinträge mit Index 0 und D + 1 in die Matrix, so besitzt jede Matrix genau (D + 1) x (D + 2) Einträge. Die Zeile j = 0 wird nicht gebraucht, da ein gemeinsamer Knoten mindestens eine Stackposition benötigt. Der Vorteil einer solchen Matrix ist, dass ihre Größe, unabhängig von der Anzahl der gemeinsamen Knoten, konstant bleibt und dennoch alle nötigen Informationen bereitstellt, um den optimalen Pfad zu einem gemeinsamen Knoten zu bestimmen. Die konkreten Kosten eines gemeinsamen Knotens g ∈ G(e), der von einem kritischen Knoten k ∈ Gk (e) abhängt, können in solch einem Fall bis nach der Entscheidung über 4 Ein Dynamic Programming-Ansatz 67 den optimalen Pfad in e ignoriert werden. Aufgrund des gewählten Kostenmodells (siehe Abschnitt 3.1) enthält jeder Knoten v ∈ V auf den Pfaden e 7→ v 7→ g die Kosten von g als Teil der Kostensumme. Deshalb können die wirklichen Kosten von g bis nach der Entscheidung bei e auf 0 KP gesetzt und danach wieder aufaddiert werden. Wie genau diese Aktualisierung funktioniert wird später im Abschnitt behandelt. Um also den optimalen Berechnungspfad für Knoten e in Abb. 4.6 finden zu können, erzeugen zuerst Knoten f und e jeweils eine (D + 1) x (D + 2)-Matrix. Da Knoten f von keinem gemeinsamen Knoten abhängt, wird die Matrix Mf f erzeugt, wobei jeweils in Zeile und Spalte j der Wert j eingetragen. Da f ein gemeinsamer Knoten ist, können schon hier für die Zeile j = 1 im Falle der Nutzung der lokalen Auslagerung die Kosten für das Laden des Wertes von 2 KP mit berücksichtigt werden. Für die Zeilen j > 1 kommen dementsprechend die Zusatzkosten c(dup) + c(store) hinzu. Sei D = 3, so erzeugt Knoten f die folgende Matrix für seine Vorgänger: Abbildung 4.8: Matrix Mf f Der Eintrag in Zeile j = 1 repräsentiert das Laden des Wertes von f und die Zeilen j > 1 enthalten die Kosten für die Auslagerung in den Speicher. Ein unärer Knoten wäre natürlich für den Fall j = 1 auch denkbar, jedoch werden in dieser Arbeit gemeinsame Nutzungen von unären Operationen aufgrund von relativ niedrigem Aufwand einer Neuberechnung nicht beachtet. Man geht also davon aus, dass jeder gemeinsame Knoten g ∈ V mindestens Ausgangsgrad GA (g) = 2 hat. Zeilen j > 1 entsprechen der tatsächlichen Berechnung des gemeinsamen Knotens, wobei dieser j Stackpositionen benötigt. Für den Fall j = D + 1 benötigt ein gemeinsamer Knoten g bereits mehr als D Stackpositionen. Da man eine Matrix aber unabhängig von den konkreten Stackanforderungen eines kritischen Knotens k ∈ G(g) erstellen möchte, besitzt man auch in diesem Fall keine genauen Daten wie viel mehr als D Positionen g benötigen könnte. Ein heuristischer Ansatz wäre einfach den Wert D + 1 als die Stackanforderung von g zu nehmen. Wird jedoch dieser Eintrag mit einem weiteren Tabelleneintrag mit den Stackanforderungen > D + 1 kombiniert, so kann es passieren, dass nicht die optimale Lösung gewählt wird, weil die tatsächlichen Stackanforderungen von g im Falle D + 1 größer als angenommen sind. Knoten e nutzt also Mf f und kombiniert die Matrix mit der Tabelle seines zweiten direkten Nachkommens 3“ zu der folgenden Matrix: ” 4 Ein Dynamic Programming-Ansatz 68 Abbildung 4.9: Matrix Mef Da im Unter-DAG von Ue bzgl. e ein kritischer Knoten f enthalten ist, erzeugt e eine zweite Matrix Mee , die von f unabhängig ist: Abbildung 4.10: Matrix Mee Da Knoten e von f abhängt und die Abhängigkeit für die Entscheidung über den optimalen Pfad für e aufgelöst werden soll, enthält Mee nur die Kosten für das Laden und Speichern des Wertes. Die Kosten zur Berechnung von e werden bis nach der Entscheidung bei b auf 0 KP gesetzt. Diese Matrix wird von den Knoten c und d entsprechend mit den anderen Nachkommen kombiniert und so liegen vor der Entscheidung bei b zwei Matrizen Mce und Mde vor: Abbildung 4.11: Mce und Mde vor Entscheidung Aus den zwei Matrizen entsteht die Matrix mit den optimalen Durchläufen für jedes e indem jede Zeile j > 1 aus der ersten Matrix mit der Zeile j = 1 aus der zweiten Matrix kombiniert wird (siehe Beispiel Abb. 4.5). Die erste Zeile einer Matrix repräsentiert dabei stets den Fall, dass der Wert von e bereits berechnet wurde und aus dem Speicher geladen werden kann. Aus diesen Grund besitzt Zeile j = 1 auch keinen 0-Wert, weil es sich auf der einen Seite nicht lohnt einen geladenen Wert wieder auszulagern und auf 4 Ein Dynamic Programming-Ansatz 69 der anderen Seite um den Wert von e laden zu können, dieser zuerst berechnet werden müsste. Wollte man diesen Wert nutzen, um die Stacktiefe zu verringern, so dürfte man diese Berechnung nicht vor allen anderen durchführen, da der Wert von e noch nicht existiert. Abbildung 4.12: Mbe nach Entscheidung Die Matrix aus Abb. 4.12 repräsentiert die bei Knoten b getroffene Entscheidung für die optimale Berechnung von Knoten e. Diese Matrix entsteht durch die Kombination der Matrizen der direkten Nachfolger von b. Die günstigste Variante in jeder Zeile repräsentiert stets die Berechnung von e als Teil des linken Kindknotens von b. Dies verdeutlicht nochmal, dass der optimale Pfad zur Berechnung von e unabhängig von der konkreten Größe von e und somit auch von f ist. In Zeile j = 2 ist der Eintrag mit i = 3 die günstigste Variante, weil Knoten c drei Stackplätze benötigt und d, als derjenige der den berechneten Wert von e nutzt, nur zwei. Dadurch entsteht der Eintrag in Mbe mit dem Wert c(c) + c(d) = 7 KP an der Position j = 2 und i = max(ic , id ) = 3. Abhängig vom gewählten x ergibt bei j = 3 entweder i = 3 oder i = 4 die günstigste Variante, die ebenso die Berechnung von e als Teil von c repräsentiert. Analog berechnet sich die Zeile j = D + 1. Wurde der optimale Berechnungspfad für e einmal bestimmt, so muss wieder die Abhängigkeit zwischen e und f berücksichtigt werden. Da für die Bestimmung der optimalen Reihenfolge die tatsächlichen Kosten und Stackanforderungen von e, die von f abhängen, ignoriert wurden, müssen diese nun aktualisiert werden. Das geschieht durch die folgendermaßen definierte Verknüpfung der Matrizen Mbe und Mef , wobei eine neue Matrix Mbf entsteht: 4 Ein Dynamic Programming-Ansatz 70 Mbf (k, i) = min(Mbe (j, i) + Mef (k, j)), 1 ≤ j, i, k ≤ D + 1 Abbildung 4.13: Aktualisierung von Mbe durch Mef Abb. 4.13 zeigt die Matrizen Mef und Mbf . Mbf repräsentiert die aktualisierte Variante von Mbe bezüglich Knoten f. Es fällt auf, dass die erste Zeile von Mbf im Vergleich zu Mbe nicht leer ist, was darauf zurückzuführen ist, dass über die optimale Berechnung von Knoten f noch nicht entschieden wurde und diese Variante deshalb mit den restlichen an die Vorgänger weitergegeben wird. Die Berechnung der 0-Werte der jeweiligen Zeile von Mbf findet nach der üblichen Formel unabhängig von Mbe und Mef statt. Bei Knoten a werden schließlich die Tabellen Mbf und Mf f kombiniert wodurch die optimale Berechnung von f bestimmt wird. Die Wahl der richtigen Tabelle/Zeile aus Mf f für die Stackcodeerzeugung wird mit Hilfe der tatsächlichen Stackanforderungen von f bestimmt. Um nicht unnötig viele Tabellen für jede Matrix zu generieren, dürfen bei einem gemeinsamen Knoten, der von keinem weiteren gemeinsamen Knoten abhängt nur zwei Tabellen gespeichert werden: eine für den Fall des Ladens des Wertes und eine für die Berechnung. Dadurch spart man etwas an Aufwand bei Kombinationen und Aktualisierungen von Matrizen. Bei mehr als zwei unentscheidbaren Knoten in einer Anordnung wie in Abb. 4.6 werden die Matrizen auf die Kosten und Stackanforderungen des jeweiligen kritischen Knotens aktualisiert. 4 Ein Dynamic Programming-Ansatz 71 Abbildung 4.14: Vier gemeinsame Knoten in Abhängigkeit Der DAG in Abb. 4.14 wird in dfpo-Ordnung rekursiv durchlaufen bis man Knoten d erreicht. Knoten d generiert seine Matrix Mdd und markiert sich als berechnet, damit die Matrix nicht mehrmals neu erstellt wird. Als nächstes wird Knoten c ausgewertet, welcher abhängig von der Mdd -Matrix seine Mcd -Matrix erstellt. Knoten b sieht als nächsten Knoten c ∈ Gk (b) und generiert deshalb zunächst Mbc , damit nicht zusätzlich auch noch die Fälle von d betrachtet werden müssen. Knoten a erstellt ebenso eine Matrix Mab nur von b abhängig. Über den optimalen Berechnungspfad wird bei Knoten 1 mit Hilfe von Matrizen abhängig von Knoten a entschieden. Nach der Bestimmung wird die resultierende Matrix auf Knoten b ∈ Gk (a) aktualisiert und somit kann bei 2 über die optimale Berechnung von b entschieden werden. Nach der Entscheidung bei Knoten 3 und der anschließenden Aktualisierung mit Mcd kann die Entscheidung bei 4 getroffen werden. Knoten 4 kombiniert M3d und Mdd und wählt eine Tabelle aus, welche die optimale Berechnung des gesamten DAGs darstellt. Wie lassen sich jedoch DAGs berechnen, die Unter-DAGs Ue mit mehr als einem kritischen Knoten k ∈ Gk (e) enthalten? Liegt zum Beispiel ein DAG wie in Abb. 3.21 vor, so enthält der Unter-DAG Ux zwei kritische gemeinsame Knoten d und e. Würde man versuchen diesen Fall mit Matrizen zu lösen, so müsste man bei Knoten x zwei Matri- 4 Ein Dynamic Programming-Ansatz 72 zen Mdd und Mee miteinander kombinieren, wodurch eine zusätzliche Dimension in der Matrix-Struktur notwendig wird. Da der DAG aus Abb. 3.21 eindeutig nicht-collabierbar ist, scheint sich die Behauptung am Anfang des Abschnitts zu bestätigen, dass collabierbare DAGs für alle Entscheidungsknoten e ∈ V maximal einen kritischen Knoten k ∈ V in ihrem Unter-DAG Ue enthalten dürfen. Der folgende Abschnitt macht einen Versuch die Behauptung zusätzlich zu untermauern. 4.3 Berechnung nicht-collabierbarer DAGs Die Berechnung des optimalen Stackcodes für den DAG aus Abb. 3.21 aus Abschnitt 3.5.3 erweist sich als sehr problematisch, da beide Entscheidungsknoten a und c voneinander abhängen. Knoten c benötigt die Entscheidung von a und die Entscheidung bei a hängt von der bei c ab. Dafür verantwortlich ist Knoten x, mit |Gk (x)| > 2, da er die Verknüpfung zu den beiden gemeinsamen Knoten d und e herstellt. Knoten wie x bedürfen einer Sonderbehandlung, zum Beispiel Vorberechnung einer der kritischen Knoten, damit zumindest die effiziente Optimierung des restlichen DAGs möglich wird. Interessanterweise fällt unter genauer Betrachtung auf, dass solche Knoten einen DAG nicht-collabierbar machen. Siehe dazu die Definition der Collabierbarkeit in Abschnitt 3.5.4. Collabierbare Anordnungen innerhalb eines DAGs zeichnen sich dadurch aus, dass eine Entscheidung über die optimale Berechnung eines gemeinsamen Knotens stets unabhängig von einem anderen gemeinsamen Knoten geschieht. Hängen mindestens zwei gemeinsame Knoten in irgendeiner Weise voneinander ab, wie in Abb. 3.21, so können sich deren Entscheidungsknoten gegenseitig behindern. Für die Erzeugung von optimalem Stackcode für solche DAGs ist es deshalb notwendig zu wissen, wann solch ein Fall vorliegt und wie man ihn löst. In diesem Abschnitt soll gezeigt werden, dass Unter-DAGs mit mehreren kritischen Knoten einen DAG tatsächlich nicht-collabierbar machen und dass durch eine korrekte Behandlung solcher Knoten die Collabierbarkeit wiederhergestellt werden kann. Satz 4.1: Jeder DAG, der einen Entscheidungsknoten e ∈ V mit mehr als einem kritischen Knoten k ∈ Gk (e) im Unter-DAG Ue enthält ist nicht collabierbar. Es ist nicht beabsichtigt diesen Satz vollständig zu beweisen, da dies nicht die Aufgabe dieser Arbeit wäre. Vielmehr soll eine Beweisidee vorgestellt werden. Hierfür die folgende These: These: Ein nicht-collabierbarer DAG besitzt mindestens zwei gemeinsame Knoten. Beweis: Zunächst soll untersucht werden welche Anordnungen von Knoten die Collabierbarkeit behindern. 4 Ein Dynamic Programming-Ansatz 73 Abbildung 4.15: Nicht-collabierbar Damit man einen Knoten y nach den Transformationsregeln für collabierbare DAGs löschen darf muss Folgendes gelten: GE (y) = 1 und GA (y) = 0 (Regel 1) oder GE (y) = 1 und GA (y) = 1 (Regel 2). Ein Knoten y kann also nur dann nicht gelöscht werden, wenn GE (y) > 1 gilt. Hierfür kommt die 3. Regel zum Einsatz, falls die eingehenden Kanten vom selben Elternknoten x kommen. Falls jedoch noch ein innerer Knoten z sich auf dem Pfad zwischen x und y Knoten befindet, so muss dieser mit der 2. Regel gelöscht werden, bevor die 3. Regel angewendet werden kann. Diese lässt sich nicht anwenden wenn GA (z) > 1 oder GE (z) > 1. Ist GE (z) > 1, so ist z der 2. gemeinsame Knoten im DAG und falls GA (z) > 1 gilt, so zeigt er entweder auf den einzigen gemeinsamen Knoten y oder auf einen anderen Knoten im DAG (rote Kante 1 oder 2 in Abb. 4.15). Der letztere Fall würde auch bedeuten, dass noch ein gemeinsamer Knoten im DAG existiert. Der erste Fall (rote Kante 3) lässt sich mit der 3. Regel auflösen. Ohne einen zweiten gemeinsamen Knoten ist also Nicht-Collabierbarkeit nicht möglich. q.e.d. Beweisidee für Satz 4.1: Man nehme zur besseren Übersicht an, es existiere genau ein Knoten a, mit |Gk (a)| = 2 und zwei gemeinsame Knoten b und c ∈ Gk (a). Außerdem gibt es nur diese zwei gemeinsame Knoten im DAG, die jeweils zwei Elternknoten besitzen, wobei einer der Eltern der Knoten a ist. Die Knoten b und c seien außerdem unabhängig, es existiert also kein Pfad b 7→ c oder c 7→ b. Es herrsche also die folgende Anordnung: Abbildung 4.16: Ein nicht-collabierbarer DAG 4 Ein Dynamic Programming-Ansatz 74 Da ein DAG zusammenhängend und somit jeder Knoten von der Wurzel aus erreichbar ist, existieren Vorgänger von b und c, die entweder auf einem Pfad von der Wurzel r zu Knoten a (rote Kanten 1) und/oder auf einem Pfad von a (also im Teil-DAG mit Wurzel a) zu b oder c liegen (rote Kanten 2). Es sollen nun zwei mögliche Fälle für die Lage des zweiten Vorfahren von c untersucht werden. Ergebnisse der beiden Fälle sollen auch analog für b gelten. Abbildung 4.17: Immer collabierbar 1. Fall: Zweiter Vorfahre von c sei a oder Nachkomme von a Die drei roten Kanten 1, 2 und 3 zeigen die drei möglichen Stellen, wo der zweite Vorfahre von c im Teil-DAG von a liegen könnte: • a sei selbst der zweite Vorfahre (rote Kante 1) • der zweite Vorfahre liege im rechten Teil-DAG von a und sei nicht a (rote Kante 2) • der zweite Vorfahre liege im linken Teil-DAG von a und sei nicht a (rote Kante 3) Ist a selbst der zweite Vorfahre von c (rote Kante 1), so lässt sich zunächst der rechte Teil-DAG von a bis auf a komplett collabieren, da aufgrund der oben definierten Beschränkungen (maximal zwei gemeinsame Knoten) c der einzige gemeinsame Knoten im rechten Teil-DAG ist und ein DAG mit weniger als zwei gemeinsamen Knoten nach der These immer collabierbar ist. In diesem Fall ist also nur b ∈ Gk (a) ein kritischer Knoten. Nach der Entscheidung über die optimale Berechnung von c, kann c aus G(a) entfernt werden. Da es insgesamt nur zwei gemeinsame Knoten im DAG gibt und c gelöscht werden kann, so wird der gesamte DAG nach der These collabierbar. Gehört der zweite Vorfahre von c zur Menge der rechten Nachkommen von a (rote Kante 2), so gilt hier dieselbe Begründung wie bei roter Kante 1. In diesem Fall ist c ∈ / G(a), 4 Ein Dynamic Programming-Ansatz 75 da c lokal im rechten Teil-DAG von a aufgelöst werden kann, wobei c aus G(a) gelöscht wird. Da also Gk (a) und somit auch G(a) nur b enthalten, wird der DAG nach der These collabierbar. Die dritte Möglichkeit repräsentiert wiederum das zu beweisende Problem, da der zweite Vorfahre von c einen Entscheidungsknoten mit zwei kritischen Knoten in seinem UnterDAG enthält. Durch die oben definierten Einschränkungen darf es aber zunächst nur einen solchen Entscheidungsknoten geben. Lässt sich also der rechte Teil-DAG von a collabieren, so bleibt b als einziger gemeinsamer Knoten im DAG, womit der gesamte DAG collabierbar wird. Der Satz 4.1 ist demnach für den 1. Fall gültig. 2. Fall: Zweiter Vorfahre von c sei ein Vorfahre von a Gehören die Vorfahren von b und c jedoch zur Menge der Vorgänger von a, so gilt |Gk (a)| = 2 und keiner der Teil-DAGs von a lässt sich collabieren. Aufgrund der Anordnung, die genau ein Mal im DAG auftaucht und der definierten Transformationen zur Collabierbarkeit eines DAGs lässt sich ein beliebiger DAG, der die oberen Einschränkungen einhält und b und c kritische Knoten für Ua sind, auf einen der folgenden DAGs collabieren: Abbildung 4.18: Nicht weiter collabierbar Es sind keine weiteren Transformationen möglich, da alle Blätter mehr als einen Elternknoten besitzen, die alle unterschiedlich sind und kein innerer Knoten genau einen Vorfahren und einen Nachkommen besitzt. Die Knoten r und s dürfen auch derselbe Knoten sein. Nun werden die definierten Einschränkungen Schritt für Schritt aufgehoben. Zunächst wird eine beliebige Anzahl von kritischen Knoten in einem Unter-DAG zugelassen, die jeweils zwei Vorfahren haben und einer dieser Vorfahren Knoten a sei. Aufgrund der 4 Ein Dynamic Programming-Ansatz 76 Transformationsregeln (insbesondere Regel 2) ändert diese Verallgemeinerung nichts an der Collabierbarkeit, da dadurch nur der Ausgangsgrad von a erhöht wird. Bei n Kindknoten von a müssen n − 1 collabierbar sein, damit auch a gelöscht werden kann. Auch die Zulassung beliebiger Eingangsgrade > 2 bzw. beliebiger Anzahl der Vorfahren der kritischen Knoten widerspricht nicht dem Satz, da nur die Tatsache zählt, wie viele der gemeinsamen Knoten ∈ Gk (a) sind. Beinhaltet der Unter-DAG von a zusätzliche Knoten v mit |Gk (v)| ≥ 2, wie im Falle der roten Kante 3 in Abb. 4.17, oder existieren solche Knoten anders wo im DAG, so ist der gesamte DAG nicht-collabierbar, da nur ein einziger Knoten solcher Art ausreicht, um den DAG nicht-collabierbar zu machen. Hängen die gemeinsamen Knoten jedoch voneinander ab, so liegt es auch an der Lage der Vorfahren von b ob die Stelle collabierbar wird oder nicht. Abbildung 4.19: Abhängigkeiten zwischen gemeinsamen Knoten Liegen alle Vorfahren von b im linken Unter-DAG von a (rote Kante 1), so kann b auf einen Knoten mit GE (b) = GA (b) = 1 reduziert werden. Mit der 2. Regel könnte dann b und anschließend auch c gelöscht werden. Gk (a) enthält in diesem Fall keine Knoten und Gk (b) nur den Knoten c. Der Satz 4.1 gilt also auch hier. Im Falle der roten Kante 2 gehört Knoten c zusammen mit b zur Menge Gk (a), denn c erfüllt die Bedingung der Definition 4.3 für kritische Knoten, da c von a aus über einen Pfad, der nicht durch b führt erreichbar ist. c erbt“ also die Unentscheidbarkeit ” von b, obwohl alle Vorfahren von c in Ua liegen. Somit ist |Gk (a)| = 2 und der DAG nicht-collabierbar. Für die rote Kante 3 gilt dieselbe Begründung: der Entscheidungsknoten e ∈ Va mit e 6= a besitzt b und c als kritische Knoten und ist damit nicht-collabierbar. q.e.d. Besitzt ein Knoten n > 1 kritische Knoten in seinem Unter-DAG, so ist der DAG nichtcollabierbar. Dadurch ist die effiziente Suche nach der optimalen Berechnung eines DAGs nicht mehr möglich (siehe vorherigen Abschnitt). Ein möglicher Ansatz dieses Problem zu lösen ist bei Erkennung eines solchen Knotens n−1 kritischen Knoten vor der erneuten 4 Ein Dynamic Programming-Ansatz 77 Optimierung des DAGs vorberechnen zu lassen und damit die mehrfache Nutzung der Knoten aufzuheben. Nach der bewiesenen These wird die Stelle dadurch collabierbar, da nur eine gemeinsame Nutzung übrig bleibt. Ein DAG wird auf diese Weise in zwei Phasen optimiert: 1 2 3 optimize (DAG) { // B e i n h a l t e t Knoten zum Vorberechnen pre calc list ; 4 // Phase 1 while ( containsNotCollapsible (DAG) ) { 5 6 7 n − 1 R e f e r e n z e n zu p r e c a l c l i s t h i n z u f ü g e n ; 8 9 A l l e Nutzungen durch B l ä t t e r e r s e t z e n ; 10 } 11 12 // Phase 2 Erzeuge Code f ü r a l l e Knoten i n p r e c a l c l i s t ; 13 14 15 Erzeuge Code f ü r den r e s t l i c h e n DAG; 16 17 } Da dies nur eine heuristische Lösung ist garantiert sie keine theoretisch optimalen Ergebnisse. Genau so wäre es möglich beim Auffinden eines Knotens mit n kritischen Knoten alle n − 1 kritischen Knoten als Nachfolger des aktuellen Knotens zu berechnen. Dies hätte jedoch unter Umständen eine höhere Stacktiefe und einen deutlich komplexeren Entscheidungsalgorithmus über die genaue Reihenfolge der Berechnung zur Folge. 4.4 Laufzeit und Speicherverbrauch Für die Optimierung eines beliebigen DAGs, also für die Erzeugung und Kombination von Matrizen, wird jeder der n Knoten im DAG genau einmal besucht. Abhängig von der Größe eines Programms arbeitet der Dynamic Programming-Ansatz damit in O(n) Schritten. Man nehme an, dass jeder Knoten eines zu optimierenden DAGs seinen Eingangsund Ausgangsgrad kennt. Für jeden besuchten Knoten muss die optimale Reihenfolge der direkten Nachfolger gefunden werden. Ist der maximale Ausgangsgrad eines DAGs max(GA ) = m, so gibt es m! Möglichkeiten die direkten Nachfolger eines Knotens anzuordnen. Sei D außerdem die maximale Stacktiefe, so gibt es (D + 2)m Kombinationen der Tabelleneinträge. Dazu kommen D + 1 mögliche Tabellen einer Matrix pro Knoten. Kombinationen mehrerer temporärer Matrizen können nur bei Knoten, die über den Berechnungspfad zu den gemeinsamen Knoten entscheiden vorkommen. Für die Aktualisierung einer Matrix mit einer anderen Matrix, nach der Formel aus Abb. 4.13, 4 Ein Dynamic Programming-Ansatz 78 werden D3 Schritte gebraucht. Im schlimmsten Fall müssen also pro besuchten Knoten O(m! ∗ (D + 1) ∗ (D + 2)m + D3 ) = O(m! ∗ Dm ) Schritte ausgeführt werden. Da jedoch für praktische Anwendungen der maximale Ausgangsgrad eines Knotens (Anzahl der Argumente einer Funktion) << 10 anzunehmen und die Größe eines stack buffers endlich ist (4 ≤ D ≤ 16 siehe Abschnitt 2.2) können die Faktoren als konstant angesehen werden. Damit nicht für jeden nicht-collabierbaren Knoten nach seiner Auflösung der gesamte DAG jedes Mal neu durchlaufen werden muss, kann eine zusätzliche Optimierungsphase solche Knoten suchen und sie entsprechend behandeln. Durch eine Vorberechnung der Permutationen (siehe 6.3.2) und Entfernung ungültiger Reihenfolgen kann der Faktor m! entschärft werden. Auch der Faktor (D + 2)m kann durch die Kombination nur der nicht-leeren Tabelleneinträge in praktischen Anwendungen deutlich gesenkt werden. Jeder der n Knoten im DAG braucht maximal eine (D +1) x (D +2) große Matrix für die Optimierung und eine zweite Matrix gleicher Größe für die spätere Stackcodeerzeugung (siehe 5.2). Man benötigt also O(n + 2 ∗ n ∗ (D + 1) ∗ (D + 2)) = O(n ∗ (2D2 + 6D + 5)) = O(n ∗ D2 ) Speicher für die Knoten und die Matrizen der Knoten. Der Speicherverbrauch ist also O(n), da D bezogen auf die Größe eines Programms als konstant angesehen werden kann. 5 Der JBCG-Zwischencode In den ersten vier Kapiteln wurden verschiedene Verfahren zur optimierten Stackcodeerzeugung für die JVM in Bezug auf die common subexpression“-Problematik vorgestellt ” und analysiert. Dieses Kapitel stellt ein ambitioniertes Java-Zwischencodemodell vor, das die Java-Bytecode-Erzeugung und die Optimierung von Java-Bytecode effizienter gestalten soll. Außerdem soll das Modell um einige interessante Funktionalitäten erweitert werden. 5.1 Einführung Das JBCG-Zwischencodemodell (Java Bytecode Generator) wurde im Rahmen einer Bachelorarbeit [Pra08] von A. Prante entwickelt und repräsentiert ein Zwischencodemodell, das sich stark am Java-Bytecode orientiert. Das Modell ist in vier Pakete ( jcb” gen“, classfile“, basicblock“, expression“) gegliedert, die die verschiedenen Ebenen ” ” ” der Codeerzeugung repräsentieren. Das classfile-Paket stellt die globale Sicht auf eine Java-class-Datei dar. Demnach enthält eine Java-Klasse auf der obersten Ebene Felder, Methoden und Konstruktoren. Methoden und Konstruktoren besitzen Parameter, lokale Variablen und einen Rumpf mit einer Abfolge von Anweisungen. Die Repräsentation des Rumpfes wurde mit der Absicht der Optimierung gewählt. Jeder Rumpf ist daher wie ein Datenflussgraph mit basic blocks als Knoten aufgebaut. Ein sequenzielle Abfolge von Befehlen, die zu einem basic block gehört wird z.B. durch die Klasse SequenceBlock“ ” dargestellt, die eine Liste von Ausdrücken besitzt. Diese repräsentieren den Inhalt eines basic blocks. Jeder Ausdruck in der Liste ist ein Objekt einer der Klassen im expressi” on“-Paket. So stellt zum Beispiel die Klasse BinaryOperation“ binäre Operationen dar, ” die einen linken und rechten Ausdruck mit Hilfe einer bestimmten Operation, z. B. der Addition, verknüpfen. Einfache Werte werden je nach Typ durch die entsprechende Con” stant“-Klasse modelliert. Ein Integer-Wert ist also ein Objekt der Klasse IntegerCon” stant“ und ist, wie alle Klassen des expression“-Pakets, eine Repräsentation eines Aus” drucks. Die vollständige Dokumentation des Zwischencodemodells in Form von JavaDoc befindet sich auf der beigelegten CD unter Code\JavaByteCodeGenerator\trunk\doc. Ein beliebiges Programm wird also im JBCG-Zwischencode als ein DAG aus Objekten der Architektur des Zwischencodes dargestellt. Das folgende Beispiel zeigt wie ein einfaches Programm durch das Zwischencodemodell repräsentiert wird: 79 5 Der JBCG-Zwischencode 80 1 int i = 1 + 2 ; Abbildung 5.1: Programm in JBCG-Objektstruktur Für die Ausführung von Java-Bytecode ist es notwendig, dass die Klasse mit der die Ausführung eines Programms beginnt, eine main-Methode mit einem String-Array- Parameter und einer leeren Returnanweisung (void) besitzt. Klassen, die nicht zur Ausführung gebracht werden sollen benötigen keine main-Methode. Um die Stackcodeerzeugung eines Programms mit Hilfe des Zwischencodemodells optimieren zu können, muss für jeden basic block ein DAG generiert werden, der die Berechnungen innerhalb des basic blocks repräsentiert. Für die effiziente Erzeugung von DAGs sind mehrere Algorithmen aus der Literatur [Mai97, Aho] bekannt. Das BeispielProgramm mit der Objektstruktur aus Abb. 5.1 würde also für die Optimierung in den folgenden DAG bzw. in diesem Fall Ausdrucksbaum umgewandelt werden: 5 Der JBCG-Zwischencode 81 Abbildung 5.2: DAG für Optimierung Die eigentliche Erzeugung von Java-Bytecode wird mit Hilfe der cojen-Bibliothek [Coj] durchgeführt. Hierfür besitzt jedes Objekt in der baumartigen Struktur des Zwischencodes, der ein Programm repräsentiert, die Methode toJavaBytcode“. Diese Methode ” enthält die entsprechenden Befehle der cojen-Bibliothek, um eine class-Datei zu generieren. Die class-Datei wird dabei zunächst im Speicher als eine cojen-Objektstruktur repräsentiert. Um die Operanden einer Operation zu erzeugen werden die toJavaByt” code“-Methoden der Operanden rekursiv aufgerufen. So werden irgendwann Objekte erreicht, die keine Operanden oder Argumente besitzen (also Blattknoten), z. B. eine Integer-Konstante. Mit Hilfe der toJavaBytecode“-Methode wird in diesen Knoten der ” entsprechende push“-Befehl in die class-Datei geschrieben. Der cojen-Befehl um eine ” 1“ auf den Stack zu schreiben wäre zum Beispiel loadConstant(1)“. Sind alle Ope” ” randen einer Operation abgearbeitet, so wird der entsprechende Operationsbefehl (z. B. iadd“ für Integer-Addition) in die class-Datei geschrieben. Nachdem das Wurzelob” ” jekt“ generiert wurde, wird die cojen-Objektstruktur in eine konkrete class-Datei auf der Festplatte des Computers übersetzt. Diese kann nun auf der JVM direkt ausgeführt werden. Die cojen-Bibliothek stellt ein Framework zur Erzeugung von Java-class-Dateien bereit. Der Funktionsumfang von cojen deckt laut den Entwicklern die Möglichkeiten der JVM vollständig ab. Zwar ermöglicht cojen damit eine relativ bequeme Generierung von JavaBytecode, jedoch verzögert sich die Entwicklung von komplexeren Abläufen oft an den fehlenden Möglichkeiten, die Prozesse effizient nach Fehlern und Schwachstellen durchsuchen zu können. Bei groben Fehlern wie z.B. dem Vergessen einer Objektreferenz für einen Methodenaufruf gibt es zwar entsprechende Fehlermeldungen, die jedoch nicht immer einen Hinweis auf den Ort des Auftretens des Fehlers angeben. Im schlimmsten Fall gibt es eine Fehlermeldung, dass sich entweder noch Elemente auf dem Stack befinden oder dass noch welche erwartet werden. In solchen Situationen helfen nur noch Stift und Papier, um sich die Abfolge der Elemente, die auf dem Stack auftauchen vor Augen führen zu können. Das JBCG-Zwischencodemodell versucht es besser zu machen, indem ungültige Stackzustände erst gar nicht zugelassen werden. Dies stellt die 5 Der JBCG-Zwischencode 82 Objektstruktur sicher, indem leere oder nicht initialisierte Operanden oder Argumente während der Bytecode-Generierung gemeldet werden oder schon bei der Erstellung der Objektstruktur zu Nullpointer-Exceptions führen. Am Beginn dieser Arbeit fehlten dem JBCG-Zwischencode einige Java-Features, wie die Objektorientierung, Vererbung und die Möglichkeit innere Klassen zu erzeugen. Im Folgenden wird auf die Erweiterung des Zwischencodemodells um einige dieser Funktionalitäten und weiteren interessanten Verbesserungen eingegangen. Details der Implementierung der Erweiterungen werden in Kapitel 6 ausführlicher beschrieben. 5.2 Erweiterung Das Zwischencodemodell stellt zwar die meisten der zur Entwicklung eines Compilers notwendigen Funktionalitäten bereit, es fehlten aber bisher wichtige Features wie die Erzeugung von Objekten, die unter anderem für die Benutzung von Arrays essenziell ist. Mit den fehlenden Möglichkeiten Objekte erzeugen und verwenden zu können waren auch Vererbungen von Klassen und Erzeugung von inneren Klassen nicht möglich. Die Realisierung der Objektorientierung war jedoch aufgrund der Eigenarten der JVM nicht mit den bisher verwendeten Techniken im Zwischencodemodell zu realisieren. Um die Problematik genau zu beschreiben, folgt nun zunächst eine kurze Einführung in die Umsetzung der Objektorientierung in der JVM. Ein Objekt wird in der JVM mit dem Befehl newobject“ erzeugt und auf den Stack ” geschrieben. Soll das Objekt einer bestimmten Klasse angehören, so muss nach seiner Erzeugung ein Konstruktoraufruf erfolgen. Der Konstruktor ist in der JVM als eine spezielle Funktion mit einer Klassenzugehörigkeit und einer Menge an Übergabeparametern modelliert. Die Parameter müssen deshalb vor dem Konstruktoraufruf, wie auch bei gewöhnlichen Funktionsaufrufen, auf dem Stack liegen. Erfolgt also auf dem Stackinhalt, der aus dem nicht-initialisierten Objekt und den Parametern des Konstruktors besteht, ein Konstruktoraufruf, so wird das Objekt initialisiert. Dabei konsumiert“ der ” Konsturktoraufruf invokeSpecial“ jedoch das Objekt und seine Parameter. Wird das ” Objekt im späteren Verlauf des Programms, z. B. für Methodenaufrufe, verwendet, so muss es gleich nach seiner Erzeugung dupliziert werden, damit nach dem Konstruktoraufruf eine initialisierte Kopie auf dem Stack liegen bleibt. Die folgende Abbildung zeigt diesen Prozess nochmal anschaulich: 5 Der JBCG-Zwischencode 83 Abbildung 5.3: Konstruktoraufruf Das bedeutet, dass man für jede Benutzung des Objektes rechtzeitig eine Kopie anfertigen muss, damit sie später verwendet werden kann. Bisher war jedoch die Erzeugung und Nutzung von Klassenobjekten im JBCG-Zwischencodemodell nicht möglich, da ein Ausdruck keine Kenntnis darüber besaß, wie oft er im gesamten Programm verwendet wird. Ein newobject“-Befehl und ein nachfolgender Konstruktoraufruf initialisieren zwar das ” Objekt, es blieb aber keine Kopie für die späteren Nutzungen mehr übrig. Ebenso ist es nicht möglich vor jeder Nutzung eines Objektes newobject“ aufzurufen und zu in” itialisieren, weil dadurch jedes Mal ein anderes Objekt entsteht. Der newobject“-Befehl ” ist demnach ein mehrfach verwendeter Ausdruck, der auch als solcher behandelt werden sollte. Die folgende Abbildung stellt die Initialisierung und Nutzung eines Objektes als Teil eines DAGs dar: Abbildung 5.4: Objektnutzung Der Knoten newobject ist also ein gemeinsamer Knoten, der von mindestens zwei Operationen verwendet wird: dem Konstruktoraufruf und den Nutzungen des Objektes für Klassenmethodenaufrufe oder Feldzugriffe. Die optimierte Erzeugung von DAGs mit gemeinsamen Knoten wurde bereits in den vorherigen Kapiteln ausführlich diskutiert. Die 5 Der JBCG-Zwischencode 84 Techniken lassen sich hier genau so wie bei anderen mehrfach genutzten Ausdrücken anwenden. Es ist aber zu beachten, dass der Konstruktoraufruf stets vor allen Nutzungen des zugehörigen Objektes ausgeführt werden muss. Bei der Wahl des optimalen Pfades zur Berechnung eines newobject“-Ausdrucks kommt also nur derjenige mit dem ” Konstruktoraufruf in Frage. Während der Erweiterung des Zwischencodemodells um die Objektorientierung war es besonders interessant festzustellen, wie Klassenmethoden in der JVM modelliert wurden. So wird eine Klassenmethode durch eine einfache statische Funktion repräsentiert, deren erster Übergabeparameter implizit auf das Objekt der entsprechenden Klasse gesetzt ist. Da jede Funktion in der JVM eine Tabelle mit den zugehörigen lokalen Variablen besitzt und die Übergabeparameter stets die ersten Tabellenplätze belegen, beinhaltet der erste Tabelleneintrag einer Klassenmethode immer die Variable mit dem Klassenobjekt. Dies musste bei der Erweiterung der Codeerzeugung für Funktionen beachtet werden. Die Implementierung der Objektorientierung und der Vererbung als Teil des Zwischencodemodells wird in Kapitel 6 ausführlicher beschrieben. Für die Erzeugung von Arrays in der JVM wird ebenfalls der newobject“-Befehl zu” sammen mit der Array-Länge verwendet. Das so erzeugte Objekt muss jedoch in diesem Fall nicht initialisiert werden. Bei der Erzeugung von mehrdimensionalen Arrays muss für jede Dimension die Länge dieser angegeben werden. Ein Array-Zugriff erfolgt, indem die Array-Referenz, die Indizes und die Werte auf den Stack in geordneter Reihenfolge geschrieben werden bevor der entsprechende Befehl ausgeführt wird. 5.3 Vor- und Nachteile Für die Entwicklung von Compilern, die als Maschinencode den Java-Bytecode generieren, bietet das JBCG-Zwischencodemodell eine gut strukturierte Objektarchitektur an. Diese baut auf der Funktionsweise des cojen-Frameworks auf und bildet dadurch eine höhere Abstraktionsebene, die sich im Vergleich zu cojen auf Ausdrücke anstatt auf Stackoperationen konzentriert. Der Entwickler eines Compilers braucht also nicht mehr unbedingt zu wissen, wie ein Stack funktioniert und welche Operationen die JVM bereitstellt um Java-Bytecode zu generieren. Er sieht nur, dass beispielsweise eine binäre Operation einen linken und einen rechten Ausdruck mit einem Operator verknüpft und dafür Stackcode generiert. Das hat sowohl Vor- und Nachteile, denn dadurch wird die Entwicklung von Compilern einerseits einfacher, es kann aber auch die Möglichkeiten der Umsetzung ausgefallener Ideen einschränken. Zusätzlich basiert die Entwicklung des Zwischencodemodells auf dem Prinzip, keine ungültigen Stackzustände während der Ausführung eines Programms zu zulassen. Dadurch werden Fehler in der Implementierung eines Compilers schneller identifizierbar. Da jeder Ausdruck eines Programms in dem JBCG-Zwischencodemodell einem basic block angehört, lassen sich lokale Optimierungstechniken besonders schnell und effizient 5 Der JBCG-Zwischencode 85 anwenden. Die Suche nach Ausdrücken, die einem basic block angehören fällt dadurch vollständig weg, was nicht nur die Effizienz, sondern auch die Übersichtlichkeit der Implementierungen von Optimierungen steigert. Für jeden basic block können schon während der Umwandlung eines Programms in den Zwischencode die zugehörigen DAGs generiert werden, die sofort optimierbar vorliegen. Durch die Entkopplung der Stackcodeerzeugung von der Architektur des Zwischencodemodells (siehe Abschnitt 6.1) lassen sich beliebig viele Optimierungen oder Optimierungsschritte auf einem Programm anwenden und ihre Ergebnisse auswerten. Für die zukünftige Weiterentwicklung des Zwischencodes wären demnach, außer der Umsetzung restlicher cojen-Funktionalitäten (z. B. innere Klassen) Hilfsklassen oder Templates für die Entwicklung bestimmter Optimierungstechniken vorteilhaft. Dadurch könnten Optimierungsansätze schneller umgesetzt und analysiert werden. Ebenso würde eine leicht konfigurierbare graphische Visualisierung von Optimierungsabläufen nicht nur die Entwicklung und Analyse von Algorithmen erleichtern, sondern auch zu Bildungszwecken eingesetzt werden können. 6 Implementierung Dieses Kapitel gibt einen Einblick in die verwendeten Implementierungstechniken zur Umsetzung der in dieser Arbeit vorgestellten Algorithmen und Ideen mit Java. Zuerst wird die Umsetzung des Durchlaufes eines Ausdrucksbaumes oder DAGs vorgestellt. Damit wird auch die Entkopplung der Stackcodeerzeugung von dem Zwischencode ermöglicht. In 6.2 folgt die Beschreibung der Implementierung der verschiedenen Techniken zur Speicherung der Werte von mehrfach genutzten Ausdrücken. Abschnitt 6.3 stellt schließlich die Umsetzung des in Kapitel 4 entwickelten Dynamic Programming-Ansatzes vor. 6.1 Durchlauf und Entkopplung Wie in Abschnitt 5.1 bereits eingeführt, wurde für die Untersuchung und Implementierung verschiedener Optimierungstechniken das JBCG-Zwischencodemodell verwendet, das um die Objektorientierung erweitert wurde. In der Originalversion des Zwischencodes wurde jeder Knoten eines Ausdrucksbaumes oder eines DAGs mit Hilfe eines Objektes der zugehörigen Klasse repräsentiert. Jede Klasse besaß dabei die toJavaBy” tecode“-Methode, mit derer Hilfe man die Baumstruktur durchlaufen und den zugehörigen Stackcode erzeugen konnte (siehe Abb. 5.2). Diese Technik hatte den Nachteil, dass für die Implementierung eines anderen Durchlaufes durch einen Baum oder DAG, zum Beispiel eine bestimmte Optimierungsphase, entweder die Methode toJavaBytecode“ ” angepasst werden oder jedoch eine zusätzliche Methode angelegt werden müsste, die ähnlich wie toJavaBytecode“ funktioniert. Die erste Lösung ergibt einen sehr unüber” sichtlichen und schwer pflegbaren Code, da sich alle Optimierungen in einer Methode befinden. Der zweite Ansatz bedeutet sogar die Anpassung der gesamten Objektstruktur des Zwischencodemodells. Beide Lösungen sind zu unflexibel und machen die Entwicklung und den Einsatz verschiedener Optimierungstechniken unnötig umständlich. Um also das Zwischencodemodell nutzen zu können, ohne es jedes Mal ändern zu müssen, sollte die Funktionalität von toJavaByteCode“ aus der Objektstruktur ausgelagert wer” den. Hierfür eignet sich das Visitor-Pattern besonders gut, denn es ist genau für den Zweck geschaffen, eine Objektstruktur und den darauf angewendeten Algorithmus zu entkoppeln. Nach dem Open-Closed Principle OCP der Software-Entwicklung sollte eine Objektstruktur zwar leicht erweiterbar (open), aber nicht notwendigerweise modifizierbar sein (closed). Für den Durchlauf der JBCG-Objektstruktur wurde deshalb im 86 6 Implementierung 87 Rahmen dieser Arbeit eine klassische TreeWalker-Klasse eingesetzt, die ein Teil des Visitor Patterns für Graphen ist. Die folgenden Codezeilen zeigen die abstrakte Definition eines TreeWalkers: 1 public abstract c l a s s TreeWalker<ReturnType , ArgumentType> { 2 public f i n a l ReturnType walk (JBCG node , ArgumentType a r g ) { return node . walk ( this , a r g ) ; } 3 4 5 6 public abstract ReturnType walkA (A node , ArgumentType a r g ) ; 7 8 } Diese beinhaltet für jedes Objekt einer Objektstruktur die abstrakte Version der zugehörigen walk-Methode. Eine solche walk-Methode wird aufgerufen um bestimmte Aktionen für das spezielle Objekt auszuführen. Außerdem definiert TreeWalker die allgemeine walk-Methode, die ein Argument des allgemeinsten Datentyps der Objektstruktur und ein Argument beliebigen Typs erwartet. Für den Einsatz des TreeWalkers im JBCGZwischencode wurde eine Klasse JBCG“ angelegt, die von allen anderen Klassen geerbt ” wird. Sie repräsentiert den allgemeinsten Datentyp und enthält eine einzige abstrakte Methode walk“. ” 1 2 3 public abstract c l a s s JBCG { public abstract <ReturnType , ArgumentType> ReturnType walk ( TreeWalker<ReturnType , ArgumentType> walker , ArgumentType a r g ) ; 4 5 } Somit hat jede Klasse, die von JBCG erbt, die Methode walk“ zu implementieren. ” Nimmt man zum Beispiel die bereits bekannt Klasse IntegerConstant“, so sieht die ” walk-Methode für diesen Objekttyp folgendermaßen aus: 1 2 3 4 5 public <ReturnType , ArgumentType> ReturnType walk ( TreeWalker<ReturnType , ArgumentType> walker , ArgumentType a r g ) { return w a l k e r . w a l k I n t e g e r C o n s t a n t ( this , a r g ) ; } Die Methode IntegerConstant.walk erhält also als ersten Parameter eine Referenz auf den aktuellen TreeWalker und ein beliebiges Argument. Mit Hilfe des TreeWalkers walker“ ” wird die IntegerConstant zugeordnete Zielmethode walkIntegerConstant“ aufgerufen. ” Diese Methode kann jeder TreeWalker beliebig implementieren. 6 Implementierung 88 Mit dem Einsatz eines TreeWalkers wird also ein Ausdrucksbaum oder DAG durchlaufen, indem man den Wurzelknoten an die Methode walk“ übergibt. Diese ruft die passende ” Zielmethode auf, die z. B. im Falle einer BinaryOperation zuerst den linken und dann den rechten Kindknoten an walk“ übergibt. Die Zielmethoden der Kinder führen ähnliche ” Aktionen aus bis man an einem Blattknoten angelangt ist. Dieser führt wiederum seine Aktionen aus und gibt einen Wert zurück. Ist erst einmal die TreeWalker-Klasse erstellt und eine walk-Methode für jeden Objekttyp definiert, so lässt sich ein beliebiger Durchlauf leicht realisieren ohne die Objektstruktur zu verändern. Der folgende Codeausschnitt zeigt die Definition eines möglichen TreeWalkers: 1 2 3 4 public c l a s s DAGWalker extends TreeWalker<Object , CodeBuilder> { // I m p l e m e n t i e r t d i e walk−Methoden } Ein konkreter TreeWalker ist demnach eine weitere Klasse, die von der abstrakten Klasse TreeWalker erbt und dazu den Rückgabe- und Argumentendatentyp seiner Methoden festlegt. Zum Beispiel Object“ und CodeBuilder“. Damit schreibt man jeder walk” ” Methode des konkreten TreeWalkers vor, einen Rückgabewert vom Typ Object“ und ” ein Argument vom Typ Codebuilder“ zu definieren. In solch einer Klasse wird jede im ” TreeWalker definierte abstrakte Methode implementiert. Man kann also beliebig viele solcher TreeWalker erzeugen, die jeweils ein Objekt aus dem Zwischencodemodell unterschiedlich behandeln. Eine mögliche Implementierung der walk-Methode der IntegerConstant-Klasse ist im folgenden Codeabschnitt zu sehen. Sie repräsentiert die Erzeugung von Stackcode einer push“-Anweisung mit dem cojen-Framework. ” 1 2 3 4 public Object w a l k I n t e g e r C o n s t a n t ( I n t e g e r C o n s t a n t node , CodeBuilder c o d e B u i l d e r ) { c o d e B u i l d e r . l o a d C o n s t a n t ( node . g e t C o n s t a n t ( ) ) ; 5 return null ; 6 7 8 } Die folgende Abbildung zeigt eine schematische Darstellung der Nutzung mehrerer verschiedener TreeWalker für ein Objekt o: 6 Implementierung 89 Abbildung 6.1: Nutzung verschiedener TreeWalker Der Durchlauf durch eine baumartige Objektstruktur beginnt stets mit dem Aufruf der walk-Methode eines TreeWalkers. Hier kommt der Polymorphismus von Java zum Einsatz, indem die Methode walk“ zur Laufzeit entscheidet, welche Zielmethode zum ” übergebenen Objekttyp passt. Diese Eigenschaft kann sehr nützlich sein, wenn man zum Beispiel ein Array aus Ausdrücken abarbeiten möchte und jeder Ausdruck/Expression einer anderen Ausdrucksklasse, z. B. IntegerConstant“ oder BinaryOperation“, an” ” gehört. Jedem Wert des Arrays kann durch den Aufruf von walk“ trotzdem die korrekte ” Zielmethode zugeordnet werden. 6.2 Erweiterung des Modells Die Umsetzung der Objektorientierung als Teil des Zwischencodemodells wurde mit Hilfe der drei Klassen Constructor“, ConstuctorCall“ und NewObject“ modelliert. ” ” ” Die Constructor“-Klasse wurde genau so wie Methoden oder Felder im classfile-Paket ” angesiedelt. ConstructorCall“ und NewObject“ gehören als Ausdrücke oder als Teile ” ” von Ausdrücken zu dem expressions-Paket. Der Aufbau der Constructor“-Klasse ist beinahe identisch mit dem der InternalFunc” ” tion“-Klasse, die die Definition einer Klassenmethode repräsentiert. Beide Klassen erben deshalb von der abstrakten Function“-Klasse. Der einzige Unterschied besteht darin, ” dass ein Konstruktor den Namen der Klasse trägt, wohingegen eine Methode einen beliebigen Namen annehmen darf. 6 Implementierung 90 Da neue Objekte, die mit dem newobject“-Befehl der JVM erzeugt sowohl für die Ob” jektorientierung als auch für das Funktionieren von Arrays benötigt werden, muss auch die NewObject“-Klasse beide Anwendungen abbilden können. Neben dem Typ des Ob” jektes, der für die Objektorientierung bereits ausreicht, besitzt die Klasse NewObject“ ” die Felder dimensions“ und dimSize“. Diese repräsentieren die Dimensionen eines ” ” Arrays. Dabei gibt dimensions“ die Anzahl der Dimensionen an, während dimSize“ ” ” die genaue Größe jeder Dimension beinhaltet. Für die korrekte Codeerzeugung muss eine NewObject“-Instanz wissen wie oft sie ver” wendet wird, bevor das zugehörige Objekt auf dem Stack mit einem Konstruktoraufruf initialisiert wird (siehe Abschnitt 5.2). Je nach dem verwendeten Algorithmus der Behandlung mehrfach genutzter Knoten, werden entweder gleich genügend Kopien auf dem Stack oder eine Kopie im Speicher abgelegt. Ist der Typ eines Objektes nicht gesetzt, so handelt es sich um ein Array. Ein Konstruktoraufruf wird mit der ConstructionCall“-Klasse modelliert. Diese ist ei” nem Funktions- oder Methodenaufruf sehr ähnlich, da in beiden Fällen Parameter übergeben werden können und nach dem Aufruf ein Rückgabewert oder ein initialisiertes Objekt auf dem Stack liegt. Ein Konstruktoraufruf benötigt immer ein uninitialisiertes Objekt der entsprechenden Klasse und die übergebenen Parameter vor der Ausführung auf dem Stack. Alle uninitialisierten Objekte der Klasse, die sich zu diesem Zeitpunkt auf dem Stack oder im Speicher befinden, werden durch den Aufruf initialisiert. Für die Vererbung von Klassen kennt jedes JBCGClass“-Objekt die übergeordnete ” Klasse. Werden dem cojen-Framework beide Klassennamen übergeben, so geschieht die Vererbung automatisch. Bei der Nutzung eines geerbten Feldes oder einer geerbten Methode wird zuerst das initialisierte Objekt der Kindklasse auf den Stack geschrieben und dann entweder der Feldzugriff oder der Methodenaufruf ausgeführt. 6.3 Dynamic Programming-Ansatz Im 4. Kapitel wurde der in dieser Arbeit entwickelte Dynamic Programming-Ansatz zur optimalen Berechnung von Programmen, die sich durch Ausdrucksbäume und DAGs darstellen lassen vorgestellt. Der zunächst relativ einfache Ansatz der Aufteilung komplexer Probleme in weniger schwierige Teilprobleme entwickelte sich zu einem interessanten Algorithmus, der sich auf beliebige Architekturen und Programme anwenden lässt. Im Folgenden soll beschrieben werden welche Herangehensweisen bei der Umsetzung des Ansatzes in Java gewählt wurden, welche Schwierigkeiten dabei auftraten und wo es in Zukunft noch etwas zu verbessern gibt. Der Dynamic Programming-Algorithmus ist ein Teil des optimizer“-Pakets in der JBCG” Objektstruktur und besteht aus den folgenden Klassen: • Costs: stellt das verwendete Kostenmodell für die Optimierung dar. 6 Implementierung 91 • Table: repräsentiert eine Tabelle. • TableEntry: ist ein Tabelleneintrag in Table“ und beinhaltet die Kosten und ” Stackanforderungen einer Berechnungsvariante. • Matrix: repräsentiert eine Matrix, die eine Liste von Tabellen enthält. • Generate: kapselt die für die Codeerzeugung eines Knotens nötigen Informationen. • Reference: repräsentiert die Referenz auf einen gemeinsamen Knoten. • Permutation: ist eine mögliche Anordnung von n Elementen mit der Gültigkeit in Bezug auf swap“. ” • CurrentState: stellt einen Zustandsmonitor für die Kombination von Tabellen dar. Abbildung 6.2: Einordnung im Übersetzungsprozess Außerdem beinhaltet das Paket folgende für die Optimierung und die Stackcodeerzeugung verwendeten TreeWalker: • SearchShared: Identifizierung gemeinsamer Knoten. • PathFinder: Berechnung der Tabellen und Entscheidung über optimalen Berechnungspfad. • DynamicWalker: Suche nach vorzuberechnenden Knoten. • DynamicGenerator: Erzeugung von Stackcode. Die Abbildung 6.2 zeigt wie sich der spezielle Optimierer in den Gesamtprozess der Übersetzung eines Programms einordnet. Die folgenden Abschnitte enthalten eine ausführliche Beschreibung der Implementierung verwendeter Algorithmen und den Aufbau einiger Klassen. 6 Implementierung 92 6.3.1 Tabellen und Matrizen Abbildung 6.3: Objektstruktur für Matrizen und Tabellen Der Knoten eines Ausdrucksbaumes oder DAGs ist ein Objekt einer der JBCG-Klassen, die in Abschnitt 5.1 kurz vorgestellt wurden. Jedem dieser Knoten ist eine Matrix zugeordnet, die entweder die zwei Fälle der Berechnungen eines gemeinsamen Knotens, also Wert berechnen oder laden, beinhaltet oder jedoch eine temporäre Entscheidungs” matrix“ Meg repräsentiert. Eine Matrix besteht also aus einer Liste von Tabellen. Diese Liste kann von Situation zu Situation verschieden groß werden, so beinhaltet sie im Falle von Ausdrucksbäumen ausschließlich eine Tabelle, wohingegen innerhalb von Teil-DAGs mit entscheidbaren oder kritischen Knoten die Matrix bis zu D + 1 Tabellen beinhalten kann. Die Klasse Matrix“ besitzt also eine ArrayList von Tabellen. ” Jede Tabelle, also eine Zeile der Matrix, besteht aus D + 2 Einträgen, die jeweils einem bestimmten Index in der Tabelle zugewiesen sind und entweder die Kosten einer bestimmten Berechnungsvariante oder die zugehörigen Befehle für die Stackcodeerzeugung beinhalten (siehe Abschnitt 6.3.4). Der Index der Tabelle repräsentiert die Stackplatzanforderungen einer Berechnungsvariante. In Java wurde eine Tabelle als ein Vektor (java.util.Vector) der Größe D + 2 realisiert. Da zu einer Tabelle auch die zugehörigen Codeerzeugungsbefehle gehören, beinhaltet ein Table“-Objekt einen Vektor für ” die Kosten- und einen für die Befehlseinträge. Für die 0- und D + 1-Einträge der Kostentabelle benötigt man zusätzlich die wirklichen Stackanforderungen (siehe Abschnitt 4.1), die sich vom Tabellenindex unterscheiden. Aus diesem Grund besteht ein Vektor der Kostentabelle aus TableEntry“-Objekten (Vector<TableEntry>), die die Kosten ” und Stackplatzanforderungen einer Berechnungsvariante kapseln. Die Befehlstabelle, auch als actions-Tabelle bezeichnet, beinhaltet zu jeder Berechnungsvariante Objektreferenzen auf die direkten Nachkommen eines Knotens in der Reihenfolge wie von der Berechnungsvariante vorgegeben. Ein binärer Knoten bin mit Kindknoten 6 Implementierung 93 a und b würde zum Beispiel für die Berechnungsvariante, in der a vor b berechnet wird, in der actions-Tabelle an der entsprechenden Stelle die Liste der Kindknoten ’a,b’ enthalten. Auf diese Weise kann Stackcode bequem mit Hilfe eines TreeWalkers erzeugt werden, indem man auf jede Referenz in der Liste die walk“-Methode anwendet. Jeder ” Eintrag in der Liste ist ein Objekt der Generate“-Klasse, die neben der Objektreferenz ” auf den Kindknoten bestimmte Anweisungen für die Stackcodeerzeugung enthält. Eine Tabelle hat außerdem Zugriff auf Klassenmethoden, die den 0-Wert berechnen, in Falle von gemeinsamen Knoten die Berechnungs- und Ladeversion erstellen oder sich durch eine andere Tabelle aktualisieren lassen. Das in Abschnitt 3.1 vorgestellte Kostenmodell, das bei der Bewertung von Optimierungstechniken Einsatz fand, ist in der Klasse Costs“ implementiert. Diese definiert ” eine Hash<String, Double>-Tabelle, die einem Befehl, zum Beispiel load“, die zugehöri” gen Kosten zuweist. Mit der Methode Double getCostValue(String key)“ kann man auf ” die Kosten eines Befehls zugreifen. 6.3.2 Kombination der Tabellen In den früheren Kapiteln wurde bereits festgestellt, dass sich die Anzahl aller möglichen Reihenfolgen der Kindknoten eines Knotens von Grad n auf n! beläuft. Dazu kommt, dass jede der n! Möglichkeiten mit allen Berechnungsvarianten der Kindknoten, also den Indizes der Tabellen, kombiniert werden muss. Im schlimmsten Fall sind es maximal n! ∗ (D + 2)n Berechnungen, um die Optimale Reihenfolge zu finden, falls D die maximale Stacktiefe repräsentiert. Sind Matrizen miteinander zu kombinieren, so kommt noch ein weiterer Faktor hinzu, denn nicht nur die Indizes der Tabellen kann man nun kombinieren, sondern auch die Zeilen/Tabellen der Matrix. Es wäre also von Vorteil, wenn die relativ hohe Anzahl von Schritten nicht für jeden Knoten im DAG durchgeführt werden müsste. Dies kann erreicht werden, indem man den Teil der Berechnungen, die alle Knoten gemeinsam haben nur ein einziges Mal ausführt und für jeden Knoten zugänglich macht. So reicht es z. B. die Berechnung aller möglichen Reihenfolgen der Kindknoten und die Bestimmung ihrer Gültigkeit in Bezug auf die swap“-Operation nur ein einziges Mal durchzuführen. Jeder Knoten hätte ” somit sofort Zugriff auf die gültigen Permutationen und könnte die optimale Permutation auswählen. 6 Implementierung 94 Abbildung 6.4: Permutationstabelle Für die Speicherung der Permutationen wurde eine ArrayList von Objekten der Per” mutation“-Klasse gewählt (Abb. 6.4). Jede Instanz von Permutation“ besitzt eine ” Liste von ganzzahligen Werten, welche die durchnummerierten direkten Nachkommen eines Knoten repräsentieren sollen. Der Wert 0“ auf der 2. Position (Spaltenindex 1) ” einer solchen Liste bedeutet, dass der am weitesten links liegende Knoten (Nummer des Nachkommens 0“) als zweiter berechnet werden soll. Außerdem besitzt jede Permu” tation die Kenntnis darüber, ob sie mit einer Abfolge von swap“-Operationen in die ” korrekte Reihenfolge 0 - n gebracht werden kann. Die Berechnung aller Permutationen einer n-elementigen Menge ganzzahliger Werte 0 bis n wird in der PermutationGe” nerator“-Klasse implementiert, die auf dem Algorithmus von K. Rosen [Ros91] basiert. Die Größe der Menge ist der maximale Grad eines Knotens im DAG und wird als Teil des SearchShared“-TreeWalkers bestimmt. ” Da nicht jeder Knoten im DAG den maximalen Grad hat, benötigt die Liste mit den Permutationen eine besondere Struktur, damit sie ohne mehrfache Neuberechnung genutzt werden kann. So sind alle Permutationen einer n-elementigen Menge auf den obersten n! Positionen in der Permutationstabelle zu finden, wenn man die ersten n Einträge einer Permutation betrachtet. Ist zum Beispiel der maximale Grad in einem DAG n = 3 so entsteht eine Tabelle wie in Abb. 6.4. Sie kann von allen Knoten mit Ausgangsgrad ≤ n genutzt werden. Ein binärer Knoten würde also nur den blauen und die gelben Einträge nutzen. Die Gültigkeit in Bezug auf die swap“-Operation, also ob sich eine Permutation in die ” korrekte Reihenfolge bringen lässt, kann bestimmt werden, indem man eine Permutation von links nach rechts durchläuft und benachbarte Elemente außerhalb der Reihenfolge miteinander vertauscht. Befinden sich alle Elemente bis zum aktuellen Index i in der korrekten Reihenfolge, so wird das Gültigkeitsflag“ (grünes Dreieck) gesetzt und die ” Permutation ist damit bis zu diesem Index gültig. Die Indizes < n der Permutationen > n können ignoriert werden, da sie nie gebraucht werden. Zu jedem Element einer 6 Implementierung 95 Permutation in der Liste wird außerdem die Berechnungsreihenfolge mit den zugehörigen swaps“ gespeichert. Für die 3. Permutation aus Abb. 6.4 ist die Berechnungsreihenfolge ” 0, 2, 1, -1“, wobei -1“ für ein swap“ und die restlichen Ziffern für die Auswertung des ” ” ” jeweiligen Nachkommens stehen. Um die Berechnung, sowohl der Kombinationen der Tabellenindizes der n Kindknoten als auch der Kombination der Tabellen der Matrizen, effizienter zu gestalten werden vor der Generierung aller möglichen Kombinationen zunächst alle nicht-leeren Einträge ausgesucht. In praktischen Anwendungen enthalten die Matrizen oft nur eins oder zwei nicht-leere Tabellen, wobei diese auch oft nicht vollständig gefüllt sind. Es also sinnvoll nur die vorhandenen Einträge miteinander zu kombinieren und dadurch Laufzeit und Speicher zu sparen. Eine Vorberechnung der Kombinationen würde zu keiner Verbesserung führen, da vor dem Besuch der Knoten keine Informationen über gesetzte Zeilen der Matrix oder gefüllte Einträge einer Tabelle vorliegen. Die folgende Abbildung zeigt beispielhaft die Kombination von drei Tabelleneinträgen, wobei die Kindknoten in bestimmter Reihenfolge/Permutation berechnet werden sollen: Abbildung 6.5: Kombination dreier Tabellen Für die Kombination der Tabellen der Kindknoten werden also zunächst die nicht-leeren Tabellenindizes ausgesucht und miteinander kombiniert. Für jedes Tupel von Indizes einer Kombination werden aus der Permutationstabelle die gültigen Reihenfolgen ausgewählt und die zugehörigen Kosten und Stackanforderungen berechnet. Den Zugriff auf die konkreten Kosten und Stackanforderungen bekommt man durch die Indizes der aktuellen Kombination. Eine Instanz der Klasse CurrentState“ dient dabei als Zustands” monitor für die Kosten, Stackanforderungen und die Befehlsreihenfolge einer Berechnungsvariante. Während der Simulation einer möglichen Berechnung wird diese Instanz aktuell gehalten und nach Beendigung der Simulation in die Ergebnistabelle geschrieben. Abb. 6.5 zeigt solch einen Ablauf in dem die Kindknoten von a in ungeänderter Reihenfolge (0,1,2) berechnet werden. Das Ergebnis dieser Berechnungsvariante sind Kosten von 4 KP mit der Nutzung vom maximal drei Stackpositionen. 6 Implementierung 96 Auch bei der Bestimmung von optimalen Pfaden für gemeinsame Knoten mit Hilfe der Tabellen der Kindknoten kann die Permutationstabelle genutzt werden. Hierfür wird jede Permutation zunächst auf ihre Gültigkeit in Bezug auf die Berechnung des gemeinsamen Knotens als Teil des Kindknotens i | 1 ≤ i ≤ n untersucht. i muss also stets das erste Element einer Permutation sein, damit es den der Wert des gemeinsamen Knotens berechnen kann. Die restlichen Kindknoten nutzen den berechneten Wert und können beliebig geswapped“ werden. Ist also eine Permutation gültig in Bezug auf die Berech” nung eines gemeinsamen Knotens und in Bezug auf die swap“-Operation, so können ” dafür die Kosten und Stackanforderungen berechnet werden. 6.3.3 Identifizierung wichtiger Knoten Während der Optimierung eines beliebigen DAGs sind unterschiedliche Arten von Knoten zu unterscheiden. Neben einfachen inneren Knoten oder Blattknoten sind gemeinsame Knoten von besonderem Interesse. Gemeinsame Knoten werden durch eine Vorberechnungsphase erkannt und markiert. Erreicht man sie während der Optimierung so ist es wichtig zu wissen, ob und von wie vielen weiteren gemeinsamen/kritischen Knoten sie abhängen. Hängt ein gemeinsamer Knoten von keinem anderen ab, so werden zwei Tabellen für die Fälle der Berechnung und des Ladens aus dem Speicher erzeugt. Alle direkten Vorfahren haben Zugriff auf diese Tabellen. Hängt jedoch ein gemeinsamer Knoten von einem kritischen Knoten ab, so muss die Abhängigkeit, wie oben beschrieben, temporär aufgelöst werden um die optimale Berechnungsreihenfolge eines Teil-DAGs bestimmen zu können. Entscheidungsknoten“, also Knoten welche eine Menge von entscheidbaren gemeinsa” men Knoten auflösen“ können, repräsentieren ebenfalls wichtige Stellen in einem DAG, ” weil sie während der Optimierung erkannt werden müssen, da hier über die optimale Berechnung der entscheidbaren Knoten entschieden wird. Entscheidungsknoten“ werden ” in diesem Abschnitt also im Gegensatz zur Definition 4.1 anders interpretiert. Im folgenden wird ein Ansatz zur Identifizierung von Knoten vorgestellt, die für die Optimierung von DAGs interessant sind. 6 Implementierung 97 Abbildung 6.6: Weitergabe von Referenzen Um bestimmte Knoten korrekt identifizieren zu können reichen alle gemeinsamen Knoten, nachdem sie besucht wurden, eine Referenz oder Zeiger auf sich an ihre direkten Vorfahren weiter. Jeder nicht gemeinsame Knoten, außer ein Entscheidungsknoten, übergibt alle Referenzen an seine direkten Vorfahren. Entscheidungsknoten werden dadurch erkannt, dass sie mindestens zwei Referenzen auf jeweils einen gemeinsamen Knoten erhalten. Um die korrekte Zuweisung von Referenz und gemeinsamen Knoten zu gewährleisten, sollte außerdem der Kindknoten, von dem aus Referenzen weitergegeben wurden ebenfalls gespeichert werden. Knoten a in Abb. 6.6 besitzt beispielsweise vier direkte Nachfolger, wobei jeweils zwei Nachfolger unterschiedliche Referenzen auf gemeinsame Knoten haben. Ohne eine Zuordnung zwischen Referenz und direkter Nachfolger könnte nicht festgestellt werden, welcher Zweig zu welchem gemeinsamen Knoten führt. Dies ist jedoch für die Entscheidung über die optimale Reihenfolge von zentraler Bedeutung. Außerdem lassen sich mit Hilfe dieser Technik Situationen wie in Abb. 4.14 erkennen, denn ein gemeinsamer Knoten der von einem oder mehreren gemeinsamen Knoten abhängt erhält Referenzen auf diese. In solchen Situationen ist es jedoch notwendig die Referenzen zu markieren, damit der zugehörige Entscheidungsknoten erkennt, ob es sich bei einer Referenz um den zum Entscheidungsknoten zugehörigen gemeinsamen Knoten handelt. Im Beispiel aus Abb. 6.6 erhält Knoten c eine Referenz auf Knoten e. Würde c die Referenz auf e nicht mit e* markieren, so könnte der Entscheidungsknoten b nicht feststellen, ob er auch über den Berechnungspfad von e entscheiden soll, obwohl er es gar nicht kann, weil e einen zweiten Vorfahren besitzt, der auch ein Vorfahre von b ist. Erhält also ein Knoten mehrere Referenzen auf einen gemeinsamen Knoten, so ist er nur dann der zugehörige Entscheidungsknoten, wenn mindestens eine der Referenzen auf den 6 Implementierung 98 gleichen Knoten nicht markiert ist. Entscheidungsknoten für mehr als einen gemeinsamen Knoten, z. B. Knoten a aus dem DAG in Abb. 6.6, bilden Gruppen von Kindknoten, die den gleichen gemeinsamen Knoten nutzen. Innerhalb einer Gruppe gilt dasselbe Prinzip wie für einen gemeinsamen Knoten: der berechnende Knoten muss vor allen anderen in der Permutation stehen. Eine Permutation ist in solchen Fällen nur dann gültig, wenn sie für alle Gruppen gültig ist. Die Berechnung der Gruppen geschieht mit Hilfe der Referenzen. Der Entscheidungsknoten besitzt eine Tabelle ArrayList<Reference> references“ mit allen Kindknoten ” und den Referenzen auf gemeinsame Knoten, die über den jeweiligen Kindknoten zu erreichen sind. Existiert ein Eintrag für Kindknoten i und gemeinsamen Knoten g, so wird i in die Gruppe von g eingetragen. Hat ein Entscheidungsknoten den optimalen Berechnungspfad des zugehörigen gemeinsamen Knoten bestimmt, so darf er die Referenzen auf diesen löschen, falls alle Pfade von dem gemeinsamen Knoten auch in diesem Entscheidungsknoten münden. Existiert ein dem Entscheidungsknoten übergeordneter Knoten, der ebenfalls den selben gemeinsamen Knoten nutzt, so ist dieser auch ein Entscheidungsknoten und benötigt die Referenzen. Ein Entscheidungsknoten darf also nur dann alle Referenzen auf den zugehörigen gemeinsamen Knoten g löschen, falls die Anzahl der Referenzen auf g = GE (g). Ansonsten leitet er einfach eine unmarkierte Referenz an seine direkten Vorfahren weiter. Außerdem sollte ein Entscheidungsknoten doppelte markierte Referenzen auf den gleichen gemeinsamen Knoten auf nur eine reduzieren, damit nicht unnötig viele weitergegeben werden. Knoten b gibt also nur ein einziges e* weiter. Erhält ein Knoten zwei Referenzen auf jeweils unterschiedliche gemeinsame Knoten, wie zum Beispiel Knoten x in Abb. 3.21, so ist das ein besonders interessanter Fall, denn die Entscheidung über die optimale Berechnung eines gemeinsamen Knotens kann nicht mehr unabhängig von den/dem anderen gemeinsamen Knoten erfolgen. Erhält ein Knoten x zwei Referenzen auf unterschiedliche gemeinsamen Knoten, so erhält er auch jeweils zwei Tabellen oder jeweils eine Matrix für jeden direkten Nachkommen von dem die Referenzen übergeben wurden. Ähnlich wie gemeinsame Knoten, die von anderen gemeinsamen Knoten abhängen müsste x die vier Tabellen miteinander kombinieren, wodurch wieder vier Tabellen entstehen würden. Solch ein Knoten vermag also nicht wie ein Entscheidungsknoten die Tabellenanzahl zu reduzieren. Bei n unterschiedlichen Referenzen entstehen also 2n Tabellen, wodurch der benötigte Speicherplatz exponentiell wächst. Eine effiziente Optimierung ist also nicht mehr möglich. In Abschnitt 4.3 wurde deshalb eine Beweisidee vorgestellt, die solche nicht-collabierbaren Knoten an der Anzahl der kritischen Knoten erkennt, wobei alle bis auf einen lokal vorberechnet werden sollen, um den restlichen DAG effizient optimieren zu können. Mit Hilfe der Referenzen werden nicht-collabierbare Knoten erkannt, indem die Anzahl der unmarkierten Referenzen auf unterschiedliche gemeinsame Ausdrücke bestimmt wird. Dabei darf jede Referenz keinen markierten oder unmarkierten Partner, also eine Referenz auf den selben gemeinsamen Knoten besitzen. 6 Implementierung 99 6.3.4 Stackcodeerzeugung Wurde ein Ausdrucksbaum oder DAG mit dem Dynamic Programming-Ansatz optimiert, so enthält der Wurzelknoten in seiner Matrix eine nicht-leere Tabelle mit den Kosten und Befehlen zu jeder Berechnungsvariante des DAGs. Um Stackcode für die günstigste Variante zu erzeugen, sucht man den Index des Tabelleneintrages mit den günstigsten Kosten und arbeitet die Liste der Befehle, die unter gleichem Index in der actions-Tabelle stehen ab. Die actions-Tabelle enthält sowohl Objektreferenzen auf die direkten Nachkommen des aktuellen Knotens als auch Objekte, die Operationen wie swap“ oder store“ repräsentieren. So existieren die Hilfsklassen Swap“, Store“, ” ” ” ” Load“ und Dup“, deren Objekte in der actions-Tabelle eingetragen werden können ” ” und die bei ihrer Nutzung die entsprechenden Bytecode-Befehle ausführen. Abbildung 6.7: Wahl des richtigen Eintrages Abb. 6.7 stellt einen einfachen Ausdrucksbaum bestehend aus drei binären Knoten a, b und c zusammen mit den zugehörigen actions-Tabellen dar. a, b und c sind Objekte der Klasse BinaryOperation“ und 1, 2, 3 und 4 sind Objekte der Klasse IntegerConstant“ ” ” der JBCG-Objektstruktur. Standardmäßig wird eine Tabelle, die die Berechnung des zugehörigen Knotens repräsentiert als zweite Zeile (Index = 1) einer Matrix abgespeichert. Die erste Zeile ist für den Fall reserviert, dass der Wert eines gemeinsamen Knotens geladen wird. Die Tabellen aus Abb. 6.7 repräsentieren also jeweils die zweite Zeile der Matrix. Möchte man Stackcode für den optimierten Ausdrucksbaum erzeugen, so sucht man sich den günstigsten Eintrag in der Kostentabelle des Wurzelknotens aus und arbeitet die Befehle im entsprechenden Eintrag der actions-Tabelle ab. Angenommen die günstigste Variante Knoten a aus Abb. 6.7 zu berechnen, sei der Eintrag mit dem Index i = 2. Das bedeutet, dass der Baum mit zwei Stackpositionen berechnet werden soll. Der 6 Implementierung 100 actions-Eintrag enthält drei Objektreferenzen, welche die Berechnungsreihenfolge der Kindknoten von a darstellen. So soll b als erster Knoten berechnet werden, danach Knoten 1 und schließlich wird mit einem swap“ die korrekte Reihenfolge hergestellt. ” Es ist zu beachten, dass Knoten a keinerlei Kenntnis über die Befehle zur Berechnung von Knoten b hat, sondern nur sieht, dass b zuerst berechnet werden soll. Ist die Liste der Objekte eines Tabelleneintrages abgearbeitet, so wird die Operation, die der Knoten repräsentiert ausgeführt. Die Abarbeitung der Befehlsliste wird mit einem TreeWalker DynamicGenerator“ rea” lisiert. Jede Objektreferenz in der Liste wird also an die walk“-Methode übergeben, die ” die zum Objekttyp passende walk“-Methode aufruft. Mit walk(b)“ gelangt man also ” ” zu Knoten b, welcher wiederum selbst eine Tabelle mit einer Liste von Befehlen besitzt. b darf jedoch nicht irgendeinen Eintrag auswählen (auch nicht den günstigsten), denn Knoten a hat eine bestimmte Variante, die eine bestimmte Anzahl von Stackpositionen repräsentiert ausgewählt und deshalb dürfen die Kindknoten von a nur eine bestimmte Anzahl von Positionen benötigen. Da Knoten a den Index i = 2 ausgewählt hat, darf Knoten b auch nur maximal i = 2 Stackpositionen beanspruchen. Hierfür wird das co” lumn“-Feld eines Generate“-Objekt benutzt. Dieses besagt welche Spalte der Matrix ” von b (Index einer Tabelle) bei der Kombination mit Knoten 1 verwendet wurde, um den entsprechenden Eintrag in der Matrix von a zu erzeugen. Die Objektreferenz auf Knoten b weiß also welchen Tabellenindex es bei seiner Berechnung verwenden soll. Vor walk(b)“ wird demnach das useNVariant“-Feld der Tabelle von b auf den column“” ” ” Wert gesetzt, so dass die Tabelle den zu verwendeten Index kennt. Bei Knoten b angekommen wird der Index useNVariant“ verwendet und falls dieser ” nicht gesetzt sein sollte, die günstigste Variante aus der Tabelle ausgewählt. Auf diese Weise gelang man über Knoten c zu den Blättern, die ihre konstanten Werte einfach auf den Stack schreiben. Soll Stackcode für einen DAG erzeugt werden, so wird es etwas komplizierter, da man nun nicht nur den richtigen Tabellenindex, sondern auch die richtige Tabelle, also die Zeile in der Matrix, übergeben muss. Das folgende Beispiel zeigt wie die zu benutzenden Zeilenindizes berechnet und weitergereicht werden: 6 Implementierung 101 Abbildung 6.8: Wahl der richtigen Tabelle Wie bereits in Kapitel 4 eingeführt, erzeugt ein gemeinsamer Knoten zwei Tabellen, die jeweils die Berechnung und Speicherung des Wertes und das Laden des Wertes aus dem Speicher repräsentieren. Knoten d in Abb. 6.8 füllt also die obersten zwei Zeilen seiner Matrix mit den Tabellen, die diese Fälle repräsentieren. Die erste Tabelle repräsentiert das Laden und enthält dementsprechend nur einen Eintrag mit einem load“-Befehl, ” der den berechneten Wert von d auf dem Stack ablegt. Die zweite Tabelle beinhaltet die Befehle zur Berechnung von d und der Auslagerung seines Wertes in den Speicher. Damit der Wert von d nach der Auslagerung nicht sofort wieder geladen werden muss, wird er zunächst dupliziert und die Kopie in den Speicher geschrieben. Bei der Berechnung des Wertes von d muss jedoch die binäre Operation, die d repräsentiert vor der Duplikation ausgeführt werden, da sonst der Wert von 3 und nicht von d dupliziert wird. Ist der Wert eines Knotens einmal berechnet, so wird ein finished“-Flag des Knotens gesetzt, ” damit dies nicht unnötig oft oder sogar auf einem falschen Stackinhalt geschieht. Alle direkten Vorfahren von d kombinieren ihre restlichen Kindknoten mit der Matrix von d und merken sich dabei, mit welcher Zeile aus der Matrix von d die Tabellen kombiniert wurden. So resultiert zum Beispiel die Kombination der Tabelle von 1 und der 1. Tabelle von d in der 1. Tabelle von b, wobei die Referenz auf den gemeinsamen Knoten d mit den Informationen über die verwendete Zeile und Spalte aus der Matrix von d ergänzt wird. Würde also b zu seiner Berechnung die Lade-Version von d verwenden wollen, so hätte die Referenz auf d die Information, dass die erste Zeile, line“ = 1, und die erste Spalte, ” column“ = 1 von Matrix d zu verwenden ist, um an den Lade-Befehl zu kommen. ” 6 Implementierung 102 Knoten a ist der Entscheidungsknoten für den gemeinsamen Knoten d, deshalb kombiniert a, wie in Abschnitt 4.2 vorgestellt, die zwei Tabellen von b und c miteinander wobei er jeweils die erste Tabelle von einem und die zweite von dem anderen Knoten verwendet. Dadurch entstehen zwei Tabellen, die die Berechnung von d entweder als ein Teil von b oder von c repräsentieren. Die Entscheidung über die beste Variante wird getroffen, indem aus beiden Ergebnistabellen die Kosten der günstigsten Berechnungsvarianten ausgesucht und miteinander verglichen werden. In diesem Fall ist die Berechnung von d als Teil von b günstiger. Knoten a kann also mit zwei Stackpositionen berechnet werden, indem zuerst b mit seiner zweiten Zeile und zweiten Spalte ( line“ = 2, column“ = 2) ” ” und danach c mit der Tabelle der Lade-Version ( line“ = 1) ausführt werden. Damit ” Knoten c auf den Wert von d im Speicher zugreifen kann, wird eine Referenz auf das cojen-Objekt, welches genau diese lokale Variable repräsentiert, im Knoten d nach der Ausführung der store“-Operation abgelegt. ” Analog zum useNVariant“-Feld einer Tabelle wird für die Auswahl der korrekten Zeile ” einer Matrix das useNVersion“-Feld verwendet. ” Mit diesen Mitteln kann man also bereits für einfache Ausdrucksbäume und DAGs optimierten Stackcode erzeugen. Um jedoch auch Fälle, in denen eine Auslagerung bestimmter Teil-Bäume oder -DAGs nötig ist abdecken zu können, muss die Behandlung der 0-Einträge einer Tabelle betrachtet werden. Diese repräsentieren die Vorberechnung der Knoten und der zugehörigen Nachkommen vor der Stackcodeerzeugung des eigentlichen Baumes oder DAGs um Stackplätze zu sparen. In Kombination mit der Speicherung der gemeinsamen Knoten und der Behandlung nicht-collabierbarer DAGs kann die Vorberechnung von Knoten zu komplizierten Situationen führen, da die Abhängigkeiten zu anderen Knoten beachtet werden müssen. So muss die Vorberechnung zweier Knoten in der korrekten Reihenfolge geschehen, wenn einer der Knoten ein Nachkomme des anderen ist. Ebenso dürfen Tabellen, welche die Lade-Version eines gemeinsamen Knotens repräsentieren, keinen 0-Wert enthalten, da sie bei ihrer Vorberechnung nicht auf den Wert des gemeinsamen Knotens zugreifen können, weil er noch nicht berechnet wurde. Da jeder Knoten nur Informationen über die direkten Nachkommen besitzt, kann der Wurzelknoten nicht wissen, ob später im Verlauf der Stackcodeerzeugung die Nutzung eines ausgelagerten Teil-Baumes oder -DAGs vorgesehen war. Aus diesem Grund muss vor der eigentlichen Stackcodeerzeugung diese simuliert werden, um vorzuberechnende Fälle zu finden und sie für die Vorberechnung einzutragen. Dies kann nicht während der Optimierungsphase geschehen, da ein Knoten nicht die Information besitzt, welche Tabelle und welcher Eintrag später für die Stackcodeerzeugung ausgewählt wird. Während der eigentlichen Stackcodeerzeugung ist dies auch nicht optimal, da diese Knoten, um Stackpositionen zu sparen, vor allen anderen auf dem noch leeren Stack berechnet werden sollen. Die Stackcodeerzeugung geschieht also in zwei Phasen: • 1. die Suche nach Knoten zum Vorberechnen. • 2. Vorberechnung und Stackcodeerzeugung. 6 Implementierung 103 Für die Vorberechnung werden die entsprechenden Knoten in bottom-up-Reihenfolge in eine Liste eingetragen und vor der eigentlichen Stackcodeerzeugung abgearbeitet. Im 0Feld der entsprechenden Tabelle ist dann nur eine Load“-Anweisung enthalten, welche ” den zugehörigen Knoten, dessen Wert sie repräsentiert mit Hilfe des forValueOf“” Feldes der Load“-Klasse kennt. ” Liegt ein DAG vor, in dem ein gemeinsamer Knoten von einem anderen abhängt, so kommen temporäre Matrizen zum Einsatz, die zwischen dem gemeinsamen Knoten und dem zugehörigen Entscheidungsknoten“ wie echte Matrizen behandelt werden. Ein gemein” samer Knoten g, der von einem kritischen Knoten k abhängt generiert also zusätzlich zu seiner Matrix Mgk eine zweite Matrix decision matrix oder Mgg , die alle möglichen Versionen des gemeinsamen Knotens g innerhalb der Stacktiefengrenze D abdeckt. In dem Teil-DAG zwischen dem zugehörigen Entscheidungsknoten“ e und der Menge der ” entscheidbaren gemeinsamen Knoten wird nur mit der decision matrix gearbeitet. Um bei der Codeerzeugung keine unnötigen Fallunterscheidungen machen zu müssen, wird die normale Matrix mit der decision matrix gleichgesetzt. Nach der Entscheidung bei e wird die decision matrix Meg mit der Matrix Mgk aktualisiert und die decision matrix gelöscht. Bei der Aktualisierung der Kosten von Meg mit Mgk werden die Befehle aus der decision matrix einfach übernommen, falls der entsprechende Eintrag in der Kostenmatrix nicht leer ist. Im Falle von nicht-collabierbaren Knoten (mindestens zwei Referenzen auf kritische Knoten) muss bereits während der Optimierungsphase entschieden werden, wie diese Stellen optimalerweise aufgelöst werden können. Um nicht unnötig oft durch einen DAG durchlaufen zu müssen bietet sich eine zusätzliche Optimierungsphase an, in der nach nicht-collabierbaren Knoten gesucht wird. Denn es ist ebenso wichtig eine Gesamtübersicht über die Anzahl solcher Knoten im DAG und deren zugehörigen gemeinsamen Knoten zu erhalten, weil mehrere nicht-collabierbare Knoten von gleichen gemeinsamen Knoten abhängen könnten. Dadurch ließe sich die Menge der vorzuberechnenden gemeinsamen Knoten reduzieren. Es wurde also eine einfache Heuristik zur Behandlung nicht-collabierbarer Knoten umgesetzt, die zunächst gemeinsame Knoten entfernt, die als Referenzen in mehreren nicht-collabierbaren Knoten auftauchen und sonst beliebige n − 1 referenzierte Knoten vorberechnet, für n = Anzahl der kritischen Referenzen. 6.4 Zukünftige Weiterentwicklung Die aktuelle Implementierung des entwickelten Dynamic Programming-Ansatzes kann sowohl in Bezug auf die Laufzeit und den Speicherverbrauch als auch in ihrer Pflegbarkeit und Erweiterbarkeit verbessert werden. Die folgenden Punkte präsentieren Ideen zur konkreten Verbesserung und Erweiterung der Umsetzung. Aufgrund der Tatsache, dass alle Berechnungen während der Optimierung auf Tabellen- 6 Implementierung 104 strukturen stattfinden, ist die effiziente Umsetzung und Nutzung dieser der wichtigste Faktor. Die Kombination der Tabelleneinträge macht den Großteil der Laufzeit der Optimierung eines Knotens im DAG aus (siehe Abschnitt 4.4), deshalb wäre es sinnvoll diesen Teil möglichst optimal zu gestalten. Eine alternative Struktur der Matrix, die keine leeren Einträge besitzt würde sowohl Laufzeit als auch Speicherplatz sparen. Damit verschiedene Tabellenstrukturen oder Tabellenkombinationstechniken effizient als Teil des Ansatzes untersucht werden können, wäre eine größtmögliche Modularisierung der Umsetzung von Vorteil. Obwohl schon die einzelnen Phasen der optimierten Codeerzeugung als TreeWalker leicht austauschbar und voneinander unabhängig pflegbar sind, wären z. B. die Tabellenstruktur oder die Art der Speicherung der Reihenfolge der Kindknoten als unabhängige Module denkbar. In Kapitel 3 wurden zwei Arten von Techniken zur Speicherung von Kopien vorgestellt und miteinander verglichen. Aufgrund der einfacheren Umsetzung der LocVarTechnik zur Speicherung der Werte von gemeinsamen Knoten, wurde nur diese Technik implementiert. Der Einsatz der StackCopy-Technik als Teil des Dynamic ProgrammingAnsatzes wäre sehr interessant, obwohl die Technik aufgrund der sehr beschränkten Stackmanipulationsbefehle der JVM bei größeren DAGs an ihre Grenze stößt. Vielversprechender wäre die Kombination der beiden Techniken (siehe Abschnitt 3.4), womit kleinere Teil-DAGs vollständig auf dem Stack berechnet werden könnten. 6.5 Fazit Sowohl die Erweiterung des JBCG-Zwischencodemodells als auch die Umsetzung des Dynamic Programming-Ansatzes waren sehr interessante und herausfordernde Aufgaben. Während die Erweiterung des Frameworks um die Objektorientierung eine relativ einfache Abbildung in eine Objektstruktur war, verlange die Implementierung des des Dynamic Programming-Ansatzes deutlich mehr Voraussicht, Planung und Verbesserung des Ansatzes. Die zur Optimierung verwendeten Matrizen mussten mit einer möglichst leicht zugänglichen Objektstruktur repräsentiert werden, damit der Zugriff auf einzelne Werte den Programmcode nicht zu unübersichtlich machte. Einer der schwierigsten und gleichzeitig wichtigsten Programmteile war die Implementierung der Kombination von Tabellen aller Kindknoten. Denn hier entschied sich ob die optimale Reihenfolge korrekt gefunden wird und ob auch die zugehörigen Kosten und, was viel komplizierter war, die zugehörigen Befehle richtig berechnet und an der richtigen Stelle in der Ergebnistabelle abgelegt wurden. Die Identifizierung der relevanten Knoten (gemeinsam, nicht-collabierbar, Entscheidungsknoten“) war oft eine Quelle von ” Fehlern, da sich diese erst durch eine Fehlermeldung während der Codeerzeugung oder im schlimmsten Fall während der Ausführung bemerkbar machten. 7 Ergebnisse In diesem Kapitel werden die gewonnen Erkenntnisse dieser Arbeit in Bezug auf die optimierte Stackcodeerzeugung für DAGs zusammengefasst und diskutiert. Schließlich folgt ein Ausblick auf mögliche Forschungsrichtungen in der Zukunft für die optimale Berechnung von DAGs. 7.1 Zusammenfassung Kostenmodell: Um verschiedene Ansätze für die Optimierung der Codeerzeugung für Programme, die sich durch DAGs darstellen lassen, bewerten und miteinander vergleichen zu können wurde in Abschnitt 3.1 ein Kostenmodell vorgestellt. Dieses misst die Kompaktheit eines Programms, d.h. die Anzahl der nötigen Bytes für die Ausführung. Da reale Architekturen oft einen begrenzten Speicherbereich für die Ausführung von Operationen haben, wurde als die zweite Messgröße die maximale Stacktiefe D eingeführt. D repräsentiert für Stackarchitekturen die Anzahl der Register, die als stack buffer angeordnet sind und kann auch für Registermaschinen als die Anzahl der zur Verfügung stehenden Register interpretiert werden. Speicherung mehrfach verwendeter Werte: Basierend auf dem Kostenmodell wurden zwei mögliche Techniken zur Wiederverwendung von einmal berechneten Werten mehrfach genutzter Ausdrücke vorgestellt und diskutiert. So speichert die LocVar-Technik den Wert eines gemeinsamen Ausdrucks direkt nach seiner Berechnung im Hauptspeicher und ermöglicht dadurch den restlichen Ausdrücken diesen Wert zu nutzen, ohne ihn nochmal berechnen zu müssen. Besitzt ein DAG n Knoten, so arbeitet der Algorithmus in O(n) Schritten und benötigt O(n) Speicherplatz. Das zweite vorgestellte Verfahren StackCopy zur Speicherung mehrfach verwendeter Werte basiert auf Koopmans [Koo92] stack scheduling“-Algorithmus, der Paare von ” Stackzuständen aufzuspüren versucht, die gleiche Elemente auf erreichbaren Positionen enthalten. Durch das Kopieren eines Wertes auf eine tiefere Stackposition ermöglicht das Verfahren die Übergabe von bereits berechneten Werten an später auszuwertende Ausdrücke, um die Neuberechnung oder erneuten Speicherzugriff zu vermeiden. Die Anwendung des Ansatzes im Rahmen der Problematik der optimalen Berechnung ge- 105 7 Ergebnisse 106 meinsamer Ausdrücke resultierte in der Entwicklung der StackCopy-Technik. Diese sucht nach Möglichkeiten die berechneten Werte von mehrfachen Ausdrücken für spätere Nutzungen im Programm auf dem Stack zu speichern. StackCopy benötigt im schlimmsten Fall O(n2 ) Schritte und O(n ∗ D) Speicherplatz mit D als die maximale Stacktiefe. Der Vergleich der beiden Techniken mit dem dfpo-Durchlauf und auch untereinander brachte interessante Erkenntnisse. So sind beide Verfahren günstiger als der dfpoDurchlauf, falls mehrfache Ausdrücke aufwendig neu zu berechnen sind und oft verwendet werden. Die Nutzung zur Verfügung stehender Stackpositionen gelingt dem LocVarVerfahren trivialerweise besser, da die Werte bereits berechneter gemeinsamer Ausdrücke im Speicher und nicht auf dem Stack gelagert werden. Das LocVar-Verfahren benötigt stets weniger oder gleich viele Stackpositionen wie der dfpo-Durchlauf. Da im Falle von StackCopy für jeden mehrfachen Ausdruck jeweils eine Kopie auf dem Stack auf ihre Nutzung warten kann, ist die Vorgehensweise bei kleinen“ Hardware-Stacks nicht zu ” empfehlen. Dafür vermag sie es die aufwendige Kommunikation mit dem Speicher sowohl bei der Berechnung von Ausdrucksbäumen als auch von DAGs zu verringern. Der Vergleich der beiden Techniken LocVar und StackCopy miteinander ergab, dass sich der Einsatz von StackCopy nur dann lohnt, wenn das Programm nur wenige mehrfach genutzte Ausdrücke besitzt und alle Nutzungen je eines solchen Ausdrucks mit StackKopien befriedigt werden können. Kann eine Kopie nicht auf auf dem Stack übergeben werden, so lohnt sich nur selten eine Neuberechnung des Wertes, infolgedessen auf die LocVar-Technik zurückgegriffen werden muss. Existieren zudem mehrere gemeinsame Ausdrücke in einem Programm, so steigt die Wahrscheinlichkeit, dass eine Stack-Kopie nicht erstellt werden kann und falls es doch möglich ist, dass mehrere Kopien, während sie auf ihre Verwendung warten, wertvolle Stackplätze belegen. Eine Kombination der beiden Verfahren scheint daher sinnvoll, wobei kleinere Teil-DAGs, zum Beispiel mit einem gemeinsamen Knoten, mit StackCopy und tiefer verzweigte Programmteile mit LocVar optimiert werden. Alternative Auswertungsreihenfolge: Damit der Stack effizienter genutzt werden kann, wurden Ansätze zur Änderung der Auswertungsreihenfolge gegenüber dfpo untersucht. Sowohl die Änderung der Berechnungsreihenfolge von Operanden einer Operation als auch die Wahl des Pfades in dem ein mehrfach genutzter Ausdruck ausgewertet wird, haben Einfluss auf die maximale Stacktiefe des Programms. Da für die Ausführung einer Operation mit n Operanden alle n Werte vor der Berechnung in umgekehrter polnischer Notation auf dem Stack liegen müssen, stellte sich auch hier die Frage, wo bei der Änderung der Auswertung der Operanden diese gespeichert werden, damit sie später effizient umgeordnet werden können. Die einfachste Möglichkeit besteht darin, alle n Operanden in beliebiger Reihenfolge zu berechnen, die Werte in den Speicher auszulagern und dann in der richtigen Reihenfolge auf den Stack zu schreiben. Das garantiert die minimale Stacktiefe und ist problemlos umsetzbar. Der Nachteil ist die häufige Kommunikation mit dem Speicher und die ineffiziente Nutzung der Stackgröße 7 Ergebnisse 107 D, da zu jeder Zeit maximal max(GA ) Elemente gleichzeitig auf dem Stack liegen. Eine weitere Variante besteht darin Operanden in einer Reihenfolge auf den Stack zu schreiben, so dass diese mit der swap“-Operation in die korrekte Ordnung gebracht wer” den können. Hierfür müssen nicht alle Operanden bereits auf dem Stack liegen, sondern können jeweils nach ihrer Berechnung mit den bereits auf dem Stack liegenden Elementen vertauscht werden. Diese Methode stößt aber bereits bei einfachen Ausdrücken an ihre Grenzen, da nur die obersten zwei Stackelemente miteinander vertauscht werden können. Außerdem muss jede potentielle Auswertungsreihenfolge vor der Ausführung überprüft werden, ob sie sich überhaupt mit swap“ in die umgekehrte polnische No” tation bringen lässt. Dies erfordert die Erzeugung einer Permutationstabelle, die alle n! möglichen Reihenfolgen der Operanden enthält und die Gültigkeit jeder Permutation in Bezug auf die Umordnung mit swap“ kennt. ” Die Kombination der lokalen Speicherung von Werten und der Umordnung einiger Operanden mit swap“ kann durchaus sinnvoll sein, wenn für die Optimierung der Stacktiefe ” bestimmte Reihenfolgen nicht mit swap“ korrigiert werden können oder eine zu häufige ” Anwendung der swap“-Operation benötigen. Operanden, die lokal gespeichert werden ” sollen, werden vor dem DAG ausgewertet und benötigen später eine Stackposition für ihren Wert. Der in Kapitel 4 vorgestellte Dynamic Programming-Ansatz hat gezeigt wie man mit Hilfe von Tabellen die optimale Berechnungsreihenfolge von Operanden bestimmt und wie sich die Umordnung auf dem Stack mit der lokalen Auslagerung verbinden lässt. Die Bestimmung des optimalen Pfades, der zur Berechnung und Speicherung des Wertes eines mehrfach genutzten Ausdruckes führt ist mit einigen Schwierigkeiten verbunden. Existiert nur ein gemeinsamer Knoten im DAG, so kann relativ einfach beim ersten Knoten, von dem aus mindestens zwei Pfade zum gemeinsamen Knoten führen, entschieden werden, welcher der Pfade zuerst besucht werden sollte. Bei n möglichen Pfaden müssen n Fälle miteinander verglichen werden, wobei stets nur einer der Pfade den Wert berechnet und die restlichen ihn nutzen. Die Entscheidung für eine Variante hat jeweils ein anderes Programm zur Folge, welches individuelle Kosten und Stackplatzanforderungen besitzt. Existieren mehrere gemeinsame Knoten in einem DAG, so können sie und die Teil-DAGs, die sie beinhalten von einander abhängen und damit die Entscheidung über den optimalen Berechnungspfad schwierig oder gar unmöglich machen. Hängt ein gemeinsamer Knoten g ∈ V von einem zweiten gemeinsamen Knoten g 0 ∈ V ab, so ist die Entscheidung über den optimalen Pfad zu g von g 0 abhängig. Findet jedoch die Auflösung von g 0 erst nach der Auswertung von g statt, so kann g selbst aufgrund der Abhängigkeit nicht optimiert werden. Die Lösung des Problems, war die zeitweise Auflösung der Abhängigkeit zwischen g und g 0 , wodurch die Entscheidung für die Auflösung von g getroffen werden konnte. Im Rahmen der Entwicklung des Dynamic Programming-Ansatzes wurde die temporäre Nicht-Beachtung der Abhängigkeit durch Erzeugung einer Matrix Meg realisiert, die alle möglichen Stackanforderungen von g innerhalb der Stackgrenze D bis 7 Ergebnisse 108 zur Auflösung bei e abdeckt. Sei e ∈ V der Knoten in dem über den optimalen Pfad von g entschieden wird und hängt ein Knoten v ∈ V mit v 6= g und e 7→ v 7→ g von einem weiteren gemeinsamen Knoten g 0 ab, so kann die Abhängigkeit nicht ignoriert werden, da g und g 0 kritische Knoten sind. In Abschnitt 4.3 wurde bewiesen, dass solche Abhängigkeiten einen DAG nichtcollabierbar machen. Die Klasse der collabierbaren DAGs wurde in [PraSet80] vorgestellt und repräsentiert eine interessante Untermenge von DAGs, die sich effizient optimieren lassen. Für die Behandlung nicht-collabierbarer DAGs wurde eine heuristische Lösung vorgestellt, die jedoch ausbaufähig ist. Der in Kapitel 4 entwickelte Dynamic Programming-Ansatz kombiniert die gewonnen Erkenntnisse und Techniken zu einem Optimierungsalgorithmus, der sowohl durch die Nutzung des Speichers als auch der vorhandenen Stackbefehle einen DAG beliebigen Grades GA optimiert. Der Ansatz geht von einer begrenzten Stacktiefe D aus, die jedoch besonders bei Stackarchitekturen selten fest ist, da bei Überschreitung der Kapazitäten vom stack buffer, die überschüssigen Elemente im Speicher gelagert werden. Ein reservierter Tabelleneintrag repräsentiert deshalb den Fall, dass die Grenze D überschritten wurde und das Programm nicht mehr vollständig auf dem Hardware-Stack ausgeführt wird. Solche Situationen können je nach Architektur Einbußen in der Laufzeit bedeuten. Dadurch lassen sich Optimierungen noch besser auf die Möglichkeiten und Eigenarten von verschiedenen Architekturen anpassen. Der Dynamic Programming-Algorithmus wurde in Java mit Hilfe von TreeWalkern realisiert und läuft in linearer Zeit O(n) mit O(n) Speicher, für n als die Anzahl der Knoten eines DAGs. Erweiterung des JBCG-Zwischencodemodells: Die Entwicklung und Untersuchung unterschiedlicher Optimierungsansätze für DAGs wurde mit Hilfe des im Kapitel 5 vorgestellten Zwischencodemodells umgesetzt. Das Zwischencodemodell stellt Abhängigkeiten von Ausdrücken eines beliebigen Programms in einer baumartigen Struktur dar, die sich im Hauptspeicher befindet und optimiert werden kann. Ein besonderes Feature des Modells ist die Unterteilung eines Programms in basic blocks, die Sequenzen von Befehlen in Form von DAGs enthalten und sich dadurch direkt optimieren lassen. Im Rahmen der Arbeit wurde das Zwischencodemodell um die Objektorientierung und die Vererbung erweitert und in zwei unabhängige Teile, Architektur und Java-Bytecodeerzeugung, zur besseren Pflege und Erweiterbarkeit gegliedert. 7.2 Ausblick Collabierbare DAGs lassen sich deutlich einfacher optimieren als die nicht-collabierbaren DAGs, da die Wahl der optimalen Berechnungsreihenfolge für jeden mehrfach genutzten Knoten unabhängig von allen anderen gemeinsamen Knoten getroffen werden kann. 7 Ergebnisse 109 In 4.3 wurde gezeigt wie nicht-collabierbare DAGs oder Knoten, die dafür verantwortlich sind, leicht erkannt werden können und was es für Möglichkeiten gibt um einen DAG wieder collabierbar zu machen. Da in der aktuellen Implementierung des Dynamic Programming-Verfahrens nur eine recht einfache Heuristik zur Behandlung solcher Knoten umgesetzt wurde, wäre die Untersuchung einer Entscheidungsstrategie basierend auf den Informationen über die betroffenen gemeinsamen Knoten für zukünftige Entwicklungen interessant. Die Notwendigkeit einer guten Heuristik für die Behandlung nicht-collabierbarer Fälle hängt jedoch davon ab, inwieweit Programme, die durch nicht-collabierbare DAGs dargestellt werden in der Praxis tatsächlich vorkommen und wie genau diese aussehen. Obwohl die Entwicklung einer guten Heuristik theoretisch durchaus interessant ist und Sinn macht, wäre eine einfache Lösung bei einem verschwindend geringen Anteil nichtcollabierbarer DAGs in der Praxis ausreichend. Die Untersuchung der Häufigkeit des Vorkommens nicht-collabierbarer DAGs wäre demnach der nächste logische Schritt in der Optimierung der Codeerzeugung für DAGs. Literaturverzeichnis [AhoJoh76] A. V. Aho, S. C. Johnson Optimal code generation for expression trees J ACM 23, 1976, p. 488-501 [AJU76] A. V. Aho, S. C. Johnson, J. D. Ullman Code Generation for Expressions with Common Subexpressions. Journal of the Association for Computing Machinery, Vol 24, 1977 [AVR09] Atmel Microcontrollers AVR32 http://www.atmel.com/products/AVR32/, 2009 32-Bit MCU. [AVRJSpec] AVR32 Java Technical Reference http://www.atmel.com/dyn/resources/prod documents/doc32049.pdf, 2009 [BurLas75] J. L. Bruno, T. Lassagne The generation of optimal code for stack machines., J. ACM 22, 1975, p. 382-397 [BruSet76] J. L. Bruno, R. Sethi, Code generation for a one-register machine. J ACM23, 1976, p. 502-510 [Coj08] Cojen Java Bytecode Generation http://cojen.sourceforge.net/, 2008 [Guet80] R. Güttler Erzeugung optimalen Codes für Series - Parallel Graphs. Universität des Saarlandes, 1980 [JVMSpec] Java Virtual Machine Specification, First http://java.sun.com/docs/books/jvms/, 1999 and Second Edition, [Koo92] P. Koopman A Preliminary Exploration of Optimized Stack Code Generation. Proceedings of the 1992 Rochester Forth Conference, 1992 [Mai97] M. Maierhofer Erzeugung optimierten Codes für Stackmaschinen. Technische Universität Wien, 1997 [Pra08] Andreas Prante Entwicklung eines Zwischencode-Modells für die JavaBytecode Generierung, Leibniz Universität Hannover, 2008 [PraSet80] B. Prabhala, R. Sethi, Efficient Computation of Expressions with Common Subexpressions. Journal of the Association for Computing Machinery, Vol 27, 1980 [RioSha42] J. Riordan, C. E. Shannon The number of two terminal series parallel graphs. J Math Phys 21, 1942, p. 83-93 110 Literaturverzeichnis 111 [Ros91] Kenneth H. Rosen Discrete Mathematics and Its Applications, 2nd edition, NY: McGraw-Hill, 1991, pp. 282-284 [SetUll70] R. Sethi, J. D. Ullman The generation of optimal code for anthmeuc expressions. J ACM 17, 1970, p. 715-728 Anhang Inhalt der CD Die CD beinhaltet ein Verzeichnis Code“, der sowohl den Java-Quellcode des erwei” terten Zwischencodemodells und der untersuchten Optimierungsansätze als auch die zugehörigen JavaDoc-Dokumentation beinhaltet. Der in Pakete unterteile Quellcode (siehe Abschnitt 5.1) befindet sich unter Code\JavaByteCodeGenerator\trunk\src und die Dokumentation unter ...\doc. Außerdem wird die Arbeit in einer PDF-Version beigefügt. 112