Karlsruhe, den 30.03.2007. Roman Kennke

Ähnliche Dokumente
Diplomarbeit Antrittsvortrag

Kompilieren und Linken

J.5 Die Java Virtual Machine

Lineargleichungssysteme: Additions-/ Subtraktionsverfahren

4D Server v12 64-bit Version BETA VERSION

In 15 einfachen Schritten zum mobilen PC mit Paragon Drive Copy 10 und Microsoft Windows Virtual PC

In 12 Schritten zum mobilen PC mit Paragon Drive Copy 11 und Microsoft Windows Virtual PC

Es sollte die MS-DOS Eingabeaufforderung starten. Geben Sie nun den Befehl javac ein.

Grundlagen von Python

Übung: Verwendung von Java-Threads

Schritt-Schritt-Anleitung zum mobilen PC mit Paragon Drive Copy 10 und VMware Player

Anleitung über den Umgang mit Schildern

Grundlagen verteilter Systeme

In 15 Schritten zum mobilen PC mit Paragon Drive Copy 14 und VMware Player

Die Programmiersprache Java. Dr. Wolfgang Süß Thorsten Schlachter

In 15 Schritten zum mobilen PC mit Paragon Drive Copy 11 und VMware Player

Systeme 1. Kapitel 6. Nebenläufigkeit und wechselseitiger Ausschluss

Arbeiten mit UMLed und Delphi

Java Virtual Machine (JVM) Bytecode

mywms Vorlage Seite 1/5 mywms Datenhaltung von Haug Bürger

! " # $ " % & Nicki Wruck worldwidewruck

10 Erweiterung und Portierung

Online Newsletter III

Installation der SAS Foundation Software auf Windows

Informationen zur Verwendung von Visual Studio und cmake

Einrichtung des Cisco VPN Clients (IPSEC) in Windows7

L10N-Manager 3. Netzwerktreffen der Hochschulübersetzer/i nnen Mannheim 10. Mai 2016

Programmierung für Mathematik (HS13)

DOKUMENTATION VOGELZUCHT 2015 PLUS

schnell und portofrei erhältlich bei beck-shop.de DIE FACHBUCHHANDLUNG mitp/bhv

Java Entwicklung für Embedded Devices Best & Worst Practices!

Outlook. sysplus.ch outlook - mail-grundlagen Seite 1/8. Mail-Grundlagen. Posteingang

PHPNuke Quick & Dirty

Inhalt. 1 Einleitung AUTOMATISCHE DATENSICHERUNG AUF EINEN CLOUDSPEICHER

Software Engineering Klassendiagramme Assoziationen

Diplomarbeit. Konzeption und Implementierung einer automatisierten Testumgebung. Thomas Wehrspann. 10. Dezember 2008

Einführung in PHP. (mit Aufgaben)

Objektorientierte Programmierung für Anfänger am Beispiel PHP

Leitfaden zur ersten Nutzung der R FOM Portable-Version für Windows (Version 1.0)

ObjectBridge Java Edition

TTS - TinyTimeSystem. Unterrichtsprojekt BIBI

icloud nicht neu, aber doch irgendwie anders

Prozessbewertung und -verbesserung nach ITIL im Kontext des betrieblichen Informationsmanagements. von Stephanie Wilke am

Projektmanagement in der Spieleentwicklung

CADEMIA: Einrichtung Ihres Computers unter Linux mit Oracle-Java

C++ mit Eclipse & GCC unter Windows

Speicher in der Cloud

Albert HAYR Linux, IT and Open Source Expert and Solution Architect. Open Source professionell einsetzen

Programmieren I. Kapitel 15. Ein und Ausgabe

Qt-Projekte mit Visual Studio 2005

Lizenzierung von System Center 2012

1 topologisches Sortieren

Handbuch ECDL 2003 Basic Modul 5: Datenbank Grundlagen von relationalen Datenbanken

Binäre Bäume. 1. Allgemeines. 2. Funktionsweise. 2.1 Eintragen

Gruppenrichtlinien und Softwareverteilung

CADEMIA: Einrichtung Ihres Computers unter Windows

Javadoc. Programmiermethodik. Eva Zangerle Universität Innsbruck

Reporting Services und SharePoint 2010 Teil 1

BüroWARE Exchange Synchronisation Grundlagen und Voraussetzungen

Upgrade auf die Standalone Editionen von Acronis Backup & Recovery 10. Technische Informationen (White Paper)

M. Graefenhan Übungen zu C. Blatt 3. Musterlösung

Workshop: Eigenes Image ohne VMware-Programme erstellen

White Paper. Konfiguration und Verwendung des Auditlogs Winter Release

Primzahlen und RSA-Verschlüsselung

Handbuch Fischertechnik-Einzelteiltabelle V3.7.3

Datensicherung. Beschreibung der Datensicherung

CADEMIA: Einrichtung Ihres Computers unter Mac OS X

Er musste so eingerichtet werden, dass das D-Laufwerk auf das E-Laufwerk gespiegelt

Software-Engineering und Optimierungsanwendungen in der Thermodynamik

IBM Software Demos Tivoli Provisioning Manager for OS Deployment

.NET Code schützen. Projekt.NET. Version 1.0

Installation SQL- Server 2012 Single Node

Einführung zu den Übungen aus Softwareentwicklung 1

Vorlesung Objektorientierte Softwareentwicklung. Kapitel 0. Java-Überblick

Applikations-Performance in Citrix Umgebungen

Erstellung von Reports mit Anwender-Dokumentation und System-Dokumentation in der ArtemiS SUITE (ab Version 5.0)

Lokale Installation von DotNetNuke 4 ohne IIS

infach Geld FBV Ihr Weg zum finanzellen Erfolg Florian Mock

SEP 114. Design by Contract

SEPA Lastschriften. Ergänzung zur Dokumentation vom Workshop Software GmbH Siemensstr Kleve / /

Installation einer C++ Entwicklungsumgebung unter Windows --- TDM-GCC und Eclipse installieren

40-Tage-Wunder- Kurs. Umarme, was Du nicht ändern kannst.

Kurzanleitung zu. von Daniel Jettka

Objektorientierte Programmierung. Kapitel 12: Interfaces

AGROPLUS Buchhaltung. Daten-Server und Sicherheitskopie. Version vom b

Programmieren. 10. Tutorium 4./ 5. Übungsblatt Referenzen

Patch Management mit

ICS-Addin. Benutzerhandbuch. Version: 1.0

Erfolgreiche Webseiten: Zur Notwendigkeit die eigene(n) Zielgruppe(n) zu kennen und zu verstehen!

0. Einführung. C und C++ (CPP)

Vermeiden Sie es sich bei einer deutlich erfahreneren Person "dranzuhängen", Sie sind persönlich verantwortlich für Ihren Lernerfolg.

Agile Vorgehensmodelle in der Softwareentwicklung: Scrum

Suche schlecht beschriftete Bilder mit Eigenen Abfragen

MORE Profile. Pass- und Lizenzverwaltungssystem. Stand: MORE Projects GmbH

GITS Steckbriefe Tutorial

Windows Server 2012 RC2 konfigurieren

Sie werden sehen, dass Sie für uns nur noch den direkten PDF-Export benötigen. Warum?

Visual Basic Express Debugging

Beschreibung und Bedienungsanleitung. Inhaltsverzeichnis: Abbildungsverzeichnis: Werkzeug für verschlüsselte bpks. Dipl.-Ing.

Transkript:

1 Hiermit erkläre ich, die vorliegende Diplomarbeit selbständig und ohne unzulässige fremde Hilfe angefertigt zu haben. Außer den angegebenen Quellen habe ich keine weiteren Hilfsmittel verwendet. Karlsruhe, den 30.03.2007 Roman Kennke

Inhaltsverzeichnis 1 Einleitung 1 1.1 Motivation............................. 1 1.2 Aufgabenstellung......................... 3 1.3 Zielsetzung............................ 3 2 Technische Grundlagen 4 2.1 Funktionsweise der Jamaica VM................. 4 2.1.1 Bytecode Interpreter................... 4 2.1.2 Runtime.......................... 7 2.1.3 Memory Management................... 8 2.1.4 Class Libraries...................... 12 2.1.5 Aufbau des Java-Stacks in der JamaicaVM....... 12 2.2 Der Jamaica Builder....................... 13 2.3 Verwendete Software....................... 15 2.3.1 Verwendete Compiler................... 15 2.3.2 Benchmarks........................ 17 2.3.3 Die Dacapo Benchmark Suite.............. 18 2.3.4 Versionierung....................... 21 3 Lösungsmöglichkeiten 22 3.1 Code Review........................... 22 3.2 Gezielte Optimierung der Bytecode-Handler.......... 22 3.3 Threaded Dispatching...................... 23 3.4 Direct Dispatching........................ 24 3.5 Top-Of-Stack Caching...................... 24 3.6 Superinstructions......................... 25 3.7 Einsatz eines VM-Interpreter-Generators............ 26 3.8 Just In Time Compilation.................... 27 2

INHALTSVERZEICHNIS 3 4 Umsetzung 29 4.1 Initiale Performanz-Analyse................... 29 4.1.1 Ausgangssituation.................... 29 4.1.2 Analyse der Performanzprobleme............ 30 4.2 Vorgehensweise.......................... 37 4.3 Interpreter-Generator....................... 38 4.3.1 GForth s Vmgen..................... 39 4.3.2 Format der Eingabedatei................. 40 4.3.3 Deklaration der Eingabe-Typen............. 40 4.3.4 Implementierung der Bytecode-Handler......... 41 4.3.5 Die Ausgabedateien.................... 42 4.3.6 Implementierung eines Interpreters mithilfe des Generators........................... 44 4.4 Stackzugriff............................ 46 4.4.1 Konsolidierung der Stack-Zugriffs-Makros........ 47 4.4.2 Minimierung der Zugriffe auf den cont locals Stack.. 51 4.4.3 Aufbau des Stackframes................. 54 4.5 Threaded Dispatching...................... 54 4.5.1 Das Problem des while-switch-dispatchers....... 55 4.5.2 Die Idee des Threaded-Dispatchers........... 57 4.5.3 Implementierung des Threaded Dispatching...... 59 4.5.4 Mögliche Ansätze für nicht GCC-Plattformen..... 62 4.6 Superinstructions......................... 69 4.6.1 Funktionsweise von Superinstructions.......... 70 4.6.2 Auswahl von Superinstructions............. 71 4.6.3 Implementierung..................... 72 4.6.4 Mögliche Probleme.................... 76 5 Ergebnisse 78 5.1 Auswertung der verschiedenen Technologien.......... 78 5.1.1 Optimierung der Stack-Zugriffe............. 79 5.1.2 Threaded Dispatching.................. 79 5.1.3 Superinstructions..................... 80 5.1.4 Zusammenfassung..................... 80 6 Ausblick 83 6.1 Threading für nicht GCC Compiler............... 83 6.2 Eliminierung des Java Stacks.................. 84 6.3 Instruction Prefetching...................... 84 6.4 Reduzierung von Sync-Points.................. 84 6.5 Direct Dispatching........................ 86

INHALTSVERZEICHNIS 4 6.6 Superinstructions......................... 87

Abbildungsverzeichnis 2.1 Schematischer Aufbau einer Java VM.............. 5 2.2 Die Datenquellen der Interpreter Schleife............ 6 2.3 Thread Problematik bei Realtime GC.............. 8 2.4 Obergrenze für Garbage Collection Arbeit........... 9 2.5 Java Objekte als verlinkte Liste................. 10 2.6 Java Arrays als Baumstruktur.................. 11 2.7 Realtime Threads......................... 12 2.8 Grundlegende Stack Struktur.................. 13 2.9 Aufbau eines Stack-Frames.................... 14 2.10 Schematische Funktionsweise des Jamaica Builders...... 15 2.11 Schematische Funktionsweise des Jamaica Compiler...... 16 4.1 Performanz-Vergleich Jamaica Interpreter........... 30 4.2 Referenz-Sicherung auf dem Stack................ 52 4.3 Vereinfachung der Referenz-Sicherung.............. 53 4.4 Problem mit areturn bei der Referenz-Sicherung........ 53 4.5 Schematischer Ablauf beim While-Switch-Dispatching..... 56 4.6 Schematischer Ablauf beim Threaded-Dispatching....... 58 5.1 Vergleich der Optimierungstechniken.............. 82 5.2 Vergleich der Jamaica Versionen................. 82 5

Tabellenverzeichnis 2.1 Zielsysteme und deren C Compiler............... 17 2.2 Populäre Java VM Benchmarks................. 18 2.3 Die Dacapo Benchmarks..................... 19 4.1 Performanz von Jamaica 3.0 und Jamaica 3.1beta zum Projektbeginn............................. 30 4.2 Häufigkeit von Bytecodes..................... 33 4.3 Absolute Performanz von Bytecodehandlern.......... 35 4.4 Relative Performanz von Bytecodehandlern........... 36 4.5 Superinstruction Statistik für 2-Bytecode-Sequenzen...... 73 4.6 Superinstruction Statistik für 3-Bytecode-Sequenzen...... 74 4.7 Superinstruction Statistik für 4-Bytecode-Sequenzen...... 75 5.1 Performanz mit und ohne Stack Optimierung.......... 79 5.2 Performanz mit und ohne Threaded Dispatching........ 80 5.3 Performanz mit und ohne Superinstructions.......... 81 6.1 Performance mit Syncpoints................... 85 6.2 Performance ohne Syncpoints.................. 85 6

Listings 2.1.1 Ein typische Interpreterschleife................. 7 4.1.1 Implementierung der Bytecodestatistik............. 31 4.4.1 Aufbau eines Stackframes beim Methodenaufruf........ 54 4.5.1 Ein while-switch-dispatcher................... 55 4.5.2 Ein Threaded-Dispatcher..................... 57 4.5.3 Vmgen Quellcode des iadd Handlers............... 59 4.5.4 Generierter Code des iadd Handlers............... 60 4.5.5 Implementierung des NEXT P2 Dispatch-Makros für Threaded Dispatching.......................... 61 4.5.6 Anlegen der Sprungtabelle für Threaded Dispatching..... 62 4.5.7 Threaded Dispatching mit Tail Recursion............ 63 4.5.8 Add Funktion, compiliert nach x86 von GCC.......... 64 4.5.9 Add Funktion, compiliert nach x86 von Microsoft CC..... 65 4.5.10Add Funktion, compiliert nach Arm von Microsoft CC.... 66 4.5.11Add Funktion, compiliert nach Arm von Ultra-C........ 67 7

Zusammenfassung Die JamaicaVM ist eine virtuelle Maschine für die Ausführung von Java Programmen auf eingebetteten und Echtzeit-Systemen. Java Programme liegen normaler als Java Bytecode vor, eine Art Maschinencode, der aber nicht durch den Mikroprozessor des Computers ausgeführt wird, sondern durch eine sogenannte virtualle Maschine. Dies ist ein Programm, das den Bytecode interpretiert und entsprechende Maschineninstruktionen durchführt. Ziel der Diplomarbeit soll es sein, die Ausführungsgeschwindigkeit der JamaicaVM zu verbessern. Die JamaicaVM unterstützt zwei Ausführungsmodi. Zum einen kann sie Java Bytecode precompilieren in Maschinencode der jeweiligen Zielplattform. Alternativ kann sie Java Bytecode interpretieren, d.h. zur Laufzeit die Bytecodes einlesen und entsprechend der Semantik der jeweiligen Bytecode-Instruktionen bestimmte Funktionen aufrufen. Im Rahmen dieser Diplomarbeit soll vor allem die Ausführungsgeschwindigkeit des Interpreters der JamaicaVM verbessert werden. In dieser Diplomarbeit soll zunächst eine systematische Analyse der Performanzprobleme des Interpreters erfolgen. Dazu gehören Benchmarks, statistische Auswertungen (z.b. welche Bytecodes werden wie häufig ausgeführt, wieviele CPU Zyklen werden je Bytecode benötigt etc), sowie Code-Analyse. Aufbauend auf den Ergebnissen dieser Analyse sollen Verbesserungen implementiert werden. Die Analyse und Implementierung sollen sich hauptsächlich auf den Interpreter beschränken. Der Garbage-Collector, Builder und andere Komponenten sollen im Rahmen dieser Diplomarbeit nicht betrachtet werden.

Kapitel 1 Einleitung Thema dieser Diplomarbeit ist Performanzoptimierung der JamaicaVM. In diesem Kapitel soll zunächst einmal erläutert werden, was die JamaicaVM ist und warum die Performanz verbessert werden soll. Weiterhin soll ein Rahmen gesteckt werden, was genau bei der Optimierung betrachtet werden soll und was das zu erreichende Ziel ist. 1.1 Motivation Die JamaicaVM ist eine echtzeitfähige Java VM. Sie ist für den Einsatz in eingebetteten Umgebungen konzipiert, wie z.b. Maschinensteuerungen. Eines der zentralen Konzepte der JamaicaVM ist, daß Java Bytecode vor dem Deployment in nativen, zielplattformspezifischen Maschinencode compiliert wird (AOT, Ahead-Of-Time Compilation, im Gegensatz zum üblichen JIT, Just-In-Time Compilation). Dies ermöglicht gute Performanz bei gleichzeitiger Erhaltung der Echtzeitfähigkeit (was bei einem JIT naturgemäß schwierig bis unmöglich ist). Im Gegensatz zur Compilierung des Bytecodes in Maschinencode (Ahead- Of-Time oder Just-In-Time) steht die reine Interpretation des Bytecode. Bei der Interpretation werden die Bytecode-Instruktionen von einem Programm, dem sogenannten Interpreter, ausgelesen und für jede Instruktion eine entsprechende Funktion aufgerufen, die die Semantik der jeweiligen Bytecode- Instruktion implementiert. Obwohl bei der JamaicaVM im Allgemeinen Java Bytecode nach Maschinencode compiliert wird, ist trotzdem ein Bytecode Interpreter notwendig. Dafür gibt es eine Reihe von Gründen: Typischerweise wird nicht eine komplette Anwendung in Nativecode compiliert. Dafür gibt es verschiedene Gründe. Zum einen ist Native- 1

KAPITEL 1. EINLEITUNG 2 code deutlich speicherintensiver als Java Bytecode. Schliesslich wurden die JVM Instruktionen als speicherplatzsparend designed, hauptsächlich mit Blick auf die Verwendung als Applet und im Internet im Allgemeinen. Zum anderen hat sich herausgestellt, daß Anwendungen bessere Performanz zeigen, wenn sie nur teilweise übersetzt werden. Die Ursache dafür ist hauptsächlich im verbesserten Inlining zu suchen, aber die Codegröße scheint auch direkt relevant zu sein im Hinblick auf das Prozessorcache und Pipeline Verhalten. Daher werden Anwendungen typischerweise nur teilweise übersetzt (circa 20%-30%), und der Rest wird als Java Bytecode in die ausführbare Binärdatei eingebettet, und auf dem Zielsystem von einem Bytecode Interpreter ausgeführt. Das Übersetzen von Java Bytecode in Maschinencode dauert im Allgemeinen sehr lange. Es ist deutlich bequemer in der Entwicklungsphase Java Bytecode direkt ausführen zu können. In Jamaica gibt es verschiedene Executables die zum Ausführen von Bytecode direkt verwendet werden können, z.b. jamaicavm_slim und jamaicavm. Zum Debuggen von Bytecode muss der Bytecode ebenfalls direkt ausgeführt werden. In Jamaica existieren 2 Methoden zum Debuggen. Zum einen gibt es eine Debug-VM (das Executable jamaicavmdb). Diese führt sehr viele zusätzliche Überprüfungen im Code aus und kann optional eine Menge Debug Ausgaben erzeugen. Dies gibt oftmals einen Hinweis auf mögliche Fehlerursachen. Andererseits gibt es noch die Möglichkeit, einen normalen Java-Debugger (z.b. jdb von Sun s JDK oder den eingebauten Debugger in Eclipse) zu verwenden. Beide Debugger führen Bytecode interpretiert aus und benötigen daher einen Bytecode Interpreter. Potentielle Kunden versuchen im Allgemeinen immer zuerst ihr Programm mal eben auszuprobieren. Dazu bietet sich selbstverständlich der Interpreter an, da dieser genauso gestartet wird, wie die VM von Sun. (Z.B. java -jar App.jar wird zu jamaicavm -jar App.jar). Es ist daher wichtig, daß die Anwendung dann halbwegs flüssig läuft, ansonsten bekommt der Kunde einen schlechten ersten Eindruck von Jamaica, der oftmals entscheidend für den Gesamteindruck ist, auch wenn es sicht im Produktionsbetrieb ganz anders darstellen würde, da die Anwendung dann kompiliert wäre. Selbst für teilweise compilierte Programme ist eine verbesserte Interpreterperformanz interessant, da eben viele Teile nicht compiliert werden.

