(variable1%variable2 == 0)が効率的でないのはなぜですか?


179

私はJavaが初めてで、昨夜いくつかのコードを実行していましたが、これは本当に私を悩ませました。forループですべてのX出力を表示する単純なプログラムを作成していて、variable % variablevs as variable % 5000またはwhatnot としてモジュラスを使用すると、パフォーマンスが大幅に低下することに気付きました。誰かがなぜこれが原因で何が原因であるかを誰かに説明できますか?だから私はもっと良くなることができます...

ここに「効率的な」コードがあります(ちょっとした構文が間違っていると、コードがインストールされていないコンピューター上にいます)

long startNum = 0;
long stopNum = 1000000000L;

for (long i = startNum; i <= stopNum; i++){
    if (i % 50000 == 0) {
        System.out.println(i);
    }
}

これが「非効率なコード」です

long startNum = 0;
long stopNum = 1000000000L;
long progressCheck = 50000;

for (long i = startNum; i <= stopNum; i++){
    if (i % progressCheck == 0) {
        System.out.println(i);
    }
}

ちなみに、違いを測定するための日付変数があり、それが十分に長くなると、最初の変数は50ミリ秒かかりましたが、他の変数は12秒かそれに似た時間を要しました。あなたのPCが私のものよりも効率的であるかどうかはstopNum、増加または減少する必要があるかもしれprogressCheckません。

私はこの質問をウェブ上で探しましたが、答えを見つけることができません。たぶん私はそれを正しく尋ねていません。

編集:私の質問がそれほど人気が​​あるとは思っていませんでした。すべての回答に感謝します。所要時間の半分ごとにベンチマークを実行しましたが、非効率的なコードにはかなり長い時間がかかりました。彼らがprintlnを使用していることは確かですが、どちらも同じ量を実行しているので、特に矛盾が繰り返し可能であるため、それが大幅に歪むとは思いません。答えについては、私はJavaを初めて使用するので、今のところどちらの答えが最適かを投票者に決定させます。水曜日までに選んでみます。

EDIT2:今夜、別のテストを行います。ここでは、係数の代わりに変数をインクリメントし、それがprogressCheckに達すると、変数を1つ実行し、その変数を0にリセットします。3番目のオプションです。

EDIT3.5:

私はこのコードを使用しました。以下に結果を示します。すばらしい支援をありがとうございます。また、longのshort値を0と比較してみたので、新しいチェックはすべて「65536」回発生し、繰り返しで等しくなります。

public class Main {


    public static void main(String[] args) {

        long startNum = 0;
        long stopNum = 1000000000L;
        long progressCheck = 65536;
        final long finalProgressCheck = 50000;
        long date;

        // using a fixed value
        date = System.currentTimeMillis();
        for (long i = startNum; i <= stopNum; i++) {
            if (i % 65536 == 0) {
                System.out.println(i);
            }
        }
        long final1 = System.currentTimeMillis() - date;
        date = System.currentTimeMillis();
        //using a variable
        for (long i = startNum; i <= stopNum; i++) {
            if (i % progressCheck == 0) {
                System.out.println(i);
            }
        }
        long final2 = System.currentTimeMillis() - date;
        date = System.currentTimeMillis();

        // using a final declared variable
        for (long i = startNum; i <= stopNum; i++) {
            if (i % finalProgressCheck == 0) {
                System.out.println(i);
            }
        }
        long final3 = System.currentTimeMillis() - date;
        date = System.currentTimeMillis();
        // using increments to determine progressCheck
        int increment = 0;
        for (long i = startNum; i <= stopNum; i++) {
            if (increment == 65536) {
                System.out.println(i);
                increment = 0;
            }
            increment++;

        }

        //using a short conversion
        long final4 = System.currentTimeMillis() - date;
        date = System.currentTimeMillis();
        for (long i = startNum; i <= stopNum; i++) {
            if ((short)i == 0) {
                System.out.println(i);
            }
        }
        long final5 = System.currentTimeMillis() - date;

                System.out.println(
                "\nfixed = " + final1 + " ms " + "\nvariable = " + final2 + " ms " + "\nfinal variable = " + final3 + " ms " + "\nincrement = " + final4 + " ms" + "\nShort Conversion = " + final5 + " ms");
    }
}

