web-dev-qa-db-fra.com

Pourquoi une boucle Java de 4 milliards d’itérations ne prend-elle que 2 ms?

J'utilise le code Java suivant sur un ordinateur portable doté d'un processeur Intel Core i7 cadencé à 2,7 GHz. J'avais l'intention de le laisser mesurer le temps qu'il fallait pour terminer une boucle avec 2 ^ 32 itérations, ce qui, selon moi, devait durer environ 1,48 seconde (4/2,7 = 1,48).

Mais en réalité, cela ne prend que 2 millisecondes, au lieu de 1,48 s. Je me demande si cela résulte d'une optimisation de la JVM en dessous?

public static void main(String[] args)
{
    long start = System.nanoTime();

    for (int i = Integer.MIN_VALUE; i < Integer.MAX_VALUE; i++){
    }
    long finish = System.nanoTime();
    long d = (finish - start) / 1000000;

    System.out.println("Used " + d);
}
113
twimo

Il y a l'une des deux possibilités suivantes:

  1. Le compilateur s'est rendu compte que la boucle était redondante et ne faisait rien, il l'a donc optimisée.

  2. Le compilateur juste-à-temps (JIT) a réalisé que la boucle est redondante et ne fait rien et l'a optimisée.

Les compilateurs modernes sont très intelligents. ils peuvent voir quand le code est inutile. Essayez de mettre une boucle vide dans GodBolt et regardez la sortie, puis activez les optimisations -O2, vous verrez que la sortie est quelque chose du genre

main():
    xor eax, eax
    ret

J'aimerais clarifier quelque chose. Java la plupart des optimisations sont effectuées par le JIT. Dans d'autres langages (comme C/C++), la plupart des optimisations sont effectuées par le premier compilateur.

105
van dench

Il semble que cela ait été optimisé par le compilateur JIT. Quand je l'éteins (-Djava.compiler=NONE), le code est beaucoup plus lent:

$ javac MyClass.Java
$ Java MyClass
Used 4
$ Java -Djava.compiler=NONE MyClass
Used 40409

Je mets le code de l'OP à l'intérieur de class MyClass.

54
Akavall

Je vais simplement préciser ce qui est évident: il s’agit d’une optimisation de la machine virtuelle, la boucle sera tout simplement supprimée. Voici un petit test qui montre quelle énorme différence JIT a été activée/activée uniquement pour C1 Compiler et désactivée du tout.

Avertissement: n'écrivez pas des tests comme celui-ci - ceci est juste pour prouver que la "suppression" de la boucle se produit dans le C2 Compiler:

@Benchmark
@Fork(1)
public void full() {
    long result = 0;
    for (int i = Integer.MIN_VALUE; i < Integer.MAX_VALUE; i++) {
        ++result;
    }
}

@Benchmark
@Fork(1)
public void minusOne() {
    long result = 0;
    for (int i = Integer.MIN_VALUE; i < Integer.MAX_VALUE - 1; i++) {
        ++result;
    }
}

@Benchmark
@Fork(value = 1, jvmArgsAppend = { "-XX:TieredStopAtLevel=1" })
public void withoutC2() {
    long result = 0;
    for (int i = Integer.MIN_VALUE; i < Integer.MAX_VALUE - 1; i++) {
        ++result;
    }
}

@Benchmark
@Fork(value = 1, jvmArgsAppend = { "-Xint" })
public void withoutAll() {
    long result = 0;
    for (int i = Integer.MIN_VALUE; i < Integer.MAX_VALUE - 1; i++) {
        ++result;
    }
}

Les résultats montrent que, selon la partie de la variable JIT activée, la méthode est plus rapide (tellement plus rapide qu’elle donne l’impression de ne rien faire - suppression de la boucle, ce qui semble se produire dans le C2 Compiler - quel est le niveau maximum):

 Benchmark                Mode  Cnt      Score   Error  Units
 Loop.full        avgt    2     ≈ 10⁻⁷          ms/op
 Loop.minusOne    avgt    2     ≈ 10⁻⁶          ms/op
 Loop.withoutAll  avgt    2  51782.751          ms/op
 Loop.withoutC2   avgt    2   1699.137          ms/op 
20
Eugene

Comme déjà indiqué, le compilateur JIT (juste à temps) peut optimiser une boucle vide afin de supprimer les itérations inutiles. Mais comment?

