オンスタック置換 (OSR)

実行中、Truffleは「ホット」な呼び出し対象をコンパイル用にスケジュールします。対象がコンパイルされると、対象のその後の呼び出しはコンパイルされたバージョンを実行できます。ただし、呼び出し対象の実行中は、コンパイル済みコードに実行を転送できないため、このコンパイルのメリットは得られません。これは、長時間実行される対象がインタプリタで「スタック」し、ウォームアップパフォーマンスが低下する可能性があることを意味します。

オンスタック置換 (OSR) は、インタプリタから「脱出」し、実行をインタプリタコードからコンパイル済みコードに転送するためにTruffleで使用される手法です。Truffleは、ASTインタプリタ (つまり、`LoopNode`を持つAST) とバイトコードインタプリタ (つまり、ディスパッチループを持つノード) の両方でOSRをサポートしています。いずれの場合も、Truffleはヒューリスティックを使用して、長時間実行されているループがいつ解釈されているかを検出し、OSRを実行して実行を高速化できます。

ASTインタプリタのOSR #

標準のTruffle APIを使用する言語は、GraalでOSRを無料で利用できます。ランタイムは、`LoopNode` (`TruffleRuntime.createLoopNode(RepeatingNode)`を使用して作成) がインタプリタで実行される回数を追跡します。ループの反復回数がしきい値を超えると、ランタイムはループを「ホット」と見なし、ループを透過的にコンパイルし、完了をポーリングしてから、コンパイルされたOSRターゲットを呼び出します。OSRターゲットは、インタプリタが使用するのと同じ`Frame`を使用します。OSR実行でループが終了すると、インタプリタ実行に戻り、結果を転送します。

詳細については、`LoopNode`のjavadocを参照してください。

バイトコードインタプリタのOSR #

バイトコードインタプリタのOSRでは、言語からの協力がもう少し必要です。バイトコードディスパッチノードは、通常、次のようなものです。

class BytecodeDispatchNode extends Node {
  @CompilationFinal byte[] bytecode;

  ...

  @ExplodeLoop(kind = ExplodeLoop.LoopExplosionKind.MERGE_EXPLODE)
  Object execute(VirtualFrame frame) {
    int bci = 0;
    while (true) {
      int nextBCI;
      switch (bytecode[bci]) {
        case OP1:
          ...
          nextBCI = ...
          ...
        case OP2:
          ...
          nextBCI = ...
          ...
        ...
      }
      bci = nextBCI;
    }
  }
}

ASTインタプリタとは異なり、バイトコードインタプリタのループは、多くの場合、構造化されておらず(暗黙的です)。バイトコード言語には構造化ループはありませんが、コード内の後方ジャンプ(「バックエッジ」)はループ反復の適切なプロキシになる傾向があります。したがって、TruffleのバイトコードOSRは、バックエッジとそのエッジの宛先(多くの場合、ループヘッダーに対応)を中心に設計されています。

TruffleのバイトコードOSRを利用するには、言語のディスパッチノードが`BytecodeOSRNode`インターフェースを実装する必要があります。このインターフェースには、(少なくとも)3つのメソッド実装が必要です。

  • `executeOSR(osrFrame, target, interpreterState)`:このメソッドは、`osrFrame`を現在のプログラム状態として使用して、指定された`target`(つまり、バイトコードインデックス)に実行をディスパッチします。`interpreterState`オブジェクトは、実行を再開するために必要な追加のインタプリタ状態を渡すことができます。
  • `getOSRMetadata()`と`setOSRMetadata(osrMetadata)`:これらのメソッドは、クラスで宣言されたフィールドへのアクセスをプロキシします。ランタイムはこれらのアクセサを使用して、OSRコンパイルに関連する状態(たとえば、バックエッジカウント)を維持します。フィールドには`@CompilationFinal`アノテーションを付ける必要があります。

メインディスパッチループで、言語がバックエッジに到達したときに、提供されている`BytecodeOSRNode.pollOSRBackEdge(osrNode)`メソッドを呼び出して、ランタイムにバックエッジを通知する必要があります。ランタイムがノードをOSRコンパイルの対象とみなした場合、このメソッドは`true`を返します。

`pollOSRBackEdge`が`true`を返す場合(そしてその場合のみ)、言語は`BytecodeOSRNode.tryOSR(osrNode, target, interpreterState, beforeTransfer, parentFrame)`を呼び出してOSRを試みることができます。このメソッドは、`target`から始まるコンパイルを要求し、コンパイルされたコードが使用可能になると、後続の呼び出しはコンパイルされたコードを透過的に呼び出して、計算された結果を返します。`interpreterState`パラメータと`beforeTransfer`パラメータについては、後ほど説明します。

上記の例は、OSRをサポートするように次のようにリファクタリングできます。

class BytecodeDispatchNode extends Node implements BytecodeOSRNode {
  @CompilationFinal byte[] bytecode;
  @CompilationFinal private Object osrMetadata;

  ...

  Object execute(VirtualFrame frame) {
    return executeFromBCI(frame, 0);
  }

  Object executeOSR(VirtualFrame osrFrame, int target, Object interpreterState) {
    return executeFromBCI(osrFrame, target);
  }

