インタープリター Java コードのホストコンパイル

次のドキュメントでは、ホストコンパイルとゲストコンパイルを区別します。

  • ホストコンパイルは、インタープリターの Java 実装に適用されます。インタープリターが HotSpot 上で実行されている場合、この種のコンパイルは、Truffle インタープリターが Java アプリケーションとして JIT コンパイル (または動的コンパイル) されるときに適用されます。このコンパイルは、ネイティブイメージ生成時に事前に行われます。
  • ゲストコンパイルは、ゲスト言語コードに適用されます。この種のコンパイルでは、部分評価と Futamura 射影を使用して、Truffle AST とバイトコードから最適化されたコードを導き出します。

このセクションでは、Truffle AST およびバイトコードインタープリターに適用されるドメイン固有のホストコンパイルについて説明します。

ホストインライン化 #

Truffle インタープリターは、最初の Futamura 射影を適用することにより、ランタイムコンパイルをサポートするように記述されています。ランタイムコンパイル可能なコードは、部分評価可能なコードとも呼ばれ、次の特性を持っています。

  • 言語のランタイムコンパイル後のパフォーマンスも定義するため、もともと高パフォーマンス向けに設計されています。
  • 再帰的なコードは部分的に迅速に評価できないため、再帰を避けるように記述されています。
  • 複雑な抽象化やサードパーティのコードは、通常 PE 向けに設計されていないため、回避します。
  • 部分評価可能なコードの境界は、@TruffleBoundary で注釈が付けられたメソッド、CompilerDirectives.transferToInterpreter() の呼び出しによって支配されるブロック、または CompilerDirectives.inInterpreter() の呼び出しによって保護されるブロックによって確実に定義されます。

Truffle ホストインライン化は、これらのプロパティを利用し、ランタイムコンパイル可能なコードパスについては、ホストコンパイル中に可能な限りインライン化を強制します。一般的な前提は、ランタイムコンパイルに重要なコードは、インタープリターの実行にも重要であるということです。PE 境界が検出されると、ホストインライン化フェーズはインライン化の決定を行わなくなり、通常の Java コードに適した後のインライン化フェーズに決定を委ねます。

このフェーズのソースコードは、HostInliningPhase にあります。

Truffle ホストインライン化は、@HostCompilerDirectives.BytecodeInterpreterSwitch で注釈が付けられたメソッドをコンパイルするときに適用されます。このようなメソッドの最大ノードコストは、ネイティブイメージの場合は -H:TruffleHostInliningByteCodeInterpreterBudget=100000、HotSpot の場合は -Djdk.graal.TruffleHostInliningByteCodeInterpreterBudget=100000 を使用して構成できます。@BytecodeInterpreterSwitch で注釈が付けられたメソッドが同じ注釈が付けられたメソッドを呼び出す場合、両方のメソッドのコストが予算を超えない限り、メソッドは直接インライン化されます。言い換えれば、このようなメソッドは、ルートバイトコードスイッチメソッドの一部であるかのように、インライン化フェーズによって処理されます。これにより、バイトコードインタープリタースイッチを必要に応じて複数のメソッドで構成できます。

ネイティブイメージは、クローズドワールド分析中に、ランタイムコンパイルのために到達可能なすべてのメソッドを計算します。RootNode.execute(...) から到達可能な可能性のあるメソッドは、ランタイムコンパイル可能と判断されます。ネイティブイメージの場合、バイトコードインタープリタースイッチに加えて、すべてのランタイムコンパイル可能なメソッドは、Truffle ホストインライン化を使用して最適化されます。このようなインライン化パスの最大ノードコストは、-H:TruffleHostInliningBaseBudget=5000 で構成できます。HotSpot では、ランタイムコンパイル可能なメソッドのセットは不明です。したがって、HotSpot でバイトコードインタープリタースイッチとして注釈が付けられていないメソッドについては、通常の Java メソッドインライン化にのみ依存できます。

コンパイルユニットの最大予算に達すると、インライン化は停止します。インライン化中にサブツリーを探索する場合も、同じ予算が使用されます。予算内で呼び出しを完全に探索してインライン化できない場合は、個々のサブツリーに関する決定は行われません。大部分のランタイムコンパイル可能なメソッドの場合、これは自然な PE 境界と、@Child ノードの execute メソッドへのポリモーフィック呼び出しによって防止されるため、この制限には達しません。予算制限を超えるメソッドがある場合は、より多くの PE 境界を追加してこのようなノードを最適化することをお勧めします。メソッドが制限を超える場合、同じコードがランタイムコンパイルのコストも高くなる可能性があります。