結果:

  • 固定= 874ミリ秒(通常は約1000ミリ秒ですが、2の累乗であるため高速です)
  • 変数= 8590ミリ秒
  • 最終変数= 1944ミリ秒(50000を使用した場合は最大1000ミリ秒)
  • 増分= 1904ミリ秒
  • 短い変換= 679 ms

除算が不足しているため、Short Conversionは「速い」方法より23%高速でした。これは注目に値します。あなたが何かを256回ごとに(またはそこについて)表示または比較する必要がある場合は、これを実行して使用できます

if ((byte)integer == 0) {'Perform progress check code here'}

1つの最後の興味深い注、65536(かなりの数ではない)で「最終宣言された変数」に係数を使用すると、固定値の半分の速度(遅い)でした。以前はほぼ同じ速度でベンチマークを行っていました。


29
実際に同じ結果を得ました。私のマシンでは、最初のループは約1.5秒で実行され、2番目のループは約9秒で実行されます。変数のfinal前に追加するとprogressCheck、両方が再び同じ速度で実行されます。これにより、コンパイラまたはJITがループprogressCheckが一定であることがわかっている場合に、ループを最適化することができます。
marstran


24
定数による除算は、乗算の逆数による乗算に簡単に変換できます。変数による除算はできません。また、32ビット除算はx86の64ビット除算よりも高速です
phuclv

2
@phuclvノート32ビット除算はここでは問題ではありません。どちらの場合も64ビット剰余演算です
user85421

4
@RobertCotterman変数をfinalとして宣言すると、コンパイラーは定数(eclipse / Java 11)を使用した場合と同じバイトコードを作成します((変数にもう1つのメモリスロットを使用しているにもかかわらず))
user85421

回答:


139

OSR(オンスタック交換)スタブを測定しています

OSRスタブは、メソッドの実行中にインタープリターモードからコンパイル済みコードに実行を転送することを特に目的とした、コンパイル済みメソッドの特別なバージョンです。

OSRスタブは、解釈されたフレームと互換性のあるフレームレイアウトを必要とするため、通常のメソッドほど最適化されていません。私はすでに、次の答えでこれを示した:123

ここでも同様のことが起こります。「非効率的なコード」が長いループを実行している間、メソッドはループ内のスタック上の置換のために特別にコンパイルされます。状態は解釈されたフレームからOSRコンパイルされたメソッドに転送され、この状態にはprogressCheckローカル変数が含まれます。この時点では、JITは変数を定数に置き換えることができないため、強度削減などの特定の最適化を適用できません。

これは特に、JITが整数除算乗算で置き換えないことを意味します。(値がインライン化後のコンパイル時定数である場合、定数の伝播が最適化が有効になっている場合、GCCが整数除算の実装で奇数の乗算を使用する理由を参照してください)。%式の右側の整数リテラルgcc -O0も、OSRスタブ内でもJITerによって最適化されるここと同様に、によって最適化されます。

ただし、同じメソッドを数回実行すると、2回目以降の実行では、完全に最適化された通常の(OSR以外の)コードが実行されます。これは、理論を証明するためのベンチマークですJMHを使用してベンチマーク):

@State(Scope.Benchmark)
public class Div {

    @Benchmark
    public void divConst(Blackhole blackhole) {
        long startNum = 0;
        long stopNum = 100000000L;

        for (long i = startNum; i <= stopNum; i++) {
            if (i % 50000 == 0) {
                blackhole.consume(i);
            }
        }
    }

    @Benchmark
    public void divVar(Blackhole blackhole) {
        long startNum = 0;
        long stopNum = 100000000L;
        long progressCheck = 50000;

        for (long i = startNum; i <= stopNum; i++) {
            if (i % progressCheck == 0) {
                blackhole.consume(i);
            }
        }
    }
}

そして結果:

# Benchmark: bench.Div.divConst

