Pourquoi 2 * (i * i) est-il plus rapide que 2 * i * i en Java?


855

Le programme Java suivant prend en moyenne entre 0,50 et 0,55 seconde pour s'exécuter:

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

Si je remplace 2 * (i * i)par 2 * i * i, cela prend entre 0,60 et 0,65 secondes pour fonctionner. Comment venir?

J'ai exécuté chaque version du programme 15 fois, en alternant entre les deux. Voici les résultats:

 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

La course la plus rapide a 2 * i * ipris plus de temps que la course la plus lente de 2 * (i * i). S'ils avaient la même efficacité, la probabilité que cela se produise serait inférieure à 1/2^15 * 100% = 0.00305%.


5
J'obtiens des résultats similaires (nombres légèrement différents, mais écart nettement visible et constant, certainement plus qu'une erreur d'échantillonnage)
Krease

29
Veuillez également consulter: stackoverflow.com/questions/504103/…
lexicore

3
@Krease Bon que vous ayez compris mon erreur. Selon le nouveau benchmark que j'ai exécuté, 2 * i * ic'est plus lent. J'essaierai aussi de courir avec Graal.
Jorn Vernee

5
@nullpointer Pour savoir pourquoi l'une est plus rapide que l'autre, nous devons obtenir les graphiques de démontage ou Idéal pour ces méthodes. L'assembleur est très ennuyeux pour essayer de comprendre, donc j'essaie d'obtenir une version de débogage OpenJDK qui peut produire de beaux graphiques.
Jorn Vernee

4
Vous pouvez renommer votre question en « Pourquoi est i * i * 2plus rapide que 2 * i * i? » Pour une meilleure clarté que le problème est dans l'ordre des opérations.
Cœur

Réponses:


1202

Il y a une légère différence dans l'ordre du bytecode.

2 * (i * i):

     iconst_2
     iload0
     iload0
     imul
     imul
     iadd

vs 2 * i * i:

     iconst_2
     iload0
     imul
     iload0
     imul
     iadd

À première vue, cela ne devrait pas faire de différence; au contraire, la deuxième version est plus optimale car elle utilise un emplacement de moins.

Nous devons donc creuser plus profondément dans le niveau inférieur (JIT) 1 .

N'oubliez pas que JIT a tendance à dérouler les petites boucles de manière très agressive. En effet on observe un déroulement 16x pour le 2 * (i * i)boîtier:

