Profile Guided Optimizations for Native Image

原文はこちら。
The original article was written by Boris Spasojević (Principal Researcher at Oracle).
https://medium.com/graalvm/profile-guided-optimization-for-native-image-f015f853f9a8

JITコンパイラがAOTコンパイラより優れている点の一つは、コンパイル中のアプリケーションのランタイム動作を分析できることです。例えば、HotSpotはif文の各分岐が何回実行されたかを記録しています。この情報はプロファイルと呼ばれる情報として最上位のJITコンパイラ(Graalなど)に渡されます。プロファイルは、実行時に特定のメソッドがどのように実行されたかをまとめたものです。JITコンパイラは、そのメソッドが今後も同じように動作すると仮定し、そのメソッドをより最適化するためにプロファイルの情報を使用します。

AOTコンパイラは通常、プロファイリング情報を持っておらず、コンパイル対象のコードの静的なビューに制限されています。つまり、ヒューリスティックを除けば、AOTコンパイラはすべてのif文の各分岐を、実行時の発生可能性が等しく、各メソッドが呼び出される可能性が他のメソッドと等しく、各ループが繰り返される回数が等しいと見なします。このためにAOTコンパイラは不利な立場に置かれます。プロファイル情報がなければ、JITコンパイラと同じ品質のマシン・コードを生成することは困難です。

プロファイルガイド付き最適化(Profile Guided Optimization、PGO) は、AOTコンパイラにプロファイル情報を導入し、パフォーマンスとサイズの面で出力の品質を向上させる技術です。

What is a Profile?

前述の通り、JITコンパイル時のプロファイルは、コード内のメソッドの実行時動作のサマリです。同じことがAOTコンパイラにも当てはまりますが、コンパイルは実行時より前に行われるため、コンパイラにこの情報を提供するランタイム(つまりJVM)がないという注意点があります。このため、プロファイルの収集はより困難になりますが、高レベルではプロファイルの内容は非常に似ています。実際には、プロファイルは、実行時に特定のイベントが何回発生したかを要約したログです。これらのイベントは、コンパイラがより適切な判断を下すために役立つ情報に基づいて選択されます。そのようなイベントの例としては以下のようなものがあります。

  • このメソッドの呼び出し回数
  • このif文でtrueブランチを通った回数、falseブランチを通った回数
  • このメソッドがオブジェクトを確保した回数
  • 特定のinstanceofチェックにString値を渡した回数

How Do I Obtain a Profile of My Application?

JVM上でアプリケーションを実行する場合、アプリケーションのプロファイリングは実行環境によって処理されます。開発者が特別なことをする必要はありません。これは間違いなく単純ですが、ランタイムが行うプロファイリングにはコストがかかります。実行時間とメモリ使用量の両方において、プロファイリング対象のコードの実行オーバーヘッドが発生します。これはウォームアップの問題を引き起こします。アプリケーションの主要部分がプロファイリングされ、JITコンパイルされるのに十分な時間が経過した後にのみ、アプリケーションは予測可能なピーク性能に達します。長時間稼動するアプリケーションでは、このオーバーヘッドは通常、それ自体に見合うだけの効果があり、後でパフォーマンスが向上します。一方、寿命の短いアプリケーションや、優れた予測可能なパフォーマンスをできるだけ早く開始する必要があるアプリケーションでは、このオーバーヘッドは逆効果です。

AOT コンパイルされたアプリケーションのプロファイルの収集はより複雑で、開発者は余分な手順を実施する必要がありますが、最終成果物なアプリケーションにはオーバーヘッドは発生しません。ここで、プロファイルは実行中にアプリケーションを観察して収集する必要があります。これは一般的に、アプリケーション・バイナリにインスツルメンテーション・コードを挿入する特別なモードでアプリケーションをコンパイルすることで実現します。インスツルメンテーションコードは、プロファイルの対象となるイベントのカウンタをインクリメントします。これはinstrumented executable(インスツルメント対象の実行ファイル)と呼ばれ、これらのカウンタを追加するプロセスはinstrumentation(インスツルメンテーション)と呼ばれます。当然ながら、インスツルメンテーション・コードのオーバーヘッドにより、アプリケーションのinstrumented executableはデフォルトのビルドほどパフォーマンスが高くならないため、instrumented executableを本番環境で定期的に実行することは推奨されません。しかし、instrumented executable上で代表的な合成ワークロードを実行すれば、(ランタイムが JIT コンパイラに対して行うのと同じように)アプリケーションの代表的なプロファイルを収集できます。最適化されたバイナリをビルドするとき、AOT コンパイラはアプリケーションの静的なビューと動的なプロファイルの両方を持ちます。これがプロファイルガイド付きの最適化済みバイナリは、AOTコンパイルされたデフォルトのバイナリよりも優れたパフォーマンスを発揮します。

