1. 概要

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

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

2. JIT コンパイラとは何ですか?

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

Javaプログラムをコンパイルすると(たとえば、java cコマンドを使用して)、ソースコードがコードのバイナリ表現(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で使用されるデフォルトの戦略です。

3.2. サーバーコンパイラ

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

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

4. プロジェクトGraalVM

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

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

4.1. Graal –Javaで記述されたJITコンパイラ

Graalは高性能のJITコンパイラです。 JVMバイトコードを受け入れ、マシンコードを生成します。

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

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

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

これが意味するのは、単純なプログラムを3つの異なる方法で実行できるということです。通常の階層型コンパイラ、Java 10上のJVMCIバージョンのGraal、またはGraalVM自体です。

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

JVMCIはJDK9以降のOpenJDKの一部であるため、標準のOpenJDKまたはOracleJDKを使用して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の公式サイトにもある例を確認してみましょう。

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. トップティアコンパイラとの比較

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

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は基本的にバイト配列を別のバイト配列に変換します。 このセクションでは、このプロセスの背後にあるものに焦点を当てます。 次の例は、JokerConf2017でのChrisSeatonの講演に依存しています。

基本的なコンパイラの仕事は、一般的に、私たちのプログラムに基づいて行動することです。 これは、適切なデータ構造でシンボル化する必要があることを意味します。  Graalは、そのような目的のためにグラフ、いわゆるプログラム依存グラフを使用します。

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

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

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

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

4.6. 実際のグラフ

IdealGraphVisualiserを使用して実際のGraalグラフを調べることができます。 実行するには、 mxigvコマンドを使用します。 また、-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は、上記のデータ構造を変更することにより、プログラムを最適化およびコンパイルすることを覚えておいてください。 GraalJITコンパイラをJavaで作成することが実際に良い選択であった理由がわかります。 グラフは、それらをエッジとして接続する参照を持つオブジェクトのセットにすぎません。 その構造は、オブジェクト指向言語(この場合はJava)と完全に互換性があります。

4.7. 事前コンパイラモード

また、 Java10のAhead-of-TimeコンパイラモードでGraalコンパイラを使用することもできます。 すでに述べたように、Graalコンパイラはゼロから作成されています。 これは、新しいクリーンなインターフェイスであるJVMCIに準拠しているため、HotSpotと統合できます。 しかし、それはコンパイラがそれにバインドされているという意味ではありません。

コンパイラを使用する1つの方法は、プロファイル駆動型アプローチを使用してホットメソッドのみをコンパイルすることですが、 Graalを使用して、コードを実行せずにオフラインモードですべてのメソッドの完全なコンパイルを実行することもできます[X223X ]。 これはいわゆる「Ahead-of-TimeCompilation」、 JEP 295、ですが、ここではAOTコンパイルテクノロジについて詳しく説明しません。

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

5. 結論

この記事では、プロジェクトGraalの一部としての新しいJavaJITコンパイラーの機能について説明しました。

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

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

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