Menyelam ke dalam Penyusun JIT Java Baru - Graal

1. Gambaran keseluruhan

Dalam tutorial ini, kita akan melihat dengan lebih mendalam mengenai penyusun Java Just-In-Time (JIT) baru, yang disebut Graal.

Kami akan melihat apa itu projek Graal dan menerangkan salah satu bahagiannya, penyusun JIT dinamik berprestasi tinggi.

2. Apakah Penyusun JIT ?

Mari kita jelaskan terlebih dahulu apa yang dilakukan penyusun JIT.

Apabila kita menyusun program Java kita (mis., Menggunakan perintah javac ), kita akan berakhir dengan kod sumber kita yang disusun menjadi representasi binari kod kita - bytecode JVM . Bytecode ini lebih ringkas dan padat daripada kod sumber kami, tetapi pemproses konvensional di komputer kami tidak dapat melaksanakannya.

Untuk dapat menjalankan program Java, JVM menafsirkan bytecode . Oleh kerana jurubahasa biasanya jauh lebih lambat daripada kod asli yang dijalankan pada pemproses sebenar, JVM dapat menjalankan penyusun lain yang sekarang akan menyusun kod bytec kita ke dalam kod mesin yang dapat dijalankan oleh pemproses . Penyusun just-in-time yang disebut ini jauh lebih canggih daripada penyusun javac , dan menjalankan pengoptimuman yang kompleks untuk menghasilkan kod mesin berkualiti tinggi.

3. Lihat Lebih terperinci ke dalam JIT Compiler

Pelaksanaan JDK oleh Oracle didasarkan pada projek OpenJDK sumber terbuka. Ini termasuk mesin maya HotSpot , yang tersedia sejak Java versi 1.3. Ia mengandungi dua penyusun JIT konvensional: penyusun pelanggan, juga disebut C1 dan penyusun pelayan, yang disebut opto atau C2 .

C1 dirancang untuk berjalan lebih cepat dan menghasilkan kod yang kurang dioptimumkan, sementara C2, sebaliknya, memerlukan sedikit lebih banyak masa untuk dijalankan tetapi menghasilkan kod yang dioptimumkan dengan lebih baik. Penyusun klien lebih sesuai untuk aplikasi desktop kerana kami tidak mahu mempunyai jeda lama untuk penyusunan JIT. Penyusun pelayan lebih baik untuk aplikasi pelayan yang sudah lama berjalan dan dapat menghabiskan lebih banyak masa untuk penyusunan.

3.1. Penyusunan Bertingkat

Hari ini, pemasangan Java menggunakan kedua-dua pengkompil JIT semasa pelaksanaan program biasa.

Seperti yang kami sebutkan di bagian sebelumnya, program Java kami, yang disusun oleh javac , memulai pelaksanaannya dalam mode interpreted. JVM mengesan setiap kaedah yang sering dipanggil dan menyusunnya. Untuk melakukannya, ia menggunakan C1 untuk penyusunan. Tetapi, HotSpot masih mengawasi panggilan masa depan kaedah tersebut. Sekiranya jumlah panggilan meningkat, JVM akan menyusun semula kaedah ini sekali lagi, tetapi kali ini menggunakan C2.

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

3.2. Penyusun Pelayan

Sekarang mari kita fokus sedikit pada C2, kerana ia adalah yang paling kompleks dari keduanya. C2 telah dioptimumkan dan menghasilkan kod yang dapat bersaing dengan C ++ atau bahkan lebih cepat. Penyusun pelayan itu sendiri ditulis dalam dialek tertentu C ++.

Walau bagaimanapun, ia datang dengan beberapa masalah. Oleh kerana kemungkinan kerosakan segmentasi dalam C ++, ia boleh menyebabkan VM mogok. Juga, tidak ada peningkatan besar yang dilaksanakan dalam penyusun sejak beberapa tahun kebelakangan ini. Kod di C2 menjadi sukar untuk dijaga, jadi kami tidak dapat mengharapkan peningkatan besar baru dengan reka bentuk semasa. Oleh itu, penyusun JIT baru sedang dibuat dalam projek bernama GraalVM.

4. Projek GraalVM

