新しいJava JITコンパイラに深く踏み込む - Graal

新しいJava JITコンパイラの詳細-Graal

1. 概要

このチュートリアルでは、Graalと呼ばれる新しいJava Just-In-Time(JIT)コンパイラについて詳しく見ていきます。

プロジェクトGraalが何であるかを確認し、その一部である高性能動的JITコンパイラについて説明します。

2. JITコンパイラとは

まず、JITコンパイラの機能について説明しましょう。

When we compile our Java program (e.g., using the javac command), we’ll end up with our source code compiled into the binary representation of our code – a JVM bytecode。 このバイトコードはソースコードよりもシンプルでコンパクトですが、コンピューターの従来のプロセッサでは実行できません。

To be able to run a Java program, the JVM interprets the bytecode。 インタプリタは通常、実際のプロセッサで実行されるネイティブコードよりもはるかに遅いため、JVM can run another compiler which will now compile our bytecode into the machine code that can be run by the processor。 このいわゆるジャストインタイムコンパイラは、javacコンパイラよりもはるかに洗練されており、複雑な最適化を実行して高品質のマシンコードを生成します。

3. JITコンパイラの詳細

OracleによるJDK実装は、オープンソースのOpenJDKプロジェクトに基づいています。 これには、Javaバージョン1.3以降で使用可能なHotSpot virtual machineが含まれます。 contains two conventional JIT-compilers: the client compiler, also called C1 and the server compiler, called opto or C2です。

C1はより高速に実行され、最適化されていないコードを生成するように設計されています。一方、C2は実行に少し時間がかかりますが、最適化されたコードを生成します。 JITコンパイルのために長い一時停止をしたくないので、クライアントコンパイラはデスクトップアプリケーションにより適しています。 サーバーコンパイラは、コンパイルにより多くの時間を費やすことができる長時間実行されるサーバーアプリケーションに適しています。

3.1. 階層型コンパイル

現在、Javaのインストールでは、通常のプログラム実行中に両方のJITコンパイラーが使用されます。

前のセクションで述べたように、javacによってコンパイルされたJavaプログラムは、インタープリターモードで実行を開始します。 JVMは、頻繁に呼び出される各メソッドを追跡し、それらをコンパイルします。 そのために、C1を使用してコンパイルします。 ただし、HotSpotはこれらのメソッドの今後の呼び出しに引き続き注意を払っています。 呼び出しの数が増えると、JVMはこれらのメソッドをもう一度再コンパイルしますが、今回はC2を使用します。

これは、tiered compilationと呼ばれるHotSpotで使用されるデフォルトの戦略です。

3.2. サーバーコンパイラ

C2は2つの中で最も複雑なので、ここで少し焦点を当てましょう。 C2は非常に最適化されており、Cと競合できる、またはさらに高速なコードを生成します。 サーバーコンパイラ自体は、Cの特定の方言で記述されています。

ただし、いくつかの問題があります。 C ++ではセグメンテーションエラーが発生する可能性があるため、VMがクラッシュする可能性があります。 また、過去数年にわたってコンパイラに大きな改善は実装されていません。 C2のコードは保守が困難になっているため、現在の設計では新しい主要な機能強化は期待できませんでした。 それを念頭に置いて、GraalVMという名前のプロジェクトで新しいJITコンパイラが作成されています。

4. プロジェクトGraalVM

プロジェクトGraalVMは、HotSpotを完全に置き換えることを目的としてOracleによって作成された研究プロジェクトです。 Graalは、HotSpot用の新しいJITコンパイラーと新しいポリグロット仮想マシンという、いくつかの接続されたプロジェクトとして見ることができます。 「多数の言語セットをサポートする包括的なエコシステム」(Javaおよびその他のJVMベースの言語; JavaScript、Ruby、Python、R、PythonC / C ++、およびその他のLLVMベースの言語)を提供します。

もちろん、Javaに焦点を当てます。

4.1. Graal – Javaで書かれたJITコンパイラ

Graal is a high-performance JIT compiler. JVMバイトコードを受け入れ、マシンコードを生成します。

Javaでコンパイラを記述することには、いくつかの重要な利点があります。 まず第一に、安全性、つまりクラッシュではなく例外であり、実際のメモリリークがないことを意味します。 さらに、優れたIDEサポートがあり、デバッガー、プロファイラー、またはその他の便利なツールを使用できるようになります。 また、コンパイラーはHotSpotから独立しており、より高速なJITコンパイル済みバージョンを生成できます。

Graalコンパイラは、これらの利点を考慮して作成されました。 It uses the new JVM Compiler Interface – JVMCI to communicate with the VM。 新しいJITコンパイラの使用を有効にするには、コマンドラインからJavaを実行するときに次のオプションを設定する必要があります。

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

これが意味するのは、we can run a simple program in three different ways: with the regular tiered compilers, with the JVMCI version of Graal on Java 10 or with the GraalVM itselfです。

4.2. JVMコンパイラインターフェイス

JVMCIはJDK 9以降のOpenJDKの一部であるため、標準のOpenJDKまたはOracle JDKを使用してGraalを実行できます。

JVMCIで実際にできることは、標準の階層型コンパイルを除外し、新しいコンパイラーをプラグインすることです(つまり、 Graal)JVMで何も変更する必要はありません。

インターフェイスは非常にシンプルです。 Graalがメソッドをコンパイルしているとき、そのメソッドのバイトコードを入力としてJVMCIに渡します。 出力として、コンパイルされたマシンコードを取得します。 入力と出力は両方とも単なるバイト配列です。

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

実際のシナリオでは、コードが実際にどのように実行されているかを知るために、通常、ローカル変数の数、スタックサイズ、インタープリターでのプロファイリングから収集された情報などの情報が必要になります。

