Tauchen Sie ein in den neuen Java JIT Compiler - Graal

1. Übersicht

In diesem Tutorial werden wir uns den neuen Java Just-In-Time (JIT) -Compiler namens Graal genauer ansehen.

Wir werden sehen, was das Projekt Graal ist und einen seiner Teile beschreiben, einen dynamischen Hochleistungs-JIT-Compiler.

2. Was ist ein JIT- Compiler?

Lassen Sie uns zunächst erklären, was der JIT-Compiler tut.

Wenn wir unser Java-Programm kompilieren (z. B. mit dem Befehl javac ), wird unser Quellcode in die binäre Darstellung unseres Codes kompiliert - einen JVM-Bytecode . Dieser Bytecode ist einfacher und kompakter als unser Quellcode, aber herkömmliche Prozessoren in unseren Computern können ihn nicht ausführen.

Um ein Java-Programm ausführen zu können, interpretiert die JVM den Bytecode . Da Interpreter normalerweise viel langsamer sind als nativer Code, der auf einem realen Prozessor ausgeführt wird, kann die JVM einen anderen Compiler ausführen, der nun unseren Bytecode in den Maschinencode kompiliert, der vom Prozessor ausgeführt werden kann . Dieser sogenannte Just-in-Time-Compiler ist viel ausgefeilter als der Javac- Compiler und führt komplexe Optimierungen durch, um qualitativ hochwertigen Maschinencode zu generieren.

3. Detaillierterer Blick in den JIT-Compiler

Die JDK-Implementierung von Oracle basiert auf dem Open-Source-OpenJDK-Projekt. Dies schließt die virtuelle HotSpot-Maschine ein , die seit Java Version 1.3 verfügbar ist. Es enthält zwei herkömmliche JIT-Compiler: den Client-Compiler, auch C1 genannt, und den Server-Compiler, opto oder C2 genannt .

C1 ist so konzipiert, dass es schneller ausgeführt wird und weniger optimierten Code erzeugt, während C2 etwas länger für die Ausführung benötigt, aber einen besser optimierten Code erzeugt. Der Client-Compiler eignet sich besser für Desktop-Anwendungen, da keine langen Pausen für die JIT-Kompilierung erforderlich sind. Der Server-Compiler eignet sich besser für Serveranwendungen mit langer Laufzeit, die mehr Zeit für die Kompilierung aufwenden können.

3.1. Tiered Compilation

Heutzutage verwendet die Java-Installation beide JIT-Compiler während der normalen Programmausführung.

Wie im vorherigen Abschnitt erwähnt , startet unser von javac kompiliertes Java-Programm seine Ausführung in einem interpretierten Modus. Die JVM verfolgt jede häufig aufgerufene Methode und kompiliert sie. Zu diesem Zweck wird C1 für die Kompilierung verwendet. Der HotSpot behält jedoch die zukünftigen Aufrufe dieser Methoden im Auge. Wenn die Anzahl der Aufrufe zunimmt, kompiliert die JVM diese Methoden erneut, diesmal jedoch mit C2.

Dies ist die vom HotSpot verwendete Standardstrategie, die als gestufte Kompilierung bezeichnet wird .

3.2. Der Server-Compiler

Konzentrieren wir uns jetzt ein wenig auf C2, da es das komplexeste von beiden ist. C2 wurde extrem optimiert und produziert Code, der mit C ++ konkurrieren oder sogar noch schneller sein kann. Der Server-Compiler selbst ist in einem bestimmten C ++ - Dialekt geschrieben.

Es gibt jedoch einige Probleme. Aufgrund möglicher Segmentierungsfehler in C ++ kann die VM abstürzen. Außerdem wurden in den letzten Jahren keine wesentlichen Verbesserungen im Compiler implementiert. Der Code in C2 ist schwierig zu pflegen, sodass wir mit dem aktuellen Design keine neuen wesentlichen Verbesserungen erwarten konnten. In diesem Sinne wird der neue JIT-Compiler im Projekt GraalVM erstellt.

4. Projekt GraalVM

Projekt GraalVM ist ein von Oracle erstelltes Forschungsprojekt. Wir können Graal als mehrere verbundene Projekte betrachten: einen neuen JIT-Compiler, der auf HotSpot aufbaut, und eine neue virtuelle Polyglot-Maschine. Es bietet ein umfassendes Ökosystem, das eine Vielzahl von Sprachen unterstützt (Java und andere JVM-basierte Sprachen; JavaScript, Ruby, Python, R, C / C ++ und andere LLVM-basierte Sprachen).

Wir werden uns natürlich auf Java konzentrieren.

4.1. Graal - ein in Java geschriebener JIT-Compiler

