1概要

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


Graal

というプロジェクトがどのようなものであるかを見て、その一部である高性能動的JITコンパイラーについて説明します。

2. JITコンパイラとは

最初にJITコンパイラが何をするのか説明しましょう。

  • (たとえば

    javac

    コマンドを使用して)Javaプログラムをコンパイルすると、ソースコードがコードのバイナリ表現(JVMバイトコード)にコンパイルされます。このバイトコードは私達のソースコードよりも単純でコンパクトですが、私達のコンピューターの通常のプロセッサーはそれを実行することができません。

  • Javaプログラムを実行できるようにするために、JVMはバイトコードを解釈します** 。

通常、インタプリタは実際のプロセッサ上で実行されるネイティブコードよりはるかに遅いので、JVMは別のコンパイラを実行することができます。これにより、バイトコードがプロセッサによって実行可能なマシンコードにコンパイルされます。このいわゆるジャストインタイムコンパイラは、

javac

コンパイラよりもはるかに洗練されており、複雑な最適化を実行して高品質のマシンコードを生成します。

3. JITコンパイラの詳細

OracleによるJDKの実装は、オープンソースのOpenJDKプロジェクトに基づいています。これには、Javaバージョン1.3以降で利用可能な

HotSpot仮想マシン

が含まれます。これには2つの従来のJITコンパイラが含まれています。C1とも呼ばれるクライアントコンパイラと、optoまたはC2と呼ばれるサーバコンパイラです。

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

3.1. 段階的コンパイル

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

前のセクションで説明したように、

javac

によってコンパイルされた私たちのJavaプログラムはインタプリタモードで実行を開始します。 JVMは頻繁に呼び出されるメソッドをそれぞれ追跡し、それらをコンパイルします。それをするために、それはコンパイルのためにC1を使います。しかし、HotSpotはまだこれらのメソッドの今後の呼び出しに注目しています。呼び出し回数が増えた場合、JVMはこれらのメソッドをもう一度再コンパイルしますが、今回はC2を使用します。

これはHotSpotで使用されるデフォルトの戦略で、

tiered compilation

と呼ばれます。

3.2. サーバーコンパイラ

2つのうち最も複雑なので、C2に少し注目しましょう。 C2はきわめて最適化されており、Cと競合したりさらに高速になるコードを生成します。サーバーコンパイラ自体はCの特定の方言で書かれています。

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

4.プロジェクトGraalVM

プロジェクトhttps://www.graalvm.org/[GraalVM]は、HotSpotを完全に置き換えることを目的にOracleが作成した研究プロジェクトです。 Graalはいくつかのコネクテッドプロジェクトとして見ることができます。HotSpot用の新しいJITコンパイラと新しいpolyglot仮想マシンです。多数の言語(Javaおよび他のJVMベースの言語、JavaScript、Ruby、Python、R、C/C、およびその他のLLVMベースの言語)をサポートする包括的なエコシステムを提供します。

私たちはもちろんJavaに焦点を合わせます。

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


  • Graalは高性能のJITコンパイラです。

    ** これはJVMバイトコードを受け入れてマシンコードを生成します。

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

Graalコンパイラはこれらの利点を念頭に置いて作成されました。 ** VMと通信するために新しいJVM Compiler Interface – JVMCIを使用します。新しいJITコンパイラを使用できるようにするには、コマンドラインからJavaを実行するときに次のオプションを設定する必要があります。

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

これが意味するのは、** 単純なプログラムを3つの異なる方法で実行できるということです。通常の段階的なコンパイラ、JVM 10版のGraal on Java 10、またはGraalVM自体です。

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

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

JVMCIが実際に許可しているのは、標準の段階的コンパイルを除外して、JVM内で何も変更することなく、新しいコンパイラー(つまりGraal)をプラグインすることです。

インターフェースはとてもシンプルです。 Graalがメソッドをコンパイルするとき、そのメソッドのバイトコードを入力としてJVMCIに渡します。出力として、コンパイル済みのマシンコードを入手します。入力と出力はどちらも単なるバイト配列です。

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

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

基本的には、

httpsの

compileMethod

