Tại sao 2 * (i * i) nhanh hơn 2 * i * i trong Java?


855

Chương trình Java sau đây mất trung bình từ 0,5 giây đến 0,55 giây để chạy:

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);
}

Nếu tôi thay thế 2 * (i * i)bằng 2 * i * i, phải mất từ ​​0,60 đến 0,65 giây để chạy. Làm thế nào mà?

Tôi đã chạy mỗi phiên bản của chương trình 15 lần, xen kẽ giữa hai phiên bản. Đây là kết quả:

 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

Chạy nhanh nhất 2 * i * imất nhiều thời gian hơn chạy chậm nhất 2 * (i * i). Nếu chúng có cùng hiệu quả, xác suất xảy ra điều này sẽ ít hơn 1/2^15 * 100% = 0.00305%.


5
Tôi nhận được kết quả tương tự (các số hơi khác nhau, nhưng khoảng cách chắc chắn đáng chú ý và nhất quán, chắc chắn nhiều hơn lỗi lấy mẫu)
Kreas 23/11/18

29
Ngoài ra, vui lòng xem: stackoverflow.com/questions/504103/
Mạnh

3
@Kreas Tốt mà bạn bắt lỗi của tôi. Theo điểm chuẩn mới tôi chạy 2 * i * ichậm hơn. Tôi cũng sẽ thử chạy với Graal.
Jorn Vernee

5
@nullpulum Để tìm hiểu thực tế tại sao cái này nhanh hơn cái kia, chúng ta phải có sự phân tách hoặc đồ thị lý tưởng cho các phương thức đó. Trình biên dịch rất khó chịu khi thử và tìm ra, vì vậy tôi đang cố gắng để có một bản dựng gỡ lỗi OpenJDK có thể tạo ra các biểu đồ đẹp.
Jorn Vernee

4
Bạn có thể đổi tên câu hỏi của mình thành " Tại sao i * i * 2nhanh hơn 2 * i * i? " Để cải thiện sự rõ ràng rằng vấn đề nằm ở thứ tự của các hoạt động.
Cœur

Câu trả lời:


1202

Có một sự khác biệt nhỏ trong thứ tự của mã byte.

2 * (i * i):

     iconst_2
     iload0
     iload0
     imul
     imul
     iadd

vs 2 * i * i:

     iconst_2
     iload0
     imul
     iload0
     imul
     iadd

Thoạt nhìn điều này không nên tạo ra sự khác biệt; nếu bất cứ điều gì phiên bản thứ hai là tối ưu hơn vì nó sử dụng ít hơn một khe cắm.

Vì vậy, chúng ta cần đào sâu hơn về cấp độ thấp hơn (JIT) 1 .

Hãy nhớ rằng JIT có xu hướng hủy bỏ các vòng lặp nhỏ rất tích cực. Thật vậy, chúng tôi quan sát một 16x unrolling cho 2 * (i * i)trường hợp:

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

Chúng tôi thấy rằng có 1 thanh ghi được "đổ" lên ngăn xếp.

Và cho 2 * i * iphiên bản:

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

Ở đây chúng tôi quan sát nhiều "tràn" hơn và nhiều quyền truy cập vào ngăn xếp hơn [RSP + ...], do kết quả trung gian hơn cần được bảo tồn.

Do đó, câu trả lời cho câu hỏi rất đơn giản: 2 * (i * i)nhanh hơn 2 * i * ivì JIT tạo mã lắp ráp tối ưu hơn cho trường hợp đầu tiên.


Nhưng tất nhiên rõ ràng là cả phiên bản thứ nhất và thứ hai đều không tốt; vòng lặp có thể thực sự được hưởng lợi từ vector hóa, vì bất kỳ CPU x86-64 nào cũng có ít nhất hỗ trợ SSE2.

Vì vậy, đó là một vấn đề của trình tối ưu hóa; như thường lệ, nó không kiểm soát quá mạnh mẽ và tự bắn vào chân mình, trong khi bỏ lỡ nhiều cơ hội khác.