Graal ist ein leistungsstarker JIT-Compiler. Es akzeptiert den JVM-Bytecode und erzeugt den Maschinencode.

Das Schreiben eines Compilers in Java bietet mehrere wichtige Vorteile. Zuallererst Sicherheit, dh keine Abstürze, sondern Ausnahmen und keine echten Speicherlecks. Darüber hinaus verfügen wir über eine gute IDE-Unterstützung und können Debugger, Profiler oder andere praktische Tools verwenden. Außerdem kann der Compiler unabhängig vom HotSpot sein und eine schnellere JIT-kompilierte Version von sich selbst erstellen.

Der Graal-Compiler wurde unter Berücksichtigung dieser Vorteile erstellt. Es verwendet die neue JVM-Compiler-Schnittstelle - JVMCI, um mit der VM zu kommunizieren . Um die Verwendung des neuen JIT-Compilers zu ermöglichen, müssen Sie die folgenden Optionen festlegen, wenn Sie Java über die Befehlszeile ausführen:

-XX:+UnlockExperimentalVMOptions -XX:+EnableJVMCI -XX:+UseJVMCICompiler

Dies bedeutet, dass wir ein einfaches Programm auf drei verschiedene Arten ausführen können: mit den regulären gestuften Compilern, mit der JVMCI-Version von Graal auf Java 10 oder mit der GraalVM selbst .

4.2. JVM-Compiler-Schnittstelle

Die JVMCI ist seit JDK 9 Teil des OpenJDK, sodass wir jedes Standard-OpenJDK- oder Oracle-JDK verwenden können, um Graal auszuführen.

Mit JVMCI können wir die standardmäßige gestufte Kompilierung ausschließen und unseren brandneuen Compiler (dh Graal) anschließen, ohne dass Änderungen an der JVM erforderlich sind.

Die Schnittstelle ist recht einfach. Wenn Graal eine Methode kompiliert, wird der Bytecode dieser Methode als Eingabe an die JVMCI übergeben. Als Ausgabe erhalten wir den kompilierten Maschinencode. Sowohl die Eingabe als auch die Ausgabe sind nur Byte-Arrays:

interface JVMCICompiler { byte[] compileMethod(byte[] bytecode); }

In realen Szenarien benötigen wir normalerweise weitere Informationen wie die Anzahl der lokalen Variablen, die Stapelgröße und die Informationen, die bei der Profilerstellung im Interpreter gesammelt wurden, damit wir wissen, wie der Code in der Praxis ausgeführt wird.

Wenn Sie compileMethod () der JVMCICompiler- Schnittstelle aufrufen, müssen Sie im Wesentlichen ein CompilationRequest- Objekt übergeben. Es gibt dann die Java-Methode zurück, die wir kompilieren möchten, und in dieser Methode finden wir alle Informationen, die wir benötigen.

4.3. Graal in Aktion

Graal selbst wird von der VM ausgeführt, sodass es zuerst interpretiert und JIT-kompiliert wird, wenn es heiß wird. Schauen wir uns ein Beispiel an, das auch auf der offiziellen Website von GraalVM zu finden ist:

public class CountUppercase { static final int ITERATIONS = Math.max(Integer.getInteger("iterations", 1), 1); public static void main(String[] args) { String sentence = String.join(" ", args); for (int iter = 0; iter < ITERATIONS; iter++) { if (ITERATIONS != 1) { System.out.println("-- iteration " + (iter + 1) + " --"); } long total = 0, start = System.currentTimeMillis(), last = start; for (int i = 1; i < 10_000_000; i++) { total += sentence .chars() .filter(Character::isUpperCase) .count(); if (i % 1_000_000 == 0) { long now = System.currentTimeMillis(); System.out.printf("%d (%d ms)%n", i / 1_000_000, now - last); last = now; } } System.out.printf("total: %d (%d ms)%n", total, System.currentTimeMillis() - start); } } }

Jetzt kompilieren wir es und führen es aus:

javac CountUppercase.java java -XX:+UnlockExperimentalVMOptions -XX:+EnableJVMCI -XX:+UseJVMCICompiler

This will result in the output similar to the following:

1 (1581 ms) 2 (480 ms) 3 (364 ms) 4 (231 ms) 5 (196 ms) 6 (121 ms) 7 (116 ms) 8 (116 ms) 9 (116 ms) total: 59999994 (3436 ms)

We can see that it takes more time in the beginning. That warm-up time depends on various factors, such as the amount of multi-threaded code in the application or the number of threads the VM uses. If there are fewer cores, the warm-up time could be longer.

If we want to see the statistics of Graal compilations we need to add the following flag when executing our program:

-Dgraal.PrintCompilation=true

This will show the data related to the compiled method, the time taken, the bytecodes processed (which includes inlined methods as well), the size of the machine code produced, and the amount of memory allocated during compilation. The output of the execution takes quite a lot of space, so we won't show it here.