基本的に、JVMCICompilerインターフェースのcompileMethod()を呼び出すときは、CompilationRequestオブジェクトを渡す必要があります。 次に、コンパイルするJavaメソッドが返され、そのメソッドで、必要なすべての情報が見つかります。

4.3. Graal in Action

Graal自体はVMによって実行されるため、高温になると最初に解釈され、JITコンパイルされます。 例を確認してみましょう。これは、GraalVM’s official siteにもあります。

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

次に、コンパイルして実行します。

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

これにより、次のような出力が生成されます。

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)

it takes more time in the beginningであることがわかります。 そのウォームアップ時間は、アプリケーション内のマルチスレッドコードの量やVMが使用するスレッドの数など、さまざまな要因に依存します。 コアが少ない場合、ウォームアップ時間が長くなる可能性があります。

Graalコンパイルの統計を表示するには、プログラムの実行時に次のフラグを追加する必要があります。

-Dgraal.PrintCompilation=true

これにより、コンパイルされたメソッドに関連するデータ、所要時間、処理されたバイトコード(インラインメソッドも含む)、生成されたマシンコードのサイズ、およびコンパイル中に割り当てられたメモリ量が表示されます。 実行の出力にはかなりのスペースが必要になるため、ここでは示しません。

4.4. 最上位コンパイラーとの比較

上記の結果を、代わりに最上位のコンパイラでコンパイルされた同じプログラムの実行と比較してみましょう。 そのためには、JVMCIコンパイラを使用しないようにVMに指示する必要があります。

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)

個々の時間の差が小さいことがわかります。 また、初期時間が短くなります。

4.5. Graalの背後にあるデータ構造

前述したように、Graalは基本的にバイト配列を別のバイト配列に変換します。 このセクションでは、このプロセスの背後にあるものに焦点を当てます。 次の例は、Chris Seaton’s talk at JokerConf 2017に依存しています。

基本的なコンパイラの仕事は、一般的に、私たちのプログラムに基づいて行動することです。 これは、適切なデータ構造でシンボル化する必要があることを意味します。 Graal uses a graph for such a purpose, the so-called program-dependence-graph

単純なシナリオでは、2つのローカル変数、つまりx + ywe would have one node for loading each variable and another node for adding themを追加します。 その横に、we’d also have two edges representing the data flow

image

The data flow edges are displayed in blue。 彼らは、ローカル変数がロードされると、結果が加算演算に入ると指摘しています。

another type of edges, the ones that describe the control flowを紹介しましょう。 そのために、変数を直接読み取るのではなく、メソッドを呼び出して変数を取得することで、例を拡張します。 その場合、メソッド呼び出しの順序を追跡する必要があります。 この順序を赤い矢印で表します。

image

ここでは、ノードが実際には変更されていないことがわかりますが、制御フローエッジが追加されています。

4.6. 実際のグラフ

IdealGraphVisualiserを使用して、実際のGraalグラフを調べることができます。 実行するには、mx igv コマンドを使用します。 また、-Dgraal.Dumpフラグを設定してJVMを構成する必要があります。

簡単な例を見てみましょう。

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

これには、非常に単純なデータフローがあります。

image

上記のグラフでは、メソッドの明確な表現を見ることができます。 パラメーターP(0)およびP(1)は、定数C(2)で除算演算に入る加算演算に流れます。 最後に、結果が返されます。

ここで、前の例を変更して、数値の配列に適用できるようにします。

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

ループを追加すると、はるかに複雑なグラフが表示されることがわかります。

image

気付くことができることhere are:

  • 開始および終了ループノード

  • 配列の読み取り値と配列の長さの読み取り値を表すノード

  • 前と同じようにデータと制御フローのエッジ。

This data structure is sometimes called a sea-of-nodes, or a soup-of-nodes。 C2コンパイラは同様のデータ構造を使用しているため、Graal専用に革新された新しいものではありません。

Graalは、上記のデータ構造を変更してプログラムを最適化し、コンパイルすることを覚えておいてください。 Graal JITコンパイラを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. 事前コンパイラモード

we can also use the Graal compiler in the Ahead-of-Time compiler mode in Java 10に言及することも重要です。 既に述べたように、Graalコンパイラーはゼロから作成されています。 新しいクリーンなインターフェースであるJVMCIに準拠しており、HotSpotと統合できます。 しかし、それはコンパイラがそれにバインドされているという意味ではありません。

コンパイラーを使用する1つの方法は、プロファイル駆動型アプローチを使用して、ホット・メソッドのみをコンパイルし、we can also make use of Graal to do a total compilation of all methods in an offline mode without executing the codeをコンパイルすることです。 これはいわゆる「Ahead-of-TimeCompilation」JEP 295,ですが、ここではAOTコンパイルテクノロジーについて詳しくは説明しません。

この方法でGraalを使用する主な理由は、HotSpotでの通常のTiered Compilationアプローチが引き継ぐまで起動時間を短縮するためです。

5. 結論

この記事では、プロジェクトGraalの一部として、新しいJava JITコンパイラーの機能を調査しました。

最初に従来のJITコンパイラについて説明し、次にGraalの新機能、特に新しいJVMコンパイラインターフェイスについて説明しました。 次に、両方のコンパイラがどのように機能するかを示し、パフォーマンスを比較しました。

その後、Graalがプログラムを操作するために使用するデータ構造について説明し、最後に、Graalを使用する別の方法としてAOTコンパイラモードについて説明しました。

いつものように、ソースコードはover on GitHubで見つけることができます。 ここで説明した特定のフラグを使用してJVMを構成する必要があることに注意してください。