Menyelam Lebih Dalam ke Compiler JIT Java Baru - Graal

1. Ikhtisar

Dalam tutorial ini, kita akan melihat lebih dalam pada compiler Java Just-In-Time (JIT) baru, yang disebut Graal.

Kita akan melihat apa proyek Graal itu dan menjelaskan salah satu bagiannya, compiler JIT dinamis berkinerja tinggi.

2. Apa itu Compiler JIT ?

Pertama mari kita jelaskan apa yang dilakukan kompilator JIT.

Ketika kita mengkompilasi program Java kita (mis., Menggunakan perintah javac ), kita akan berakhir dengan kode sumber kita yang dikompilasi ke dalam representasi biner dari kode kita - bytecode JVM . Bytecode ini lebih sederhana dan lebih ringkas daripada kode sumber kami, tetapi prosesor konvensional di komputer kami tidak dapat menjalankannya.

Untuk dapat menjalankan program Java, JVM menginterpretasikan bytecode . Karena interpreter biasanya jauh lebih lambat daripada kode asli yang dijalankan pada prosesor sebenarnya, JVM dapat menjalankan kompilator lain yang sekarang akan mengkompilasi bytecode kita ke dalam kode mesin yang dapat dijalankan oleh prosesor . Yang disebut kompilator just-in-time ini jauh lebih canggih daripada kompilator javac , dan menjalankan pengoptimalan kompleks untuk menghasilkan kode mesin berkualitas tinggi.

3. Melihat Lebih Mendetail ke Kompiler JIT

Implementasi JDK oleh Oracle didasarkan pada proyek OpenJDK open-source. Ini termasuk mesin virtual HotSpot , tersedia sejak Java versi 1.3. Ini berisi dua kompiler JIT konvensional: kompilator klien, juga disebut C1 dan kompilator server, disebut opto atau C2 .

C1 dirancang untuk berjalan lebih cepat dan menghasilkan kode yang kurang dioptimalkan, sedangkan C2, di sisi lain, membutuhkan lebih banyak waktu untuk dijalankan tetapi menghasilkan kode yang dioptimalkan dengan lebih baik. Kompiler klien lebih cocok untuk aplikasi desktop karena kami tidak ingin memiliki jeda yang lama untuk kompilasi JIT. Kompiler server lebih baik untuk aplikasi server yang berjalan lama yang dapat menghabiskan lebih banyak waktu untuk kompilasi.

3.1. Kompilasi Bertingkat

Saat ini, instalasi Java menggunakan kedua kompiler JIT selama eksekusi program normal.

Seperti yang kami sebutkan di bagian sebelumnya, program Java kami, yang dikompilasi oleh javac , memulai eksekusinya dalam mode interpreted. JVM melacak setiap metode yang sering dipanggil dan mengkompilasinya. Untuk melakukan itu, ia menggunakan C1 untuk kompilasi. Tapi, HotSpot masih mengawasi panggilan metode tersebut di masa mendatang. Jika jumlah panggilan meningkat, JVM akan mengkompilasi ulang metode ini sekali lagi, tetapi kali ini menggunakan C2.

Ini adalah strategi default yang digunakan oleh HotSpot, yang disebut kompilasi berjenjang .

3.2. Penyusun Server

Sekarang mari kita fokus sedikit pada C2, karena ini adalah yang paling kompleks dari keduanya. C2 telah sangat dioptimalkan dan menghasilkan kode yang dapat bersaing dengan C ++ atau bahkan lebih cepat. Kompilator server itu sendiri ditulis dalam dialek khusus C ++.

Namun, ada beberapa masalah. Karena kemungkinan kesalahan segmentasi di C ++, ini dapat menyebabkan VM macet. Selain itu, tidak ada perbaikan besar yang telah diterapkan pada compiler selama beberapa tahun terakhir. Kode di C2 menjadi sulit untuk dipertahankan, jadi kami tidak dapat mengharapkan peningkatan besar baru dengan desain saat ini. Dengan pemikiran tersebut, compiler JIT baru sedang dibuat dalam proyek bernama GraalVM.

4. Proyek GraalVM

Project GraalVM adalah proyek penelitian yang dibuat oleh Oracle. Kita dapat melihat Graal sebagai beberapa proyek yang terhubung: kompiler JIT baru yang dibangun di HotSpot dan mesin virtual polyglot baru. Ia menawarkan ekosistem yang komprehensif yang mendukung banyak bahasa (Java dan bahasa berbasis JVM lainnya; JavaScript, Ruby, Python, R, C / C ++, dan bahasa berbasis LLVM lainnya).