# Run progress: 0,00% complete, ETA 00:00:16
# Fork: 1 of 1
# Warmup Iteration   1: 126,967 ms/op
# Warmup Iteration   2: 105,660 ms/op
# Warmup Iteration   3: 106,205 ms/op
Iteration   1: 105,620 ms/op
Iteration   2: 105,789 ms/op
Iteration   3: 105,915 ms/op
Iteration   4: 105,629 ms/op
Iteration   5: 105,632 ms/op


# Benchmark: bench.Div.divVar

# Run progress: 50,00% complete, ETA 00:00:09
# Fork: 1 of 1
# Warmup Iteration   1: 844,708 ms/op          <-- much slower!
# Warmup Iteration   2: 105,893 ms/op          <-- as fast as divConst
# Warmup Iteration   3: 105,601 ms/op
Iteration   1: 105,570 ms/op
Iteration   2: 105,475 ms/op
Iteration   3: 105,702 ms/op
Iteration   4: 105,535 ms/op
Iteration   5: 105,766 ms/op

の最初の反復はdivVar、OSRスタブが効率的にコンパイルされていないため、実際にははるかに遅くなります。しかし、メソッドが最初から再実行されるとすぐに、使用可能なすべてのコンパイラー最適化を活用する新しい制約のないバージョンが実行されます。


5
私はこれに投票するのをためらいます。一方で、「ベンチマークをめちゃくちゃにして、JITについて何かを読んでください」という複雑な言い方のように聞こえます。一方、OSRがここでの主な関連ポイントであると確信しているように思われるのはなぜでしょうか。私が必要とするが、(マイクロ)ベンチマークやって、意味System.out.printlnほぼます必ずしもガベージ結果を生成し、両方のバージョンが同じように高速であるという事実は、中OSRで何もする必要はありません特定の ...私の知る限りを、
Marco13

2
(私は好奇心があり、これを理解したいと思います。コメントが邪魔にならないことを願っています。後でそれらを削除するかもしれませんが:)リンク1は少し怪しいです-空のループも完全に最適化できます。2つ目は、その2つに似ています。あなたはOSRに違いを属性理由しかし、再び、それははっきりしていない、特に。私はただ言います:ある時点で、メソッドはJITされ、より速くなります。私の理解では、OSRは、最終的に最適化されたコードの使用を(ほぼ)〜「次の最適化パスまで延期」するだけです。(続き...)
Marco13

1
(続き:)ホットスポットログを具体的に分析しているのでない限り、違いがJITされたコードとJITされていないコードの比較によるものか、JITされたコードとOSR-stub-codeの比較によるものかはわかりません。そして、あなたは確かに確かに問題は実際のコードまたは完全なJMHベンチマークが含まれていないときと言うことはできません。違いはOSRサウンドが原因であると主張するのは、私にとっては、JITが一般的に原因であると言うのと比べて、不適切で具体的(そして「正当化されていない」)です。(違反はありません-私はただ不思議に思っています...)
Marco13 '29

4
@ Marco13には単純なヒューリスティックがあります。JITのアクティビティ%がないと、オプティマイザが実際に作業を行った場合にのみ最適化された実行が可能になるため、各操作の重みは同じになります。したがって、1つのループバリアントが他のループバリアントよりも大幅に高速であるという事実は、オプティマイザの存在を証明し、さらに、ループの1つを他と同じ程度に(同じメソッド内で)最適化できなかったことを証明します。この答えは、両方のループを同程度に最適化する機能を証明しているので、最適化を妨げるものが存在しているに違いありません。そして、すべてのケースの99.9%でOSRです
Holger

4
@ Marco13これは、HotSpotランタイムの知識と、以前に同様の問題を分析した経験に基づく「教育的推測」でした。そのような長いループは、特に単純な手作りのベンチマークでは、OSR以外の方法ではほとんどコンパイルできません。ここで、OPが完全なコードを投稿したとき、でコードを実行することによってのみ、推論を再度確認できます-XX:+PrintCompilation -XX:+TraceNMethodInstalls
アパンギン

42