()を呼び出すとき: src/jdk/vm/ci/runtime/JVMCICompiler.java[JVMCICompiler]

インターフェース、

CompilationRequest

オブジェクトを渡す必要があります。それから、コンパイルしたいJavaメソッドが返され、そのメソッドの中に、必要な情報がすべて見つかります。

4.3. 実行中の穀物

Graal自体はVMによって実行されるため、ホットになると最初に解釈され、JITコンパイルされます。例を見てみましょう。これはhttps://www.graalvm.org/docs/examples/java-performance-examples/[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);
        }
    }
}

それでは、コンパイルして実行します。

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)

  • 最初は時間がかかることがわかります。ウォームアップ時間は、アプリケーション内のマルチスレッドコードの量や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は基本的にバイト配列を別のバイト配列に変換します。このセクションでは、このプロセスの背後にあるものに焦点を当てます。以下の例はhttps://chrisseaton.com/truffleruby/jokerconf17/[JokerConf 2017でのChris Seatonの講演]に基づいています。

基本的なコンパイラの仕事は、一般的に、私たちのプログラムに基づいて行動することです。これは適切なデータ構造でそれを象徴しなければならないことを意味します。 Graalはそのような目的でグラフ、いわゆるprogram-dependency-graph ** を使います。

2つのローカル変数、つまり

x y

を追加する単純なシナリオでは、各変数をロードするための1つのノードとそれらを追加するための別のノードがあります。それに加えて、データフローを表す2つのエッジもあります。

  • データフローの端は青色で表示されます** 。彼らは、ローカル変数がロードされると、結果が加算演算に入ることを指摘しています。

それでは、制御フローを説明する別の種類のエッジを紹介しましょう。そのためには、変数を直接読み取るのではなく、メソッドを呼び出して変数を取得することによって、例を拡張します。それをするとき、我々はメソッド呼び出し順序を追跡する必要があります。この順番を赤い矢印で表します。

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

4.6. 実際のグラフ

実際のGraalグラフはhttp://ssw.jku.at/General/Staff/TW/igv.html[IdealGraphVisualiser]で調べることができます。

実行するには、

__ mx igv


コマンドを使用します。また、

-Dgraal.Dump__フラグを設定してJVMを構成する必要があります。

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

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

これは非常に単純なデータフローです。

上のグラフで、私たちは私たちの方法の明確な表現を見ることができます。

パラメータ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;
}

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

私たちが気づくことができるものはここにあります:

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

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

  • 以前と同様に、データと制御フローのエッジ。

  • このデータ構造は、ノードオブシー、またはノードスープ** とも呼ばれます。 C2コンパイラも同様のデータ構造を使用しているので、Graal専用に革新された新しいものではありません。

Graalが上記のデータ構造を修正することによってプログラムを最適化しコンパイルすることは注目に値する。 Graal JITコンパイラをJavaで書くのが実際には良い選択である理由を理解することができます。

グラフは辺としてそれらを接続する参照を持つオブジェクトのセットにすぎません。その構造は、この場合はJava

であるオブジェクト指向言語と完全に互換性があります。


4.7. 先読みコンパイラモード

また、GraalコンパイラをJava 10のAhead-of-Timeコンパイラモードで使用することもできます。すでに述べたように、Graalコンパイラは最初から書かれています。これは新しいクリーンなインターフェース、JVMCIに準拠しています。これにより、HotSpotとの統合が可能になります。

しかし、それはコンパイラがそれに縛られるという意味ではありません。

コンパイラを使用する1つの方法は、プロファイル主導のアプローチを使用してhotメソッドだけをコンパイルすることですが、

Graalを使用してコードを実行せずにすべてのメソッドをオフラインモードで完全にコンパイルすることもできます

。これはいわゆる「Ahead-of-Time Compilation」(http://openjdk.java.net/jeps/295[JEP 295])ですが、ここではAOTのコンパイルテクノロジについては詳しく説明しません。

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

5.まとめ

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

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

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

いつものように、ソースコードはhttps://github.com/eugenp/tutorials/tree/master/core-java-10[GitHub上で動く]にあります。ここで説明したように、JVMは特定のフラグを使用して構成する必要があることを忘れないでください。