ホストインライン化のデバッグ #

このフェーズで実行されるインライン化の決定は、ネイティブイメージの場合は -H:Log=HostInliningPhase,~CanonicalizerPhase,~GraphBuilderPhase、HotSpot の場合は -Djdk.graal.Log=HostInliningPhase,~CanonicalizerPhase,~GraphBuilderPhase でデバッグするのが最適です。-Djdk.graal.LogFile=FILE を使用して、出力をファイルにリダイレクトできます (両方で機能します)。

次の例を考えてみましょう。これは、Truffle インタープリターの部分評価可能なコードの一般的なパターンを示しています。

class BytecodeNode extends Node {

    @CompilationFinal(dimensions = 1) final byte[] ops;
    @Children final BaseNode[] polymorphic = new BaseNode[]{new SubNode1(), new SubNode2()};
    @Child SubNode1 monomorphic = new SubNode1();

    BytecodeNode(byte[] ops) {
        this.ops = ops;
    }

    @BytecodeInterpreterSwitch
    @ExplodeLoop(kind = LoopExplosionKind.MERGE_EXPLODE)
    public void execute() {
        int bci = 0;
        while (bci < ops.length) {
            switch (ops[bci++]) {
                case 0:
                    // regular operation
                    add(21, 21);
                    break;
                case 1:
                    // complex operation in @TruffleBoundary annotated method
                    truffleBoundary();
                    break;
                case 2:
                    // complex operation protected behind inIntepreter
                    if (CompilerDirectives.inInterpreter()) {
                        protectedByInIntepreter();
                    }
                    break;
                case 3:
                    // complex operation dominated by transferToInterpreter
                    CompilerDirectives.transferToInterpreterAndInvalidate();
                    dominatedByTransferToInterpreter();
                    break;
                case 4:
                    // first level of recursion is inlined
                    recursive(5);
                    break;
                case 5:
                    // can be inlined is still monomorphic (with profile)
                    monomorphic.execute();
                    break;
                case 6:
                    for (int y = 0; y < polymorphic.length; y++) {
                        // can no longer be inlined (no longer monomorphic)
                        polymorphic[y].execute();
                    }
                    break;
                default:
                    // propagates transferToInterpeter from within the call
                    throw CompilerDirectives.shouldNotReachHere();
            }
        }
    }

    private static int add(int a, int b) {
        return a + b;
    }

    private void protectedByInIntepreter() {
    }

    private void dominatedByTransferToInterpreter() {
    }

    private void recursive(int i) {
        if (i == 0) {
            return;
        }
        recursive(i - 1);
    }

    @TruffleBoundary
    private void truffleBoundary() {
    }

    abstract static class BaseNode extends Node {
        abstract int execute();
    }

    static class SubNode1 extends BaseNode {
        @Override
        int execute() {
            return 42;
        }
    }

    static class SubNode2 extends BaseNode {
        @Override
        int execute() {
            return 42;
        }
    }
}

これは、graal/compiler で次のコマンドラインを実行することにより、Graal リポジトリ (クラス HostInliningBytecodeInterpreterExampleTest を参照) で単体テストとして実行できます。

mx unittest  -Djdk.graal.Log=HostInliningPhase,~CanonicalizerPhase,~GraphBuilderPhase -Djdk.graal.Dump=:3  HostInliningBytecodeInterpreterExampleTest

これは、次のように出力されます。

