プロファイルに基づく最適化の基本的な使い方

GraalVM Native ImageのコンテキストにおけるPGOの使い方を説明するために、「ライフゲーム」のサンプルアプリケーションを考えてみましょう。これは、4000 x 4000グリッド上のコンウェイのライフゲームシミュレーションの実装です。アプリケーションは、世界の初期状態を指定するファイル、最終状態を出力するファイルパス、およびシミュレーションを実行する反復回数を宣言する整数を、入力として受け取ります。これは現実世界のアプリケーションを説明するものではありませんが、例としては十分に役立つはずです。

以下は、このリソースから変更されたアプリケーションのソースコードです。

import java.io.BufferedReader;
import java.io.FileReader;
import java.io.FileWriter;

public class GameOfLife {

    private static final int M = 4000;
    private static final int N = 4000;

    public static void main(String[] args) {
        new GameOfLife().run(args);
    }

    private void run(String[] args) {
        if (args.length < 3) {
            System.err.println("Too few arguments, need input file, output file and number of generations");
            System.exit(1);
        }

        String input = args[0];
        String output = args[1];
        int generations = Integer.parseInt(args[2]);

        int[][] grid = loadGrid(input);
        for (int i = 1; i <= generations; i++) {
            grid = nextGeneration(grid);
        }
        saveGrid(grid, output);
    }

    static int[][] nextGeneration(int[][] grid) {
        int[][] future = new int[M][N];
        for (int l = 0; l < M; l++) {
            for (int m = 0; m < N; m++) {
                applyRules(grid, future, l, m, getAliveNeighbours(grid, l, m));
            }
        }
        return future;
    }

    private static void applyRules(int[][] grid, int[][] future, int l, int m, int aliveNeighbours) {
        if ((grid[l][m] == 1) && (aliveNeighbours < 2)) {
            // Cell is lonely and dies
            future[l][m] = 0;
        } else if ((grid[l][m] == 1) && (aliveNeighbours > 3)) {
            // Cell dies due to over population
            future[l][m] = 0;
        } else if ((grid[l][m] == 0) && (aliveNeighbours == 3)) {
            // A new cell is born
            future[l][m] = 1;
        } else {
            // Remains the same
            future[l][m] = grid[l][m];
        }
    }

    private static int getAliveNeighbours(int[][] grid, int l, int m) {
        int aliveNeighbours = 0;
        for (int i = -1; i <= 1; i++) {
            for (int j = -1; j <= 1; j++) {
                if ((l + i >= 0 && l + i < M) && (m + j >= 0 && m + j < N)) {
                    aliveNeighbours += grid[l + i][m + j];
                }
            }
        }
        // The cell needs to be subtracted from its neighbors as it was counted before
        aliveNeighbours -= grid[l][m];
        return aliveNeighbours;
    }

    private static void saveGrid(int[][] grid, String output) {
        try (FileWriter myWriter = new FileWriter(output)) {
            for (int i = 0; i < M; i++) {
                for (int j = 0; j < N; j++) {
                    if (grid[i][j] == 0)
                        myWriter.write(".");
                    else
                        myWriter.write("*");
                }
                myWriter.write(System.lineSeparator());
            }
        } catch (Exception e) {
            throw new IllegalStateException();
        }
    }

    private static int[][] loadGrid(String input) {
        try (BufferedReader reader = new BufferedReader(new FileReader(input))) {
            int[][] grid = new int[M][N];
            for (int i = 0; i < M; i++) {
                String line = reader.readLine();
                for (int j = 0; j < N; j++) {
                    if (line.charAt(j) == '*') {
                        grid[i][j] = 1;
                    } else {
                        grid[i][j] = 0;
                    }
                }
            }
            return grid;
        } catch (Exception e) {
            throw new IllegalStateException(e);
        }
    }
}

アプリケーションのパフォーマンスは、経過時間で測定されます。より良い最適化をアプリケーションに適用すると、アプリケーションがワークロードを完了するのにかかる時間が短縮されると想定されています。パフォーマンスの違いを確認するために、2つの異なる方法でアプリケーションを実行できます。最初にPGOなしで、次にPGOありで実行します。

アプリケーションのビルド #

前提条件は、Oracle GraalVMをインストールすることです。最も簡単に始める方法は、SDKMAN!を使用することです。その他のインストールオプションについては、ダウンロードセクションをご覧ください。

注:PGOはGraalVM Community Editionでは使用できません。

最初のステップは、*GameOfLife.java*をクラスファイルにコンパイルすることです。

javac GameOfLife.java

次に、`-o`オプションで一意の名前を指定して、アプリケーションのネイティブイメージをビルドします。

native-image -cp . GameOfLife -o gameoflife-default

