Miks on 2 * (i * i) kiirem kui 2 * i * i Javas?

Järgmise Java-programmi käivitamine võtab keskmiselt 0,50 sekundit kuni 0,55 sekundit:

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

Kui ma asendan 2 * (i * i) programmiga 2 * i * i, kulub selle käivitamiseks 0,60 kuni 0,65 sekundit. Kuidas on see võimalik?

Ma jooksutasin mõlemat programmi versiooni 15 korda, vaheldumisi. Siin on tulemused:

 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

Kiireim käivitamine 2 * i * i võttis kauem aega kui kõige aeglasem käivitamine 2 * (i * i). Kui mõlemad oleksid sama tõhusad, oleks selle tõenäosus väiksem kui 1/2^15 * 100% = 0,00305%.

Baidikoodid: https://cs.nyu.edu/courses/fall00/V22.0201-001/jvm2.html Baitikoodid Viewer: https://github.com/Konloch/bytecode-viewer

Minu JDK (Windows 10 64 bit, 1.8.0_65-b17) ma saan reprodutseerida ja selgitada:

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

Väljund:

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

Miks siis? Baitkood on selline:

 private static multiB(int arg0) { // 2 * (i * i)


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

 private static multi(int arg0) { // 2 * i * i


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

Erinevus on: Sulgudes (2 * (i * i)):

  • push const stack
  • push local on stack
  • push local on stack
  • korruta virna tippu
  • korruta virna ülemine osa

Ilma sulgudeta (2 * i * i):

  • push const stack
  • push local on stack
  • korruta virna tippu
  • push local on stack
  • korruta virna ülemine osa

Kõike korstnasse laadimine ja seejärel tagasi töötamine on kiirem kui korstnasse panemise ja korstnas töötamise vahel ümberlülitamine.

Kommentaarid (1)

Sain sarnaseid tulemusi:

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

Ma sain SAME tulemused, kui mõlemad silmused olid samas programmis või mõlemad olid eraldi .java failis/.classis, mida käivitati eraldi.

Lõpuks on siin javap -c -v dekompileerimine mõlemast:

     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)
Kommentaarid (7)

Need kaks lisamismeetodit genereerivad veidi erinevat baitkoodi:

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

Sest 2 * (i * i) vs:

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

For 2 * i * i.

Ja kui kasutada JMH võrdlusnäitajat, nagu see:

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

}

Erinevus on selge:

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

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

See, mida te täheldate, on õige ja mitte lihtsalt teie võrdlusuuringu stiili anomaalia (st ei ole soojendust, vt Kuidas kirjutada õiget mikromarki Java's?).

Käivitan uuesti Graaliga:

# 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

Näete, et tulemused on palju lähemal, mis on mõistlik, kuna Graal on üldiselt parema jõudlusega, kaasaegsem kompilaator.

Nii et see sõltub tegelikult lihtsalt sellest, kui hästi JIT-kompilaator suudab konkreetset koodi optimeerida, ja sellel ei pruugi olla loogilist põhjust.

Kommentaarid (1)