[thread:1] scope: main
  [thread:1] scope: main.Testing
  Context: HotSpotMethod<HostInliningBytecodeInterpreterExampleTest$BytecodeNode.execute()>
  Context: StructuredGraph:1{HotSpotMethod<HostInliningBytecodeInterpreterExampleTest$BytecodeNode.execute()>}
      [thread:1] scope: main.Testing.EnterpriseHighTier.HostInliningPhase
      Truffle host inlining completed after 2 rounds. Graph cost changed from 136 to 137 after inlining:
      Root[jdk.graal.compiler.truffle.test.HostInliningBytecodeInterpreterExampleTest$BytecodeNode.execute]
          INLINE jdk.graal.compiler.truffle.test.HostInliningBytecodeInterpreterExampleTest$BytecodeNode.add(int, int)                      [inlined    2, monomorphic false, deopt false, inInterpreter false, propDeopt false, subTreeInvokes    0, subTreeCost    8, incomplete false,  reason null]
          CUTOFF jdk.graal.compiler.truffle.test.HostInliningBytecodeInterpreterExampleTest$BytecodeNode.truffleBoundary()                  [inlined   -1, monomorphic false, deopt false, inInterpreter false, propDeopt false, subTreeInvokes    1, subTreeCost    0, incomplete false,  reason truffle boundary]
          INLINE com.oracle.truffle.api.CompilerDirectives.inInterpreter()                                                                    [inlined    0, monomorphic false, deopt false, inInterpreter false, propDeopt false, subTreeInvokes    0, subTreeCost    6, incomplete false,  reason null]
          CUTOFF jdk.graal.compiler.truffle.test.HostInliningBytecodeInterpreterExampleTest$BytecodeNode.protectedByInIntepreter()          [inlined   -1, monomorphic false, deopt false, inInterpreter  true, propDeopt false, subTreeInvokes    1, subTreeCost    0, incomplete false,  reason protected by inInterpreter()]
          INLINE com.oracle.truffle.api.CompilerDirectives.transferToInterpreterAndInvalidate()                                               [inlined    3, monomorphic false, deopt  true, inInterpreter false, propDeopt false, subTreeInvokes    0, subTreeCost   32, incomplete false,  reason null]
            INLINE com.oracle.truffle.api.CompilerDirectives.inInterpreter()                                                                  [inlined    3, monomorphic false, deopt  true, inInterpreter false, propDeopt false, subTreeInvokes    0, subTreeCost    6, incomplete false,  reason null]
            CUTOFF com.oracle.truffle.runtime.hotspot.AbstractHotSpotTruffleRuntime.traceTransferToInterpreter()                              [inlined   -1, monomorphic false, deopt  true, inInterpreter  true, propDeopt false, subTreeInvokes    0, subTreeCost    0, incomplete false,  reason dominated by transferToInterpreter()]
          CUTOFF jdk.graal.compiler.truffle.test.HostInliningBytecodeInterpreterExampleTest$BytecodeNode.dominatedByTransferToInterpreter() [inlined   -1, monomorphic false, deopt  true, inInterpreter false, propDeopt false, subTreeInvokes    0, subTreeCost    0, incomplete false,  reason dominated by transferToInterpreter()]
          INLINE jdk.graal.compiler.truffle.test.HostInliningBytecodeInterpreterExampleTest$BytecodeNode.recursive(int)                     [inlined    4, monomorphic false, deopt false, inInterpreter false, propDeopt false, subTreeInvokes    1, subTreeCost   20, incomplete false,  reason null]
            CUTOFF jdk.graal.compiler.truffle.test.HostInliningBytecodeInterpreterExampleTest$BytecodeNode.recursive(int)                   [inlined   -1, monomorphic false, deopt false, inInterpreter false, propDeopt false, subTreeInvokes    1, subTreeCost    0, incomplete false,  reason recursive]
          INLINE jdk.graal.compiler.truffle.test.HostInliningBytecodeInterpreterExampleTest$BytecodeNode$SubNode1.execute()                 [inlined    1, monomorphic false, deopt false, inInterpreter false, propDeopt false, subTreeInvokes    0, subTreeCost    6, incomplete false,  reason null]
          CUTOFF jdk.graal.compiler.truffle.test.HostInliningBytecodeInterpreterExampleTest$BytecodeNode$BaseNode.execute()                 [inlined   -1, monomorphic false, deopt false, inInterpreter false, propDeopt false, subTreeInvokes    1, subTreeCost    0, incomplete false,  reason not direct call: no type profile]
          CUTOFF com.oracle.truffle.api.CompilerDirectives.shouldNotReachHere()                                                               [inlined   -1, monomorphic false, deopt false, inInterpreter false, propDeopt  true, subTreeInvokes    0, subTreeCost   98, incomplete false,  reason propagates transferToInterpreter]

