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 초 걸립니다. 어떻게 오세요?

프로그램의 각 버전을 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 당신이 내 실수를 잡았다 니 다행이다. 내가 실행 한 새로운 벤치 마크에 따르면 2 * i * i속도가 느립니다. Graal로 달리기를 시도합니다.
Jorn Vernee

5
@nullpointer 왜 하나가 다른 것보다 더 빠른지 알아 보려면 해당 방법에 대한 분해 또는 이상적인 그래프를 얻어야합니다. 어셈블러는 시도하고 알아내는 것이 매우 성가 시므로 멋진 그래프를 출력 할 수있는 OpenJDK 디버그 빌드를 얻으려고합니다.
Jorn

4
문제가 작업 순서에 따른 명확성을 높이기 위해 질문의 이름을 " i * i * 2빠릅니다 2 * i * i? "로 바꿀 수 있습니다.
Cœur

답변:


1202

바이트 코드 순서에 약간의 차이가 있습니다.

2 * (i * i):

     iconst_2
     iload0
     iload0
     imul
     imul
     iadd

vs 2 * i * i:

     iconst_2
     iload0
     imul
     iload0
     imul
     iadd

첫눈에 차이가 없어야합니다. 하나의 슬롯을 덜 사용하기 때문에 두 번째 버전이 더 최적이라면