Trên thực tế, các CPU x86-64 hiện đại chia nhỏ các hướng dẫn thành micro-op (Phiên bản) và với các tính năng như đổi tên đăng ký, bộ đệm và bộ đệm vòng lặp, tối ưu hóa vòng lặp sẽ tinh tế hơn nhiều so với việc hủy đăng ký đơn giản để có hiệu suất tối ưu. Theo hướng dẫn tối ưu hóa của Agner Fog :

Hiệu suất đạt được nhờ bộ đệm Bộ đệm có thể khá đáng kể nếu độ dài lệnh trung bình lớn hơn 4 byte. Có thể xem xét các phương pháp tối ưu hóa việc sử dụng bộ đệm Bộ đệm:

  • Đảm bảo rằng các vòng lặp quan trọng đủ nhỏ để vừa với bộ đệm cache.
  • Căn chỉnh các mục vòng lặp quan trọng nhất và các mục chức năng theo 32.
  • Tránh vòng lặp không cần thiết không kiểm soát.
  • Tránh các hướng dẫn có thêm thời gian tải
    . . .

Về thời gian tải - ngay cả lần truy cập L1D nhanh nhất cũng tốn 4 chu kỳ , một thanh ghi phụ và bổ sung, vì vậy, thậm chí một vài lần truy cập vào bộ nhớ sẽ ảnh hưởng đến hiệu suất trong các vòng lặp chặt chẽ.

Nhưng quay lại cơ hội vector hóa - để xem nó có thể nhanh đến mức nào, chúng ta có thể biên dịch một ứng dụng C tương tự với GCC , nó hoàn toàn vector hóa nó (AVX2 được hiển thị, SSE2 tương tự) 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

Với thời gian chạy:

  • SSE: 0,24 giây, hoặc nhanh hơn 2 lần.
  • AVX: 0,15 giây, hoặc nhanh hơn 3 lần.
  • AVX2: 0,08 giây, hoặc nhanh hơn 5 lần.

1 Để có được đầu ra lắp ráp được tạo bởi JIT, hãy lấy JVM gỡ lỗi và chạy với-XX:+PrintOptoAssembly

2 Phiên bản C được biên dịch với -fwrapvcờ, cho phép GCC xử lý tràn số nguyên đã ký như là một gói bổ sung hai phần.


11
Vấn đề lớn nhất mà trình tối ưu hóa gặp phải trong ví dụ C là hành vi không xác định được gọi bởi tràn số nguyên đã ký. Điều này, nếu không, có lẽ sẽ dẫn đến việc tải một hằng số đơn giản vì toàn bộ vòng lặp có thể được tính toán tại thời điểm tính toán.
Damon

44
@Damon Tại sao hành vi không xác định sẽ là một vấn đề cho trình tối ưu hóa? Nếu trình tối ưu hóa thấy nó tràn ra khi cố gắng tính kết quả, điều đó chỉ có nghĩa là nó có thể tối ưu hóa nó theo cách nó muốn, bởi vì hành vi không được xác định.

13
@Runemoro: nếu trình tối ưu hóa chứng minh rằng việc gọi hàm chắc chắn sẽ dẫn đến hành vi không xác định, nó có thể chọn giả định rằng hàm sẽ không bao giờ được gọi và không phát ra thân cho nó. Hoặc chỉ phát ra một retlệnh, hoặc phát ra nhãn và không có lệnh ret để thực thi chỉ rơi vào. GCC thực tế đã hành xử điều này đôi khi khi nó gặp UB, mặc dù. Ví dụ: tại sao ret biến mất với tối ưu hóa? . Bạn chắc chắn muốn biên dịch mã được hình thành tốt để chắc chắn mã asm là lành mạnh.
Peter Cordes

8
Đây có lẽ chỉ là một nút cổ chai thông lượng đầu cuối vì mã gen không hiệu quả. Nó thậm chí không sử dụng LEA như một lỗ nhìn trộm cho mov/ add-immediate. ví dụ movl RBX, R9/ addl RBX, #8nên là leal ebx, [r9 + 8], 1 uop để sao chép và thêm. Hoặc leal ebx, [r9 + r9 + 16]để làm ebx = 2*(r9+8). Vì vậy, yeah, không kiểm soát đến mức tràn ra là ngu ngốc, và codegen ngây thơ cũng không tận dụng lợi thế của danh tính số nguyên và toán học số nguyên liên kết.
Peter Cordes