また、-Djdk.graal.Dump=:3 オプションも使用しました。これにより、グラフは、さらに検査するために実行中の IdealGraphVisualizer インスタンスに送信されます。ネイティブイメージでは、-H:Dump=:2 -H:MethodFilter=... を使用して、特定のメソッドのホストコンパイルグラフをダンプします。

不完全な探索 (incomplete true のエントリ) の CUTOFF の決定をデバッグするには、-Djdk.graal.TruffleHostInliningPrintExplored=true オプションを使用して、ログにすべての不完全なサブツリーを表示します。

ホストインライン化のチューニング #

ホストインライン化の決定をデバッグおよび追跡する方法を学習した後、チューニング方法について見ていく時間です。最初の手順として、インタープリターのパフォーマンスを向上させるために不可欠なコンパイルユニットを特定する必要があります。これを行うには、engine.Compilation フラグを false に設定して、インタープリターのみのモードで Truffle インタープリターを実行できます。その後、Java プロファイラーを使用して、実行中のホットスポットを特定できます。プロファイリングの詳細については、Profiling.md を参照してください。Truffle インタープリターを最適化する方法とタイミングに関するアドバイスをお探しの場合は、Optimizing.md を参照してください。

ホットメソッド (たとえば、Truffle バイトコードインタープリターのバイトコードディスパッチループ) を特定した後、前のセクションで説明したように、ホストインライン化ロギングを使用してさらに調査できます。興味深いエントリには CUTOFF というプレフィックスが付けられており、個々のカットオフの理由を説明する reason が含まれています。

CUTOFF エントリの一般的な理由は次のとおりです。

  • dominated by transferToInterpreter() または protected by inInterpreter(): これは、低速パスで実行された呼び出しを意味します。ホストインライン化は、このような呼び出しについては決定せず、CUTOFF としてマークするだけです。
  • target method not inlinable これは、インライン化できないホスト VM メソッドで発生します。これについては、通常何もできません。
  • Out of budget このメソッドのインライン化の予算がなくなりました。これは、メソッドのコストが高くなりすぎると発生します。

さらに、コードサイズの爆発を回避するために、ホストインライン化には、インライン化するには複雑すぎると見なされる呼び出しサブツリーを検出するための組み込みのヒューリスティックがあります。たとえば、トレースでは次のように出力される場合があります。