Project GraalVM adalah projek penyelidikan yang dibuat oleh Oracle. Kita dapat melihat Graal sebagai beberapa projek yang bersambung: penyusun JIT baru yang membina HotSpot dan mesin maya polyglot baru. Ia menawarkan ekosistem yang komprehensif yang menyokong sejumlah besar bahasa (Java dan bahasa lain berdasarkan JVM; JavaScript, Ruby, Python, R, C / C ++, dan bahasa berasaskan LLVM lain).

Kita tentu akan fokus pada Java.

4.1. Graal - Penyusun JIT yang ditulis di Jawa

Graal adalah penyusun JIT berprestasi tinggi. Ia menerima kod bytec JVM dan menghasilkan kod mesin.

Terdapat beberapa kelebihan utama menulis penyusun di Java. Pertama sekali, keselamatan, yang bermaksud tidak ada kemalangan tetapi pengecualian dan tidak ada kebocoran memori sebenar. Selain itu, kami akan mendapat sokongan IDE yang baik dan kami dapat menggunakan debuger atau profiler atau alat lain yang mudah. Juga, pengkompil boleh bebas dari HotSpot dan ia dapat menghasilkan versi tersusun JIT yang lebih cepat.

Penyusun Graal dicipta dengan mempertimbangkan kelebihan tersebut. Ia menggunakan JVM Compiler Interface - JVMCI baru untuk berkomunikasi dengan VM . Untuk membolehkan penggunaan pengkompil JIT baru, kita perlu menetapkan pilihan berikut ketika menjalankan Java dari baris perintah:

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

Maksudnya ialah kita dapat menjalankan program sederhana dengan tiga cara yang berbeza: dengan penyusun berjenjang biasa, dengan versi JVMCI Graal di Java 10 atau dengan GraalVM itu sendiri .

4.2. Antaramuka Penyusun JVM

JVMCI adalah sebahagian daripada OpenJDK sejak JDK 9, jadi kami dapat menggunakan OpenJDK standard atau Oracle JDK untuk menjalankan Graal.

Apa yang sebenarnya JVMCI izinkan untuk kita lakukan adalah mengecualikan kompilasi berjenjang standard dan memasukkan penyusun baru (iaitu Graal) tanpa perlu mengubah apa-apa dalam JVM.

Antaramuka cukup mudah. Ketika Graal menyusun kaedah, ia akan meneruskan bytecode kaedah tersebut sebagai input ke JVMCI '. Sebagai output, kami akan mendapatkan kod mesin yang disusun. Input dan output hanyalah susunan bait:

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

Dalam senario kehidupan sebenar, biasanya kita memerlukan lebih banyak maklumat seperti jumlah pemboleh ubah tempatan, ukuran timbunan, dan maklumat yang dikumpulkan dari profil dalam jurubahasa sehingga kita tahu bagaimana kod tersebut berjalan dalam praktiknya.

Pada dasarnya, semasa memanggil compileMethod () antara muka JVMCICompiler , kita perlu meneruskan objek CompilationRequest . Ia kemudian akan mengembalikan kaedah Java yang ingin kita kumpulkan, dan dalam kaedah itu, kita akan menemui semua maklumat yang kita perlukan.

4.3. Graal dalam Tindakan

Graal sendiri dijalankan oleh VM, jadi pertama kali akan ditafsirkan dan disusun JIT ketika menjadi panas. Mari lihat contoh, yang juga terdapat di laman rasmi 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 menyusunnya 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.

Sebab utama mengapa kami menggunakan Graal dengan cara ini adalah untuk mempercepat masa permulaan sehingga pendekatan Penyusunan Bertingkat biasa di HotSpot dapat mengambil alih.

5. Kesimpulan

Dalam artikel ini, kami meneroka kefungsian penyusun Java JIT baru sebagai bahagian projek Graal.

Kami mula-mula menerangkan penyusun JIT tradisional dan kemudian membincangkan ciri baru Graal, terutamanya antara muka JVM Compiler yang baru. Kemudian, kami menggambarkan bagaimana kedua penyusun berfungsi dan membandingkan persembahan mereka.

Selepas itu, kami telah membincangkan struktur data yang digunakan oleh Graal untuk memanipulasi program kami dan, akhirnya, mengenai mod penyusun AOT sebagai cara lain untuk menggunakan Graal.

Seperti biasa, kod sumber boleh didapati di GitHub. Ingat bahawa JVM perlu dikonfigurasi dengan bendera tertentu - yang dijelaskan di sini.