How Does a Profile “Guide” Optimization?

コンパイラの最適化は多くの場合、コンパイル中に判断する必要があります。例えば、以下のメソッドでは、関数インライン化による最適化では、どのコールサイトをインライン化すべきか否かを決定する必要があります。

Inline expansion
https://en.wikipedia.org/wiki/Inline_expansion

private int run(String[] args) {
    if (args.length < 3) {
        return handleNotEnoughArguments(args);
    } else {
        return doActualWork(args);
    }
}

説明目的で、インライン化による最適化には生成できるコード量に制限があり、そのため呼び出しの片方しかインライン化できないと想像してみましょう。コンパイル対象のコードの静的ビューだけを見ると、doActualWorkhandleNotEnoughArgumentsの両方の呼び出しはほとんど区別がつきません。ヒューリスティックがなければ、インライン化を実行する最適化は、どちらがインライン化するのに適しているかを推測しなければなりません。しかし、間違った選択をすると、効率の悪いコードになる可能性があります。runは実行時に適切な数の引数で呼び出されることが最も多いと仮定しましょう。その場合、handleNotEnoughArgumentsをインライン化すると、性能上の利点がほぼなく、コンパイル・ユニットのコード・サイズが大きくなってしまいます。というのも、ほとんどの場合にdoActualWorkの呼び出しが行われる必要があるためです。

アプリケーションのランタイム・プロファイルがあれば、インライン化すべき呼び出しを区別することが簡単になるデータをコンパイラに渡せます。例えば、実行時プロファイルがこのif条件(args.length < 3)でfalse100回、true3回と記録していた場合、おそらくdoActualWorkをインライン化すべきでしょう。これがPGOの本質です。プロファイルの情報を使う、つまりコンパイル対象のアプリケーションの実行時動作から得た情報を使って、コンパイラが判断を下すときにデータの根拠を与えるのです。実際の決定やプロファイルが記録する実際のイベントはフェーズによって異なりますが、前述の例は一般的な考え方を示しています。

ここで、PGOはアプリケーションのinstrumented executableで代表的なワークロードを実行することが期待されていることに注意してください。つまり、逆効果のプロファイル、つまりアプリの実際の実行時の動作と正反対の動作を記録するプロファイルを提供すると逆効果になります。今回の例では、実際のアプリケーションでは実施しないのに、少ない引数でrunメソッドを呼び出すというワークロードでinstrumented executableを実行しようとしています。これは、インライン化フェーズがhandleNotEnoughArgumentsをインライン化することを選択し、最適化されたビルドのパフォーマンスを低下させることにつながります。

したがって、目標は、本番のワークロードにできるだけ一致するワークロードのプロファイルを収集することです。このためのゴールドスタンダードは、instrumented executableで本番環境とまったく同じワークロードを実行することです。

A Game Of Life example

GraalVMネイティブイメージのコンテキストにおけるPGOの使用法を理解するために、サンプルアプリケーションを考えてみましょう。このアプリケーションは、4000×4000のグリッド上のコンウェイの人生ゲームのシミュレーションの実装です。

Conway’s Game of Life
https://en.wikipedia.org/wiki/Conway%27s_Game_of_Life

このアプリケーションは非常に単純で、実世界を説明するものではありませんが、実行例として十分に役立つことに注目してください。このアプリケーションは、世界の初期状態を指定するファイル、世界の最終状態を出力するファイルパス、繰り返し実行するシミュレーションの回数を指定する整数を入力として受け取ります。

このアプリケーションのソースコード全体はGameOfLife.javaにありますが、これは結果の再現目的においています。今は詳しく理解する必要はないので、読み飛ばしてもらってかまいません。

アプリケーションのパフォーマンス、つまりアプリケーションの最適化度合いをはかるために、経過時間に関心があります。アプリケーションに適用される最適化が優れていればいるほど、アプリケーションがワークロードを完了するのにかかる時間は短くなるという前提です。そこで、同じアプリケーションを2つの異なる方法、PGOなしのGraalVMネイティブ・イメージとPGO付きのGraalVMネイティブ・イメージでそれぞれ実行します。

Build instructions

環境変数JAVA_HOMEがGraalVMのインストール先を指している前提で、以下のコマンドを実行できます。

$ $JAVA_HOME/bin/java -version
    java version "21.0.1" 2023-10-17
    Java(TM) SE Runtime Environment Oracle GraalVM 21.0.1+12.1 (build 21.0.1+12-jvmci-23.1-b19)
    Java HotSpot(TM) 64-Bit Server VM Oracle GraalVM 21.0.1+12.1 (build 21.0.1+12-jvmci-23.1-b19, mixed mode, sharing)

