web-dev-qa-db-fra.com

Pourquoi (a * b! = 0) est-il plus rapide que (a! = 0 && b! = 0) en Java?

J'écris du code dans Java où, à un moment donné, le déroulement du programme est déterminé par le fait que deux variables int, "a" et "b", sont non nulles (note: a et b ne sont jamais négatifs, et jamais dans la plage de dépassement d’entier).

Je peux l'évaluer avec

if (a != 0 && b != 0) { /* Some code */ }

Ou bien

if (a*b != 0) { /* Some code */ }

Parce que je m'attends à ce que ce code soit exécuté des millions de fois par exécution, je me demandais lequel serait le plus rapide. J'ai fait l'expérience en les comparant sur un énorme tableau généré de manière aléatoire, et j'étais également curieux de voir comment la rareté du tableau (fraction de données = 0) affecterait les résultats:

long time;
final int len = 50000000;
int arbitrary = 0;
int[][] nums = new int[2][len];

for (double fraction = 0 ; fraction <= 0.9 ; fraction += 0.0078125) {
    for(int i = 0 ; i < 2 ; i++) {
        for(int j = 0 ; j < len ; j++) {
            double random = Math.random();

            if(random < fraction) nums[i][j] = 0;
            else nums[i][j] = (int) (random*15 + 1);
        }
    }

    time = System.currentTimeMillis();

    for(int i = 0 ; i < len ; i++) {
        if( /*insert nums[0][i]*nums[1][i]!=0 or nums[0][i]!=0 && nums[1][i]!=0*/ ) arbitrary++;
    }
    System.out.println(System.currentTimeMillis() - time);
}

Et les résultats montrent que si vous vous attendez à ce que "a" ou "b" soit égal à 0 plus de ~ 3% du temps, a*b != 0 est plus rapide que a!=0 && b!=0:

Graphical graph of the results of a AND b non-zero

Je suis curieux de savoir pourquoi. Quelqu'un pourrait-il nous éclairer? Est-ce le compilateur ou est-ce au niveau matériel?

Edit: Par curiosité ... maintenant que j'ai entendu parler de la prédiction de branche, je me demandais quoi la comparaison analogique montrerait pour a OU b est non nul:

Graph of a or b non-zero

Nous voyons le même effet que prévu de la prédiction de branche, il est intéressant de noter que le graphique est légèrement inversé le long de l’axe des X.

Mise à jour

1- J'ai ajouté !(a==0 || b==0) à l'analyse pour voir ce qui se passe.

2- J'ai aussi inclus a != 0 || b != 0, (a+b) != 0 et (a|b) != 0 par curiosité, après avoir appris la prédiction de branche. Mais elles ne sont pas logiquement équivalentes aux autres expressions, car seul a OU b doit être différent de zéro pour être vrai, ainsi elles ne doivent pas être comparées pour le traitement. Efficacité.

3- J'ai aussi ajouté le repère que j'ai utilisé pour l'analyse, qui consiste simplement à itérer une variable int arbitraire.

4- Certaines personnes ont suggéré d'inclure a != 0 & b != 0 par opposition à a != 0 && b != 0, avec la prédiction qu'il se comporterait plus près de a*b != 0 parce que nous supprimerions l'effet de prédiction de branche. Je ne savais pas que & pouvait être utilisé avec des variables booléennes, je pensais qu'il n'était utilisé que pour des opérations binaires avec des entiers.

Remarque: dans le contexte dans lequel j'ai envisagé tout cela, le débordement int n'est pas un problème, mais c'est certainement une considération importante dans les contextes généraux.

CPU: Intel Core i7-3610QM à 2,3 GHz

Version Java: 1.8.0_45
Environnement d'exécution Java (TM) SE (version 1.8.0_45-b14)
Serveur 64 bits Java HotSpot (TM) VM (version 25.45-b02, mode mixte)

388
Maljam

J'ignore le problème selon lequel votre analyse comparative pourrait être erronée et prendre le résultat à sa valeur nominale.

Est-ce le compilateur ou est-ce au niveau matériel?

Ce dernier, je pense:

  if (a != 0 && b != 0)