@phuclv コメントのフォローアップで、JIT 1によって生成されたコードを確認しました。結果は次のとおりです。

variable % 5000(定数による除算)。

mov     rax,29f16b11c6d1e109h
imul    rbx
mov     r10,rbx
sar     r10,3fh
sar     rdx,0dh
sub     rdx,r10
imul    r10,rdx,0c350h    ; <-- imul
mov     r11,rbx
sub     r11,r10
test    r11,r11
jne     1d707ad14a0h

以下のためにvariable % variable

mov     rax,r14
mov     rdx,8000000000000000h
cmp     rax,rdx
jne     22ccce218edh
xor     edx,edx
cmp     rbx,0ffffffffffffffffh
je      22ccce218f2h
cqo
idiv    rax,rbx           ; <-- idiv
test    rdx,rdx
jne     22ccce218c0h

除算は常に乗算よりも時間がかかるため、最後のコードスニペットはパフォーマンスが低下します。

Javaバージョン:

java version "11" 2018-09-25
Java(TM) SE Runtime Environment 18.9 (build 11+28)
Java HotSpot(TM) 64-Bit Server VM 18.9 (build 11+28, mixed mode)

1-使用されるVMオプション: -XX:+UnlockDiagnosticVMOptions -XX:CompileCommand=print,src/java/Main.main


14
x86_64の「遅い」に1桁の大きさを与えると、imul3サイクル、idiv30〜90サイクルになります。したがって、整数の除算は整数の乗算よりも10倍から30倍遅くなります。
Matthieu M.

2
興味はあるがアセンブラは話さない読者にとって、それが何を意味するのか説明してもらえますか?
Nico Haase

7
@NicoHaase 2つのコメント行が唯一の重要な行です。最初のセクションでは、コードは整数の乗算を実行していますが、2番目のセクションでは、コードは整数の除算を実行しています。乗算と除算を手作業で行うことを考えた場合、乗算するときは通常、一連の小さな乗算と1つの大きな加算を行いますが、除算は小さな除算、小さな乗算、減算、および繰り返しです。あなたは本質的にたくさんの乗算をしているので、除算は遅いです。
MBraedley

4
@MBraedleyあなたの入力に感謝しますが、そのような説明は回答自体に追加する必要があります。コメントセクションで非表示にしないでください
Nico Haase

6
@MBraedley:要するに、最近のCPUでの乗算は高速です。これは、部分積が独立しているため個別に計算できるため、除算の各ステージが前のステージに依存しているためです。
スーパーキャット

26

他の人が指摘したように、一般的なモジュラス演算では除算を行う必要があります。場合によっては、除算は乗算によって(コンパイラによって)置き換えることができます。しかし、どちらも加算/減算に比べて遅くなる可能性があります。したがって、次のような方法で最高のパフォーマンスを期待できます。

long progressCheck = 50000;

long counter = progressCheck;

for (long i = startNum; i <= stopNum; i++){
    if (--counter == 0) {
        System.out.println(i);
        counter = progressCheck;
    }
}