これで、PGO対応のネイティブイメージのビルドに進むことができます。そのためには、まず、`--pgo-instrumented`オプションを追加し、以下のように別の名前を指定することにより、アプリケーションの実行時の動作のプロファイルを生成する「インストルメント化されたバイナリ」をビルドする必要があります。

native-image  --pgo-instrument -cp . GameOfLife -o gameoflife-instrumented

次に、そのインストルメント化されたバイナリを実行して、プロファイルを収集します。デフォルトでは、終了直前に、現在の作業ディレクトリにデフォルト名*default.iprof*のファイルが生成されますが、インストルメント化されたバイナリを実行するときに`-XX:ProfilesDumpFile`オプションを渡すことで、プロファイルに別の名前とパスを指定できます。また、アプリケーションに標準の予想される入力、つまり世界の初期状態(*input.txt*)、アプリケーションが世界の最終状態を出力するファイル(*output.txt*)、および必要な反復回数(この場合は`10`)を提供する必要があります。

./gameoflife-instrumented -XX:ProfilesDumpFile=gameoflife.iprof input.txt output.txt 10

*gameoflife.iprofファイル*に含まれるアプリケーションのランタイムプロファイルがあれば、以下に示すように、`--pgo`オプションを使用し、収集したプロファイルを提供することで、最適化されたネイティブ実行可能ファイルを最終的にビルドできます。

native-image -cp . GameOfLife -o gameoflife-pgo --pgo=gameoflife.iprof

これらすべてが揃ったら、異なるモードで実行されているアプリケーションのランタイムパフォーマンスの評価に進むことができます。

パフォーマンスの評価 #

パフォーマンスを評価するには、同じ入力でアプリケーションの両方のネイティブ実行可能ファイルを実行します。カスタム出力形式(`--format=>> Elapsed: %es`)を使用して、`time`コマンドを介して実行可能ファイルの経過時間を測定できます。

注:ノイズを最小限に抑え、再現性を向上させるために、CPUクロックはすべての測定中に2.5GHzに固定されています。

単一反復での実行 #

以下に示すようにアプリケーションを実行して、1回だけ反復するようにします。

time  ./gameoflife-default input.txt output.txt 1
    >> Elapsed: 1.67s

time  ./gameoflife-pgo input.txt output.txt 1
    >> Elapsed: 0.97s

経過時間を見ると、PGOで最適化されたネイティブ実行可能ファイルを実行する方が、パーセンテージで大幅に高速であることがわかります。それを念頭に置いて、0.5秒の違いはこのアプリケーションの単一の実行には大きな影響を与えませんが、これが頻繁に実行されるサーバーレスアプリケーションである場合、累積的なパフォーマンスの向上は加算され始めます。

100回の反復で実行 #

次に、100回の反復でアプリケーションを実行します. 以前と同じように、実行されたコマンドと時間出力が以下に示されています.

time  ./gameoflife-default input.txt output.txt 100
    >> Elapsed: 24.02s

time  ./gameoflife-pgo input.txt output.txt 100
    >> Elapsed: 13.25s

どちらの評価実行でも、PGOで最適化されたネイティブ実行可能ファイルは、デフォルトのネイティブビルドよりも大幅に優れています。この場合のPGOによる改善量は、現実世界のアプリケーションのPGOゲインを代表するものではありません。このアプリケーションは小さく、1つのことだけを実行するため、提供されるプロファイルは測定されているのとまったく同じワークロードに基づいているためです。ただし、一般的なポイントは示しています。プロファイルに基づく最適化により、AOTコンパイラはJITコンパイラと同様の最適化を実行できます。

実行可能ファイルのサイズ #

GraalVM Native ImageでPGOを使用するもう1つの利点は、ネイティブ実行可能ファイルのサイズです。ファイルのサイズを測定するには、以下に示すようにLinuxの`du`コマンドを使用できます。

du -hs gameoflife-default
    7.9M    gameoflife-default

du -hs gameoflife-pgo
    6.7M    gameoflife-pgo

ご覧のとおり、PGOで最適化されたネイティブ実行可能ファイルは、デフォルトのネイティブビルドよりも約15%小さくなっています。

これは、最適化ビルドに提供されるプロファイルにより、コンパイラがパフォーマンスにとって重要なコード(「ホットコード」)と重要でないコード(エラー処理などの「コールドコード」)を区別できるためです。この区別が利用できるため、コンパイラはホットコードの最適化に重点を置き、コールドコードの最適化には重点を置かない、またはまったく重点を置かないことを決定できます。これは、JVMが行うアプローチと似ています。実行時にコードのホットな部分を特定し、実行時にそれらの部分をコンパイルします。主な違いは、Native Image PGOがプロファイリングと最適化を事前に行うことです。

さらに読む #

お問い合わせ