  Object getOSRMetadata() {
    return osrMetadata;
  }

  void setOSRMetadata(Object osrMetadata) {
    this.osrMetadata = osrMetadata;
  }

  @ExplodeLoop(kind = ExplodeLoop.LoopExplosionKind.MERGE_EXPLODE)
  Object executeFromBCI(VirtualFrame frame, int bci) {
    while (true) {
      int nextBCI;
      switch (bytecode[bci]) {
        case OP1:
          ...
          nextBCI = ...
          ...
        case OP2:
          ...
          nextBCI = ...
          ...
        ...
      }

      if (nextBCI < bci) { // back-edge
        if (BytecodeOSRNode.pollOSRBackEdge(this)) { // OSR can be tried
          Object result = BytecodeOSRNode.tryOSR(this, nextBCI, null, null, frame);
          if (result != null) { // OSR was performed
            return result;
          }
        }
      }
      bci = nextBCI;
    }
  }
}

バイトコードOSRの微妙な違いは、OSRの実行がループの終わりを超えて呼び出し対象の終わりまで続くことです。したがって、OSRから実行が戻った後、インタプリタで実行を続ける必要はありません。結果は単に呼び出し元に転送できます。

`tryOSR`の`interpreterState`パラメータには、実行に必要な追加のインタプリタ状態を含めることができます。この状態は`executeOSR`に渡され、実行の再開に使用できます。たとえば、インタプリタがデータポインタを使用して読み取り/書き込みを管理し、それが各`target`に対して一意である場合、このポインタは`interpreterState`で渡すことができます。コンパイラから見え、部分評価で使用されます。

`tryOSR`の`beforeTransfer`パラメータは、OSRを実行する前に呼び出されるオプションのコールバックです。`tryOSR`はOSRを実行する場合としない場合があるため、このパラメータはOSRコードに転送する前にアクションを実行する方法です。たとえば、言語はコールバックを渡して、OSRコードにジャンプする前に計測イベントを送信できます。

`BytecodeOSRNode`インターフェースには、デフォルトの実装をオーバーライドできるフックメソッドもいくつか含まれています。

  • `copyIntoOSRFrame(osrFrame, parentFrame, target)`と`restoreParentFrame(osrFrame, parentFrame)`:解釈された`Frame`をOSRコード内で再利用することは最適ではありません。これは、OSR呼び出し対象からエスケープし、スカラー置換を妨げるためです(スカラー置換の背景については、この論文を参照)。可能な場合、Truffleは`copyIntoOSRFrame`を使用して解釈された状態(`parentFrame`)をOSR `Frame`(`osrFrame`)にコピーし、`restoreParentFrame`を使用して状態を親`Frame`にコピーします。デフォルトでは、どちらのフックもソースフレームとデスティネーションフレームの間で各スロットをコピーしますが、より細かい制御のためにオーバーライドできます(たとえば、ライブ変数のみをコピーするため)。オーバーライドする場合、これらのメソッドはスカラー置換をサポートするように慎重に記述する必要があります。
  • `prepareOSR(target)`:このフックは、OSRターゲットをコンパイルする前に呼び出されます。コンパイル前に初期化を強制的に実行するために使用できます。たとえば、フィールドをインタプリタでのみ初期化できる場合、`prepareOSR`は初期化されていることを確認できるため、OSRコードはアクセスしようとしたときに最適化を解除しません。

バイトコードベースのOSRの実装は難しい場合があります。デバッグのヒントを次に示します。

  • メタデータフィールドが`@CompilationFinal`とマークされていることを確認します。
  • 特定の`FrameDescriptor`を持つ`Frame`が以前に具体化されている場合、Truffleはコピーする代わりにインタプリタ`Frame`を再利用します(コピーが使用されている場合、既存の具体化された`Frame`はOSR `Frame`と同期しなくなる可能性があります)。
  • `prepareOSR`で実行できる初期化作業を特定するために、コンパイルログと最適化解除ログをトレースすると役立ちます。
  • IGVでコンパイルされたOSRターゲットを検査すると、コピーフックが部分評価と適切に連携していることを確認できます。

詳細については、`BytecodeOSRNode`のjavadocを参照してください。

コマンドラインオプション #

OSRを設定するために使用できる(実験的な)オプションが2つあります。

  • `engine.OSR`:OSRを実行するかどうか (デフォルト:`true`)
  • `engine.OSRCompilationThreshold`:OSRコンパイルをトリガーするために必要なループ反復/バックエッジの数 (デフォルト:`100,352`)

デバッグ #

OSRコンパイルターゲットは、`<OSR>`(またはバイトコードOSRの場合、`n`がディスパッチターゲットである`<OSR@n>`)でマークされます。これらのターゲットは、コンパイルログやIGVなどの標準のデバッグツールを使用して表示およびデバッグできます。たとえば、コンパイルログでは、バイトコードOSRエントリは次のようになります。

[engine] opt done     BytecodeNode@2d3ca632<OSR@42>                               |AST    2|Tier 1|Time   21(  14+8   )ms|Inlined   0Y   0N|IR   161/  344|CodeSize   1234|Addr 0x7f3851f45c10|Src n/a

Graalコンパイルのデバッグの詳細については、デバッグを参照してください。

お問い合わせ