プロファイルに基づく最適化によるネイティブ実行可能ファイルの最適化

GraalVMネイティブ・イメージは、デフォルトでネイティブ実行可能ファイルとして動作するJavaアプリケーションに対して、迅速に起動し、メモリー消費が削減されます。プロファイルに基づく最適化(PGO)を適用することで、このネイティブ実行可能ファイルをさらに最適化し、パフォーマンスとスループットを向上させることができます。

PGOを使用すると、プロファイリング・データを事前に収集してからnative-imageツールにフィードでき、この情報に基づいて結果のバイナリのパフォーマンスが最適化されます。

ノート: PGOは、GraalVM Community Editionでは使用できません。

このガイドでは、PGOを適用し、Javaアプリケーションを最適化されたネイティブ実行可能ファイルに変換する方法を示します。

デモの実行

デモ・パートでは、Java Streams APIで実装された問合せを実行するJavaアプリケーションを実行します。ユーザーは、反復回数とデータ配列の長さの2つの整数引数を指定することが想定されています。アプリケーションは、決定論的な乱数シードを使用してデータ・セットを作成し、10回反復します。各反復とそのチェックサムに要した時間がコンソールに出力されます。

最適化するストリーム式を次に示します:

Arrays.stream(persons)
   .filter(p -> p.getEmployment() == Employment.EMPLOYED)
   .filter(p -> p.getSalary() > 100_000)
   .mapToInt(Person::getAge)
   .filter(age -> age > 40)
   .average()
   .getAsDouble();

PGOを使用して最適化されたネイティブ実行可能ファイルをビルドするには、次のステップに従います。

ノート: GraalVM JDKがインストール済であることを確認します。最も簡単に始めるには、SDKMAN!を使用します。その他のインストール・オプションについては、「ダウンロード」セクションを参照してください。

  1. 次のコードStreams.javaという名前のファイルに保存します:

    import java.util.Arrays;
    import java.util.Random;
    
    public class Streams {
    
      static final double EMPLOYMENT_RATIO = 0.5;
      static final int MAX_AGE = 100;
      static final int MAX_SALARY = 200_000;
    
      public static void main(String[] args) {
    
        int iterations;
        int dataLength;
        try {
          iterations = Integer.valueOf(args[0]);
          dataLength = Integer.valueOf(args[1]);
        } catch (Throwable ex) {
          System.out.println("Expected 2 integer arguments: number of iterations, length of data array");
          return;
        }
    
        Random random = new Random(42);
        Person[] persons = new Person[dataLength];
        for (int i = 0; i < dataLength; i++) {
          persons[i] = new Person(
              random.nextDouble() >= EMPLOYMENT_RATIO ? Employment.EMPLOYED : Employment.UNEMPLOYED,
              random.nextInt(MAX_SALARY),
              random.nextInt(MAX_AGE));
        }
    
        long totalTime = 0;
        for (int i = 1; i <= 20; i++) {
          long startTime = System.currentTimeMillis();
    
          long checksum = benchmark(iterations, persons);
    
          long iterationTime = System.currentTimeMillis() - startTime;
          totalTime += iterationTime;
          System.out.println("Iteration " + i + " finished in " + iterationTime + " milliseconds with checksum " + Long.toHexString(checksum));
        }
        System.out.println("TOTAL time: " + totalTime);
      }
    
      static long benchmark(int iterations, Person[] persons) {
        long checksum = 1;
        for (int i = 0; i < iterations; ++i) {
          double result = getValue(persons);
    
          checksum = checksum * 31 + (long) result;
        }
        return checksum;
      }
    
      public static double getValue(Person[] persons) {
        return Arrays.stream(persons)
            .filter(p -> p.getEmployment() == Employment.EMPLOYED)
            .filter(p -> p.getSalary() > 100_000)
            .mapToInt(Person::getAge)
            .filter(age -> age >= 40).average()
            .getAsDouble();
      }
    }
    
    enum Employment {
      EMPLOYED, UNEMPLOYED
    }
    
    class Person {
      private final Employment employment;
      private final int age;
      private final int salary;
    
      public Person(Employment employment, int height, int age) {
        this.employment = employment;
        this.salary = height;
        this.age = age;
      }
    
      public int getSalary() {
        return salary;
      }
    
      public int getAge() {
        return age;
      }
    
      public Employment getEmployment() {
        return employment;
      }
    }
    
  2. アプリケーションをコンパイルします:
    $JAVA_HOME/bin/javac Streams.java
    

    (オプション)デモ・アプリケーションを実行し、いくつかの引数を指定してパフォーマンスを監視します。

    $JAVA_HOME/bin/java Streams 100000 200
    
  3. クラス・ファイルからネイティブ実行可能ファイルをビルドし、それを実行してパフォーマンスを比較します:
     $JAVA_HOME/bin/native-image Streams
    

    実行可能ファイルstreamsは、現在の作業ディレクトリに作成されます。ここで、同じ引数を使用して実行し、パフォーマンスを確認します:

     ./streams 100000 200
    

    このバージョンのプログラムは、GraalVMのJDKや通常のJDKよりも動作が遅くなることが予想されます。

  4. --pgo-instrumentオプションをnative-imageに渡して、インストゥルメントされたネイティブ実行可能ファイルをビルドします:

     $JAVA_HOME/bin/native-image --pgo-instrument Streams
    
  5. これを実行して、コード実行頻度プロファイルを収集します:

     ./streams 100000 20
    

    はるかに小さいデータ・サイズでプロファイルできることに注意してください。この実行から収集されたプロファイルは、デフォルトでdefault.iprofファイルに格納されます。

    ノート: インストゥルメントされたネイティブ実行可能ファイルの実行時にプロファイルを収集する場所を指定するには、実行時に-XX:ProfilesDumpFile=YourFileNameオプションを渡します。

  6. 最後に、収集されたプロファイルへのパスを指定して、最適化されたネイティブ実行可能ファイルをビルドします:

     $JAVA_HOME/bin/native-image --pgo=default.iprof Streams
    

    ノート: 異なるファイル名を指定して複数のプロファイル・ファイルを収集し、ビルド時にnative-imageツールに渡すこともできます。

    この最適化されたネイティブ実行可能ファイルを実行タイミングを計って実行し、システム・リソースおよびCPU使用率を確認します:

     time ./streams 100000 200
    

    Javaバージョンのプログラムと同等、またはそれ以上のパフォーマンスが得られるはずです。たとえば、16GBのメモリーと8コアを持つマシンでは、10回の反復のTOTAL timeが最大2200から最大270ミリ秒に削減されました。

このガイドでは、ネイティブ実行可能ファイルを最適化して、パフォーマンスとスループットを向上させる方法を紹介しました。Oracle GraalVMには、プロファイルに基づく最適化(PGO)など、ネイティブ実行可能ファイルをビルドすることに関して追加の利点があります。PGOを使用すると、特定のワークロードに対してアプリケーションを「トレーニング」し、パフォーマンスを大幅に向上させることができます。