環境変数が想定するGraalVMのバージョンに設定されていることを確認するためです。

まずは、.javaファイルをクラスファイルにコンパイルします。

$ $JAVA_HOME/bin/javac GameOfLife.java

また、以下のように、アプリケーションのネイティブ実行ファイルをビルドする必要があります。

$ $JAVA_HOME/bin/native-image -cp . GameOfLife -o gameoflife-default
    ========================================================================================================================
    GraalVM Native Image: Generating 'gameoflife-default' (executable)...
    ========================================================================================================================
    For detailed information and explanations on the build output, visit:
    https://github.com/oracle/graal/blob/master/docs/reference-manual/native-image/BuildOutput.md
    ------------------------------------------------------------------------------------------------------------------------
    [1/8] Initializing...                                                                                    (3.5s @ 0.14GB)
     Java version: 21.0.1+12, vendor version: Oracle GraalVM 21.0.1+12.1
     Graal compiler: optimization level: 2, target machine: x86-64-v3, PGO: ML-inferred
    ...

これで、PGO対応ネイティブ実行ファイルのビルドに移ることができます。前述の通り、最初のステップでは、アプリケーションのランタイム動作のプロファイルを生成するinstrumented executableをビルドします。このために、以下のようにnative-imageコマンドに--pgo-instrumentedを追加して実行します。

$ $JAVA_HOME/bin/native-image -cp . GameOfLife -o gameoflife-instrumented --pgo-instrument
    ========================================================================================================================
    GraalVM Native Image: Generating 'gameoflife-instrumented' (executable)...
    ========================================================================================================================
    For detailed information and explanations on the build output, visit:
    https://github.com/oracle/graal/blob/master/docs/reference-manual/native-image/BuildOutput.md
    ------------------------------------------------------------------------------------------------------------------------
    [1/8] Initializing...                                                                                    (3.6s @ 0.14GB)
     Java version: 21.0.1+12, vendor version: Oracle GraalVM 21.0.1+12.1
     Graal compiler: optimization level: 2, target machine: x86-64-v3, PGO: instrument
    ...

この結果、gameoflife-instrumentedという実行ファイルができあがりますが、このファイルは実のところアプリケーションのintrumented executableです。この実行ファイルは、通常アプリケーションが行うすべてのことを行いますが、終了する直前に.iprofファイル(.iprofファイルは、ネイティブイメージがランタイムプロファイルを保存するために使用するフォーマット)を生成します。.iprofファイルは、デフォルトでは、intrumented executableはプロファイルをdefault.iprofに保存しますが、プロファイルを保存するiprofファイルの正確な名前/パスを指定することもできます。その場合、アプリケーションのintrumented executableを起動するときに、-XX:ProfilesDumpFile 引数を指定します。以下では、gameoflife.iprofファイルにプロファイルを保存したいことを指定して、アプリケーションのintrumented executableを実行しています。また、初期状態(input.txt)、最終状態(output.txt)、シミュレーションの繰り返し回数(ここでは10回)を指定します。

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

この実行が終了すると、アプリケーションの実行時プロファイルがgameoflife.iprofファイルに収集されます。これにより、以下のように --pgo オプションを使ってアプリケーションの実行時プロファイルを渡すことで、最終的にアプリケーションの最適化バージョンをビルドできます。

$ $JAVA_HOME/bin/native-image -cp . GameOfLife -o gameoflife-pgo --pgo=gameoflife.iprof
    ========================================================================================================================
    GraalVM Native Image: Generating 'gameoflife-pgo' (executable)...
    ========================================================================================================================
    For detailed information and explanations on the build output, visit:
    https://github.com/oracle/graal/blob/master/docs/reference-manual/native-image/BuildOutput.md
    ------------------------------------------------------------------------------------------------------------------------
    [1/8] Initializing...                                                                                    (3.6s @ 0.14GB)
     Java version: 21.0.1+12, vendor version: Oracle GraalVM 21.0.1+12.1
     Graal compiler: optimization level: 3, target machine: x86-64-v3, PGO: user-provided
    ...

これでようやく、異なるモードで実行するアプリケーションのランタイム・パフォーマンスの評価に移ることができます。

Evaluation

同じ入力を使って、両方のアプリケーションを実行します。経過時間の測定は、カスタム出力フォーマット(--format=>> Elapsed: %es)でLinuxのtimeコマンドを使用します。注:ノイズを最小限に抑え、再現性を向上させるため、すべての測定においてCPUクロックを2.5GHzに固定しました。

1 iteration

まずは少ない回数から始めます。Game Of Lifeアプリケーションを1回だけ実行してみましょう。両方のアプリケーションのコマンドと出力は以下の通りです。

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

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