4.4. Comparing with the Top Tier Compiler

Let's now compare the above results with the execution of the same program compiled with the top tier compiler instead. To do that, we need to tell the VM to not use the JVMCI compiler:

java -XX:+UnlockExperimentalVMOptions -XX:+EnableJVMCI -XX:-UseJVMCICompiler 1 (510 ms) 2 (375 ms) 3 (365 ms) 4 (368 ms) 5 (348 ms) 6 (370 ms) 7 (353 ms) 8 (348 ms) 9 (369 ms) total: 59999994 (4004 ms)

We can see that there is a smaller difference between the individual times. It also results in a briefer initial time.

4.5. The Data Structure Behind Graal

As we said earlier, Graal basically turns a byte array into another byte array. In this section, we'll focus on what's behind this process. The following examples are relying on Chris Seaton's talk at JokerConf 2017.

Basic compiler's job, in general, is to act upon our program. This means that it must symbolize it with an appropriate data structure. Graal uses a graph for such a purpose, the so-called program-dependence-graph.

In a simple scenario, where we want to add two local variables, i.e., x + y, we would have one node for loading each variable and another node for adding them. Beside it, we'd also have two edges representing the data flow:

The data flow edges are displayed in blue. They're pointing out that when the local variables are loaded, the result goes into the addition operation.

Let's now introduce another type of edges, the ones that describe the control flow. To do so, we'll extend our example by calling methods to retrieve our variables instead of reading them directly. When we do that, we need to keep track of the methods calling order. We'll represent this order with the red arrows:

Here, we can see that the nodes didn't change actually, but we have the control flow edges added.

4.6. Actual Graphs

We can examine the real Graal graphs with the IdealGraphVisualiser. To run it, we use the mx igv command. We also need to configure the JVM by setting the -Dgraal.Dump flag.

Let's check out a simple example:

int average(int a, int b) { return (a + b) / 2; }

This has a very simple data flow:

In the graph above, we can see a clear representation of our method. Parameters P(0) and P(1) flow into the add operation which enters the divide operation with the constant C(2). Finally, the result is returned.

We'll now change the previous example to be applicable to an array of numbers:

int average(int[] values) { int sum = 0; for (int n = 0; n < values.length; n++) { sum += values[n]; } return sum / values.length; }

We can see that adding a loop led us to the much more complex graph:

What we can notice here are:

  • the begin and the end loop nodes
  • the nodes representing the array reading and the array length reading
  • data and control flow edges, just as before.

This data structure is sometimes called a sea-of-nodes, or a soup-of-nodes. We need to mention that the C2 compiler uses a similar data structure, so it's not something new, innovated exclusively for Graal.

It is noteworthy remember that Graal optimizes and compiles our program by modifying the above-mentioned data structure. We can see why it was an actually good choice to write the Graal JIT compiler in Java: a graph is nothing more than a set of objects with references connecting them as the edges. That structure is perfectly compatible with the object-oriented language, which in this case is Java.

4.7. Ahead-of-Time Compiler Mode

It is also important to mention that we can also use the Graal compiler in the Ahead-of-Time compiler mode in Java 10. As we said already, the Graal compiler has been written from scratch. It conforms to a new clean interface, the JVMCI, which enables us to integrate it with the HotSpot. That doesn't mean that the compiler is bound to it though.

One way of using the compiler is to use a profile-driven approach to compile only the hot methods, but we can also make use of Graal to do a total compilation of all methods in an offline mode without executing the code. This is a so-called “Ahead-of-Time Compilation”, JEP 295, but we'll not go deep into the AOT compilation technology here.

Der Hauptgrund, warum wir Graal auf diese Weise verwenden würden, besteht darin, die Startzeit zu beschleunigen, bis der reguläre Tiered Compilation-Ansatz im HotSpot die Oberhand gewinnen kann.

5. Schlussfolgerung

In diesem Artikel haben wir die Funktionen des neuen Java JIT-Compilers als Teil des Projekts Graal untersucht.

Wir haben zuerst traditionelle JIT-Compiler beschrieben und dann neue Funktionen des Graal besprochen, insbesondere die neue JVM-Compiler-Oberfläche. Anschließend haben wir gezeigt, wie beide Compiler funktionieren, und ihre Leistungen verglichen.

Danach haben wir über die Datenstruktur gesprochen, mit der Graal unser Programm manipuliert, und schließlich über den AOT-Compilermodus als eine andere Möglichkeit, Graal zu verwenden.

Wie immer ist der Quellcode auf GitHub zu finden. Denken Sie daran, dass die JVM mit den spezifischen Flags konfiguriert werden muss, die hier beschrieben wurden.