プロファイルに基づく最適化の基本的な使用法
GraalVMネイティブ・イメージのコンテキストでのPGOの使用方法を説明するために、「Game of Life」サンプル・アプリケーションを考えてみましょう。これは、コンウェイのライフゲーム・シミュレーションを4000x4000のグリッドに実装したものです。アプリケーションは、世界の初期状態を指定するファイル、最終状態を出力するファイル・パス、および実行するシミュレーションの反復回数を宣言する整数を入力とします。このアプリケーションは現実世界を説明するものではありませんが、例としては十分に役立つはずです。
次に、このリソースから変更されたアプリケーションのソース・コードを示します。
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
これらがすべて準備できたら、異なるモードで実行されているアプリケーションのランタイム・パフォーマンスの評価に進むことができます。
パフォーマンスの評価
パフォーマンスを評価するには、アプリケーションのネイティブ実行可能ファイルを同じ入力で実行します。実行可能ファイルの経過時間は、time
コマンドでカスタム出力形式(--format=>> Elapsed: %es
)を使用して測定できます。
ノート: CPUクロックは、ノイズを最小限に抑え、再現性を向上させるために、すべての測定で2.5GHzに固定されています。
1回の反復で実行
次に示すようにアプリケーションを実行し、1回のみ繰り返します:
time ./gameoflife-default input.txt output.txt 1
>> Elapsed: 1.67s
time ./gameoflife-pgo input.txt output.txt 1
>> Elapsed: 0.97s
経過時間を見ると、PGO最適化ネイティブ実行可能ファイルの実行がパーセンテージの点で大幅に高速であることがわかります。このことを念頭に置くと、このアプリケーションの1回の実行での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ネイティブ・イメージでPGOを使用するもう1つの利点は、ネイティブ実行可能ファイルのサイズです。ファイルのサイズを測定するには、次に示すようにLinuxのdu
コマンドを使用します。
du -hs gameoflife-default
7.9M gameoflife-default
du -hs gameoflife-pgo
6.7M gameoflife-pgo
ご覧のとおり、PGOで最適化されたネイティブ実行可能ファイルは、デフォルトのネイティブ・ビルドより約15%小さくなっています。
これは、最適化ビルドに提供されるプロファイルによって、コンパイラがパフォーマンスにとって重要なコード(ホット・コード)と重要でないコード(エラー処理などのコールド・コード)を区別できるためです。この区別ができるため、コンパイラはホット・コードの最適化に重点を置き、コールド・コードの最適化にはあまり重点を置かないか、まったく置かないようにすることができます。これは、JVMの動作と同様のアプローチです。実行時にコードのホットな部分を特定し、実行時にそれらの部分をコンパイルします。主な違いは、ネイティブ・イメージPGOがプロファイリングと最適化を事前に実行することです。