KAPITEL 1. EINLEITUNG 3 Es kann auch vorkommen, daß selbst performanzkritische Programmteile nicht compiliert werden. Beispielsweise kann es passieren, daß beim Erstellen eines Profils bestimmte Routinen nicht verwendet wurden, die aber später im Produktionsbetrieb doch aufgerufen werden, oder daß Programmteile dynamisch nachgeladen werden (also zum Zeitpunkt der Profil-Erstellung noch unbekannt sind). Eine gute Performanz des Jamaica Interpreters ist also trotz Ahead- Of-Time Compilierung nicht unwichtig. Insbesondere die Möglichkeit, Programmmodule dynamisch hinzuladen zu können macht den Einsatz von Java attraktiv, aber gleichzeitig auch schwierig für Jamaica. Ohne eine gute Performanz des Interpreters ist dies nur sehr unpraktikabel. 1.2 Aufgabenstellung Im Rahmen dieser Diplomarbeit soll die Performanz des Interpreters der JamaicaVM signifikant verbessert werden. Das bedeutet, daß die Ausführungsgeschwindigkeit von Java Programmen (JVM Bytecode) messbar schneller wird. Dazu gehören als Teilaufgabe eine Analyse und Einordung der bestehenden Performanzprobleme, Erarbeitung von möglichen Vorgehensweisen zur Verbesserung der Performanz, die Auswahl und schliesslich die Umsetzung geeigneter Techniken. Es soll dabei im Wesentlichen nur der Interpreter betrachtet werden. Performanzprobleme in anderen Teilen der VM (z.b. im Garbage Collector oder im compilierten Code) sind zwar interessant zu betrachten, würden aber den Rahmen dieser Diplomarbeit sprengen. Besonderes Augenmerk sollte auch auf speziellere Zielsysteme mit besonderen Eigenheiten (z.b. RISC Prozessor, besonders kleiner Prozessor-Cache u.ä.) gelegt werden. 1.3 Zielsetzung Das Ziel dieser Diplomarbeit soll sein, die Interpreterperformanz um ca. 20-30 % zu steigern. Dabei soll es um möglichst realitätsnahe Anwendungen gehen, und nicht um spezielle Benchmarks, die besondere Effekte besonders hervorheben. Eine geeignete Methode zur Performanz-Bestimmung muss also im Vorfeld erarbeitet werden. Die Performanzmessungen sollen sich allerdings auf rein interpretierten Code beziehen, es wird daher im Rahmen dieser Diplomarbeit nicht die Ahead-Of-Time Compiler Funktionalität der JamaicaVM verwendet werden.

Kapitel 2 Technische Grundlagen Ein grundlegendes Verständnis des Aufbaus einer Java VM im Allgemeinen und der JamaicaVM im Speziellen ist Vorraussetzung für eine erfolgreiche Optimierung des Interpreters. Weiterhin müssen gewisse technische Randbedingungen und die verwendete Software betrachtet werden. Dieses Kapitel widmet sich daher ganz der Erläuterung aller technischen Grundlagen. 2.1 Funktionsweise der Jamaica VM Im Folgenden soll kurz erläutert werden, wie eine Java VM im Allgemeinen und die Jamaica VM im speziellen ungefähr funktioniert. Das Verständnis für die Funktionsweise ist wichtig um das Vorgehen bei der Performanzoptimierung zu verstehen. Ich werde nur den Interpreter genauer erläutern, da es im Rahmen dieser Diplomarbeit nur um den Interpreter geht. Ich werde die anderen Teile der VM nur kurz anschneiden. Eine Java VM besteht im Wesentlichen aus vier Komponenten, dem Bytecode Interpreter, der Memory Management Einheit (Garbage Collector), der Runtime und den Class Libraries. Der Interpreter bildet dabei den ausführenden Kern der VM, der Garbage Collector ist für die Speicherverwaltung zuständig, die Runtime bildet die Schnittstelle zur Native Plattform und die Class Libraries die Schnittstelle zur Applikation (API). Abbildung 2.1 veranschaulicht diesen Aufbau. 2.1.1 Bytecode Interpreter Der Bytecode Interpreter ist mehr oder weniger der Kern der VM. Er ist zuständig für die Interpretation des Programm Bytecode Streams, und die 4

KAPITEL 2. TECHNISCHE GRUNDLAGEN 5 Ausführung der dazugehörigen Operationen. Der Interpreter übernimmt sozusagen die Rolle des Prozessors für den Bytecode. Datenquellen Der Interpreter verarbeitet Daten aus verschiedenen Quellen. Da wäre zunächst der Bytecode-Stream. Von diesem werden die Bytecode Instruktionen ausgelesen, die letztendlich interpretiert werden. Weiterhin sind für viele Instruktionen feste Parameter enthalten, z.b. ein Wert, der auf den Stack geladen werden soll, oder eine Sprungadresse. Bytecode- Streams sind grundsätzlich nur lesbar, es werden nur Bytecodes und Parameter gelesen, aber nicht geschrieben. Es existiert ein Bytecode-Stream für jede Methode jeder geladenen Klasse. Alle Bytecode-Streams werden auf dem Java-Heap abgelegt, entweder in der Form eines sogenannten contiguous array (zusammenhängender Speicher), oder in Form eines Java array, eine Datenstruktur, die möglicherweise nicht zusammenhängend ist, sondern in einer Baumstruktur vorliegt. Das letztere ist - kurz gesagt - ein Feature der Speicherverwaltung um Echtzeitfähigkeit garantieren zu können. Dazu mehr im Abschnitt Speicherverwaltung. Die andere Datenquelle, die relevant für den Interpreter ist, ist der sogenannte Stack. Der Stack ist ein Datenspeicher, auf dem Operanden, lokale Variablen und Metadaten zu Methodenaufrufen (Frames) abgelegt werden. Jeder Java Thread besitzt genau einen Stack. Die Stacks selbst sind Datenstrukturen, die auf dem Java Heap liegen und von der Speicherverwaltung verwaltet werden. Der Interpreter greift sowohl lesend als auch schreibend auf den Stack zu. Class Libraries JNI Runtime Interpreter Memory Management Native Platform Abbildung 2.1: Schematischer Aufbau einer Java VM

KAPITEL 2. TECHNISCHE GRUNDLAGEN 6 Dispatching Der Dispatcher ist der Teil des Interpreters, der die Bytecode-Instruktionen aus dem Stream liest und die dazugehörigen Bytecode Handler aufruft. Ein Bytecode Handler ist eine Funktion, die die Funktionalität des dazugehörigen Bytecodes implementiert. Naive Implementierungen verwenden zum Beispiel eine Schleife mit einem Switch-Statement wie im folgenden Code-Fragment: Der Dispatcher ist kritisch für gute Performanz, da der Dispatch Code für jede einzelne Bytecode Instruktion ausgeführt wird. Es ist daher wichtig, den Dispatch Overhead so klein wie möglich zu halten. Der obige Ansatz z.b. bereitet einige Probleme wenn man gute Performanz benötigt. Teil der Diplomarbeit wird es sein, diese Probleme zu untersuchen und Lösungsansätze dafür zu entwerfen. Bytecode Handler Die Bytecode Handler schliesslich implementieren die Logik für jede Bytecode Instruktion. Ein Bytecode Handler ist im wesentlichen eine Funktion, die die Semantik des jeweiligen Opcodes implementiert. Eine mögliche Implementierung für den Java Opcode iadd könnte z.b. so aussehen: Interpreter Loop pop push read Bytecode Stream Stacks Abbildung 2.2: Die Datenquellen der Interpreter Schleife

