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 * i
pris 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%
.
java
performance
benchmarking
bytecode
jit
Stefan
la source
la source
2 * i * i
c'est plus lent. J'essaierai aussi de courir avec Graal.i * i * 2
plus rapide que2 * i * i
? » Pour une meilleure clarté que le problème est dans l'ordre des opérations.Réponses:
Il y a une légère différence dans l'ordre du bytecode.
2 * (i * i)
:vs
2 * i * i
:À 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:Nous voyons qu'il y a 1 registre qui est "renversé" sur la pile.
Et pour la
2 * i * i
version: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 que2 * i * i
parce 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 :
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 :
Avec des temps d'exécution:
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'
-fwrapv
indicateur, ce qui permet à GCC de traiter le dépassement d'entier signé comme un bouclage à complément à deux.la source
ret
instruction, 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.mov
/add-immediate
. par exemplemovl RBX, R9
/addl RBX, #8
devrait êtreleal ebx, [r9 + 8]
, 1 uop à copier et ajouter. Ouleal ebx, [r9 + r9 + 16]
à faireebx = 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.Lorsque la multiplication est
2 * (i * i)
terminée, la JVM est capable de factoriser la multiplication par à2
partir de la boucle, ce qui donne ce code équivalent mais plus efficace: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:
if (n == 0) n = 1
instruction 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ême2 * (i * i)
versionVoici le code de test que j'ai utilisé pour tirer ces conclusions:
Et voici les résultats:
la source
n *= 2000000000;
2*1*1 + 2*2*2 + 2*3*3
. Il est évident que calculer1*1 + 2*2 + 3*3
et multiplier par 2 est correct, alors que multiplier par 8 ne le serait pas.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.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?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:
Production:
Alors pourquoi? Le code d'octet est le suivant:
La différence étant: entre parenthèses (
2 * (i * i)
):Sans crochets (
2 * i * i
):Tout charger sur la pile puis redescendre est plus rapide que de basculer entre la mise sur la pile et son fonctionnement.
la source
Kasperd a demandé dans un commentaire de la réponse acceptée:
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.
la source
*2
hors 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 queadd 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. (leimul ecx,edx
débit est de 1 par horloge sur Ryzen et sur Intel).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:
Résultat:
2 * (i * i)
2 * i * i
la source
J'ai obtenu des résultats similaires:
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:contre.
FYI -
la source
-XX:+PrintOptoAssembly
. Ou utilisez simplement vtune ou similaire.Observation intéressante à l'aide de Java 11 et désactivation du déroulement de la boucle avec l'option VM suivante:
La boucle avec l'
2 * (i * i)
expression donne un code natif 1 plus compact :par rapport à la
2 * i * i
version:Version Java:
Résultats de référence:
Code source de référence:
1 - Options VM utilisées:
-XX:+UnlockDiagnosticVMOptions -XX:+PrintAssembly -XX:LoopUnrollLimit=0
la source
i
avant de le copier pour le calculer2*i
, il le fait après donc il a besoin d'uneadd r11d,2
instruction supplémentaire . (De plus, il manque leadd same,same
judas au lieu deshl
1 (ajouter des exécutions sur plus de ports). Il manque également un judas LEA pourx*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.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.)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 .
Les résultats sont ici:
Sur mon PC ( Core i7 860 - il ne fait pas grand-chose à part lire sur mon smartphone):
n += i*i
puisn*2
est d' abord2 * (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_2
avant nousimul
: c'est probablement là que vous obtenez la différence: je peux supposer que la JVM optimise la lecturei
deux fois (i
est déjà là, et il n'est pas nécessaire de la charger à nouveau) tandis que dans le2*i*i
il peut '' t.la source
Plus d'un addendum. J'ai reproché l'expérience en utilisant la dernière JVM Java 8 d'IBM:
Et cela montre des résultats très similaires:
(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:
les résultats sont en moyenne un peu plus lents:
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.
la source
Les deux méthodes d'ajout génèrent du code d'octets légèrement différent:
Pour
2 * (i * i)
vs:Pour
2 * i * i
.Et lorsque vous utilisez un benchmark JMH comme celui-ci:
La différence est claire:
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:
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.
la source