Java:手動で展開されたループは、元のループよりも高速です。どうして?


13

長さ2の配列に対する次の2つのコードスニペットを考えてみます。

boolean isOK(int i) {
    for (int j = 0; j < filters.length; ++j) {
        if (!filters[j].isOK(i)) {
            return false;
        }
    }
    return true;
}

そして

boolean isOK(int i) {
     return filters[0].isOK(i) && filters[1].isOK(i);
}

十分なウォームアップ後、これら2つのピースのパフォーマンスは同様になるはずだと思います。ここここなどで
説明されているように、JMHマイクロベンチマークフレームワークを使用してこれをチェックし、2番目のスニペットが10%以上高速であることを確認しました。

質問:Javaが基本的なループ展開技術を使用して最初のスニペットを最適化していないのはなぜですか?
特に、以下について理解したいと思います。

  1. 2つのフィルターの場合に最適なコードを簡単に作成できますが、別の数のフィルターの場合でも機能します(単純なビルダーを想像してください)
    return (filters.length) == 2 ? new FilterChain2(filters) : new FilterChain1(filters)。JITCは同じことを行うことができますか?
  2. JITCは 'filters.length == 2 'が最も頻繁に発生するケースであることを検出し、ウォームアップ後にこのケースに最適なコードを生成できますか?これは、手動で展開したバージョンとほぼ同じくらい最適です。
  3. JITCは、特定のインスタンスが非常に頻繁に使用されていることを検出し、この特定のインスタンス(フィルターの数が常に2であることがわかっている)のコード生成できますか?
    更新: JITCはクラスレベルでのみ機能するという回答を得ました。はい、わかった。

理想的には、JITCの仕組みを深く理解している方から回答をいただきたいと考えています。

ベンチマーク実行の詳細:

  • 最新バージョンのJava 8 OpenJDKとOracle HotSpotで試した結果は同じです
  • 使用されているJavaフラグ:-Xmx4g -Xms4g -server -Xbatch -XX:CICompilerCount = 2(ファンシーフラグなしで同様の結果を得た)
  • ちなみに、ループで(JMH経由ではなく)数十億回実行するだけでも、同様の実行時間比率が得られます。つまり、2番目のスニペットは常に明らかに高速です。

一般的なベンチマーク出力:

ベンチマーク(filterIndex)モードCntスコアエラー単位
LoopUnrollingBenchmark.runBenchmark 0 avgt 400 44.202±0.224 ns / op
LoopUnrollingBenchmark.runBenchmark 1 avgt 400 38.347±0.063 ns / op