따라서 하위 레벨 (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.


그러나 물론 첫 번째 버전과 두 번째 버전이 좋은 것은 아닙니다. x86-64 CPU는 최소한 SSE2를 지원하므로 루프는 벡터화의 이점을 얻을 수 있습니다.

최적화의 문제입니다. 종종 그렇듯이 너무 공격적으로 풀리고 발로 스스로를 쏠 수 있습니다.

사실, 최신 x86-64 CPU는 명령어를 마이크로 옵스 (µops)로 세분화하고 레지스터 이름 변경, µop 캐시 및 루프 버퍼와 같은 기능을 통해 루프 최적화는 단순한 성능 풀림보다 훨씬 세밀한 작업을 수행합니다. Agner Fog의 최적화 가이드에 따르면 :

평균 명령어 길이가 4 바이트를 초과하면 µop 캐시로 인한 성능 향상이 상당 할 수 있습니다. µ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, #8해야 leal ebx, [r9 + 8]하나 UOP 복사하여 추가로. 또는 leal ebx, [r9 + r9 + 16]할 수 ebx = 2*(r9+8)있습니다. 그렇습니다. 유출 지점까지 풀리는 것은 바보이며 정수 정체성과 연관 정수 수학을 활용하지 않는 순진한 뇌사 코드 젠도 마찬가지입니다.
Peter Cordes

7
순차적 감소를위한 벡터화는 C2 ( bugs.openjdk.java.net/browse/JDK-8078563 ) 에서 비활성화 되었지만 이제는 다시 활성화하는 것으로 간주됩니다 ( bugs.openjdk.java.net/browse/JDK-8188313 ).
pron

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
optimizeVersion이라고 생각합니다.n *= 2000000000;
StefansArya

4
@ StefansArya-No. 한계가 4 인 경우를 고려하여 계산하려고합니다 2*1*1 + 2*2*2 + 2*3*3. 1*1 + 2*2 + 3*32 를 계산 하고 곱하는 것은 정확하지만 8을 곱하는 것은 맞지 않습니다.
Martin Bonner는 Monica

5
수학 방정식은 이렇습니다 2(1²) + 2(2²) + 2(3²) = 2(1² + 2² + 3²). 그것은 매우 간단했고 루프 증가 때문에 그것을 잊어 버렸습니다.
StefansArya

5
디버그 jvm을 사용하여 어셈블리를 인쇄하면 올바른 것으로 나타나지 않습니다. 루프에서 sall ..., # 1이 2를 곱한 것을 볼 수 있습니다. 흥미롭게도, 느린 버전은 루프에서 곱하기가 아닌 것으로 보입니다.
Daniel Berlin

2
JVM이 왜 2를 2 * (i * i)제외 시킬 수 (2 * i) * i있습니까? 나는 그것들이 동등하다고 생각할 것입니다 (그것은 나의 나쁜 가정 일 수도 있습니다). 그렇다면 JVM이 최적화하기 전에 표현식을 정규화 할 수 없습니까?
RedSpikeyThing

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 구문을 푸시
  • 스택에 로컬 푸시
  • 스택 상단에 곱하기
  • 스택에 로컬 푸시
  • 스택 상단에 곱하기

스택에 모두 넣은 다음 다시 작업하는 것이 스택에 놓고 스택에서 작동하는 것보다 빠릅니다.


그러나 푸시-푸쉬-곱셈-곱셈이 푸쉬-곱셈-푸쉬 곱셈보다 빠른 이유는 무엇입니까?
m0skit0

35

Kasperd 는 허용 된 답변에 대한 의견을 물었습니다.

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 버전이 풀리지 않는다는 것입니다. 실제 머신 코드 루프 당 하나의 루프 라운드 만 실행합니다. JVM 버전에는 하나의 물리적 루프에 16 라운드의 루프가 있지만 (rustyx 답변을 기반으로하지만 어셈블리를 재 해석하지 않았습니다). 루프 바디가 실제로 16 배 더 길기 때문에 더 많은 레지스터가 사용되는 이유 중 하나입니다.


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, 438ms
  • 결과 : 119860736, 433ms
  • 결과 : 119860736, 437ms
  • 결과 : 119860736, 435ms
  • 결과 : 119860736, 436ms
  • 결과 : 119860736, 435ms
  • 결과 : 119860736, 435ms
  • 결과 : 119860736, 439ms
  • 결과 : 119860736, 436ms
  • 결과 : 119860736, 437ms

2 * 나는 * 나는

  • 결과 : 119860736, 417ms
  • 결과 : 119860736, 417ms
  • 결과 : 119860736, 417ms
  • 결과 : 119860736, 418ms
  • 결과 : 119860736, 418ms
  • 결과 : 119860736, 417ms
  • 결과 : 119860736, 418ms
  • 결과 : 119860736, 416ms
  • 결과 : 119860736, 417ms
  • 결과 : 119860736, 418ms

1
이것은 질문에 대한 답변이 아니지만 가치를 더합니다. 즉, 게시물에 중요한 것이 있으면 외부 리소스에 연결하지 말고 게시물에 게시 하십시오. 연결이 끊어졌습니다.
Jared Smith

1
@JaredSmith 피드백에 감사드립니다. 언급 한 링크가 "결과"링크 인 경우 해당 이미지는 오프 사이트 소스가 아닙니다. 자체 패널을 통해 스택 오버플로에 업로드했습니다.
Ünsal Ersö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

두 루프가 동일한 프로그램에 있거나 각각 별도의 실행에서 실행되는 별도의 .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

vs.

     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가 아닙니다.
Naman

@ nullpointer-동의합니다. 가능하다면 삭제를 취소하기 위해 투표 할 것입니다. 나는 또한 "중요한"에 대한 양적 정의를 제공하기 위해
스테판

그것은 잘못된 것을 측정 한 이후에 스스로 삭제 된 것입니다 – 위의 질문에 대한 저자의 의견을보십시오
Krease

2
디버그 jre를 얻고로 실행하십시오 -XX:+PrintOptoAssembly. 또는 vtune 등을 사용하십시오.
rustyx

1
@ rustyx-문제가 JIT 구현 인 경우 ... 완전히 다른 JRE의 "디버그 버전 가져 오기"가 반드시 도움이되는 것은 아닙니다. 그럼에도 불구하고 JRE에서 JIT 분해와 함께 위에서 찾은 것과 마찬가지로 OP의 JRE 및 광산에서의 동작을 설명합니다. 또한 다른 JRE가 왜 "다른"행동을하는지 설명합니다. +1 : 훌륭한 형사 연구에 감사드립니다!
paulsm4

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 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
와우, 그것은 약간의 뇌사자입니다. 계산 i 하기 위해 복사 하기 전에 증분하는 대신 2*i추가 add r11d,2명령 이 필요합니다 . (또한 1 add same,same개가 아닌 들여다 보는 구멍 shl이 없습니다. (더 많은 포트에서 실행을 추가하십시오.) 또한 미친 스케쥴 예약 이유로 순서대로 작업을 수행하려는 경우 x*2 + 2( lea r11d, [r8*2 + 2])에 대한 LEA 들여다 보는 구멍 이 없습니다. LEA에서 빠진 풀린 버전은 여기 두 루프와 마찬가지로 많은 비용이 들었습니다 .
Peter Cordes

2
lea eax, [rax + r11 * 2]JIT 컴파일러가 장기 실행 루프에서 해당 최적화를 찾을 시간이 있으면 두 명령 (두 루프 모두)을 대체합니다. 괜찮은 사전 컴파일러는 그것을 찾을 것입니다. (스케일 인덱스 LEA의주기가 2주기이므로 AMD에 대해서만 튜닝하지 않는 한 그럴만한 가치가 없습니다.)
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) 두 번째입니다.

JVM은 Runemoro의 답변을 기반으로 인간과 동일한 방식으로 분명히 최적화하지 않습니다.

이제 바이트 코드를 읽으십시오. javap -c -v ./target/classes/org/sample/MyBenchmark.class

바이트 코드에 대한 전문가는 아니지만 우리는 iload_2전에 imul차이가 생길 수 있습니다 .JVM은 i두 번 읽을 수 있지만 ( i이미 여기에 있으며 다시로드 할 필요가 없다) 가정 2*i*i할 수 있습니다. 티.


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를 사용한 두 번째 결과).

흥미롭게도 동일한 머신에서 실행하지만 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

추가하는 두 가지 방법은 약간 다른 바이트 코드를 생성합니다.

  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.