経過時間を見ると、PGO実行ファイルを実行した方がパーセンテージの観点で大幅に高速であることがわかります。0.5秒の差は、このアプリケーションの1回の実行では大きな影響をありませんが、これが頻繁に実行されるサーバーレスアプリケーションであれば、累積的なパフォーマンスの向上が見込まれるでしょう。

100 Iterations

続いて、アプリケーションを100回繰り返し実行してみます。先ほどと同じように、実行されたコマンドと時間の出力は以下の通りです。

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

$ time  ./gameoflife-pgo input.txt output.txt 100
    >> Elapsed: 14.40s
Native Image performance comparison with and without PGO

今回の実行例(1および100反復)では、PGOビルドのパフォーマンスはデフォルトのネイティブイメージビルドのそれを大幅に上回っています。PGOが今回の例でもたらす改善は、当然ながら現実のアプリケーションでPGOがもたらすものを示しているわけではありません。というのも、私たちのGame Of Lifeアプリケーションは規模が小さく、まさに1つのことしか行わないため、提供されたプロファイルは私たちが測定しているのとまったく同じ作業負荷に基づいているからです。しかし、これは一般的なポイントを示しています。プロファイルガイド付き最適化によって、AOTコンパイラは、生成するコードのパフォーマンスを向上させるためにJITコンパイラができるのと同じようなトリックを実行できるようになります。

Binary size

native-image生成にPGOを使用する特典として、アプリケーションのデフォルトの実行ファイルとPGO実行ファイルのサイズを見てみましょう。Linux の du コマンドを使います。

$ du -hs gameoflife-default
    7.9M    gameoflife-default

$ du -hs gameoflife-pgo
    6.7M    gameoflife-pgo

ご覧の通り、PGOビルドはデフォルトビルドよりもバイナリが~15%小さくなっています。PGOバージョンは、私たちがテストした両方の反復回数において、デフォルトバージョンを上回ったことを思い出してください。また、先に述べた関数のインライン化など、特定の最適化によって、パフォーマンスを向上させるためにバイナリサイズが大きくなることも思い出してください。では、なぜPGOビルドではバイナリサイズが小さくてもパフォーマンスが向上するのでしょうか?

それは、私たちが最適化ビルドのために提供したプロファイルを使うことで、コンパイラがパフォーマンスにとって重要なコード(すなわちホットコード、実行時にほとんどの時間を費やすコード)とパフォーマンスにとって重要でないコード(すなわちコールドコード、エラー処理など実行時に多くの時間を費やすことのないコード)を区別できるようになるからです。この区別を利用することで、コンパイラーはホットコードの最適化に重点を置き、コールドコードの最適化を減らすか、まったく行わないかを決定できます。これは、JVMが実行時にコードのホットな部分を特定し、その部分をコンパイルするのとよく似た考え方です。主な違いは、Native Image PGOがプロファイリングと最適化を先行して行う点です。

Conclusion

このエントリでは、プロファイルガイド付き最適化(PGO)の背後にある主なアイデアの概要を、Native ImageにおけるPGOの実装を中心にご紹介しました。実行時にアプリケーションの動作を記録し(プロファイリング)、この情報を後で使用できるように保存する(Native Imageの.iprofファイルに保存されたプロファイル)ことで、通常では得られない情報にAOTコンパイラがアクセスできるようになることを説明しました。この情報をコンパイラの意思決定の指針として使用でき、その結果、パフォーマンスが向上し、バイナリが小さくなります。Game-of-Lifeの例でPGOの利点を説明しました。

PGOは些細なテクニックではないことも重要です。PGOが有益であるためには、現実的なワークロードでintrumented executableを実行する必要があるからです。そのため、PGOは最適化ビルドに提供されるプロファイルと同程度の性能しかないことに留意してください。つまり、逆効果になるワークロードにプロファイルを集めても逆効果になる可能性があり、アプリケーションの機能の一部しかカバーしていないワークロードでは、より優れたカバレッジを持つ現実的なワークロードと比較して、パフォーマンスの向上が小さくなる可能性が高いということです。

なおOracle GraalVMでPGOを使用しない場合、デフォルトでMLベースのプロファイル推論を使用します。

Machine Learning for Compiler Optimizations (A New GraalVM Release and New Free License!)
https://medium.com/graalvm/a-new-graalvm-release-and-new-free-license-4aab483692f5#894e
https://logico-jp.io/2023/06/15/a-new-graalvm-release-and-new-free-license/#894e

このしくみでは、プロファイリング情報を持たない場合と比較して、最大で約6%、実行時の高速化がもたらされます。

コメントを残す

このサイトはスパムを低減するために Akismet を使っています。コメントデータの処理方法の詳細はこちらをご覧ください