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
, sẽ 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
Lần chạy nhanh nhất của 2 * i * i
mất nhiều thời gian hơn so với lần chạy chậm nhất của 2 * (i * i)
. Nếu cả hai đều hiệu quả, xác suất xảy ra điều này sẽ ít hơn 1/2 ^ 15 * 100% = 0,00305%.
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
so với 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ào 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 thấy việc hủy đăng ký 16 lần cho trường hợp 2 * (i * i)
:
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à đối với phiên bản 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
Ở đây chúng tôi quan sát nhiều hơn "tràn" và nhiều quyền truy cập vào ngăn xếp [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 * i
vì JIT tạo mã hội 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 do 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 bổ sung và Thẻop, do đó, 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:
1Để có được đầu ra hội được tạo JIT, hãy gỡ lỗi JVM và chạy với -XX:+PrintOptoAssembly
2Phiên bản C được biên dịch với cờ -fwrapv
, cho phép GCC xử lý tràn số nguyên đã ký dưới dạng bao quanh hai phần bù.
Khi phép nhân là 2 * (i * i)
, JVM có thể nhân hệ số nhân với 2
từ 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:
if (n == 0) n = 1
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 nhau2 * (i * i)
Đâ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
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 dấu ngoặc (2 * (i * i)
):
Không có dấu ngoặc (2 * i * i
):
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ó.
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). Đâ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.
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)
2 * i * i
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ảGIỐNGnế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.
Cuối cùng, đây là một dịch ngược javap -c -v <.Java>
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
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
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)
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ì khác ngoài việc đọc trên điện thoại thông minh của tôi):
n += i*i
sau đó n*2
là đầu tiên2 * (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_2
trước khi chúng tôi imul
: đó có thể 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 i
hai lần (i
đã ở đây và không cần tải lại 2*i*i
không thể.
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ú ý.
Quan sát thú vị bằng cách sử dụng Java 11 và tắt tắt vòng lặp với việc tùy chọn VM sau:
-XX:LoopUnrollLimit=0
Vòng lặp với biểu thức 2 * (i * i)
dẫn đến mã gốc nhỏ gọn hơn1:
L0001: add eax,r11d
inc r8d
mov r11d,r8d
imul r11d,r8d
shl r11d,1h
cmp r8d,r10d
jl L0001
so với phiên bản 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
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 - VM tùy chọn được sử dụng: -XX:+UnlockDiagnosticVMOptions -XX:+PrintAssembly -XX:LoopUnrollLimit=0
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
Dành cho 2 * (i * i)
so với:
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 a JMH điểm chuẩn 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 đo điểm chuẩn của bạn (tức là không khởi động, xem Làm cách 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ó.