114 votes

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

J'exécute le code Java suivant sur un ordinateur portable avec un Intel Core i7 à 2,7 GHz. J'avais l'intention de le laisser mesurer le temps qu'il faut pour terminer une boucle avec 2^32 itérations, ce que je pensais être d'environ 1,48 secondes (4/2,7 = 1,48).

Mais en fait, cela ne prend que 2 millisecondes, au lieu de 1,48 s. Je me demande si c'est le résultat d'une optimisation sous-jacente de la JVM ?

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

69 votes

Eh bien oui. Comme le corps de la boucle n'a pas d'effets secondaires, le compilateur l'élimine allègrement. Examinez le byte-code avec javap -v à voir.

0 votes

@ElliottFrisch pouvez-vous nous en dire un peu plus ?

36 votes

Vous ne verrez pas ça dans le byte-code. javac n'effectue que très peu d'optimisation réelle et en laisse la majeure partie au compilateur JIT.

106voto

van dench Points 1283

Il y a deux possibilités ici :

  1. Le compilateur a réalisé que la boucle était redondante et ne faisait rien, il l'a donc optimisée.

  2. Le JIT (compilateur juste à temps) s'est rendu compte que la boucle est redondante et ne fait rien, il l'a donc optimisée.

Les compilateurs modernes sont très intelligents ; ils peuvent voir quand un code est inutile. Essayez de mettre une boucle vide dans GodBolt et regarder la sortie, puis allumer -O2 vous verrez que la sortie ressemble à quelque chose du genre

main():
    xor eax, eax
    ret

Je voudrais clarifier quelque chose, en Java la plupart des optimisations sont faites par le JIT. Dans certains autres langages (comme C/C++), la plupart des optimisations sont effectuées par le premier compilateur.

0 votes

Le compilateur est-il autorisé à effectuer de telles optimisations ? Je n'en suis pas sûr pour Java, mais les compilateurs .NET devraient généralement éviter cela pour permettre au JIT d'effectuer les meilleures optimisations pour la plate-forme.

1 votes

@IllidanS4 En général, cela dépend de la norme du langage. Si le compilateur peut effectuer des optimisations qui font que le code, interprété par la norme, a le même effet, alors oui. Il y a cependant de nombreuses subtilités à prendre en compte, par exemple, certaines transformations pour les calculs en virgule flottante peuvent entraîner la possibilité d'un dépassement ou d'un sous-dépassement, de sorte que toute optimisation doit être effectuée avec soin.

9 votes

@IllidanS4 comment l'environnement d'exécution devrait-il pouvoir faire une meilleure optimisation ? Il doit au moins analyser le code, ce qui ne peut pas être plus rapide que de supprimer le code lors de la compilation.

56voto

Akavall Points 7357

Il semble qu'il ait été optimisé par le compilateur JIT. Lorsque je le désactive ( -Djava.compiler=NONE ), le code s'exécute beaucoup plus lentement :

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

J'ai mis le code de l'OP à l'intérieur de class MyClass .

2 votes

Bizarre. Quand j'exécute le code dans les deux sens, il est plus rapide sans le drapeau, mais seulement par un facteur de 10, et l'ajout ou la suppression de zéros au nombre d'itérations dans la boucle affecte également le temps d'exécution par des facteurs de dix, avec et sans le drapeau. Ainsi (pour moi), la boucle ne semble pas avoir été entièrement optimisée, mais simplement rendue 10 fois plus rapide, d'une manière ou d'une autre. (Oracle Java 8-151)

0 votes

@tobias_k cela dépend de l'étape du JIT par laquelle la boucle passe je suppose. stackoverflow.com/a/47972226/1059372

21voto

Eugene Points 6271

Je vais juste énoncer l'évidence - que c'est une optimisation de la JVM qui se produit, la boucle sera simplement supprimée du tout. Voici un petit test qui montre ce qu'une énorme différence JIT a lorsqu'il est activé/activé uniquement pour C1 Compiler et désactivé du tout.

Avertissement : n'écrivez pas de tests de ce type - il s'agit juste de prouver que la "suppression" réelle de la boucle a lieu dans le processus d'exécution de l'opération. 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 qu'en fonction de la partie des JIT est activée, la méthode devient plus rapide (tellement plus rapide qu'on a l'impression qu'elle ne fait "rien" - suppression de la boucle, ce qui semble se produire dans la fonction C2 Compiler - qui est le niveau maximal) :

 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

13voto

Oleksandr Points 7545

Comme déjà souligné, JIT (just-in-time) peut optimiser une boucle vide afin de supprimer les itérations inutiles. Mais comment ?

En fait, il existe deux compilateurs JIT : C1 & C2 . D'abord, le code est compilé avec le C1. C1 collecte les statistiques et aide la JVM à 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.

À titre d'exemple, je vais tester l'extrait de code suivant (mon JDK est réglé sur slowdebug build 9-interne ) :

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 y a différentes versions de mon exécuter compilé avec les C1 et C2 de manière appropriée. Pour moi, la variante finale (C2) ressemble à quelque chose comme 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 désordonné, mais si vous regardez de près, vous remarquerez qu'il n'y a pas de longue boucle 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.

8voto

Peter Lawrey Points 229686

Vous mesurez le temps qu'il faut pour détecter que la boucle ne fait rien, compiler le code dans un thread d'arrière-plan et éliminer 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 l'exécutez avec -XX:+PrintCompilation vous pouvez voir que le code a été compilé en arrière-plan au niveau 3 ou compilateur 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 modifiez la boucle pour utiliser un long il n'est pas aussi optimisé.

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

Au lieu de cela, 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

0 votes

C'est étrange... pourquoi un long contre empêcher la même optimisation de se produire ?

0 votes

@RyanAmos l'optimisation n'est appliquée qu'au comptage de la boucle primitive commune si le type int note char et short sont effectivement les mêmes au niveau du code d'octet.

Prograide.com

Prograide est une communauté de développeurs qui cherche à élargir la connaissance de la programmation au-delà de l'anglais.
Pour cela nous avons les plus grands doutes résolus en français et vous pouvez aussi poser vos propres questions ou résoudre celles des autres.

Powered by:

X