(1行目は最初のスニペットに対応し、2行目は2行目に対応します。

完全なベンチマークコード:

public class LoopUnrollingBenchmark {

    @State(Scope.Benchmark)
    public static class BenchmarkData {
        public Filter[] filters;
        @Param({"0", "1"})
        public int filterIndex;
        public int num;

        @Setup(Level.Invocation) //similar ratio with Level.TRIAL
        public void setUp() {
            filters = new Filter[]{new FilterChain1(), new FilterChain2()};
            num = new Random().nextInt();
        }
    }

    @Benchmark
    @Fork(warmups = 5, value = 20)
    @BenchmarkMode(Mode.AverageTime)
    @OutputTimeUnit(TimeUnit.NANOSECONDS)
    public int runBenchmark(BenchmarkData data) {
        Filter filter = data.filters[data.filterIndex];
        int sum = 0;
        int num = data.num;
        if (filter.isOK(num)) {
            ++sum;
        }
        if (filter.isOK(num + 1)) {
            ++sum;
        }
        if (filter.isOK(num - 1)) {
            ++sum;
        }
        if (filter.isOK(num * 2)) {
            ++sum;
        }
        if (filter.isOK(num * 3)) {
            ++sum;
        }
        if (filter.isOK(num * 5)) {
            ++sum;
        }
        return sum;
    }


    interface Filter {
        boolean isOK(int i);
    }

    static class Filter1 implements Filter {
        @Override
        public boolean isOK(int i) {
            return i % 3 == 1;
        }
    }

    static class Filter2 implements Filter {
        @Override
        public boolean isOK(int i) {
            return i % 7 == 3;
        }
    }

    static class FilterChain1 implements Filter {
        final Filter[] filters = createLeafFilters();

        @Override
        public boolean isOK(int i) {
            for (int j = 0; j < filters.length; ++j) {
                if (!filters[j].isOK(i)) {
                    return false;
                }
            }
            return true;
        }
    }

    static class FilterChain2 implements Filter {
        final Filter[] filters = createLeafFilters();

        @Override
        public boolean isOK(int i) {
            return filters[0].isOK(i) && filters[1].isOK(i);
        }
    }

    private static Filter[] createLeafFilters() {
        Filter[] filters = new Filter[2];
        filters[0] = new Filter1();
        filters[1] = new Filter2();
        return filters;
    }

    public static void main(String[] args) throws Exception {
        org.openjdk.jmh.Main.main(args);
    }
}

1
コンパイラは、配列の長さが2であることを保証できません。ただし、展開できたとしても、展開されるかどうかはわかりません。
marstran

1
@Setup(Level.Invocation):わかりません(javadocを参照)。
GPI、

3
配列が常に長さ2であるという保証はどこにもないため、2つのメソッドは同じことを行っていません。JITはどのようにして最初のものを2番目のものに変更することを許可できますか?
Andreas

@Andreas私はあなたが質問に答えることをお勧めしますが、JITがこのケースでアンロールできない理由を詳しく説明します
アレクサンダー

1
@Alexander JIT 、フィールドがであるため、作成後に配列の長さを変更できないことを確認できますがfinal、JITは、クラスのすべてのインスタンスが長さ2の配列を取得することを確認しません。これを確認するには、createLeafFilters()メソッドは、配列が常に2になることを知るのに十分なほど深くコードを分析します。JITオプティマイザーがそれをコードに深く掘り下げると信じているのはなぜですか?
Andreas

回答:


10

TL; DRここでのパフォーマンスの違いの主な理由は、ループのアンロールとは関係ありません。むしろ、型の推測インラインキャッシュです。

展開戦略

実際、HotSpotの用語では、そのようなループはカウントされたものとして扱われ、特定のケースでは、JVM それらのループ展開できます。あなたの場合ではありませんが。

HotSpotには2つのループ展開戦略があります。1)最大限に展開する、つまりループを完全に削除する。または2)いくつかの連続する反復を一緒に接着します。

正確な反復回数がわかっている場合にのみ、最大のアンロールを実行できます。

  if (!cl->has_exact_trip_count()) {
    // Trip count is not exact.
    return false;
  }

ただし、あなたのケースでは、関数は最初の繰り返しの後、早く戻るかもしれません。

部分的な展開が適用される可能性がありますが、次の条件は展開を中断します

  // Don't unroll if the next round of unrolling would push us
  // over the expected trip count of the loop.  One is subtracted
  // from the expected trip count because the pre-loop normally
  // executes 1 iteration.
  if (UnrollLimitForProfileCheck > 0 &&
      cl->profile_trip_cnt() != COUNT_UNKNOWN &&
      future_unroll_ct        > UnrollLimitForProfileCheck &&
      (float)future_unroll_ct > cl->profile_trip_cnt() - 1.0) {
    return false;
  }

あなたの場合、予想されるトリップカウントは2未満であるため、HotSpotは2回の反復でもアンロールする価値がないと想定します。とにかく、最初の反復はループ前に抽出される(ループピーリング最適化)ので、アンロールは実際にはあまり役に立ちません。

タイプの推測

アンロールバージョンには、2つの異なるinvokeinterfaceバイトコードがあります。これらのサイトには2つの異なるタイプのプロファイルがあります。最初のレシーバーは常にFilter1で、2番目のレシーバーは常にFilter2です。したがって、基本的に2つのモノモーフィックな呼び出しサイトがあり、HotSpotは両方の呼び出しを完全にインライン化できます。この場合、「インラインキャッシュ」と呼ばれ、ヒット率は100%です。

ループでは、invokeinterfaceバイトコードは1つだけで、タイププロファイルは1つだけ収集されます。HotSpot JVMは、それfilters[j].isOK()Filter1レシーバーで86%回、レシーバーで14%回呼び出されることを認識していFilter2ます。これはバイモーフィックな呼び出しになります。幸いなことに、HotSpotは投機的にバイモルフィックコールもインライン化できます。両方のターゲットを条件付きブランチでインライン化します。ただし、この場合、ヒット率は最大で86%になり、パフォーマンスはアーキテクチャレベルで対応する誤って予測されたブランチの影響を受けます。

3つ以上の異なるフィルターを使用すると、状況はさらに悪化します。この場合isOK()、HotSpotがインライン化できないメガモーフィックな呼び出しになります。したがって、コンパイルされたコードには、パフォーマンスに大きな影響を与える真のインターフェイス呼び出しが含まれます。

