pdf-Version - Ehemaliges Fachgebiet Programmiersprachen und

Werbung
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
Herunterladen