(マイナーな最適化の試みとして、ここでは事前減少のダウンカウンターを使用します。これは、多くのアーキテクチャでは0、算術演算の直後と比較すると、ALUのフラグが前の演算によって適切に設定されているため、正確に0命令/ CPUサイクルのコストがかかるためです。適切な最適化ただし、コンパイラーは、を記述しても、その最適化を自動的に行いますif (counter++ == 50000) { ... counter = 0; }

ループカウンター(i)または1だけインクリメントされることがわかっているため、実際にはモジュラスを必要としない/必要としないことがよくあります。また、モジュラスが実際に与える残りについては気にしないでください。 1ずつ増加するカウンターが何らかの値に達した場合。

別の「トリック」は、2の累乗の値/制限を使用することprogressCheck = 1024;です。モジュラス2のべき乗はand、ビット単位ですばやく計算できますif ( (i & (1024-1)) == 0 ) {...}。これもかなり高速で、一部のアーキテクチャではcounter上記の明示的なパフォーマンスよりも優れている場合があります。


3
スマートコンパイラは、ここでループを反転させます。または、ソースでそれを行うことができます。if()本体は、外側のループボディとなり、外部ものがif()のために実行され、内側ループ本体なるmin(progressCheck, stopNum-i)反復。つまり、最初counterは0に到達するたびlong next_stop = i + min(progressCheck, stopNum-i);に、for(; i< next_stop; i++) {}ループを設定する必要があります。この場合、内部ループは空であり、うまくいけば完全に最適化されるはずですが、ソースでそれを行うとJITerが簡単になり、ループがi + = 50kに減ります。
Peter Cordes

2
しかし、はい。一般に、ダウンカウンターは、fizzbuzz / progresscheckタイプのものに適した効率的な手法です。
Peter Cordes

私は私の質問に追加して、インクリメントを行いました。これ--counterは私のインクリメントバージョンと同じくらい高速ですが、コードは少なくなります。また、本来の値よりも1だけ低かっcounter--たので、正確な数値を取得する必要があるかどうか知りたいです、それが大きな違いではない
ロバート・コッターマン

@PeterCordes スマートコンパイラは数値を出力するだけで、ループはまったく発生しません。(おそらくほんの少しだけ重要なベンチマークがおそらく10年前にその方法で失敗し始めたと思います。)
ピーター-モニカを復活

2
@RobertCottermanはい、--counter1つずれています。counter--正確progressCheckに反復回数を提供します(またはprogressCheck = 50001;もちろん設定できます)。
JimmyB

4

上記のコードのパフォーマンスにも驚いています。宣言された変数に従ってプログラムを実行するためにコンパイラーが要する時間のすべてです。2番目の(非効率的な)例では:

for (long i = startNum; i <= stopNum; i++) {
    if (i % progressCheck == 0) {
        System.out.println(i)
    }
}

2つの変数間でモジュラス演算を実行しています。ここでは、コンパイラは、の値をチェックしなければならないstopNumprogressCheck、それは可変であり、その値が変化する可能性があるため、これらの変数のためにある特定のメモリブロックに各反復の後に毎回行くことに。

そのため、各反復の後、コンパイラは変数の最新の値をチェックするためにメモリロケーションに行きました。したがって、コンパイル時に、コンパイラーは効率的なバイトコードを作成できませんでした。

最初のコード例では、変数と定数の数値の間でモジュラス演算子を実行しています。これは、実行中に変更されることはなく、コンパイラーはメモリー位置からその数値の値をチェックする必要はありません。そのため、コンパイラは効率的なバイトコードを作成できました。または変数として宣言progressCheckした場合、ランタイム/コンパイル時のコンパイラーは、それが最終的な変数であり、その値が変更されないことをコンパイラーが知っている場合、コンパイラーはコード内のをで置き換えます。finalfinal staticprogressCheck50000

for (long i = startNum; i <= stopNum; i++) {
    if (i % 50000== 0) {
        System.out.println(i)
    }
}

これで、このコードも最初の(効率的な)コード例のようになっていることがわかります。最初のコードのパフォーマンスと、前述のとおり、両方のコードが効率的に機能します。どちらのコード例の実行時間にも大きな違いはありません。


1
1兆回以上の操作を行っていたとしても、大きな違いがあります。そのため、1兆回以上の操作で、「効率的な」コードを実行するために89%の時間を節約できました。ほんの数千回だけやっていて、そのような小さな違いについて話していたとしても、大したことではないでしょう。つまり、1000回以上の操作で7秒の100万分の1を節約できます。
ロバートコッターマン

1
@Bishal Dubey「両方のコードの実行時間に大きな違いはありません。」質問を読みましたか?
Grant Foster、

「そのため、各反復の後、コンパイラは変数の最新の値をチェックするためにメモリロケーションに行きました」-変数が宣言されvolatileていない限り、「コンパイラ」はRAMからその値を何度読みません
JimmyB
弊社のサイトを使用することにより、あなたは弊社のクッキーポリシーおよびプライバシーポリシーを読み、理解したものとみなされます。
Licensed under cc by-sa 3.0 with attribution required.