CUTOFF com.oracle.truffle.espresso.nodes.BytecodeNode.putPoolConstant(VirtualFrame, int, char, int)   [inlined   -1, explored    0, monomorphic false, deopt false, inInterpreter false, propDeopt false, graphSize 1132, subTreeCost 5136, invokes    1, subTreeInvokes   12, forced false, incomplete false,  reason call has too many fast-path invokes - too complex, please optimize, see truffle/docs/HostOptimization.md

これは、サブツリーに高速パス呼び出しが多すぎる (デフォルトでは 10) ことを示しており、その数を超えると探索も停止します。-Djdk.graal.TruffleHostInliningPrintExplored=true フラグを指定して、決定のサブツリー全体を確認できます。次の呼び出しは、高速パス呼び出しと見なされます。

  • ターゲットメソッドが @TruffleBoundary で注釈が付けられている呼び出し。
  • ポリモーフィックであるか、モノモーフィックプロファイリングフィードバックが利用できない呼び出し。たとえば、サブ式の execute メソッドの呼び出し。
  • 再帰的な呼び出し。
  • それ自体が複雑すぎる呼び出し。たとえば、高速パス呼び出しが多すぎる呼び出し。

次の呼び出しは、高速パス呼び出しとみなされません

  • ホストインライン化ヒューリスティックを使用してインライン化できる呼び出し。
  • 低速パスでの呼び出し。transferToInterpreter() によって支配される、または isInterpreter() によって保護される呼び出し。
  • Throwable.fillInStackTrace() の呼び出しなど、ホスト VM の制限によりインライン化できない呼び出し。
  • 到達できなくなった呼び出し。

たとえば、子ノードは AST で実行する必要があるため、高速パス呼び出しを完全に回避することは不可能です。理論的には、バイトコードインタープリターで高速パス呼び出しをすべて回避できます。実際には、言語は @TruffleBoundary に依存して、より複雑なバイトコードを実装します。

次のセクションでは、ホストインタープリターコードを改善する方法について説明します。

最適化: @HostCompilerDirectives.InliningCutoff を使用して手動でコードパスをカットする #

前のセクションで述べたように、ヒューリスティックは、呼び出しが多すぎるインライン化サブツリーを自動的にカットします。これを最適化する方法の 1 つは、@InliningCutoff 注釈を使用することです。

次の例を考えてみましょう。

abstract class AddNode extends Node {

   abstract Object execute(Object a, Object b);

   @Specialization int doInt(int a, int b) { return a + b; }
   
   @Specialization double doDouble(double a, double b) { return a + b; }
   
   @Specialization double doGeneric(Object a, Object b, @Cached LookupAndCallNode callNode) { 
       return callNode.execute("__add", a, b); 
   }
}

この例では、特殊化 doIntdoDouble は非常に単純ですが、複雑なルックアップチェーンを呼び出す doGeneric 特殊化もあります。LookupAndCallNode.execute が 10 個を超える高速パスサブツリー呼び出しを含む非常に複雑なメソッドであると仮定すると、execute メソッドがインライン化されることは期待できません。ホストインライン化は現在、自動コンポーネント分析をサポートしていません。ただし、@InliningCutoff 注釈を使用すると手動で指定できます。

abstract class AddNode extends Node {

   abstract Object execute(Object a, Object b);

   @Specialization int doInt(int a, int b) { return a + b; }
   
   @Specialization double doDouble(double a, double b) { return a + b; }
   
   @HostCompilerDirectives.InliningCutoff
   @Specialization double doGeneric(Object a, Object b, @Cached LookupAndCallNode callNode) { 
       return callNode.execute("__add__", a, b); 
   }
}

コードを変更した後、ホストインライン化は、AddNode の execute メソッドがホストインライン化予算に収まる場合は、そのメソッドをインライン化することを決定する可能性がありますが、doGeneric(...) メソッド呼び出しで CUTOFF を強制します。この注釈のその他のユースケースについては、javadoc を参照してください。

最適化: 部分評価中に折りたたまれるブランチからの呼び出しの重複排除 #

次に、部分評価を使用してコンパイルする場合に効率的だが、ホストコンパイルには理想的ではないコードの例を示します。

@Child HelperNode helperNode;

final boolean negate;
// ....

int execute(int argument) {
	if (negate) {
		return helperNode.execute(-argument);
	} else {
         return helperNode.execute(argument);
	}
}

このコードが部分評価を使用してコンパイルされる場合、negate フィールドはコンパイル最終であるため、条件が単一のケースに折りたたまれることが保証されているため、このコードは効率的です。ホスト最適化中、negate フィールドはコンパイル最終ではなく、コンパイラーはコードを 2 回インライン化するか、execute メソッドをインライン化しないことを決定します。これを回避するために、コードは次のように書き換えることができます。

@Child HelperNode helperNode;

final boolean negate;
// ....

int execute(int argument) {
    int negatedArgument;
    if (negate) {
        negatedArgument = -argument;
    } else {
        negatedArgument = argument;
    }
    return helperNode.execute(negatedArgument);
}

同様のコードパターンは、同じメソッド本体を持つ多くの特殊化が使用されている場合、コード生成を通じて間接的に発生する可能性があります。ホストコンパイラは通常、このようなパターンを自動的に最適化するのが苦手です。

最適化: 複雑な低速パスコードを別のメソッドに抽出する #

次の例を考えてみましょう。

int execute(int argument) {
	if (argument == 0) {
	   CompilerDirectives.transferToInterpeterAndInvalidate();
	   throw new RuntimeException("Invalid zero argument " + argument);
	}
	return argument;
}

Javaコンパイラは、以下のコードと同等のバイトコードを生成します。

int execute(int argument) {
	if (argument == 0) {
	   CompilerDirectives.transferToInterpeterAndInvalidate();
	   throw new RuntimeException(new StringBuilder("Invalid zero argument ").append(argument).build());
	}
	return argument;
}

このコードは部分評価には効率的ですが、ホストインライン化中に不必要なスペースを消費します。そのため、コードの低速パス部分に対して単一のメソッドを抽出することを推奨します。

int execute(int argument) {
	if (argument == 0) {
	   CompilerDirectives.transferToInterpeterAndInvalidate();
	   throw invalidZeroArgument(argument);
	}
	return argument;
}

RuntimeException invalidZeroArgument(int argument) {
   throw new RuntimeException("Invalid zero argument " + argument);
}

お問い合わせ