7
Vector hóa để giảm tuần tự đã bị vô hiệu hóa trong C2 ( bug.openjdk.java.net/browse/JDK-8078563 ), nhưng hiện đang được xem xét để kích hoạt lại ( bug.openjdk.java.net/browse/JDK-8188313 ).
pron

131

Khi phép nhân là 2 * (i * i), JVM có thể tính hệ số nhân bằng 2từ vòng lặp, dẫn đến mã tương đương nhưng hiệu quả hơn này:

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

nhưng khi phép nhân là (2 * i) * i, JVM không tối ưu hóa nó vì phép nhân với hằng số không còn đúng trước khi bổ sung.

Dưới đây là một vài lý do tại sao tôi nghĩ rằng đây là trường hợp:

  • Thêm một if (n == 0) n = 1 câu lệnh khi bắt đầu vòng lặp dẫn đến cả hai phiên bản đều hiệu quả, vì việc bao thanh toán nhân không còn đảm bảo rằng kết quả sẽ giống nhau
  • Phiên bản được tối ưu hóa (bằng cách bao gồm phép nhân với 2) chính xác nhanh như 2 * (i * i)phiên bản

Đây là mã kiểm tra mà tôi đã sử dụng để rút ra những kết luận sau:

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;
}

Và đây là kết quả:

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
Tôi nghĩ rằng trên tối ưu hóa, nó nên làn *= 2000000000;
StefansArya

4
@StefansArya - Không. Hãy xem xét trường hợp giới hạn là 4 và chúng tôi đang cố gắng tính toán 2*1*1 + 2*2*2 + 2*3*3. Rõ ràng là tính toán 1*1 + 2*2 + 3*3và nhân với 2 là chính xác, trong khi nhân với 8 sẽ không được.
Martin Bonner hỗ trợ Monica

5
Phương trình toán học là như thế này 2(1²) + 2(2²) + 2(3²) = 2(1² + 2² + 3²). Điều đó rất đơn giản và tôi chỉ quên nó vì sự gia tăng vòng lặp.
StefansArya

5
Nếu bạn in ra lắp ráp bằng cách sử dụng jvm gỡ lỗi, điều này dường như không chính xác. Bạn sẽ thấy một loạt sall ..., # 1, được nhân với 2, trong vòng lặp. Thật thú vị, phiên bản chậm hơn dường như không có bội số trong vòng lặp.
Daniel Berlin

2
Tại sao yếu tố JVM có thể ra khỏi 2 từ 2 * (i * i)mà không phải từ (2 * i) * i? Tôi sẽ nghĩ rằng chúng là tương đương (đó có thể là giả định xấu của tôi). Nếu vậy, JVM sẽ hợp thức hóa biểu thức trước khi tối ưu hóa?
RedSpikeyThing

41

Mã byte: https://cs.nyu.edu/cifts/fall00/V22.0201-001/jvm2.html Mã byte Trình xem: https://github.com/Konloch/bytecode-viewer

Trên JDK của tôi (Windows 10 64 bit, 1.8.0_65-b17) tôi có thể sao chép và giải thích:

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;
}

Đầu ra:

...
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

Vậy tại sao? Mã byte là đây:

 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 {
     }
 }

Sự khác biệt là: Với ngoặc ( 2 * (i * i)):

  • đẩy const stack
  • đẩy cục bộ lên ngăn xếp
  • đẩy cục bộ lên ngăn xếp
  • nhân lên trên cùng của ngăn xếp
  • nhân lên trên cùng của ngăn xếp

Không có dấu ngoặc ( 2 * i * i):

  • đẩy const stack
  • đẩy cục bộ lên ngăn xếp
  • nhân lên trên cùng của ngăn xếp
  • đẩy cục bộ lên ngăn xếp
  • nhân lên trên cùng của ngăn xếp