Kami tentu saja akan fokus pada Java.

4.1. Graal - Compiler JIT yang Ditulis di Java

Graal adalah kompiler JIT berkinerja tinggi. Ini menerima bytecode JVM dan menghasilkan kode mesin.

Ada beberapa keuntungan utama menulis compiler di Java. Pertama-tama, keamanan, yang berarti tidak ada crash tetapi sebagai gantinya pengecualian dan tidak ada kebocoran memori yang sebenarnya. Selain itu, kami akan memiliki dukungan IDE yang baik dan kami akan dapat menggunakan debugger atau profiler atau alat praktis lainnya. Selain itu, kompiler dapat menjadi independen dari HotSpot dan akan dapat menghasilkan versi kompilasi JIT yang lebih cepat dari dirinya sendiri.

Kompiler Graal dibuat dengan mempertimbangkan keunggulan tersebut. Ini menggunakan Antarmuka Kompilator JVM baru - JVMCI untuk berkomunikasi dengan VM . Untuk mengaktifkan penggunaan compiler JIT baru, kita perlu menyetel opsi berikut saat menjalankan Java dari baris perintah:

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

Artinya, kita dapat menjalankan program sederhana dengan tiga cara berbeda: dengan kompiler berjenjang biasa, dengan Graal versi JVMCI di Java 10 atau dengan GraalVM itu sendiri .

4.2. Antarmuka Compiler JVM

JVMCI adalah bagian dari OpenJDK sejak JDK 9, jadi kita dapat menggunakan OpenJDK standar atau JDK Oracle untuk menjalankan Graal.

Apa yang JVMCI sebenarnya memungkinkan kita lakukan adalah mengecualikan kompilasi berjenjang standar dan menyambungkan kompiler baru kita (yaitu Graal) tanpa perlu mengubah apa pun di JVM.

Antarmukanya cukup sederhana. Ketika Graal sedang menyusun metode, itu akan melewati bytecode dari metode itu sebagai input ke JVMCI '. Sebagai keluaran, kita akan mendapatkan kode mesin yang dikompilasi. Baik input dan output hanyalah array byte:

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

Dalam skenario kehidupan nyata, kami biasanya memerlukan lebih banyak informasi seperti jumlah variabel lokal, ukuran tumpukan, dan informasi yang dikumpulkan dari pembuatan profil di interpreter sehingga kami tahu bagaimana kode berjalan dalam praktiknya.

Pada dasarnya, saat memanggil compileMethod () dari antarmuka JVMCICompiler , kita harus meneruskan objek CompilationRequest . Ini kemudian akan mengembalikan metode Java yang ingin kita kompilasi, dan dalam metode itu, kita akan menemukan semua informasi yang kita butuhkan.

4.3. Graal dalam Aksi

Graal sendiri dijalankan oleh VM, jadi ini pertama-tama akan diinterpretasikan dan dikompilasi JIT ketika sudah panas. Mari kita lihat contohnya, yang juga dapat ditemukan di situs resmi GraalVM:

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); } } }

Sekarang, kami akan mengkompilasinya dan menjalankannya:

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.

Alasan utama mengapa kami menggunakan Graal dengan cara ini adalah untuk mempercepat waktu startup hingga pendekatan Kompilasi Bertingkat reguler di HotSpot dapat mengambil alih.

5. Kesimpulan

Pada artikel ini, kami menjelajahi fungsionalitas compiler Java JIT baru sebagai bagian dari proyek Graal.

Kami pertama kali menjelaskan kompiler JIT tradisional dan kemudian mendiskusikan fitur baru Graal, terutama antarmuka Compiler JVM baru. Kemudian, kami mengilustrasikan bagaimana kedua kompiler bekerja dan membandingkan kinerja mereka.

Setelah itu, kita telah membicarakan tentang struktur data yang digunakan Graal untuk memanipulasi program kita dan, terakhir, tentang mode compiler AOT sebagai cara lain untuk menggunakan Graal.

Seperti biasa, kode sumber dapat ditemukan di GitHub. Ingatlah bahwa JVM perlu dikonfigurasi dengan flag khusus - yang dijelaskan di sini.