KAPITEL 2. TECHNISCHE GRUNDLAGEN 7 void do bc iadd ( ) { i n t 3 2 v1, v2, r ; v2 = stack[ stackpointer ] ; v1 = stack[ stackpointer ] ; r = v1 + v2 ; stack [ stackpointer ] = r ; } Selbstverständlich ist es für die Performanz des Interpreters extrem wichtig, wie effizient die einzelnen Bytecode Handler implementiert sind. Dies zu untersuchen und gegebenenfalls zu verbessern wird ebenfalls Teil dieser Diplomarbeit sein. 2.1.2 Runtime Als Runtime werden unterstützende Funktionen bezeichnet, die von den Bytecodehandlern aufgerufen werden, um bestimmte komplexere Funktionalität zu implementieren. Dazu gehören z.b. das Erzeugen von Objekten, das Laden von Klassen, das Werfen von Exceptions und vieles mehr. Insbesondere bildet die Runtime eine Schnittstelle zur darunterliegenden Hardware- und Betriebssystem-Plattform. Ein weiterer wichtiger Teil der Runtime bilden die verschiedenen Schnittstellen nach außen. Da wären insbesondere JNI zu nennen - das Java Native Interface -, das es ermöglicht Java Methoden in nicht-java Programmiersprachen zu implementieren (hauptsächlich C und while ( pc >= 0) { switch ( bytecodes++) { case op1 : // Bytecode Handler f ü r op1. break ; case op2 : // Bytecode Handler f ü r op2. break ;... case opn : // Bytecode Handler f ü r opn. break ; } } Listing 2.1.1: Ein typische Interpreterschleife

KAPITEL 2. TECHNISCHE GRUNDLAGEN 8 C++). Weiterhin wären da die Debug und Toolschnittstellen JVM/TI (Java VM Tool Interface), JVM/DI (Java VM Debug Interface, ein Subset des JV- M/TI), sowie JDWP (Java Debug Wire Protocol). Die Runtime Funktionen stehen nicht im Mittelpunkt dieser Diplomarbeit, sind aber vermutlich an manchen Stellen peripher von verschiedenen Anpassungen betroffen. 2.1.3 Memory Management Der Speicherverwaltung kommt bei Java eine zentrale Rolle zu. Eine der grundlegenden Eigenschaften von Java ist es, daß Entwickler von Java Programmen sich nicht explizit um die Freigabe von Objekten auf dem Java Heap kümmern müssen. Stattdessen sorgt ein sogenannter Garbage Collector für das Freigeben von Objekten, die nicht mehr referenziert werden. Weiterhin existieren Funktionen zur Allokation von Objekten. Diese bilden allerdings keine separate Einheit sondern sind Teil des Garbage Collectors. Daher implementiert der Garbage Collector im weiteren Sinne die Speicherverwaltung einer Java VM. Die folgenden Informationen finden sich in detaillierter Form in [10]. Eine wesentliche Eigenschaft des JamaicaVM Garbage Collector (im folgenden GC genannt) ist seine Echtzeitfähigkeit. Das größte Problem bei herkömmlichen GCs bezüglich Echtzeitfähigkeit ist, daß sie in einem separaten Thread ausgeführt werden. Sie müssen daher in regelmäßigen Abständen aufgerufen werden um unbenutzte Objekte einzusammeln. Dies ist nötig, damit die Anwendung Speicher allozieren kann. Allerdings ist diese Garbage Collection Arbeit nicht vorhersehbar, sie kann mehr oder weniger unvorhersehbar jeden anderen Task unterbrechen. Abbildung 2.3 veranschaulicht dieses Problem. Abbildung 2.3: Thread Problematik bei Realtime GC Ein weiteres Problem ist die Handhabung von unterschiedlich großen Objekten. Es kann keine Garantie bezüglich der Ausführungszeit gegeben wer-

KAPITEL 2. TECHNISCHE GRUNDLAGEN 9 den, wenn die Einheiten, mit denen der Garbage Collector umgeht verschieden groß sind. Um diese Probleme zu lösen implementiert der JamaicaVM Garbage Collector verschiedene Techniken: Garbage Collection im Anwendungsthread Die Garbage Collection Arbeit wird beim JamaicaVM GC nicht wie bei den meisten VMs üblich in einem separaten Thread erledigt, sondern direkt im jeweiligen Anwendungsthread. Genauer gesagt, wird Garbage Collection angestoßen, wenn immer Objekte alloziert werden. Dabei wird jeweils eine genau vorgeschriebene Menge Garbage Collection Arbeit durchgeführt. Die genaue Menge an Garbage Collection Arbeit ist abhängig von der Auslastung des Heap Speichers. Je voller der Heap ist, desto mehr Garbage Collection Arbeit wird pro Allokation durchgeführt. Die Obergrenze der Garbage Collection Arbeit ist durch eine Funktion implementiert, die in Abbildung 2.4 dargestellt ist. Abbildung 2.4: Obergrenze für Garbage Collection Arbeit Auf diese Weise wird sichergestellt, daß: 1. Die Anwendungsthreads nicht durch GC Threads unterbrochen werden.

KAPITEL 2. TECHNISCHE GRUNDLAGEN 10 2. Die Worst Case Execution Time für Allokationen vorhersehbar (und nicht zu hoch) ist. Aufbrechen von Objektstrukturen in Blöcke In den meisten Java VMs werden Java-Objekte durch zusammenhängenden Speicher repräsentiert, der groß genug ist, um die Felder des Objektes und einige Meta-Daten unterzubringen. Das Problem mit diesem Ansatz ist, daß die Ausführungszeit des Garbage Collectors nicht 100% deterministisch ist. Selbst wenn er inkrementell arbeitet (wie der Jamaica GC) kann es passieren, daß besonders große Objekte den GC unvorhersehbar aufhalten. Das Problem wird verstärkt wenn der GC sogenannte Compaction ausführen muss, um Heap-Fragmentierung vorzubeugen oder zu beseitigen. Dann müssen Objekte relokiert werden, was ein relativ aufwendiger Prozess ist, vor allem, weil alle Referenzen auf ein solches Objekt erneuert werden müssen. Aus diesem Grund werden Objekte in der JamaicaVM nicht als zusammenhängende Speicherbereiche repräsentiert, sondern als eine Datenstruktur aus sogenannten Blöcken. Diese Blöcke haben eine vorgegebene feste Größe (z.b. 32 Wörter). Damit kann der Heap als ein Speicherbereich betrachtet werden, der aus lauter gleich großen Objekten besteht. Fragmentierung und Compaction spielen damit keine Rolle mehr. Diese spezielle Repräsentation bringt natürlich einen gewissen Overhead mit sich, sowohl vom Speicherverbrauch her, als auch von der Verarbeitungszeit. Dafür bekommt man allerdings garantiertes Echtzeitverhalten für normale Java Anwendungen. Es gibt zwei verschiedene Repräsentationen von Objekten. Auf der einen Seite wären das Java-Arrays, und auf der anderen Seite normale Java Objekte. Java Objekte werden als verlinkte Liste von Blöcken repräsentiert (Abbildung 2.5, während Arrays als Baumstrukture dargestellt werden (Abbildung 2.6). Abbildung 2.5: Java Objekte als verlinkte Liste

KAPITEL 2. TECHNISCHE GRUNDLAGEN 11 Implementierung der RTSJ Spezifikation Um Echtzeitfähigkeit auch in herkömmlicheren VMs anbieten zu können, hat Sun eine Erweiterung entwickelt, die letztendlich in den ersten JSR und damit in die Realtime Specification for Java [9] gemündet ist. Im Wesentlichen ist dies eine Erweiterung der API Spezifikation, um Realtime Features bereitzustellen. Das Garbage Collection Problem wird hier völlig anders gelöst. Kurz gesagt ist es mit RTSJ möglich, spezielle Heaps bereitzustellen, die nicht unter der Kontrolle des Garbage Collectors stehen. Das bedeutet, daß man Objekte auf diesen Heaps manuell verwalten muss. Um die Unterbrechbarkeit von Anwendungsthreads durch den GC in den Griff zu bekommen, besteht die Möglichkeit, sogenannte Realtime Threads zu verwenden. Diese können grundsätzlich nicht durch den Garbage Collector unterbrochen werden (siehe Abbildung 2.7). Mithilfe dieser Techniken kann man auch mit herkömmlichen VMs Echtzeitfähigkeit implementieren. Allerdings wird hierbei erheblicher Aufwand auf der Seite des Entwicklers erwartet. Obwohl die JamaicaVM andere bessere Wege zur Echtzeitfähigkeit anbietet, ist es selbstverständlich auch möglich, RTSJ zu verwenden. RTSJ ist ein Teil der API der JamaicaVM. Abbildung 2.6: Java Arrays als Baumstruktur

KAPITEL 2. TECHNISCHE GRUNDLAGEN 12 2.1.4 Class Libraries Als Klassenbibliothek verwendet die JamaicaVM zum größtenteil den Code von GNU Classpath [6]. Ausnahmen sind die Pakete java.lang.***, für welches in JamaicaVM eine komplett separate Implementierung existiert, die Realtime Erweiterung RTSJ (javax.realtime) sowie Plattform spezifische Implementierungsklassen, z.b. für die AWT Backends. Viele Klassen der Klassenbibliothek stellen Schnittstellen zu Funktionen des Betriebssystems dar, vor allem die Klassen in den Core Packages java.lang, java.io, java.net, java.nio, java.awt. Diese werden im Allgemeinen durch Native- Funktionen implementiert, die über JNI oder JBI (ein Jamaica-spezifisches Äquivalent zu JNI) aufgerufen werden. Die Class Libraries spielen im Zusammenhang mit dieser Diplomarbeit keine große Rolle und sollen daher nicht weitergehend betrachtet werden. 2.1.5 Aufbau des Java-Stacks in der JamaicaVM Eine Java Virtual Machine ist eine Stack-basierte Maschine (im Gegensatz zu Register-basierten Maschinen wie sie z.b. von vielen Prozessoren implementiert werden). Der Java Stack ist daher eine zentrale Datenstruktur in einer Java VM. Auf ihm werden lokale Variablen, Operanden für die Bytecode Instruktionen sowie zusätzliche Informationen zu Methodenaufrufen gespeichert. Es existiert pro Thread ein Java Stack. Wenn in der Folge von Stack die Rede ist, ist damit dieser Java Stack gemeint. Es gibt noch mehr Stacks in der virtuellen Maschine, insbesondere der native Stack, das ist der Aufrufstack des Betriebssystem für native Funktionen. Um die Optimierungsmöglichkeiten beim Stackzugriff zu verstehen muss zunächst einmal erläutert werden, wie der Stack aufgebaut ist und welche Besonderheiten es in der JamaicaVM gibt. Abbildung 2.7: Realtime Threads

KAPITEL 2. TECHNISCHE GRUNDLAGEN 13 Grundsätzlich ist der Stack ein Array von jamaica_int32 Wörtern. Diese sind unterteilt in eine Menge sogenannter Stack-Frames. Ein Stack-Frame ist die grundlegende Struktur auf dem Stack, alle Stack-Operationen sind letztendlich Operationen auf einem Stack-Frame. index Frame n Frame (n-1) Frame 1 ct->javastack Frame 0 Abbildung 2.8: Grundlegende Stack Struktur Pro Methodenaufruf wird ein Frame auf dem Stack erzeugt. Ein Frame bildet die grundlegende Struktur für die VM für die Abarbeitung einer Java Methode. In einem Frame werden Argumente und lokale Variablen der Methode gespeichert sowie Operanden für die Bytecode Instruktionen. Zusätzlich beherbergt ein Frame zusätzliche Informationen wie z.b. eine Referenz zu der zugehörigen Methode und zum darunterliegenden Frame. 2.2 Der Jamaica Builder Der Jamaica Builder ist neben der eigentlichen VM ein wesentlicher Bestandteil von Jamaica. Er dient dem Übersetzen von Java Bytecode nach Maschinen-Code. Im Gegensatz zu Just-In-Time Compilation wird allerdings dieser Compilierungsschritt nicht zur Laufzeit durchgeführt, sondern zur Deploy-Zeit, also wenn die Anwendung auf dem Zielsystem vorbereitet wird. Dies macht insofern Sinn, als das die Anwendung nicht wie bei normalen Desktop Anwendung auf beliebigen Computerplattformen, Betriebssystemen und virtuellen Maschinen laufen soll, sondern das genaue Zielsystem mit allen seinen Komponenten bekannt ist.

KAPITEL 2. TECHNISCHE GRUNDLAGEN 14 Der grundsätzliche Aufbau und die Funktionsweise des Jamaica Builders ist in Abbildung 2.10 illustriert. Die Java Quelldateien werden zunächst wie gewohnt mit dem JDK Java Compiler (javac) in Java Bytecode übersetzt. (Oftmals liegt ein Programm schon/nur in Bytecode vor, dann entfällt dieser Schritt). Der Java Bytecode wird dann vom eigentlichen Builder übersetzt in C Code, der in einer temporären.c Datei gespeichert wird. Dieser C Code wird dann mit einem plattformspezifischen C Compiler in eine Objektdatei compiliert, und schliesslich zusammen mit der VM Library und den Objektdateien der anderen Klassen und Packages des Programms gelinkt in ein ausführbares Executable. Die detaillierte Funktionsweise des Jamaica Compilers (der die.class Dateien in.c Dateien compiliert) ist in Abbildung 2.11 dargestellt. Der Class File Loader liesst zunächst die Klassendateien ein. Diese werden vom Static Analyser analysiert, in diesem Schritt werden viele Informationen gesammelt, die später der Static Compiler, der Bytecode Optimizer und der Smart Linker benötigen, z.b. Klassen-Hierarchie-Informationen, Field-Offsets und vieles mehr. Klassen und Methoden, die tatsächlich kompiliert werden sollen werden vom Static Compiler in C Code umgewandelt. Code, der nicht kompiliert, sondern als Java Bytecode eingebettet werden soll, wird in einem Zwischenschritt vom Bytecode Optimizer modifiziert, sodaß er später schneller ausgeführt werden kann. Der Smart Linker bringt die Code Fragmente zusammen, und vor allem entfernt er sehr viel Code, z.b. nicht benötigte Methoden und Klassen. Dies reduiziert die Code-Größe beträchtlich. Im Class Compaction Schritt wird die Code Größe weiter reduziert, indem die Abbildung 2.9: Aufbau eines Stack-Frames

KAPITEL 2. TECHNISCHE GRUNDLAGEN 15 Daten in ein weniger speicherintensives Format konvertiert werden. Z.B. werden redundante konstante Strings entfernt und vieles mehr. Der Class Writer schliesslich schreibt die resultierende C Datei, die dann vom C Compiler weiterverarbeitet wird. 2.3 Verwendete Software Eine wichtige Rolle bei der Entwicklung am Jamaica Interpreter spielt die Software, die im Rahmen dieses Projekts zur Verwendung kommen soll. Dies wird in den folgenden Abschnitten erläutert werden. Wichtig sind hier vor allem die verwendeten Compiler sowie die Benchmarks, die zur Bestimmung der Performanz verwendet werden. 2.3.1 Verwendete Compiler Die JamaicaVM unterstützt eine Reihe von Zielsystemen, z.b. Linux, Vx- Works, OS9, WindowsCE, nur um die wichtigsten zu nennen. Für die Übersetzung von JamaicaVM und von Anwendungen nach Maschinencode der jeweiligen Zielplattform kommen eine Reihe verschiedener C Compiler zum Einsatz. Die wichtigsten wären hier der GCC [4] in vielen verschiedenen Versionen (von 2.9 bis 4.1), der Microsoft C-Compiler für WindowsCE sowie der Ultra-C Compiler für OS/9. Java Quellcode.java javac Java Bytecode.class Builder Java class data.c C-Compiler Java class data.o Jamaica VM Library Linker Executable Abbildung 2.10: Schematische Funktionsweise des Jamaica Builders

KAPITEL 2. TECHNISCHE GRUNDLAGEN 16 Java Class Files.class Class file loader Static Analyser + Static GC Profiling Information Static Compiler Bytecode Optimizer Smart Linker Class Compaction Class Writer Java class Data.c Abbildung 2.11: Schematische Funktionsweise des Jamaica Compiler

KAPITEL 2. TECHNISCHE GRUNDLAGEN 17 Bei der Implementierung der Interpreter Optimierungen muss sehr darauf geachtet werden, welche C-Compiler verwendet werden und welche Features diese Unterstützen. Bisher wurde bei der Implementierung jeglicher C - Funktion streng darauf geachtet, nur ANSI-C zu verwenden. Es gibt allerdings interessante C Erweiterungen, die die Implementierung mancher Technologien vereinfachen, bzw. überhaupt erst ermöglichen. Hier muss jeweils möglichst eine ANSI-C Implementierung bevorzugt werden, oder mindestens eine alternative (möglicherweise weniger performante) ANSI-C Implementierung bereitgestellt werden, für Plattformen, die die jeweilige Erweiterung nicht unterstützt. Dies kann z.b. über bedingte Compilierung realisiert werden. Tabelle 2.1 listet die wichtigsten Zielsysteme und deren verwendete Compiler auf. Im Rahmen dieser Diplomarbeit wird wohl hauptsächlich der GCC verwendet werden, schlicht aus dem Grund, weil dieser bei Linux mitgeliefert wird und weil damit leicht gearbeitet werden kann. Die exotischeren Compiler werden nur für Zielsysteme verwendet, was bedeuten würde, daß bei jedem Test ein Programm für das Zielsystem compiliert werden muss. Zielsystem Compiler Besonderheiten Linux GCC VxWorks GCC v2.95 keine Label-Pointer WindowsCE Microsoft VC keine Label-Pointer OS9 Ultra C keine Label-Pointer Tabelle 2.1: Zielsysteme und deren C Compiler 2.3.2 Benchmarks Im allen Phasen des Projektes sollen Performanz-Messungen vorgenommen werden. In der Analysephase dient es dazu festzustellen, wo die Probleme liegen, während der Implementierungsphase zur Dokumentation des Fortschritts und der Einschätzung des Effizienz der angewendeten Optimierungen. Abschliessend soll eine weitere Analyse zum Vergleich mit dem Beginn des Projektes und zur Beurteilung des Projekterfolgs dienen. Zur Einschätzung der Performanz des Jamaica-Interpreters sollen Testprogramme mit folgenden Eigenschaften dienen: Testläufe müssen vergleichbar sein. Das heisst, das zwei Testläufe mit derselben Interpretersoftware auch die gleichen Resultate liefert. Einflüsse, die die Testresultate unvorhersehbar beeinflussen müssen vermieden werden.

KAPITEL 2. TECHNISCHE GRUNDLAGEN 18 Testprogramme müssen realistisch sein. Benchmarks, die bestimmte Eigenheiten besonders verstärken und andere vernachlässigen verzerren das Gesamtbild. Idealerweise sollten real verwendete Programme zum Einsatz kommen. Dadurch soll sichergestellt sein, daß die Performanz- Verbesserungen auch im realen Einsatz voll zur Geltung kommen, und nicht nur im Laborbetrieb signifikant sind. Der Benchmark muss konstenlos verfügbar sein. Es wäre ausserdem hilfreich, wenn der Benchmark mit Quellcode (unter einer Open Source Lizenz) verfügbar wäre. In Tabelle 2.2 sind einige populäre Benchmarks aufgelistet. Ein offensichtliches Problem bei den bekannten Java VM Benchmarks ist ihre Verfügbarkeit. Offenbar sind die meisten ehemals populären Benchmarks entweder nicht mehr auffindbar (JMark und Caffeine) oder nicht ohne Kosten verfügbar (SpecJVM und Volcano). Der einzige Benchmark, der sowohl aktuell gepflegt, kostenlos verfügbar ist, und noch dazu reale Anwendungsfälle testet ist der DaCapo Benchmark. Benchmark Dacapo SpecJVM98 JMark Caffeine Mark Volcano Mark Bemerkungen Open Source Keine neuen Releases, kostenpflichtig Keine reale Anwendungen, Projekt nicht auffindbar Keine neuen Releases, Projekt nicht auffindbar Keine neuen Releases, kein Sourcecode Tabelle 2.2: Populäre Java VM Benchmarks 2.3.3 Die Dacapo Benchmark Suite Eine Benchmark-Suite, die die obengenannten Kriterien erfüllt ist die Dacapo Benchmark Suite. Diese wird für die meisten Performanz-Messungen im Rahmen dieser Diplomarbeit zum Einsatz kommen. Die anderen Benchmarks sind aus verschiedenen Gründen nicht praktikabel. Manche sind schlichtweg nicht oder nicht kostenlos verfügbar, andere Benchmarks werden nicht mehr weiterentwickelt und liefern keinen Sourcecode mit aus (was sehr nachteilig beim Debuggen sein kann). In Dacapo sind eine Menge von Testläufen definiert. Jeder dieser Testläufe führt ein bestimmtes häufig verwendetes Java Programm und/oder Bibliothek aus, jedes mit leicht unterschiedlichem Fokus im Laufzeitverhalten. Die verschiedenen Einzelbenchmarks sind in Tabelle 2.3 aufgelistet.

KAPITEL 2. TECHNISCHE GRUNDLAGEN 19 Testname Beschreibung Besonderheiten antlr Parser-Generator Ok. bloat Bytecode level optimization Ok. and Analysis Tool chart JfreeChart chart plotting Benötigt Java2D, nicht toolkit möglich. eclipse Eclipse Platform Wirft InternalError, sehr wahrscheinlich ein Class- Loader Problem. fop Print Formatter Ok. hsqldb SQL Database engine Ok. jython Python interpreter Ok. luindex Lucene indexing engine Complains about not beeing able to create directory. lusearch Lucene search engine Complains about not beeing able to create directory. pmd Source code analyzer Ok. xalan XSLT processor ExcepionInInitializerError Tabelle 2.3: Die Dacapo Benchmarks

KAPITEL 2. TECHNISCHE GRUNDLAGEN 20 Einige Benchmarks bereiten offenbar Probleme mit der JamaicaVM. Da wäre zunächst der chart Benchmark, dieser zeichnet verschiedene Diagramme per Java2D in BufferedImages. JamaicaVM unterstützt noch kein Java2D, daher wird dieser Benchmark nicht verwendet werden. Von den anderen Benchmarks machen eclipse, luindex, lusearch sowie xalan Probleme. Es wird in einem vorbereitenden Schritt nötig sein, die Probleme soweit wie möglich zu beheben. Die Dacapo Benchmark Suite definiert ausserdem für jedes Benchmark 3 verschiedene Konfigurationen: Small Ein sehr kurzer Testlauf. Dauer im Minutenbereich. Dieser kann im Laufe der Entwicklung zum Schnellen Testen verwendet werden. Die Ergebnisse liefern schon deutliche Hinweise auf Performanz-Veränderungen, ist aber für brauchbare Statistiken zu ungenau. Die Ergebnisse dieser Tests sollen daher hier auch nicht extra erwähnt werden. Im Einzelfall kann bei Besonderheiten eine Ausnahme gemacht werden. Default Ein Testlauf mittleren Umfangs. Die Dauer bewegt sich hier im Bereich von 10-30 (?) Minuten. Diese Konfiguration soll für Performanzmessungen dienen, die jeweils vor und nach Abschluss eines Teilprojekts vorgenommen werden. Die Messungen sind schon sehr genau und aussagekräftig. Large Ein sehr langer Testlauf. Die Dauer bewegt sich hier im Stundenbereich. Diese Konfiguration soll nur für die initiale und abschliessende Analyse zum Einsatz kommen, um die Gesamtergebnisse des ganzen Projektes so genau wie möglich zu erfassen. Wenn nötig, kann evtl. ein Zwischenbericht erzeugt werden. Zu Beginn der Diplomarbeit war gerade ein neuer Release von DaCapo in Arbeit. Ich habe meine ersten Versuche mit dem Release Candidate 4 Release gemacht. Offenbar gab es einen Fehler in DaCapo der mit JamaicaVM (und anderen GNU Classpath-basierten VMs) manche Tests fehlschlagen liess. Nach einer Nachricht von mir auf der DaCapo Mailingliste wurde das Problem prompt behoben und RC5 funktioniert weitestgehened. Verwendet wird letztendlich die finale Version 2006-10-MR1. Die verschiedenen Konfigurationen der Dacapo Benchmark sind sehr nützlich. Es werden im Laufe der Entwicklung die Small und Default-Sets zum Testen eingesetzt werden, und die Default- und Large-Sets für ausführliche Performanz-Analysen zu Beginn und am Ende des Projekts.

KAPITEL 2. TECHNISCHE GRUNDLAGEN 21 2.3.4 Versionierung DaCapo Benchmark Für diese Diplomarbeit wird die Version 2006-10-MR1 der Dacapo Benchmark Suite verwendet. Die Dacapo Benchmark Suite wird im DaCapo Paper [1] und ausführlicher im DaCapo Technical Report [2] beschrieben. JamaicaVM Die Diplomarbeit basiert auf der Entwicklungsversion von Jamaica zum Zeitpunkt des Projektbeginns. Das ist im Wesentlichen Jamaica VM 3.1 alpha vom 12. Dezember 2006. Die Entwicklungen im Rahmen dieses Projekts werden in einem separaten Entwicklungs-Zweig vorgenommen, damit andere Änderungen an der JamaicaVM nicht das Ergebnis mitverfälschen.

Kapitel 3 Lösungsmöglichkeiten Zur Verbesserung der Interpreter Performanz kann man verschiedene Ansätze wählen. Auf der einen Seite gibt es eine Reihe von bewährten Technologien die z.b. in anderen Java VMs eingesetzt werden. Von diesen soll hier eine Auswahl vorgestellt sowie deren Umsetzbarkeit im Kontext der JamaicaVM diskutiert werden. Auf der anderen Seite gibt es einige Ansätze, die speziell im Kontext der JamaicaVM Sinn machen. Auch diese sollen hier vorgestellt werden 3.1 Code Review Eine naheliegende Methode zur Verbesserung von Performanz ist eine direkte manuelle Analyse des Programm-Codes. Es ist möglich, daß dadurch verschiedene Fehl-Konzeptionen oder schlechte Umsetzungen im Interpreter- Code gefunden werden können. Manueller Code-Review ist auch ansonsten sehr sinnvoll, wenn nicht sogar unvermeidlich, um überhaupt erst einmal mit dem Programm-Code des Interpreters vertraut zu werden. 3.2 Gezielte Optimierung der Bytecode-Handler Die Bytecode Handler spielen eine zentrale Rolle im Interpreter. Ineffiziente Implementierungen derselben müssen vermieden werden. Es muss hier allerdings gezielt vorgegangen werden. Es scheint hier sehr sinnvoll zu sein, zunächst einmal das Laufzeit-Verhalten der unterschiedlichen Bytecode-Handler zu analysieren. Anhand von Statistiken über die Laufzeit der unterschiedlichen Bytecode-Handler können kritische Handler identifiziert und gezielt optimiert werden. 22

KAPITEL 3. LÖSUNGSMÖGLICHKEITEN 23 Eine solche Optimierung ist sehr naheliegend. Eine Statistik über das Laufzeit-Verhalten der Verschiedenen Bytecode-Handler ist auch in anderen Zusammenhängen nützlich. Falls im Zusammenhang mit dem Code Review Probleme in performanz-kritischen Bytecode-Handlern identifiziert werden können, dann sollten diese auch behoben werden. 3.3 Threaded Dispatching Threaded Dispatching ist eine Optimierung des Dispatch Codes. Der normale (naive) Ansatz zum Bytecode-Dispatchen ist eine while-schleife mit einem eingebetteten Switch Statement. Dieses Switch Statement führt zu jeder Bytecode Instruktion den entsprechenden Bytecode Handler aus, der die Semantik dieses Bytecodes implementiert. Ein solches Switch Statement wird normalerweise vom C Compiler in einen indizierten Sprungbefehl übersetzt. Trotzdem ist der Overhead für das Dispatchen im Vergleich zu den meisten Operationen doch noch sehr groß. Im Allgemeinen werden folgende Operationen im Dispatcher ausgeführt: 1. Auswerten der Schleifenbedingung (ggf. Ausbruch aus Schleife). 2. Indizierter Sprung zum Bytecode Handler 3. (nach Ausführung des Handlers) Sprung zum Ende des Switch Statements 4. Sprung zum Beginn der Schleife Es gibt also insgesamt drei Sprünge, von denen zwei nicht wirklich nötig sind. Die Idee beim Threaded Dispatching ist, diese zwei Sprünge zu eliminieren. Dazu wird der relevante Dispatch Code (Punkt 1 und 2) an jeden Bytecode Handler angehängt und direkt vom Ende eines Handlers zum Anfang des nächsten Handlers gesprungen. Das Problem hierbei ist, daß man soetwas nicht mit reinem ANSI C realisieren kann. Es gibt Compiler die gewisse Erweiterungen haben, die man zur Implementierung eines solchen Dispatchers verwenden kann. Insbesondere der GCC besitzt die nötigen Syntax Erweiterungen. Dies ist der Compiler, der auch für die meisten Zielsysteme eingesetzt wird, die von JamaicaVM unterstützt werden. Eine wichtige Ausnahme davon ist WindowsCE, wo der Microsoft C Compiler verwendet wird. Wie das Problem dort gelöst werden kann, soll untersucht werden. Eine andere Plattform, die keinen GCC verwendet ist OS9. Auch für diese Plattform sollte nach einer alternativen Lösung gesucht werden. Im schlimmsten Fall muss für nicht-gcc Compiler weiterhin der While-Switch-Interpreter verwendet werden.

KAPITEL 3. LÖSUNGSMÖGLICHKEITEN 24 3.4 Direct Dispatching Direct Dispatching ist eine Optimierung des Dispatching Codes, der auf dem Threaded Dispatching aufbaut. Beim Threaded Dispatching wird die Adresse des jeweils nächsten auszuführenden Handlers aus einer Sprungtabelle ausgelesen, mit dem jeweiligen Bytecode als Index. Die Idee beim Direct Dispatching ist nun, diesen Lookup einzusparen, und die Adresse des nächsten Bytecode-Handlers direkt im Bytecode-Stream zu speichern. Diese Technologie wird in vielen VMs implementiert (z.b. JamVM [8] und GCJ [5]) und hat dort jeweils signifikante Performanz Gewinne gebracht. Ein großer Nachteil allerdings ist der gesteigerte Speicherbedarf. Während normale Bytecode-Instruktionen nur ein Byte (== 8 Bit) Arbeitsspeicher verbrauchen, benötigen Adressen auf 32 Bit Systemen 4 Byte (== 32 Bit). Das bedeutet, daß der Speicherverbrauch von Java Programmen im Arbeitsspeicher im schlimmsten Fall vervierfacht wird, da für jeden eingelesenen Bytecode 4 statt 1 Byte im Speicher reserviert werden muss. Das mag für Desktop-Systeme nicht besonders signifikant sein, aber auf eingebetteten Systemen ist es nicht sinnvoll, Arbeitsspeicher für Performanz zu opfern. Es muss weiterhin bemerkt werden, daß Direct Dispatching eine direkte Erweiterung des Threaded Dispatching ist. Die Implementierung dieser Technologie hängt also unmittelbar von der Implementierung des Threaded Dispatching ab. Aus diesen Gründen wurde Direct Dispatching im Rahmen dieser Diplomarbeit nicht implementiert. Nichtdestotrotz ist es eine interessante Technologie, die möglicherweise in Zukunft im Rahmen eines separaten Projekts implementiert werden sollte. 3.5 Top-Of-Stack Caching Eine Java VM ist laut Spezifikation eine Stack-orientierte Maschine. Alle Operationen verwenden einen sogenannten Operanden-Stack zur Zwischenspeicherung von Operanden und Ergebnissen. Eine grundlegende Eigenschaft von Stacks ist es, daß immer nur das oberste Element zugreifbar ist. Im Gegensatz dazu stehen Register-basierte Maschinen, bei denen Operanden und Ergebnisse in speziellen Speicherstellen des Prozessors, den sogenannten Registern, abgelegt werden. Diese sind beliebig zugreifbar. Das Stack-basierte Design macht eine Implementierung von Interpretern und Compilern sehr einfach. Ein offensichtlicher Nachteil ist allerdings, daß die allermeisten (Hardware) Prozessoren register-basiert sind. Das bedeutet, daß man den Stack im Hauptspeicher verwalten muss (der deutlich langsamer im Zugriff ist als

KAPITEL 3. LÖSUNGSMÖGLICHKEITEN 25 Prozessorregister). Die Möglichkeiten des Prozessors lassen sich also nur begrenzt ausnutzen. Eine Register-basierte virtualle Maschine liese sich ggf. wesentlich besser auf einen Hardware-Prozessor abbilden. Eine Möglichkeit dieses Problem zu lösen, ist das sogenannte Top-Of- Stack-Caching. Dabei wird das jeweils oberste Element des Stacks (oder sogar mehrere Elemente) in einem Prozessor-Register gespeichert, statt im Hauptspeicher, und nur bei Bedarf (z.b. bei einem Methodenaufruf oder falls mehr Werte auf dem Stack landen) in den Hauptspeicher zurückgeschrieben. Dadurch lässt sich Interpreter-Performanz signifikant steigern. Allerdings erkauft man sich diesen Gewinn mit einer stark erhöhten Komplexität des Stack-Zugriffs-Codes. Top-Of-Stack Caching ist durch die Verwendung von Registern eine inherent plattformabhängige Technologie. Sowohl die Implementierung als auch der mögliche Nutzen hängen sehr stark von der Zielplattform ab. Die Implementierung von plattformunabhängigeren Optimierungen hat natürlicherweise Vorrang, da sie Performanzgewinn für alle oder viele unterstützte Plattformen versprechen. Sehr plattformabhängige Optimierungen wie diese werden daher hintenangestellt. Darum konnte diese Optimierung im Rahmen dieser Diplomarbeit nicht implementiert werden. 3.6 Superinstructions Es ist relativ wahrscheinlich, daß bestimmte Bytecode Gruppen existieren, die besonders häufig gemeinsam, in einer bestimmten Sequenz, vorkommen. Als Beispiel möchte ich hier die Addition zweier Werte aufführen. Zum Addieren zweier Integer Werte müssen diese auf den Stack gepusht, dann addiert und schliesslich das Ergebnis wieder vom Stack gepoppt werden. Beispielsweise durch folgende Bytecode-Sequenz: iload0 // Lädt lokale Variable 0 auf den Stack iload1 // Lädt lokale Variable 1 auf den Stack iadd // Addiert beide Werte und pushed das Ergebnis auf den Stack istore2 // Speichert Ergebnis in lokaler Variable 2 Es ist wahrscheinlich, daß solche oder ähnliche Bytecode-Sequenzen relativ häufig vorkommen. Eine mögliche Optimierung ist daher, häufig vorkommende Sequenzen zusammenzufassen und durch einen speziellen neuen Bytecode zu ersetzen. Solche Bytecode-Instruktionen werden Superinstructions genannet. Der Vorteil bei der Verwendung von Superinstructions besteht zum

KAPITEL 3. LÖSUNGSMÖGLICHKEITEN 26 einen darin, daß für diese Bytecode-Sequenzen der Dispatch-Overhead komplett eingespart werden kann. Ausserdem können in vielen Fällen Optimierungen implementiert werden, wie zum Beispiel das Weglassen von unnötigen Stack-Zugriffen. Aufgabe hier ist es, herauszufinden, welche Bytecode-Sequenzen besonders häufig auftreten, einen Mechnismus zu implementieren, um solche Sequenzen zu ersetzen (z.b. beim Klassenladen), und natürlich die neuen Superinstructions als Bytecode-Handler zu implementieren. 3.7 Einsatz eines VM-Interpreter-Generators In einem Java Interpreter gibt es sehr viel Code, der sich prinzipiell wiederholt. Zum Beispiel muss jeder Bytecode-Handler Werte vom Stack poppen, auf den Stack pushen oder aus dem Bytecode-Stream lesen. Dazu kommt, daß in der JamaicaVM zwei Interpreter-Schleifen existieren, und evtl noch mehr dazukommen. Ein weiterer Gesichtspunkt ist, daß Bytecode-Handler und der Dispatching Code idealerweise voneinander unabhängig sein sollten. Momentan sind der Dispatch Code und die Bytecode Handler sehr stark aneinander gekoppelt. Möchte man einen alternativen Dispatcher implementieren, muss man entweder sehr viel Code kopieren, oder manuell anpassen, beides ist sehr fehleranfällig. Es scheint daher sinnvoll zu sein, die Bytecode-Handler inklusive deren Boilerplate Code (Stack- und Bytecode-Streamzugriffe) zu generieren und sinnvoll aus dem Dispatch-Code herauszuabstrahieren. Die Idee dazu stammt von Vmgen [3], einem Interpreter Generator, der in GForth eingesetzt wird. Der Einsatz eines VM Generators kann als Querschnitts-Technologie betrachtet werden. Ein VM Generator ist für sich genommen keine Optimierung am Interpreter, aber es erleichtert die Implementierung verschiedener Technologien, oder macht es überhaupt erst machbar. Weiterhin muss festgestellt werden, daß durch die Generierung von häufig verwendeten, immer gleichen Codes (z.b. Stack-Zugriffs-Code) viele potentielle Fehlerquellen vermieden bzw. minimiert werden. Die Implementierung eines VM Generators im Stile des GForth Generators scheint sehr sinnvoll, und sollte auf jeden Fall zu Beginn des Projektes erfolgen, um größtmöglichen Nutzen zu bringen, und massives Refactoring in späteren Phasen zu vermeiden.

KAPITEL 3. LÖSUNGSMÖGLICHKEITEN 27 3.8 Just In Time Compilation Bei der Just In Time Compilation wird der Java Bytecode nicht mehr im strengen Sinne interpretiert, sondern vor der Ausführung direkt in Maschinencode übersetzt. Damit wird zum einen der Dispatch-Overhead vollständig eliminiert. Weiterhin eröffnen sich bei der Just In Time Compilation eine Vielzahl weiterer Optimierungen, solche wie sie auch von gängigen Compilern implementiert werden, aber auch spezielle JIT spezifische Techniken. Man kann einen JIT Compiler allerdings nicht mehr wirklich als Interpreter bezeichnen. Es gibt verschiedene prinzipielle Ansätze zur JIT Compilation. Zum einen besteht die Möglichkeit, prinzipiell jede Methode vor Ihrer ersten Ausführung zu compilieren und nur Maschinencode auszuführen. Die andere Möglichkeit ist, nur Methoden zu compilieren, die auch häufig genug ausgeführt werden. Dieses Verfahren nennt man auch adaptiver JIT, da sich das Laufzeitverhalten den Bedürfnissen der Anwendung und der Benutzer anpasst. Das erste Verfahren hat den Vorteil, daß es relativ einfach ist, man benötigt keinen zusätzlichen Interpreter, und muss auch nicht den Übergang vom Interpreter in Maschinencode und zurück berücksichtigen, da schlichtweg nur Maschinencode letztendlich ausgeführt wird. Dafür ist die Startupzeit von solchen JITs im allgemeinen sehr lang. Das mag in manchen Umgebungen allerdings keine große Rolle spielen (z.b. Production Server und Embedded Anwendungen). Das zweite Verfahren ist in vielerlei Hinsicht deutlich effektiver. Zum einen ist die Startupzeit im Vergleich zum ersten Verfahren deutlich besser. Außerdem benötigt ein solcher JIT viel weniger zusätzlichen Arbeitsspeicher zur Laufzeit, da viel weniger Code letztendlich compiliert wird. Und nicht zuletzt können adaptive Compiler oftmals besser optimieren, da sie in der Regel auch verschiedene Informationen über das Laufzeitverhalten der Anwendung sammeln. JIT Compiler haben aber auch eine Reihe von Nachteilen. Insbesondere im Embedded und Realtime Bereich gibt es Probleme, die den sinnvollen Einsatz eines JITs fragwürdig erscheinen lassen: JIT Compiler benötigen Speicher. Zusätzlich zu dem Speicher, der durch den Java Bytecode belegt wird, benötigt ein JIT zur Laufzeit zusätzliche Speicher-Buffer um den Maschinencode abzulegen. Während dies auf modernen Desktop- und Server-Systemen kein großes Problem darstellt (man allokiert einfach zusätzliche Speicherbereiche), ist das bei einem Embedded System oftmals nicht möglich. Die Speicheranforderungen eines JITs sind im Allgemeinen nicht gering. Der compilierte Maschinencode zu einer Java Methode benötigt in der Regel ein

KAPITEL 3. LÖSUNGSMÖGLICHKEITEN 28 Vielfaches des Speichers, der durch den Java Bytecode belegt wird. Schlimmer noch, die Speicheranforderungen bei einem JIT sind nicht vorhersehbar. Bei den geringen verfügbaren Mengen an Arbeitsspeicher auf einem Embedded System muss immer damit gerechnet werden, daß einem JIT der Speicher ausgeht. JIT Compiler zerstören die Echtzeitfähigkeit einer Anwendung. Echtzeitfähigkeit bedeutet, daß zu einer gegebenen Methode eine sinnvolle Worst Case Execution Time bestimmt werden kann, die nicht (hartechtzeitfähig) oder nur selten und geringfügig (weich-echtzeitfähig) überschritten wird. Es ist zwar sicher zu sagen, daß die Worst Case Execution Time bei einem JIT durch die Worst Case Execution Time der rein interpretierten Methode beschränkt ist, diese Abschätzung ist aber ziemlich wertlos, da die Ausführungszeit einer compilierten Methode so viel kürzer ist. Abgesehen von diesen zwei großen Problemen muss festgestellt werden, daß JamaicaVM bereits einen Ahead Of Time Compiler (AOT) zur Verfügung stellt, mit dem Java Bytecode bereits vor seiner Ausführung, genauer gesagt zum Deploy-Zeitpunkt, in Maschinencode compiliert wird. Die Performanz von solchem Code ist vergleichbar mit modernen JITs wie Sun s Hotspot. Desweiteren zeigt er keine der beiden oben genannten Probleme. Es ist also prinizipiell nicht den Aufwand wert, einen JIT für JamaicaVM zu implementieren. Ganz abgesehen davon würde es bei weitem den Rahmen dieser Diplomarbeit sprengen.

Kapitel 4 Umsetzung Im vorangegangenen Kapitel wurden eine Reihe von möglichen Vorgehensweisen zur Optimierung der JamaicaVM vorgestellt und diskutiert. Es sollen nun einige dieser Technologien umgesetzt werden. Zunächst wird eine Auswahl der zu implementierenden Technologien getroffen und eine sinnvolle Reihenfolge gewählt. Im Folgenden wird die Umsetzung jeweils im Detail erläutert. 4.1 Initiale Performanz-Analyse In diesem Kapitel dient der Erfassung und Beschreibung der Ausgangssituation zu Beginn des Projektes sowie einer eingehenden Analyse der Probleme bezüglich der Performanz im Jamaica Interpreter. 4.1.1 Ausgangssituation Zunächst soll einmal festgestellt werden, wie sich die Performanz des JamaicaVM Interpreters zu Beginn des Projektes verhält. Die gemessene Performanz soll später als Vergleich dienen um den Erfolg des Projektes einschätzen zu können. Weiterhin soll der JamaicaVM Interpreter mit anderen verbreiteten Java Interpretern verglichen werden, um ein Gefühl dafür zu bekommen, wie die Performanz des Jamaica Interpreters einzuordnen ist. Die Performanzwerte im folgenden Abschnitt wurden mit der Dacapo Benchmark Suite gegen Jamaica 3.0 und der damaligen Entwicklungsversion von Jamaica vom 21.12.2006 ermittelt. Jamaica 3.0 ist der zum Zeitpunkt der Diplomarbeit aktuelle Release. Die Entwicklerversion enthält schon eine Reihe Optimierungen in verschiedenen Bereichen der VM. 29

KAPITEL 4. UMSETZUNG 30 Abbildung 4.1 stellt die Performanz der verschiedenen Jamaica Interpreter gegenüber. Abbildung 4.1: Performanz-Vergleich Jamaica Interpreter 4.1.2 Analyse der Performanzprobleme Um gezielte Verbesserungen vornehmen zu können soll zunächst untersucht werden, wo genau nun eigentlich die Geschwindigkeitsprobleme liegen. Es soll vermieden werden, daß Arbeit in die Optimierung seltener Randfälle gesteckt wird, die in Wirklichkeit gar nicht auftreten. Insbesondere sind folgende Informationen relevant: Benchmark Jamaica 3.0 Jamaica HEAD antlr 245956 240116 bloat 1367279 1516106 fop 116670 111658 hsqldb 345615 352980 jython 844452 luindex 1155132 1032247 lusearch 881848 789785 pmd 676812 592078 Gesamt 2077900 1864441 Tabelle 4.1: Performanz von Jamaica 3.0 und Jamaica 3.1beta zum Projektbeginn

KAPITEL 4. UMSETZUNG 31 Welche Bytecodes kommen in typischen Anwendungen wie häufig vor? Wieviel Prozessorzeit benötigen die einzelnen Bytecodehandler im Schnitt? In den folgenden beiden Abschnitten sollen diese beiden Punkte genauer analysiert werden. Häufigkeit von Bytecodes Anhand der Häufigkeit der einzelnen Bytecodes lassen sich besonders interessante Ziele für Optimierungen ausmachen. Wenn man bei einem Bytecodehandler eines besonders häufig benutzten Bytecodes nur wenige Prozessorzyklen spart, kann das schon signifikante Auswirkungen auf die Gesamt- Performanz haben. Die Bestimmung der Häufigkeit von Bytecodes ist verhältnismässig einfach. Zu diesem Zweck muss nur in der Interpreterschleife direkt vor dem Switch-Statement ein Zähler hinzugefügt werden, der in einem Array die entsprechenden Indices hochzählt (nur auszugsweise angedeutet): unsigned long bytecodeexec [ 2 5 6 ] ; LOCAL void i n t e r p r e t ( jamaica thread ct ) { // <... > while ( ct >c o d e c a r r a y == NULL && ct >pc >= 0) { // <... > bc = jamaica next ( ct ) ; i f ( bytecodeexec [ bc ] < 4294967295) bytecodeexec [ bc]++; switch ( bc ) { // <... > } } } Listing 4.1.1: Implementierung der Bytecodestatistik Als Referenz dient hier wieder die DaCapo Benchmark Suite, da sie eine Reihe von Real-World-Anwendungen ausführt. Die Ergebnisse sind in Tabelle 4.2 zusammengefasst.

KAPITEL 4. UMSETZUNG 32 Es lässt sich feststellen, daß die mit Abstand am häufigsten ausgeführte Bytecode aload_0 und getfield sind. Der Bytecode aload_0 implementiert (meistens) den Zugriff auf das this Objekt in Instanz-Methoden, this ist bei Instanz-Methoden immer die lokale Variable an der Position 0 im Stack- Frame. Für statische Methoden allerdings ist aload_0 der Zugriff auf die erste lokale Variable, oder der erste Methodenparameter. Der Opcode getfield implementiert das Auslesen eines Instanz-Feldes. Im Abschnitt 4.6.2 wird gezeigt werden, daß diese beiden Opcodes sogar meistens gemeinsam ausgeführt werden als Sequenz aload_0 getfield, was zusammen ein Instanz-Feld des this Objekts ausliest. Die nächsthäufigsten Bytecodes (aber mit deutlichem Abstand) sind invokevirtual, also das Aufrufen einer normalen (nicht-konstruktor und nicht- Interface) Methode sowie diverse Stack-Zugriffs-Opcodes (iload*). Es wird sich wohl lohnen, wenn die aload_0 und getfield Instruktionen optimiert würden. Die weiteren Instruktionen sind deswegen nicht uninteressant, aber haben vorerst geringere Priorität. Durchschnittliche Ausführungszeit der Bytecode Handler Um nun die Auswirkungen der verschiedenen Bytecode Handler realistisch betrachten zu können benötigt man nicht nur die Häufigkeit der Aufrufe der einzelnen Handler, sondern auch deren durchschnittliche Ausführungszeit. Diese variiert je nach Komplexität des Bytecodes beträchtlich. Während manche Handler nur minimal CPU Zeit verbrauchen (z.b. das Laden eines Wertes auf den Stack), benötigen andere Operationen deutlich mehr Zeit (z.b. Aufruf einer Methode oder Allokation eines Objekts). Die Ausführungszeit der Bytecode Handler kann bestimmt werden, indem jeweils vor und nach Ausführung eines Handlers ein Zeitstempel abgefragt wird. Hier benötigt man allerdings eine extrem genaue Messung, da wie schon gesagt manche Handler nur sehr wenig CPU Zyklen verbrauchen und daher einzeln betrachtet nur sehr schwer messbar sind. Es existiert in der JamaicaVM bereits eine Methode zum genauen Bestimmen von Ausführugszeit, und zwar ein Makro daß den Zyklenzähler des Prozessors abfragt. Der Vorteil ist, daß man damit eine sehr genaue Messung bekommt, vor allem auch für die kleinen Bytecode Handler. Der Nachteil ist, daß die Messungen nicht in einer metrischen Einheit vorliegen, sondern eben in der Einheit Prozessorzyklen. Das ist aber kein Problem, da wir hier nur die relative Ausführungszeit der Bytecode Handler untereinander benötigen, und nicht die genaue Anzahl Millisekunden für jeden Handler. Die Implementierung der Zeitmessung macht sich schon den Interpreter Generator (siehe nächstes Kapitel) zunutze. Mithilfe dieses Generators

KAPITEL 4. UMSETZUNG 33 Bytecode Name Häufigkeit Relativ 42 aload 0 185817422 14,2023472158607 180 getfield 143933272 11,0010691293464 182 invokevirtual 77235885 5,90327238688248 43 aload 1 62420501 4,77090694213798 21 iload 57935631 4,42812056466904 27 iload 1 50916630 3,89164616825602 172 ireturn 36003158 2,75178368787989 29 iload 3 32907204 2,51515456452281 176 areturn 31610084 2,41601343759103 181 putfield 28531298 2,18069649419199 153 ifeq 28333684 2,16559251409955 96 iadd 27389640 2,09343759702698 192 checkcast 26532083 2,02789303107453 132 iinc 24254362 1,85380287227953 89 dup 24101296 1,84210377294028 177 return 22879356 1,74870878354607 3 iconst 0 22121930 1,6908174032517 4 iconst 1 21130571 1,61504611882624 183 invokespecial 20294442 1,55113933200595 16 bipush 18481671 1,41258610654551 28 iload 2 16998962 1,29926009108674 54 istore 16614213 1,26985305901116 161 if cmplt 16286173 1,2447803939696 126 iand 15962967 1,22007719991577 167 goto 15939930 1,21831644212842 162 if cmpge 15463149 1,18187524498424 17 sipush 15010814 1,14730249793641 154 ifne 13400075 1,02419092795602 100 isub 12267634 0,937636502055757 50 aaload 11162743 0,853187770344908 51 baload 10554191 0,806675087573395 44 aload 2 9434080 0,721063064916526 120 ishl 8539722 0,652705734831068 193 instanceof 7942615 0,607067813221 155 iflt 7585341 0,579760742955008 90 dup x1 6775055 0,517829181359288 61 istore 2 6378271 0,487502293400967 178 getstatic 6313980 0,4825884209824 184 invokestatic 6200333 0,473902184047949 190 arraylength 6068861 0,463853551508189 156 ifge 5381689 0,411331806044421 160 if cmpne 5349689 0,408885990652 7 iconst 4 5099894 0,389793726403571 62 istore 3 5013373 0,383180776604582 Tabelle 4.2: Häufigkeit von Bytecodes

KAPITEL 4. UMSETZUNG 34 lässt sich sehr einfach zusätzlicher Code direkt in alle Bytecode Handler einfügen, ohne die jeden einzelnen Handler ändern zu müssen. In diesem Falle wurde jeweils am Anfang und am Ende jedes Bytecode-Handlers etwas Code eingefügt, um den Prozessor-Zyklen-Zähler von Intel i586 Prozessoren abzufragen. Zu diesem Zweck existieren bereits Makros in der JamaicaVM, diese müssen nur noch abgefragt werden. Es wird jeweils am Anfang eines Bytecode-Handlers der Zyklen-Zähler abgefragt und am Ende eines Bytecode-Handlers nocheinmal, und dann die Differenz gebildet. Der errechnete Wert wird dann in einer Tabelle zu den bisherigen Zyklen für den jeweiligen Bytecode hinzuaddiert. Hier muss bereits 64-Bit Arithmetik eingesetzt werden, da normale 32 Bit Integer überlaufen würden. Die auf diese Weise ermittelten Zyklen-Statistiken sind in den Tabellen 4.3 und 4.4 zusammengefasst, einmal sortiert nach den absoluten Zyklenwerten und einmal sortiert nach dem Quotienten cycles/f requency. Es zeigt sich hier, daß invokevirtual offenbar die meisten Zyklen verbraucht. Dieses Ergebnis muss allerdings mit Vorsicht genossen werden. Um die Problematik zu verstehen muss kurz erklärt werden, wie der Methodenaufruf funktioniert. Es gibt im Wesentlichen zwei Fälle: Eine Java Methode wird aufgerufen, indem der Program Counter (PC) auf 0 und das Code-Array auf die neue Methode gesetzt wird sowie der Stackframe für die neue Methode aufgebaut wird. Der Bytecode- Handler invokevirtual kehrt sofort zurück und der Interpreter setzt die Arbeit in der neuen Methode ganz normal fort. Eine Native-Methode (z.b. JNI, JBI oder eine compilierte Methode) wird aufgerufen, indem die entsprechende Stub-Funktion mit den zugehörigen Parametern aufgerufen wird. Im Gegensatz zu Java Methoden kehrt invokevirtual erst zurück, wenn die Native-Methode zurückkehrt. Die Zeitmessung für invokevirtual schliesst also die kompletten Ausführungszeiten für evtl. aufgerufene Native-Methoden mit ein. Aus Tabelle 4.4 wird offensichtlich, daß die Bytecode-Handler für alle new* Operationen sehr viel Rechenzeit verbrauchen. Die Ursache dafür ist hauptsächlich im Garbage Collector zu suchen. Der Jamaica Garbage Collector führt seine Arbeit jeweils bei der Allokation neuer Objekte aus (im Gegensatz zu herkömmlichen Garbage Collectoren, die ihre Arbeit in einem separaten Thread erledigen). Abgesehen von den invoke* und new* Bytecodes zeigt sich auch hier wieder, daß eine Optimierung von aload_0 sowie getfield lohnenswert sein sollte.

KAPITEL 4. UMSETZUNG 35 Bytecode Häufigkeit Zyklen Zyklen pro BC invokevirtual 77235885 74800976233 968,47438509962 getfield 143933272 26548326007 184,448846594691 invokespecial 20294442 19964739149 983,754032212366 invokestatic 6200333 17743349433 2861,67685396897 ireturn 36003158 8439801781 234,418374660356 checkcast 26532083 8344248338 314,496541338273 aload 0 185817422 8124296134 43,7219290126628 new 1937810 7466055349 3852,83146902947 areturn 31610084 7037320199 222,628962295703 putfield 28531298 6253286713 219,172878605102 invokeinterface 3674494 5210300650 1417,96411968559 return 22879356 5051161125 220,773745773264 newarray 404285 2943373298 7280,44151526769 dup 24101296 2841316329 117,89060343477 instanceof 7942615 2578435107 324,633021618195 getstatic 6313980 2577683092 408,250119892683 aload 1 62420501 2563052726 41,0610726434253 iload 57935631 2304981293 39,785210814395 ldc 3513128 1916138881 545,422450021747 anewarray 271451 1669071112 6148,7012831045 iload 1 50916630 1629257714 31,9985378843808 iinc 24254362 1618629986 66,7356241322695 dup x1 6775055 1467022306 216,532899880518 iload 3 32907204 1257871628 38,2248102269643 iadd 27389640 1151195126 42,0303124100937 goto 15939930 1144611191 71,8077928196673 ifeq 28333684 1104297618 38,974727677488 aaload 11162743 1068123225 95,6864477664674 istore 16614213 862499593 51,9133583396337 iload 2 16998962 842020469 49,5336402893306 aload 4600936 825013719 179,314321911889 bipush 18481671 798799968 43,221198342942 iconst 1 21130571 793615206 37,557679155949 if cmplt 16286173 777598267 47,7459171654384 aastore 1993793 746939459 374,632401156991 sipush 15010814 739109364 49,2384599529379 iand 15962967 652578320 40,8807660881589 iconst 0 22121930 649598330 29,3644510221305 if cmpge 15463149 645761528 41,761320931461 baload 10554191 576061245 54,5812791335688 isub 12267634 539538031 43,9806103605634 ifne 13400075 515793918 38,4918679932762 ldc2 w 2092744 462283818 220,89840802315 ishl 8539722 408026449 47,7798280787126 ldc w 263091 383964077 1459,43448084503 Tabelle 4.3: Absolute Performanz von Bytecodehandlern

KAPITEL 4. UMSETZUNG 36 Bytecode Häufigkeit Zyklen Zyklen pro BC multianewarray 1064 8987767 8447,14943609023 newarray 404285 2943373298 7280,44151526769 anewarray 271451 1669071112 6148,7012831045 putstatic 37653 162482553 4315,26181180782 new 1937810 7466055349 3852,83146902947 invokestatic 6200333 17743349433 2861,67685396897 athrow 167277 288562160 1725,05580563975 ldc w 263091 383964077 1459,43448084503 invokeinterface 3674494 5210300650 1417,96411968559 invokespecial 20294442 19964739149 983,754032212366 invokevirtual 77235885 74800976233 968,47438509962 fcmpg 162 151509 935,240740740741 wide 17941 14688569 818,715177526336 ddiv 4003 2798252 699,038720959281 dneg 11305 6869972 607,693233082707 ldc 3513128 1916138881 545,422450021747 i2d 34633 18010149 520,028556578985 d2l 11 5239 476,272727272727 monitorenter 88429 39237312 443,715432720035 lneg 1 415 415 getstatic 6313980 2577683092 408,250119892683 dup2 x1 2 810 405 dsub 29 11609 400,310344827586 lstore 1 3834 1533852 400,06572769953 f2d 7 2658 379,714285714286 aastore 1993793 746939459 374,632401156991 fastore 14 4970 355 freturn 83 29163 351,361445783133 dstore 3 4172 1435562 344,094439117929 jsr 24080 8014331 332,821054817276 instanceof 7942615 2578435107 324,633021618195 lshr 25638 8291527 323,407715110383 fadd 115 36765 319,695652173913 checkcast 26532083 8344248338 314,496541338273 f2i 13016 3855516 296,213583282114 lookupswitch 78730 21843652 277,45017147212 lreturn 393940 107604355 273,149096309083 d2i 236 58138 246,347457627119 dreturn 1003411 240887979 240,069103288682 ireturn 36003158 8439801781 234,418374660356 areturn 31610084 7037320199 222,628962295703 lxor 25005 5556487 222,215036992601 ldc2 w 2092744 462283818 220,89840802315 return 22879356 5051161125 220,773745773264 Tabelle 4.4: Relative Performanz von Bytecodehandlern

KAPITEL 4. UMSETZUNG 37 4.2 Vorgehensweise Es soll hier eine Auswahl zu implementierender Technologien getroffen werden. Als Grundlage dieser Auswahl dienen die Technologien aus Abschnitt 3 sowie die Performanz-Analyse aus Abschnitt 4.1. Die folgenden Vorgehensweise scheint zur Performanz-Optimierung sinnvoll zu sein: 1. Zu Beginn steht ein eingehendes Code Review stehen, schon alleine um mit dem Interpreter Code vertraut zu werden. Desweiteren besteht natürlich die Hoffnung, daß dadurch einige offensichtliche Performance- Bottlenecks identifiziert werden können. 2. Im nächsten Schritt wird der VM Generator implementiert und intergriert. Durch die Verwendung dieses Generators wird die folgende Entwicklung wesentlich einfacher, insbesondere wird sich-wiederholender Code sowie Änderungen, die global alle Bytecode-Handler betreffen wesentlich einfacher und fehler-sicherer zu implementieren sein. 3. Grundlegende Probleme die im ersten Schritt (Code Review) gefunden wurden, sollten als erstes nach Umstellung auf den VM Generator umgesetzt werden. Dies schliesst eine gezielte Optimierung bestimmter performanz-kritischer Bytecode-Handler ein. 4. Threaded Dispatching ist eine sehr vielversprechende Technologie um die Interpreter Performanz zu steigern. Diese kann erst mit Hilfe des VM Generators sinnvoll implementiert werden. 5. Super-Instructions scheinen ebenfalls eine sinnvolle Technologie für den Einsatz in der JamaicaVM zu sein, zumal etwas ähnliches schon für compilierten Code mit Erfolg umgesetzt wurde. 6. Top Of Stack Caching könnte implementiert werden, scheint allerdings eine Menge Plattform-Spezifika zu benötigen. Einige andere Technologien machen prinzipiell keinen Sinn im Rahmen dieser Diplomarbeit: Direct Dispatching benötigt zusätzlichen Speicher, da 8-Bit Bytecodes durch 32-Bit Adressen ersetzt werden müssten. Das wäre zwar prinzipiell möglich, scheint aber sehr aufwendig zu sein, da auf eingebetteten Systemem nicht garantiert werden kann, daß zusätzlicher Speicher vorhanden ist. Man bräuchte also gegebenenfalls einen Mechanismus, der bei mangelndem Speicher zurückfällt auf normales Dispatching.

KAPITEL 4. UMSETZUNG 38 Just In Time Compilation macht noch weniger Sinn zu implementieren. Zum einen benötigt Just In Time Compilation viel zusätzlichen Speicher, mehr noch als Direct Dispatching. Abgesehen davon zerstört Just In Time Compilation die Echtzeitfähigkeit einer Anwendung, da nicht mehr sicher bestimmt werden kann, ob eine Methode kompiliert ausgeführt wird oder nicht. 4.3 Interpreter-Generator Bei der Implementierung eines Bytecode-Interpreters gibt es gewisse wiederkehrende Muster. Insbesondere die Bytecode-Handler Implementierungen folgen gewissen Regelmässigkeiten. Der Aufbau eines Bytecode-Handlers folgt in der Regel folgendem Muster: 1. Präambel (z.b. ein case-label) 2. Laden eventueller Parameter aus dem Bytecode-Stream (z.b. Konstanten) 3. Laden eventueller Parameter vom Stack (z.b. Summanden) 4. Nutzcode des Bytecode-Handlers (z.b. Addition) 5. Speichern des Ergebnisses auf dem Stack (z.b. Summe) 6. Postambel (z.b. break Statement) Viele dieser Schritte können je nach Implementierung sehr verschieden sein. Beispielsweise wird die Prä- und Postambel bei einem Switch-basierten Interpreter aussehen wie oben als Beispiel angegeben, es könnte aber im Prinzip genausogut eine Funktions-Definition sein, oder etwas noch anderes (wie im päteren Kapitel Threaded Dispatching 4.5 besprochen). Ebenso kann der Bytecode- und Stack-Zugriffscode verschieden sein, z.b: existiert in der Jamaica VM eine Interpreter-Schleife, die Bytecodes aus einem C-Array lädt und eine, die Bytecodes aus einem Java-Array lädt. Die einzige wirkliche Konstante ist der eigentliche Nutzcode des Bytecode-Handlers. Es scheint sinnvoll, den Nutzcode der Bytecode-Handler vom Boilerplate Code zu trennen. Dies hätte folgende Vorteile: 1. Bestimmte Aspekte des Interpreters können leich geändert werden. Beispielsweise könnte die Stack- und Bytecode-Zugriffe leicht angepasst und optimiert werden, ohne jeden einzelnen Handler ändern zu müssen.

KAPITEL 4. UMSETZUNG 39 2. Es kann leicht zusätzlicher Code für bestimmte Zwecke eingefügt werden. Davon wird z.b: in Abschnitt 4.1.2 Gebrauch gemacht, um Zeitmessungs- Code in jeden Bytecode-Handler einzufügen. 3. Die Implementierung des Dispatching kann leicht geändert werden. Anstatt für jeden Bytecode-Handler die case-labels usw. manuell durch etwas anderes ersetzen zu müssen, kann das an einer zentralen Stelle angepasst werden, und wird automatisch in allen Handlern implementiert. 4. Durch die automatisierte Erzeugung von häufigem sich immer wiederholendem Code wird die Fehlerwahrscheinlichkeit bei der Implementierung deutlich reduziert sowie Redundanz vermieden. 4.3.1 GForth s Vmgen In [3] wird (u.a.) eine Implementierung eines solchen Interpreter Generators beschrieben. Diese Implementierung verwendet im Prinzip eine Eingabedatei, die die Bytecode-Handler auf abstrakte Weise beschreibt und erzeugt daraus eine Reihe Ausgabedateien, die unter anderem den Nutzcode der Bytecode- Handler in verschiedene Makros einbetten. Um nun darauf aufbauend einen Interpreter zu implementieren, muss man nur die entsprechenden Makros in seinem Interpreter-Quellcode geeignet implementieren sowie die generierten Dateien einbinden. Diese Implementierung wurde im Rahmen dieser Diplomarbeit auf eine mögliche Verwendbarkeit evaluiert. Aufgrund der folgenden Nachteile habe ich mich allerdings dazu entschlossen, diese Implementierung nicht zu verwenden, und stattdessen einen eigenen Generator zu implementieren: 1. Schlechte Unterstützung für Bottom-Up Stacks. Der GForth Vmgen setzt ein Stack-Layout vorraus, bei dem das niedrigste Stack-Element an der höchsten Speicherstelle steht, und der Stack nach unten wächst. Das hat gewisse Vorteile und kann auf einigen Plattformen möglicherweise positiv für die Performanz sein. Jamaica verwendet allerdings ein Bottom-Up Stack Layout, und das zu ändern wäre ein unverhältnismässiger Aufwand. 2. In GForth s Vmgen ist es nur schwer möglich, mithilfe einer Eingabedatei mehrere verschiedene Interpreter zu erzeugen, die auf verschiedene Weise Bytecodes lesen. Dies wird benötigt, um die zwei Methoden zum Lesen von Bytecodes (aus einem C-Array vs. einem Java-Array) zu unterstützen.

KAPITEL 4. UMSETZUNG 40 3. Vmgen selbst ist in GForth implementiert. Dies hat gleich zwei Nachteile. Forth ist eine sehr alte und im Vergleich zu Java eher schwierige Sprache. Sie lässt sich nur schwer pflegen wenn kaum noch jemand im Team diese Programmiersprache beherrscht. Zum anderen setzt es eine Installation von GForth auf allen Build-Maschinen vorraus, was insbesondere auf den Windows Build-Maschinen nicht trivial ist. Da die Implementierung eines solchen Generators im Prinzip nicht schwer ist, ist das Vorgehen hier, eine Adaption von GForth s Interpreter Generator in Java zu implementieren, und dort die nötigen Erweiterungen einzubauen. Diese Implementierung ist angelehnt an GForth s Vmgen, nicht zuletzt um z.b. auch mögliche Optimierungen die in [3] beschrieben werden umsetzen zu können. 4.3.2 Format der Eingabedatei Die Eingabedatei für den Interpreter-Generator gliedert sich in zwei Teile, eine Präambel und einen Implementierungsteil. In der Präambel werden die Datentypen für die Eingabe-Streams und Stacks deklariert. Der Implementierungsteil beschreibt die einzelnen Bytecode-Handler, genauer gesagt, deren Eingabedaten, die Funktionalität sowie die Ausgabedaten. Das Formate der Eingabedatei ist im Wesentlichen zeilenbasiert. Jeweils eine Zeile ist entweder ein Kommentar oder Leerzeile, eine Eingabe-Deklaration, eine Bytecode-Handler-Deklaration oder ein Teil einer Bytecode-Handler Implementierung. Im Folgenden sollen die Sektionen der Eingabedatei im Detail erläutert werden. 4.3.3 Deklaration der Eingabe-Typen Zu Beginn der Eingabedatei findet sich ein Abschnitt zur Deklaration der Eingabequellen und deren Datentypen. Ein Interpreter verarbeitet Daten aus zwei Quellen, dem Bytecode-Stream (z.b. Bytecode-Parameter und Konstanten) sowie dem Operanden-Stack (Ein- und Ausgabewerte). Die Eingabequellen werden zum Beispiel wie folgt deklariert:! stack jamaica_int32! stream jamaica_int8 Diese beiden Anweisungen legen die grundlegenden Datentypen für den Operanden-Stack und den Bytecode-Stream fest. In diesem Fall sind das die Typen jamaica_int32 für den Stack und jamaica_int8 für den Bytecode- Stream.

KAPITEL 4. UMSETZUNG 41 Weiterhin werden die unterschiedlichen Datentypen benötigt, mit denen der Interpreter letztendlich arbeitet. Beispielsweise wird der Interpreter im Allgemeinen nicht (nur) jamaica_int32 Werte verarbeiten, sondern z.b. auch floats, doubles, Referenzen, usw. Abbildungen auf solche Typen werden wie folgt deklariert:! type stream single b jamaica_value8! type stream double s jamaica_value16! type stream quad i jamaica_value32! type stack single r jamaica_ref! type stack single i jamaica_value32! type stack single f jamaica_value32! type stack double l jamaica_value64! type stack double d jamaica_value64! type stack single u jamaica_refval32 Das type Statement leitet hierbei eine Typ-Deklaration ein. Das folgende Argument (stream oder stack) gibt an, ob sich die Typdeklaration auf einen Operanden-Stack-Typ bezieht, oder auf einen Bytecode-Stream-Typ. Es folgt eine Größenangabe (single, double oder quad), die angibt, wieviel Einheiten des Basistyps der deklarierte Typ benötigt. Beispielsweise belegt ein Java double zwei Einheiten des Basistyps jamaica_int32 auf dem Operandenstack. Das folgende Zeichen bestimmt den Prefix, anhand dessen der Typ später in den Bytecode-Handler-Deklarationen identifiziert wird. Beispielsweise wird eine Variable mit dem Namen rresult als Variable vom Type jamaica_ref angelegt. Dieses Prefix-Mapping ist im Wesentlichen aus GForth s Vmgen übernommen. Das letzte Argument schliesslich gibt den Typen an, auf den Variablen mit dem jeweiligen Prefix abgebildet werden. 4.3.4 Implementierung der Bytecode-Handler Ein Bytecode-Handler wird definiert durch die Deklaration seiner Ein- und Ausgabeparameter sowie durch den eigentlichen implementierenden Code. Dies soll anhand des Bytecode-Handlers für die Instruktion iadd erläutert werden, die zwei Werte vom Operanden-Stack lädt, sie addiert, und das Ergebnis auf den Operanden-Stack zurückschreibt. Diese Funktionalität wird implementiert durch folgende Handler-Definition: iadd ( iv1 iv2 -- ir ) ir.i = iv1.i + iv2.i;

KAPITEL 4. UMSETZUNG 42 Die erste Zeile deklariert die Ein- und Ausgabeparameter. Die Variablen vor dem -- sind dabei die Eingabe-Parameter, und die Variablen nach dem -- bezeichnen Ausgabeparameter. Die Eingabeparameter werden hierbei rückwärts vom Stack gepoppt. Zuoberst auf dem Stack liegt also in diesem Beispiel der Wert für iv2, darunter der Wert für iv1. Dies wurde so implementiert, um die Notation weitestgehend an die Notation in der Java Virtual Machine Specification [7] anzugleichen. Um Parameter aus dem Bytecode-Stream zu lesen muss der Parameter mit einem $ Zeichen beginnen, wie im folgenden Beispiel: bipush ( $bbyte -- iv ) iv.i = (jamaica_int32) bbyte.b; Dieser Bytecode-Handler liesst z.b. ein einzelnes Byte aus dem Bytecode- Stream und pusht es auf den Operanden-Stack. Die restlichen Zeilen der Handler-Definition sind ganz gewöhnlicher C- Code. Auf die Variablen kann man direkt zugreifen. Der Generator deklariert diese mit dem Typ, den man in der Präambel für den jeweiligen Prefix angegeben hat. Unbekannte Prefixe erzeugen selbstverständlich einen Fehler. Die Handler-Definition wird durch eine Leerzeile beendet. Das bedeutet, daß man innerhalb der Implementierung eines Handlers keine Leerzeilen einfügen darf. Diese Beschränkung stammt aus GForth s Vmgen und sollte in einer zukünftigen Version behoben werden. Bis dahin kann man sich behelfen, indem man z.b. ein ; in eine Leerzeile schreibt. 4.3.5 Die Ausgabedateien Eine Eingabedatei in der Form wie oben beschrieben wird durch den Interpreter- Generator wie folgt verarbeitet: java com.aicas.jamaica.tools.vmgen.main jamaica.vmg Ein solcher Aufruf erzeugt drei Ausgabe-Dateien: jamaica-vm-c.i, jamaica-vm-j.i und jamaica-labels.i. Die erste Datei, jamaica-labels.i enthält nur eine Liste der Handler-Namen wie folgt: <..> INST_ADDR(swap), INST_ADDR(iadd), INST_ADDR(ladd), INST_ADDR(fadd), <..>

KAPITEL 4. UMSETZUNG 43 Die INST_ADDR() Instruktionen sind tatsächlich C Makros, die in der Implementierung des Interpreters definiert werden müssen. Diese Liste mit Makro- Aufrufen kann verwendet werden, um Sprungtabellen oder ähnliches zu generieren. Wie das implementiert werden kann wird im Abschnitt 4.3.6 anhand eines Beispiels erläutert. Die beiden anderen Dateien, jamaica-vm-c.i und jamaica-vm-j.i sind beide im Wesentlichen äquivalent. Sie enthalten die eigentliche Implementierung der Bytecode-Handler, einmal mit Zugriff auf einen C-Array-basierten Bytecode-Stream und einmal mit Zugriff auf einen Java-Array-basierten Bytecode- Stream. Als Beispiel sei hier noch einmal der Bytecode-Handler für die JVM Instruktion iadd aufgeführt: iadd ( iv1 iv2 -- ir ) ir.i = iv1.i + iv2.i; Dieser Handler wird durch den Generator wie folgt erzeugt: /** iadd ( iv1 iv2 -- ir ) **/ LABEL(iadd) NAME("iadd") { jamaica_value32 iv1; jamaica_value32 iv2; jamaica_value32 ir; DEF_CA START NEXT_P0 STACK_POP_JAMAICA_VALUE32(ct, iv2, -1); STACK_POP_JAMAICA_VALUE32(ct, iv1, -2); { #line 447 "jamaica.vmg" ir.i = iv1.i + iv2.i; } NEXT_P1 STACK_PUSH_JAMAICA_VALUE32(ct, ir, -2); STACK_INCR(ct, -1) LABEL2(iadd) END NEXT_P2 }

KAPITEL 4. UMSETZUNG 44 Der Handler beginnt mit einem LABEL Makro. Dieses wird benutzt, um zum Beispiel in einem Switch-Statement die case Labels zu erzeugen. Das NAME Makro dient Debug-Zwecken, z.b. kann man sich hier den Namen des Bytecode-Handlers über ein printf ausgeben lassen. Es folgen die Deklarationen der Ein- und Ausgabevariablen. Diese werden vom Generator aus den Typen-Deklarationen und der Handler-Deklaration abgeleitet. Über das folgende DEF_CA Makro lassen sich leicht weitere Variablen-Deklarationen einbinden. Im Start-Makro kann zusätzlicher Code an den Beginn jedes Handlers eingefügt werden, wie zum Beispiel Code zur Zeitmessung wie in Abschnitt 4.1.2 verwendet. Das NEXT_P0 Makro dient dem Instruction-Prefetching. Damit können alternative Strategien implementiert werden, um die nächste Bytecode-Instruktion zu laden. Dies ist der GForth-Implementierung entlehnt um in einem späteren Projekt solche Strategien wie in [3] beschrieben implementieren zu können. Es folgen zwei STACK_POP Makros, diese dienen dem Laden der Eingabevariablen mit den entsprechenden Stack-Werten. Der folgende Block ist die eigentliche Implementierung des Handlers, wie in der Eingabe-Datei angegeben. Es wird hier eine #line... Zeile eingefügt. Diese wird vom GCC-Compiler ausgewertet und als Debug-Information in die Objekt-Datei eingebettet. Damit kann man beim Debuggen die ursprüngliche Quellen des Codes besser zurückverfolgen. Der restliche Code ist mehr oder weniger symmetrisch zur Präambel des Handlers. NEXT_P1 und NEXT_P2 dienen dem Laden des nächsten Bytecodes. Die STACK_PUSH und STACK_INCR Makros dem Speichern der Ergebnisse. Dabei gibt STACK_INCR an, wieviel der Stack nach Ausführen des Handlers tatsächlich gewachsen ist. Mit LABEL2 kann man sich ein End-Label ausgeben lassen, wenn man eines benötigt. END schliesslich dient dem Einfügen von abschliessendem Code in jeden Handler, z.b. zur Zeitmessung. 4.3.6 Implementierung eines Interpreters mithilfe des Generators Die eigentliche Implementierung eines Interpreters muss jetzt nur noch die richtigen Dateien and der richtigen Stelle laden sowie die richtigen Makros definieren. Ein einfacher kompletter Interpreter könnte dann wie folgt aussehen: #define INST_ADDR(name) I_##name #define LABEL(name) case I_##name: #define NEXT_P2 break; #define FETCH_J_JAMAICA_VALUE8(bc) bc.b = next(); /*.. more FETCH definitions here... */

KAPITEL 4. UMSETZUNG 45 #define STACK_INCR(ct,i) #define STACK_POP_JAMAICA_REF(target, idx) (target).r = pop_ref(); /*.. more STACK definitions here... */ /** The entry point for the interpreter **/ void interpret() { /* This defines an enum which is then used for indexing the switch cases. */ enum { #include "jamaica-labels.i" }; /* The interpreter loop. */ while (pc >= 0) { FETCH_J_JAMAICA_VALUE8(bc); switch (bc.u) { #include "jamaica-vm-j.i" default: handle_unknown_bc(); } } } Dies ist ausreichend um einen sehr einfachen Interpreter zu implementieren. Zunächst werden alle benötigten Makros sinnvoll definiert. Alle weiteren Makros müssen natürlich auch definiert werden, aber zu inem leeren Statement expandieren. Diese sind hier nicht extra aufgeführt. In diesem Beispiel wird ein Switch-basierter Interpreter implementiert. Zunächst wird hier ein enum definiert, das später zum Indizieren des Switch-Cases verwendet wird. Dieses enum expandiert nach dem Präprozessor zu (Ausschnitt): enum { /*... */ I_swap, I_iadd, I_ladd, I_fadd, /*... */ };

KAPITEL 4. UMSETZUNG 46 Die eigentliche Interpreter-Schleife wird vom Präprozessor folgendermaßen expandiert (Ausschnitt): while (pc >= 0) { bc.b = next(); switch (bc.u) { /*... */ case I_iadd: jamaica_value32 iv1; jamaica_value32 iv2; jamaica_value32 ir; iv2.i = pop_value32(); iv2.i = pop_value32(); { ir.i = iv1.i + iv2.i; } push_value32(ir.i); break; /*... */ default: handle_unknown_bc(); } } Mit Hilfe dieses generierten Handler-Codes ist es nun sehr einfach, einen völlig anderen Interpreter zu implementieren (z.b. mit Threaded Dispatching wie in Abschnitt 4.5 beschrieben), oder bestimmte Aspekte einfach zu ändern, wie zum Beispiel den Stack-Zugriff. 4.4 Stackzugriff Im Abschnitt 3 wurden eine Reihe von Vorgehensweisen erläutert. Als einer der wichtigen Ansätze wurde Code Review genannt. Eines der Resultate des Code Review war die Feststellung, daß der Zugriff auf den Operanden Stack in einigen Punkten suboptimal ist. In diesem Abschnitt wird erläutert, wie der Stack-Zugriff verbessert wurde. Um die Umsetzung zu verstehen, ist es erforderlich den Aufbau der Stacks in der Jamaica VM zu verstehen. Dies ist im Abschnitt 2.1.5 erläutert.

KAPITEL 4. UMSETZUNG 47 4.4.1 Konsolidierung der Stack-Zugriffs-Makros Der Zugriff auf den Java Stack ist in der Jamaica VM über eine Reihe von Makros implementiert. Die Idee dahinter ist, daß Programmteile, die auf den Java Stack nicht die beteiligten Datenstrukturen selbst manipulieren sollen, sondern diese Zugriffe soweit wie nötig abstrahiert sind. Es werden keine Inline Methoden verwendet, da die Interpreterschleife komplett in einer Funktion liegt und diese sehr groß ist. Ein C Compiler würde Funktionen hier nicht inlinen. In der JamaicaVM gibt es eine ganze Reihe von Stackzugriffsmakros. Leider sind diese wenig systematisch und enthalten viele Doppelungen. Dies ist historisch gewachsen und wurde einfach nur nie bereinigt. Um nun aber unnötige Arbeit bei der eigentlichen Änderung des Stack Zugriffs zu vermeiden, sollen diese Zugriffsmakros zunächst einmal vereinheitlicht werden. Folgende Makros finden sich zum Stackzugriff: JAMAICA_PUSH JAMAICA_PUSH2 JAMAICA_PUSHR JAMAICA_PUSH_IR JAMAICA_POP JAMAICA_POP2 JAMAICA_POPR JAMAICA_POP_IR JAMAICA_AT JAMAICA_AT_2 JAMAICA_AT_R JAMAICA_AT_IR JAMAICA_STORE_AT JAMAICA_STORE_AT_2 JAMAICA_STORE_AT_R

KAPITEL 4. UMSETZUNG 48 JAMAICA_GET JAMAICA_GET2 JAMAICA_GETR JAMAICA_GETIR JAMAICA_STACK_PUT JAMAICA_STACK_PUT2 JAMAICA_STACK_PUTR JAMAICA_POP_INT32 JAMAICA_POP_INT64 JAMAICA_POP_FLOAT JAMAICA_POP_DOUBLE JAMAICA_POP_REFERENCE JAMAICA_PUSH_INT32 JAMAICA_PUSH_INT64 JAMAICA_PUSH_FLOAT JAMAICA_PUSH_DOUBLE JAMAICA_PUSH_REFERENCE JAMAICA_GET_INT32 JAMAICA_GET_INT64 JAMAICA_GET_FLOAT JAMAICA_GET_DOUBLE JAMAICA_GET_REFERENCE JAMAICA_PUT_INT32 JAMAICA_PUT_INT64 JAMAICA_PUT_FLOAT

KAPITEL 4. UMSETZUNG 49 JAMAICA_PUT_DOUBLE JAMAICA_PUT_REFERENCE JAMAICA_POP_REMOVE JAMAICA_POPN JAMAICA_STACK_DISCARD INTERPRET_PUSH_CHECK INTERPRET_PUSHR_CHECK INTERPRET_POP_CHECK INTERPRET_POPR_CHECK Weiterhin existieren noch ein paar Inline-Funktionen: jamaicainterpreter_push() jamaicainterpreter_push2() jamaicainterpreter_pushr() jamaicainterpreter_pop() jamaicainterpreter_pop2() jamaicainterpreter_popr() Zusaätzlich existieren noch einige Zugriffsmakros für lokale Variablen, die letztendlich auch auf den Stack zugreifen: JAMAICA_LOCAL JAMAICA_LOCAL2 JAMAICA_LOCAL_R JAMAICA_SET_LOCAL JAMAICA_SET_LOCAL2 JAMAICA_SET_LOCAL_R INTERPRET_LOCAL

KAPITEL 4. UMSETZUNG 50 INTERPRET_LOCAL_R Ich erspare mir die Erläuterung der Funktionalität dieser Makros. Diese ergibt sich größtenteils aus dem Namen. Aber es ist offensichtlich, daß hier unnötig viel Duplikate vorhanden sind. Dies macht den Code nicht nur unübersichtlich, sondern auch anfällig für Fehler. Änderungen müssen immer an mehreren Stellen eingepflegt werden, wenn man Inkonsistenzen vermeiden möchte. Im Zusammenhang mit dem Interpreter-Generator wurde der Stack-Zugriff signifikant verbessert. Im Wesentlichen bestehen jetzt zwei verschiedene Wege um auf den Stack zuzugreifen: Von innerhalb des Interpreters über die neuen Stack-Zugriffs-Makros (siehe unten). Dies Verwendung dieser Makros wird ausschliesslich vom Interpreter-Generator erzeugt. Von ausserhalb des Interpreters über ein Set von Funktionen. Die Zugriffsmakros bzw. Funktionen sind jetzt einheitlich und Typ-spezifisch. Weiterhin wurde Wert darauf gelegt, daß nur POP und PUSH Zugriffe für den Operanden-Stack erlaubt werden. Das erzwingt eine sauberere Programmierung. Die einzigen Ausnahmen bleiben der Zugriff auf Stack-Frame-Daten und auf lokale Variablen. Folgende Zugriffsmakros werden nun innerhalb des Interpreters verwendet: STACK_POP_JAMAICA_VALUE32 STACK_POP_JAMAICA_VALUE64 STACK_POP_JAMAICA_REF STACK_POP_JAMAICA_REFVAL32i STACK_PUSH_JAMAICA_VALUE32 STACK_PUSH_JAMAICA_VALUE64 STACK_PUSH_JAMAICA_REF STACK_PUSH_JAMAICA_REFVAL32i Für den Stack-Zugriff von ausserhalb des Interpreters (also zum Beispiel aus dem Garbage Collector oder aus der Runtime Implementierung) bleiben die folgenden Funktionen bestehen:

KAPITEL 4. UMSETZUNG 51 jamaicainterpreter_pop() jamaicainterpreter_pop2() jamaicainterpreter_popr() jamaicainterpreter_push() jamaicainterpreter_push2() jamaicainterpreter_pushr() Diese Konsolidierung hat nicht nur den Interpreter Code übersichtlicher gemacht, sondern führt zu folgenden Verbesserungen: Die Pflege des Codes wird deutlich einfacher. Eine Änderung am Stack- Zugriff muss nun an wesentlich weniger Stellen angepasst werden als vorher. Durch weniger Code und einfachere Pflege desselben werden selbstverständlich auch viele potentielle Fehlerquellen vermieden. Der beliebige Zugriff auf den Operanden Stack wurde komplett entfernt. Der Operanden-Stack kann jetzt nur noch über pop und push- Operationen zugegriffen werden. Das erzwingt saubere Programmierung und vermeidet auf diese Weise Fehler. 4.4.2 Minimierung der Zugriffe auf den cont locals Stack Wie in Abschnitt 2.1.5 erläutert, müssen Referenzen, die auf dem Operanden Stack gespeichert werden, separat in einem zweiten Stack gespeichert, damit der Garbage Collector diese nicht als löschbar identifizieren kann. Daher wurde bisher jedes pushen einer Referenz auf den cont_locals Stack gespiegelt, ebenso das poppen von Referenzen. Damit wird sichergestellt, daß keine Referenz die auf den Operanden-Stack liegt vom Garbage Collector gelöscht werden kann. Dies wird schematisch in Abbildung 4.2 dargestellt. Allerdings ist das tatsächlich nur nötig, wenn neue Referenzen auf den Stack gelangen. Wenn eine Referenz bereits auf dem cont_locals Stack gesichert ist, dann muss sie nicht ein zweites Mal gesichert werden. Die interessante Frage ist, wie das festgestellt werden kann. Es ist einleuchtend, daß es nicht sinnvoll ist, bei jedem Stackzugriff zu prüfen, ob dieselbe Referenz bereits gesichert ist. Ein recht einfacher Weg bietet sich hier, indem man die Semantik mancher Bytecodes genauer analysiert. Ich möchte hier im Speziellen auf den Bytecode

KAPITEL 4. UMSETZUNG 52 sichert kopie sichert kopie aload_0 eingehen, daß sich dieser in Abschnitt 4.1 als besonders performanzkritisch herausgestellt hat. Dieselbe Überlegung lässt sich in ähnlicher Weise auf andere Bytecodes anwenden. Der Bytecode aload_0 lädt eine lokale Variable auf den Operanden-Stack, und zwar die lokale Variable an Position 0 auf dem Stack-Frame. Das bedeutet gleichzeitig, daß sie aber schon gesichert ist. Das lässt sich relativ leicht auf induktive Weise zeigen, wenn man sich überlegt, wie der Operanden-Stack und die Stack-Frames aufgebaut werden. Abbildung 4.3 zeigt, wie eine solche Optimierung des Stack-Zugriffs aussehen müsste. Wenn sichergestellt wird, daß das erste Speichern einer Referenz auf dem Stack auf gesicherte Weise geschieht, also auf dem cont_locals Stack gespiegelt wird. Der Bytecode aload_0 kann allerdings niemals die erste Speicher-Operation einer Referenz auf den Stack sein, da diese Referenz vorher schon einmal im Stack-Frame gesichert werden musste. Es gibt allerdings einen Spezialfall, der Probleme bereiten kann. Dieser ist in Abbildung 4.4 dargestellt. Es kann passieren, daß mit new eine neue Referenz auf den Stack gelangt, die an dieser Stelle auch gesichert wird. Wird diese Referenz dann mit einem aload geladen, und danach mit areturn der Frame abgebaut, so geht die ursprüngliche Referenz samt Kopie verloren, da die lokalen Variablen abgebaut werden, und nur der Rückgabewert der aktuellen Methode (die gerade vorher mit aload geladen wurde) auf die oberste Position des darunterliegenden Frames kopiert wird. Glücklicherweise lässt sich dieses Problem relativ leicht und effizient vermeiden, indem man den Handler für areturn so ändert, daß der Rückgabesichert java stack cont_locals stack Abbildung 4.2: Referenz-Sicherung auf dem Stack

KAPITEL 4. UMSETZUNG 53 kopie kopie sichert java stack cont_locals stack Abbildung 4.3: Vereinfachung der Referenz-Sicherung kopie areturn new lokale vars sichert! java stack cont_locals stack java stack cont_locals stack Abbildung 4.4: Problem mit areturn bei der Referenz-Sicherung

KAPITEL 4. UMSETZUNG 54 wert explizit noch einmal auf dem cont_locals Stack gesichert wird. Ein ähnliches Problem ergibt sich, wenn nach einem aload_0 ein astore_0 ausgeführt wird. In diesem Fall wird unter Umständen die bereits gesicherte Referenz in der lokalen Variable 0 ersetzt durch eine andere Referenz, und kann damit die ursprüngliche Referenz auf dem Stack dem Garbage Collector preisgeben. Die Lösung dieses Problems besteht darin, beim Laden einer Methode alle Vorkommen des Bytecodes aload_0 durch eine optimierte Version aload_0_safe zu ersetzen, aber nur dann, wenn in dieser Methode kein astore_0 vorkommt. Dies ist zum Glück fast nie der Fall, da die lokale Variable bei Instanz-Methoden das this Objekt speichert, das nicht geändert werden kann. Der Bytecode astore_0 kann also nur bei statischen Methoden vorkommen. 4.4.3 Aufbau des Stackframes Beim Aufbau des neuen Stackframes wurde der Platz für lokale Variablen in einer Schleife reserviert: for ( i=c a l l e d n a r g s ; i<c a l l e d n l o c a l s ; i++) { jamaicascheduler syncpoint ( ct ) ; JAMAICA PUSH( ct, 0 ) ; } Listing 4.4.1: Aufbau eines Stackframes beim Methodenaufruf Das Löschen der Speicherstellen für die lokalen Variablen ist unnötig, da vom Java-Compiler erzwungen wird, daß lokale Variablen initialisiert werden. Es reicht daher, einfach den Stack-Zeiger um die nötige Anzahl Elemente hochzusetzen: ct->m.cont_locals_index += called_nlocals-called_nargs; Diese Optimierung ist besonders für Methoden mit vielen lokalen Variablen oder Parametern interessant. 4.5 Threaded Dispatching Beim Interpretieren von Java Bytecode (und in diesem Zusammenhang in jedem Interpreter) spielt der sogenannte Dispatcher (deutsch: Zusteller oder

KAPITEL 4. UMSETZUNG 55 Bote ). Damit ist der Teil des Interpreters gemeint, der aus dem Bytecode- Stream die Bytecode-Instruktionen ausliest und die dazugehörigen Funktionen, die sogenannten Bytecode-Handler, aufruft. Das ist sozusagen der eigentliche Kern des Interpreters. Dieser ist deshalb so wichtig, weil er für jeden einzelnen Bytecode immer wieder ausgeführt wird. Es von absoluter Wichtigkeit, daß der Overhead des Dispatching so minimal wie möglich gehalten wird. 4.5.1 Das Problem des while-switch-dispatchers Es soll zunächst einmal betrachtet werden, wie der Dispatcher bisher in der JamiacaVM implementiert war, sowie dessen Probleme näher betrachtet werden. Der Dispatcher in der JamaicaVM ist ein ganz einfacher sogenannter while-switch-dispatcher. Das ist der offensichtliche Ansatz zur Implementierung eines Bytecode-Interpreters wie er in vielen VMs umgesetzt ist. Listing 4.5.1 veranschaulicht die prinzipielle Implementierung eines solchen Dispatchers. while ( pc >= 0) { switch ( bytecodes++) { case op1 : // Bytecode Handler f ü r op1. break ; case op2 : // Bytecode Handler f ü r op2. break ;... case opn : // Bytecode Handler f ü r opn. break ; } } Listing 4.5.1: Ein while-switch-dispatcher Im Wesentlichen besteht dieser aus einer großen while-schleife. Die Abbruchbedingung ist hier so gewählt, daß die Schleife sich beendet, wenn der progam counter kleiner als 0 ist. Der Program Counter wird dann auf -1 gesetzt, wenn entweder eine Exception geworfen wurde, die nirgends gefangen wird, oder wenn das Programm am ordnungsgemäßen Ende angekommen ist.

KAPITEL 4. UMSETZUNG 56 Innerhalb der while-schleife befindet sich ein großes switch-statement, der eigentliche Kern des Dispatchers. Dieses Switch-Statement verzweigt abhängig vom eingelesenen Bytecode zum jeweils dazugehörigen Bytecode Handler. Ein solches Switch-Statement wird von einem C Compiler in der Regel zu einem indizierten Sprung über eine Sprungtabelle kompiliert. Der schematische Ablauf beim Interpretieren von Bytecodes mit einem whileswitch-dispatcher ist in Abbildung 4.5 dargestellt. Diese Abbildung veranschaulicht auch gleich das Problem eines while-switch-dispatchers: Es müssen hier pro Dispatch-Vorgang drei Sprünge ausgeführt werden, ein Sprung vom Schleifenanfang zum Bytecode-Handler, ein Sprung vom Bytecodehandler zum Schleifenende und noch ein Sprung vom Schleifenende zum Schleifenanfang. Davon ist nur ein Sprung wirklich vorhersehbar (weil unbedingt), und das ist der Sprung vom Handler-Ende zum Schleifenende. Die beiden anderen Sprünge sind beide abhängig von den Eingangsdaten, und damit für einen Prozessor schwerer bis gar nicht vorhersehbar. Das hat schwerwiegende Auswirkungen für die Ausnutzung der Prozessorpipeline, im schlimmsten Fall, und dieser dürfte hier sehr häufig sein, muss die Pipeline für jeden Bytecode ein bis zweimal geleert werden. Dispatcher 1 BC Handler #1 4 2 3 6 BC Handler #2 5 BC Hander #3 Dispatcher Abbildung 4.5: Schematischer Ablauf beim While-Switch-Dispatching

KAPITEL 4. UMSETZUNG 57 4.5.2 Die Idee des Threaded-Dispatchers Um die Performanz des Dispatchers zu verbessern, ist die Idee, die Anzahl der nötigen Sprünge auf ein Minimum zu reduzieren. Ideal wäre, wenn man pro Dispatch Vorgang nur einen Sprung hätte (es geht noch besser, bei einem JIT hat man gar keinen Sprung mehr, weil es da kein Dispatching mehr gibt). Erreicht werden kann das, indem man den Dispatch-Code an jeden einzelnen Bytecode-Handler anhängt, und von dort direkt zum folgenden Bytecode- Handler springt. Schematisch sähe das in etwa aus wie in Listing 4.5.2. void handlers [NUM BC] = {&&do bc1, &&do bc2,... &&do bcn } ; // S t a r t I n t e r p r e t e r by jumping // to f i r s t handler. goto handlers [ bytecodes +]; // No ANSI C!! //... do bc1 : // Bytecode Handler f ü r op1. goto handlers [ bytecodes +]; // No ANSI C!! do bc2 : // Bytecode Handler f ü r op2. goto handlers [ bytecodes +]; // No ANSI C!! //... do bcn : // Bytecode Handler f ü r opn. goto handlers [ bytecodes +]; // No ANSI C!! Listing 4.5.2: Ein Threaded-Dispatcher Wie letztendlich das Dispatching funktioniert, kann man sehr gut in Abbildung 4.6 erkennen. Sehr deutlich wird auch, wieviel weniger Sprünge hier benötigt werden. Hier wird auch klar, woher der Name threaded dispatcher kommt. Die einzelnen Bytecode-Handler werden sozusagen wie an einem Faden aufgefädelt hintereinander angesprungen, gesteuert durch die Bytecode- Sequenz des Eingabestroms. Es gibt zwei Gesichtspunkte, die beim Threaded-Dispatching beachtet werden müssen: 1. Das Anhängen des Dispatch Codes an jeden einzelnen Bytecode-Handler kann die Gesamtgröße des Interpreter Executables negativ beeinflussen.

KAPITEL 4. UMSETZUNG 58 Während in einem While-Switch-Dispatcher der Dispatch Code nur einmal vorhanden ist, muss dieser Code beim Threaded-Dispatching für jeden einzelnen Bytecode-Handler dupliziert werden. Bei mehr als 200 Bytecode-Handlern kann das eine signifikante Menge werden. (Wie wir später sehen werden, kann der Compiler das allerdings optimieren und in manchen Fällen sogar ein kleineres Binary erzeugen als beim While- Switch-Dispatcher). 2. Ein Threaded-Dispatcher lässt sich nicht (oder nur sehr schwer und ineffizient) mit reinen ANSI-C Mitteln implementieren. Es existieren zumindest im GCC Compiler [4] Spracherweiterungen, mit denen man relativ einfach einen Threaded Dispatcher implementieren kann. Für andere Compiler, z.b. den Microsoft C Compiler, der bei der JamaicaVM für Windows CE verwendet wird, existieren solche Erweiterungen nicht. Es muss ggf. auf solchen Plattformen untersucht werden, wie man trotzdem einen Threaded Dispatcher implementieren kann, oder falls das nicht möglich ist, weiterhin der While-Switch-Dispatcher verwendet werden. interpret() 1 BC Handler #1 2 BC Handler #2 3 usw. BC Handler #3 Abbildung 4.6: Schematischer Ablauf beim Threaded-Dispatching

KAPITEL 4. UMSETZUNG 59 4.5.3 Implementierung des Threaded Dispatching Die Implementierung des Threaded Dispatching macht starken Gebrauch von der Abstraktion die durch die Generierung des Interpreter-Codes durch Vmgen erreicht wurde. Betrachten wir noch einmal am Beispiel des Bytecode- Handlers iadd, wie der generierte Code aufgebaut ist. Listing 4.5.3 zeigt den Quellcode für iadd (aus jamaica.vmg). Listing 4.5.4 zeigt den Code, der durch Vmgen für diesen Handler generiert wurde (aus jamaica-vm.i). Weiterhin wird in jamaica-labels.i ein Eintrag der Form INST_ADDR(iadd), erzeugt. Interessant für den Dispatcher sind hier die Makros INST_ADDR, LABEL sowie NEXT_P2 (NEXT_P0 und NEXT_P1 sind vorgesehen für verbesserte Versionen des Threaded Dispatching, werden aber hier nicht verwendet. # iadd (0x60) iadd ( iv1 iv2 -- ir ) ir.i = iv1.i + iv2.i; Listing 4.5.3: Vmgen Quellcode des iadd Handlers Die eigentliche Implementierung ist relativ einfach zu bewerkstelligen. Es müssen nur die relevanten Makros sinnvoll implementiert werden, und der generierte Code zusammengesetzt werden. Zunächst einmal benötigen wir die Addressen für die Bytecode Handler. Diese bekommen wir, indem wir uns eine GCC spezifische Syntax-Erweiterung für C zunutze machen. Es handelt sich dabei um sogenannte Label-Pointer, vom Konzept her ähnlich wie Funktionspointer, aber eben angewendet auf Labels innerhalb einer Funktion. Das heisst, daß die Bytecode-Handler als einfache Code-Blöcke mit Labels versehen implementiert werden. Zu diesem Zweck lassen wir das Makro LABEL(name) wie folgt zu einem Label expandieren: #define LABEL(name) IJ_##name: Dies erzeugt aus einem Bytecode-Handler mit dem Namen XYZ ein Label der Form IJ_XYZ:, also z.b. für iadd ein Label IJ_iadd:. Es sollte bemerkt werden, daß das J eine besondere Bedeutung hat, es werden nämlich für alle Bytecode-Handler zwei Implementierungen erzeugt, eine, die auf einem Bytecode-Stream in einem C-Array operiert (also einfacher Speicher) und eine, die auf einem Bytecode-Stream in einem Java-Array operiert (der in einer Baumstruktur vorliegen kann). Die erstere Version wird durch Labels der Form IC_name dargestellt, und die zweite Version durch IJ_name.

KAPITEL 4. UMSETZUNG 60 /** iadd ( iv1 iv2 -- ir ) **/ LABEL(iadd) NAME("iadd") { jamaica_value32 iv1; jamaica_value32 iv2; jamaica_value32 ir; DEF_CA START NEXT_P0 STACK_POP_JAMAICA_VALUE32(ct, iv2, -1); STACK_POP_JAMAICA_VALUE32(ct, iv1, -2); { #line 447 "jamaica.vmg" ir.i = iv1.i + iv2.i; } NEXT_P1 STACK_PUSH_JAMAICA_VALUE32(ct, ir, -2); STACK_INCR(ct, -1) LABEL2(iadd) END NEXT_P2 } Listing 4.5.4: Generierter Code des iadd Handlers

KAPITEL 4. UMSETZUNG 61 Der wichtigste Teil der Implementierung für Threaded Dispatching ist der Dispatcher selbst. Dieser ist realisiert im Makro NEXT_P2. Dieses Makro wird von Vmgen nach dem eigentlichen Bytecode-Handler und dem Stackzugriff eingefügt. Die Implementierung dieses Makros enthält die Endbedingung für den Interpreter (die beim While-Switch-Interpreter in der While-Schleife codiert ist) sowie den Lookup und Sprung zum nächsten Bytecode-Handler. #define NEXT P2 \ i f ( ct >c o d e c a r r a y!= NULL ct >pc < 0) \ goto e x i t J ; \ else { \ FETCH JAMAICA VALUE8( bc ) ; \ jamaicascheduler syncpoint ( ct ) ; \ goto b c j l a b e l s [ bc. u ] ; \ } Listing 4.5.5: Implementierung des NEXT P2 Dispatch-Makros für Threaded Dispatching Im Interpreter selbst muss die Sprungtabelle aufgebaut werden. Hier ergibt sich ein Problem. Definiert man die Sprungtabelle als lokale Variable, dann erzeugt man bei jedem Aufruf der interpret() Funktion diese Sprungtabelle auf dem Aufrufstack. Das führt sehr schnell zu einem Stack Overflow. Legt man die Sprungtabelle stattdessen als globale Variable an, kann man in der Initialisierung nicht auf die Labels innerhalb der interpret() Funktion zugreifen. Die Initialisierung müsste also trotzdem in der interpret() Funktion implementiert sein. Das gestaltet sich aber nicht einfach. Man müsste bei jedem Aufruf von interpret() prüfen, ob die Sprungtabelle schon angelegt wurde, und falls nicht, eine entsprechende Initialisierungsroutine aufrufen, die die Adressen der Sprunglabels in die Tabelle einträgt. Es geht allerdings auch eleganter. In C gibt es das Konzept von statischen lokalen Variablen. Im Wesentlichen sind das globale Variablen, die nur innerhalb einer Funktion (oder innerhalb eines Blockes) sichtbar sind. Die Sprungtabelle kann auf diese Weise wie in Listing 4.5.6 angelegt werden. Was hier passiert ist schnell erklärt: Für jedes Label, das in jamaica-labels.i über ein Makro INST_ADDR(name) referenziert ist wird die Adresse des Labels in den Array-Initializer eingefügt. Die Adresse eines Labels bekommt man über die GCC-spezifische Sytax-Erweiterung &&label. Letztendlich muss der Threaded Interpreter noch gestartet werden, indem zum Bytecode-Handler für den ersten Bytecode gesprungen wird. Dies ist ganz einfach durch einfügen eines NEXT_P2 Makros direkt nach dem Erzeugen

KAPITEL 4. UMSETZUNG 62 der Sprungtabelle realisiert. 4.5.4 Mögliche Ansätze für nicht GCC-Plattformen Der Implementierungsansatz der in den vorigen Abschnitten beschrieben wurde, verwendet Syntax-Erweiterungen von C, die nur im GCC implementiert sind. Jamaica unterstützt allerdings eine Reihe von Plattformen, auf denen nicht der GCC zum Compilieren verwendet wird. Das betrifft insbesondere WindowsCE, weil dies die meistverwendete Zielplattform ohne GCC ist. (Es gibt noch mehr Plattformen, aber diese sind eher exotisch und werden - noch - nicht in performanzkritischen Anwendungen eingesetzt). Im folgenden sollen einige Ansätze diskutiert werden um auch auf diesen Plattformen einen Threaded Interpreter implementieren zu können. Tail Recursion Statt Compiler Erweiterungen zu verwenden, um die Bytecode-Handler aufzurufen, kann man versuchen, die Bytecodehandler als Funktionen zu erzeugen, und am Ende jedes Handlers die jeweils nächstfolgende Funktion aufzurufen. Keine dieser Handler kehrt je wirklich zurück. Dies entspricht im Wesentlichen einer Tail-Recursion. Das Problem hierbei ist, daß das normalerweise relativ schnell einen Stack-Overflow erzeugt, da bei jedem Aufruf einer Funktion entsprechende Adressen auf dem Stack zwischengespeichert werden. Viele Compiler können allerdings solche speziellen Tail Recursions erkennen und so optimieren, daß der Stack für die Aufrufe überhaupt nicht verwendet wird. Eine prototypische Beispielimplementierung zeigt Listing 4.5.4. Ein Test mit verschiedenen Compilern soll zeigen, ob und wie diese Comvoid i n t e r p r e t ( ) { //... #define INST ADDR( name) &&I J ##name static void b c j l a b e l s [MAX BC HANDLERS] = { #include jamaica l a b e l s. i &&e x i t J } ; //... } Listing 4.5.6: Anlegen der Sprungtabelle für Threaded Dispatching

KAPITEL 4. UMSETZUNG 63 typedef void Word ; typedef void ( I n s t r ) (Word pc ) ; static int acc ; static void Add(Word pc ) { acc += ( int ) pc++; ( ( I n s t r ) ( pc ) ) ( pc +1); } static void Print (Word pc ) { p r i n t f ( Accumulator = %d\n, acc ) ; ( ( I n s t r ) ( pc ) ) ( pc +1); } static void Halt (Word pc ) { e x i t ( 0 ) ; } int main ( int argc, char argv ) { Word pc ; Word program [ ] = { (Word) &Add, (Word)3, (Word) &Print, (Word) &Add, (Word)5, (Word) &Print, (Word) &Halt, } ; acc = 0 ; // the acc r e g i s t e r i s a g l o b a l v a r i a b l e pc = &program [ 0 ] ; // the pc r e g i s t e r i s threaded through c a l l s ( ( I n s t r ) ( pc ) ) ( pc +1); // S t a r t! } p r i n t f ( Error! \ n ) ; return 1 ; Listing 4.5.7: Threaded Dispatching mit Tail Recursion

KAPITEL 4. UMSETZUNG 64 piler diesen Prototyp in Maschinencode abbilden. Zu diesem Zwecke möchte ich hier relevante Ausschnitte aus dem erzeugten Assembler Code für GCC, den Microsoft Compiler sowie den OS9 Compiler aufführen. Listing 4.5.4 zeigt den X86 Assembler Code, den der GCC für den obigen Prototyp generiert, genauer gesagt den Handler für die Add() Funktion. Interessant sind hier folgende Beobachtungen: Der Funktionsaufruf zum nächsten Handler am Ende der Funktion wird nicht als call generiert, sondern als jmp, d.h. es wird keine Rücksprungadresse auf den Stack gespeichert. Auch besitzt die Funktion keine Rücksprung-Instruktion (ret). Die Tail-Recursion wird also offenbar korrekt erkannt und wegoptimiert. Der Funktions-Boilerplate wird hier nicht vollständig eliminiert. Die pushl und popl Instruktionen sind offenbar Überbleibsel davon, die aber offenbar keinen Effekt haben. Das ist nicht optimal, aber vermutlich immer noch besser als ein Switch-Dispatcher (aber nicht so gut wie ein Threaded-Dispatcher basierend auf GCC s Erweiterungen). Add: pushl movl movl movl addl leal movl movl popl jmp %ebp %esp, %ebp 8(%ebp), %edx (%edx), %eax %eax, acc 8(%edx), %eax %eax, 8(%ebp) 4(%edx), %ecx %ebp *%ecx Listing 4.5.8: Add Funktion, compiliert nach x86 von GCC Listing 4.5.4 zeigt dasselbe Code-Fragment, compiliert mit dem Microsoft C-Compiler. Der erzeugte Code ist im Wesentlichen recht ähnlich. Die Tail- Recursion wird auch hier richtig erkannt, und der Funktionsaufruf durch ein jmp generiert, statt durch ein call. Im Unterschied zum GCC allerdings erzeugt der Microsoft Compiler keine pushl und popl Instruktionen. Damit ist dieser Ansatz für den Microsoft Compiler im Grunde genommen äquivalent zu dem Threaded Dispatcher, der die GCC Erweiterungen nutzt.

KAPITEL 4. UMSETZUNG 65 Listing 4.5.4 zeigt dieselbe Funktion, compiliert vom Microsoft C Compiler nach Arm Assembly. Auffällig ist hier, daß dieser Code den Funktions- Boilerplate enthält ( stmdb und ldmia). Besonders interessant ist auch, daß die ldmia nie erreicht wird, da der Prozessor vorher verzweigt (mov pc,r2 ist ein Sprung in Arm Schreibweise). Das bedeutet, daß der Stack-Abbau in ldmia nie durchgeführt wird, und der Stack in kurzer Zeit überlaufen würde. Also ist die Verwendung des Microsoft Compilers auf Arm nicht machbar für Tail Recursion. Listing 4.5.4 schliesslich zeigt dieselbe Funktion in Arm Assembler, diesmal compiliert von OS9 s C Compiler. Hier fällt auf, daß der Code nicht nur deutlich komplizierter ist, als Microsoft s Code. Insbesondere scheint dieser Compiler die Tail-Recursion nicht zu erkennen. Als letzte Instruktion findest sich ein ldmfd, das offenbar die Register, inkl. den Program Counter (PC) vom Stack restauriert (symmetrisch zur stmfd Instruktion). Alles in allem scheint eine solche Implementierung nur attraktiv zu sein für den Microsoft Compiler für x86. Alle anderen erkennen offenbar die Tail- Recursion nicht korrekt, oder lösen sie zumindest nicht elegant auf. Verwenden von Inline-Assembly Statt der &&label und goto *addr Syntax lässt sich Inline-Assembler-Code verwenden, um die gewünschte Funktionalität eingehen. An dieser Stelle soll darauf verzichtet werden, dies im Detail zu erläutern. Es soll nur festgestellt werden, daß diese Option nur im äußersten Notfall zur Anwendung kommen sollte, da es sich hierbei um hochgradig Prozessor-spezifischen Code handelt. Dies ist aufwendig zu entwickeln, und zwar für jede mögliche _Add PROC ; Line 14 mov eax, DWORD PTR _pc$[esp-4] mov ecx, DWORD PTR [eax] add DWORD PTR _acc, ecx add eax, 4 ; Line 15 lea edx, DWORD PTR [eax+4] mov eax, DWORD PTR [eax] mov DWORD PTR _pc$[esp-4], edx jmp eax Listing 4.5.9: Add Funktion, compiliert nach x86 von Microsoft CC

KAPITEL 4. UMSETZUNG 66 Add PROC ; Line 13 $LN5@Add stmdb sp!, {r4, lr} $M3286 ; Line 14 ldr r4, [pc, #0x20] ldr r1, [r0], #4 ; Line 15 ldr r3, [r4] ldr r2, [r0] add r3, r3, r1 str r3, [r4] add r0, r0, #4 mov lr, pc mov pc, r2 ; Line 16 ldmia sp!, {r4, pc} $LN6@Add DCD acc $M3287 ENDP ; Add Listing 4.5.10: Add Funktion, compiliert nach Arm von Microsoft CC

KAPITEL 4. UMSETZUNG 67 =Add mov R11,R13 stmfd R13!,0x4001 sub R13,R13,8 str R11,[R13] mov R8,R7 ldr R7,[R8],4 mov R9,(=_$s0)&0xfff00000 add R9,R9,(=_$s0)&0xff000 add R9,R9,R6 ldr R10,[R9,(=_$s0)&0xfff] add R10,R10,R7 str R10,[R9,(=_$s0)&0xfff] add R7,R8,4 ldr R8,[R8,0] mov R14,R15 mov R15,R8 add R13,R13,8 ldmfd R13!,0x8001 Listing 4.5.11: Add Funktion, compiliert nach Arm von Ultra-C

KAPITEL 4. UMSETZUNG 68 Prozessor-Plattform separat, und schwer zu handhaben. Grundsätzlich sollte auf Inline-Assembly verzichtet werden. Äquivalente Compiler Erweiterungen Es besteht eine gewisse Wahrscheinlichkeit, daß andere Compiler ähnliche Syntax-Erweiterungen kennen, mit deren Hilfe die Implementierung eines Threaded Dispatchers möglich ist. Dies müsste gegebenenfalls in einem späteren Projekt untersucht werden. Dies konnte aus Zeitgründen nicht im Rahmen dieser Arbeit durchgeführt werden. Compiler Optimierungen Manche Compiler unterstützen Optimierungsoptionen, die den Compiler veranlassen, einen While-Switch-Dispatcher in einen Threaded-Dispatcher zu compilieren. Beispielsweise unterstützt der GCC ein Flag -funswitch-loops, das zu diesem Zweck implementiert wurde (Es muss hier gesagt werden, daß dieses Flag laut einem Test offenbar nicht besonders gut funnktioniert, zumindest deutlich weniger Effekt hat, als die hier vorgestellte manuelle Implementierung). C Compiler können theoretisch bei einem dichten Switch-Statement die Schleifenbedingung in jeden Case Zweig eincompilieren und auf diese Weise Threaded-Dispatching unterstützen. Inwiefern das durch z.b: Microsoft C Compiler möglich ist, müsste untersucht werden. Das würde aber ebenfalls den Rahmen dieser Diplomarbeit sprengen. Verwenden von GCC GCC Portierungen gibt es für so gut wie alle Plattformen. JamaicaVM verwendet andere Compiler in der Regel nur dann, wenn diese performanteren Code liefern. Das ist zum Beispiel der Fall auf Windows CE mit dem Microsoft Compiler. Möglicherweise könnte die Verwendung von GCC für den Interpreter aber trotzdem sinnvoll sein. Es müsste untersucht werden, ob man verschiedene Compiler für verschiedene Programmteile verwenden kann, und trotzdem ein einheitliches Executable erzeugen kann. Auch dies soll nicht teil dieser Diplomarbeit sein. Fallback While-Switch-Loop Falls keine der oben genannten Optionen möglich ist, kann immer noch auf die bisherige While-Switch-Implementierung zurückgegriffen werden. Dies ist

KAPITEL 4. UMSETZUNG 69 relativ einfach durch bedingte Compilierung zu realisieren. Zum Beispiel definiert der GCC implizit das Makro GNUC, das man abfragen kann um GCC spezifischen Code zu compilieren. 4.6 Superinstructions Bei einem einfachen Interpreter (wie z.b. bei der JamaicaVM bisher zum Einsatz kam) wird jede Bytecode Instruktion einzeln ausgeführt. Zu diesem Zweck existiert für jeden Opcode ein Programmfragment zu dem der Interpreter verzweigt, wenn die entsprechende Instruktion im Bytecodestream erkannt wird. Die Idee von Superinstructions ist nun, häufig auftretende Gruppen von Bytecode Instruktionen sinnvoll zusammenzufassen und gemeinsam auszuführen. Beispielsweise könnte die Folge iload1 iload2 iadd istore1 (addiere die lokalen Variablen 1 und 2 und speichere das Ergebnis in der lokalen Variable 1) durch eine Instruktion iadd_1_2_1 ersetzt werden, die eben genau das macht, aber mit weniger Overhead. Setzen wir das eben genannte Beispiel fort und schauen uns an, was ein einfacher Interpreter alles machen muss um die Bytecodesequenz auszuführen: 1. Dispatch. 2. Lade lokale Variable 1. 3. Lege sie auf den Stack ab. 4. Dispatch. 5. Lade lokale Variable 2. 6. Lege sie auf den Stack ab. 7. Dispatch. 8. Hole einen Integer vom Stack. 9. Hole noch einen Integer vom Stack. 10. Addiere sie. 11. Speichere Ergebnis auf Stack. 12. Dispatch.

KAPITEL 4. UMSETZUNG 70 13. Hole einen Integer vom Stack. 14. Speichere Wert in lokale Variable 1. Pro Schritt muss mindestens ein Speicherzugriff ausgeführt werden. Beim Dispatchen kommen noch 3 Sprünge dazu, einer zum Bytecodehandler, einer ans Ende der Interpreter Schleife und einer zum Anfang der Interpreter Schleife (zur Dispatch-Optimierung in einem anderen Kapitel mehr). Das dispatchen ist besonders schlecht da es oft eine Entleerung der Prozessor- Pipeline bedeutet. Könnten wir die 4 Bytecode Instruktionen in einem einzigen Bytecode Handler ausführen hätten wir folgende Operationen: 1. Dispatch. 2. Lade lokale Variable 1. 3. Lade lokale Variable 2. 4. Addiere beide Werte. 5. Speichere Ergebnis in lokaler Variable 1. Wir haben also den Zugriff auf den Operandenstack für diese Sequenz vollkommen eliminiert sowie das Dispatching minimiert. Im folgenden soll erörtert werden, wie geeignete Sequenzen für Superinstructions gefunden werden können und wie Superinstructions im Interpreter umgesetzt werden können. 4.6.1 Funktionsweise von Superinstructions Die Implementierung von Superinstructions erfordert Eingriffe an zwei Stellen: 1. Im ClassLoader müssen bekannte Bytecodesequenzen erkannt und durch speziell kodierte Superinstructions ersetzt werden. Es wird also der Bytecode Stream einer Java Methode direkt im Arbeitsspeicher modifiziert indem neue bisher illegale Opcodes eingefügt werden. Es ist wichtig zu beachten das der resultierende Bytecode Stream kein legaler Java Bytecode mehr ist. Insbesondere muss jeglicher Programmcode der für eine Java Methode legalen Bytecode ausgeben soll angepasst werden, so daß Superinstructions wieder zurücktransformier werden. Das ist insbesondere für die Instrumentation API sowie für JVMTI (Java VM Tool Interface) relevant. Diese werden allerdings bisher nicht von der Jamaica VM unterstützt.

KAPITEL 4. UMSETZUNG 71 2. Im Interpreter müssen zusätzliche Bytecode Handler für die zusätzlichen Opcodes für Superinstructions hinzugefügt werden. Die Standard Bytecodes für Java VM Instruktionen belegen den Bereich 0x00 bis 0xc9. Der Bytecode 0xca ist für Breakpoints im Java Debugger reserviert. Die Opcodes 0xfe und 0xff sind durch die Java VM Spezifikation reserviert, werden aber nicht benutzt. Es sind also praktisch noch 53 Slots frei für 1-Byte lange Opcodes. Zusätzlich gäbe es die Möglichkeit, 2 Byte lange Opcodes einzuführen, sollte sich herausstellen, daß 1 Byte lange Opcodes nicht ausreichen. Dazu müsste ein Opcode reserviert werden (z.b: 0xfd), bei dem ein weiteres Byte dazu dient, zwischen weiteren maximal 255 Instruktionen zu verzweigen. Es ist allerdings unwahrscheinlich, daß dies nötig wird, da der Binärcode für den Interpreter schliesslich auch mit jedem neuen Bytecode Handler wächst, was zum einen den Speicherverbrauch negativ beeinflusst und ausserdem für Systeme mit kleinem Cache ungünstig ist. 4.6.2 Auswahl von Superinstructions Leider sieht die Java VM Spezifikation eine Vielzahl von Instruktionen zum Laden von und Speichern auf den Stack vor (z.b. iload_0, iload_1, iload_2, iload_3 sowie iload mit Parameter). Diese können nun zusammen mit den eigentlichen Operationen (z.b. iadd) eine überwältigende Vielzahl von Kombinationen bilden. Bezieht man nun noch mit ein, daß nicht alle Operationen nach dem Muster load load do store gestrickt sind, sondern daß oftmals schon ein Wert aus einer vorigen Operation auf dem Stack vorhanden ist (load do store) oder das Ergebnis nicht in einer lokalen Variable gespeichert sondern an die nächste Operation weitergereicht wird (load load do), wird deutlich, daß es keinen Sinn macht, alle möglichen Superinstruktionen zu implementieren. In der Realität werden natürlich bei weitem nicht alle Kombinationen verwendet. Vielmehr gibt es Sequenzen die häufiger bzw. weniger häufig vorkommen. Um eine Vorstellung zu bekommen, welche Bytecode Sequenzen besonders häufig vorkommen, und die es daher wert wären als Superinstruktion zu implementieren, scheint es sinnvoll eine Reihe von einfachen Statistiken zu erstellen. Zunächst einmal soll ermittelt werden, welche Muster einer vorgegebenen Größe wie häufig ausgeführt werden. Zu diesem Zweck habe ich einige Funktionen in den Interpreter eingefügt: superstat(char) wird für jede Bytecode Instruktion aufgerufen, um die Statistik zu führen, und printsuperstats() wird alle 1000000 Bytecodes aufgerufen um die Statistik auf der Konsole auszugeben.

KAPITEL 4. UMSETZUNG 72 Die Funktion superstat(char) verwaltet ein kleines Array in dem die letzten N Bytecodes im Stream gespeichert werden. Bei jedem neuen Aufruf wird das Array verschoben und vorn der neue Bytecode eingefügt. Dieses Array repräsentiert das aktuelle Muster. Dieses wird dann in einer Datenstruktur, die die Häufigkeiten für jedes Muster speichert, gesucht, und der zugehörige Zähler um 1 inkrementiert. Weiterhin wird diese Liste durch eine Bubblesort-artige Methode sortiert, aber nicht die ganze Liste auf einmal, sondern es wird nur der betreffende Eintrag N mit dem vorigen N-1 vertauscht, falls die Zähler indizieren, daß N-1 weniger häufig auftrat als N. Auf diese Weise erhält man keine perfekt sortierte Liste, aber eine einigermaßen sortierte Liste mit dem Effekt, daß die Suche angenehm schnell geht. Zumindest schnell genug für diese Zwecke. Weitere Optimierungen sind hier nicht notwendig, da es sich ja um eine einmalige Statistik handelt. Die Ergebnisse sind in den Tabellen 4.5 bis 4.7 zusammengefasst. Aus Platzgründen führe ich hier jeweils die häufigsten Bytecode Sequenzen auf. Aus allen drei Statistiken zusammen kann man folgende Schlüsse ableiten: Wie schon in den Bytecode-Statistiken vermutet, spielen Sequenzen mit aload_0 eine besonders kritische Rolle. Besonders auffällig sind dabei Sequenzen mit aload_0 getfield. Es gibt eine unwahrscheinliche Vielzahl möglicher Kombinationen von Bytecodes. Aufgrund der Vielzahl der möglichen Kombinationen sowie der zeitlichen Beschränkung des Projekts wurde im Rahmen der Diplomarbeit nur ein spezieller Fall exemplarisch betrachtet. Es wurde die performanz-intensivste Sequenz aload_0 getfield ausgewählt und implementiert. Dies dient unter anderem auch der Evaluierung des Performanzgewinns im Verhältnis zum Implementierungsaufwand. 4.6.3 Implementierung Die Implementierung von Superinstructions soll hier am Beispiel der Sequenz aload_0 getfield erläutert werden. Im ersten Schritt wurde der Klassen-Lade-Code erweitert um eine zusätzliche Routine, die den geladenen Bytecode nach Superinstruction-Sequenzen scannt und gefundene Sequenzen durch einen neuen Bytecode ersetzt wie im folgenden Code-Fragment angedeutet. while (i < code_length) { int8 bc = jamaicaclasses_getbytecode(method, i);

KAPITEL 4. UMSETZUNG 73 Bytecodesequenz Häufigkeit aload 0 getfield 697444222 dup getfield 224301841 aload 0 dup 220038104 getfield aload 0 186227691 sipush iand 171058186 getfield iload 1 156634596 iadd istore 127043311 putfield aload 0 124628333 istore iload 113965471 iload iload 112795555 iadd putfield 110438634 aload 1 iload 2 109154957 iload aload 1 96003533 iconst 1 iadd 88347803 iinc iload 87780679 istore iinc 87603469 iinc baload 86219386 iload 2 iinc 86152449 baload sipush 82723689 getfield dup x1 81391739 dup x1 iconst 1 81048562 iload iadd 74594904 putfield return 64977071 getfield invokevirtual 57541864 iload bipush 56459049 getfield iload 2 56212640 iload ifge 54496697 iand iadd 52222210 bipush iand 50213121 dup istore 2 49715590 getfield iconst 1 48582341 iload 3 ifge 47371328 iconst 1 isub 45485670 aload 0 aload 1 41462845 getstatic iload 41162105 iinc iload 3 41000931 bipush iushr 39229050 isub putfield 38829019 iload 1 isub 38412546 ixor istore 37797922 Tabelle 4.5: Superinstruction Statistik für 2-Bytecode-Sequenzen

KAPITEL 4. UMSETZUNG 74 Bytecodesequenz Häufigkeit aload 0 dup getfield 218156726 aload 0 getfield aload 0 166580757 getfield aload 0 getfield 116564905 iload aload 1 iload 2 87713271 aload 1 iload 2 iinc 86116520 iload 2 iinc baload 84947182 iconst 1 iadd putfield 82772541 baload sipush iand 82728337 getfield dup x1 iconst 1 81054039 dup getfield dup x1 81053986 dup x1 iconst 1 iadd 80689770 aload 0 getfield iload 1 79734700 dup getfield iload 1 75696359 iadd istore iload 71373851 getfield aload 0 dup 69238152 istore iload iload 58339910 aload 0 getfield invokevirtual 56317001 aload 0 getfield iload 2 56105768 iload iadd istore 55139560 iload iload iadd 54880636 iinc iload iflt 52924490 sipush iand iadd 52225845 iinc baload sipush 51319724 iadd istore goto 50569756 iand iadd istore 50507853 putfield aload 0 getfield 50237441 putfield aload 0 dup 45740263 aload 0 getfield iconst 1 38322101 iinc iload 3 iflt 38056529 iload bipush iushr 36747219 baload ixor sipush 34460976 ixor sipush iand 34460976 sipush iand iaload 34412683 bipush iushr ixor 34406340 iaload iload bipush 34405988 iand iaload iload 34405981 getstatic iload aload 1 34405980 iinc baload ixor 34405980 iushr ixor istore 34405980 ixor istore goto 34405980 iadd putfield baload 34332422 getfield iload 1 if icmpge 33717195 iconst 1 isub iand 33692372 ishl iconst 1 isub 33692344 getfield iconst 1 iload 1 33687051 Tabelle 4.6: Superinstruction Statistik für 3-Bytecode-Sequenzen

KAPITEL 4. UMSETZUNG 75 Bytecodesequenz Häufigkeit getfield aload 0 dup getfield 288910467 aload 0 getfield aload 0 getfield 275354719 aload 0 dup getfield iconst 1 250895946 dup getfield iconst 1 isub 237840009 aload iload aload 0 iload 167465558 iload aload 0 iload invokevirtual 167312458 aload 0 getfield getfield aload 0 156129835 getfield iconst 1 isub dup x1 153012863 iconst 1 isub dup x1 putfield 153012863 isub dup x1 putfield aaload 152600095 dup x1 putfield aaload astore 1 152595722 getfield getfield aload 0 dup 152557533 putfield aaload astore 1 goto 152545767 dup getfield dup x1 iconst 1 148839404 aload 0 dup getfield dup x1 148568855 dup x1 iconst 1 iadd putfield 148486714 getfield dup x1 iconst 1 iadd 148486581 aload 0 getfield aload 0 dup 136352936 iload 3 iload iadd aload 1 117696155 aload iload 3 iload iadd 117658502 iload iadd aload 1 iload 117658502 iadd aload 1 iload invokevirtual 117658502 iconst 1 iadd putfield iload 1 95154586 aload 0 aload 1 putfield aload 0 90197065 aload 0 getfield astore 1 aload 1 89203487 getfield astore 1 aload 1 ifnonnull 88635071 getfield iload 1 aload 0 getfield 86162626 getfield iconst 1 isub putfield 85059834 putfield aload 0 getfield iconst 1 83292310 aload 1 putfield aload 0 getfield 79597545 aload 0 aload 1 getfield putfield 78609379 getfield aload 0 getfield getfield 78099499 aload 1 getfield putfield aload 0 77218391 aload 0 getfield iinc iload 77018688 getfield iinc iload iaload 77018687 iinc iload iaload tableswitch 76940196 aload 0 aload 0 getfield invokeinterface 76882547 aload 0 getfield iload 1 aload 0 76248500 isub putfield aload 0 getfield 76096893 getfield putfield aload 0 aload 1 76044579 Tabelle 4.7: Superinstruction Statistik für 4-Bytecode-Sequenzen

KAPITEL 4. UMSETZUNG 76 } int32 next_i = i + sizeofbc(method, i); /* Replace a sequence of aload_0 and getfield by optimized * superinstruction getfield_this. */ if (prev == O_aload_0 && bc == O_getield ) { setbytecode(method, i - 1, O_getfield_this); } prev = bc; i = next_i; Eine Bytecode-Sequenz aload_0 getfield $X $Y (wobei XandY zusammen den Field-Index des zugegriffenen Feldes ergeben) wird also ersetzt durch eine Sequenz getfield_this getfield $X $Y. Weiterhin wird ein neuer Bytecode-Handler für getfield_this benötigt, der, vereinfacht gesagt, wie folgt aussieht: getfield_this ( $bnbc $soff -- ) (0xd7) jamaica_ref o; /* This corresponds to the aload_0 part. */ JAMAICA_LOCAL_R(ct, 0, o); do_bc_getfield(ct, soff, o); Der Bytecode-Handler überspringt dabei den alten getfield Opcode durch den Parameter $bnbc. Danach wird wie beim normalen getfield der Field-Offset eingelesen. In der Implementierung des Handlers wird nun die lokale Variable 0 gelesen und direkt an die Funktion do_bc_getfield weitergereicht, statt sie auf den (Java-) Stack zwischenzuspeichern und wieder zu lesen, wie es bei einer normalen aload_0 getfield Sequenz geschehen würde. 4.6.4 Mögliche Probleme Ein sehr wichtiges Problem bei der Implementierung von Superinstructions sind Verzweigungen aller Art. Es kann passieren, daß ein Sprung, z.b. ein if Verzweigung, in die Mitte einer Bytecode-Sequenz springt. Dies würde die Superinstruction gewissermaßen aufbrechen und kann zu Fehlern führen, wenn die ursprüngliche Instruktionen ungünstig ersetzt wurden. Es bieten sich prinzipiell zwei Lösungsmöglichkeiten zur Lösung dieses Problems an: Der Klassen-Lader kann eine strukturelle Analyse des gesamten Codes einer Methode durchführen, und die Ersetzung nur vornehmen, wenn

KAPITEL 4. UMSETZUNG 77 festgestellt wird, daß keine Verzweigung in eine Superinstruction hineinspringt. Die Ersetzung kann auf eine Weise vorgenommen werden, die kompatibel mit Sprüngen ist. Das heisst, daß wenn eine Verzweigung in die Mitte einer Superinstruktion springt, die ursprüngliche Funktionalität ausgeführt wird. Im Fall der getfield_this Implementierung wurde die zweite Möglichkeit gewählt, da sie wesentlich einfacher zu implementieren ist. Wenn der Interpreter auf den getfield_this stößt, dann führt er den optimierten Handler aus. Dieser überspringt schlichtweg die folgende ursprüngliche getfield Instruktion, die danach folgt. Springt allerdings der Interpreter durch eine Verzweigung direkt zu der getfield Instruktion, so wird diese ganz normal ausgeführt. Es wurde also keine Änderung an der Struktur der Bytecodes sowie deren Semantik durchgeführt.

Kapitel 5 Ergebnisse 5.1 Auswertung der verschiedenen Technologien Im vorigen Abschnitt wurden die Performanz-Verbesserungen auf verschiedenen Zielsystemen gegenübergestellt und diskutiert. In diesem Abschnitt sollen die verschiedenen angewendeten Techniken betrachtet werden. Das Ziel soll sein, festzustellen, welche Technik die meiste Performanz eingebracht hat, und warum. Als Grundlage für die Performanzbestimmung dient selbstverständlich wieder das Dacapo Benchmark. Im Gegensatz zum vorigen Abschnitt werden hier der Einfachheit und Vergleichbarkeit halber alle Benchmarks auf ein und demselben System ausgeführt. Es handelt sich in diesem Fall um ein Linux System mit einem AMD Athlon Prozessor, der mit 1500MHz getaktet ist, und 768 MB Arbeitsspeicher besitzt. Die bestimmten Werte sind jeweils relativ zu verstehen, immer einmal mit und einmal ohne die betreffenden Optimierung. Die jeweilige Performanz- Differenz kann dann später verwendet werden, um die verschiedenen Techniken einmal gegenüberzustellen. Weiterhin wurde versucht, die Performanz- Messungen in der Reihenfolge durchzuführen, in der sie implementiert wurden. Das heisst, die Ergebnisse sind auch untereinander relativ zu verstehen. Z.B. sind bei den Messungen für die Superinstructions alle vorigen Optimierungen auch eingeschaltet, die Messungen für die Stackzugriffe allerdings haben ansonsten keinerlei Optimierung. Dies ermöglicht auch einen chronologischen Vergleich und tut den Einzelergebnissen keinen Nachteil. 78

KAPITEL 5. ERGEBNISSE 79 5.1.1 Optimierung der Stack-Zugriffe Der erste Schritt bei der Performanz-Optimierung bestand aus Code-Cleanup, der Einführung des Interpreter-Generators sowie damit einhergehend eine Konsolidierung und Optimierung des Stack-Zugriffe innerhalb der Bytecode- Handler. Die Tabelle 5.1 stellt das Performanz-Verhalten zu Beginn des Projekts dar, sowie das Verhalten nach Implementierung dieser ersten Optimierungen. Es ist leider nicht möglich, die einzelnen Aspekte (VM-Generator, Stack- Zugriffe und Cleanup) einzeln zu betrachten, weil diese Hand-in-Hand gingen. Beispielsweise wurde die Optimierung des Stack-Zugriffs z.t. erst durch Verwendung des VM-Generators sinnvoll machbar. Es lässt sich feststellen, daß es einen durchschnittlichen Performanz-Gewinn von ca. 10% gibt. Es sollte darauf hingewiesen werden, daß ein Benchmark (jython) sogar eine Verschlechterung der Performant zeigt. Dies lässt sich auch bei wiederholten Benchmarks zeigen, es handelt sich hier also nicht um eine zufällige einmalige Abweichung in der Messung. Leider blieb mir im Zeitraum der Diplomarbeit nicht die Zeit, die Ursachen dafür zu analysieren. Benchmark Ohne Optimierung Mit Stack-Optimierung antlr 225412 186126 bloat 1384071 975245 fop 109345 76269 hsqldb 348264 269348 jython 803442 920174 luindex 975044 829690 lusearch 776102 775507 pmd 580862 563640 Gesamt 5202542 4595999 Differenz 606543 (11,6 %) Tabelle 5.1: Performanz mit und ohne Stack Optimierung 5.1.2 Threaded Dispatching Die zweite große Optimierung war die Implementierung des Threaded Dispatching. Die Tabelle 5.2 fasst die Performanz-Verbesserung dieser Optimierung zusammen. Es lässt sich zum einen feststellen, daß das Threaded Dispatching die bisher beste Performanz-Verbesserung gebracht hat. Unglücklicherweise lässt sich Threaded Dispatching nicht auf allen Plattformen ohne weiteres

KAPITEL 5. ERGEBNISSE 80 implementieren, wie im entsprechenden Abschnitt erläutert. Diese Performanzverbesserung kommt also vorerst nur Plattformen zugute, auf denen Jamaica mit dem GCC übersetzt wird. Benchmark Ohne Threading Mit Threading antlr 189411 142800 bloat 1039665 762711 fop 78552 59220 hsqldb 290995 207195 jython 928942 682627 luindex 1038590 659046 lusearch 865004 613854 pmd 606121 440107 Gesamt 5037280 3567560 Differenz 1469720 (29,1 %) Tabelle 5.2: Performanz mit und ohne Threaded Dispatching 5.1.3 Superinstructions In Tabelle 5.3 sind die Performanzwerte der Dacapo Benchmarks jeweils mit und ohne Superinstructions Unterstützung zusammengefasst. Zur Messung wurde einmal die Endversion des Jamaica Interpreters verwendet, und einmal der Code zur Generierung der Superinstructions auskommentiert. Die Optimierung für die Stack-Zugriffe und Threaded Dispatching sind bei beiden Messungen eingeschaltet. Es zeigt sich, daß die Implementierung von Superinstructions ungefähr 10% Performanzgewinn bringt. Es muss hier betont werden, daß nur sehr wenige (wichtige) Superinstructions implementiert wurden. Das Ergebnis kann sicher durch Implementierung weiterer Superinstruction verbessert werden (siehe auch Kapitel 6. 5.1.4 Zusammenfassung Die unterschiedlichen Optimierungstechniken haben in unterschiedlichem Grade zur Verbesserung der Gesamtperformanz der Jamaica VM beigetragen. Folgende Charakteristiken liessen sich feststellen: Die Optimierung des Stack-Zugriffs und die Verwendung eines generierten Interpreters führten zwar insgesamt zu einer Verbesserung von ca. 10%, aber interessanterweise sind einzelne Benchmarks trotzdem langsamer geworden.

KAPITEL 5. ERGEBNISSE 81 Den größten Performanzgewinn hat bisher die Implementierung des Threaded Dispatching gebracht. Das Threaded Dispatching bewirkt eine deutliche Verringerung des Dispatch-Overheads. Die Verringerung der Anzahl der Sprünge, die der Prozessor pro Dispatch-Vorgang ausführen muss führt zu einem deutlich besseren Vorhersageverhalten in der Prozessor- Pipeline. Insgesamt kann ein Performanzgewinn von ca 20% festgestellt werden. Nachteilig ist allerdings die schlechte Portabilität. Die im Rahmen dieser Diplomarbeit umgesetzte Implementierung verwendet GCC-spezifische Erweiterungen der C-Syntax, um Threaded Dispatching implementieren zu können. Alternativen für Plattformen mit einem anderen Compiler als dem GCC wurden erörtert und sollten in einem Folgeprojekt implementiert werden. Die Implementierung einiger wichtiger Superinstructions führte zu einer Performanzsteigerung von ca 10%. Die nötige Infrastruktur für die Implementierung von Superinstructions wurde implementiert. Es spricht also nicht viel dagegen, weitere Superinstructionen zu implementieren. Es muss allerdings abgewogen werden, wie weit man hier gehen möchte, da jede zusätzliche Superinstruktion selbstverständlich das Interpreter-Executable aufbläht. Dies kann auf manchen Systemen sogar kontraproduktiv sein, nämlich wenn dies zu einem schlechteren Prozessor-Cache-Verhalten führt (vor allem bei sehr kleinen Prozessor- Caches). In 5.2 wird die Performanz des verbesserten Interpreters insgesamt mit der Performanz von der letzten Jamaica Release Version 3.0 sowie der Jamaica Entwicklerversion 3.1 alpha zu Beginn des Projektes gegenübergestellt. Es Benchmark Ohne Superinstructions Mit Superinstructions antlr 142800 127718 bloat 762711 654271 fop 59220 54240 hsqldb 207195 188434 jython 682627 639194 luindex 659046 580718 lusearch 613854 543723 pmd 440107 398508 Gesamt 3567560 3186806 Differenz 380754 (10,6 %) Tabelle 5.3: Performanz mit und ohne Superinstructions

KAPITEL 5. ERGEBNISSE 82 lässt sich zusammenfassend sagen, daß die Performanz gegenüber Projektbeginn um ca. 40% gesteiger werden konnte und gegenüber der JamaicaVM Version 3.0 sogar um ca. 45%. Abbildung 5.1: Vergleich der Optimierungstechniken Abbildung 5.2: Vergleich der Jamaica Versionen