Tải tất cả vào ngăn xếp và sau đó làm việc trở lại nhanh hơn so với chuyển đổi giữa việc đặt lên ngăn xếp và vận hành trên nó.


Nhưng tại sao đẩy-đẩy-nhân-nhân nhanh hơn so với đẩy-nhân-đẩy-nhân?
m0skit0

35

Kasperd hỏi trong một bình luận về câu trả lời được chấp nhận:

Các ví dụ Java và C sử dụng các tên đăng ký khá khác nhau. Cả hai ví dụ đều sử dụng AMD AMD64?

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

Tôi không có đủ danh tiếng để trả lời điều này trong các bình luận, nhưng đây là cùng một ISA. Thật đáng để chỉ ra rằng phiên bản GCC sử dụng logic số nguyên 32 bit và phiên bản được biên dịch JVM sử dụng logic số nguyên 64 bit bên trong.

R8 đến R15 chỉ là các thanh ghi X86_64 mới . EAX đến EDX là các phần thấp hơn của các thanh ghi mục đích chung RAX đến RDX. Phần quan trọng trong câu trả lời là phiên bản GCC không được kiểm soát. Nó chỉ đơn giản thực hiện một vòng lặp trên mỗi vòng lặp mã máy thực tế. Mặc dù phiên bản JVM có 16 vòng lặp trong một vòng lặp vật lý (dựa trên câu trả lời rustyx, tôi đã không diễn giải lại hội đồng). Đây là một trong những lý do tại sao có nhiều thanh ghi được sử dụng vì thân vòng lặp thực sự dài hơn 16 lần.


2
Gcc quá tệ không nhận thấy nó có thể chìm *2khỏi vòng lặp. Mặc dù trong trường hợp này, nó thậm chí không phải là một chiến thắng để làm điều đó, bởi vì nó được làm miễn phí với LEA. Trên CPU Intel, lea eax, [rax+rcx*2]có độ trễ 1c tương tự như add eax,ecx. Tuy nhiên, trên CPU AMD, bất kỳ chỉ số tỷ lệ nào cũng làm tăng độ trễ LEA lên 2 chu kỳ. Vì vậy, chuỗi phụ thuộc mang theo vòng lặp kéo dài đến 2 chu kỳ, trở thành nút cổ chai trên Ryzen. ( imul ecx,edxthông lượng là 1 trên mỗi đồng hồ trên Ryzen và trên Intel).
Peter Cordes

31

Mặc dù không liên quan trực tiếp đến môi trường của câu hỏi, chỉ vì tò mò, tôi đã thực hiện thử nghiệm tương tự trên .NET Core 2.1, x64, chế độ phát hành.

Đây là kết quả thú vị, xác nhận âm vị tương tự (cách khác xung quanh) xảy ra trên mặt tối của lực lượng. Mã số:

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");
    }
}

Kết quả:

2 * (i * i)

  • kết quả: 119860736, 438 ms
  • kết quả: 119860736, 433 ms
  • kết quả: 119860736, 437 ms
  • kết quả: 119860736, 435 ms
  • kết quả: 119860736, 436 ms
  • kết quả: 119860736, 435 ms
  • kết quả: 119860736, 435 ms
  • kết quả: 119860736, 439 ms
  • kết quả: 119860736, 436 ms
  • kết quả: 119860736, 437 ms

2 * i * i

  • kết quả: 119860736, 417 ms
  • kết quả: 119860736, 417 ms
  • kết quả: 119860736, 417 ms
  • kết quả: 119860736, 418 ms
  • kết quả: 119860736, 418 ms
  • kết quả: 119860736, 417 ms
  • kết quả: 119860736, 418 ms
  • kết quả: 119860736, 416 ms
  • kết quả: 119860736, 417 ms
  • kết quả: 119860736, 418 ms

1
Mặc dù đây không phải là một câu trả lời cho câu hỏi, nhưng nó làm tăng giá trị. Điều đó đang được nói, nếu một cái gì đó quan trọng đối với bài đăng của bạn, xin vui lòng nội tuyến nó trong bài viết thay vì liên kết với một tài nguyên ngoài trang web. Liên kết đi chết.
Jared Smith

