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

113

J'exécute le code Java suivant sur un ordinateur portable avec un processeur 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 m'attendais à être d'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 est le résultat d'une optimisation 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);
}
Twimo
la source
69
Hé bien oui. Parce que le corps de la boucle n'a pas d'effets secondaires, le compilateur l'élimine assez volontiers. Examinez l'octet-code avec javap -vpour voir.
Elliott Frisch
36
Vous ne le verrez pas dans l'octet-code. javacfait très peu d'optimisation réelle et en laisse la majeure partie au compilateur JIT.
Jorn Vernee
4
«Je me demande si c'est le résultat d'une optimisation JVM en dessous? - Qu'est-ce que tu penses? Que pourrait-il être d'autre si ce n'est une optimisation JVM?
apangin
7
La réponse à cette question est essentiellement contenue dans stackoverflow.com/a/25323548/3182664 . Il contient également l'assembly résultant (code machine) que le JIT génère pour de tels cas, montrant que la boucle est complètement optimisée par le JIT . (La question posée sur stackoverflow.com/q/25326377/3182664 montre que cela pourrait prendre un peu plus de temps si la boucle ne fait pas 4 milliards d'opérations, mais 4 milliards moins un ;-))). Je considérerais presque cette question comme un double de l'autre - des objections?
Marco13
7
Vous supposez que le processeur effectuera une itération par Hz. C'est une hypothèse de grande portée. Les processeurs effectuent aujourd'hui toutes sortes d'optimisations, comme l'a mentionné @Rahul, et à moins que vous n'en sachiez beaucoup plus sur le fonctionnement du Core i7, vous ne pouvez pas le supposer.
Tsahi Asher

Réponses:

106

Il y a une des deux possibilités ici:

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

  2. Le JIT (compilateur juste à temps) s'est rendu compte que la boucle était redondante et ne faisait rien, donc il 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 -O2optimisations, vous verrez que la sortie est quelque chose du genre

main():
    xor eax, eax
    ret

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

van dench
la source
Le compilateur est-il autorisé à faire de telles optimisations? Je ne sais pas avec certitude pour Java, mais les compilateurs .NET devraient généralement éviter cela pour permettre au JIT de faire les meilleures optimisations pour la plate-forme.
IllidanS4 veut que Monica revienne
1
@ IllidanS4 En général, cela dépend de la norme de langue. Si le compilateur peut effectuer des optimisations qui signifient que le code, interprété par le standard, a le même effet, alors oui. Il y a cependant de nombreuses subtilités à prendre en compte, par exemple, il y a des transformations pour les calculs en virgule flottante qui peuvent entraîner la possibilité d'introduire un dépassement excessif / inférieur, donc toute optimisation doit être effectuée avec soin.
user1997744
9
@ IllidanS4 comment l'environnement d'exécution devrait-il être capable de faire une meilleure optimisation? À tout le moins, il doit analyser le code qui ne peut pas être plus rapide que de supprimer le code lors de la compilation.
Gerhardh
2
@Gerhardh Je ne parlais pas de ce cas précis, lorsque le runtime ne peut pas faire un meilleur travail pour supprimer les parties redondantes du code, mais il peut bien sûr y avoir des cas où cette raison est correcte. Et comme il peut y avoir d'autres compilateurs pour JRE à partir d'autres langages, le runtime devrait également faire ces optimisations, donc il n'y a potentiellement aucune raison pour qu'ils soient effectués à la fois par le runtime et le compilateur.
IllidanS4 veut que Monica revienne
6
@ IllidanS4, toute optimisation d'exécution ne peut prendre moins de zéro. Empêcher le compilateur de supprimer le code n'aurait aucun sens.
Gerhardh
55

Il semble qu'il a été optimisé par le compilateur JIT. Quand 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 d'OP à l'intérieur de class MyClass.

Akavall
la source
2
Bizarre. Quand je lance 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 des 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 drapeau. Donc (pour moi) la boucle ne semble pas être entièrement optimisée, juste 10 fois plus rapide, d'une manière ou d'une autre. (Oracle Java 8-151)
tobias_k
@tobias_k cela dépend de l'étape du JIT que traverse la boucle, je suppose que stackoverflow.com/a/47972226/1059372
Eugene
21

Je vais juste dire l'évidence - qu'il s'agit d'une optimisation JVM qui se produit, la boucle sera simplement supprimée du tout. Voici un petit test qui montre la différence énormeJIT quand il est activé / activé uniquement pour C1 Compileret désactivé du tout.

Avertissement: n'écrivez pas de tests comme celui-ci - c'est juste pour prouver que la "suppression" réelle 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 qu'en fonction de la partie de la JITméthode activée, la méthode devient plus rapide (tellement plus rapide qu'il semble qu'elle ne fait «rien» - suppression de la boucle, ce qui semble se produire dans le C2 Compiler- qui 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 
Eugène
la source
13

Comme déjà souligné, 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 et C2 . Tout 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 à l'aide des statistiques collectées.

À titre d'exemple, je vais tester 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 avec les C1 et C2 de manière appropriée. 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 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 -> B3ou B1 -> B3. Où Freq: 1- fréquence estimée normalisée d'exécution d'un bloc.

Oleksandr Pyrohov
la source
8

Vous mesurez le temps nécessaire pour détecter que 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 cela avec, -XX:+PrintCompilationvous pouvez voir que le code a été compilé en arrière-plan vers le compilateur de niveau 3 ou C1 et après quelques boucles vers le 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, longil n'est pas aussi optimisé.

    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
Peter Lawrey
la source
C'est étrange ... pourquoi un longcompteur empêcherait-il la même optimisation de se produire?
Ryan Amos
@RyanAmos, l'optimisation n'est appliquée au nombre de boucles primitives communes que si le type intnote char et short sont effectivement les mêmes au niveau du code d'octet.
Peter Lawrey
-1

Vous considérez l'heure de début et de fin en nanoseconde et vous divisez par 10 ^ 6 pour calculer la latence

long d = (finish - start) / 1000000

cela devrait être 10^9parce que 1seconde = 10^9nanoseconde.

DHARMENDRA SINGH
la source
Ce que vous avez suggéré n’a rien à voir avec mon propos. Ce que je me demandais, c'est combien de temps cela a pris, et peu importe si cette durée est imprimée / représentée en milli-seconde ou en seconde.
twimo