投機的インライン化の詳細については、「The Black Magic of(Java)Method Dispatch」を参照してください

結論

仮想/インターフェース呼び出しをインライン化するために、HotSpot JVMは呼び出しバイトコードごとにタイププロファイルを収集します。ループ内に仮想呼び出しがある場合、ループが展開されているかどうかに関係なく、呼び出しのタイププロファイルは1つだけです。

仮想呼び出しの最適化を最大限に活用するには、主にタイププロファイルを分割する目的で、ループを手動で分割する必要があります。HotSpotはこれまでのところこれを自動的に行うことはできません。


すばらしい答えをありがとう。完全を期すために:特定のインスタンスのコードを生成する可能性のあるJITCテクニックを知っていますか?
アレクサンダー

@Alexander HotSpotは、特定のインスタンスのコードを最適化しません。バイトコードごとのカウンター、タイププロファイル、ブランチターゲットの確率などを含むランタイム統計を使用します。特定のケースに合わせてコードを最適化する場合は、手動または動的なバイトコード生成を使用して、コード用に別のクラスを作成します。
アパンジン

13

提示されたループは、ループの「カウントされない」カテゴリに該当する可能性があります。ループは、コンパイル時でも実行時にも反復回数を決定できないループです。配列サイズに関する@Andreas引数のためだけでなく、ランダムに条件付きbreak(この投稿を書いたときにベンチマークで使用されていた)のためにもです。

最先端のコンパイラは積極的にそれらを最適化しません。非カウントループのアンロールには、ループの終了条件の複製も含まれることが多いため、その後のコンパイラの最適化でアンロールされたコードを最適化できる場合にのみランタイムパフォーマンスが向上するためです。彼らがそのようなものを展開する方法を提案する場所の詳細については、この2017年のペーパーを参照してください。

このことから、ループの「手動での展開」を行ったという仮定は成り立たないことがわかります。条件付きブレークを使用して配列の反復を&&連鎖ブール式に変換する基本的なループ展開技法を検討しています。私はこれをかなり特殊なケースだと思いますが、ホットスポットオプティマイザが複雑なリファクタリングをその場で実行していることに驚いています。ここで彼らはそれが実際に何をするかもしれないかについて議論しています、おそらくこの参照は興味深いです。

これは、現在の展開のメカニズムをより詳細に反映したものであり、展開されたマシンコードがどのように見えるかには、まだ近いと思われます。

if (! filters[0].isOK(i))
{
   return false;
} 
if(! filters[1].isOK(i))
{
   return false;
}
return true;

あるコードが別のコードよりも高速に実行されるため、ループは展開されませんでした。たとえそうであっても、異なる実装を比較しているという事実により、ランタイムの違いを見ることができます。

より確実にしたい場合は、マシンコード(github)(プレゼンテーションスライド)を含む実際のJit操作のjitwatchアナライザー/ビジュアライザーがあります。すべてのケースに具体的な内容があるため、最終的に確認するものがある場合、JITが一般に何を行うか、または行わないかについての意見よりも、自分の目を信頼します。ここでは、JITに関する限り、特定のケースの一般的なステートメントに到達するのが困難であることと、いくつかの興味深いリンクが提供されていることを懸念しています。

目標は最小ランタイムであるa && b && c ...ため、ループ展開の希望に依存したくない場合は、フォームが最も効率的なフォームであると考えられます。少なくとも、他のどのフォームよりも効率的です。しかし、それを一般的な方法で行うことはできません。java.util.Functionの関数構成では、やはり大きなオーバーヘッドがあります(各関数はクラスであり、各呼び出しはディスパッチが必要な仮想メソッドです)。おそらくそのようなシナリオでは、実行時に言語レベルを覆し、カスタムバイトコードを生成することが理にかなっています。一方、&&ロジックにバイトコードレベルでの分岐も必要であり、if / returnと同等である可能性があります(オーバーヘッドなしで生成することもできません)。


ちょうど小さなadendum:JVMの世界では、カウントループは終わっ「実行」いずれかのループであるint i = ....; i < ...; ++i任意ののループではありません。
ユージーン
弊社のサイトを使用することにより、あなたは弊社のクッキーポリシーおよびプライバシーポリシーを読み、理解したものとみなされます。
Licensed under cc by-sa 3.0 with attribution required.