Javaで2 *(i * i)が2 * i * iより速いのはなぜですか?


855

次のJavaプログラムの実行には、平均で0.50秒から0.55秒かかります。

public static void main(String[] args) {
    long startTime = System.nanoTime();
    int n = 0;
    for (int i = 0; i < 1000000000; i++) {
        n += 2 * (i * i);
    }
    System.out.println((double) (System.nanoTime() - startTime) / 1000000000 + " s");
    System.out.println("n = " + n);
}

で置き換える2 * (i * i)2 * i * i、実行に0.60〜0.65秒かかります。どうして?

プログラムの各バージョンを2回交互に15回実行しました。結果は次のとおりです。

 2*(i*i)  |  2*i*i
----------+----------
0.5183738 | 0.6246434
0.5298337 | 0.6049722
0.5308647 | 0.6603363
0.5133458 | 0.6243328
0.5003011 | 0.6541802
0.5366181 | 0.6312638
0.515149  | 0.6241105
0.5237389 | 0.627815
0.5249942 | 0.6114252
0.5641624 | 0.6781033
0.538412  | 0.6393969
0.5466744 | 0.6608845
0.531159  | 0.6201077
0.5048032 | 0.6511559
0.5232789 | 0.6544526

の最速実行は2 * i * i、の最遅実行よりも時間がかかりました2 * (i * i)。それらが同じ効率であった場合、これが発生する確率は未満になり1/2^15 * 100% = 0.00305%ます。


5
同様の結果が得られます(数値はわずかに異なりますが、はっきりと目立ち、一貫したギャップ、間違いなくサンプリングエラーよりも大きい)
Krease


3
@Krease Goodあなたは私の間違いを見つけた。新しいベンチマークによると、私2 * i * iが実行した速度は遅くなります。Graalでも実行してみます。
Jorn Vernee

5
@nullpointerなぜ一方が他方より速いのかを実際に知るには、これらのメソッドの逆アセンブリまたは理想グラフを取得する必要があります。アセンブラーを試して理解するのは非常に面倒なので、素敵なグラフを出力できるOpenJDKデバッグビルドを取得しようとしています。
Jorn Vernee、2018年

4
問題が操作の順序にある​​ことをより明確にするために、質問の名前を「なぜi * i * 2高速なの2 * i * i」に変更できます。
・クール

回答:


1202

バイトコードの順序には若干の違いがあります。

2 * (i * i)

     iconst_2
     iload0
     iload0
     imul
     imul
     iadd

vs 2 * i * i

     iconst_2
     iload0
     imul
     iload0
     imul
     iadd

一見すると、これは違いを生むべきではありません。どちらかと言えば、使用するスロットが1つ少ないため、2番目のバージョンの方が最適です。

したがって、下位レベル(JIT)をさらに掘り下げる必要があります1

JITは小さなループを非常に積極的に展開する傾向があることを覚えておいてください。実際、この2 * (i * i)ケースでは16倍の展開が観察されます。

