Το ακόλουθο πρόγραμμα 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 i" χρειάστηκε περισσότερο χρόνο από την πιο αργή εκτέλεση του "2 (i i)". Αν ήταν και οι δύο το ίδιο αποτελεσματικές, η πιθανότητα να συμβεί αυτό θα ήταν μικρότερη από 1/2^15 100% = 0,00305%.
Κωδικοί byte: https://cs.nyu.edu/courses/fall00/V22.0201-001/jvm2.html Προβολή κωδικών byte: https://github.com/Konloch/bytecode-viewer
Στο δικό μου JDK (Windows 10 64 bit, 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
Λοιπόν, γιατί; Ο κώδικας byte είναι ο εξής:
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)
):
Χωρίς αγκύλες (2 * i * i * i
):
Η φόρτωση όλων στη στοίβα και στη συνέχεια η επιστροφή προς τα κάτω είναι ταχύτερη από την εναλλαγή μεταξύ της τοποθέτησης στη στοίβα και της λειτουργίας σε αυτήν.
Έχω παρόμοια αποτελέσματα:
2 * (i * i): 0.458765943 s, n=119860736
2 * i * i: 0.580255126 s, n=119860736
Πήρα τα SAME αποτελέσματα αν και οι δύο βρόχοι ήταν στο ίδιο πρόγραμμα, ή αν ο καθένας ήταν σε ξεχωριστό αρχείο .java/.class, που εκτελούνταν σε ξεχωριστή εκτέλεση.
Τέλος, εδώ είναι ένα javap -c -v <.java>
decompile του καθενός:
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)
Οι δύο μέθοδοι προσθήκης παράγουν ελαφρώς διαφορετικό κώδικα byte:
17: iconst_2
18: iload 4
20: iload 4
22: imul
23: imul
24: iadd
Για 2 * (i * i)
έναντι 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
Αυτό που παρατηρείτε είναι σωστό, και όχι απλώς μια ανωμαλία του στυλ του benchmarking σας (π.χ. χωρίς προθέρμανση, βλέπε Πώς να γράψω ένα σωστό micro-benchmark σε 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 είναι σε θέση να βελτιστοποιήσει ένα συγκεκριμένο κομμάτι κώδικα, και δεν έχει απαραίτητα κάποιο λογικό λόγο.