compilera jusqu'à 2 charges de mémoire et deux branches conditionnelles

  if (a * b != 0)

compilera à 2 charges de mémoire, une multiplication et une branche conditionnelle.

La multiplication sera probablement plus rapide que la deuxième branche conditionnelle si la prédiction de branche au niveau matériel est inefficace. Au fur et à mesure que vous augmentez le ratio ... la prévision de branche devient moins efficace.

La raison pour laquelle les branches conditionnelles sont plus lentes est qu’elles bloquent le pipeline d’exécution des instructions. La prédiction de branche consiste à éviter le blocage en prédisant la direction que prendra la branche et en choisissant de manière spéculative l'instruction suivante en fonction de celle-ci. Si la prédiction échoue, il y a un délai pendant le chargement de l'instruction pour l'autre sens.

(Remarque: l'explication ci-dessus est trop simpliste. Pour une explication plus précise, vous devez consulter la documentation fournie par le fabricant de la CPU pour les codeurs et les rédacteurs de compilateur en langage Assembly. La page Wikipedia sur Prédicteurs de branche est bonne. Contexte.)


Cependant, il y a une chose sur laquelle vous devez faire attention avec cette optimisation. Existe-t-il des valeurs où a * b != 0 donnera une mauvaise réponse? Prenons les cas où le calcul du produit entraîne un dépassement d'entier.


UPDATE

Vos graphiques ont tendance à confirmer ce que j'ai dit.

  • Il existe également un effet de "prédiction de branche" dans le cas conditionnel de branche a * b != 0, et cela apparaît dans les graphiques.

  • Si vous projetez les courbes au-delà de 0,9 sur l'axe des abscisses, il se présente comme suit: 1) elles se rejoindront aux environs de 1,0 et 2) le point de rencontre sera approximativement à la même valeur Y que pour X = 0.0.


UPDATE 2

Je ne comprends pas pourquoi les courbes sont différentes pour les cas a + b != 0 et a | b != 0. Là , il pourrait y avoir quelque chose d'intelligent dans la logique des prédicteurs de branche. Ou cela pourrait indiquer autre chose.

(Notez que ce genre de chose peut être spécifique à un numéro de modèle de puce ou même à une version particulière. Les résultats de vos tests de performance peuvent être différents sur d'autres systèmes.)

Cependant, ils ont tous deux l'avantage de fonctionner pour toutes les valeurs non négatives de a et b.

233
Stephen C

Je pense que votre point de repère présente des défauts et peut ne pas être utile pour déduire de véritables programmes. Voici mes pensées:

  • (a+b)!=0 fera ce qui ne va pas pour les valeurs positives et négatives dont la somme est égale à zéro. Vous ne pouvez donc pas l'utiliser dans le cas général, même si cela fonctionne ici.

  • De même, (a*b)!=0 ne fera pas ce qu'il faut pour les valeurs qui débordent. (Exemple aléatoire: 196608 * 327680 est égal à 0 car le résultat réel est divisible par 2.32Ainsi, ses 32 bits les plus bas sont 0 et ces bits sont tout ce que vous obtenez s'il s'agit d'une opération int.)

  • (a|b)!=0 et (a+b)!=0 teste si l'une ou l'autre des valeurs est non nulle, alors que a != 0 && b != 0 et (a*b)!=0 teste si les deux sont non nuls. Vous ne comparez donc pas uniquement le temps de l'arithmétique: si la condition est vraie plus souvent, le nombre d'exécutions du corps if sera plus long, ce qui prend également plus de temps.

  • La VM optimisera l'expression au cours des premières exécutions de la boucle externe (fraction), lorsque fraction vaut 0, lorsque les branches ne sont presque jamais prises. L'optimiseur peut faire différentes choses si vous démarrez fraction à 0,5.

  • À moins que VM ne puisse éliminer certaines des vérifications des limites du tableau, il existe quatre autres branches dans l'expression uniquement en raison des vérifications des limites, ce qui complique la tâche lorsque l'on tente de comprendre ce qui se passe à un moment donné. niveau faible. Vous obtiendrez peut-être des résultats différents si vous divisez le tableau à deux dimensions en deux tableaux plats, en modifiant nums[0][i] et nums[1][i] en nums0[i] et nums1[i].

  • Les prédicteurs de branche d'UC détectent des modèles courts dans les données ou des exécutions de toutes les branches prises ou non prises. Vos données de référence générées de manière aléatoire constituent le pire des scénarios pour un prédicteur de branche. Si les données du monde réel ont un modèle prévisible ou si elles comportent de longues séries de valeurs nulles et non nulles, les branches pourraient coûter beaucoup Moins.

  • Le code particulier exécuté après que la condition est remplie peut affecter les performances de l'évaluation de la condition elle-même, car il affecte par exemple le déroulement ou non de la boucle, les registres de processeurs disponibles et le fait que l'un des fichiers récupérés nums les valeurs doivent être réutilisées après avoir évalué la condition. Le simple fait d'incrémenter un compteur dans l'indice de référence n'est pas un espace réservé idéal pour ce que ferait un code réel.

  • System.currentTimeMillis() n'est pas plus précis sur la plupart des systèmes que +/- 10 ms. System.nanoTime() est généralement plus précis.

Il y a beaucoup d'incertitudes et il est toujours difficile de dire quoi que ce soit de précis avec ce type de micro-optimisations, car une astuce plus rapide sur un VM ou un processeur peut être plus lent sur un autre. Si vous exécutez la machine virtuelle HotSpot 32 bits plutôt que la version 64 bits, sachez qu'elle existe en deux versions: avec le "client" VM ayant des optimisations différentes (plus faibles) par rapport au "serveur" VM.

Si vous pouvez désassembler le code machine généré par la VM , faites-le plutôt que d'essayer de deviner ce qu'il fait!

65
Boann

Les réponses ici sont bonnes, même si j'avais une idée qui pourrait améliorer les choses.

Comme les deux branches et la prédiction de branche associée sont probablement les coupables, nous pourrons peut-être réduire la branche en une seule branche sans changer la logique du tout.

bool aNotZero = (nums[0][i] != 0);
bool bNotZero = (nums[1][i] != 0);
if (aNotZero && bNotZero) { /* Some code */ }

Cela peut aussi marcher

int a = nums[0][i];
int b = nums[1][i];
if (a != 0 && b != 0) { /* Some code */ }

La raison en est, selon les règles du court-circuit, si le premier booléen est faux, le second ne doit pas être évalué. Il doit effectuer une branche supplémentaire pour éviter d'évaluer nums[1][i] si nums[0][i] était faux. Maintenant, vous ne vous souciez peut-être pas que nums[1][i] soit évalué, mais le compilateur ne peut pas être certain qu'il ne jettera pas une valeur hors de portée ou une référence null lorsque vous le ferez. En réduisant le bloc if à un simple booléen, le compilateur peut être assez intelligent pour se rendre compte que l'évaluation inutile du second booléen n'aura pas d'effets secondaires négatifs.

23
Pagefault

Quand on prend la multiplication, même si un nombre est 0, le produit est 0. En écrivant

    (a*b != 0)

Il évalue le résultat du produit, éliminant ainsi les quelques premières occurrences de l'itération à partir de 0. En conséquence, les comparaisons sont inférieures à celles de la condition.

   (a != 0 && b != 0)

Où chaque élément est comparé à 0 et évalué. Par conséquent, le temps requis est inférieur. Mais je crois que la deuxième condition pourrait vous donner une solution plus précise.

10
Sanket Gupte

Vous utilisez des données d'entrée aléatoires qui rendent les branches imprévisibles. Dans la pratique, les branches sont souvent prévisibles (environ 90%), alors dans le code réel, le code avec des branches est susceptible d'être plus rapide.

Cela dit. Je ne vois pas comment a*b != 0 peut être plus rapide que (a|b) != 0. Généralement, la multiplication d’entiers coûte plus cher qu’un OU au niveau des bits. Mais des choses comme ça deviennent parfois bizarres. Voir, par exemple, l'exemple "Exemple 7: Complexité matérielle" de Galerie des effets de cache de processeur .

8
StackedCrooked