1
@JaredSmith Cảm ơn bạn đã phản hồi. Xem liên kết bạn đề cập là liên kết "kết quả", hình ảnh đó không phải là nguồn ngoài trang web. Tôi đã tải nó lên stackoverflow thông qua bảng điều khiển riêng của nó.
Ünsal Ersöz

1
Đó là một liên kết đến imgur, vì vậy, đúng vậy, không quan trọng bằng cách bạn thêm liên kết. Tôi không thấy những gì quá khó khăn khi sao chép một số đầu ra giao diện điều khiển.
Jared Smith

5
Ngoại trừ đây là cách khác
leppie

2
@SamB nó vẫn còn trên miền imgur.com, có nghĩa là nó sẽ chỉ tồn tại trong chừng nào imgur.
p91paul

21

Tôi đã nhận được kết quả tương tự:

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

Tôi đã nhận được kết quả CÙNG nếu cả hai vòng lặp trong cùng một chương trình hoặc mỗi vòng lặp nằm trong một tệp .java /. Class riêng biệt, được thực hiện trên một lần chạy riêng biệt.

Cuối cùng, đây là một javap -c -v <.java>dịch ngược của mỗi:

     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

so với

     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

FYI -

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
Một câu trả lời tốt hơn và có lẽ bạn có thể bỏ phiếu để hủy xóa - stackoverflow.com/a/53452836/1746118 ... Lưu ý bên lề - Dù sao tôi cũng không phải là người xuống cấp.
Naman

@nullpulum - Tôi đồng ý. Tôi chắc chắn sẽ bỏ phiếu để phục hồi, nếu tôi có thể. Tôi cũng muốn "nhân đôi" stefan để đưa ra định nghĩa định lượng về "đáng kể"
paulsm4

Cái đó đã tự xóa vì nó đo sai - hãy xem nhận xét của tác giả về câu hỏi trên
Kreas 23/11/18

2
Nhận một jre gỡ lỗi và chạy với -XX:+PrintOptoAssembly. Hoặc chỉ sử dụng vtune hoặc như nhau.
rustyx

1
@ rustyx - Nếu vấn đề là do triển khai JIT ... thì "lấy phiên bản gỡ lỗi" CỦA JRE HOÀN TOÀN KHÁC BIỆT không nhất thiết phải giúp đỡ. Tuy nhiên: có vẻ như những gì bạn đã tìm thấy ở trên với việc tháo gỡ JIT trên JRE của bạn cũng giải thích hành vi trên JRE của OP và của tôi. Và cũng giải thích tại sao các JRE khác lại cư xử "khác biệt". +1: cảm ơn bạn vì công việc thám tử xuất sắc!
paulsm4

18

Quan sát thú vị khi sử dụng Java 11 và tắt kiểm soát vòng lặp với tùy chọn VM sau:

-XX:LoopUnrollLimit=0

Vòng lặp với 2 * (i * i)biểu thức dẫn đến mã gốc nhỏ gọn hơn 1 :

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

so với 2 * i * iphiên bản:

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

Phiên bản 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)

Kết quả điểm chuẩn:

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

Mã nguồn điểm chuẩn:

@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 - Tùy chọn VM được sử dụng: -XX:+UnlockDiagnosticVMOptions -XX:+PrintAssembly -XX:LoopUnrollLimit=0


2
Wow, đó là một số asm braindead. Thay vì tăng i trước khi sao chép nó để tính toán 2*i, nó sẽ thực hiện sau đó vì vậy nó cần một add r11d,2hướng dẫn thêm . (Thêm vào đó, nó bỏ lỡ add same,samelỗ nhìn trộm thay vì shl1 (thêm chạy trên nhiều cổng hơn). Nó cũng bỏ lỡ một lỗ nhìn trộm LEA cho x*2 + 2( lea r11d, [r8*2 + 2]) nếu nó thực sự muốn làm mọi thứ theo thứ tự đó vì lý do lập lịch chỉ dẫn điên rồ. phiên bản không được kiểm soát mà thiếu LEA đã khiến nó tốn rất nhiều tiền, giống như cả hai vòng ở đây.
Peter Cordes

2
lea eax, [rax + r11 * 2]sẽ thay thế 2 hướng dẫn (trong cả hai vòng) nếu trình biên dịch JIT có thời gian để tìm kiếm tối ưu hóa đó trong các vòng lặp dài. Bất kỳ trình biên dịch trước thời gian tốt sẽ tìm thấy nó. (Trừ khi có thể chỉ điều chỉnh cho AMD, trong đó LEA có chỉ số tỷ lệ có độ trễ 2 chu kỳ nên có thể không có giá trị.)
Peter Cordes

15

Tôi đã thử một JMH bằng cách sử dụng nguyên mẫu mặc định: Tôi cũng đã thêm một phiên bản được tối ưu hóa dựa trên lời giải thích của 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;
  }
}