En fait, il existe deux compilateurs JIT: C1 & C2. Tout d'abord, le code est compilé avec le C1. C1 collecte les statistiques et aide la machine virtuelle à découvrir que dans 100% des cas, notre boucle vide ne change rien et est inutile. Dans cette situation, C2 entre en scène. Lorsque le code est appelé très souvent, il peut être optimisé et compilé avec le C2 en utilisant les statistiques collectées.

Par exemple, je testerai le prochain extrait de code (mon JDK est défini sur slowdebug build 9-internal ):

public class Demo {
    private static void run() {
        for (int i = Integer.MIN_VALUE; i < Integer.MAX_VALUE; i++) {
        }
        System.out.println("Done!");
    }
}

Avec les options de ligne de commande suivantes:

-XX:+UnlockDiagnosticVMOptions -XX:CompileCommand=print,*Demo.run

Et il existe différentes versions de ma méthode run, compilées de manière appropriée avec C1 et C2. Pour moi, la variante finale (C2) ressemble à ceci:

...

; B1: # B3 B2 <- BLOCK HEAD IS JUNK  Freq: 1
0x00000000125461b0: mov   dword ptr [rsp+0ffffffffffff7000h], eax
0x00000000125461b7: Push  rbp
0x00000000125461b8: sub   rsp, 40h
0x00000000125461bc: mov   ebp, dword ptr [rdx]
0x00000000125461be: mov   rcx, rdx
0x00000000125461c1: mov   r10, 57fbc220h
0x00000000125461cb: call  indirect r10    ; *iload_1

0x00000000125461ce: cmp   ebp, 7fffffffh  ; 7fffffff => 2147483647
0x00000000125461d4: jnl   125461dbh       ; jump if not less

; B2: # B3 <- B1  Freq: 0.999999
0x00000000125461d6: mov   ebp, 7fffffffh  ; *if_icmpge

; B3: # N44 <- B1 B2  Freq: 1       
0x00000000125461db: mov   edx, 0ffffff5dh
0x0000000012837d60: nop
0x0000000012837d61: nop
0x0000000012837d62: nop
0x0000000012837d63: call  0ae86fa0h

...

C'est un peu brouillon, mais si vous y regardez de près, vous remarquerez peut-être qu'il n'y a pas de longue boucle en cours ici. Il y a 3 blocs: B1, B2 et B3 et les étapes d'exécution peuvent être B1 -> B2 -> B3 ou B1 -> B3. Où Freq: 1 - fréquence estimée normalisée de l'exécution d'un bloc.

12
Oleksandr Pyrohov

Vous mesurez le temps nécessaire pour détecter la boucle ne fait rien, compilez le code dans un thread d'arrière-plan et éliminez le code.

for (int t = 0; t < 5; t++) {
    long start = System.nanoTime();
    for (int i = Integer.MIN_VALUE; i < Integer.MAX_VALUE; i++) {
    }
    long time = System.nanoTime() - start;

    String s = String.format("%d: Took %.6f ms", t, time / 1e6);
    Thread.sleep(50);
    System.out.println(s);
    Thread.sleep(50);
}

Si vous exécutez ceci avec -XX:+PrintCompilation, vous pouvez voir que le code a été compilé en arrière-plan du compilateur de niveau 3 ou C1 et après quelques boucles au niveau 4 de C4.

    129   34 %     3       A::main @ 15 (93 bytes)
    130   35       3       A::main (93 bytes)
    130   36 %     4       A::main @ 15 (93 bytes)
    131   34 %     3       A::main @ -2 (93 bytes)   made not entrant
    131   36 %     4       A::main @ -2 (93 bytes)   made not entrant
0: Took 2.510408 ms
    268   75 %     3       A::main @ 15 (93 bytes)
    271   76 %     4       A::main @ 15 (93 bytes)
    274   75 %     3       A::main @ -2 (93 bytes)   made not entrant
1: Took 5.629456 ms
2: Took 0.000000 ms
3: Took 0.000364 ms
4: Took 0.000365 ms

Si vous changez la boucle pour utiliser un long, elle ne sera pas optimisée.

    for (long i = Integer.MIN_VALUE; i < Integer.MAX_VALUE; i++) {
    }

à la place vous obtenez

0: Took 1579.267321 ms
1: Took 1674.148662 ms
2: Took 1885.692166 ms
3: Took 1709.870567 ms
4: Took 1754.005112 ms
8
Peter Lawrey