Universität Karlsruhe (TH) Fakultät für Informatik Institut für Programmstrukturen und Datenorganisation (IPD) Lehrstuhl Prof. Dr. Gerhard Goos Graphersetzungsregelgewinnung aus Hochsprachen und deren Anwendung Diplomarbeit Andreas Schösser Betreuer: Dipl.-Inform. Rubino Geiß Verantwortlicher Betreuer: Prof. em. Dr. Dr. h.c. Gerhard Goos 14. September 2007 Hiermit erkläre ich, die vorliegende Arbeit selbstständig verfasst und keine anderen als die angegebenen Hilfsmittel benutzt zu haben. Datum, Ort Unterschrift Kurzfassung In dieser Arbeit erweitern wir einen Übersetzer um eine Optimierung, die die Ausnutzung von reichhaltigen Befehlssätzen, wie sie von modernen Prozessoren angeboten werden, erlaubt. Die Optimierung findet auf einer übersetzerinternen, graphbasierten Zwischendarstellung statt. Hierbei wird in Zwischensprachengraphen nach Teilgraphen – bestehend aus konventionellen Einzeloperationen – gesucht und diese durch reichhaltige Befehle ersetzt. Zur Graphtransformation setzen wir ein Graphersetzungssystem ein. Bisher mussten Graphersetzungsregeln manuell geschrieben werden, was sehr zeitaufwändig und fehleranfällig ist. Wir untersuchen deshalb, wie Graphersetzungsregeln automatisch aus einer Spezifikation, welche das Verhalten von reichhaltigen Befehlen in einer gängigen Hochsprache beschreibt, gewonnen werden können. Dadurch können neue reichhaltige Befehle mit wenig Aufwand integriert werden. Wir entwerfen eine geeignete Spezifikationssprache basierend auf der Hochsprache C und zeigen, wie aus einer solchen Befehls-Spezifikation Graphersetzungsregeln entstehen. Gleichzeitig entwickeln wir Techniken, um die Trefferwahrscheinlichkeit bei der Mustersuche zu erhöhen und demonstrieren, wie die Auswahl und Anwendung von Graphersetzungsregeln automatisch gesteuert werden kann. Schließlich integrieren wir die Optimierung in einen bestehenden Übersetzer und zeigen durch Laufzeittests an realitätsnahen Testprogrammen ihre Leistungsfähigkeit. i Inhaltsverzeichnis 1 Einleitung 1.1 Aufgabenstellung . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 1.2 Überblick . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 2 Grundlagen 2.1 Firm und libfirm . . . . . . . . . . . . . . . 2.2 Die Komplexität von reichhaltigen Befehlen . 2.3 Reichhaltige Befehle in Firm . . . . . . . . . 2.4 Transformation von Programmgraphen . . . . 2.5 Zusammenarbeit von Übersetzer und GES . . 2.6 Verwandte Arbeiten . . . . . . . . . . . . . . 2.6.1 Automatische Algorithmenerkennung . 2.6.2 Befehlsauswahl mittels PBQP . . . . . 1 3 4 . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 5 5 6 8 9 10 11 11 13 3 Generierung von Graphersetzungsregeln 3.1 Entwurf der Spezifikationssprache . . . . . . . . . . . . . . . . . . 3.1.1 Entitäten . . . . . . . . . . . . . . . . . . . . . . . . . . . 3.1.2 Informationen für das Backend . . . . . . . . . . . . . . . 3.1.3 Steuerung der Regelanwendung . . . . . . . . . . . . . . . 3.1.4 Beispiel einer Befehls-Spezifikation . . . . . . . . . . . . . 3.2 Generierung des Mustergraphen . . . . . . . . . . . . . . . . . . . 3.2.1 Analyse des initialen Graphen . . . . . . . . . . . . . . . . 3.2.2 Transformation des initialen Graphen . . . . . . . . . . . 3.2.3 Speicherkante . . . . . . . . . . . . . . . . . . . . . . . . . 3.3 Generierung des Ersetzungsgraphen . . . . . . . . . . . . . . . . 3.4 Regelgenerierung . . . . . . . . . . . . . . . . . . . . . . . . . . . 3.4.1 Syntax einer Graphersetzungsregel . . . . . . . . . . . . . 3.4.2 Graphmodell . . . . . . . . . . . . . . . . . . . . . . . . . 3.5 Überdeckung mehrerer Grundblöcke . . . . . . . . . . . . . . . . 3.5.1 Phi-Knoten . . . . . . . . . . . . . . . . . . . . . . . . . . 3.5.2 Grundblock von Operationen . . . . . . . . . . . . . . . . 3.5.3 Mehrere Register-Stores auf dieselbe Vektor-Komponente . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 15 15 16 17 17 18 19 19 20 21 22 24 24 25 25 25 26 27 . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . iii 4 Ersetzungsschritt 4.1 Priorität von Regeln . . . . . . . . . . . . . . . 4.2 Anordnen des reichhaltigen Befehls . . . . . . . 4.2.1 Auffinden der Speichervorgänger . . . . 4.2.2 Grundblock des reichhaltigen Befehls . . 4.3 Analyse einer Passung . . . . . . . . . . . . . . 4.3.1 Speicherabhängigkeit von Musterknoten 4.3.2 Verwendung von Zwischenergebnissen . 4.3.3 Speicherabhängigkeit von Operanden . . 4.3.4 Grundblock von Operanden . . . . . . . 4.3.5 Ausrichtung von Daten . . . . . . . . . 4.4 Entfernen überflüssiger Knoten . . . . . . . . . 4.4.1 Entfernen toten Codes . . . . . . . . . . 4.4.2 Load-Store-Optimierung . . . . . . . . . 4.4.3 Kontrollflussoptimierung . . . . . . . . . 5 Normalisierung und Varianten 5.1 Vortransformationen . . . 5.2 Normalisierung . . . . . . 5.2.1 Vergleiche . . . . . 5.2.2 Adressierungsarten 5.3 Variantenbildung . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 29 29 29 31 33 34 34 34 35 37 37 38 39 39 39 . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 41 41 42 42 43 45 6 Algorithmische Steuerung der Regelanwendung 6.1 Überlappung von Passungen . . . . . . . . . . 6.2 Suchbaum . . . . . . . . . . . . . . . . . . . . 6.2.1 Aufbauen des Suchbaums . . . . . . . 6.2.2 Ersetzen der günstigsten Passungen . 6.2.3 Prioritätsklassen . . . . . . . . . . . . 6.3 Musterselektion mittels PBQP . . . . . . . . 6.3.1 PBQP . . . . . . . . . . . . . . . . . . 6.3.2 Änderungen am bisherigen Ansatz . . 6.3.3 Erstellen des PBQP-Graphen . . . . . 6.3.4 Musterauswahl per PBQP am Beispiel 6.3.5 Bewertung . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 47 47 48 49 51 52 53 53 54 55 58 61 . . . . . . . 63 63 65 65 66 67 68 70 . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 7 Implementierung und Messungen 7.1 Architektur . . . . . . . . . . . . . . . . . 7.2 Messergebnisse . . . . . . . . . . . . . . . 7.2.1 Bewegungsschätzung . . . . . . . . 7.2.2 Skalarprodukt . . . . . . . . . . . . 7.2.3 Reihungselemente begrenzen . . . 7.2.4 Demonstration der Musterauswahl 7.3 Bewertung . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 7.4 Weiterführende Ideen . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 70 8 Zusammenfassung und Ausblick 73 1 Einleitung Reichhaltige Befehle, wie sie von modernen Prozessoren angeboten werden, bleiben von den verbreiteten Programmiersprachen aufgrund fehlender Sprachkonstrukte, die die Ausnutzung solcher Befehle erlauben würden, weitgehend unberücksichtigt. Unter den Begriff reichhaltige Befehle fallen u.A. SIMD-Befehle1 , es sind aber auch noch komplexere Befehle denkbar. Generell verstehen wir unter einem reichhaltigen Befehl ein kleines, in Hardware gegossenes Programm, das aus mehreren konventionellen Einzelbefehlen besteht und parallel auf mehreren Daten arbeiten kann. Der Einsatz von reichhaltigen Befehlen verspricht eine Steigerung der Effizienz im Vergleich zur Ausführung von Einzelbefehlen. Einem Programmierer verbleiben verschiedene Möglichkeiten, um reichhaltige Befehle auszunutzen: Programmierung auf Assemblerebene. Mit dieser Methode kann schnell die Übersichtlichkeit und Wartbarkeit der Software verloren gehen. Übersetzerspezifische Intrinsics sind durch den Übersetzerhersteller definierte Erweiterungen einer Hochsprache. Beim Wechsel der Zielarchitektur oder des Übersetzers muss ein Programm deshalb, wie auch bei der Assemblerprogrammierung, von Hand angepasst werden. Bereits vorhandene Programme können nur optimiert werden, indem der Quellcode manuell umgeschrieben wird. Eine übersetzerinterne Optimierung ist transparent für den Programmierer. Der Übersetzer entscheidet selbstständig und hardwareabhängig, wann reichhaltige Befehle einzusetzen sind. Assemblerprogrammierung und Intrinsics sind für unsere Zwecke nicht geeignet, da wir den Quellcode portabel halten wollen. Momentan unterstützen die meisten Übersetzer eine oben angesprochene Optimierung nicht vollständig. Einige Übersetzer verwenden sogenannte Vektorisierer, die Vektorbefehle in Schleifen erkennen, Programme aber nicht außerhalb von Schleifen optimieren und oft bei reichhaltigen Befehlen, deren Komplexität die von reinen Vektorbefehlen übersteigt, versagen. In dieser Arbeit stellen wir einen neuartigen Ansatz vor, um einen Übersetzer mit solch einer Optimierung auszustatten. Optimierungen finden üblicherweise auf der übersetzerinternen Zwischensprache statt. Moderne Zwischensprachen stellen Programme als 1 Single Instruction Multiple Data 1 2 1 Einleitung Proj P Load Proj Is 0x4 Proj M Add P Load Proj Is Proj P Optimierung Vector Load Proj Vector VProj 0 Is Proj M VProj 1 Is Proj M Abbildung 1.1: Optimierung eines Firm Zwischensprachengraphen gerichteten Graph dar, wobei die Knoten Operationen und die Kanten Datenabhängigkeiten und Kontrollfluss repräsentieren. Da eine Zwischensprache möglichst hardwareunabhängig sein soll, sind in einem Zwischensprachengraphen initial keine Knoten für hardware-spezifische, reichhaltige Befehle vorhanden. Reichhaltige Befehle können aber als Teilgraphen, bestehend aus mehreren Einzeloperationen, in Zwischensprachengraphen gefunden werden. Die Aufgabe unserer Optimierung ist es, solche Teilgraphen zu finden und sie durch neu eingeführte Knoten für reichhaltige Befehle zu ersetzen. Das Suchen und Ersetzen von Teilgraphen sind Aufgaben, die wir an ein Graphersetzungssystem delegieren können. Abbildung 1.1 zeigt, wie eine solche Ersetzung in der von uns verwendeten Zwischensprache Firm [27] aussehen könnte. Das linke Programmfragment, das zwei LoadOperationen auf zwei aufeinanderfolgende Speicherstellen ausführt, wird durch das rechte Programmfragment, welches beide Operationen auf einmal durch einen VectorLoadBefehl ausführt, ersetzt. Dadurch wird ein Geschwindigkeitsvorteil erreicht. Beide Programmversionen produzieren dasselbe Ergebnis (fett markiert). Es ist zu beachten, dass in Firm Datenabhängigkeiten statt Datenfluss verwendet werden. Deshalb sind die Abhängigkeitskanten rückwärts zu lesen, um die Ausführungsreihenfolge zu erhalten. Die zur Mustersuche verwendeten Mustergraphen mussten bisher explizit manuell geschrieben oder die Mustersuche sogar ausprogrammiert werden. Diese Methode macht es sehr aufwändig und fehleranfällig, neue reichhaltige Befehle zu integrieren, da viele Feinheiten der Zwischensprache beachtet werden mussten. Außerdem ist dazu ein tiefgründiges Fachwissen über die Zwischensprache und das Übersetzer-Backend notwendig, so dass neue Mustergraphen bisher nur von Experten erstellt werden konnten. Unser Ansatz überwindet diese Hürde, indem wir Mustergraphen aus einer Spezifikation gewinnen, die das Verhalten von reichhaltigen Befehlen in einer Hochsprache beschreibt. Dadurch können auch Benutzer ohne Kenntnis der Zwischensprache neue reichhalti- 1.1 Aufgabenstellung 3 ge Befehle spezifizieren. Anstatt die Mustersuche auszuprogrammieren, generieren wir aus dieser Befehls-Spezifikation automatisch Graphersetzungsregeln für ein Graphersetzungssystem. 1.1 Aufgabenstellung Die Aufgabe dieser Arbeit ist zu untersuchen, wie solche Graphersetzungsregeln aus einer Befehls-Spezifikation gewonnen und angewendet werden können. Die grundlegende Idee, Graphersetzungsregeln zur Programmoptimierung einzusetzen, wurde bereits von Hofmann beschrieben, der ansatzweise die Funktionsfähigkeit der Vorgehensweise zeigt [15]. Wir übernehmen diesen Grundgedanken, erweitern ihn jedoch mit dem Ziel, eine lauffähige Optimierung zur automatischen Ausnutzung von reichhaltigen Befehlen auf der Zwischensprache Firm unter Verwendung eines Graphersetzungssystems zu implementieren und in einen bestehenden Übersetzer zu integrieren. Wir entwerfen eine Spezifikationssprache basierend auf der Hochsprache C, um das Verhalten von reichhaltigen Befehlen zu beschreiben. Die Spezifikationssprache soll intuitiv und leicht erweiterbar sein. Außerdem soll es möglich sein, alle Informationen, die zur automatischen Ausführung der Optimierung nötig sind, zu spezifizieren. Das Hauptproblem der Implementierung von Hofmann war, dass geringe syntaktische Abweichungen zwischen Befehls-Spezifikation und dem zu optimierenden Programm dazu führten, dass keine reichhaltigen Befehle mehr gefunden werden konnten. Wir analysieren die Gründe dafür genauer und entwickeln Methoden, um die Trefferwahrscheinlichkeit der Mustersuche zu erhöhen. Wir untersuchen außerdem, ob eine automatische Integration der neuen reichhaltigen Befehle in das Übersetzer-Backend möglich ist, um Registerzuteilung zu ermöglichen und Assemblercode zu erzeugen, was die Implementierung von Hofmann noch nicht leistete. Die zu entwerfende Spezifikationssprache soll es dem Benutzer ermöglichen, alle dazu notwendigen, hardware-spezifischen Informationen anzugeben. Die Regelanwendung war bei der Implementierung von Hofmann noch handgesteuert, d.h. es erfolgte keine automatische Regelauswahl. Hofmann erwähnte zwar schon die Idee eines Rückrollmechanismus, um bei Bedarf den Graph wieder in den Ausgangszustand zu versetzen oder um mehrere Möglichkeiten der Ersetzung auszuprobieren. Eine Strategie, um Ersetzungen wieder rückgängig zu machen oder zwischen mehreren Ersetzungsmöglichkeiten auszuwählen, wird jedoch nicht beschrieben. Wir entwickeln verschiedene Verfahren, um die Regelauswahl zu automatisieren und die Reihenfolge der Graphersetzung zu steuern. Um nicht den Einschränkungen der bestehenden Implementierung unterworfen zu sein, soll eine komplette Neuimplementierung erfolgen. Die Leistungsfähigkeit unserer Implementierung soll anhand von Messungen an realitätsnahen Testprogrammen gezeigt werden. 4 1 Einleitung 1.2 Überblick In Kapitel 2 werden die Grundlagen der Arbeit vorgestellt und einige notwendige Definitionen getroffen. In Kapitel 3 beschreiben wir, wie aus der Befehls-Spezifikation die Graphersetzungsregeln entstehen. Wie ein komplexer Befehl in den Zwischensprachengraphen eingefügt wird, nachdem ein Muster gefunden wurde, wird in Kapitel 4 erklärt. In Kapitel 5 werden Techniken vorgestellt, um die Trefferwahrscheinlichkeit bei der Mustersuche zu erhöhen. Kapitel 6 widmet sich dem Thema, kostengesteuert die billigste Ersetzung durchzuführen, falls es mehrere Ersetzungsmöglichkeiten geben sollte. In Kapitel 7 werden Details unserer Implementierung offengelegt und die Integration in einen bestehenden Übersetzer besprochen. Außerdem stellen wir hier unsere Messergebnisse vor. Kapitel 8 fasst schließlich die Ergebnisse dieser Arbeit zusammen. 2 Grundlagen In diesem Kapitel gehen wir auf die grundlegenden Ideen ein, auf die diese Arbeit aufbaut. Wir beschrieben die von uns verwendete Zwischensprache und geben Beispiele für reichhaltige Befehle an. Desweiteren erläutern wir die grobe Architektur unserer Optimierung und stellen verwandte Arbeiten vor, die ähnliche Ziele wie diese Arbeit verfolgen. 2.1 Firm und libfirm Die von uns implementierte Optimierung arbeitet auf der Zwischensprache Firm [27]. Es handelt sich dabei um eine graphbasierte Programmdarstellung in SSA-Form (Single Static Assignment1 ) [8, 6]. Operationen werden als Knoten eines Programmgraphen dargestellt. Jede Operation verweist durch Datenabhängigkeitskanten auf ihre Operanden. Die Verwendung von Datenabhängigkeiten statt des Datenflusses ist eine Besonderheit von Firm. In Anlehnung daran wird in Firm der Steuerfluss umgekehrt dargestellt: Jeder Grundblock verweist durch Steuerflusskanten auf seine Vorgänger. Ein spezieller Kantentyp ist die Speicherabhängigkeitskante, durch die Speicheroperationen, die möglicherweise auf denselben Speicherbereich zugreifen, serialisiert werden. In allen Abbildungen dieser Arbeit verwenden wir Datenabhängigkeiten statt des Datenflusses. Viele der Knotentypen von Firm wie z.B. Load-, Store-, Mul- oder Add-Knoten sind selbsterklärend. An dieser Stelle beschreiben wir einige für diese Arbeit wichtige spezielle Knotentypen detaillierter: Da manche Firm-Knoten ein Tupel von Ergebnissen bereit stellen, verfügt die Zwischensprache über Proj -Knoten, die ein bestimmtes Element des Ergebnistupels auswählen. Proj -Knoten existieren nur virtuell und erzeugen keinen zusätzlichen Code. Eine Spezialität von SSA-Programmen sind Phi -Operationen. Phi -Operationen stehen für die Vereinigung alternativer Datenflusspfade und definieren einen neuen Wert, indem sie genau einen der alternativen Werte gemäß des Steuerflusses auswählen. Statt mehrerer Verweise haben Verwender dieser Werte also nur noch einen Verweis auf den von der Phi -Operation generierten Wert. Phi -Knoten dienen also der Aufrechterhaltung der SSA-Eigenschaft, dass eine Verwendung genau eine Definition haben muss und werden vor der Codeerzeugung wieder abgebaut. Schlussendlich sei noch der Knotentyp Sync erwähnt, der eingesetzt wird, falls mehrere parallele Speicherabhängigkeitsketten zu einer vereinigt werden sollen. 1 deutsch: Statische Einmalzuweisung 5 6 2 Grundlagen In Firm sind auch die Typen der Werte des Programms zugänglich. Der primitive Typ des Ergebnisses einer jeden Operation ist an deren Knoten durch ein sogenanntes Mode-Attribut annotiert. So steht beispielsweise Is für Signed Integer, T für Tupel oder M für Memory. Dadurch ist auch implizit bekannt, welche Bitbreite ein Ergebniswert hat. Firm stellt den Anspruch, von allen nicht essentiellen Abhängigkeiten zu abstrahieren [12] und so die Befehlsanordnung und Befehlsauswahl nicht implizit vorzugeben. Erst das Übersetzer-Backend soll die Befehle in den Grenzen, die durch den Steuerfluss, Speicherabhängigkeit und Datenabhängigkeit gegeben sind, anordnen. Allerdings heißt das nicht, dass für unterschiedliche Anordnungen der Befehle im Quellprogramm immer derselbe Zwischensprachengraph entsteht. Beispielsweise könnten für einen arithmetischen Ausdruck die beiden semantisch äquivalenten aber syntaktisch verschiedenen Bäume (a + b) + c und a + (b + c) aufgebaut werden. Wichtig ist jedoch, dass es von der Programmiersprache abhängt, ob wir arithmetische Ausdrücke derart umordnen dürfen. Ein Beispiel eines Firm-Programms zeigt Abbildung 2.1. Der Graph entspricht der Zwischendarstellung des folgenden C Programms, welches zwei Vektoren komponentenweise addiert und uns in den nächsten Abschnitten als Beispiel dienen wird: void VectorAdd(int *a, int *b, int *c) { c[0] = a[0] + b[0]; c[1] = a[1] + b[1]; } libfirm [21] ist eine leistungsfähige Implementierung der Zwischensprache Firm. Es handelt sich um eine Bibliothek, die die nötigen Funktionen bereitstellt, um Zwischensprachengraphen aufzubauen, zu verwalten und zu manipulieren. 2.2 Die Komplexität von reichhaltigen Befehlen Moderne Prozessoren bieten reichhaltige Befehlssätze an, deren Befehle sich nicht mehr eindeutig nach Flynn [11] kategorisieren lassen: Die klassische Einteilung in SISD (Single Instruction Single Data) und SIMD (Single Instruction Multiple Data) reicht nicht mehr aus, da reichhaltige Befehle viele verschiedene Operationen parallel ausführen. Generell arbeitet ein reichhaltiger Befehl ein und dasselbe kleine Hardware-Programm – bestehend aus mehreren Einzeloperationen und möglicherweise mit verzweigtem Steuerfluss – parallel auf mehreren Daten ab. Vielen reichhaltigen Befehlen ist gemein, dass sie zusätzlich zu skalaren Daten auch Vektordaten verarbeiten können. Als Beispiel dient uns hier der SSE2-Befehlssatz2 der Intel IA32 Architektur [18]. Dieser Befehlssatz stellt SIMD-Befehle zur Verfügung, um komplette Vektoren aus dem Speicher zu laden oder zu speichern (movdqu), und um arithmetische Operationen auf 2 Streaming SIMD Extensions 2.2 Die Komplexität von reichhaltigen Befehlen 7 Abbildung 2.1: Firm-Graph einer Funktion, die zwei Vektoren komponentenweise addiert. Der markierte Teilgraph lädt einen Vektor aus dem Speicher. 8 2 Grundlagen Anwendung Matrix Multiplikation zweier 6x6 Matrizen Matrix Inversion 4x4 Wiener Filter (Reduktion von Rauschen) Maximum-/Minimum-Suche Kosinus Transformation 3D Transformation Upsampling Speedup 2,1 4,03 n/a n/a 1,70 2,01 n/a Befehlssatz SSE SSE SSE SSE SSE, SSE2 SSE2 SSE Quelle AP-930 AP-928 AP-807 AP-804 AP-945,922 AP-939 AP-822 Tabelle 2.1: Speedup durch Ausnutzen reichhaltiger Befehle Vektorelementen durchzuführen (addps, subps, mulps). Als typisches Beispiel eines reichhaltigen Befehls, der über die reine Vektorverarbeitung hinausgeht und verzweigten Steuerfluss enthält, sei der psadbw -Befehl erwähnt, der die Summe der absoluten Differenzen (SAD) über 16 Vektorkomponenten gleichzeitig berechnen kann. Das folgende C-Programm könnte also durch diesen reichhaltigen Befehl ersetzt werden: unsigned char a[16], b[16]; int result = 0, i; ... for(i = 0; i < 16; i++) { if(a[i] > b[i]) result += a[i] - b[i]; else result += b[i] - a[i]; } Viele Befehle des SSE-Befehlssatzes arbeiten auf dem XMM-Registersatz. Dabei handelt es sich um spezielle, 128 Bit breite Register. Die Interpretation des Registerinhaltes hängt vom jeweiligen Befehl ab. Beispielsweise kann der Inhalt eines XMM-Registers als Vektor zu vier Integer Komponenten vom Typ Integer (32 Bit) ausgelegt werden. Register dieser Art bezeichnen wir allgemein als Spezialregister. Reichhaltige Befehle werden ausgenutzt, um einen Geschwindigkeitsvorteil gegenüber dem Ausführen von Einzelbefehlen zu erreichen. Große Erfolge wurden dabei im Multimediabereich verzeichnet: Durch von Hand optimierte Programme wurde eine hohe Geschwindigkeitssteigerung erreicht. In Tabelle 2.1 sind einige ausgewählte Beispiele aus den Intel Application Notes [16] mit den erzielten Speedups (soweit von Intel angegeben) dargestellt. 2.3 Reichhaltige Befehle in Firm Reichhaltige Befehle haben initial keinen entsprechenden Knotentyp in Firm, können aber als Teilgraphen, bestehend aus mehreren Einzeloperationen, in Firm-Graphen ge- 2.4 Transformation von Programmgraphen 9 funden werden. In Abbildung 2.1 ist ein solcher Teilgraph, mit der Semantik eines VektorLadebefehls, als dunkel markierte Knoten zu sehen. Die Knoten Load[Is] 595:28 und Load[Is] 985:19 laden zwei Integerwerte an zwei aufeinanderfolgenden Speicherstellen. Die geladenen Werte entsprechen also den Komponenten eines Vektors. Die restlichen Knoten des Teilgraphen dienen der Adressberechnung. Dieser Teilgraph könnte durch einen speziellen, hardwarespezifischen Knoten mit derselben Semantik ersetzt werden, der vom Backend direkt in den entsprechenden Assemblerbefehl umgesetzt wird. Beim Ausführen des Programms hätte der Prozessor weniger Befehle zu dekodieren, die Adressrechnung würde nur einmal erfolgen und das Programm würde von der Parallelverarbeitung des neuen Befehls profitieren. Firm erlaubt es zwar, zur Laufzeit neue Knotentypen einzuführen. Es ist aber zunächst unvereinbar mit Firm, neue hardwarespezifische Knoten einzufügen, da es sich um eine Zwischensprache handelt, die möglichst hardwareunabhängig sein soll. Hier hilft uns die Implementierung unseres aktuellen Backends für die IA32 Architektur weiter [28]. Im Gegensatz zu herkömmlichen Backend-Implementierungen arbeitet dieses Backend ebenfalls graphbasiert und erzeugt aus dem hardware-unabhängigen Zwischensprachengraphen einen hardware-spezifischen Backendgraphen. Da unsere Optimierung in einer späten Phase angesiedelt ist, kann das Einfügen hardware-spezifischer Knoten als Teil des Backends angesehen werden. 2.4 Transformation von Programmgraphen Um einen Firm-Graph durch Einfügen von reichhaltigen Befehlen zu optimieren, muss der ursprüngliche Graph transformiert werden. Die Transformation besteht darin, zu optimierende Teilgraphen von Programmgraphen aufzufinden und sie durch eine optimierte Version zu ersetzen. Dies sind Aufgaben, auf die zu erledigen Graphersetzungssysteme spezialisiert sind und wir deshalb an ein solches delegieren können. Definition 1. Für uns ist ein Graphersetzungssystem (GES) ein Tupel Graphersetzungssystem := (C, G) aus Graphersetzungsregeln C und einem Arbeitsgraphen G. Eine Graphersetzungsregel p ist eine abstrakte Darstellung einer Graphtransformation und hat die Form r p:L− →R Eine Graphersetzungsregel mit SPO-Semantik [9] besteht aus dem Mustergraph L, dem Ersetzungsgraph R und einem Homomorphismus r zwischen L und R. L beschreibt den Teilgraphen, der im Arbeitsgraphen gefunden werden soll. Wird ein Teilgraph gefunden, bezeichnen wir diesen als Passung. Nach erfolgreichem Finden einer Passung legen r und R nun fest, welche Knoten oder Kanten aus dem Arbeitsgraphen entfernt, welche neu hinzukommen oder welche retypisiert werden sollen. Weitere Details sind in [13] zu finden. 10 2 Grundlagen Der Einsatz eines Graphersetzungssystems entbindet uns von der Aufgabe, die Mustersuche und die Programmtransformation auszuprogrammieren. Stattdessen generieren wir Regeln für ein Graphersetzungssystem, was weniger aufwändig und weniger fehleranfällig ist. Für diese Arbeit setzen wir das Graphersetzungssystem GrGen [13] ein. GrGen wurde am Institut für Programmstrukturen und Datenorganisation entwickelt und arbeitet sehr effizient. Es besitzt eine einheitliche und umfangreiche Syntax für Graphersetzungsregeln. GrGen führt Syntax- und Semantikprüfungen von Graphersetzungsregeln durch, wodurch Fehler frühzeitig erkannt werden können. Es verfügt über eine leicht erweiterbare Spezifikation des verwendeten Graphmodells, was für diese Arbeit wichtig ist, da für die Optimierung neue Knotentypen und Knotenattribute eingeführt werden müssen. GrGen ist optimiert, gesuchte Muster schnell zu finden, indem es Arbeitsgraphen analysiert, bevor es mit der Suche beginnt. Auch diese Eigenschaft kommt uns zugute, denn eine Optimierung soll die Übersetzungszeit nicht übermäßig verlängern. Der modulare Aufbau von GrGen erlaubt es, verschiedene Backends zu verwenden. Ein Backend für die Graphverwaltung libfirm existiert bereits und wurde von Batz implementiert [3]. Dadurch kann die Graphersetzung direkt auf der übersetzerinternen Zwischendarstellung stattfinden, so dass die Zwischensprachengraphen nicht erst in ein geeignetes Format exportiert werden müssen. Falls die Optimierung mit einer anderen Firm-Implementierung durchgeführt werden soll, ist es so nicht nötig, den Regelgenerator neu zu schreiben. Es besteht die Möglichkeit, ein anderes, dafür vorgesehenes GrGen-Backend zu verwenden. 2.5 Zusammenarbeit von Übersetzer und GES Wir demonstrieren nun, wie Übersetzer und Graphersetzungssystem zusammenarbeiten. Der grobe Ablauf der Optimierung ist in Abbildung 2.2 zu sehen. Die Optimierung besteht aus zwei Schritten: Im ersten Schritt (dunkelgrau markiert) werden die zur Optimierung benötigten Graphersetzungsregeln generiert. Dazu wird die Befehls-Spezifikation der reichhaltigen Befehle an den Übersetzer übergeben. Das Übersetzer-Frontend wandelt die BefehlsSpezifikation in Zwischensprachengraphen um, welche vom Regelgenerator in Graphersetzungsregeln übersetzt und in einer Regeldatenbank gespeichert werden. Der Regelgenerator ist eine Erweiterung des Übersetzers und erzeugt zusätzlich zu den Graphersetzungsregeln automatisch auch die Spezifikation der neu hinzugekommenen Befehle für das Backend. Dieser erste Schritt muss nur dann durchgeführt werden, wenn neue reichhaltige Befehle hinzukommen. Ist die Regeldatenbank einmal aufgebaut, kann diese zur Optimierung von Programmen genutzt werden. Die eigentliche Optimierung geschieht im zweiten (weiß markierten) Schritt. Das zu optimierende Quellprogramm wird dem Übersetzer übergeben, welcher durch das Fron- 2.6 Verwandte Arbeiten 11 C Spezifikation reichhaltiger Befehl Quellprogramm Frontend Zwischensprache Übersetzer Regelgenerator Regel− Datenbank GES Backend Regelgenerierungsschritt Optimierter Assemblercode Optimierungsschritt Beide Schritte Abbildung 2.2: Grober Ablauf der Optimierung. tend Zwischensprachengraphen erzeugt. Auf diesen Zwischensprachengraphen arbeitet nun das Graphersetzungssystem GES, welches Bestandteil des Übersetzers ist [3] und sich der erzeugten Graphersetzungsregeln aus der Regeldatenbank bedient. Anschließend erzeugt das Backend aus dem optimierten Zwischensprachengraphen unter Zuhilfenahme der in der Regeldatenbank vorhandenen, hardware-spezifischen Zusatzinformationen optimierten Assemblercode. 2.6 Verwandte Arbeiten Im Folgenden beschreiben wir Arbeiten, die ein ähnliches Ziel verfolgen oder deren Methoden auf das von uns zu lösende Problem angewandt werden können. 2.6.1 Automatische Algorithmenerkennung Metzger und Wen [23] beschreiben ausführlich einen Ansatz, um komplette Algorithmen im sogenannten Computational Kernel eines Programms aufzufinden. Unter Computational Kernel wird der Teil des Programms verstanden, der einen Großteil der Rechenzeit benötigt und deshalb optimiert werden soll. Die Optimierung besteht darin, langsame 12 2 Grundlagen Programmteile durch einen Aufruf einer schnelleren, optimierten Bibliotheksfunktion mit derselben Semantik zu ersetzen. Die Optimierung findet auf einer Zwischensprache in Baumform, die für den Convex Application Compiler [1] entworfen wurde, statt. Die verwendeten Datenstrukturen sind • Der Control Tree, der die Anweisungen (engl. Statements) sowie den Steuerfluss des Programms inklusive Schleifen enthält. Ausdrücke werden durch Expression Trees repräsentiert. • Ein separater Graph zur Darstellung des Datenflusses zwischen Statements. • Der i-val Tree, der Schleifenvariablen sowie deren Abhängigkeiten darstellt. • Parameterlisten von Unterprogrammen. Die Idee besteht darin, den Computational Kernel auf Zwischensprachenebene auf vordefinierte Muster aus einer Musterdatenbank abzusuchen. Um diese Mustersuche zu beschleunigen, werden Muster und die Zwischendarstellung des Computational Kernel auf eine einheitliche Form (die sog. Canonical Form) gebracht. D.h. die Zwischensprachen-Bäume werden umgeordnet, indem Knoten durch eine Nummer kodiert und dann sortiert werden. Dieses Vorgehen soll die Anzahl der Freiheitsgrade bei der Mustersuche einschränken und die Mustersuche darauf reduzieren, die Bäume Knoten für Knoten zu vergleichen. Metzger und Wen beschreiben außerdem, wie Gruppen von Anweisungen aus einem Programm extrahiert werden, um sie mit Mustern aus der Musterdatenbank zu vergleichen. Dazu werden Eigenschaften der Anweisungsgruppe wie Knotentypen und die Topologie des Datenabhängigkeitsgraphen herangezogen. Für den Fall, dass verschiedene zu ersetzende Anweisungsgruppen gefunden wurden, wird beschrieben, wie eine Auswahl getroffen werden kann, um den größtmöglichen Kostenvorteil zu erhalten. Im Vergleich mit unserem Ansatz besteht dieselbe Grundidee, eine Datenbank mit vordefinierten Mustern zu unterhalten, die im Zwischensprachengraphen des zu optimierenden Programms gesucht werden. Metzger und Wen erwähnen sogar schon die Idee, die Muster automatisch zu erzeugen, indem man einen Algorithmus in einer Hochsprache spezifiziert. Dadurch kann selbst ein Benutzer des optimierenden Übersetzers neue Muster hinzufügen, ohne selbst über die interne Zwischensprache Bescheid wissen zu müssen. Auch der Ansatz, Muster und zu optimierendes Programm u.A. durch Standardoptimierungen näher an eine einheitliche Form zu bringen, ist in beiden Arbeiten ähnlich. Unterschiedlich ist die Art der verwendeten Datenstrukturen auf Zwischensprachenebene. Firm hat Graphstruktur im Unterschied zur Baumstruktur mit Anweisungslisten der oben erwähnten Zwischensprache. Daten- und Kontrollfluss, sowie Ausdrücke, Anweisungen und Funktionsparameter sind in unserem Fall in einem gemeinsamen Graphen enthalten. Da Firm-Programme in SSA Form sind, spielen Variablennamen auf 2.6 Verwandte Arbeiten 13 Zwischensprachenebene keine Rolle. Im Unterschied zu Metzger und Wen müssen wir das Zusammenspiel von Variablen nicht erst durch Umordnen von Anweisungslisten und Umbenennen prüfen. Alle möglichen Anweisungsanordnungen sind durch den Datenfluss in Firm gegeben. Metzger und Wen stellen den Anspruch, komplette Algorithmen inklusive Schleifen in einem Programm wiederzufinden, während wir DAGs (Directed Acyclic Graph) in Programmgraphen auffinden wollen, die dem Verhalten reichhaltiger Befehle entsprechen. Unsere Optimierung ist mehr Backend orientiert, da wir gefundene Muster nicht durch einen Funktionsaufruf, sondern durch schnelle Assemblerbefehle ersetzen wollen. Dadurch haben wir auch auf Spezifikationsebene mit Hardwareeigenschaften wie die benutzen Registerklassen etc. zu tun. Unsere Optimierung muss teilweise in das Compilerbackend integriert werden. Wir benutzen ein modernes GES, um Muster in Zwischensprachen aufzufinden. Schließlich kann sich unsere Arbeit auf eine Implementierung stützen, während Metzger und Wen nur den Normalisierungsprozess, nicht aber Mustergenerierung, Mustersuche und Ersetzungsschritt implementiert haben. 2.6.2 Befehlsauswahl mittels PBQP Jakschitsch verwendet einen PBQP-Löser (Partitioned Boolean Quadratic Problem), um die kostengünstigste Auswahl von Assemblerbefehlen für ein Firm-Programm zu treffen [19]. Ein PBQP lässt sich bildlich als Graph darstellen, an dessen Knoten kostenbehaftete Alternativen existieren (dargestellt durch Kostenvektoren). An den Kanten werden Übergangskosten zwischen Alternativen der beiden verbundenen Knoten als Kostenmatrizen annotiert. Das Problem der Befehlsauswahl wurde erstmals von Eckstein, König und Scholz auf ein PBQP abgebildet [10] und von Jakschitsch erweitert. Wir gehen später noch genauer auf dieses Befehlsauswahlverfahren ein und zeigen, wie PBQP zur kostengesteuerten Auswahl von Ersetzungen verwendet werden kann. 14 2 Grundlagen 3 Generierung von Graphersetzungsregeln Die zur Optimierung verwendeten Graphersetzungsregeln können aufgrund der Komplexität von reichhaltigen Befehlen und der Zwischensprache sehr groß werden. Um die Regeln nicht von Hand schreiben zu müssen, werden sie ausgehend von einer BefehlsSpezifikation in der Hochsprache C generiert. Das erleichtert das Hinzufügen von neuen reichhaltigen Befehlen erheblich. In diesem Kapitel entwerfen wir eine Spezifikationssprache basierend auf C und beschreiben die Transformation der Befehls-Spezifikation in Graphersetzungsregeln. Dabei gehen wir auf die notwendigen Informationen ein, die die Spezifikation beinhalten muss, um eine Optimierung durchführen und ausführbaren Code generieren zu können. Wir beschreiben außerdem, wie aus dem initialen Zwischensprachengraphen Muster- und Ersetzungsgraphen entstehen. 3.1 Entwurf der Spezifikationssprache Wir verändern und erweitern die Spezifikationssprache, wie sie von Hofmann [15] beschrieben wurde, um sie vielseitiger und mächtiger zu gestalten. Die alte Art der Spezifikation war nicht ausreichend, da sie es nicht erlaubte, neben dem reinen Verhalten weitere Eigenschaften eines reichhaltigen Befehls anzugeben. So konnte z.B. nicht festgelegt werden, in welchen Registerklassen ein reichhaltiger Befehl seine Operanden erwartet. Eine automatische Registerzuteilung war so nicht möglich. Außerdem war die Spezifikation nicht intuitiv genug. Speicher- und Registerzugriffe wurden während der Verhaltensbeschreibung unterschiedlich behandelt, was zu Verwirrung bei der Spezifikation führte. Diese Probleme beleuchten wir im Folgenden genauer. Zunächst stellen wir fest, dass außer dem reinen Verhalten eines reichhaltigen Befehls weitere Informationen vom Benutzer benötigt werden. Da wären z.B. die vom Befehl verwendeten Entitäten, die Operanden oder Ergebnis des Befehls darstellen, oder der zu emittierende Assemblercode. Um dies in einer einheitlichen Form spezifizieren zu können, führen wir eine neue Schreibweise ein: Wir gewinnen die gewünschten Informationen aus dem Aufruf einer vordefinierten Funktion. Beispielsweise definiert der Aufruf float *MyOp = Operand_0(attributes); die Variable MyOp, mit deren Hilfe sich nun das Verhalten des Befehls beschreiben lässt. Durch die Funktionsparameter attributes lassen sich der Variablen weitere Attribute zuordnen, auf die wir im nächsten Abschnitt zu sprechen kommen. Dieser Ansatz ist leicht erweiterbar, da sich so nicht nur die Entitäten, sondern beliebige weitere Eigenschaften eines reichhaltigen Befehls spezifizieren lassen. 15 16 3 Generierung von Graphersetzungsregeln 3.1.1 Entitäten Jeder reichhaltige Befehl arbeitet auf bestimmten Entitäten wie Registern oder Speicherbereichen, die wir in der Befehls-Spezifikation durch Variablen modellieren. Wir müssen die Möglichkeit bieten, solche Variablen zu definieren und erlauben, die Variablen mit Attributen zu versehen. Durch Auslesen der Attributwerte müssen folgende Fragen beantwortet werden können: 1. Nimmt die Variable einen Operanden oder das Ergebnis des reichhaltigen Befehls auf? 2. Repräsentiert die Variable einen Vektor oder einen skalaren Wert? 3. Befindet sich der Wert im Hauptspeicher oder in einem Register? 4. In welcher Registerklasse wird der Wert übergeben? Eine Variable wird durch einen Funktionsaufruf Operand n() oder Result() definiert. Diese Funktionen haben einen Zeiger als Ergebnis, mit dessen Hilfe das Verhalten des reichhaltigen Befehls beschrieben werden kann. Die Attribute der Variablen werden als Funktionsparameter angegeben: type *MyOp = Operand_n(char *kind, char *location, char *register_class); oder type *MyRes = Result(char *kind, char *location, char *register_class); Die oben geforderten Informationen gewinnen wir wie folgt: 1. Der Name der Funktion ist bedeutungstragend. Operand n bedeutet, dass die Variable MyOp den n. Quelloperanden des reichhaltigen Befehls repräsentiert. Result bedeutet, dass die Variable MyRes den Zieloperanden des reichhaltigen Befehls darstellt. Der primitive Typ type und der Name der Variablen kann frei vom Benutzer festgelegt werden. 2. Der Funktionsparameter kind darf die Werte ”vector” oder ”scalar” annehmen und gibt an, ob die Variable einen Vektor oder einen skalaren Wert repräsentiert. 3. Der Parameter location legt durch die Werte ”register” oder ”memory” fest, ob sich der Wert der Variablen in einem Register oder im Speicher befindet. 4. Die Registerklasse wird durch den Parameter register class angegeben und entspricht dem Namen einer im Backend deklarierten Registerklasse. Beispielsweise kommen hier Werte wie ”xmm” für die XMM Registerklasse oder ”gp” für die Allgemeinregisterklasse (engl. General Purpose) in Frage. Werte im Speicher erfordern ebenfalls die Angabe einer Registerklasse, da die Speicheradresse im Register übergeben wird. 3.1 Entwurf der Spezifikationssprache 17 3.1.2 Informationen für das Backend Um Assemblercode ausgeben zu können, werden einige Informationen über den reichhaltigen Befehl an das Backend weitergereicht. In Anlehnung an die Schreibweise zur Spezifikation der Entitäten, werden die Informationen für das Backend ebenfalls durch den Aufruf vordefinierter Funktionen spezifiziert: Der zu emittierende Assemblercode wird als Parameter der Funktion Emit(char *assembler_template); angegeben. Der Parameter assembler template besteht aus dem Namen des zu emittierende Assemblerbefehls sowie den Platzhaltern ”%Sn ” für den n. Quelloperanden und ”%Dn ” für den n. Zieloperanden. Die Platzhalter werden vom Backend durch die tatsächlich zugeteilten Register ersetzt. Es ist auch möglich, mehrere Assemblerbefehle – getrennt durch einen Zeilenumbruch – anzugeben. Emit(".movdqu %S0, %D0"); Außerdem müssen wir dem Backend Bescheid geben, falls ein reichhaltiger Befehl weitere Register außer seinem Zielregister überschreibt. Das passiert beispielsweise beim Umkopieren von einzelnen Vektorkomponenten aus XMM Registern in Allgemeinregister mit SSE2 Befehlen. Durch den Aufruf der Funktion Destroys(char *destroyed_register); kann dieser Umstand spezifiziert werden. Der Parameter destroyed register kann die Werte ”in rn ” annehmen. Damit ist gemeint, dass der Befehl zusätzlich das Register überschreibt, in dem der n. Quelloperand übergeben wurde. 3.1.3 Steuerung der Regelanwendung Wie wir später sehen werden, ist die Reihenfolge der Anwendung der generierten Graphersetzungsregeln wichtig. Dazu bieten wir dem Benutzer die Möglichkeit, jeden reichhaltigen Befehl einer bestimmten Prioritätsklasse zuzuordnen. Um dies zu spezifizieren, ist der Aufruf der Funktion Priority(int priority_class); notwendig, wobei der Parameter priority class die Nummer der Prioritätsklasse angibt. Regeln in einer niedrigeren Prioritätsklasse werden früher ausgeführt als Regeln in einer höheren Prioritätsklasse. Durch den Aufruf der Funktion CostSavings(int savings); kann festgelegt werden, welche Kostenersparnis ein reichhaltiger Befehl im Vergleich zur Ausführung von Einzelbefehlen hat. Die spezifizierten Kosten werden später bei der Musterauswahl (Kapitel 6) eingesetzt. 18 3 Generierung von Graphersetzungsregeln #include <rich_instructions_opt.h> void addps(void) { // Definitionsteil float *op0 = Operand_0("vector", "memory", "gp"); float *op1 = Operand_1("vector", "register", "xmm"); float *result = Result("in_r1"); Emit("addps %S0, %S1"); Priority(3); CostSavings(15); // Verhalten des reichhaltigen Befehls result[0] = op0[0] + op1[0]; result[1] = op0[1] + op1[1]; result[2] = op0[2] + op1[2]; result[3] = op0[3] + op1[3]; } Abbildung 3.1: Spezifikation des Befehls addps 3.1.4 Beispiel einer Befehls-Spezifikation Nachdem wir die Spezifikationsmöglichkeiten detailliert besprochen haben, präsentieren wir nun eine Beispielspezifikation. Die Spezifikation von mehreren reichhaltigen Befehlen besteht aus mehreren Funktionen, wobei jede Funktion genau einen Befehl beschreibt. Jede Funktion erhält einen beliebigen, aber eindeutigen Namen. So können, falls nötig, für einen Befehl mehrere Verhaltensbeschreibungen angegeben werden. Als Beispiel zeigt Abbildung 3.1 eine Spezifikation des Befehls addps des SSE2 Befehlssatzes, der zwei Vektoren zu jeweils vier Komponenten addiert. Die Komponenten sind vom Typ Fließkomma mit einfacher Genauigkeit (Float). Zu Beginn des Funktionsrumpfes werden die beiden Operanden op0 und op1 definiert. In beiden Fällen handelt es sich um Vektoren (im Gegensatz zu Skalaren), wobei sich op0 im Speicher befindet und op1 in Registern des Registersatzes xmm. Die einzelnen Vektorkomponenten haben den Datentyp float. Desweiteren legt der Aufruf der Funktion Result fest, dass der Befehl sein Ergebnis im Register ”in r1” speichert, d.h. in dem Register, in dem auch der Quelloperand op1 liegt. Alternativ könnte der Befehl sein Ergebnis im Speicher ablegen. Als nächstes wird der zu produzierende Assemblercode als Parameter der Funktion Emit angegeben. Schließlich wird durch Priority(3) dem Befehl die Prioritätsklasse 3 und durch CostSavings(15) die Kosteneinsparung 15 zugewiesen, wodurch die Reihenfolge, in welcher die Graphersetzungsregeln zur Anwendung kommen, beeinflusst wird. Nach diesen einleitenden Definitionen wird das Verhalten des reichhaltigen Befehls unter Verwendung der im Definitionsteil angelegten Variablen beschrieben. Auf Variablen, die einen Registerinhalt repräsentieren, kann dabei wie auf normale Variablen oder 3.2 Generierung des Mustergraphen 19 Reihungen im Speicher zugegriffen werden. In diesem Beispiel werden zwei Vektoren komponentenweise addiert und das Ergebnis im festgelegten Zielregister abgespeichert. 3.2 Generierung des Mustergraphen Da nun bekannt ist, wie reichhaltige Befehle in C spezifiziert werden können, beschreiben wir jetzt, wie aus der Befehls-Spezifikation der Mustergraph generiert wird. Dazu müssen wir zunächst die Befehls-Spezifikation in einen Zwischensprachengraphen umwandeln. Einen initialen Zwischensprachengraphen erhalten wir, indem wir die BefehlsSpezifikation des reichhaltigen Befehls dem unmodifizierten Übersetzer-Frontend übergeben. Das ist möglich, denn für die Spezifikation wurden nur Sprachelemente der Programmiersprache C verwendet, so dass das Frontend nicht speziell angepasst werden muss. Der vom Frontend gelieferte Graph ist jedoch nicht direkt als Mustergraph geeignet. Zum Einen enthält er Knoten, die im Mustergraph nicht erwünscht sind (z.B. Start- und End-Knoten) und zum Anderen muss der initiale Graph teilweise transformiert und normalisiert werden. Um diese Transformation durchzuführen, müssen die Informationen, die der Benutzer auf Hochsprachenebene spezifiziert hat, im initialen Graph wiedergefunden werden. Dazu analysieren wir den initialen Graphen. 3.2.1 Analyse des initialen Graphen Insbesondere Load- und Store-Knoten haben im initialen Graphen eine vom Arbeitsgraphen, in dem wir die Muster finden wollen, etwas abweichende Bedeutung. Im Arbeitsgraphen stellen Load- und Store-Knoten Speicherzugriffe dar, im initialen Graphen werden dadurch auch Registerzugriffe modelliert, abhängig davon, auf welche Variable ein Speicherbefehl zugreift. Greift eine Load- bzw. Store-Operation im Mustergraphen auf das Abbild eines Registers zu, bezeichnen wir diese als Register Load bzw. Register Store. Aufgrund der Grapheigenschaft von Firm können wir die Bedeutung von Speicheroperationen mit geringem Aufwand zurückgewinnen, indem wir die Datenabhängigkeitskanten ausgehend vom betreffenden Load- oder Store-Knoten absuchen. Für jedes Load und Store existiert ein Datenflusspfad zur Definition der Variablen, auf die zugegriffen wird. An dieser Stelle können wir die benötigten Informationen auslesen. Abbildung 3.2 zeigt ein generisches Muster, das im initialen Graphen vorzufinden ist: Über die Adresskante des Load- oder Store-Knotens ist ein optionaler Add-Knoten und über über einen Proj -Knoten direkt ein Call-Knoten erreichbar, der die Definition einer Variablen darstellt. Die Vorgänger des Call-Knotens sind die Funktionsparameter der aufgerufenen Funktion und stellen somit direkt die Attribute dar, die der Benutzer der entsprechenden Variablen zugeordnet hat. Durch diese Attribute können wir entscheiden, ob es sich um eine Register- oder Speicheroperation handelt. Um bei der Mustertransformation (siehe Abschnitt 3.2.2) schnell auf diese Attribute zurgreifen zu können, annotieren wir 20 3 Generierung von Graphersetzungsregeln SymC ... SymC Call Proj P Const Add Load Abbildung 3.2: Generisches Muster, um Analyseinformationen über Load und Store Knoten zu gewinnen. sie an jedem Load- und Store-Knoten. Auf ähnliche Weise werden auch die restlichen, im Definitionsteil festgelegten Informationen, extrahiert. Der zu emittierende Code, die Prioritätsklasse des Befehls oder die zusätzlich überschriebenen Register können aus den Parametern derjenigen Call-Knoten extrahiert werden, die dem jeweiligen Funktionsaufruf entsprechen. 3.2.2 Transformation des initialen Graphen Da wir jetzt zwischen Register- und Speicheroperationen unterscheiden können, kann mit den gewonnenen Informationen der initiale Graph zum Mustergraph transformiert werden. Zunächst kümmern wir uns um Register Load- und Register Store-Knoten. Wenn eine Register Load-Operation mit einem gewissen Offset auf die Basis eines Registers zugreift, hat dies die Bedeutung, dass eine einzelne Vektorkomponente aus einem Spezialregister geladen wird. Um solche Zugriffe auf Vektorkomponenten im Graph explizit sichtbar zu machen, ersetzen wir Register Load-Knoten durch die in von Hofmann [15, Abschnitt 4.4] eingeführten VProj -Knoten (Projektion einer Komponente aus einem Vektor). Damit steht ein Knotentyp zur Verfügung, um eine Vektorkomponente eines Vektorregisters auszuwählen. Die Nummer der Vektorkomponente wird durch ein Indexattribut VProj Number angegeben. Das Indexattribut berechnen wir durch VProj Number = Offset SizeOfMode (3.1) wobei mit Offset der Versatz zur Basis des Vektors gemeint ist und mit SizeOfMode die Breite des Modes der zu ladenden Daten in Byte. Der Ansatz, VProj -Knoten mit in den Mustergraph aufzunehmen, setzt vorraus, dass VProj -Knoten ebenfalls im Arbeitsgraphen vorkommen. Sie werden durch vorangegangene Graphersetzungen in den Arbeitsgraphen eingefügt. 3.2 Generierung des Mustergraphen 21 Register Store-Operationen legen ein Endergebnis des reichhaltigen Befehls in einem (Teil-)Register ab. Da dieser Vorgang im Arbeitsgraphen nicht explizit vorkommt, nehmen wir Register Store-Knoten nicht in den Mustergraph mit auf; sie werden zunächst ignoriert. Die Knoten, die Endergebnisse des reichhaltigen Befehls darstellen, kommen im Arbeitsgraphen jedoch vor und können im Mustergraphen als Vorgänger des Register Store-Knotens gefunden werden kann. Dieser Vorgängerknoten wird erst später bei der Generierung des Ersetzungsgraphen interessant, da sein Ergebnis nun durch den reichhaltigen Befehl berechnet werden soll (Siehe Abschnitt 3.3). Ebenfalls nicht mit in den Mustergraph mit aufgenommen werden alle Knoten, die nicht zur Verhaltensbeschreibung des reichhaltigen Befehls gehören. Dazu gehören die Knoten des Definitionsteils sowie Start- und End-Knoten. Dazu beschneiden wir den initialen Graphen um diese Knoten. 3.2.3 Speicherkante Ein Nachteil der Implementierung von Hofmann [15] war, dass geringe Abweichungen von Spezifikation und zu optimierendem Programm dazu führten, dass reichhaltige Befehle nicht gefunden wurden. Spezifizierte beispielsweise der Benutzer das Verhalten eines Vektorlade-Befehls folgendermaßen: res[0] = a[0]; res[1] = a[1]; und wurden die Werte im realen Programm aber in umgekehrter Reihenfolge geladen: x = a[1]; y = a[0]; so konnte die Vektor-Ladeoperation, obwohl vorhanden, im unteren Programmstück bisher nicht erkannt werden. Ein weiteres Problem ist, dass in realen Programmen die Speicheroperationen einer Passung durch nicht zur Passung gehörende Speicheroperationen unterbrochen werden kann. Um das zu demonstrieren greifen wir das Beispiel aus Abbildung 2.1 noch einmal auf: Zwischen den zur dunkel markierten Passung gehörenden Knoten Proj[M] 962:27 und Load[Is] 987:13 befinden sich entlang der Speicherkante mehrere Knoten, die nicht zur Passung gehören: Load[Is] 970:25, Proj[M] 961:29, Store[Is] 980:19, Proj[Is] 981:18. Der einzufügende reichhaltige Befehl würde die Einzelbefehle der Passung aber auf einmal ausführen. Wir müssen also prüfen, ob wir die dazwischenliegenden Knoten so umordnen dürfen, dass die Operationen der Passung bezüglich der Speicherkante zusammenhängend sind. Beide Probleme hängen mit der Speicherkante zusammen. In beiden Fällen wurden Speicheroperationen serialisiert, obwohl eigentlich keine Speicherabhängigkeit vorhanden ist. Das liegt daran, dass mangels einer entsprechenden Transformation alle Speicheroperationen so serialisiert wurden, wie sie im Quellprogramm vorkommen. 22 3 Generierung von Graphersetzungsregeln Um auch mit diesen Tatsachen zurecht zu kommen, nehmen wir die Speicherkante zwischen den Speicheroperationen nicht mit in den Mustergraph auf. Der große Vorteil ist, dass auch Programme, die die Ladebefehle in einer anderen Reihenfolge als spezifiziert durchführen, optimiert, sowie Passungen, die bezüglich der Speicherkante ineinander verzahnt“ sind, ersetzt werden können. Wir erkaufen uns das jedoch dadurch, dass ” wir nach erfolgreicher Mustersuche noch herausfinden müssen, ob eine Passung wirklich ersetzt werden darf. Darauf gehen wir im Detail in Kapitel 4 ein. 3.3 Generierung des Ersetzungsgraphen Zusätzlich zum Mustergraphen besteht eine Graphersetzungsregel aus dem Ersetzungsgraphen, der das Ergebnis der Graphtransformation beschreibt. Zunächst ist klar, dass der Ersetzungsgraph den Knoten des neu einzufügenden reichhaltigen Befehls enthalten muss. In diesem Abschnitt klären wir die weiterführenden Fragen, mit welchen Operanden der neue Befehl verbunden wird, wie seine Endergebnisse bekannt gemacht werden und welche Knoten und Kanten aus dem Arbeitsgraphen gelöscht werden können. Auf den ersten Blick erscheint es offensichtlich, dass alle Einzeloperationen einer gefundenen Passung gelöscht werden dürfen, da diese jetzt implizit durch den reichhaltigen Befehl durchgeführt und nicht mehr gebraucht werden. Bei näherer Betrachtung erkennen wir aber, dass innere Knoten1 möglicherweise Zwischenergebnisse produzieren, die der reichhaltige Befehl nicht explizit bereit stellt. Finden diese Zwischenergebnisse im Arbeitsgraphen außerhalb einer Passung Verwendung, würde der Verwender durch das sofortige Löschen Operanden verlieren, was wiederum einen unzulässigen Firm-Graphen zur Folge hätte. Die Einzeloperationen dürfen also nicht sofort gelöscht werden, da wir den Arbeitsgraphen zum Zeitpunkt der Regelgenerierung noch nicht kennen. Der Ersetzungsgraph enthält somit ebenfalls alle Knoten und Kanten des Mustergraphen. Überflüssige Knoten werden erst später bei der Eliminierung toten Codes entfernt (siehe Abschnitt 4.4). Eine Ausnahme bilden die Knoten, die die Endergebnisse der Berechnung liefern. Diese Knoten werden nicht mehr gebraucht, da die Verwender der bisherigen Ergebnisse nun die Ergebnisse des reichhaltigen Befehls erhalten sollen. Dazu müssten die Eingangskanten der Verwender zum reichhaltigen Befehl umverdrahtet werden. Die Anzahl der Verwender ist aber vom Arbeitsgraphen abhängig und uns deshalb zum Zeitpunkt der Regelgenerierung noch nicht bekannt. Dadurch ist es schwierig, diese Transformation in einer einzigen Graphersetzungsregel auszudrücken. Um diese Schwierigkeit zu vermeiden, erfolgt die Ersetzung nach dem Verfahren, wie es in in Abbildung 3.3 dargestellt ist: Die linke Seite der Abbildung zeigt eine gefundene Passung eines reichhaltigen Befehls. Die n Operanden der Berechnung sind durch die Knoten Op 1 bis Op n, die m Endergebnisse durch die Knoten Res 1 bis Res m darge1 innere Knoten = Knoten, die weder einen Operanden noch ein Endergebnis darstellen des reichhaltigen Befehls darstellen 3.3 Generierung des Ersetzungsgraphen Op 1 Op n 23 Op 1 Op n Reichh. Befehl innere Knoten innere Knoten Proj Data Res 1 Res m Gefundene Passung VProj 1 [Res 1] Proj M VProj m [Res m] Ergebnis der Graphersetzung Abbildung 3.3: Grundsätzliches Vorgehen zum Erstellen des Ersetzungsgraphen stellt. Die Endergebnisse lassen sich zu einem m-komponentigen Vektor zusammenfassen. Außerdem sind die inneren Knoten der Passung abstrakt zusammengefasst. Nun soll der reichhaltige Befehl in das Programm eingefügt werden, was im rechten Teilbild zu sehen ist: Die neu hinzugekommenen Knoten sind der Knoten des reichhaltigen Befehls selbst sowie der Knoten Proj Data, der das Gesamtergebnis des Befehls darstellt, und Proj M für die Speicherabhängigkeit. Die Verwender der Endergebnisse sind nicht bekannt und können so auch nicht in die Transformation mit einbezogen werden. Stattdessen werden die bisherigen Ergebnisknoten zum Typ VProj k umtypisiert und mit dem Gesamtergebnis des reichhaltigen Befehls verbunden. Ihre ursprünglichen Eingangskanten werden gelöscht. Die VProj k -Knoten wählen nun die k. Komponente des vom reichhaltigen Befehl gelieferten Ergebnisvektors aus. Auf diese Weise zeigen alle Verwender der bisherigen Ergebnisse durch ihre Eingangskanten auf die VProj k Knoten und verwenden so die Ergebnisse des reichhaltigen Befehls. Durch die VProj -Knoten vermerken wir außerdem explizit im Graph, dass ein Wert eine Vektorkomponente eines Spezialregisters darstellt. Sollte also ein Einzelbefehl das Ergebnis weiterverwenden, muss zusätzlicher Konvertierungscode eingefügt werden. Liefert der reichhaltige Befehl einen skalaren Wert als Ergebnis, welcher von Einzeloperationen direkt verarbeitet werden kann, typisieren wir den Ergebnisknoten zum Typ Proj statt VProj um. Die Operanden des reichhaltigen Befehls sind diejenigen Knoten, die im Mustergraphen keine Vorgänger besitzen. Der reichhaltige Befehl wird also über seine Eingangskanten mit diesen Operanden verbunden. Die Kantenposition entspricht der vom Benutzer spezifizierten Operandennummer (siehe Abschnitt 3.1.1) und kann somit aus der Befehls-Spezifikation gewonnen werden. Es fehlt noch die endgültige Einreihung in die Speicherabhängigkeitskette des Ar- 24 3 Generierung von Graphersetzungsregeln beitsgraphen, die wir in diesem Schritt aber noch nicht durchführen können. Dazu eine Analyse des Arbeitsgraphen notwendig ist, was zur Optimierungszeit erfolgt. Wir beschreiben dieses Vorgehen in Kapitel 4.2. Die inneren Knoten der Passung sind nach wie vor enthalten, wurden aber vom restlichen Programm abgetrennt, weil die umtypisierten Ergebnisknoten neu verbunden wurden. Die inneren Knoten verlieren so möglicherweise alle Verwender und werden dann später automatisch durch die Eliminierung toten Codes entfernt. Für Store-Knoten funktioniert dieser Automatismus nicht mehr, da Store-Knoten kein Datenergebnis und somit auch keine Verwender der Daten haben, die sie verlieren könnten. Wir weisen das Graphersetzungssystem explizit an, diese Knoten zu entfernen. Das ist korrekt, da die betreffenden Speicherstellen nun durch den reichhaltigen Befehl beschrieben werden. 3.4 Regelgenerierung Nachdem bekannt ist, welche Gestalt Muster- und Ersetzungsgraph haben, wenden wir uns der Frage zu, wie daraus GrGen-Graphersetzungsregeln generiert werden. Dabei nutzen wir besondere Eigenschaften von GrGen, die wir im Folgenden beschreiben. 3.4.1 Syntax einer Graphersetzungsregel Eine Graphersetzungsregel besteht zum Einen aus einem pattern { }-Block, der die Knoten- und Kantenstruktur des Mustergraphen aufnimmt. Knotendefinitionen haben die Form: KnotenName : KnotenTyp; Kantendefinitionen haben die Form QuellknotenName - KantenName : KantenTyp -> ZielknotenName; Knoten und Kanten bezeichnen wir allgemein als Graphelemente. Um Attribute von Graphelementen zu vergleichen, erlaubt GrGen sogenannte Bedingungen innerhalb des pattern Teils, eingeschlossen in einen if { }-Block. Darin befinden sich Boolesche Ausdrücke, wobei Attribute von Graphelementen als Variablen verwendet werden dürfen. Zum Anderen besteht eine Graphersetzungsregel aus dem modify { }-Block. Dieser beschreibt den Ersetzungsgraph oder genauer die Unterschiede von Muster- und Ersetzungsgraph. Dadurch können Knoten bzw. Kanten hinzugefügt, gelöscht oder umtypisiert werden. Das Löschen erfolgt durch delete(GraphelementName, ...); Umtypisierte Graphelemente erhalten die Werte ihrer Attribute, falls diese in einem gemeinsamen Obertyp vorkommen. Umtypisieren kann aber auch quer zur Typhierarchie erfolgen. Die Syntax, um Graphelemente umzutypisieren, lautet 3.5 Überdeckung mehrerer Grundblöcke 25 NeuerGraphelementName : NeuerTyp<AlterGraphelementName>; Schließlich können im eval { }-Block Attribute von Graphelementen des Ersetzungsgraphen auf einen bestimmten Wert gesetzt werden. Zu deren Berechnung sind arithmetische Ausdrücke erlaubt. 3.4.2 Graphmodell Zur Regelgenerierung nutzen wir aus, dass GrGen erlaubt, ein hierarchisches Graphmodell zu definieren. So fassen wir beispielsweise die Knotentypen Sel und Proj, die möglicherweise eine Basisadresse eines Vektors darstellen, zu einem Obertyp VectorBase zusammen. Typen werden definiert durch node class TypName { AttributName : Typ, ... }; edge class TypName { AttributName : Typ, ... }; Untertypen von Knoten oder Kanten werden definiert durch node class UntertypName extends TypName { AttributName : Typ, ... }; edge class UntertypName extends TypName { AttributName : Typ, ... }; Mehr Details über die Syntax und Semantik von GrGen Sprachkonstrukten können im GrGen.NET Benutzerhandbuch [4] nachgelesen werden. 3.5 Überdeckung mehrerer Grundblöcke Erstreckt sich die Verhaltensbeschreibung eines reichhaltigen Befehls über mehrere Grundblöcke, sind weitere Feinheiten zu beachten, die wir im Folgenden untersuchen. 3.5.1 Phi-Knoten Im Fall von verzweigtem Steuerfluss enthält der Mustergraph Phi -Knoten. Phi -Knoten stehen für die Vereinigung alternativer Datenflüsse und wählen gemäß des Steuerflusses einen bestimmten Wert aus mehreren Datenflussvorgängern aus. Um zu vermerken, welche Daten auf welchem Steuerflusspfad berechnet werden, muss die Kantennummer einer Datenflusskante am Phi -Knoten mit der Kantennummer der entsprechenden Steuerflusskante am zugehörigen Grundblock übereinstimmen. Um dies während der Mustersuche zu überprüfen, dürfen wir nicht einfach die absoluten Kantennummern des Mustergraphen mit den Kantennummern des Arbeitsgraphen vergleichen. Es wäre möglich, im Arbeitsgraphen zwei beliebige Kanten eines Phi Knotens zu vertauschen, ohne die Semantik des Programms zu ändern, wenn gleichzeitig die Steuerflusskanten mit denselben Nummern am Grundblock vertauscht werden. Dann 26 3 Generierung von Graphersetzungsregeln würden aber aber die absoluten Kantennummern von Mustergraph und Arbeitsgraph nicht mehr übereinstimmen. Aus diesem Grund lassen wir erst beliebige Kantennummern an Phi -Knoten und Grundblöcken bei der Mustersuche zu. Ungültige Passungen filtern wir durch Bedingungen in der Graphersetzungsregel heraus. Dazu führen wir ein Positions-Attribut für Kanten ein. Die Kantennummern von Datenflusskanten der Phi -Knoten und Steuerflusskanten der zugehörigen Grundblöcke, die im Mustergraph gleich sind, werden im Arbeitsgraphen paarweise verglichen. Tritt eine Ungleichheit auf, wird die Passung aussortiert. Ein Nachteil von Bedingungen ist, dass die darin enthaltenen Informationen nicht in die Suchplanung mit einbezogen werden. Andererseits werden Bedingungen so früh wie möglich berechnet (in diesem Fall, sobald beide zu vergleichenden Kanten gefunden wurden), so dass die Suche möglicherwiese schon in einem frühen Stadium abgebrochen werden kann. 3.5.2 Grundblock von Operationen Wenn ein Knoten in den Mustergraphen aufgenommen wird, müssen wir auch seinen Grundblock mit aufnehmen. Ohne Grundblockinformation könnten beispielsweise zwei Ladebefehle, die sich auf unterschiedlichen Steuerflusspfaden befinden, fälschlicherweise als Passung eines Vektorlade-Befehls aufgefasst werden. In manchen Situationen ist diese Vorgehensweise aber zu restriktiv, weil Befehle, die im Mustergraphen aus einem Grundblock bestehen, im Arbeitsgraphen in mehrere Blöcke zerfallen können. Das hängt von der Anordnung der Einzelbefehle im Quellcode oder von vorausgegangenen Optimierungen ab, die Knoten möglicherweise umgeordnet haben. Wir weichen deshalb die Bedingung auf, dass jeder Knoten genau einem Grundblock zugeordnet sein muss. Stattdessen dürfen die Knoten, die sich im Mustergraphen in einem Grundblock befinden, im Arbeitsgraphen auf mehrere Grundblöcke verteilt sein. Diese Grundblöcke müssen sich dann aber paarweise dominieren bzw. nachdominieren. Das bedeutet, Knoten die sich im Mustergraph auf demselben Steuerflusspfad befunden haben, können im Arbeitsgraphen nicht auf verzweigte Steuerflusspfade ausgelagert sein. Für zwei Knoten a und b, die sich im Mustergraphen in einem Grundblock befinden, muss also im Arbeitsgraphen eine der beiden folgenden Bedingungen gelten: (a.block dom b.block ∧ b.block ndom a.block) (3.2) (b.block dom a.block ∧ a.block ndom b.block) (3.3) Um diese schwächere Bedingung per Graphersetzungsregel zu prüfen, können wir wieder Bedingungen einsetzen. Allerdings setzt das vorraus, dass es Knotenattribute gibt, die die Dominator-/Nachdominator-Beziehung von Knoten modellieren. Eine Ausnahme bilden Phi -Knoten, für die ja die Eingangskanten mit den Steuerflusskanten ihres Grundblocks abgeglichen werden müssen und deshalb an einen bestimmten 3.5 Überdeckung mehrerer Grundblöcke 27 Value 1 Value 1 RegStore Value 2 Value 3 RegStore RegStore Value 2 Value 3 Phi (a) Mustergraph mit mehreren Grundböcken (b) Zu erkennendes Muster im Arbeitsgraph Abbildung 3.4: Parallele Register Stores Grundblock gebunden sind. Für diese Knoten passen wir die Grundblöcke exakt. Dadurch stellen wir sicher, dass Datenfluss und Steuerfluss im Arbeitsgraphen genau so verknüpft sind wie im Mustergraphen. Für einige spezielle Knotentypen passen wir den Grundblock überhaupt nicht. Dazu zählen Konstanten, da sie sich im Firm-Graphen immer in Startblock befinden. 3.5.3 Mehrere Register-Stores auf dieselbe Vektor-Komponente Ein reichhaltiger Befehl, der verzweigten Steuerfluss enthält, schreibt möglicherweise auf den verschiedenen Steuerflusspfaden verschiedene Werte in eine Vektorkomponente eines Vektorregisters. In diesem Fall weichen Mustergraph und Arbeitsgraph stark voneinander ab, wie Abbildung 3.4 zeigt. Grundblöcke sind grau hinterlegt, Steuerflusskanten grau und Datenflusskanten schwarz eingezeichnet. Abbildung 3.4(a) stellt den Mustergraphen dar. Auf drei verschiedenen Steuerpfaden werden die Werte Value 1, Value 2 und Value 3 berechnet und in dieselbe Vektorkomponente geschrieben, dargestellt durch die RegStoreKnoten. Dem gegenüber steht der Arbeitsgraph aus Abbildung 3.4(b). RegStore-Knoten kommen im Arbeitsgraph nicht vor, stattdessen werden die drei verschiedenen Werte durch einen Phi -Knoten vereinigt. Dieser Phi -Knoten und seine inzidenten Kanten sind 28 3 Generierung von Graphersetzungsregeln im Mustergraphen initial nicht enthalten. Wir benötigen also eine Methode, um den Mustergraphen dem Arbeitsgraphen anzupassen. Unsere Implementierung setzt dazu die aktuelle Load-Store-Optimierung der libfirm ein. Diese führt die gewünschte Transformation durch, wenn sie, wie in Abbildung 3.4(a) der Fall, Store-Operationen auf dieselbe Adresse erkennt. Sie ersetzt die verschiedenen Store-Knoten durch einen einzigen Store-Knoten im Nachfolgerblock und fügt einen Phi Knoten ein. Wenn pro Grundblock mehrere verschiedene Vektorkomponenten beschrieben werden, funktioniert dieses Verfahren nicht mehr, da durch die serialisierten Speicheroperationen aktuell wichtige Analyseinformationen der Load-Store-Optimierung verborgen bleiben. Die aktuelle Implementierung hat deshalb Schwierigkeiten, solche reichhaltigen Befehle zu erkennen. Abhilfe würde eine vorgeschaltete Optimierung schaffen, die Speicheroperationen wenn möglich parallelisiert, wodurch die Load-Store-Optimierung wieder den von uns benötigten Graph erzeugen würde. Dies ist ein prinzipiell lösbares Problem, gehört aber nicht zu den Aufgaben dieser Arbeit. Trotz dieser Einschränkung konnten mit unserer Implementierung alle uns bekannten reichhaltigen Befehle spezifiziert werden. 4 Ersetzungsschritt In diesem Kapitel erklären wir, wie die generierten Graphersetzungsregeln angewendet werden und warum die Reihenfolge der Anwendung wichtig ist. Wir beschreiben, wie eine gefundene Passung auf Konsistenz geprüft und der reichhaltige Befehl in die Speicherabhängigkeitskette eingereiht wird. Abschließend erläutern wir noch, wie überflüssige Knoten aus dem Graph entfernt werden. 4.1 Priorität von Regeln Durch die Einführung der VProj -Knoten werden Graphersetzungsregeln voneinander abhängig. Betrachten wir beispielsweise einen VectorLoad und einen VectorAdd Befehl, der seine Operanden in einem Vektorregister erwartet. Im Mustergraphen des VectorAddBefehls befinden sich deshalb VProj -Knoten, die seine Operanden darstellen. Diese VProj -Knoten werden aber erst durch die Graphersetzungsregel des VectorLoad-Befehls in den Arbeitsgraphen eingefügt. Deswegen muss die VectorLoad-Graphersetzungsregel vor der VectorAdd-Graphersetzungsregel ausgeführt werden. Dazu führen wir Prioritätsklassen ein. Graphersetzungsregeln mit höherer Prioritätsklasse hängen von Regeln mit niedriger Prioritätsklasse ab. An dieser Stelle sei erwähnt, dass Prioritätsklassen zwar den Suchaufwand verringern, jedoch für einige Abhängigkeiten nicht ausreichend sind. Wir erörtern dieses Thema in Kapitel 6 genauer. Eine speziell ausgezeichnete Prioritätsklasse ist die Klasse CleanUp. Muster in dieser Klasse werden zuletzt gesucht und enthalten typischerweise Regeln, um im Arbeitsgraphen übrig gebliebene VProj Knoten in Allgemeinregister umzukopieren. 4.2 Anordnen des reichhaltigen Befehls Bisher haben wir nicht darüber gesprochen, an welcher Stelle ein reichhaltiger Befehl im Programmfluss angeordnet werden soll. Befehlsanordnung ist zwar die Aufgabe des Übersetzer-Backends, jedoch ist in Firm-Programmen durch die Speicherabhängigkeitskette eine Ausführungsreihenfolge von Speicheroperationen vorgegeben. Ein reichhaltiger Befehl, der den Speicher benutzt, muss in die Speicherabhängigkeitskette eingereiht werden. Wir müssen dafür sorgen, dass dabei keine Verklemmungen entstehen. Von einer Verklemmung sprechen wir, wenn ein Befehl so angeordnet wird, dass zum Zeitpunkt seiner Ausführung seine Operanden noch nicht zur Verfügung stehen. 29 30 4 Ersetzungsschritt Load 1 Proj Is VectorLoad Proj M E1 Proj M E2 Store Load 1 Proj M Proj Is Load 2 Proj Is Store Proj M VectorLoad E1 Proj M E2 (a) Verklemmung: Store kann das Ergebnis E1 des reichhaltigen Befehls nicht verwenden, da es zu spaet berechnet wird. Proj M Load 2 Proj Is Proj M (b) Korrekte Anordnung: Store kann das Ergebnis E1 des reichhaltigen Befehls verwenden, Load 1 und Load 2 können entfallen Abbildung 4.1: Mögliche Anordnungen des reichhaltigen Befehls. Dunkel gefärbte Knoten gehören zum Such- bzw. Ersetzungsmuster. Gestrichelte Elemente können nach der Regelanwendung entfallen. Eine mögliche Verklemmung ist in Abbildung 4.1(a) dargestellt1 . Hier ist grau markiert die Passung eines Vektor-Ladebefehls inklusive dem bereits eingefügten reichhaltigen Befehl VectorLoad zu sehen. Die Passung wird entlang der fett gezeichneten Speicherabhängigkeitskette durch einen Store-Knoten unterbrochen. Diese Store-Operation verwendete ursprünglich den von Load 1 geladenen Wert, soll aber stattdessen nun den äquivalenten Wert E1 verwenden, der durch den reichhaltigen Befehl VectorLoad von derselben Speicherstelle geladen wird. Der Knoten VectorLoad wurde jedoch hinter der Passung und insbesondere hinter der Store-Operation angeordnet. Zur Ausführungszeit der Store-Operation steht der benötigte Wert E1 also noch gar nicht zur Verfügung. Da der VectorLoad-Befehl zu spät angeordnet wurde, entsteht somit eine Verklemmung. Um solche Verklemmungen zu vermeiden, führen wir den reichhaltigen Befehl möglichst früh aus. Abbildung 4.1(b) zeigt diesen Fall: Der reichhaltige Befehl VectorLoad muss so angeordnet werden, dass er vor allen Speicheroperationen der zugehörigen 1 Adressberechnung, Operanden der Load -Knoten sowie des reichhaltigen Befehls wurden der Übersicht halber weggelassen. 4.2 Anordnen des reichhaltigen Befehls 31 Passung ausgeführt wird. Folglich wird in unserem Beispiel der Knoten VectorLoad vor dem Load 1 -Knoten eingereiht. So kann der Store-Befehl das Teilergebnis E1 des reichhaltigen Befehls verwenden und die Ladebefehle Load 1 und Load 2 können komplett entfallen. Wie im Beispiel zu sehen ist, hängt die Stelle, an der ein reichhaltiger Befehl angeordnet werden soll, vom Verlauf der Speicherabhängigkeitskette durch die Passung ab. Wir haben aber Informationen über die Speicherkante im Mustergraphen weggelassen, um nicht zu restriktiv zu sein. Deshalb kann ein reichhaltiger Befehl nicht durch eine vorgenerierte Graphersetzungsregel in die Speicherabhängigkeitskette eingereiht werden. Dazu analysieren wir den Arbeitsgraphen separat und fügen die Speicherabhängigkeit wenn nötig nach der erfolgreichen Anwendung einer Graphersetzungsregel hinzu. 4.2.1 Auffinden der Speichervorgänger Wir müssen also eine geeignete Stelle im Programmgraphen finden, um den reichhaltigen Befehl möglichst früh anzuordnen. Es stellt sich die Frage, ob es eindeutig eine solche Stelle gibt. Der Befehl soll vor allen Speicheroperationen seiner Passung angeordnet werden. Es muss aber im Allgemeinen keine eindeutige erste Speicheroperation einer Passung geben, weil in Firm auch parallele Speicherabhängigkeitsketten zugelassen sind. Speicheroperationen auf parallelen Speicherpfaden greifen auf disjunkte Speicherbereiche zu2 . Insbesondere kann es somit mehrere Pfade der Speicherabhängigkeit durch ein Passung geben. Wir haben also im Allgemeinen keinen eindeutigen Knoten, vor dem der reichhaltige Befehl anzuordnen wäre. Es gibt vielmehr drei Fälle der Speicherabhängigkeit einer Passung vom restlichen Graph zu unterscheiden, die wir im Folgenden beschreiben. Zunächst benötigen wir jedoch eine exakte Definition des Begriffs des Speichervorgängers einer Passung: Definition 2. Wir bezeichnen einen Knoten n als Speichervorgänger einer Passung p, falls ein direkter Nachfolger m von n zu p gehört und es über die Speicherkante einen Weg vom Start-Knoten nach n gibt, der keinen zu p gehörenden Knoten überstreicht. m bezeichnen wir als eine erste Speicheroperation. Als Beispiel betrachten wir eine Passung des Befehls VectorLoad, der zwei aufeinanderfolgende Speicherstellen lädt. Die Passung besteht aus 4 Knoten, nämlich den beiden Ladeoperationen Load 1 und Load 2 sowie ihren jeweiligen Proj M -Knoten. Abbildung 4.2(a) zeigt den Fall, dass alle Speicheroperationen dieser Passung – hellgrau hinterlegt – komplett serialisiert sind. Hier gibt es eine eindeutige erste Speicheroperation Load 1, die nach Definition Teil der Passung ist. Folglich existiert auch ein eindeutiger Speichervorgänger: der dunkelgrau hinterlegte Proj M -Knoten außerhalb der Passung. Der reichhaltige Befehl kann direkt vor dem Load 1 -Knoten eingefügt werden. 2 Eine vorausgegangene Optimierung könnte Speicheroperationen parallelisiert haben, nachdem sie herausgefunden hat, dass zwei Load -Knoten auf disjunkte Speicherbereiche zugreifen. 32 4 Ersetzungsschritt Proj M Proj M ? Load 1 Load 1 Load 2 Proj M ... Proj M Proj M Proj M Load 1 Proj M Proj M Load 2 Load 2 Proj M Proj M (a) Eindeutiger Speichervorgänger eine erste Speicheroperation (b) Eindeutiger Speichervorgänger mehrere erste Speicher− operationen (c) Mehrere Speichervorgänger mehrere erste Speichero− operationen Abbildung 4.2: Drei mögliche Speicherabhängigkeiten. Die Musterknoten sind hellgrau markiert, die Speichervorgänger dunkelgrau. In Abbildung 4.2(b) sind die Speicheroperationen Load 1 und Load 2 parallelisiert worden. Die Passung hat nun mehrere erste Speicheroperationen Load 1 und Load 2, jedoch ebenfalls einen eindeutigen Speichervorgänger Proj M außerhalb der Passung. In diesem Fall ordnen wir den reichhaltigen Befehl direkt hinter dem Speichervorgänger ein. Abbildung 4.2(c) zeigt eine problematische Situation: Zusätzlich zu den Operationen Load 1 und Load 2 sind weitere Speicheroperationen außerhalb der Passung parallelisiert worden. Es gibt nun mehrere, dunkelgrau markierte Speichervorgänger außerhalb der Passung, so dass der reichhaltige Befehl nicht mehr eindeutig angeordnet werden kann. Wir müssen in diesem Fall eine künstliche Sync-Operation einfügen, die die beiden Flüsse der Speicherabhängigkeit miteinander vereint. Dadurch entsteht wieder ein eindeutiger Speichervorgänger, den wir wie in Fall (b) behandeln können. Diesen Fall deckt unsere Implementierung momentan noch nicht ab. Das Bestimmen der Speichervorgänger einer Passung geschieht zwischen Mustersuche und Ersetzung, indem wir eine Implementierung des Algorithmus 1 ausführen. Initial übergeben wir dem Algorithmus den Start-Knoten als Parameter. Dieser Algorithmus führt dann eine Tiefensuche auf dem Zwischensprachengraphen entlang der Speicherkante beginnend beim Start-Knoten durch. Trifft die Suche auf einen Knoten der Passung, wird der Vorgänger dieses Knotens, der außerhalb der Passung liegt, der Menge M der Speichervorgänger hinzugefügt, und die Suche entlang dieses Pfades abgebrochen. Nach dem Terminieren des Algorithmus enthält M die Menge aller Speichervorgänger. Unsere Implementierung setzt momentan |M | = 1 vorraus. Nach dem Ausführen einer Graphersetzungsregel wird der reichhaltige Befehl direkt nach dem gefundenen Speichervorgänger 4.2 Anordnen des reichhaltigen Befehls 33 in die Speicherabhängigkeitskette eingefügt. Algorithmus 1 Suche der Speichervorgänger procedure SucheSpeichervorgaenger(n) if Knoten n schon besucht then return end if Markiere n als besucht if Knoten n gehört zur Passung then Markiere Vorgänger von n als Speichervorgänger return end if if mode(n) != Memory then for all Nachfolger m von n mit mode(m) == Memory do Speichervorgaenger(m) end for end if if mode(n) == Memory then for all Nachfolger m von n do Speichervorgaenger(m) end for end if end procedure 4.2.2 Grundblock des reichhaltigen Befehls Den Grundblock, in den ein reichhaltiger Befehl angeordnet werden soll, bestimmen wir ebenfalls erst, nachdem eine Passung gefunden wurde. Wir können den richtigen Grundblock nicht per Graphersetzungsregel bestimmen, da eine Passung, die im Mustergraphen aus nur einem Grundblock besteht, im Arbeitsgraphen auf mehrere Blöcke verteilt sein kann (siehe Abschnitt 3.5.2). Zum Zeitpunkt der Regelgenerierung kennen wir diese Verteilung noch nicht. Wir haben zwei Fälle zu beachten: 1. Der reichhaltige Befehl benutzt den Speicher. In diesem Fall wurde der Befehl schon in die Speicherabhängigkeitskette eingereiht und damit ist der Grundblock schon fest vorgegeben. Würde der Befehl in einem anderen Grundblock angeordnet werden, entstünden Verklemmungen. 2. Der Befehl arbeitet nur auf Registern. Falls der reichhaltige Befehl keinen Speicher benutzt bietet sich mehr Spielraum. Wir suchen dazu den Grundblock eines inneren Knotens der Passung, der alle anderen Blöcke der Passung dominiert und ordnen den reichhaltigen Befehl in diesen ein. 34 4 Ersetzungsschritt 4.3 Analyse einer Passung In den folgenden Abschnitten beschreiben wir die Fälle, in denen der reichhaltige Befehl nicht eingesetzt werden darf, obwohl eine Passung gefunden wurde. Vor dem Ersetzen einer Passung müssen wir deshalb durch geeignete Analysen prüfen, ob wir den Ersetzungsschritt überhaupt durchführen dürfen. 4.3.1 Speicherabhängigkeit von Musterknoten Die erste Analyse, die wir durchführen, betrifft den Verlauf der Speicherkante durch eine Passung. Da wir im Mustergraph die Speicherkanten weggelassen haben, kennen wir diesen Verlauf nicht. Möglicherweise befinden sich zwischen den Knoten der Passung entlang der Speicherkante Knoten, die nicht zur Passung gehören (siehe Abschnitt 3.2.3). Um das herauszufinden, suchen wir, ausgehend von jeder Speicheroperation n einer Passung, die Speicherkante nach oben hin ab. Wir können die Suche abbrechen, wenn wir auf einen Speichervorgänger der Passung treffen. Dadurch arbeitet die Suche nur lokal im Bereich der Passung. Stößt die Suche auf einen fremden Knoten m, müssen wir überprüfen, ob beide Knoten n und m auf denselben Speicherbereich S zugreifen und ob einer der beiden Knoten n oder m den Speicher verändert. Ist das der Fall, darf die Ersetzung nicht durchgeführt werden, da wir wie in Abschnitt 4.2 besprochen den reichhaltigen Befehl vor allen Speicheroperationen der Passung und somit auch vor dem fremden Knoten m anordnen. Dadurch hätten wir die Reihenfolge, in der auf den Speicherbereich S zugegriffen wird verändert. Da es sich zusätzlich bei einem der Zugriffe um einen Schreibzugriff handelt, hätten wir die Semantik des Programms verändert. 4.3.2 Verwendung von Zwischenergebnissen Eine weitere Analyse ist notwendig, wenn in einer Passung gleichzeitig Load- und StoreOperationen enthalten sind. Die Store-Operation schreibt möglicherweise eine Speicherstelle, die zuvor von der Load-Operation geladen wurde. Eine wichtige Folge der in 4.2 beschriebenen Befehlsanordnung ist aber, dass ein reichhaltiger Befehl eine Speicherstelle, die er liest, nur dann beschreiben darf, wenn der Ladebefehl nicht ein Endergebnis der Passung ist und keine weiteren Verwender außerhalb der Passung besitzt. Ein Beispiel dazu ist in in Abbildung 4.3 dargestellt. Links ist grau hinterlegt die Passung eines reichhaltigen Befehls namens LoadStore vor der Ersetzung zu sehen. Der Befehl lädt einen Wert aus dem Speicher, ändert ihn durch eine beliebige Operation und speichert den Wert an dieselbe Speicherstelle zurück. Die Speicherkanten sind fett eingezeichnet. Der Knoten Load hat einen Verwender außerhalb der Passung. Deshalb muss der Load-Knoten auch nach der Regelanwendung stehen bleiben. Diese Situation ist auf der rechten Seite der Abbildung zu sehen. Der reichhaltige Befehl LoadStore wurde nach unserer Befehlsanordnungsstrategie vor dem Load-Knoten eingereiht. Da LoadStore nun implizit die Store-Operation ausführt, die im Ursprungsprogramm nach 4.3 Analyse einer Passung 35 Proj P Proj P Regel− anwendung LoadStore Load 1 Proj M Proj Is Proj M Load 1 Verwendung ... Store Proj Is Verwendung Abbildung 4.3: Nach der Ersetzung liest ein übrig gebliebenes Load einen fehlerhaften Wert. Grau markierte Knoten gehören zum Such- bzw. Ersetzungsmuster. der Load-Operation ausgeführt wird, haben wir die Semantik des Programms verändert: Die stehen gebliebene Load-Operation liest fälschlicherweise den bereits veränderten Speicher. Kritisch sind also Passungen mit lesenden und schreibenden Speicheroperationen. Für diese Passungen prüfen wir, ob eine Load-Operation, die nicht das Endergebnis des reichhaltigen Befehls liefert, weitere Verwender hat und schließen solche Passungen von der Ersetzung aus. Diese Prüfung könnte innerhalb der Graphersetzungsregel durch NACs3 geschehen. In unserer Implementierung ist die Prüfung momentan ausprogrammiert. 4.3.3 Speicherabhängigkeit von Operanden Wenn die Operanden eines reichhaltigen Befehls durch die Speicherkante voneinander abhängen, kann dies möglicherweise zu Verklemmungen führen. Ein Beispiel dazu ist in Abbildung 4.4 dargestellt. Abbildung 4.4 (a) zeigt das Ausgangsprogramm, welches zwei Vektoren a und b aus dem Speicher lädt und komponentenweise multipliziert. Der einfacheren Darstellung wegen wurden Proj -Knoten weggelassen; von Adressberechnungen wurde durch Angabe der Vektorkomponenten im Namen der Load-Knoten abstrahiert: Load 1a lädt die erste Komponente des Vektors a. Die Speicherabhängigkeit ist durch gestrichelte Kanten dargestellt. Außerdem seien Graphersetzungsregeln für zwei Befehle in unserer Daten3 Negativ Application Condition, deutsch: Negative Anwendungsbedingung 36 4 Ersetzungsschritt Load 1a Load 1a VectorMul Load 1b Load 1b Load 2a VectorLoad Load 2b Mul Mul (a) VProj 0 Mul VectorLoad VProj 1 Mul (b) (c) Abbildung 4.4: Es entsteht eine Verklemmung, da Speicherabhängigkeiten von Operanden existieren. bank: Für einen VectorLoad-Befehl, der einen Vektor aus dem Speicher lädt, und für einen VectorMul-Befehl, der zwei Vektoren komponentenweise multipliziert. VectorMul kann einen seiner beiden Operanden direkt aus dem Speicher laden, der zweite muss in Registern vorliegen und explizit geladen werden. Dies geschieht, indem wir das grau hinterlegte Muster durch einen VectorLoad-Befehl ersetzen. In Abbildung 4.4(b) wurde der VectorLoad-Befehl bereits eingesetzt und in die Speicherabhängigkeitskette eingereiht. Nun kann die grau hinterlegte Passung des VectorMul-Befehls ersetzt werden. Abbildung 4.4(c) stellt das Ergebnis dieser Ersetzung dar. Nach unserer Strategie wurde der VectorMul-Befehl vor dem VectorLoad-Befehl eingereiht. Dieses Vorgehen erzeugt ein unzulässiges Programm, da der VectorMul-Befehl die Ergebnisse des VectorLoad-Befehls benötigt, diese aber noch gar nicht berechnet sind. Diese Ersetzung darf also nicht angewendet und muss zurückgewiesen werden. Der Grund für diese Verklemmung ist in Abbildung 4.4(b) zu sehen: Die beiden Operanden VProj 0 und VProj 1 hängen über den Knoten VectorLoad und die Speicherkante von der zur VectorMul-Operation gehörenden Speicheroperation Load1a ab. In solchen Fällen wird der reichhaltige Befehl immer oberhalb seines Operanden angeordnet, was zur Verklemmung führt. Wir benötigen also eine Analyse, die prüft, ob die Operanden des reichhaltigen Befehls direkt oder indirekt über die Speicherkante voneinander abhängen. Dazu starten wir von jedem Operanden aus eine Suche im Arbeitsgraphen. Trifft die Suche auf eine Speicheroperation, suchen wir die von dort ausgehende Speicherabhängigkeitskette nach oben hin ab. Treffen wir dabei auf einen beliebigen zur Passung gehörigen Knoten, wird die Passung zurückgewiesen, da eine Speicherabhängigkeit von Operanden festgestellt 4.3 Analyse einer Passung 37 wurde. Die Suche kann abgebrochen werden, wenn ein Grundblock erreicht wird, der höher als alle von der Passung überdeckten Blöcke in der Dominanzrelation steht. An dieser Stelle sei noch anzumerken, dass die Verklemmung in Abbildung 4.4 zu verhindern gewesen wäre, indem Load 1a und Load 2a statt Load 1b und Load 2b als Passung für den VectorLoad-Befehl gewählt worden wäre. In Kapitel 6 beschreiben wir Verfahren, wie die bessere der beiden Musterkombinationen ausgewählt werden kann. 4.3.4 Grundblock von Operanden Wir müssen sicherstellen, dass die Operanden des reichhaltigen Befehls zur Verfügung stehen, wenn er ausgeführt wird. Dies ist vor allem dann wichtig, wenn sich die Passung des Befehls über mehrere Grundblöcke erstreckt. Die Operanden müssen sich im selben Block oder in einem Dominatorblock des Blocks befinden, in den der reichhaltige Befehl eingeordnet werden soll. Da wir die Knoten, die als Operanden des reichhaltigen Befehls dienen, bereits zum Zeitpunkt der Passung kennen und den Block des reichhaltigen Befehls schon bestimmt haben, können wir diese Bedingung für jeden Operanden einzeln nachprüfen. 4.3.5 Ausrichtung von Daten Manche reichhaltige Befehle können nur auf Daten im Speicher zugreifen, wenn diese speziell ausgerichtet sind. Beispielsweise kann der movdqa-Befehl aus dem SSE-Befehlssatz nur Daten laden, wenn deren Basisadresse durch 16 teilbar ist. Ist dies nicht der Fall, erzeugt der Befehl eine Ausnahme4 . Bevor wir einen solchen Befehl einsetzen, muss garantiert sein, dass die Ausrichtung5 der Daten im Speicher stimmt [7]. Dazu ist zum Einen eine Erweiterung der Spezifikationssprache notwendig, so dass der Benutzer die Art der Ausrichtung angeben kann. Zum Anderen muss der Übersetzer bzw. die Programmiersprache die Möglichkeit bieten, Daten auszurichten. Einige Lösungsansätze dazu stammen von Intel. Intel schlägt zur Ausrichtung von Daten die Anwendung spezieller Intrinsics vor [7]. Der Intel eigene C-Übersetzer ICC kennt das Intrinsic __declspec(align(16)) float buffer[400]; um die Reihung6 buffer an 16-Byte-Grenzen auszurichten. Ist buffer eine lokale Variable, richtet der Übersetzer gleichzeitig die Prozedurschachtel mit aus. In C kann ein Programmierer Daten auch manuell ausrichten, indem er mehr Speicher als benötigt reserviert und die erste, mit der benötigten Ausrichtung verträglichen Adresse innerhalb des reservierten Speicherbereichs zur Platzierung seiner Daten heranzieht: 4 engl. Exception engl. Alignment 6 engl. Array 5 38 4 Ersetzungsschritt float *p_buffer; float buffer[400 + 15]; p_buffer = (float*)(((unsigned) buffer + 15) & ~0xf); Intrinsics haben den Vorteil, dass dem Übersetzer explizit bekannt gemacht wird, dass Daten ausgerichtet sind. Werden die Daten manuell ausgerichtet, muss diese Information zuerst durch eine geeigneten Analyse gewonnen werden. Unsere Motivation für eine übersetzerinterne Optimierung war allerdings, dass durch sie keine Intrinsics verwendet werden müssen und so einfach der Übersetzer oder die Architektur gewechselt werden kann. In diesem Fall könnten diese Intrinsics, zumindest in der Programmiersprache C, durch Präprozessor-Makros automatisch aus dem Programm entfernt werden, wenn das Programm von einem anderen Übersetzer übersetzt wird. Da in diesem Fall keine reichhaltigen sondern Einzelbefehle ausgeführt werden, müssen die Daten möglicherweise nicht ausgerichtet sein. Obwohl Reihungen korrekt ausgerichtet sind, können Daten von nicht erlaubten Adressen geladen werden, da wir zunächst beliebige Offsets zu Basisadressen von Reihungen erlauben, beispielsweise durch einen Schleifenzähler i: a = buffer[i]; Wir müssen also zusätzlich garantieren, dass sich der Schleifenzähler so verhält, dass nur von erlaubten Basisadressen geladen wird. Dies wurde z.B. von Pryanishnikov, Krall und Horspool [24], [25] untersucht. Eventuell müssen Techniken wie z.B. Schleifenschälen [20] angewandt werden. Es kann sich möglicherweise auch lohnen, vor jedem Speicherzugriff zur Laufzeit zu prüfen, ob die Daten korrekt ausgerichtet sind und dann dynamisch zwischen reichhaltigen Befehlen und Einzelbefehlen umzuschalten. Dies ist jedoch nicht Aufgabe dieser Arbeit. Unsere Implementierung enthält diese Techniken noch nicht, stattdessen profitieren wir von Befehlen wie z.B. lddqu des SSE3-Befehlssatzes, um auch nicht-ausgerichtete Daten schnell zu laden. Gibt es solche Befehle nicht, müssen die bisherigen Überlegungen in Zukunft in die Implementierung mit einbezogen werden. 4.4 Entfernen überflüssiger Knoten Durch die Regelanwendung fallen die inneren Knoten einer Passung nicht weg, da sie im Ersetzungsgraph ebenfalls vorhanden sind. Haben diese Knoten aber keine Verwender außerhalb der Passung, sollen sie entfernt werden, da ihre Ergebnisse nicht mehr gebraucht werden. Dazu verwenden wir drei aus dem Übersetzerbau bekannte [2] und bereits in unseren Übersetzer eingebaute Optimierungen, die erst dann ausgeführt werden, nachdem alle möglichen Graphersetzungsregeln ausgeführt und die entsprechenden reichhaltigen Befehle eingesetzt worden sind. 4.4 Entfernen überflüssiger Knoten 39 4.4.1 Entfernen toten Codes Durch das Retypisieren der Ergebnisknoten des reichhaltigen Befehls zu VProj -Knoten verlieren die ehemaligen Operanden der Ergebnisknoten ihre Verwender. Da in Firm Datenabhängigkeiten statt Datenfluss dargestellt wird, sind diese verwaisten“ Vorgänger” knoten automatisch nicht mehr sichtbar und werden so bei der Codeauswahl nicht mehr berücksichtigt. 4.4.2 Load-Store-Optimierung Für Load-Knoten funktioniert dieses Verfahren nicht mehr, da diese mehrere Ergebniskanten haben. Fällt durch das oben beschriebene Verfahren der Verwender des Datenergebnisses weg, ist der Load-Knoten immer noch durch die Speicherkante sichtbar. Die Load-Store-Optimierung erkennt Load-Knoten ohne Verwender und entfernt diese. Store-Knoten wurden bereits während der Regelanwendung entfernt, da sie kein Datenergebnis produzieren, das außerhalb der Passung verwendet werden könnte. 4.4.3 Kontrollflussoptimierung Enthält ein Grundblock durch die obigen Optimierungen keine Knoten mehr, entfernt die Kontrollflussoptimierung diese leeren Blöcke. Das ist häufig der Fall, wenn sich ein Muster über mehrere Grundblöcke erstreckt. In diesem Fall kann die Kontrollflussoptimierung auch nicht mehr benötigte Verzweigungsoperationen entfernen. 40 4 Ersetzungsschritt 5 Normalisierung und Varianten In diesem Kapitel erläutern wir, welche Techniken zum Einsatz kommen, um syntaktische Abweichungen bei semantischer Gleichheit von Muster- und Arbeitsgraph möglichst gering zu halten. Da es im Allgemeinen unentscheidbar ist, ob zwei syntaktisch verschiedenen Programm semantisch gleich sind, sind wir nicht in der Lage, jeden Teilgraph zu finden, der die Semantik des Mustergraphen besitzt. Wir können jedoch die Trefferwahrscheinlichkeit bei der Mustersuche erhöhen, indem wir durch normalisierende Transformationen Muster- und Arbeitsgraph einander annähern und Spezialfälle abdecken, indem wir ausgehend von einem Mustergraphen automatisch mehrere Varianten generieren. Zusätzlich wenden wir aus dem Übersetzerbau bekannte Optimierungen an, die wir im folgenden Abschnitt beschreiben. 5.1 Vortransformationen Unsere Optimierung wird in einen Übersetzer integriert, der bereits viele andere Optimierungen durchführt, die unsere eigene Optimierung positiv oder negativ beeinflussen können. Einige bereits vorhandene Optimierungen haben eine normalisierende Auswirkung auf Muster- und Arbeitsgraph, andere bewirken genau das Gegenteil. Das liegt daran, dass dabei der Mustergraph nur musterlokal optimiert wird, der Arbeitsgraph aber musterübergreifend. Wir müssen also genau überlegen, wann unsere Optimierung zwischengeschaltet wird. In der Praxis hat sich gezeigt, dass die folgenden aus dem Gebiet des Übersetzerbaus bekannte Graphtransformationen auf den Zwischensprachen- und Mustergraphen angewendet, Muster- und Arbeitsgraph einander annähern: Lowering. Unsere Optimierung wird ausgeführt, nachdem das sogenannte Lowering des Firm-Graphen durchgeführt wurde. Das bedeutet, dass abstrakte Konstrukte für Zugriffe auf Arrayelemente durch Zeigerarithmetik ersetzt wurden. Auf diese Weise können wir direkte Arrayzugriffe und Zeigerarithmetik im Quellprogramm gleichermaßen handhaben. Kritische Kanten entfernen. Das Entfernen kritischer Kanten entbindet uns von einer Normalisierung in speziellen Fällen von verzweigtem Steuerfluss. Schleifen ausrollen. Es wäre möglich, dass Vektorbefehle deshalb nicht gefunden werden, weil sich der Zugriff auf Vektorkomponenten hinter verschiedenen Schleifeni- 41 42 5 Normalisierung und Varianten terationen verbirgt. Das Ausrollen von Schleifen, kann solche verborgenen Befehle aufdecken. Die Anzahl der ausgerollten Iterationen kann beispielsweise von den in der Schleife verwendeten Datentypen und der maximalen Breite der Vektorregister abhängig gemacht werden. Load-Store-Optimierung. Eine Optimierung, die die Informationen einer Alias-Analyse dazu verwendet, um unnötige Load- oder Store-Knoten zu entfernen. Momentan wird die Load-Store-Optimierung erweitert, um Speicherbefehle zu parallelisieren, wenn keine Abhängigkeit zwischen ihnen besteht. Eine gute Alias-Analyse ist wichtig für die Funktionsfähigkeit unserer Optimierung. Wir verwenden einen sogenannten, in unseren Übersetzer eingebauten Memory Disambiguator [14]. If-Konversion. Durch die If-Konversion [22] fallen viele Grundblöcke weg, was die Größe von Mustergraphen verringert. Allerdings kann die If-Konversion den Registerdruck erhöhen, was negative Auswirkungen auf die Laufzeit hat. Da die aktuellen Implementierung der If-Konversion nicht erlaubt, die Darstellung wieder abzubauen, vermeiden wir sie in unserer Implementierung Code Placement. Ein für uns kritische Optimierung, die in vielen Fällen eine normalisierende Wirkung hat, da sie mehrere Konvertierungsknoten (Knotentyp Conv ) zu einem Knoten vereinigen kann. Allerdings kann sie Operationen auch verschieben, was eine erfolgreiche Mustersuche schwieriger macht. Folgende Optimierungen hatten negative Auswirkung auf unsere eigene Optimierung: Strength reduction. Die Adressberechnung erfolgt nach Anwendung der Strength Reduction iterativ, die Mustersuche kann dadurch die einzelnen Vektorkomponenten nicht mehr unterscheiden. 5.2 Normalisierung Zusätzlich zu den Standardoptimierungen verwenden wir spezielle normalisierende Transformationen. Hofmann beschreibt eine Normalisierung arithmetischer Ausdrücke [15, Abschnitt 4.6]. Wir greifen diese Idee auf und wenden sie auf die Ausdrücke zur Adressberechnung verschiedener Adressierungsarten an. Diese und weitere Techniken beschreiben wir in den folgenden Abschnitten. 5.2.1 Vergleiche Vorausgegangene Optimierungen haben möglicherweise die Operanden von Vergleichsoperationen vertauscht. Da wir aber eine möglichst große Ähnlichkeit von Muster- und Arbeitsgraph herstellen möchten, wandeln wir alle Vergleichsoperationen Ist-Größer bzw. Ist-Größer-Gleich in Ist-Kleiner bzw. Ist-Kleiner-Gleich um. 5.2 Normalisierung (a) Basisadresse + Const 43 (b) Basisadresse + Vektor−Basisadresse + Const (c) Basisadresse + Arraybreite * Zeile + Vektor−Spalte + Const Abbildung 5.1: Mögliche Positionen eines Vektors (grau markiert) im Speicher. 5.2.2 Adressierungsarten Wir fordern, dass ein Zugriff auf Reihungselemente mit konstantem Offset in der BefehlsSpezifikation – z.B. a[0], a[1] – auf verschiedene direkte und indirekte Zugriffe im zu optimierenden Programm passt – z.B. a[i+1], a[i][j+1], a[b[i]+j+1]. Der Grund ist, dass wir zum Zeitpunkt der Spezifikation die Lage von Vektoren im Speicher nicht kennen können und deshalb davon abstrahieren. Oft sind Vektoren in mehrdimensionale Reihungen eingebettet oder ihre Basisadresse hängt von verschiedenen Schleifenzählern ab, was z.B. ein Ergebnis des Schleifenausrollens sein kann. Abbildung 5.1 zeigt mögliche Positionen eines 4-dimensionalen Vektors innerhalb einer Reihung a und die entsprechende Berechnung der Speicheradresse einer Vektorkomponente (unter der Annahme, dass Reihungen zeilenweise im Speicher abgelegt werden). Wir diskutieren diese drei Fälle im Folgenden genauer: 1. Fall (a) zeigt eine eindimensionale Reihung, die Basisadresse des Vektors entspricht der Basisadresse der Reihung. Dies entspricht der Situation zum Zeitpunkt der Spezifikation. Ein lesender Zugriff auf die ersten beiden Vektorkomponenten sähe folgendermaßen aus: LOAD(Basis + 0), LOAD(Basis + 1) (5.1) 2. Befindet sich der Vektor nicht am Beginn der eindimensionalen Reihung, wie Fall (b) zeigt, sondern an einer zur Übersetzungszeit noch nicht bestimmbaren Stelle a[j], so lautet der Zugriff auf die ersten beiden Komponenten: LOAD(Basis + j + 0), LOAD(Basis + j + 1) (5.2) Bei j könnte es sich beispielsweise um einen Schleifenzähler handeln. Der Unterschied zu (a) ist, dass j als zusätzlicher Summand in beiden Ausdrücken zur Adressberechnungen auftaucht. 3. Greift das zu optimierende Programm auf einen in ein mehrdimensionales Array eingebetteten Vektor zu, was Fall (c) zeigt, kommt noch einen zusätzlicher Summand hinzu. Angenommen der Vektor beginne an Position a[i][j], so lautet der 44 5 Normalisierung und Varianten Zugriff auf die ersten beiden Komponenten: LOAD(Basis + i ∗ Breite + j + 0), LOAD(Basis + i ∗ Breite + j + 1) (5.3) 4. Ein in Abbildung 5.1 nicht dargestellter Fall ist der indirekte Zugriff über eine Verweistabelle. Jedes Reihungselement einer eindimensionalen Reihung verweist wieder auf eine Reihung, womit sich mehrdimensionale Reihungen modellieren lassen. In C ist dies beispielsweise eine Möglichkeit, um Reihungen dynamischer Größe an eine Funktion zu übergeben. Der Zugriff auf eine solches Reihung erfolgt nicht durch die oben genannten Formeln sondern durch kaskadierte Load Befehle. Die erste Vektorkomponente ließe sich folgendermaßen laden: LOAD(LOAD(Basis + i) + j + 1) (5.4) Da das innere Load nur die Basisadresse einer zweiten eindimensionalen Reihung lädt, lässt sich dies auf Fall (b) herunterbrechen. Es ist wünschenswert, all diese Zugriffsarten mit nur einer Spezifikation abzudecken. Dazu transformieren wir sowohl den Muster- als auch den Arbeitsgraphen. Normalisierung Wir erkennen zum Einen, dass sich die Ausdrücke zur Adressberechnung aller beschriebenen Adressierungsarten aus einer Summe unterschiedlich vieler Summanden zusammensetzen. Der Zugriff auf verschiedene Vektorkomponenten unterscheidet sich nur durch eine Konstante, alle anderen Summanden des Ausdrucks werden zur Berechnung der Basisadresse verwendet. Um den Zugriff auf Vektorkomponenten zu erkennen, müssen wir also überprüfen, ob alle Summanden der Adressberechnungsausdrücke bis auf die Konstante übereinstimmen. Im Firm-Graphen bestehen Ausdrücke zur Adressberechnung aus einer Kaskade von Add-Knoten. Da die Addition kommutativ und assoziativ ist, können die Knoten der Add-Kaskade von einer vorausgegangenen Optimierung (z.B. Code-Placement) beliebig vertauscht worden sein, wenn die Programmiersprache dies erlaubt. Für die Graphersetzung wünschen wir uns aber eine einheitliche Darstellung. Deshalb falten wir die Add-Kaskaden zu einem MultiAdd-Knoten zusammen. Ein MultiAdd-Knoten stellt eine (nicht hardwarenahe) Additionsoperation dar, die mehrere Summanden auf einmal verarbeiten kann. Durch die vorausgegangene CSE (Common Subexpression Elimination1 ) ist sichergestellt, dass die Kanten verschiedener MultiAddKnoten auf denselben Knoten zeigen, falls diese denselben Wert transportieren. Um Zugriffe auf verschiedene Vektorkomponenten zu erkennen, ist die Bedingung an MultiAddKnoten, dass alle Kanten bis auf eine auf dieselbe Knotenmenge zeigen. Die verbleibende Kante muss auf eine Konstante zeigen. 1 deutsch: Eliminierung gemeinsamer Teilausdrücke 5.3 Variantenbildung 45 Auf- und Abbau der Normalisierung Für alle Speicheroperationen s des Programms können wir überprüfen, ob die Adressberechnung einen oder mehrere Add-Knoten enthält. Die Vorgänger der gefundenen AddKnoten sammeln wir in einer Menge M . Dann erzeugen wir einen neuen MultiAddKnoten, der die Knoten aus M als Vorgänger erhält und die neue Adresse der Speicheroperation s bildet. Wichtig ist, die abgekoppelte Add-Kaskade nicht zu zerstören, sondern für den Abbau der Normalisierung an geeigneter Stelle zu sichern. Speicheroperationen, die direkt auf eine Basisadresse einer Reihung zugreifen, haben initial keinen arithmetischen Ausdruck zur Adressberechnung. In diesem Fall fügen wir künstlich den Knoten MultiAdd(Basisadresse, 0) als Adresse ein. Dies hat den Vorteil, dass wir auch Zugriffe auf Vektoren finden können, die an einem zur Übersetzungszeit bekannten, konstanten Offset ungleich 0 zu einer Basisadresse beginnen. Dies können wir erreichen, indem wir in Bedingungen der Graphersetzungsregel nicht den absoluten Wert der Konstanten prüfen, sondern das Größenverhältnis der Konstanten untereinander. Um die Normalisierung abzubauen, wird der MultiAdd-Knoten an allen Speicheroperationen, die nach der Optimierung noch übrig geblieben sind, wieder durch die ursprüngliche Adresse ausgetauscht. Dies hat den Vorteil, dass vorausgegangen Optimierungen, die die Adressrechnung bereits optimiert haben, nicht noch einmal laufen müssen. 5.3 Variantenbildung Die Einführung von MultiAdd-Knoten allein reicht noch nicht aus, um Passungen mit verschiedenen Adressierungsarten mit nur einer Befehls-Spezifikation zu finden. Der Unterschied zwischen den entsprechenden Ausdrücken zur Adressberechnung liegt in der unterschiedlichen Anzahl der Summanden, d.h. der unterschiedlichen Anzahl der Vorgänger der MultiAdd-Knoten. Daher generieren wir für jede Adressierungsart eine Variante. Die Varianten unterscheiden sich durch die unterschiedliche Anzahl der Vorgänger ihrer MultiAdd-Knoten, wie Abbildung 5.2 zeigt. Fall (a) stellt die Variante 0 dar, wie sie nach der Normalisierung des Mustergraphen entsteht. Fall (b) stellt Variante 1 dar, im Vergleich zu Variante 0 wurden die MultiAdd-Knoten den Vorgänger i erweitert. Im Allgemeinen enthalten pro neuer Variante alle MultiAdd-Knoten, die Adresse innerhalb eines Vektors darstellen, einen neuen gemeinsamen Vorgänger. Greift ein Befehl also 2 Vektoren zu, kommen pro Variante insgesamt 2 neue Knoten hinzu. Die Anzahl der generierten Varianten kann durch eine zuvor definierte Höchstgrenze festgelegt werden. Bei der Mustersuche ist es wichtig, die Anzahl der Vorgänger eines MultiAdd-Knoten im Muster mit der Anzahl der Vorgänger seiner Passung im Arbeitsgraphen abzugleichen. Ansonsten könnten im Arbeitsgraphen weitere Vorgänger vorhanden sein, was einer 46 5 Normalisierung und Varianten 0x0 Base 0x4 MultiAdd MultiAdd Load Load (a) Variante 0 0x0 i 0x4 Base MultiAdd MultiAdd Load Load b) Variante 1 Abbildung 5.2: Automatisch generierte Varianten des Mustergraphen eines Vektor-LadeBefehls. anderen Adressierungsart und somit nicht der Semantik des Mustergraphen entspricht. 6 Algorithmische Steuerung der Regelanwendung Bisher haben wir nur die Anwendung einer Graphersetzungsregel diskutiert. Enthält unsere Regeldatenbank mehrere Regeln, gilt es zu entscheiden, welche Regeln in welcher Reihenfolge anzuwenden sind und wann es sinnvoll ist, den Ersetzungsschritt durchzuführen. Beispielsweise kann es vorkommen, dass sich zwei oder mehrere Passungen überlappen. Dann ist nicht sofort klar, welche Passung zur Ersetzung freigegeben werden soll. In diesem Kapitel präsentieren wir zwei verschiedene Möglichkeiten, um die Regelanwendung zu steuern und die kostengünstigste Kombination von Passungen zur Ersetzung auszuwählen. 6.1 Überlappung von Passungen Wir sprechen von einer Überlappung zweier Passungen, wenn mindestens ein innerer Knoten einer Passung ebenfalls von der anderen Passung überdeckt wird. Zum Einen können sich mehrere Passungen eines Mustergraphen überlappen. Um das herauszufinden, weisen wir das GES bei der Anwendung einer Regel an, gleichzeitig alle im Graph vorhandenen Passungen eines Mustergraphen zu finden und auszugeben. Wir dürfen jetzt den Ersetzungsschritt allerdings noch nicht durchführen, da sich zum Anderen auch Passungen verschiedener Mustergraphen überlappen können. Deshalb wenden wir vor dem Ersetzen zuerst noch weitere Regeln an, um weitere Passungen zu finden. Wir können dann für alle gefundenen Passungen durch Vergleich der Knotenmengen herausfinden, ob eine Überlappung vorliegt. Wenn sich zwei beliebige Passungen p1 und p2 überlappen, ist nicht sofort klar, welche der beiden Passungen ersetzt werden soll. Jede Ersetzung könnte das Auffinden weiterer Passungen – wir nennen diese Folgepassungen – nach sich ziehen, die aber zu diesem Zeitpunkt noch nicht bekannt sind (siehe Abschnitt 4.1). Wir gehen zunächst davon aus, dass nur eine der beiden Passungen ersetzt werden darf1 , weil das Ersetzen der Passung p1 durch Umtypisieren Knoten verändert, welche möglicherweise gleichzeitig p2 angehören. Das würde zu unvorhergesehenen Ergebnissen beim Ersetzen von p2 führen. Um die Problematik klarer zu machen, geben wir ein konkretes Beispiel an: Wir gehen davon aus, dass unsere Regeldatenbank genau zwei Regeln für die Befehle VectorLoad 1 In Abschnitt 6.3 heben wir diese Einschränkung auf. 47 48 6 Algorithmische Steuerung der Regelanwendung VectorLoad 1 VectorLoad 2 VectorSub 1 Abbildung 6.1: Überlappung von Passungen der Befehle VectorMul und VectorLoad. und VectorSub enthält. Der VectorLoad-Befehl lädt einen Vektor aus dem Speicher in ein Spezialregister. Der VectorSub-Befehl subtrahiert zwei Vektoren komponentenweise und kann einen der beiden Vektoren direkt aus dem Speicher laden. Den zweiten Vektor erwartet er in einem Spezialregister. Abbildung 6.1 stellt abstrakt einen Arbeitsgraphen dar, in dem drei Passungen der beiden Befehle zu finden sind. VectorLoad hat die beiden Passungen VectorLoad 1 und VectorLoad 2, VectorSub hat die Passung VectorSub 1, welche sich mit VectorLoad 2 überlappt. Wir erkennen, dass es für dieses Beispiel sinnvoll ist, VectorLoad 1 und VectorSub 1 zu ersetzen, und die Passung VectorLoad 2 von der Ersetzung auszuschließen, da ihre Funktionalität schon durch den VectorSub-Befehl abgedeckt ist. An diesem Beispiel werden zwei Probleme deutlich: Erstens kann die Passung VectorSub 1 erst dann gefunden werden, wenn VectorLoad 1 schon ersetzt wurde, da VectorSub 1 dessen Ergebnisse verwendet. Wir müssen also zuerst nach VectorLoadBefehlen suchen. Wird eine VectorLoad-Passung gefunden, können wir noch nicht wissen, ob sie zu ersetzen ist, da wir nach einer Passung für VectorSub noch gar nicht gesucht haben. Wir müssten also mit dem Ersetzen warten, bis auch die Passung VectorSub 1 gefunden wurde. Allerdings kann wie bereits beschrieben, VectorSub 1 nicht ohne einen bereits eingefügten VectorLoad-Befehl gefunden werden. Zur Lösung dieses Problems geben wir in den Abschnitten 6.2 und 6.3 zwei unterschiedliche Verfahren an. Zweitens ist, um die günstigste“ Kombination von Ersetzungen zu finden, ein Kos” tenmodell erforderlich. Dazu müssen einerseits die Kosten der Einzelbefehle bekannt sein, und andererseits die Kosten eines reichhaltigen Befehls, woraus statisch die Kostenersparnis des reichhaltigen Befehls berechnet werden kann. Kennen wir die Kostenersparnis jedes reichhaltigen Befehls, ist es möglich, die Kombination von Ersetzungen auszuwählen, die nach dem Modell die größte Kostenersparnis ergibt. 6.2 Suchbaum Unser Ziel ist es also, für jede gefundene Passung kostengesteuert zu entscheiden, ob die Passung ersetzt werden soll oder nicht. Um dies zu erreichen, könnten wir eine gefun- 6.2 Suchbaum 49 dene Passung p zunächst nur testweise ersetzen und prüfen, welche Folgepassungen in diesem Fall gefunden werden. Zugleich merken wir uns die Gesamtkostenersparnis K1 , die durch das Ersetzen der Folgepassungen entsteht. Dann wäre ein Rückrollmechanismus von Nöten, um den Graph in den Zustand zurückzuversetzen, in dem er sich vor der Ersetzung von p befand. Nun könnten wir testweise p nicht ersetzen, um zu prüfen, welche Folgepassungen in diesem Fall gefunden werden und wiederum deren Gesamtkostenersparnis K2 zu berechnen. Dann wäre ein weiteres Rückrollen erforderlich. Wir könnten p dann persistent ersetzten, falls K1 > K2 oder die Ersetzung von p zurückweisen, falls K1 < K2 . Da das Rückrollen von Graphersetzungen aufwändig ist und bei diesem Verfahren pro gefundener Passung zwei Mal durchgeführt werden muss, verfolgen wir eine etwas abweichende Strategie: Bei genauerem Hinsehen erkennen wir, dass die VProj -Knoten dafür verantwortlich sind, dass Folgepassungen erst nach dem Ersetzen einer Passung gefunden werden (siehe Abschnitt 4.1). Um Folgepassungen von p zu finden, müssen wir p nicht ersetzen, sondern können einfach die VProj -Knoten künstlich in den Graph einfügen, die bei der Ersetzung von p entstehen würden. Dadurch wird der Graph nur um diese künstlichen Knoten angereichert, alle anderen Knoten bleiben zunächst unverändert erhalten. Der Rückrollmechanismus beschränkt sich darauf, die künstlich eingefügten VProj -Knoten nach dem Suchen wieder zu löschen, was mit mit geringem Aufwand durchgeführt werden kann. Dieses Vorgehen ändert zwar nicht die Komplexitätsklasse des Algorithmus, lässt sich jedoch deutlich performanter implementieren. Unsere Strategie sieht also folgendermaßen aus: Wir trennen die Mustersuche komplett von der Musterersetzung ab. Für eine gefundene Passung p prüfen wir einerseits, welche Folgepassungen gefunden werden, wenn p nicht ersetzt wird. Danach prüfen wir, welche Folgepassungen gefunden werden, wenn p ersetzt wird, indem wir künstlich VProj -Knoten einfügen. Nachdem alle Folgepassungen gefunden wurden, entfernen wir die VProj -Knoten wieder, um den Arbeitsgraph in den Ursprungszustand zu versetzen. Keine der gefundenen Passungen wird ersetzt, wir merken uns stattdessen alle gefundenen (Folge-)Passungen und deren Kostenersparnisse in einer geeigneten Datenstruktur, die wir Suchbaum nennen. 6.2.1 Aufbauen des Suchbaums Die Struktur eines Suchbaums ist beispielhaft in in Abbildung 6.2 dargestellt, welche einem möglichen Suchbaum des Beispiels aus Abbildung 6.1 entspricht. Ein Suchbaum ist ein Binärbaum, dessen Knoten die gefundenen Passungen repräsentieren. Die Wurzel des Suchbaums stellt die erste gefundene Passung p1 dar. Ausgehend von einem Knoten p zeigt die Kante vom Typ 1 auf den Unterbaum, der die Folgepassungen enthält, die entstehen, wenn p ersetzt wird. Dementsprechend zeigt die Kante vom Typ 0 auf den Unterbaum, der die Folgepassungen enthält, die entstehen, wenn p nicht ersetzt wird. Außerdem ist an jedem Knoten die Kostenersparnis Ersparnis annotiert, die wir erhalten würden, wenn der entsprechende reichhaltige Befehl eingesetzt würde. Zusätzlich 50 6 Algorithmische Steuerung der Regelanwendung VectorLoad 2 Ersparnis: 5 Gesamtersparnis: 15 0 VectorLoad 1 Ersparnis: 5 Gesamtersparnis: 15 1 VectorSub 1 Ersparnis: 10 Gesamtersparnis: 10 1 0 0 1 VectorLoad 1 Ersparnis: 5 Gesamtersparnis: 5 0 1 1 0 Passung wird ersetzt Passung wird nicht ersetzt Keine Passung gefunden Abbildung 6.2: Beispiel eines Suchbaumes berechnen wir für jeden Knoten die Gesamtersparnis, die sich aus der Gesamtersparnis der beiden Unterknoten Gesamtersparnis 0 und Gesamptersparnis 1 ergibt. Gesamtersparnis = MAX(Gesamtersparnis 0 , Gesamtersparnis 1 + Ersparnis) (6.1) Anhand dieser Informationen kann ist im Ersetzungsschritt an jedem Knoten des Suchbaums die nächste zu ersetzende Passung bekannt. Wir erklären aber zuerst, wie ein solcher Suchbaum aufgebaut werden kann. Der Aufbau erfolgt nach Algorithmus 2. Die rekursive Funktion SuchbaumAufbau bekommt die Menge R der Graphersetzungsregeln, die Menge I der zu ignorierenden Passungen und die Menge P der von zu ersetzenden Passungen überdeckten Knoten übergeben. Die Funktion ruft zunächst FindePassung auf, die eine Passung p zu einer Regel r aus R auswählt. p muss gültig sein nach den Kriterien aus Abschnitt 4.3. Außerdem prüfen wir, ob p nicht in I enthalten ist, d.h. ob p nicht schon gepasst wurde. Das kann vorkommen, denn wir suchen ja auch Folgepassungen für den Fall, dass p nicht ersetzt wird. In diesem Fall wird der Arbeitsgraph nicht verändert und bei der Suche nach Folgepassungen wird p möglicherweise erneut gefunden. Schließlich darf sich p nicht mit Passungen überlappen, die bereits zum Ersetzen ausgewählt worden sind. Dazu prüfen wir, ob sich von p überdeckte Knoten schon in P befinden. Sind all diese Kriterien erfüllt, liefert FindePassung p zurück. Falls FindePassung eine Passung p zurückliefert, baut die Funktion SuchbaumAufbau einen neuen Suchbaumknoten K auf. Zunächst wird p zur Menge I hinzugefügt, um zu verhindern, dass p in einem Unterbaum von K erneut gefunden wird. Der rekursive Aufruf von SuchbaumAufbau findet die Folgepassungen von p im Fall, dass p nicht ersetzt wird und baut den zugehörigen Unterbaum auf. Dann werden die künstlichen VProj -Knoten in den Graphen eingefügt, um ein Ersetzen von p zu simulieren. Dazu 6.2 Suchbaum 51 müssen zusätzlich die Eingangskanten der potentiellen Verwender der VProj -Knoten temporär auf die erzeugten VProj -Knoten umgebogen werden. Da wir nun annehmen, dass p ersetzt wird, müssen wir verhindern, dass sich Folgepassungen mit p überlappen. Deswegen tragen wir die inneren Knoten von p in die Menge P ein. Jetzt baut ein weiterer rekursiver Aufruf von SuchbaumAufbau den Unterbaum von K für den Fall, dass p ersetzt wird, auf. Sind beide Unterbäume aufgebaut, wird der Arbeitsgraph in den Zustand versetzt, den er bei Eintritt in die Funktion hatte. D.h. die VProj -Knoten werden wieder entfernt und die inzidenten Kanten auf ihre ursprünglichen Knoten zurückgebogen. Außerdem entfernen wir p aus I und aus P . Schließlich muss noch die Gesamtkostenersparnis an K, die nach Formel (6.1) berechnet wird, annotiert werden. Algorithmus 2 Aufbau des Suchbaums procedure SuchbaumAufbau(R, I, P) p := FindePassung(R, I, P) if Kein p gefunden then return end if Füge p in I ein K.notreplaced := SuchbaumAufbau(R) Füge VProj Knoten ein Füge p in P ein K.replaced := SuchbaumAufbau(R) Entferne p aus P Entferne p aus I Entferne VProj Knoten Berechne Kosten für K return K end procedure procedure FindePassung(R, I, P) Finde gültige Passung p für ein r aus R, p ∈ / I ∧p*P return p end procedure 6.2.2 Ersetzen der günstigsten Passungen Im Suchbaum befinden sich nun alle Passungen, die im Arbeitsgraphen gefunden werden können. Beginnend bei der Wurzel durchlaufen wir den günstigsten Pfad durch den Baum. An jedem Knoten können wir direkt entscheiden, welcher Unterknoten als nächstes besucht und ob die Passung p ersetzt werden soll, indem wir die Gesamtkosten Gesamtersparnis 0 und Gesamtersparnis1 der Unterknoten vergleichen. Der Fall Gesamtersparnis 1 < Gesamtersparnis 0 bedeutet, dass es günstiger ist p nicht zu ersetzen. Wir ignorieren in diesem Fall p und fahren mit dem Unterbaum 0 fort. Falls 52 6 Algorithmische Steuerung der Regelanwendung VectorLoad 1 VectorLoad 2 VectorSub 1 Prioritätsklasse 0 VectorAdd Prioritätsklasse 1 Abbildung 6.3: Überlappungsproblem mit mehreren Prioritätsklassen Gesamtersparnis1 > Gesamtersparnis 0 ist es günstiger, p zu ersetzen. Wir ersetzen in diesem Fall p wie in Kapitel 4 beschrieben und fahren mit dem Unterbaum 1 fort. An dieser Stelle sei noch angemerkt, dass bei diesem Verfahren eine weitere Feinheit zu beachten ist: Die im Suchbaum enthaltenen Passungen enthalten u.A. Verweise auf die künstlich eingefügten VProj -Knoten, die aber wieder entfernt wurden. Während des Suchbaum-Aufbaus merken wir uns deshalb zusätzlich für jeden VProj -Knoten, welche Ersetzung den VProj -Knoten erzeugen würden. Während des Ersetzungsschritts greifen wir auf diese Information zurück und ersetzen Verweise auf künstliche VProj -Knoten durch Verweise auf die persistenten VProj -Knoten. Auf diese Weise ersetzen wir nur die Passungen, die nach unserem Kostenmodell das günstigste Programm erzeugen. Alle anderen Passungen werden unersetzt wieder freigegeben. 6.2.3 Prioritätsklassen Wie schon diverse Backend-Implementierungen gezeigt haben [5], kann das Ausprobieren aller Möglichkeiten, wie es bei diesem Verfahren geschieht, sehr zeitaufwändig werden. Pro gefundener Passung kann sich der Suchbaum im schlechtesten Fall exponentiell vergrößern. Außerdem werden in unterschiedlichen Teilbäumen möglicherweise oft dieselben Passungen gefunden. Um Suchbäume klein zu halten, erstellen wir einen Suchbaum pro Prioritätsklasse. Dieser Ansatz ist dann günstig, wenn Mustergraphen höherer Prioritätsklasse nur dann gefunden werden können, wenn Mustergraphen niedriger Prioritätsklassen bereits gefunden und ersetzt wurden. Typischerweise ist das bei Befehlssätzen so, bei denen Ladeoperationen, arithmetische Operationen und Speicheroperationen strikt getrennt sind. Es gibt jedoch auch prioritätsklassenübergreifende Abhängigkeiten, die mit diesem Ansatz nicht erkannt werden können. 6.3 Musterselektion mittels PBQP 53 Ziehen wir dazu nocheinmal das Beispiel aus Anschnitt 6.1 heran. Zusätzlich sei der Befehl VectorAdd in der Regeldatenbank, der zwei Vektoren in Registern erwartet und diese komponentenweise addiert. Alle Befehle, die aus dem Speicher laden, seien der Prioritätsklasse 0 zugeteilt, Befehle, die rein auf Registern arbeiten, der Prioritätsklasse 1. Abbildung 6.3 zeigt das entstehende Problem: Die beiden Befehle VectorLoad und VectorSub wurden in Prioritätsklasse 0 eingeteilt, was dazu führt, dass VectorLoad 1 und VectorSub 1 ersetzt werden, VectorLoad 2 wird nicht ersetzt, gestrichelt gezeichnet. Erst jetzt arbeiten wir die Befehle in Prioritätsklasse 1 ab. Der Befehl VectorAdd kann nicht erkannt werden, da VectorLoad 2 zurückgewiesen wurde. Wir können durch die Einführung von Prioritätsklassen möglicherweise klassenübergreifende Zusammenhänge nicht erkennen. Unsere Tests haben jedoch gezeigt, dass trotz Verwendung von Prioritätsklassen in vielen Fällen gute Ergebnisse erzielt werden können. 6.3 Musterselektion mittels PBQP Um dem Problem von zu großen Suchbäumen und zu langen Laufzeiten der Musterauswahl Sorge zu tragen, machen wir in diesem Abschnitt einen Exkurs zu einem alternativen Verfahren der Musterauswahl. Unser Ziel ist es, die Musterauswahl auf ein PBQP2 abzubilden. Wir beschreiben in den folgenden Abschnitten das PBQP genauer und erörtern, welche Änderungen an unserem bisherigen Ansatz nötig sind. Schließlich zeigen wir am Beispiel, wie der neue Ansatz funktioniert. 6.3.1 PBQP Ausgangslage eines PBQPs ist ein ungerichteter Graph, an dessen Knoten unterschiedlich viele, kostenbehaftete Alternativen existieren. Ziel eines PBQP-Lösers ist es, an jedem Knoten des Graphen genau eine Alternative auszuwählen, so dass die Gesamtkosten minimiert werden. Zusätzlich zu den Kosten der einzelnen Alternativen, die als Kostenvektor an jedem Knoten annotiert sind, gibt es sogenannte Übergangskosten, die an den Kanten des Graphen stehen. Die Übergangskosten beschreiben, welche zusätzlichen Kosten entstehen, wenn an den beiden durch eine Kante verbundenen Knoten die Alternativen Ai und Aj ausgewählt werden. Alle Übergangskosten an einer Kante werden deshalb als Matrix Mij dargestellt. Einen Beispielgraph zeigt Abbildung 6.4. Die Kosten der Alternativen sind an jedem Knoten n als Vektoren cn , die Übergangskosten an jeder Kante zwischen den Knoten n und m als Matrix C (n,m) eingezeichnet. PBQP wurde erstmals von Eckstein und Scholz [10] zur Befehlsauswahl eingesetzt und von Jakschitsch [19] erweitert. Dazu wird die Knoten- und Kantenstruktur des SSAGraphen übernommen, um den PBQP-Graphen zu erhalten. Wir geben ein ähnliches Verfahren an, um das Problem der Auswahl verschiedener Passungen auf ein PBQP abzubilden. 2 Partitioned Boolean Quadratic Problem 54 6 Algorithmische Steuerung der Regelanwendung c 3 = (10, 20) C(1, 3) = ( 3 4 ) c1 = (15) 3 1 C(4, 3) = ( 3 4 ) 4 c4 = (40) ( ) C(2, 3) = 7 8 48 C(1, 2) = ( 2 3 ) 2 c2 = (15, 20) Abbildung 6.4: Ein PBQP als Graph aufgefasst. 6.3.2 Änderungen am bisherigen Ansatz Die Abbildung der Musterauswahl auf ein PBQP verlangt, dass alle möglichen Passungen im Arbeitsgraph gleichzeitig gefunden werden können, da wir im Vorraus bereits alle Alternativen kennen müssen. Das Finden einer Passung darf nicht von vorangegangen Ersetzungsschritten abhängig sein. Nach dem bisherigen Ansatz hindern uns daran die expliziten VProj -Knoten, die Passungen voneinander abhängig machen. Wie benötigen einen neuen Ansatz, durch den VProj -Knoten nicht mehr explizit im Graph dargestellt werden. VProj -Knoten in bisherigen Mustergraphen bedeuten, dass der reichhaltige Befehl Vektordaten als Eingabe erwartet. Wir schaffen nun die expliziten VProj -Knoten in Mustergraphen ab; alle VProj -Knoten werden durch allgemeine IR Knoten ersetzt. Dadurch werden möglicherweise zunächst auch Passungen gefunden, deren Operanden keine Vektorkomponenten repräsentieren. Dies kann man in einem zweiten Schritt überprüfen und eventuell Konvertierungen vornehmen oder die Passung verwerfen. Der große Vorteil dieses Verfahrens ist, dass die erfolgreiche Mustersuche nicht mehr davon abhängt, dass bereits VProj Knoten in den Graph eingefügt worden sind. Alle Muster können unabhängig voneinander und ohne eine Ersetzung durchgeführt zu haben, gefunden werden. Die Prioritätsklassen fallen weg. Mittels PBQP soll nun die kostengünstigste Kombination von Ersetzungen herausgefunden werden. Dies ist insbesondere dann interessant, wenn sich Muster überlappen. Bisher hatten wir nur die Möglichkeit, genau ein Muster aus der Menge der sich überlappenden Muster auszuwählen. Da nun VProj -Knoten wegfallen und deshalb auch kein Umtypisieren von Knoten mehr stattfindet, ist es möglicherweise auch geschickt, mehrere der sich überlappenden Passungen gleichzeitig zur Ersetzung freizugeben. Das führt zwar dazu, dass gemeinsame Teilausdrücke mehrfach berechnet werden, kann jedoch trotzdem einen Kostenvorteil bringen, wenn die Ergebnisse aller ausgewählten Passungen durch weitere reichhaltige Befehle verarbeitet werden können. Der neue Ansatz bringt auch Änderungen beim Ersetzungsschritt mit sich. Wir finden nun im Arbeitsgraphen nicht mehr explizit die Information vor, welche Vektorkompo- 6.3 Musterselektion mittels PBQP 55 nente ein Knoten darstellt. Diese Information muss extra gespeichert werden. Da wir nun mehrfache Verwendung von gemeinsamen Teilausdrücken erlauben, ist Retypisieren nicht mehr erlaubt. Die Ergebnisse des reichhaltigen Befehls können deshalb nicht mehr durch eine Graphersetzungsregel mit ihren Verwendern verbunden werden, dies muss manuell oder mit Hilfe mehrerer kleinerer, manuell gesteuerter Regeln geschehen. Dadurch werden die Fähigkeiten des Graphersetzungssystems weniger stark ausgenutzt. Die Hauptaufgabe des GES besteht bei diesem Ansatz in der Mustersuche. 6.3.3 Erstellen des PBQP-Graphen In den folgenden Abschnitten beschreiben wir, wie der PBQP-Graph entsteht und erläutern, wie die Knoten- sowie Kantenkosten berechnet werden. Den initialen Graphen erhalten wir, indem wir die Knoten- und Kantenstruktur des SSA-Graphen übernehmen und alle gerichtete Kanten in ungerichtete umwandeln. Dann müssen an jedem Knoten die Alternativen und deren Kosten sowie an jeder Kante die Übergangskosten eingetragen werden. Alternativen Jeder Knoten erhält initial die Alternative ”nicht überdeckt“, dargestellt durch das Symbol ⊗. Danach setzen wir das GES ein, um nach Abschnitt 6.3.2 veränderte Mustergraphen zu suchen. Die n gefundenen Passungen werden nach dem Schema p1 bis pn durchnummeriert. Wird ein Knoten im SSA-Graphen von der Passung pi überdeckt, so erhält seine Entsprechung im PBQP-Graph die Alternative Ai := {pi }. Nach diesem Schritt hat ein Knoten n + 1 Alternativen, falls er von n Passungen überdeckt wird. Wir möchten nun den Umstand modellieren, dass auch mehrere überlappende Passungen gleichzeitig ersetzt werden können. Der PBQP Ansatz gestattet es nicht, mehrere Alternativen gleichzeitig auszuwählen. Um mehrere sich überlappende Passungen zur Ersetzung freigeben zu können, gehen wir vor, wie von Jakschitsch [19, Kapitel 5] beschrieben: Wir erzeugen weitere Alternativen, die mehrere Passungen gleichzeitig darstellen. Dazu bestimmen wir an jedem Knoten die Potenzmenge P der Menge der überdeckenden Passungen und fügen die neu hinzugekommenen Kombinationen als neue Alternativen hinzu. Wird beispielsweise ein Knoten von Passung p1 und p2 überdeckt, so sind die Alternativen A := ({⊗}, {p1 }, {p2 }, {p1 , p2 }). Das kann sich dann lohnen, wenn die Ergebnisse aller sich überlappenden Passungen durch reichhaltige Befehle weiterverwendet und so die zusätzlichen Kosten wieder eingespart werden können. Überlappende Passungen pn und pm treten dann in Konflikt, wenn in beiden Passungen Speicheroperationen vorkommen und mindestens eine davon den Speicher verändert. In einem solchen Fall muss die simultane Auswahl der Passungen verhindert werden, indem die Alternativen entfernt werden, in denen pn und pm gemeinsam vorkommen. 56 6 Algorithmische Steuerung der Regelanwendung Knotenkosten Die an jedem Knoten existierende Alternative ⊗ erhält im Kostenvektor die Kosten des dem Knoten entsprechenden Einzelbefehls, da bei deren Auswahl ein Knoten nicht von einer Passung überdeckt wird und somit erhalten bleibt. Die Gesamtkosten einer Passung pm entsprechen den Kosten des einzufügenden reichhaltigen Befehls. Diese Gesamtkosten werden über die Knoten der Passung verteilt. Wird eine Alternative aus mehreren Passungen gebildet, summieren sich deren Kosten auf. An jedem Knoten k entstehen also für die Alternative Ai die Kosten X Gesamtkosten(pm ) (6.2) Kosten(k, Ai ) = Anzahl Knoten(pm ) ∀pm ∈Ai Diese Kostenverteilung auf alle Knoten einer Passung ist mit PBQP vereinbar: Da ein PBQP-Löser die Gesamtkosten des Graphen minimiert, werden die Einzelkosten der Knoten aufsummiert. Die verteilten Kosten an den Knoten einer Passung ergeben also aufsummiert wieder die Kosten der reichhaltigen Befehls. Damit sind wir von der Entscheidung befreit, an welchem Knoten einer Passung die Kosten zu annotieren sind (vgl. Jakschitsch [19, Abschnitt 4.5.4]). Kantenkosten Ein PBQP-Löser wählt an jedem Knoten des Arbeitsgraphen genau eine Alternative aus. Wir fordern zusätzlich, dass falls an einem beliebigen Knoten einer Passung p eine Alternative gewählt wird, die p enthält, so müssen auch an allen anderen Knoten der Passung Alternativen gewählt werden, die p enthalten. Eine Passung darf also immer nur komplett oder gar nicht ausgewählt werden. Durch die Kantenkosten stellen wir sicher, dass diese Forderung erfüllt wird. Aus diesem Grund stellen wir die folgenden Bedingungen an den Übergang von Alternative Ai an Knoten X zur Alternative Aj an Knoten Y auf. Man beachte, dass ein PBQP ungerichtet ist und die Bedingungen deshalb für beide Richtungen einer Kante geprüft werden müssen. Die Folgerung aus diesen Bedingungen geben wir im Anschluss an. 1. Ist ein px ∈ Ai aber px ∈ / Aj , darf px in keinem Al an Knoten Y vorkommen. Ansonsten ist der Übergang nicht erlaubt, d.h. die Übergangskosten sind ∞. 2. Der Übergang von Ai 6= {⊗} nach Aj = {⊗} ist nur erlaubt, wenn an Y kein px aus Ai enthalten ist. Eventuell müssen Konvertierungskosten veranschlagt werden, falls X ein Ergebnisknoten oder Erhaltungskosten, falls X ein innerer Knoten des Musters ist (siehe nächster Abschnitt). Erlaubte Übergänge erhalten die Kosten 0. Eventuell notwendige Konvertierungskosten werden zusätzlich aufgeschlagen. Nicht erlaubte Übergänge erhalten die Kosten ∞. Als Folge aus diesen Regeln ergibt sich: 6.3 Musterselektion mittels PBQP 1. 57 a) Ist an Knoten X die Passung px ausgewählt, und wird Knoten Y ebenfalls von px überdeckt, muss an Knoten Y ebenfalls px ausgewählt werden. b) Ist an Knoten X die Passung px nicht ausgewählt, und wird Knoten Y ebenfalls von px überdeckt, darf an Knoten Y px nicht ausgewählt werden. 2. a) Ein reichhaltiger Befehl darf nur eingefügt werden, wenn es möglich ist, seine Operanden so zu konvertieren, dass er sie verarbeiten kann (z.B. durch Packen von einzelnen Werten in Spezialregister). b) Ergebnisse eines reichhaltigen Befehls müssen eventuell konvertiert werden, z.B. durch Umkopieren von Komponenten aus Spezialregistern in Allgemeinregister. Ist das nicht möglich, darf der reichhaltige Befehl nicht eingesetzt werden. c) Übergänge zwischen zwei Knoten mit der Alternative ⊗ sind immer möglich. Konvertierungskosten Trotz der Symmetrie eines PBQP müssen wir bei der Berechnung von Konvertierungskosten zusätzlich die Richtung der entsprechenden SSA-Kante beachten. Wenn beim Übergang von Knoten X nach Knoten Y in Richtung der Kante im SSA-Graphen eine Passung hinzukommt“, handelt es sich bei Knoten Y um einen Operanden eines ” reichhaltigen Befehls. Diese Übergangskosten dürfen nur 0 sein, wenn der reichhaltige Befehl den Wert des Knotens Y direkt verarbeiten kann. Das bedeutet, der reichhaltige Befehl kann skalare Werte direkt verarbeiten oder es handelt sich bei Y um eine Vektorkomponente, die durch einen voranstehenden reichhaltigen Befehl bereit gestellt wird. Ansonsten muss Knoten X erhalten bleiben und es müssen Konvertierungskosten veranschlagt werden. Kann nicht konvertiert werden, ist der Übergang nicht erlaubt, d.h. Übergangskosten sind unendlich oder die entsprechende Alternative wird gelöscht. Fällt dagegen beim Übergang von Knoten X nach Knoten Y in Richtung der Kante im SSA-Graphen eine Passung weg, muss geprüft werden, ob der Wert an Knoten X direkt von anderen reichhaltigen Befehlen weiterverwendet werden kann oder ob Konvertierungsoperationen für Einzelbefehle notwendig sind. Durch die fehlenden VProj -Knoten wird also eine Abhängigkeitsanalyse notwendig, die prüft, welche Befehle Ergebnisse von Befehlen Passungen weiterverwenden. Die Ergebnisse dieser Analyse können dazu verwendet werden, die Passungen in der korrekten Reihenfolge zu ersetzen, so dass die Operanden von neu eingefügte reichhaltige Befehle direkt mit ihren Vorgängern verbunden werden können. Unabhängige Teilmuster Durch die in diesem Abschnitt beschriebene Änderung der Mustergraphen, können diese in unabhängige Teilmuster zerfallen. Bisher wurden die Berechnungen, die auf einzelnen Vektorkomponenten stattfinden, durch die VProj -Knoten und deren gemeinsame 58 6 Algorithmische Steuerung der Regelanwendung p1 (VectorLoad) p4 (V Basis 1 Load 1 Load 2 erarb eite Load 3 + 4) Load 4 + p2 (HorizontalAdd) Teuer Basis 2 Store 1 p3 (VectorStore) Store 2 Abbildung 6.5: Ein Firm-Graph mit Passungen einiger reichhaltiger Befehle Vorgänger aneinander gekoppelt. Da die VProj -Knoten nun weggefallen sind, ist das nicht mehr der Fall. Zwischen den Teilmustern gibt es keine Kante, wodurch wir auch keine Übergangskosten angeben können. Hier funktioniert der PBQP-Ansatz nicht mehr, da wir allein durch die Übergangskosten garantieren, dass eine Passung px an allen Knoten, die sie überdeckt, konsistent ausgewählt wird. Wir können diese Teilmuster aber durch künstliche, nicht im SSA-Graph vorhandene Kanten, verbinden. Für die künstliche Kante gelten dieselben Regeln zur Übergangskostenberechnung wie für normale Datenflusskanten. 6.3.4 Musterauswahl per PBQP am Beispiel Abbildung 6.5 zeigt einen vereinfachten SSA-Graphen eines Programms, das vier aufeinanderfolgende Speicherstellen lädt, jeweils zwei der geladenen Werte addiert und die Ergebnisse der Addition wieder abspeichert. Statt der Einzelbefehle könnten auch die reichhaltigen Befehle VectorLoad (4 Komponenten), HorizontalAdd und VectorStore (2 Komponenten) eingesetzt werden. Die zugehörigen Passungen p1 , p2 und p3 sind in der Abbildung in unterschiedlichen Grautönen hinterlegt. Zusätzlich gibt es eine teure Einzeloperation Teuer, die die 4. Vektorkomponente ebenfalls verarbeitet. Das Laden der 4. Vektorkomponente und Ausführen der teuren Operation könnte auch von einem billigeren reichhaltigen Befehl Verarbeite 4 erledigt werden; seine Passung p4 ist dunkelgrau hinterlegt. p4 überlappt sich mit p1 und p2 3 . Es stellt sich nun die Frage, ob es günstiger ist, die 4. Vektorkomponente durch den reichhaltigen Befehl Verarbeite 4 erneut laden und 3 p1 und p2 sowie p2 und p3 überlappen sich nur scheinbar. Im Bereich deren Überlappung“ befinden ” sich lediglich Operanden bzw. Ergebnisknoten. 6.3 Musterselektion mittels PBQP Befehl Vector Load Vector Add Vector Store Verarbeite 4 Passung p1 p2 p3 p4 Kosten 10 2 3 6 59 Knotenanzahl 5 6 5 3 Kosten pro Knoten 2 0,33 0,6 2 Tabelle 6.1: Über die Knoten einer Passung verteilte Kosten von reichhaltigen Befehlen. verarbeiten zu lassen, oder falls möglich das Ergebnis des VectorLoad Befehls zu konvertieren und durch die Einzeloperation Teuer verarbeiten zu lassen. Möglicherweise ist es auch günstiger, VectorLoad und die davon abhängigen Befehle gar nicht zu verwenden. Die Lösung hängt von den Kosten der Einzel- und reichhaltigen Befehle ab und soll von einem PBQP-Löser gefunden werden. Dazu berechnen wir wie in Abschnitt 6.3.3 beschrieben die Knoten- und Kantenkosten des PBQP-Graphs: Alle Einzelbefehle haben die Kosten 3, mit Ausnahme der Operation Teuer, der wir die Kosten 5 zuweisen. Die Kosten der reichhaltigen Befehle sind in Tabelle 6.1 zusammengefasst. Wichtig sind hierbei die Kosten pro Knoten, die wir im nächsten Schritt an die einzelnen Alternativen der Knoten zuweisen. Dazu geben wir beispielhaft für den Knoten Load 1 die Alternativen sowie deren Kosten an. Load 1 wird durch die Passungen p1 sowie p2 gleichzeitig überdeckt. Um zu modellieren, dass an Knoten Load 1 die Möglichkeiten bestehen (1) keine der beiden Passungen zu ersetzen, (2) nur p1 zu ersetzen, (3) nur p2 zu ersetzen oder (4) p1 und p2 zu ersetzen, erhält er die Alternativen A := ({⊗}, {p1 }, {p2 }, {p1 , p2 }). Die Kosten der Alternative {⊗} entsprechen den Kosten des Einzelbefehls, also 3. Die Alternativen {p1 } und {p2 } erhalten die Kosten 2 und 0, 33, wie aus Tabelle 6.1 auszulesen ist. Schließlich addieren sich die Kosten von p1 und p2 , um die Kosten der Alternative {p1 , p2 } zu erhalten, folglich 2, 33. In Tabelle 6.2 sind die Alternativen der weiteren Knoten inklusive deren Kosten dargestellt. Es fehlen jetzt noch die Übergangskosten an den Kanten, die wir mit Hilfe der Regeln aus Abschnitt 6.3.3 aufstellen. Wir ziehen als Beispiel die Kante Basis 1 → Load 1 heran. Die komplette Übergangsmatrix ist in Tabelle 6.3 (a) dargestellt. In den Zeilen sind die Alternativen an Knoten Basis 1, in den Spalten die an Knoten Load 1 aufgetragen. Wir besprechen die Matrix nun zeilenweise: (1) Alternative ⊗ an Basis 1 darf nicht nach p1 oder p1 p2 übergehen. Ansonsten könnte p1 unvollständig ausgewählt werden. Sie darf auch nicht nach p2 übergehen, da p2 nicht die Ergebnisse der Einzelbefehle verarbeiten und daher nicht ohne p1 ersetzt werden kann. (2) Alternative p1 an Basis 1 darf nicht nach ⊗ oder p2 übergehen, da p1 ansonsten unvollständig ausgewählt werden könnte. (3) Wird an Basis 1 nur p4 ausgewählt, dürfen p1 und p1 p2 nicht gewählt werden, sonst wäre möglicherweise p1 unvollständig. p2 darf nicht gewählt werden, da sie nicht ohne p1 ersetzt werden kann. (4) In Zeile 4 greifen dieselben Regeln wie in Zeile 2. Eine weitere Übergangsmatrix für den Übergang Load 4 → T euer ist in Tabelle 6.3 (b) 60 6 Algorithmische Steuerung der Regelanwendung Knoten Base Load 1 ... Load 4 +1 ... Store 1 Teuer Alternativen (⊗, p1 , p4 , p1 p4 ) (⊗, p1 , p2 , p1 p2 ) ... (⊗, p1 , p2 , p4 , p1 p2 , p1 p4 , p2 p4 , p1 p2 p4 ) (⊗, p2 , p3 , p2 p3 ) ... (⊗, p3 ) (⊗, p4 ) Kosten (3, 2, 2, 4) (3, 2, 0.33, 2.33) ... (3, 2, 0.33, 2, 2.33, 4, 2.33, 4.33) (3, 0.33, 30, 0.99) ... (3, 0.66) (20, 2) Tabelle 6.2: Kosten der Alternativen pro Knoten. angegeben. Zu bemerken sind hier die Kosten 100 beim Übergang von Alternative p1 nach p4 und von p1 p2 nach p4 . Das sind Konvertierungskosten, die anfallen, da in diesen Fällen das Ergebnis des VectorLoad-Befehls so konvertiert werden muss, damit es von der Operation Teuer verarbeitet werden kann. Alle weiteren Übergangsmatrizen wurden ebenfalls nach diesen Regeln erstellt. Als nächstes übergeben wir den PBQP-Graph inklusive aller Alternativen und deren Kosten, sowie der Übergangskosten an einen PBQP-Löser. Ergebnisse Wir setzen einen PBQP-Löser ein, um die Alternativen mit den minimalen Gesamtkosten berechnen zu lassen. Das Ergebnis des Experiments ist, dass mit oben festgelegten Kosten alle Passungen p1 , p2 , p3 und p4 zur Ersetzung freigegeben werden. Das ist korrekt, denn die Kosten der reichhaltigen Befehle VectorLoad, HorizontalAdd und VectorStore sind geringer als die Kosten der Einzelbefehle. Es lohnt sich in diesem Fall, auch p4 zu ersetzen, obwohl die vierte Vektorkomponente doppelt geladen wird. Der Grund ist, dass die durch die Ersetzung von p1 , p2 und p3 gewonnenen Einsparungen groß genug sind, um dies zu kompensieren und zusätzlich sind die Kosten zur Konvertierung des Ergebnisses des VectorLoad-Befehls für die Operation Teuer sehr hoch. Die Gesamtkosten der Auswahl belaufen sich auf 21. Wir übergeben das PBQP nun nochmals dem PBQP-Löser, allerdings lassen wir jetzt eine günstige Konvertierung des Ergebnisses des VectorLoad-Befehls zu, d.h. wir ersetzen die Werte 100 aus Tabelle 6.3 (b) durch 0. Nun fallen für die Operation Teuer nur die Kosten 5 an, während für die Operation Verarbeite 4 die Kosten 6 anfallen würden. Es ist in diesem Fall also günstiger, die Operation Teuer auszuführen, da die Konvertierung ihres Operanden keine Kosten verursacht, statt durch den Befehl Verarbeite 4 die vierte Vektorkomponente ein zweites Mal zu laden. Folglich werden nur p1 , p2 und p3 zur Ersetzung freigegeben, p4 zurückgewiesen. Die Gesamtkosten sind damit 20. Wir verändern die Kosten des PBQP nun abermals, so dass der VectorStore-Befehl 6.3 Musterselektion mittels PBQP (a) Basis1→Load1 ⊗ p1 p4 p1 p4 ⊗ 0 ∞ 0 ∞ 61 (b) p1 ∞ 0 ∞ 0 p2 ∞ ∞ ∞ ∞ p1 p2 ∞ 0 ∞ 0 Load4→Teuer ⊗ p1 p2 p4 p1 p2 p1 p4 p2 p4 p1 p2 p4 ⊗ 0 100 ∞ ∞ 100 ∞ ∞ ∞ p4 ∞ ∞ ∞ 0 ∞ 0 0 0 Tabelle 6.3: Übergangskosten ausgewählter Kanten. sehr teuer ist, indem wir ihre Kosten auf 100 erhöhen. Außerdem sei eine Konvertierung des Ergebnisses des VectorAdd-Befehls, so dass sie durch einzelne Store-Operationen verarbeitet werden können, ebenfalls teuer. Dann wählt der PBQP-Löser korrekt keine Passung zur Ersetzung aus. p1 , p2 und p3 lohnen sich nun nicht mehr. Es lohnt sich ebenfalls nicht, p4 zu ersetzen, da in diesem Fall die Operation Load 4 trotzdem stehen bleiben müsste. Die doppelte Ausführung von Load 4 lohnt sich aber in diesem Fall nicht, da die Einsparungen durch den Befehl Verarbeite 4 zu gering sind. Also wird auch p4 nicht zur Ersetzung freigegeben. Die Gesamtkosten sind in diesem Fall 35. 6.3.5 Bewertung Wir haben an einem Beispiel gezeigt, wie die Musterauswahl auf ein PBQP abgebildet werden kann. Der große Vorteil ist, dass zur Lösung des im Grunde NP-vollständigen Problems der Musterauswahl ein generischen PBQP-Löser eingesetzt werden kann, der heuristische Verfahren zur Lösung verwendet. Auch wenn viele Passungen im Arbeitsgraph gefunden werden, arbeitet der PBQP-Löser aufgrund der Charakteristik unserer Graphen noch effizient [19]. Durch die generischeren Mustergraphen werden deutlich mehr Muster gefunden als beim bisherigen Ansatz. Viele der gefundenen Passungen müssen wieder aussortiert werden, jedoch ist es auch möglich zu prüfen, ob Werte aus verschiedenen Einzelregistern in ein Vektorregister gepackt werden können. Bisher werden die Vektorregister nur über Vektorlade-Befehle befüllt. Dieses Verfahren bringt jedoch einige Änderungen an unserem ursprünglichen Verfahren mit sich. Es gibt keine VProj -Knoten mehr, die explizit im Graph die Information darstellen, ob ein Wert eine Vektorkomponente darstellt. Wir müssen also erst durch geeignete Analysen herausfinden, welche Knoten potentielle Ergebnisknoten von reichhaltigen Befehlen darstellen und welche anderen reichhaltigen Befehle diese Ergebnisse verwenden können. Aus Zeitgründen beinhaltet unsere Implementierung deshalb nur das 62 6 Algorithmische Steuerung der Regelanwendung in Abschnitt 6.2 beschriebene, suchbaumbasierte Verfahren. 7 Implementierung und Messungen In diesem Kapitel gehen wir auf den Aufbau unserer Implementierung ein, mit deren Hilfe wir Messungen durchgeführt haben. Die Messergebnisse und deren Bewertung stellen wir zum Abschluss des Kapitels vor. 7.1 Architektur Unsere Implementierung ist modular aufgebaut. Sie lässt sich grob in einen statischen Teil und einen dynamischen Teil unterteilen. initialer Mustergraph Graph−Normalisierer Graph−Analysierer GrGen− Regelgenerator Backend− Regelgenerator Datenbank− Generator Abbildung 7.1: Architektur des Moduls Regelerzeuger Der statische Teil entspricht der Regelerzeugung und ist in Abbildung 7.1 dargestellt. Der Regelerzeuger bekommt als Eingabe die initialen Zwischensprachengraphen der Befehls-Spezifikation. Diese werden zunächst durch den Graph-Normalisierer nach den in Kapitel 5 beschriebenen Verfahren in eine einheitliche Form gebracht. Aus den normalisierten Graphen extrahiert der Graph-Analysierer alle Informationen, die zusätzlich zum Verhalten der reichhaltigen Befehle angegeben wurden. Diese Analyseinformation wird einerseits vom GrGen-Regelgenerator dazu verwendet, den initialen Graphen zu beschneiden und Muster- und Ersetzungsgraph zu erzeugen, woraus wiederum die Graphersetzungsregeln entstehen. Andererseits verwendet der Backend-Regelgenerator die Analyseinformation, um Knotenspezifikationen für das IA32-Backend [28] zu erzeugen. Dadurch kann das Backend für die neu eingeführten Maschinensprachenknoten auto- 63 64 7 Implementierung und Messungen Regeldatenbank zu optimierender Firm−Graph Graph−Normalisierer Suchbaum−Generator Firm GES Filter Ersetzer Firm GES Nachbearbeiter optimierter Firm−Graph Abbildung 7.2: Architektur des Moduls Regelanwender matisch Code ausgeben. Schließlich speichert der Datenbank-Generator die erzeugten Regeln in geeigneter Form ab. Ist die Regeldatenbank erstellt, können die Regeln durch den Regelanwender, der dem dynamischen Teil der Implementierung und der eigentlichen Optimierung entspricht, ausgeführt werden. Zwischen der Regelgenerierung und Regelanwendung muss der Übersetzer neu übersetzt werden, da er die generierte Backend-Spezifikation nicht dynamisch einlesen kann, sondern diese statisch mitübersetzt werden muss. Der Regelanwender ist in Abbildung 7.2 dargestellt. Dieser erhält als Eingabe den zu optimierenden Zwischensprachengraphen sowie Informationen über die Regeldatenbank. Zunächst wird der zu optimierende Graph durch den Graph-Normalisierer auf eine einheitliche Form gebracht. Der normalisierte Graph wird dem Suchbaum-Generator übergeben, der nach den Methoden in Abschnitt 6.2 die möglichen Passungen sucht. Zur Mustersuche ruft er wiederholt das Graphersetzungssystem Firm GES auf. Wird eine Passung gefunden, wird diese durch den Filter nach den Methoden aus Abschnitt 4.3 auf Gültigkeit überprüft. Den fertigen Suchbaum verwendet der Ersetzer, um die kostengünstigste Kombination von Graphersetzungsregeln auszuwählen und anzuwenden. Dazu ruft er wieder für jede anzuwendende Regel Firm GES auf, welches die eigentliche Ersetzung durchführt. Der Nachbearbeiter reiht, wie in Abschnitt 4.2 beschrieben, den neu eingefügten reichhaltigen Befehl in die Speicherabhängigkeitskette ein. Schließlich steht nach dem Anwenden aller Graphersetzungsregeln der optimierte Zwischensprachengraph zur Verfügung, welcher durch das Übersetzer-Backend zu ausführbarem Code verarbeitet wird. 7.2 Messergebnisse 65 7.2 Messergebnisse Unser Testrechner ist mit einem Intel Pentium 4 Prozessor (Prescott Kern, 3,2 GHz) ausgestattet, der sich durch die SSE3 Befehlssatzerweiterung auszeichnet. Das System verfügt über 2 GByte Hauptspeicher. Als Betriebssystem verwenden wir SUSE Linux 9.3. 7.2.1 Bewegungsschätzung Unser erstes Beispiel implementiert einen sogenannten Block-Matching-Algorithmus zur Bewegungsschätzung1 , wie er bei der MPEG Kompression in Video-Codecs Einsatz findet [17]. Der Algorithmus operiert auf einem Feld von der Größe 256x256 Byte und wird testweise 100 mal ausgeführt. Das Quellprogramm ist in Listing 7.1 zu sehen. Listing 7.1: Block-Matching 1 2 3 4 5 unsigned int sad(int test_blockx, int test_blocky, int *best_block_x, int *best_block_y, unsigned char frame[256][256]) { int i, x, y, blocky, blockx; unsigned tmp_diff, min_diff = 0xFFFFFFFF; 6 // Iterate over whole frame; x,y=coords of current block for(x = 1; x < 256 - 16; x++) for(y = 0; y < 256 - 16; y++) { tmp_diff = 0; // Compare current block with reference block for(blocky = 0; blocky < 16; blocky++) { for(blockx = 0; blockx < 16; blockx++) if(a[blocky][blockx] > b[blocky + y][blockx]) tmp_diff += (frame[blocky][blockx] - frame[blocky + y][blockx]); else tmp_diff += (frame[blocky + y][blockx] - frame[blocky][blockx]); } 7 8 9 10 11 12 13 14 15 16 17 18 19 // Check if the current block is least different if(min_diff > tmp_diff) { min_diff = tmp_diff; *best_block_x = x; *best_block_y = y; } 20 21 22 23 24 25 } } return(min_diff); 26 27 28 29 } 1 engl. Motion estimation 66 7 Implementierung und Messungen (a) Laufzeit und Graph Statistiken Standard Opt. 2392 5324 13,55 s # Knoten # Kanten Laufzeit reichhaltige Befehle 680 1480 420 ms Faktor 3,52 3,60 Speedup: 32,26 (b) Ausgenutzte reichhaltige Befehle Muster VectorLoad 16b v1 VectorLoad 16b v3 angewandt 1x 1x SAD 16b 1x Component 0Iu 1x Befehle lddqu lddqu psadbw pshudf paddd movd # Knoten 82 84 # Kanten 96 128 289 461 3 2 Tabelle 7.1: Messergebnisse Block-Matching“. ” Die Ergebnisse der Optimierung zeigt Tabelle 7.1. Die Änderungen, die am Arbeitsgraphen vorgenommen wurden, sind in Tabelle 7.1(a) dargestellt, die gefundenen Passungen und die eingefügten reichhaltigen Befehle in Tabelle 7.1(b). Das ursprüngliche Programm, optimiert mit Standardoptimierungen, hatte exklusive der zufälligen Initialisierung der Testdaten eine Laufzeit 13,55 Sekunden. Das von uns optimierte Programm hatte eine Ausführungszeit von nur 450ms, was eine Beschleunigung um Faktor 32,26 bedeutet. Der gesamte Optimierungsprozess dauerte 1,2 Sekunden. Insgesamt wurden vom Graphersetzungssystem 4480 Knoten und 17853 Kanten gepasst und 4 Passungen ersetzt. Die eigentliche Graphersetzung inkl. Mustersuche schlug mit 40ms zu Buche, der Rest der Zeit wurde für zusätzliche Analysen und das Entfernen von totem Code verwendet. 7.2.2 Skalarprodukt Ein weiteres Beispiel berechnet das Skalarprodukt von 2 500 000 vierdimensionalen Vektorpaaren und addiert die Ergebnisse aller Skalarprodukte auf. Der Quellcode ist in Listing 7.2 zu sehen. Listing 7.2: Skalarprodukt 1 2 3 4 5 6 7 static float scalar_product(float *a, float *b) { float res; for(i = 0; i < 10000000; i += 4) res += a[i] * b[i] + a[i + 1] * b[i + 1] + a[i + 2] * b[i + 2] + a[i + 3] * b[i + 3]; return(res); } 7.2 Messergebnisse 67 (a) Laufzeit und Graph Statistiken # Knoten # Kanten Laufzeit Standard Opt. 604 1376 760 ms reichhaltige Befehle 404 1008 720 ms Faktor 1,30 1,37 Speedup: 1,05 (b) Ausgenutzte reichhaltige Befehle Muster VectorLoad 4f v1 VectorMul Speicher 4f v1 HorizontalAdd 4f Component 0f angewandt 1x 1x 1x 1x Befehle movaps mulps 2x haddps - # Knoten 23 31 9 3 # Kanten 25 39 13 2 Tabelle 7.2: Messergebnisse Skalarprodukt“. ” Die Ergebnisse der Optimierung sind in Tabelle 7.2 zu sehen. Insgesamt wurden 549 Knoten und 1836 Kanten gepasst. Von 28 Passungen wurden 4 ersetzt. Die von Firm GES verbrauchte Zeit lag unter der Messgrenze, die Gesamtzeit betrug 1,5 Sekunden. Den vergleichsweisen geringen Speedup von 1,05 erklären wir dadurch, dass der sehr langsame Befehl haddps eingesetzt werden musste, der für eine komplette horizontale Vektoraddition in jeder Iteration zwei Mal angewendet werden muss. Erwähnenswert an diesem Beispiel ist, dass durch die algorithmische Steuerung (siehe Kapitel 6) korrekterweise der reichhaltige Befehl VectorMul mit Speicheroperand – ein Multiplikationsbefehl, der einen Vektor direkt aus dem Speicher laden kann – dem Multiplikationsbefehl, der nur auf Registern arbeitet, vorgezogen wurde. 7.2.3 Reihungselemente begrenzen Dieses Beispiel aus dem Bereich der Klangverarbeitung sucht eine Reihung aus 50 000 000 Elementen nach zu großen Werten ab und setzt sie ggf. auf einen konstanten Maximalwert herab (sog. Clipping“). ” Listing 7.3: Clipping 1 2 3 4 5 6 7 8 9 10 void clipping(short *a, int max) { short konstant[8] = {8000, 8000, 8000, 8000, 8000, 8000, 8000, 8000}; int i, j; for(i = 0; i < max; i += 8) { for(j = 0; j < 8; j++) { if(a[i + j] > konstant[j]) a[i + j] = konstant[j]; } } } 68 7 Implementierung und Messungen (a) Laufzeit und Graph Statistiken # Knoten # Kanten Laufzeit Standard Opt. 1120 2468 510 ms reichhaltige Befehle 508 1076 170 ms Faktor 2,20 2,29 Speedup: 3 (b) Ausgenutzte reichhaltige Befehle Muster VectorLoad 8sI v0 VectorLoad 8sI v1 max 8sI VectorStore 8sI v1 angewandt 1x 1x 1x 1x Befehle lddqu lddqu pmaxsw movups # Knoten 42 43 123 43 # Kanten 41 49 161 62 Tabelle 7.3: Messergebnisse Clipping“. ” Bei diesem Beispiel musste der Optimierung durch einen entsprechenden Programmierstil etwas nachgeholfen werden: Da Vektorkomponenten nicht direkt mit einer Konstanten verglichen werden, muss die Konstante 8-fach in ein Vektorregister geladen werden. Die Ergebnisse der Optimierung sind in Tabelle 7.3 dargestellt. Insgesamt wurden 28 Passungen gefunden, von denen 4 ersetzt wurden. Alle Passungen umfassten 1331 Knoten und 4717 Kanten. Firm GES benötigte nur 16ms für das Finden aller Passungen. Die gesamte Optimierungszeit betrug 1 Sekunde. Das Programm wurde um Faktor 3 beschleunigt. 7.2.4 Demonstration der Musterauswahl Unser letztes Beispiel berechnet den Ausdruck ~a ∗~b+~b∗ d~ und ist in Listing 7.4 dargestellt. Listing 7.4: Zweifaches Skalarprodukt 1 2 3 float ueberlappung() { float a[4], b[4], d[4], sp1, sp2; 4 sp1 = a[0] * b[0] + a[1] * b[1] + a[2] * b[2] + a[3] * b[3]; sp2 = b[0] * d[0] + b[1] * d[1] + b[2] * d[2] + b[3] * d[3]; 5 6 7 return(sp1 + sp2); 8 9 } Dieses Beispiel dient der Demonstration der algorithmischen Steuerung, weshalb wir nicht die Laufzeit messen, sondern den Suchbaum präsentieren. Interessant an diesem Beispiel ist, dass zwei Varianten eines Vektormultiplikations-Musters zur Verfügung stehen: Eine Version, die ihre Operanden in Registern erwartet, und eine Version mit einem 7.2 Messergebnisse 69 Priorität 0 1 Muster VectorLoad 4f VectorLoad 4f VectorMul Speicher 4f VectorMul 4f Ausdruck Laden von ~b Laden von d~ Laden von ~a ai ∗ bi ai ∗ di Tabelle 7.4: Ersetzte Passungen beim Beispiel Zwei Skalarprodukte“. ” Speicheroperanden. Allerdings soll durch den Speicheroperanden nicht der Vektor ~b geladen werden, da dieser vom zweiten Skalarprodukt auch gebraucht wird und deshalb explizit in Registern zur Verfügung stehen muss. Die Steuerung beachtet dies, indem dynamische Kosten aufaddiert werden, falls innere Knoten einer Passungen Verwendung außerhalb finden. Als Ergebnis wurden insgesamt 8 reichhaltige Befehle eingefügt. Wir zeigen im Speziellen die Befehle der Prioritätsklasse 0, da hier Überlappungen vorkommen. Die eingesetzten Befehle sind in Tabelle 7.4 dargestellt. Der Vektor ~b wurde korrekterweise explizit in ein Register geladen. Der Multiplikationsbefehl mit Speicheroperand wurde zwar zwei Mal erkannt, konnte jedoch nur einmal eingesetzt werden, da es ansonsten zu Verklemmungen der Speicherkante gekommen wäre (siehe Abschnitt 4.3.3). Der Vollständigkeit halber ist in Abbildung 7.3 der erzeugte Suchbaum für die Prioritätsklasse 0 zu sehen. Die markierten Knoten repräsentieren ersetzte Passungen, die nicht markierten Knoten repräsentieren gefundene, jedoch aus kostengründen nicht ersetzte Passungen. Abbildung 7.3: Suchbaum für Prioritätsklasse 0 70 7 Implementierung und Messungen 7.3 Bewertung Die Messungen demonstrieren, dass großes Potential besteht, Programme mit Hilfe von vorgenerierten Graphersetzungsregeln zu optimieren. Während den Tests hat sich gezeigt, wie einfach reichhaltige Befehle durch die Befehls-Spezifikation in einer Hochsprache zu integrieren sind. Vor allem Programme aus dem Multimediabereich wie Video-Codecs oder numerische Berechnungen, die Skalar- und Matrixmultiplikationen durchführen, kommen für die Optimierung in Frage. Es ist vor allem dann eine hohe Beschleunigung zu erreichen, wenn die ersetzten reichhaltigen Befehle verzweigten Steuerfluss beinhalten. Da sich auch die von der Optimierung in Anspruch genommene Übersetzungszeit in Grenzen hält, lohnt es sich unserer Meinung nach, eine solche Optimierung in Standard-Übersetzer einzubauen. Wie sich im Verlauf der Messungen gezeigt hat, ist noch nicht die Optimierung beliebiger Programme möglich, weshalb im Folgenden kurz einige weiterführende Ideen präsentiert werden sollen. 7.4 Weiterführende Ideen Ergebnisse von reichhaltigen Befehlen lassen sich bisher noch nicht über verschiedene Schleifeniterationen hinweg transportieren. Durch die SSA-typischen Phi -Knoten, die im Mustergraph so nicht vorkommen, können wir aktuell noch keine Passungen finden, die Schleifengrenzen überstreichen. Eine Möglichkeit wäre, auf Unterstützung von Seiten des Graphersetzungssystems zu hoffen. Sogenannte Pfadausdrücke würden es uns erlauben, über Phi -Operationen hinwegzuschauen“. ” Wird ein ein reichhaltiger Befehl gefunden, der Ergebnisse aus einer früheren Schleifeniteration verwendet, wären weitere Transformationen notwendig. Die entsprechenden Phi -Knoten müssten möglicherweise zu Vektor-Phi -Knoten konvertiert werden. Dabei ist zu prüfen, ob alle Operanden auch vektorwertig sind. Gegebenenfalls müssen Operationen zur Konvertierung der Operanden oder des Ergebnisses eingefügt werden. Desweiteren ist das Finden von Folgepassungen momentan oft abhängig davon, dass bereits Vektorlade-Befehle, die Vektorregister befüllen, eingefügt worden sind. Um das zu umgehen, wäre es möglicherweise sinnvoll, unabhängig von den benutzerspezifizierten Befehlen Knoten einzuführen, die automatisch Werte aus verschiedenen Einzelregistern in Vektorregistern packen“ können. Es wäre auch denkbar, Komponenten eines Vektorregisters ” zu permutieren, um den Registerinhalt für den nächsten Vektorbefehl aufzubereiten. In Abbildung 7.4 ist dargestellt, wie das Einfügen eines reichhaltigen Befehls, der Ergebnisse aus einer früheren Schleifeniteration verwendet, vonstatten gehen könnte. Abbildung 7.4(a) zeigt die Operationen und die Datenabhängigkeiten einer einfachen Schleife, die zwei mit 0 initialisierte Werte pro Iteration um 4 erhöht. Die gestrichelten Kanten deuten Verwender außerhalb der Schleife an, die das Endergebnis weiterverwenden. Nun wäre es möglich, die beiden Werte als Vektor aufzufassen und statt einzelner Additions- 7.4 Weiterführende Ideen 71 (a) 0x0 Phi Phi 0x4 Add (c) (b) 0x0 0x4 0x0 Pack Pack Pack VPhi Add VProj 0 Pack VProj 1 0x4 Add VPhi Pack VAdd VProj 0 VProj 1 Add 0x4 0x0 Pack Pack VPhi VAdd (d) Abbildung 7.4: Vektorwerte über Schleifeniterationen transportieren befehle einen Vektoradditions-Befehl einzusetzen. Es ist aber nicht sofort offensichtlich, wie ein solcher Befehl eingefügt werden kann. Dazu sind weitere Transformationen nötig, die wir im folgenden schrittweise beschreiben. Wir beginnen bei der Initialisierung des Vektors vor Beginn der Schleife. Die in Abbildung 7.4(a) grau hinterlegten Knoten, die beide Werte einzeln mit 0 initialisieren, ersetzen wir in Abbilding 7.4(b) durch eine spezielle Pack -Operation. Diese befüllt die beiden Komponenten eines Vektorregisters gleichzeitig mit 0. Die beiden Phi Operationen, die die beiden Einzelwerte dargestellt haben, sind durch ein einziges VPhi (Vektor Phi) ersetzt worden. Wir müssen nun dafür sorgen, dass alle anderen Operanden des VPhi -Knotens ebenfalls Vektorwerte sind. Deswegen ist eine zusätzliche Pack Operation vonnöten, die beide Ergebnisse der einzelnen Additionen zu einem Vektor zusammenfasst. Da die Verwender des Ergebnisses des Phi -Knotens noch Einzelbefehle 72 7 Implementierung und Messungen sind, müssen noch VProj -Knoten zwischengeschaltet werden, um einzelne Vektorkomponenten zu extrahieren. Erst jetzt können wir die Passung des Vektoradditions-Befehls erkennen, die in 7.4(b) grau hinterlegt ist. In Abbildung 7.4(c) wurde sie durch einen VAdd-Knoten ersetzt. Die VProj -Knoten des VPhi können wegfallen, da wir annehmen, dass auch der Verwender außerhalb der Schleife das im Vektorregister gespeicherte Endergebnis direkt weiterverarbeiten kann, ohne Komponenten extrahieren zu müssen. Es verbleiben jetzt nur noch die grau markierten VProj -Knoten des VAdd, die wir eliminieren müssen. Da die Pack Operation gerade als inverser VProj -Knoten angesehen werden kann, heben sich die grau markierten Knoten gerade auf. Eventuell sind Permutationen von Vektorkomponenten nötig. Das Ergebnis der Transformation ist in Abbildung 7.4(d) zu sehen. Die Schleife ist nun komplett vektorisiert. Aus Zeitgründen konnten diese Überlegungen nicht mehr in die Implementierung mit aufgenommen werden. 8 Zusammenfassung und Ausblick Ziel dieser Arbeit war es, Graphersetzungsregeln aus einer Befehls-Spezifikation, welche auf einer Hochsprache basiert, zu generieren und deren Anwendbarkeit in Bezug auf eine übersetzerinterne Optimierung zu prüfen. Dazu haben wir einen bestehender Übersetzer um eine Optimierung erweitert, die automatisch die von modernen Prozessoren angebotenen reichhaltigen Befehle unterstützt. Die Optimierung findet auf der Zwischensprache Firm statt. Dabei werden Teilgraphen der Zwischendarstellung – bestehend aus mehreren Einzeloperationen – durch reichhaltige Befehle ersetzt. Zur Graphtransformation setzen wir das Graphersetzungssystem GrGen ein. Die Graphersetzungsregeln für GrGen werden ausgehend von einer Spezifikation in einer Hochsprache automatisch generiert. Die von uns entworfene Spezifikationssprache bedient sich nur der Sprachelemente der Programmiersprache C und ist deshalb einfach zu erlernen, intuitiv und leicht erweiterbar, wie sich während unserer Tests gezeigt hat. Nach einem einleitenden Definitionsteil der Spezifikation, der alle für die Optimierung und für das Backend wichtigen Informationen enthält, können im Verhaltensteil selbst komplexe reichhaltige Befehle mit relativ geringem Aufwand beschrieben werden. Wir haben dargelegt, wie aus der Spezifikation die Graphersetzungsregeln entstehen und haben untersucht, wie die Regeln angewendet werden können. Um die Trefferwahrscheinlichkeit bei der Mustersuche zu erhöhen, haben wir Maßnahmen wie Normalisierung und Variantenbildung ergriffen, um die syntaktischen Unterschiede von Muster- und Arbeitsgraph gering zu halten. Wir haben uns ausführlich mit der Alias-Problematik auseinandergesetzt und Lösungen für die Mustersuche bei unterschiedlich serialisierten Speicheroperationen angegeben. Gerade bei der Speicherabhängigkeit von Operationen gibt es viele Feinheiten zu beachten, auf die wir deshalb genauer eingegangen sind. Da Graphersetzungsregeln voneinander abhängen, bedarf es einer algorithmischen Steuerung der Regelanwendung. Dazu haben wir zwei Verfahren entwickelt. Das suchbaumbasierte, explorative Verfahren floss in die Implementierung ein und hat für alle Testprogramme in ausreichend kurzer Zeit funktioniert. Für sehr große Arbeitsgraphen und eine große Menge an gefundenen Passungen ist jedoch das Problem der Regelauswahl durch das zweite vorgestellte, PBQP basierte Verfahren effizienter lösbar. Wir haben an einem Beispiel gezeigt, wie das PBQP-basierte Verfahren angewendet werden kann. Es war uns möglich, hardware-spezifische Attribute in die Spezifikationssprache mit aufzunehmen. Die Integration in das Backend ist also für den Benutzer transparent. Es 73 74 8 Zusammenfassung und Ausblick ist somit möglich, unsere Implementierung vollkommen automatisch ohne Eingreifen des Benutzers zur Codeoptimierung einzusetzen. Es konnten reale Programme optimiert und hohe Beschleunigung erzielt werden. Allerdings musste bei einigen Beispielen der Programmierstil trotzdem angepasst werden. Einschränkungen und Verbesserungsmöglichkeiten der Implementierung sehen wir deshalb in folgenden Punkten: • Prüfen, ob Daten im Speicher korrekt ausgerichtet sind. Die Spezifikationssprache kann dazu mit geringem Aufwand erweitert werden. Allerdings benötigen wir hierbei auch Unterstützung des Übersetzers und entsprechende Analysen (siehe Abschnitt 4.3.5). • Parallelisierung von Speicherkanten. Eine Optimierung, die gemäß den Informationen einer Alias-Analyse Speicheroperationen parallelisiert, würde uns Aufwand bei der Analyse von Passungen ersparen und Vorteile bei Passungen, die sich über mehrere Grundblöcke erstrecken, bringen. Um der Alias-Analyse unter die Arme zu greifen, wäre z.B. die Unterstützung des C99 Schlüsselworts restrict durch den Übersetzer hilfreich. • Transportieren von Ergebnissen reichhaltiger Befehle über Schleifengrenzen. Außerdem das Befüllen von Vektorregistern nicht nur durch Vektorlade-Operationen, sondern auch durch das automatische Kopieren von Werten aus Allgemeinregistern. Dazu sind generischere Muster notwendig, so dass eine größere Anzahl von Passungen gefunden werden kann (siehe Abschnitt 7.4). • Eine große Anzahl von Passungen führt zwangsweise zum PBQP-basierten Ansatz der Regelauswahl, da dieses Verfahren im Vergleich zum explorativen Ansatz deutlich besser skaliert (siehe Abschnitt 6.3). Literaturverzeichnis [1] CONVEX Computer Corp , O. Box 5937, Denver, CO 80217-9808. CONVEX Application Compiler User’s Guide, 2nd ed. , 1992. [2] Alfred V. Aho, Ravi Sethi, and Jeffrey D. Ullman. Compilers: Principles, Techniques, and Tools. Addison-Wesley Pub Co, 1986. [3] Gernot Veit Batz. Graphersetzung für eine Zwischendarstellung im Übersetzerbau. Master’s thesis, Universität Karlsruhe, 2005. [4] Jakob Blomer and Rubino Geiß. The GrGen.NET User Manual. Technical Report 2007-5, Universität Karlsruhe, IPD Goos, July 2007. ISSN 1432-7864. [5] Boris Boesler. Codeerzeugung aus Abhängigkeitsgraphen. Master’s thesis, Universität Karlsruhe (TH), IPD, Jun 1998. [6] Keith D. Cooper and Linda Torczon. Engineering a Compiler. Morgran Kaufmann Publishers, 2004. [7] Intel Corp. Data Alignment and Programming Issues for the Streaming SIMD Extensions with the Intel C/C++ Compiler. Intel Application Notes, AP-833, 1999. [8] Ron Cytron, Jeanne Ferrante, Barry K. Rosen, Mark N. Wegman, and F. Kenneth Zadeck. Efficiently computing static single assignment form and the control dependence graph. ACM Trans. Program. Lang. Syst., 13(4):451–490, 1991. [9] H. Ehrig, R. Heckel, M. Korff, M. Löwe, L. Ribeiro, A. Wagner, and A. Corradini. Algebraic Approaches to Graph Transformation - Part II: Single Pushout A. and Comparison with Double Pushout A. In [26], volume 1, pages 247–312. 1999. [10] Bernhard Scholz Erik Eckstein, Oliver König. Code instruction selection based on SSA-graphs. Lecture notes in computer science (Lect. notes comput. sci.) ISSN 0302-9743, 2003. [11] M. Flynn. Some computer organizations and their effectiveness. IEEE Trans. Comput., Vol. C-21, pp. 948, 1972. [12] R. Geiß. Graphersetzung mit Anwendungen im Übersetzerbau. PhD thesis, Universität Karlsruhe, IPD Prof. Goos, 2007. 75 76 Literaturverzeichnis [13] Rubino Geiß, Gernot Veit Batz, Daniel Grund, Sebastian Hack, and Adam M. Szalkowski. Grgen: A fast spo-based graph rewriting tool. In A. Corradini, H. Ehrig, U. Montanari, L. Ribeiro, and G. Rozenberg, editors, Graph Transformations ICGT 2006, Lecture Notes in Computer Science, pages 383 – 397. Springer, 2006. Natal, Brasil. [14] Rakesh Ghiya, Daniel Lavery, and David Sehr. On the importance of points-to analysis and other memory disambiguation methods for C programs. Proceedings of the ACM SIGPLAN 2001 conference on Programming language design and implementation, pages 47–58, 2001. [15] Enno Hofmann. Regelerzeugung zur maschinenabhängigen Codeoptimierung. Master’s thesis, Universität Karlsruhe, November 2004. [16] Intel Corporation. Intel application notes. http://developer.intel.com. [17] Intel Corporation. Block-Matching In Motion Estimation Algorithms Using Streaming SIMD Extensions 3. Intel Application Notes, december 2003. [18] Intel Corporation, O. Box 5937, Denver, CO 80217-9808. IA-32 Intel Architecture Software Developer’s Manual – Instruction Set Reference, 2005. [19] Hannes Jakschitsch. Befehlsauswahl auf SSA-Graphen. Master’s thesis, Universität Karlsruhe, November 2004. [20] Ken Kennedy and Randy Allen. Optimizing Compilers for Modern Architectures: A Dependence-based Approach. Morgan Kaufmann, 2001. [21] Götz Lindenmaier. libfirm – a library for compiler optimization research implementing firm. Technical Report 2002-5, Universität Karlsruhe, Fakultät für Informatik, Sep 2002. [22] Christoph Mallon. If-Konversion auf SSA, 3 2007. [23] Robert Metzger and Zhaofang Wen. Automatic algorithm recognition and replacement: a new approach to program optimization. MIT Press, Cambridge, MA, USA, 2000. [24] Ivan Pryanishnikov, Andreas Krall, and Nigel Horspool. Pointer Alignment Analysis for Processors with SIMD Instructions. 5th Workshop on Media and Streaming Processors, 2003. [25] Ivan Pryanishnikov, Andreas Krall, and Nigel Horspool. Compiler optimizations for processors with SIMD instructions. Softw. Pract. Exper., 37(1):93–113, 2007. [26] G. Rozenberg, editor. Handbook of Graph Grammars and Computing by Graph Transformation. World Scientific, 1999. Literaturverzeichnis 77 [27] Martin Trapp, Götz Lindenmaier, and Boris Boesler. Documentation of the intermediate representation FIRM. Technical Report 1999-14, Universität Karlsruhe, Fakultät für Informatik, Dec 1999. [28] Christian Würdig. Entwurf und Implementierung eines SSA-basierten x86Backends, September 2006. 78 Literaturverzeichnis Danksagung Mein Dank gilt allen Mitgliedern des Instituts für Programmstrukturen und Datenorganisation, Prof. G. Goos, die mir während der Anfertigung dieser Diplomarbeit mit Rat und Tat beiseite gestanden sind. Mein besonderer Dank gilt meinem Betreuer Rubino Geiß, Michael Beck und meinen Kommilitonen Christoph Mallon, Veit Batz, Moritz Kroll, Edgar Jakumeit, Matthias Braun und Christian Würdig für die stets angenehme Atmosphäre, viele fruchtbare Gespräche, tatkräftige Hilfe und wertvolle Kritik. Außerdem bedanke ich mich bei meinen Eltern, die mich während meines Studiums immer voll unterstützt haben. Nicht zuletzt gilt mein Dank Marijana dafür, dass sie da ist, für ihre Liebe und ihre mentale Unterstützung. 79