Kết quả là đây:

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

Trên PC của tôi ( Core i7 860 - nó không làm gì nhiều ngoài việc đọc trên điện thoại thông minh của tôi):

  • n += i*isau đó n*2là đầu tiên
  • 2 * (i * i) là thứ hai.

JVM rõ ràng là không tối ưu hóa theo cách tương tự như con người (dựa trên câu trả lời của Runemoro).

Bây giờ sau đó, đọc mã byte: javap -c -v ./target/classes/org/sample/MyBenchmark.class

Tôi không phải là chuyên gia về mã byte, nhưng chúng tôi iload_2trước chúng tôi imul: đó có lẽ là nơi bạn nhận được sự khác biệt: Tôi có thể giả sử rằng JVM tối ưu hóa việc đọc ihai lần ( iđã ở đây và không cần phải tải lại) trong khi 2*i*icó thể ' t.


4
Mã byte AFAICT khá không liên quan đến hiệu suất và tôi sẽ không cố ước tính những gì nhanh hơn dựa trên nó. Đó chỉ là mã nguồn cho trình biên dịch JIT ... chắc chắn có thể bảo tồn ý nghĩa các dòng mã nguồn sắp xếp lại thay đổi mã kết quả và đó là hiệu quả, nhưng tất cả đều không thể đoán trước.
maaartinus

13

Thêm một phụ lục. Tôi đã repro thử nghiệm bằng cách sử dụng JVM Java 8 mới nhất từ ​​IBM:

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)

Và điều này cho thấy kết quả rất giống nhau:

0.374653912 s
n = 119860736
0.447778698 s
n = 119860736

(kết quả thứ hai sử dụng 2 * i * i).

Thật thú vị, khi chạy trên cùng một máy, nhưng sử dụng 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)

kết quả trung bình chậm hơn một chút:

0.414331815 s
n = 119860736
0.491430656 s
n = 119860736

Câu chuyện dài: ngay cả số phiên bản nhỏ của vấn đề HotSpot ở đây, vì sự khác biệt tinh tế trong triển khai JIT có thể có tác dụng đáng chú ý.


5

Hai phương thức thêm làm tạo mã byte hơi khác nhau:

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

Cho 2 * (i * i)vs:

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

Dành cho 2 * i * i .

Và khi sử dụng điểm chuẩn JMH như thế này:

@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;
    }

}

Sự khác biệt là rõ ràng:

# 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

Những gì bạn quan sát là chính xác và không chỉ là sự bất thường trong phong cách điểm chuẩn của bạn (tức là không có sự khởi động, hãy xem Làm thế nào để tôi viết một điểm chuẩn vi mô chính xác trong Java? )

Chạy lại với 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

Bạn thấy rằng các kết quả gần hơn nhiều, điều này có ý nghĩa, vì Graal là một trình biên dịch tổng thể hoạt động tốt hơn, hiện đại hơn.

Vì vậy, điều này thực sự phụ thuộc vào mức độ mà trình biên dịch JIT có thể tối ưu hóa một đoạn mã cụ thể và không nhất thiết phải có lý do hợp lý với nó.

Khi sử dụng trang web của chúng tôi, bạn xác nhận rằng bạn đã đọc và hiểu Chính sách cookieChính sách bảo mật của chúng tôi.
Licensed under cc by-sa 3.0 with attribution required.