030   B2: # B2 B3 <- B1 B2  Loop: B2-B2 inner main of N18 Freq: 1e+006
030     addl    R11, RBP    # int
033     movl    RBP, R13    # spill
036     addl    RBP, #14    # int
039     imull   RBP, RBP    # int
03c     movl    R9, R13 # spill
03f     addl    R9, #13 # int
043     imull   R9, R9  # int
047     sall    RBP, #1
049     sall    R9, #1
04c     movl    R8, R13 # spill
04f     addl    R8, #15 # int
053     movl    R10, R8 # spill
056     movdl   XMM1, R8    # spill
05b     imull   R10, R8 # int
05f     movl    R8, R13 # spill
062     addl    R8, #12 # int
066     imull   R8, R8  # int
06a     sall    R10, #1
06d     movl    [rsp + #32], R10    # spill
072     sall    R8, #1
075     movl    RBX, R13    # spill
078     addl    RBX, #11    # int
07b     imull   RBX, RBX    # int
07e     movl    RCX, R13    # spill
081     addl    RCX, #10    # int
084     imull   RCX, RCX    # int
087     sall    RBX, #1
089     sall    RCX, #1
08b     movl    RDX, R13    # spill
08e     addl    RDX, #8 # int
091     imull   RDX, RDX    # int
094     movl    RDI, R13    # spill
097     addl    RDI, #7 # int
09a     imull   RDI, RDI    # int
09d     sall    RDX, #1
09f     sall    RDI, #1
0a1     movl    RAX, R13    # spill
0a4     addl    RAX, #6 # int
0a7     imull   RAX, RAX    # int
0aa     movl    RSI, R13    # spill
0ad     addl    RSI, #4 # int
0b0     imull   RSI, RSI    # int
0b3     sall    RAX, #1
0b5     sall    RSI, #1
0b7     movl    R10, R13    # spill
0ba     addl    R10, #2 # int
0be     imull   R10, R10    # int
0c2     movl    R14, R13    # spill
0c5     incl    R14 # int
0c8     imull   R14, R14    # int
0cc     sall    R10, #1
0cf     sall    R14, #1
0d2     addl    R14, R11    # int
0d5     addl    R14, R10    # int
0d8     movl    R10, R13    # spill
0db     addl    R10, #3 # int
0df     imull   R10, R10    # int
0e3     movl    R11, R13    # spill
0e6     addl    R11, #5 # int
0ea     imull   R11, R11    # int
0ee     sall    R10, #1
0f1     addl    R10, R14    # int
0f4     addl    R10, RSI    # int
0f7     sall    R11, #1
0fa     addl    R11, R10    # int
0fd     addl    R11, RAX    # int
100     addl    R11, RDI    # int
103     addl    R11, RDX    # int
106     movl    R10, R13    # spill
109     addl    R10, #9 # int
10d     imull   R10, R10    # int
111     sall    R10, #1
114     addl    R10, R11    # int
117     addl    R10, RCX    # int
11a     addl    R10, RBX    # int
11d     addl    R10, R8 # int
120     addl    R9, R10 # int
123     addl    RBP, R9 # int
126     addl    RBP, [RSP + #32 (32-bit)]   # int
12a     addl    R13, #16    # int
12e     movl    R11, R13    # spill
131     imull   R11, R13    # int
135     sall    R11, #1
138     cmpl    R13, #999999985
13f     jl     B2   # loop end  P=1.000000 C=6554623.000000

スタックに「こぼれた」レジスタが1つあることがわかります。

そして2 * i * iバージョンについて:

05a   B3: # B2 B4 <- B1 B2  Loop: B3-B2 inner main of N18 Freq: 1e+006
05a     addl    RBX, R11    # int
05d     movl    [rsp + #32], RBX    # spill
061     movl    R11, R8 # spill
064     addl    R11, #15    # int
068     movl    [rsp + #36], R11    # spill
06d     movl    R11, R8 # spill
070     addl    R11, #14    # int
074     movl    R10, R9 # spill
077     addl    R10, #16    # int
07b     movdl   XMM2, R10   # spill
080     movl    RCX, R9 # spill
083     addl    RCX, #14    # int
086     movdl   XMM1, RCX   # spill
08a     movl    R10, R9 # spill
08d     addl    R10, #12    # int
091     movdl   XMM4, R10   # spill
096     movl    RCX, R9 # spill
099     addl    RCX, #10    # int
09c     movdl   XMM6, RCX   # spill
0a0     movl    RBX, R9 # spill
0a3     addl    RBX, #8 # int
0a6     movl    RCX, R9 # spill
0a9     addl    RCX, #6 # int
0ac     movl    RDX, R9 # spill
0af     addl    RDX, #4 # int
0b2     addl    R9, #2  # int
0b6     movl    R10, R14    # spill
0b9     addl    R10, #22    # int
0bd     movdl   XMM3, R10   # spill
0c2     movl    RDI, R14    # spill
0c5     addl    RDI, #20    # int
0c8     movl    RAX, R14    # spill
0cb     addl    RAX, #32    # int
0ce     movl    RSI, R14    # spill
0d1     addl    RSI, #18    # int
0d4     movl    R13, R14    # spill
0d7     addl    R13, #24    # int
0db     movl    R10, R14    # spill
0de     addl    R10, #26    # int
0e2     movl    [rsp + #40], R10    # spill
0e7     movl    RBP, R14    # spill
0ea     addl    RBP, #28    # int
0ed     imull   RBP, R11    # int
0f1     addl    R14, #30    # int
0f5     imull   R14, [RSP + #36 (32-bit)]   # int
0fb     movl    R10, R8 # spill
0fe     addl    R10, #11    # int
102     movdl   R11, XMM3   # spill
107     imull   R11, R10    # int
10b     movl    [rsp + #44], R11    # spill
110     movl    R10, R8 # spill
113     addl    R10, #10    # int
117     imull   RDI, R10    # int
11b     movl    R11, R8 # spill
11e     addl    R11, #8 # int
122     movdl   R10, XMM2   # spill
127     imull   R10, R11    # int
12b     movl    [rsp + #48], R10    # spill
130     movl    R10, R8 # spill
133     addl    R10, #7 # int
137     movdl   R11, XMM1   # spill
13c     imull   R11, R10    # int
140     movl    [rsp + #52], R11    # spill
145     movl    R11, R8 # spill
148     addl    R11, #6 # int
14c     movdl   R10, XMM4   # spill
151     imull   R10, R11    # int
155     movl    [rsp + #56], R10    # spill
15a     movl    R10, R8 # spill
15d     addl    R10, #5 # int
161     movdl   R11, XMM6   # spill
166     imull   R11, R10    # int
16a     movl    [rsp + #60], R11    # spill
16f     movl    R11, R8 # spill
172     addl    R11, #4 # int
176     imull   RBX, R11    # int
17a     movl    R11, R8 # spill
17d     addl    R11, #3 # int
181     imull   RCX, R11    # int
185     movl    R10, R8 # spill
188     addl    R10, #2 # int
18c     imull   RDX, R10    # int
190     movl    R11, R8 # spill
193     incl    R11 # int
196     imull   R9, R11 # int
19a     addl    R9, [RSP + #32 (32-bit)]    # int
19f     addl    R9, RDX # int
1a2     addl    R9, RCX # int
1a5     addl    R9, RBX # int
1a8     addl    R9, [RSP + #60 (32-bit)]    # int
1ad     addl    R9, [RSP + #56 (32-bit)]    # int
1b2     addl    R9, [RSP + #52 (32-bit)]    # int
1b7     addl    R9, [RSP + #48 (32-bit)]    # int
1bc     movl    R10, R8 # spill
1bf     addl    R10, #9 # int
1c3     imull   R10, RSI    # int
1c7     addl    R10, R9 # int
1ca     addl    R10, RDI    # int
1cd     addl    R10, [RSP + #44 (32-bit)]   # int
1d2     movl    R11, R8 # spill
1d5     addl    R11, #12    # int
1d9     imull   R13, R11    # int
1dd     addl    R13, R10    # int
1e0     movl    R10, R8 # spill
1e3     addl    R10, #13    # int
1e7     imull   R10, [RSP + #40 (32-bit)]   # int
1ed     addl    R10, R13    # int
1f0     addl    RBP, R10    # int
1f3     addl    R14, RBP    # int
1f6     movl    R10, R8 # spill
1f9     addl    R10, #16    # int
1fd     cmpl    R10, #999999985
204     jl     B2   # loop end  P=1.000000 C=7419903.000000

ここでは[RSP + ...]、保持する必要のある中間結果が多くなるため、「スピル」とスタックへのアクセスが増加することがわかります。

したがって、質問に対する答えは単純です。JITが最初のケースに対してより最適なアセンブリコードを生成するため2 * (i * i)よりも高速です2 * i * i


しかしもちろん、最初のバージョンも2番目のバージョンもどちらも良いものではないことは明らかです。すべてのx86-64 CPUが少なくともSSE2をサポートしているため、ループは本当にベクトル化の恩恵を受けることができます。

したがって、これはオプティマイザの問題です。よくあることですが、それはあまりにも積極的に展開し、足で自分自身を撃ちますが、その間、他のさまざまな機会を逃しています。

実際、最新のx86-64 CPUは、命令をさらにマイクロオペレーション(µop)に分解し、レジスタの名前変更、µopキャッシュ、ループバッファなどの機能により、ループ最適化は、最適なパフォーマンスを得るための単純な展開よりもはるかに巧妙です。Agner Fogの最適化ガイドによると

µopキャッシュによるパフォーマンスの向上は、平均命令長が4バイトを超える場合、かなり大きくなる可能性があります。µopキャッシュの使用を最適化するには、次の方法が考えられます。

  • 重要なループがµopキャッシュに収まるほど小さいことを確認してください。
  • 最も重要なループエントリと関数エントリを32ずつ揃えます。
  • 不要なループのアンロールを避けてください。
  • ロード時間が余分にかかる命令は避けてください
    。。。

これらのロード時間に関して- 最速のL1Dヒットでも4サイクル、追加のレジスタとµopがかかるため、メモリへのアクセスが数回であっても、タイトなループではパフォーマンスが低下します。

しかし、バックベクトル化の機会に-それがいかに速く見るために、私たちはGCCと同様のCアプリケーションのコンパイルができあからさまにそれをベクトル化し、(AVX2が示されているが、SSE2も同様です)2

  vmovdqa ymm0, YMMWORD PTR .LC0[rip]
  vmovdqa ymm3, YMMWORD PTR .LC1[rip]
  xor eax, eax
  vpxor xmm2, xmm2, xmm2
.L2:
  vpmulld ymm1, ymm0, ymm0
  inc eax
  vpaddd ymm0, ymm0, ymm3
  vpslld ymm1, ymm1, 1
  vpaddd ymm2, ymm2, ymm1
  cmp eax, 125000000      ; 8 calculations per iteration
  jne .L2
  vmovdqa xmm0, xmm2
  vextracti128 xmm2, ymm2, 1
  vpaddd xmm2, xmm0, xmm2
  vpsrldq xmm0, xmm2, 8
  vpaddd xmm0, xmm2, xmm0
  vpsrldq xmm1, xmm0, 4
  vpaddd xmm0, xmm0, xmm1
  vmovd eax, xmm0
  vzeroupper

実行時:

  • SSE:0.24秒、または2倍速い。
  • AVX:0.15秒、または3倍高速。
  • AVX2:0.08秒、または5倍高速。

1 JITで生成されたアセンブリ出力を取得するには、デバッグJVM取得して、-XX:+PrintOptoAssembly

2 Cバージョンは-fwrapvフラグでコンパイルされます。これにより、GCCは符号付き整数オーバーフローを2の補数のラップアラウンドとして扱うことができます。


11
Cの例でオプティマイザが遭遇する最大の問題は、符号付き整数オーバーフローによって引き起こされる未定義の動作です。それ以外の場合は、ループ全体がコンパイル時に計算されるため、おそらく定数を単にロードすることになります。
デイモン

44
@Damon未定義の動作がオプティマイザにとって問題になるのはなぜですか?結果を計算しようとしたときにオーバーフローが発生するとオプティマイザが確認した場合は、動作が定義されていないため、必要に応じて最適化できることを意味します。

13
@Runemoro:オプティマイザが関数の呼び出しが必然的に未定義の動作をもたらすことを証明した場合、関数が呼び出されないことを想定し、そのためのボディを出力しないことを選択できます。または、ret命令のみを発行するか、ラベルを発行してret命令を発行しないことで、実行が失敗するだけです。GCCは実際、これがUBに遭遇したときの振る舞いをします。例:なぜ最適化によってretが消えるのですか?。正しい形式のコードをコンパイルして、asmが正常であることを確認する必要があります。
Peter Cordes

8
非効率的なコード生成のため、それはおそらくフロントエンドのuopスループットのボトルネックにすぎません。LEAをmov/ののぞき穴として使用することすらありませんadd-immediate。例: movl RBX, R9/ addl RBX, #8leal ebx, [r9 + 8]、コピーして追加するには1 uopである必要があります。またはleal ebx, [r9 + r9 + 16]行うにはebx = 2*(r9+8)。ええ、そうです、こぼれるところまで展開するのはばかげていますし、整数IDや連想整数演算を利用しない素朴な頭の悪いcodegenもそうです。
Peter Cordes

7
順次削減のベクトル化はC2(bugs.openjdk.java.net/browse/JDK-8078563)で無効化されていましたが、現在は再有効化が検討されています(bugs.openjdk.java.net/browse/JDK-8188313)。
18年

131

乗算がの2 * (i * i)場合、JVMは2ループから乗算を除算できるため、次の同等のコードでより効率的なコードが得られます。

int n = 0;
for (int i = 0; i < 1000000000; i++) {
    n += i * i;
}
n *= 2;

ただし、乗算がの場合、(2 * i) * i定数による乗算は加算の直前ではなくなるため、JVMはそれを最適化しません。

これが私がそうだと思う理由はいくつかあります:

  • if (n == 0) n = 1ループの最初にステートメントを追加すると、乗算を除外すると結果が同じになることが保証されなくなるため、両方のバージョンが同じくらい効率的になります。
  • 最適化されたバージョン(2による乗算を因数分解することによる)は、2 * (i * i)バージョンとまったく同じ速さです。

これらの結論を導き出すために使用したテストコードは次のとおりです。

public static void main(String[] args) {
    long fastVersion = 0;
    long slowVersion = 0;
    long optimizedVersion = 0;
    long modifiedFastVersion = 0;
    long modifiedSlowVersion = 0;

    for (int i = 0; i < 10; i++) {
        fastVersion += fastVersion();
        slowVersion += slowVersion();
        optimizedVersion += optimizedVersion();
        modifiedFastVersion += modifiedFastVersion();
        modifiedSlowVersion += modifiedSlowVersion();
    }

    System.out.println("Fast version: " + (double) fastVersion / 1000000000 + " s");
    System.out.println("Slow version: " + (double) slowVersion / 1000000000 + " s");
    System.out.println("Optimized version: " + (double) optimizedVersion / 1000000000 + " s");
    System.out.println("Modified fast version: " + (double) modifiedFastVersion / 1000000000 + " s");
    System.out.println("Modified slow version: " + (double) modifiedSlowVersion / 1000000000 + " s");
}

private static long fastVersion() {
    long startTime = System.nanoTime();
    int n = 0;
    for (int i = 0; i < 1000000000; i++) {
        n += 2 * (i * i);
    }
    return System.nanoTime() - startTime;
}

private static long slowVersion() {
    long startTime = System.nanoTime();
    int n = 0;
    for (int i = 0; i < 1000000000; i++) {
        n += 2 * i * i;
    }
    return System.nanoTime() - startTime;
}

private static long optimizedVersion() {
    long startTime = System.nanoTime();
    int n = 0;
    for (int i = 0; i < 1000000000; i++) {
        n += i * i;
    }
    n *= 2;
    return System.nanoTime() - startTime;
}

private static long modifiedFastVersion() {
    long startTime = System.nanoTime();
    int n = 0;
    for (int i = 0; i < 1000000000; i++) {
        if (n == 0) n = 1;
        n += 2 * (i * i);
    }
    return System.nanoTime() - startTime;
}

private static long modifiedSlowVersion() {
    long startTime = System.nanoTime();
    int n = 0;
    for (int i = 0; i < 1000000000; i++) {
        if (n == 0) n = 1;
        n += 2 * i * i;
    }
    return System.nanoTime() - startTime;
}

そしてここに結果があります:

Fast version: 5.7274411 s
Slow version: 7.6190804 s
Optimized version: 5.1348007 s
Modified fast version: 7.1492705 s
Modified slow version: 7.2952668 s

3
最適化されたn *= 2000000000;
バージョン

4
@StefansArya-いいえ。制限が4の場合を考えてください2*1*1 + 2*2*2 + 2*3*3。計算を試みています。1*1 + 2*2 + 3*32の計算と乗算が正しいのは明らかですが、8の乗算はそうで​​はありません。
Martin Bonnerがモニカをサポートする

5
数学の方程式はこんな感じ2(1²) + 2(2²) + 2(3²) = 2(1² + 2² + 3²)でした。それは非常に単純で、ループがインクリメントするので忘れてしまいました。
StefansArya 2018年

5
デバッグjvmを使用してアセンブリを印刷する場合、これは正しくないようです。ループ内に、sall ...、#1の束が2倍になります。おもしろいことに、遅いバージョンではループ内に乗算が存在しないようです。
ダニエルベルリン

2
なぜすることができますから、2アウトJVMの要因2 * (i * i)ではないから(2 * i) * i?私はそれらは同等だと思います(それは私の悪い仮定かもしれません)。もしそうなら、JVMは最適化する前に式を正規化しませんか?
RedSpikeyThing 2018

41

バイトコード:https : //cs.nyu.edu/courses/fall00/V22.0201-001/jvm2.html バイトコードビューア:https : //github.com/Konloch/bytecode-viewer

私のJDK(Windows 10 64ビット、1.8.0_65-b17)では、再現して説明できます。

public static void main(String[] args) {
    int repeat = 10;
    long A = 0;
    long B = 0;
    for (int i = 0; i < repeat; i++) {
        A += test();
        B += testB();
    }

    System.out.println(A / repeat + " ms");
    System.out.println(B / repeat + " ms");
}


private static long test() {
    int n = 0;
    for (int i = 0; i < 1000; i++) {
        n += multi(i);
    }
    long startTime = System.currentTimeMillis();
    for (int i = 0; i < 1000000000; i++) {
        n += multi(i);
    }
    long ms = (System.currentTimeMillis() - startTime);
    System.out.println(ms + " ms A " + n);
    return ms;
}


private static long testB() {
    int n = 0;
    for (int i = 0; i < 1000; i++) {
        n += multiB(i);
    }
    long startTime = System.currentTimeMillis();
    for (int i = 0; i < 1000000000; i++) {
        n += multiB(i);
    }
    long ms = (System.currentTimeMillis() - startTime);
    System.out.println(ms + " ms B " + n);
    return ms;
}

private static int multiB(int i) {
    return 2 * (i * i);
}

private static int multi(int i) {
    return 2 * i * i;
}

出力:

...
405 ms A 785527736
327 ms B 785527736
404 ms A 785527736
329 ms B 785527736
404 ms A 785527736
328 ms B 785527736
404 ms A 785527736
328 ms B 785527736
410 ms
333 ms

なぜ?バイトコードはこれです:

 private static multiB(int arg0) { // 2 * (i * i)
     <localVar:index=0, name=i , desc=I, sig=null, start=L1, end=L2>

     L1 {
         iconst_2
         iload0
         iload0
         imul
         imul
         ireturn
     }
     L2 {
     }
 }

 private static multi(int arg0) { // 2 * i * i
     <localVar:index=0, name=i , desc=I, sig=null, start=L1, end=L2>

     L1 {
         iconst_2
         iload0
         imul
         iload0
         imul
         ireturn
     }
     L2 {
     }
 }

違いは次のとおりです。角かっこ(2 * (i * i))付き:

  • constスタックをプッシュする
  • スタックにローカルをプッシュする
  • スタックにローカルをプッシュする
  • スタックの上部を乗算
  • スタックの上部を乗算

ブラケットなし(2 * i * i):

  • constスタックをプッシュする
  • スタックにローカルをプッシュする
  • スタックの上部を乗算
  • スタックにローカルをプッシュする
  • スタックの上部を乗算

スタックにすべてをロードしてから元に戻すのは、スタックに置くこととスタックで操作することを切り替えるよりも高速です。


しかし、なぜpush-push-multiply-multiplyはpush-multiply-push-multiplyよりも高速なのですか?
m0skit0 2018

35

カスパードは受け入れられた答えのコメントで尋ねました:

JavaとCの例では、まったく異なるレジスタ名を使用しています。どちらの例もAMD64 ISAを使用していますか?

xor edx, edx
xor eax, eax
.L2:
mov ecx, edx
imul ecx, edx
add edx, 1
lea eax, [rax+rcx*2]
cmp edx, 1000000000
jne .L2

コメントでこれに答えるほどの評判はありませんが、これらは同じISAです。GCCバージョンは32ビット整数ロジックを使用し、JVMコンパイルバージョンは64ビット整数ロジックを内部で使用することを指摘する価値があります。

R8からR15は、新しいX86_64 レジスタです。EAX to EDXは、RAX to RDX汎用レジスターの下位部分です。答えの重要な部分は、GCCバージョンが展開されないことです。実際のマシンコードループごとにループを1回実行するだけです。JVMバージョンには、1つの物理ループに16ラウンドのループがあります(rustyxの回答に基づいて、アセンブリを再解釈しませんでした)。これは、ループ本体が実際には16倍長いため、より多くのレジスタが使用されている理由の1つです。


2
悪すぎるgccは*2、ループの外にシンクできることに気づきません。この場合でも、LEAを使って無料でやっているので、それをするのは勝利でもありません。Intel CPUでは、lea eax, [rax+rcx*2]1cレイテンシはと同じadd eax,ecxです。ただし、AMD CPUでは、スケーリングされたインデックスによりLEAレイテンシが2サイクルに増加します。そのため、ループで運ばれる依存関係チェーンは2サイクルになり、Ryzenのボトルネックになります。(imul ecx,edxRyzenおよびIntelでは、スループットはクロックごとに1つです)。
Peter Cordes

31

質問の環境に直接関係はありませんが、好奇心のために、.NET Core 2.1、x64、リリースモードで同じテストを行いました。

これは興味深い結果であり、力の暗い側で同様の現象(逆)が発生していることを確認しています。コード:

static void Main(string[] args)
{
    Stopwatch watch = new Stopwatch();

    Console.WriteLine("2 * (i * i)");

    for (int a = 0; a < 10; a++)
    {
        int n = 0;

        watch.Restart();

        for (int i = 0; i < 1000000000; i++)
        {
            n += 2 * (i * i);
        }

        watch.Stop();

        Console.WriteLine($"result:{n}, {watch.ElapsedMilliseconds} ms");
    }

    Console.WriteLine();
    Console.WriteLine("2 * i * i");

    for (int a = 0; a < 10; a++)
    {
        int n = 0;

        watch.Restart();

        for (int i = 0; i < 1000000000; i++)
        {
            n += 2 * i * i;
        }

        watch.Stop();

        Console.WriteLine($"result:{n}, {watch.ElapsedMilliseconds}ms");
    }
}

結果:

2 *(i * i)

  • 結果:119860736、438ミリ秒
  • 結果:119860736、433 ms
  • 結果:119860736、437 ms
  • 結果:119860736、435 ms
  • 結果:119860736、436 ms
  • 結果:119860736、435 ms
  • 結果:119860736、435 ms
  • 結果:119860736、439ミリ秒
  • 結果:119860736、436 ms
  • 結果:119860736、437 ms

2 * i * i

  • 結果:119860736、417 ms
  • 結果:119860736、417 ms
  • 結果:119860736、417 ms
  • 結果:119860736、418ミリ秒
  • 結果:119860736、418ミリ秒
  • 結果:119860736、417 ms
  • 結果:119860736、418ミリ秒
  • 結果:119860736、416ミリ秒
  • 結果:119860736、417 ms
  • 結果:119860736、418ミリ秒

1
これは質問への回答ではありませんが、付加価値をもたらします。そうは言っても、何かがあなたの投稿に不可欠である場合、オフサイトのリソースにリンクするのではなく、投稿にインラインで書き込んでください。リンクが機能しなくなります。
Jared Smith

1
@JaredSmithフィードバックをありがとう。あなたが言及するリンクが「結果」リンクであることを考えると、その画像はオフサイトのソースではありません。独自のパネルを介してそれをstackoverflowにアップロードしました。
ÜnsalErsöz

1
これはimgurへのリンクなので、はい、そうです。リンクをどのように追加したかは関係ありません。一部のコンソール出力をコピーして貼り付けることの難しさを理解できません。
Jared Smith

5
これが逆の場合を除いて
-leppie

2
@SamBそれはまだimgur.comドメインにあります。つまり、imgurの間だけ存続します。
p91paul

21

私は同様の結果を得ました:

2 * (i * i): 0.458765943 s, n=119860736
2 * i * i: 0.580255126 s, n=119860736

私が得たSAME両方のループが同じプログラムにあった場合の結果を、またはそれぞれが別々の実行時に実行され、別の.javaファイル/ .classファイルにありました。

最後に、ここにあります javap -c -v <.java>それぞれを逆コンパイルします。

     3: ldc           #3                  // String 2 * (i * i):
     5: invokevirtual #4                  // Method java/io/PrintStream.print:(Ljava/lang/String;)V
     8: invokestatic  #5                  // Method java/lang/System.nanoTime:()J
     8: invokestatic  #5                  // Method java/lang/System.nanoTime:()J
    11: lstore_1
    12: iconst_0
    13: istore_3
    14: iconst_0
    15: istore        4
    17: iload         4
    19: ldc           #6                  // int 1000000000
    21: if_icmpge     40
    24: iload_3
    25: iconst_2
    26: iload         4
    28: iload         4
    30: imul
    31: imul
    32: iadd
    33: istore_3
    34: iinc          4, 1
    37: goto          17

     3: ldc           #3                  // String 2 * i * i:
     5: invokevirtual #4                  // Method java/io/PrintStream.print:(Ljava/lang/String;)V
     8: invokestatic  #5                  // Method java/lang/System.nanoTime:()J
    11: lstore_1
    12: iconst_0
    13: istore_3
    14: iconst_0
    15: istore        4
    17: iload         4
    19: ldc           #6                  // int 1000000000
    21: if_icmpge     40
    24: iload_3
    25: iconst_2
    26: iload         4
    28: imul
    29: iload         4
    31: imul
    32: iadd
    33: istore_3
    34: iinc          4, 1
    37: goto          17

ご参考までに -

java -version
java version "1.8.0_121"
Java(TM) SE Runtime Environment (build 1.8.0_121-b13)
Java HotSpot(TM) 64-Bit Server VM (build 25.121-b13, mixed mode)

1
良い答えと多分あなたは元に戻すに投票することができます- stackoverflow.com/a/53452836/1746118 ...サイドノート-私はとにかくdownvoterないです。
ナマン

@nullpointer-同意する。できれば、削除を取り消すことに間違いなく投票します。私はまた、「重要」の定量的な定義を与えるために「二重賛成」のステファンを提供したいと思います
paulsm4

それは間違ったものを測定したので、それは自己削除されました
Krease

2
デバッグjreを取得し、で実行し-XX:+PrintOptoAssemblyます。または、vtuneなどを使用します。
rustyx

1
@ rustyx-問題がJIT実装である場合...完全に異なるJREの「デバッグバージョンの取得」が必ずしも役立つとは限りません。それでも、JREでのJIT逆アセンブルでで見つけたものが、OPのJREと私のものの動作を説明しているようです。また、他のJREが「異なる」動作をする理由についても説明します。+1:優れた探偵の仕事に感謝します!
paulsm4 2018年

18

Java 11を使用し、次のVMオプションを使用してループのアンロールをオフにする興味深い観察:

-XX:LoopUnrollLimit=0

2 * (i * i)式を含むループにより、ネイティブコードがよりコンパクトになります1

L0001: add    eax,r11d
       inc    r8d
       mov    r11d,r8d
       imul   r11d,r8d
       shl    r11d,1h
       cmp    r8d,r10d
       jl     L0001

2 * i * iバージョンとの比較:

L0001: add    eax,r11d
       mov    r11d,r8d
       shl    r11d,1h
       add    r11d,2h
       inc    r8d
       imul   r11d,r8d
       cmp    r8d,r10d
       jl     L0001

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)

ベンチマーク結果:

Benchmark          (size)  Mode  Cnt    Score     Error  Units
LoopTest.fast  1000000000  avgt    5  694,868 ±  36,470  ms/op
LoopTest.slow  1000000000  avgt    5  769,840 ± 135,006  ms/op

ベンチマークソースコード:

@BenchmarkMode(Mode.AverageTime)
@OutputTimeUnit(TimeUnit.MILLISECONDS)
@Warmup(iterations = 5, time = 5, timeUnit = TimeUnit.SECONDS)
@Measurement(iterations = 5, time = 5, timeUnit = TimeUnit.SECONDS)
@State(Scope.Thread)
@Fork(1)
public class LoopTest {

    @Param("1000000000") private int size;

    public static void main(String[] args) throws RunnerException {
        Options opt = new OptionsBuilder()
            .include(LoopTest.class.getSimpleName())
            .jvmArgs("-XX:LoopUnrollLimit=0")
            .build();
        new Runner(opt).run();
    }

    @Benchmark
    public int slow() {
        int n = 0;
        for (int i = 0; i < size; i++)
            n += 2 * i * i;
        return n;
    }

    @Benchmark
    public int fast() {
        int n = 0;
        for (int i = 0; i < size; i++)
            n += 2 * (i * i);
        return n;
    }
}

1-使用されるVMオプション: -XX:+UnlockDiagnosticVMOptions -XX:+PrintAssembly -XX:LoopUnrollLimit=0


2
うわー、それはいくつかの脳死のasmです。コピーして計算するi にインクリメントするの2*iではなく、後で実行するため、追加のadd r11d,2命令が必要です。(さらに、1だけではadd same,sameなくshlピープホールを逃します(より多くのポートで実行を追加します。)いくつかのクレイジーな命令スケジューリングの理由で実際にその順序で実行したい場合は、x*2 + 2lea r11d, [r8*2 + 2])のLEAピープホールも逃します。 LEAで欠落していた展開されたバージョンは、ここの両方のループと同じように、多くのuopsを要していました
Peter Cordes

2
lea eax, [rax + r11 * 2]JITコンパイラーが長時間実行ループでその最適化を探す時間があった場合、(両方のループで)2つの命令を置き換えます。まともな先取りコンパイラはそれを見つけるでしょう。(おそらく、AMDのみをチューニングする場合を除き、スケールインデックスLEAには2サイクルのレイテンシがあるため、おそらくそれに値しないでしょう。)
Peter Cordes

15

デフォルトのアーキタイプを使用してJMHを試しました。Runemoroの説明に基づいて最適化されたバージョンも追加しました。

@State(Scope.Benchmark)
@Warmup(iterations = 2)
@Fork(1)
@Measurement(iterations = 10)
@OutputTimeUnit(TimeUnit.NANOSECONDS)
//@BenchmarkMode({ Mode.All })
@BenchmarkMode(Mode.AverageTime)
public class MyBenchmark {
  @Param({ "100", "1000", "1000000000" })
  private int size;

  @Benchmark
  public int two_square_i() {
    int n = 0;
    for (int i = 0; i < size; i++) {
      n += 2 * (i * i);
    }
    return n;
  }

  @Benchmark
  public int square_i_two() {
    int n = 0;
    for (int i = 0; i < size; i++) {
      n += i * i;
    }
    return 2*n;
  }

  @Benchmark
  public int two_i_() {
    int n = 0;
    for (int i = 0; i < size; i++) {
      n += 2 * i * i;
    }
    return n;
  }
}

結果はここにあります:

Benchmark                           (size)  Mode  Samples          Score   Score error  Units
o.s.MyBenchmark.square_i_two           100  avgt       10         58,062         1,410  ns/op
o.s.MyBenchmark.square_i_two          1000  avgt       10        547,393        12,851  ns/op
o.s.MyBenchmark.square_i_two    1000000000  avgt       10  540343681,267  16795210,324  ns/op
o.s.MyBenchmark.two_i_                 100  avgt       10         87,491         2,004  ns/op
o.s.MyBenchmark.two_i_                1000  avgt       10       1015,388        30,313  ns/op
o.s.MyBenchmark.two_i_          1000000000  avgt       10  967100076,600  24929570,556  ns/op
o.s.MyBenchmark.two_square_i           100  avgt       10         70,715         2,107  ns/op
o.s.MyBenchmark.two_square_i          1000  avgt       10        686,977        24,613  ns/op
o.s.MyBenchmark.two_square_i    1000000000  avgt       10  652736811,450  27015580,488  ns/op

私のPC(Core i7 860-スマートフォンで読む以外は何もしていません):

  • n += i*i その後 n*2最初です
  • 2 * (i * i) 2番目です。

JVMが人間と同じように最適化していないことは明らかです(Runemoroの回答に基づく)。

次に、バイトコードを読み取ります。 javap -c -v ./target/classes/org/sample/MyBenchmark.class

私はバイトコードの専門家ではないですが、私たちはiload_2前に、私たちimul、私が読んでJVMを最適化することを想定することができます:あなたは違いを取得する場所それはおそらくだi二回は(iここではすでにあり、そして再びそれをロードする必要はありません)にしながら、2*i*i「それができますt。


4
AFAICTバイトコードはパフォーマンスにはほとんど関係がないので、それに基づいてより高速なものを推定することはしません。これはJITコンパイラのソースコードにすぎません...ソースコードの行を並べ替えることで意味を保持し、結果のコードと効率を変更できますが、そのすべてはかなり予測不可能です。
maaartinus

13

補遺の詳細。IBMの最新のJava 8 JVMを使用して実験を再現しました。

java version "1.8.0_191"
Java(TM) 2 Runtime Environment, Standard Edition (IBM build 1.8.0_191-b12 26_Oct_2018_18_45 Mac OS X x64(SR5 FP25))
Java HotSpot(TM) 64-Bit Server VM (build 25.191-b12, mixed mode)

そして、これは非常に類似した結果を示しています。

0.374653912 s
n = 119860736
0.447778698 s
n = 119860736

(2 * i * iを使用した2番目の結果)。

興味深いことに、同じマシンで実行しているが、Oracle Javaを使用している場合:

Java version "1.8.0_181"
Java(TM) SE Runtime Environment (build 1.8.0_181-b13)
Java HotSpot(TM) 64-Bit Server VM (build 25.181-b13, mixed mode)

結果は平均して少し遅いです:

0.414331815 s
n = 119860736
0.491430656 s
n = 119860736

要するに、JIT実装内の微妙な違いが顕著な影響を与える可能性があるため、ここではHotSpotのマイナーバージョン番号も重要です。


5

2つの追加方法では、わずかに異なるバイトコードが生成されます。

  17: iconst_2
  18: iload         4
  20: iload         4
  22: imul
  23: imul
  24: iadd

以下のための2 * (i * i)対:

  17: iconst_2
  18: iload         4
  20: imul
  21: iload         4
  23: imul
  24: iadd

のために2 * i * i

そして、次のようなJMHベンチマークを使用する場合:

@Warmup(iterations = 5, batchSize = 1)
@Measurement(iterations = 5, batchSize = 1)
@Fork(1)
@BenchmarkMode(Mode.AverageTime)
@OutputTimeUnit(TimeUnit.MILLISECONDS)
@State(Scope.Benchmark)
public class MyBenchmark {

    @Benchmark
    public int noBrackets() {
        int n = 0;
        for (int i = 0; i < 1000000000; i++) {
            n += 2 * i * i;
        }
        return n;
    }

    @Benchmark
    public int brackets() {
        int n = 0;
        for (int i = 0; i < 1000000000; i++) {
            n += 2 * (i * i);
        }
        return n;
    }

}

違いは明らかです:

# JMH version: 1.21
# VM version: JDK 11, Java HotSpot(TM) 64-Bit Server VM, 11+28
# VM options: <none>

Benchmark                      (n)  Mode  Cnt    Score    Error  Units
MyBenchmark.brackets    1000000000  avgt    5  380.889 ± 58.011  ms/op
MyBenchmark.noBrackets  1000000000  avgt    5  512.464 ± 11.098  ms/op

観察したことは正しく、ベンチマークスタイルの異常だけではありません(ウォームアップなし、Javaで正しいマイクロベンチマークを作成するにどうすればよいですか?を参照してください)。

Graalで再度実行:

# JMH version: 1.21
# VM version: JDK 11, Java HotSpot(TM) 64-Bit Server VM, 11+28
# VM options: -XX:+UnlockExperimentalVMOptions -XX:+EnableJVMCI -XX:+UseJVMCICompiler

Benchmark                      (n)  Mode  Cnt    Score    Error  Units
MyBenchmark.brackets    1000000000  avgt    5  335.100 ± 23.085  ms/op
MyBenchmark.noBrackets  1000000000  avgt    5  331.163 ± 50.670  ms/op

Graalは全体的にパフォーマンスが高く、よりモダンなコンパイラーであるため、結果ははるかに近いことがわかります。

したがって、これは実際にはJITコンパイラが特定のコードを最適化できるかどうかにかかっており、必ずしも論理的な理由があるわけではありません。

弊社のサイトを使用することにより、あなたは弊社のクッキーポリシーおよびプライバシーポリシーを読み、理解したものとみなされます。
Licensed under cc by-sa 3.0 with attribution required.