030   B2: # B2 B3 <- B1 B2  Loop: B2-B2 inner main of N18 Freq: 1e+006
030     addl    R11, RBP    # int
033     movl    RBP, R13    # spill
036     addl    RBP, #14    # int
039     imull   RBP, RBP    # int
03c     movl    R9, R13 # spill
03f     addl    R9, #13 # int
043     imull   R9, R9  # int
047     sall    RBP, #1
049     sall    R9, #1
04c     movl    R8, R13 # spill
04f     addl    R8, #15 # int
053     movl    R10, R8 # spill
056     movdl   XMM1, R8    # spill
05b     imull   R10, R8 # int
05f     movl    R8, R13 # spill
062     addl    R8, #12 # int
066     imull   R8, R8  # int
06a     sall    R10, #1
06d     movl    [rsp + #32], R10    # spill
072     sall    R8, #1
075     movl    RBX, R13    # spill
078     addl    RBX, #11    # int
07b     imull   RBX, RBX    # int
07e     movl    RCX, R13    # spill
081     addl    RCX, #10    # int
084     imull   RCX, RCX    # int
087     sall    RBX, #1
089     sall    RCX, #1
08b     movl    RDX, R13    # spill
08e     addl    RDX, #8 # int
091     imull   RDX, RDX    # int
094     movl    RDI, R13    # spill
097     addl    RDI, #7 # int
09a     imull   RDI, RDI    # int
09d     sall    RDX, #1
09f     sall    RDI, #1
0a1     movl    RAX, R13    # spill
0a4     addl    RAX, #6 # int
0a7     imull   RAX, RAX    # int
0aa     movl    RSI, R13    # spill
0ad     addl    RSI, #4 # int
0b0     imull   RSI, RSI    # int
0b3     sall    RAX, #1
0b5     sall    RSI, #1
0b7     movl    R10, R13    # spill
0ba     addl    R10, #2 # int
0be     imull   R10, R10    # int
0c2     movl    R14, R13    # spill
0c5     incl    R14 # int
0c8     imull   R14, R14    # int
0cc     sall    R10, #1
0cf     sall    R14, #1
0d2     addl    R14, R11    # int
0d5     addl    R14, R10    # int
0d8     movl    R10, R13    # spill
0db     addl    R10, #3 # int
0df     imull   R10, R10    # int
0e3     movl    R11, R13    # spill
0e6     addl    R11, #5 # int
0ea     imull   R11, R11    # int
0ee     sall    R10, #1
0f1     addl    R10, R14    # int
0f4     addl    R10, RSI    # int
0f7     sall    R11, #1
0fa     addl    R11, R10    # int
0fd     addl    R11, RAX    # int
100     addl    R11, RDI    # int
103     addl    R11, RDX    # int
106     movl    R10, R13    # spill
109     addl    R10, #9 # int
10d     imull   R10, R10    # int
111     sall    R10, #1
114     addl    R10, R11    # int
117     addl    R10, RCX    # int
11a     addl    R10, RBX    # int
11d     addl    R10, R8 # int
120     addl    R9, R10 # int
123     addl    RBP, R9 # int
126     addl    RBP, [RSP + #32 (32-bit)]   # int
12a     addl    R13, #16    # int
12e     movl    R11, R13    # spill
131     imull   R11, R13    # int
135     sall    R11, #1
138     cmpl    R13, #999999985
13f     jl     B2   # loop end  P=1.000000 C=6554623.000000

Nous voyons qu'il y a 1 registre qui est "renversé" sur la pile.

Et pour la 2 * i * iversion:

05a   B3: # B2 B4 <- B1 B2  Loop: B3-B2 inner main of N18 Freq: 1e+006
05a     addl    RBX, R11    # int
05d     movl    [rsp + #32], RBX    # spill
061     movl    R11, R8 # spill
064     addl    R11, #15    # int
068     movl    [rsp + #36], R11    # spill
06d     movl    R11, R8 # spill
070     addl    R11, #14    # int
074     movl    R10, R9 # spill
077     addl    R10, #16    # int
07b     movdl   XMM2, R10   # spill
080     movl    RCX, R9 # spill
083     addl    RCX, #14    # int
086     movdl   XMM1, RCX   # spill
08a     movl    R10, R9 # spill
08d     addl    R10, #12    # int
091     movdl   XMM4, R10   # spill
096     movl    RCX, R9 # spill
099     addl    RCX, #10    # int
09c     movdl   XMM6, RCX   # spill
0a0     movl    RBX, R9 # spill
0a3     addl    RBX, #8 # int
0a6     movl    RCX, R9 # spill
0a9     addl    RCX, #6 # int
0ac     movl    RDX, R9 # spill
0af     addl    RDX, #4 # int
0b2     addl    R9, #2  # int
0b6     movl    R10, R14    # spill
0b9     addl    R10, #22    # int
0bd     movdl   XMM3, R10   # spill
0c2     movl    RDI, R14    # spill
0c5     addl    RDI, #20    # int
0c8     movl    RAX, R14    # spill
0cb     addl    RAX, #32    # int
0ce     movl    RSI, R14    # spill
0d1     addl    RSI, #18    # int
0d4     movl    R13, R14    # spill
0d7     addl    R13, #24    # int
0db     movl    R10, R14    # spill
0de     addl    R10, #26    # int
0e2     movl    [rsp + #40], R10    # spill
0e7     movl    RBP, R14    # spill
0ea     addl    RBP, #28    # int
0ed     imull   RBP, R11    # int
0f1     addl    R14, #30    # int
0f5     imull   R14, [RSP + #36 (32-bit)]   # int
0fb     movl    R10, R8 # spill
0fe     addl    R10, #11    # int
102     movdl   R11, XMM3   # spill
107     imull   R11, R10    # int
10b     movl    [rsp + #44], R11    # spill
110     movl    R10, R8 # spill
113     addl    R10, #10    # int
117     imull   RDI, R10    # int
11b     movl    R11, R8 # spill
11e     addl    R11, #8 # int
122     movdl   R10, XMM2   # spill
127     imull   R10, R11    # int
12b     movl    [rsp + #48], R10    # spill
130     movl    R10, R8 # spill
133     addl    R10, #7 # int
137     movdl   R11, XMM1   # spill
13c     imull   R11, R10    # int
140     movl    [rsp + #52], R11    # spill
145     movl    R11, R8 # spill
148     addl    R11, #6 # int
14c     movdl   R10, XMM4   # spill
151     imull   R10, R11    # int
155     movl    [rsp + #56], R10    # spill
15a     movl    R10, R8 # spill
15d     addl    R10, #5 # int
161     movdl   R11, XMM6   # spill
166     imull   R11, R10    # int
16a     movl    [rsp + #60], R11    # spill
16f     movl    R11, R8 # spill
172     addl    R11, #4 # int
176     imull   RBX, R11    # int
17a     movl    R11, R8 # spill
17d     addl    R11, #3 # int
181     imull   RCX, R11    # int
185     movl    R10, R8 # spill
188     addl    R10, #2 # int
18c     imull   RDX, R10    # int
190     movl    R11, R8 # spill
193     incl    R11 # int
196     imull   R9, R11 # int
19a     addl    R9, [RSP + #32 (32-bit)]    # int
19f     addl    R9, RDX # int
1a2     addl    R9, RCX # int
1a5     addl    R9, RBX # int
1a8     addl    R9, [RSP + #60 (32-bit)]    # int
1ad     addl    R9, [RSP + #56 (32-bit)]    # int
1b2     addl    R9, [RSP + #52 (32-bit)]    # int
1b7     addl    R9, [RSP + #48 (32-bit)]    # int
1bc     movl    R10, R8 # spill
1bf     addl    R10, #9 # int
1c3     imull   R10, RSI    # int
1c7     addl    R10, R9 # int
1ca     addl    R10, RDI    # int
1cd     addl    R10, [RSP + #44 (32-bit)]   # int
1d2     movl    R11, R8 # spill
1d5     addl    R11, #12    # int
1d9     imull   R13, R11    # int
1dd     addl    R13, R10    # int
1e0     movl    R10, R8 # spill
1e3     addl    R10, #13    # int
1e7     imull   R10, [RSP + #40 (32-bit)]   # int
1ed     addl    R10, R13    # int
1f0     addl    RBP, R10    # int
1f3     addl    R14, RBP    # int
1f6     movl    R10, R8 # spill
1f9     addl    R10, #16    # int
1fd     cmpl    R10, #999999985
204     jl     B2   # loop end  P=1.000000 C=7419903.000000

Ici, nous observons beaucoup plus de «débordement» et plus d'accès à la pile [RSP + ...], en raison de résultats plus intermédiaires qui doivent être préservés.

Ainsi, la réponse à la question est simple: 2 * (i * i)est plus rapide que 2 * i * iparce que le JIT génère un code d'assemblage plus optimal pour le premier cas.


Mais bien sûr, il est évident que ni la première ni la deuxième version ne sont bonnes; la boucle pourrait vraiment bénéficier de la vectorisation, car tout processeur x86-64 prend au moins en charge SSE2.

C'est donc un problème d'optimiseur; comme c'est souvent le cas, il se déroule de manière trop agressive et se tire une balle dans le pied, tout en ratant diverses autres opportunités.

En fait, les processeurs x86-64 modernes décomposent davantage les instructions en micro-opérations (µops) et avec des fonctionnalités telles que le changement de nom de registre, les caches µop et les tampons de boucle, l'optimisation de boucle prend beaucoup plus de finesse qu'un simple déroulement pour des performances optimales. Selon le guide d'optimisation d'Agner Fog :

Le gain de performances dû au cache µop peut être assez considérable si la longueur moyenne des instructions est supérieure à 4 octets. Les méthodes suivantes d'optimisation de l'utilisation du cache µop peuvent être envisagées:

  • Assurez-vous que les boucles critiques sont suffisamment petites pour tenir dans le cache µop.
  • Alignez les entrées de boucle et les entrées de fonction les plus critiques par 32.
  • Évitez le déroulement inutile des boucles.
  • Évitez les instructions qui ont un temps de chargement supplémentaire
    . . .

En ce qui concerne ces temps de chargement - même le hit L1D le plus rapide coûte 4 cycles , un registre supplémentaire et µop, donc oui, même quelques accès à la mémoire nuiront aux performances dans les boucles serrées.

Mais revenons à l'opportunité de vectorisation - pour voir à quelle vitesse cela peut être, nous pouvons compiler une application C similaire avec GCC , qui la vectorise carrément (AVX2 est montré, SSE2 est similaire) 2 :

  vmovdqa ymm0, YMMWORD PTR .LC0[rip]
  vmovdqa ymm3, YMMWORD PTR .LC1[rip]
  xor eax, eax
  vpxor xmm2, xmm2, xmm2
.L2:
  vpmulld ymm1, ymm0, ymm0
  inc eax
  vpaddd ymm0, ymm0, ymm3
  vpslld ymm1, ymm1, 1
  vpaddd ymm2, ymm2, ymm1
  cmp eax, 125000000      ; 8 calculations per iteration
  jne .L2
  vmovdqa xmm0, xmm2
  vextracti128 xmm2, ymm2, 1
  vpaddd xmm2, xmm0, xmm2
  vpsrldq xmm0, xmm2, 8
  vpaddd xmm0, xmm2, xmm0
  vpsrldq xmm1, xmm0, 4
  vpaddd xmm0, xmm0, xmm1
  vmovd eax, xmm0
  vzeroupper

Avec des temps d'exécution:

  • SSE: 0,24 s, ou 2 fois plus rapide.
  • AVX: 0,15 s, ou 3 fois plus rapide.
  • AVX2: 0,08 s, ou 5 fois plus rapide.

1 Pour obtenir la sortie d'assembly générée par JIT, obtenez une JVM de débogage et exécutez avec-XX:+PrintOptoAssembly

2 La version C est compilée avec l' -fwrapvindicateur, ce qui permet à GCC de traiter le dépassement d'entier signé comme un bouclage à complément à deux.


11
Le problème le plus important rencontré par l'optimiseur dans l'exemple C est le comportement indéfini invoqué par le débordement d'entier signé. Sinon, cela entraînerait probablement simplement le chargement d'une constante, car la boucle entière peut être calculée au moment de la compilation.
Damon

44
@Damon Pourquoi un comportement indéfini serait-il un problème pour l'optimiseur? Si l'optimiseur voit qu'il déborde lorsqu'il essaie de calculer le résultat, cela signifie simplement qu'il peut l'optimiser comme il le souhaite, car le comportement n'est pas défini.

13
@Runemoro: si l'optimiseur prouve que l'appel de la fonction entraînera inévitablement un comportement indéfini, il pourrait choisir de supposer que la fonction ne sera jamais appelée et n'émettra aucun corps pour elle. Ou émettez simplement une retinstruction, ou émettez une étiquette et aucune instruction ret, de sorte que l'exécution échoue. En fait, GCC se comporte parfois comme cela quand il rencontre UB. Par exemple: pourquoi ret disparaître avec l'optimisation? . Vous voulez certainement compiler du code bien formé pour être sûr que l'asm est sain d'esprit.
Peter Cordes

8
C'est probablement juste un goulot d'étranglement de débit uop frontal en raison du code-gen inefficace. Il n'utilise même pas LEA comme judas pour mov/ add-immediate. par exemple movl RBX, R9/ addl RBX, #8devrait être leal ebx, [r9 + 8], 1 uop à copier et ajouter. Ou leal ebx, [r9 + r9 + 16]à faire ebx = 2*(r9+8). Alors oui, le déroulement au point de se renverser est stupide, tout comme le codegen braindead naïf qui ne tire pas parti des identités entières et des mathématiques entières associatives.
Peter Cordes

7
La vectorisation pour la réduction séquentielle a été désactivée dans C2 ( bugs.openjdk.java.net/browse/JDK-8078563 ), mais est actuellement envisagée pour la réactivation ( bugs.openjdk.java.net/browse/JDK-8188313 ).
pron

131

Lorsque la multiplication est 2 * (i * i)terminée, la JVM est capable de factoriser la multiplication par à 2partir de la boucle, ce qui donne ce code équivalent mais plus efficace:

int n = 0;
for (int i = 0; i < 1000000000; i++) {
    n += i * i;
}
n *= 2;

mais quand la multiplication est (2 * i) * i, la JVM ne l'optimise pas puisque la multiplication par une constante n'est plus juste avant l'addition.

Voici quelques raisons pour lesquelles je pense que c'est le cas:

  • L'ajout d'une if (n == 0) n = 1instruction au début de la boucle rend les deux versions aussi efficaces, car la suppression de la multiplication ne garantit plus que le résultat sera le même
  • La version optimisée (en factorisant la multiplication par 2) est exactement aussi rapide que la 2 * (i * i)version

Voici le code de test que j'ai utilisé pour tirer ces conclusions:

public static void main(String[] args) {
    long fastVersion = 0;
    long slowVersion = 0;
    long optimizedVersion = 0;
    long modifiedFastVersion = 0;
    long modifiedSlowVersion = 0;

    for (int i = 0; i < 10; i++) {
        fastVersion += fastVersion();
        slowVersion += slowVersion();
        optimizedVersion += optimizedVersion();
        modifiedFastVersion += modifiedFastVersion();
        modifiedSlowVersion += modifiedSlowVersion();
    }

    System.out.println("Fast version: " + (double) fastVersion / 1000000000 + " s");
    System.out.println("Slow version: " + (double) slowVersion / 1000000000 + " s");
    System.out.println("Optimized version: " + (double) optimizedVersion / 1000000000 + " s");
    System.out.println("Modified fast version: " + (double) modifiedFastVersion / 1000000000 + " s");
    System.out.println("Modified slow version: " + (double) modifiedSlowVersion / 1000000000 + " s");
}

private static long fastVersion() {
    long startTime = System.nanoTime();
    int n = 0;
    for (int i = 0; i < 1000000000; i++) {
        n += 2 * (i * i);
    }
    return System.nanoTime() - startTime;
}

private static long slowVersion() {
    long startTime = System.nanoTime();
    int n = 0;
    for (int i = 0; i < 1000000000; i++) {
        n += 2 * i * i;
    }
    return System.nanoTime() - startTime;
}

private static long optimizedVersion() {
    long startTime = System.nanoTime();
    int n = 0;
    for (int i = 0; i < 1000000000; i++) {
        n += i * i;
    }
    n *= 2;
    return System.nanoTime() - startTime;
}

private static long modifiedFastVersion() {
    long startTime = System.nanoTime();
    int n = 0;
    for (int i = 0; i < 1000000000; i++) {
        if (n == 0) n = 1;
        n += 2 * (i * i);
    }
    return System.nanoTime() - startTime;
}

private static long modifiedSlowVersion() {
    long startTime = System.nanoTime();
    int n = 0;
    for (int i = 0; i < 1000000000; i++) {
        if (n == 0) n = 1;
        n += 2 * i * i;
    }
    return System.nanoTime() - startTime;
}

Et voici les résultats:

Fast version: 5.7274411 s
Slow version: 7.6190804 s
Optimized version: 5.1348007 s
Modified fast version: 7.1492705 s
Modified slow version: 7.2952668 s

3
Je pense que sur la version optimisée, cela devrait êtren *= 2000000000;
StefansArya

4
@StefansArya - Non. Considérons le cas où la limite est 4, et nous essayons de calculer 2*1*1 + 2*2*2 + 2*3*3. Il est évident que calculer 1*1 + 2*2 + 3*3et multiplier par 2 est correct, alors que multiplier par 8 ne le serait pas.
Martin Bonner soutient Monica

5
L'équation mathématique était exactement comme ça 2(1²) + 2(2²) + 2(3²) = 2(1² + 2² + 3²). C'était très simple et je l'ai juste oublié car l'incrémentation de la boucle.
StefansArya

5
Si vous imprimez l'assembly à l'aide d'un jvm de débogage, cela ne semble pas être correct. Vous verrez un tas de sall ..., # 1, qui sont multipliés par 2, dans la boucle. Fait intéressant, la version plus lente ne semble pas avoir de multiplications dans la boucle.
Daniel Berlin

2
Pourquoi la JVM peut-elle prendre en compte les 2 2 * (i * i)mais pas les (2 * i) * i? Je pense qu'ils sont équivalents (c'est peut-être ma mauvaise hypothèse). Si oui, la JVM ne canoniserait-elle pas l'expression avant de l'optimiser?
RedSpikeyThing

41

Codes d'octets: https://cs.nyu.edu/courses/fall00/V22.0201-001/jvm2.html Visionneuse de codes d'octets: https://github.com/Konloch/bytecode-viewer

Sur mon JDK (Windows 10 64 bits, 1.8.0_65-b17) je peux reproduire et expliquer:

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

Production:

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

Alors pourquoi? Le code d'octet est le suivant:

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

La différence étant: entre parenthèses ( 2 * (i * i)):

  • pousser la pile de const
  • pousser local sur pile
  • pousser local sur pile
  • multiplier le haut de la pile
  • multiplier le haut de la pile

Sans crochets ( 2 * i * i):

  • pousser la pile de const
  • pousser local sur pile
  • multiplier le haut de la pile
  • pousser local sur pile
  • multiplier le haut de la pile

Tout charger sur la pile puis redescendre est plus rapide que de basculer entre la mise sur la pile et son fonctionnement.


Mais pourquoi pousser-pousser-multiplier-multiplier plus vite que pousser-multiplier-pousser-multiplier?
m0skit0

35

Kasperd a demandé dans un commentaire de la réponse acceptée:

Les exemples Java et C utilisent des noms de registre assez différents. Est-ce que les deux utilisent l'exemple AMD64 ISA?

xor edx, edx
xor eax, eax
.L2:
mov ecx, edx
imul ecx, edx
add edx, 1
lea eax, [rax+rcx*2]
cmp edx, 1000000000
jne .L2

Je n'ai pas assez de réputation pour répondre à cela dans les commentaires, mais ce sont les mêmes ISA. Il convient de souligner que la version GCC utilise une logique entière 32 bits et la version compilée JVM utilise une logique entière 64 bits en interne.

R8 à R15 ne sont que de nouveaux registres X86_64 . EAX à EDX sont les parties inférieures des registres à usage général RAX à RDX. La partie importante de la réponse est que la version GCC n'est pas déroulée. Il exécute simplement un tour de boucle par boucle de code machine réelle. Alors que la version JVM a 16 tours de boucle dans une boucle physique (basé sur la réponse rustyx, je n'ai pas réinterprété l'assemblage). C'est l'une des raisons pour lesquelles il y a plus de registres utilisés car le corps de la boucle est en réalité 16 fois plus long.


2
Dommage que gcc ne remarque pas qu'il peut couler *2hors de la boucle. Bien que dans ce cas, ce n'est même pas une victoire de le faire, car il le fait gratuitement avec LEA. Sur les processeurs Intel, lea eax, [rax+rcx*2]a la même latence 1c que add eax,ecx. Cependant, sur les processeurs AMD, tout index mis à l'échelle augmente la latence LEA à 2 cycles. Ainsi, la chaîne de dépendance portée par la boucle s'allonge à 2 cycles, devenant le goulot d'étranglement sur Ryzen. (le imul ecx,edxdébit est de 1 par horloge sur Ryzen et sur Intel).
Peter Cordes

31

Bien que n'étant pas directement lié à l'environnement de la question, juste pour la curiosité, j'ai fait le même test sur .NET Core 2.1, x64, mode de sortie.

Voici le résultat intéressant, confirmant des phonomènes similaires (à l'inverse) se produisant sur le côté sombre de la force. Code:

static void Main(string[] args)
{
    Stopwatch watch = new Stopwatch();

    Console.WriteLine("2 * (i * i)");

    for (int a = 0; a < 10; a++)
    {
        int n = 0;

        watch.Restart();

        for (int i = 0; i < 1000000000; i++)
        {
            n += 2 * (i * i);
        }

        watch.Stop();

        Console.WriteLine($"result:{n}, {watch.ElapsedMilliseconds} ms");
    }

    Console.WriteLine();
    Console.WriteLine("2 * i * i");

    for (int a = 0; a < 10; a++)
    {
        int n = 0;

        watch.Restart();

        for (int i = 0; i < 1000000000; i++)
        {
            n += 2 * i * i;
        }

        watch.Stop();

        Console.WriteLine($"result:{n}, {watch.ElapsedMilliseconds}ms");
    }
}

Résultat:

2 * (i * i)

  • résultat: 119860736, 438 ms
  • résultat: 119860736, 433 ms
  • résultat: 119860736, 437 ms
  • résultat: 119860736, 435 ms
  • résultat: 119860736, 436 ms
  • résultat: 119860736, 435 ms
  • résultat: 119860736, 435 ms
  • résultat: 119860736, 439 ms
  • résultat: 119860736, 436 ms
  • résultat: 119860736, 437 ms

2 * i * i

  • résultat: 119860736, 417 ms
  • résultat: 119860736, 417 ms
  • résultat: 119860736, 417 ms
  • résultat: 119860736, 418 ms
  • résultat: 119860736, 418 ms
  • résultat: 119860736, 417 ms
  • résultat: 119860736, 418 ms
  • résultat: 119860736, 416 ms
  • résultat: 119860736, 417 ms
  • résultat: 119860736, 418 ms

1
Bien que ce ne soit pas une réponse à la question, cela ajoute de la valeur. Cela étant dit, si quelque chose est vital pour votre message, veuillez l'inclure dans le message plutôt que de créer un lien vers une ressource hors site. Les liens disparaissent.
Jared Smith

1
@JaredSmith Merci pour la rétroaction. Étant donné que le lien que vous mentionnez est le lien "résultat", cette image n'est pas une source hors site. Je l'ai téléchargé sur le stackoverflow via son propre panneau.
Ünsal Ersöz

1
C'est un lien à imgur, donc oui, ça ne fait rien, comment vous avez ajouté le lien. Je n'arrive pas à voir ce qui est si difficile à copier-coller une sortie de console.
Jared Smith

5
Sauf que c'est l'inverse
leppie

2
@SamB est toujours sur le domaine imgur.com, ce qui signifie qu'il ne survivra que pendant aussi longtemps que imgur.
p91paul

21

J'ai obtenu des résultats similaires:

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

J'ai obtenu les mêmes résultats si les deux boucles se trouvaient dans le même programme ou si chacune se trouvait dans un fichier .java / .class distinct, exécuté sur une exécution distincte.

Enfin, voici une javap -c -v <.java>décompilation de chacun:

     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

contre.

     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)

1
Une meilleure réponse et vous pouvez peut-être voter pour annuler la suppression - stackoverflow.com/a/53452836/1746118 ... Note latérale - Je ne suis pas le votant de toute façon.
Naman

@nullpointer - Je suis d'accord. Je voterais certainement pour annuler la suppression, si possible. J'aimerais aussi "doubler le vote" stefan pour avoir donné une définition quantitative de "significatif"
paulsm4

Celui-ci a été auto-supprimé car il a mesuré la mauvaise chose - voir le commentaire de cet auteur sur la question ci
Krease

2
Obtenez un débogage jre et exécutez avec -XX:+PrintOptoAssembly. Ou utilisez simplement vtune ou similaire.
rustyx

1
@ rustyx - Si le problème est l'implémentation JIT ... alors "obtenir une version de débogage" D'UN JRE COMPLÈTEMENT DIFFÉRENT ne va pas nécessairement aider. Néanmoins: cela ressemble à ce que vous avez trouvé ci-dessus avec votre démontage JIT sur votre JRE explique également le comportement sur le JRE de l'OP et le mien. Et explique également pourquoi les autres JRE se comportent "différemment". +1: merci pour l'excellent travail de détective!
paulsm4

18

Observation intéressante à l'aide de Java 11 et désactivation du déroulement de la boucle avec l'option VM suivante:

-XX:LoopUnrollLimit=0

La boucle avec l' 2 * (i * i)expression donne un code natif 1 plus compact :

L0001: add    eax,r11d
       inc    r8d
       mov    r11d,r8d
       imul   r11d,r8d
       shl    r11d,1h
       cmp    r8d,r10d
       jl     L0001

par rapport à la 2 * i * iversion:

L0001: add    eax,r11d
       mov    r11d,r8d
       shl    r11d,1h
       add    r11d,2h
       inc    r8d
       imul   r11d,r8d
       cmp    r8d,r10d
       jl     L0001

Version Java:

java version "11" 2018-09-25
Java(TM) SE Runtime Environment 18.9 (build 11+28)
Java HotSpot(TM) 64-Bit Server VM 18.9 (build 11+28, mixed mode)

Résultats de référence:

Benchmark          (size)  Mode  Cnt    Score     Error  Units
LoopTest.fast  1000000000  avgt    5  694,868 ±  36,470  ms/op
LoopTest.slow  1000000000  avgt    5  769,840 ± 135,006  ms/op

Code source de référence:

@BenchmarkMode(Mode.AverageTime)
@OutputTimeUnit(TimeUnit.MILLISECONDS)
@Warmup(iterations = 5, time = 5, timeUnit = TimeUnit.SECONDS)
@Measurement(iterations = 5, time = 5, timeUnit = TimeUnit.SECONDS)
@State(Scope.Thread)
@Fork(1)
public class LoopTest {

    @Param("1000000000") private int size;

    public static void main(String[] args) throws RunnerException {
        Options opt = new OptionsBuilder()
            .include(LoopTest.class.getSimpleName())
            .jvmArgs("-XX:LoopUnrollLimit=0")
            .build();
        new Runner(opt).run();
    }

    @Benchmark
    public int slow() {
        int n = 0;
        for (int i = 0; i < size; i++)
            n += 2 * i * i;
        return n;
    }

    @Benchmark
    public int fast() {
        int n = 0;
        for (int i = 0; i < size; i++)
            n += 2 * (i * i);
        return n;
    }
}

1 - Options VM utilisées: -XX:+UnlockDiagnosticVMOptions -XX:+PrintAssembly -XX:LoopUnrollLimit=0


2
Wow, c'est un peu de braindead asm. Au lieu d'incrémenter i avant de le copier pour le calculer 2*i, il le fait après donc il a besoin d'une add r11d,2instruction supplémentaire . (De plus, il manque le add same,samejudas au lieu de shl1 (ajouter des exécutions sur plus de ports). Il manque également un judas LEA pour x*2 + 2( lea r11d, [r8*2 + 2]) s'il veut vraiment faire les choses dans cet ordre pour une raison folle de programmation d'instructions. Nous pouvions déjà voir à partir de la version déroulée qui manquait à LEA lui coûtait beaucoup d'ups, comme les deux boucles ici.
Peter Cordes

2
lea eax, [rax + r11 * 2]remplacerait 2 instructions (dans les deux boucles) si le compilateur JIT avait le temps de rechercher cette optimisation dans les boucles de longue durée. Tout compilateur décent à l'avance le trouverait. (À moins que le réglage ne soit possible que pour AMD, où le LEA à index scalaire a une latence de 2 cycles, alors cela ne vaut peut-être pas la peine.)
Peter Cordes

15

J'ai essayé un JMH en utilisant l'archétype par défaut: j'ai également ajouté une version optimisée basée sur l'explication de Runemoro .

@State(Scope.Benchmark)
@Warmup(iterations = 2)
@Fork(1)
@Measurement(iterations = 10)
@OutputTimeUnit(TimeUnit.NANOSECONDS)
//@BenchmarkMode({ Mode.All })
@BenchmarkMode(Mode.AverageTime)
public class MyBenchmark {
  @Param({ "100", "1000", "1000000000" })
  private int size;

  @Benchmark
  public int two_square_i() {
    int n = 0;
    for (int i = 0; i < size; i++) {
      n += 2 * (i * i);
    }
    return n;
  }

  @Benchmark
  public int square_i_two() {
    int n = 0;
    for (int i = 0; i < size; i++) {
      n += i * i;
    }
    return 2*n;
  }

  @Benchmark
  public int two_i_() {
    int n = 0;
    for (int i = 0; i < size; i++) {
      n += 2 * i * i;
    }
    return n;
  }
}

Les résultats sont ici:

Benchmark                           (size)  Mode  Samples          Score   Score error  Units
o.s.MyBenchmark.square_i_two           100  avgt       10         58,062         1,410  ns/op
o.s.MyBenchmark.square_i_two          1000  avgt       10        547,393        12,851  ns/op
o.s.MyBenchmark.square_i_two    1000000000  avgt       10  540343681,267  16795210,324  ns/op
o.s.MyBenchmark.two_i_                 100  avgt       10         87,491         2,004  ns/op
o.s.MyBenchmark.two_i_                1000  avgt       10       1015,388        30,313  ns/op
o.s.MyBenchmark.two_i_          1000000000  avgt       10  967100076,600  24929570,556  ns/op
o.s.MyBenchmark.two_square_i           100  avgt       10         70,715         2,107  ns/op
o.s.MyBenchmark.two_square_i          1000  avgt       10        686,977        24,613  ns/op
o.s.MyBenchmark.two_square_i    1000000000  avgt       10  652736811,450  27015580,488  ns/op

Sur mon PC ( Core i7 860 - il ne fait pas grand-chose à part lire sur mon smartphone):

  • n += i*ipuis n*2est d' abord
  • 2 * (i * i) est deuxième.

La JVM n'optimise clairement pas de la même manière qu'un humain (d'après la réponse de Runemoro).

Maintenant, en lisant le bytecode: javap -c -v ./target/classes/org/sample/MyBenchmark.class

Je ne suis pas un expert en bytecode, mais nous iload_2avant nous imul: c'est probablement là que vous obtenez la différence: je peux supposer que la JVM optimise la lecture ideux fois ( iest déjà là, et il n'est pas nécessaire de la charger à nouveau) tandis que dans le 2*i*iil peut '' t.


4
Le bytecode AFAICT est peu pertinent pour les performances, et je n'essaierais pas d'estimer ce qui est plus rapide en fonction de cela. C'est juste le code source pour le compilateur JIT ... bien sûr, le fait de conserver la signification en réorganisant les lignes de code source peut changer le code résultant et son efficacité, mais tout cela est assez imprévisible.
maaartinus

13

Plus d'un addendum. J'ai reproché l'expérience en utilisant la dernière JVM Java 8 d'IBM:

java version "1.8.0_191"
Java(TM) 2 Runtime Environment, Standard Edition (IBM build 1.8.0_191-b12 26_Oct_2018_18_45 Mac OS X x64(SR5 FP25))
Java HotSpot(TM) 64-Bit Server VM (build 25.191-b12, mixed mode)

Et cela montre des résultats très similaires:

0.374653912 s
n = 119860736
0.447778698 s
n = 119860736

(deuxième résultat avec 2 * i * i).

Fait intéressant, lors de l'exécution sur la même machine, mais en utilisant Oracle Java:

Java version "1.8.0_181"
Java(TM) SE Runtime Environment (build 1.8.0_181-b13)
Java HotSpot(TM) 64-Bit Server VM (build 25.181-b13, mixed mode)

les résultats sont en moyenne un peu plus lents:

0.414331815 s
n = 119860736
0.491430656 s
n = 119860736

Pour faire court: même le numéro de version mineur de HotSpot importe ici, car de subtiles différences dans l'implémentation JIT peuvent avoir des effets notables.


5

Les deux méthodes d'ajout génèrent du code d'octets légèrement différent:

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

Pour 2 * (i * i)vs:

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

Pour 2 * i * i .

Et lorsque vous utilisez un benchmark JMH comme celui-ci:

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

}

La différence est claire:

# 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

Ce que vous observez est correct, et pas seulement une anomalie de votre style d'analyse comparative (c'est-à-dire pas d'échauffement, voir Comment écrire un micro-benchmark correct en Java? )

Courir à nouveau avec 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

Vous voyez que les résultats sont beaucoup plus proches, ce qui est logique, car Graal est un compilateur globalement plus performant et plus moderne.

Donc, cela dépend vraiment de la capacité du compilateur JIT à optimiser un morceau de code particulier, et n'a pas nécessairement de raison logique.

En utilisant notre site, vous reconnaissez avoir lu et compris notre politique liée aux cookies et notre politique de confidentialité.
Licensed under cc by-sa 3.0 with attribution required.